Files
OSGKeyboard/OSGKeyboard/Views/AIAgentSkillsView.swift
T
2026-08-25 13:42:00 +08:00

1002 lines
36 KiB
Swift

// AIAgentSkillsView.swift
// OSGKeyboard · Main App
//
// Catalog of installed and available AI Agent clipboard skills. Selecting a
// row opens its detail page; installation state is managed there. Export
// skills confirm a companion Shortcut before installation completes.
import Foundation
import OSGKeyboardShared
import SwiftUI
private enum SkillRoute: Hashable {
case detail(String)
}
struct AIAgentSkillsView: View {
@Environment(\.themePalette) private var palette
@Environment(\.scenePhase) private var scenePhase
@ObservedObject private var config = ProviderConfig.shared
@ObservedObject private var store = AIAgentSkillLayoutStore.shared
@State private var editingDraft: SkillEditorDraft?
@State private var pasteAccessVerified = AppPermissions.hasVerifiedPasteAccess
@State private var pasteAccessNeedsRecovery = false
@State private var showPasteNoTextAlert = false
@State private var showPasteAccessSuccess = false
@State private var path = NavigationPath()
private var skillCardShape: RoundedRectangle {
RoundedRectangle(cornerRadius: Radius.xl, style: .continuous)
}
var body: some View {
NavigationStack(path: $path) {
ScrollView {
CardPageContent {
if showsClipboardAccessGuide {
clipboardAccessGuide
.transition(.opacity.combined(with: .move(edge: .top)))
}
installedSection
if !store.skillManagementAvailableSkills.isEmpty {
uninstalledSection
}
}
.tabBarScrollBottomPadding()
}
.background(palette.background)
.navigationTitle("skills.title")
.navigationBarTitleDisplayMode(.large)
.navigationDestination(for: SkillRoute.self) { route in
switch route {
case .detail(let skillID):
if let skill = store.mergedCatalog.first(where: { $0.id == skillID }) {
skillDetailView(skill)
} else {
ContentUnavailableView(
"skills.installed.empty",
systemImage: "wand.and.sparkles"
)
}
}
}
.toolbar {
ToolbarItem(placement: .topBarTrailing) {
Button {
editingDraft = .blank()
} label: {
Image(systemName: "plus")
}
.accessibilityLabel(Text("skills.add"))
}
}
}
.navigationStackTabBarVisibility(isRoot: path.isEmpty)
.sheet(item: $editingDraft) { draft in
SkillEditorSheet(
draft: draft,
onSave: { try saveDraft($0) },
onDelete: { id in
store.deleteUserSkill(id: id)
editingDraft = nil
}
)
}
.alert(
AppL10n.string("clipboard.paste.noText.title", language: config.uiLanguage),
isPresented: $showPasteNoTextAlert
) {
Button("common.done") { showPasteNoTextAlert = false }
} message: {
Text(AppL10n.string("clipboard.paste.noText.message", language: config.uiLanguage))
}
.onAppear {
store.reload()
refreshOfficialSkillCatalog(
reason: "AIAgentSkillsView.onAppear",
force: true
)
refreshPasteAccessState()
}
.onChange(of: scenePhase) { _, phase in
guard phase == .active else { return }
store.reload()
refreshOfficialSkillCatalog(
reason: "AIAgentSkillsView.active",
force: true
)
refreshPasteAccessState()
pasteAccessNeedsRecovery = false
}
}
private var clipboardAccessGuide: some View {
VStack(alignment: .leading, spacing: Spacing.sm) {
HStack(alignment: .top, spacing: Spacing.sm) {
skillIcon(systemImage: clipboardGuideIcon)
VStack(alignment: .leading, spacing: 4) {
Text(clipboardGuideTitle)
.font(TypeStyle.bodyEmph)
.foregroundStyle(palette.textPrimary)
Text(clipboardGuideBody)
.font(TypeStyle.caption)
.foregroundStyle(palette.textSecondary)
.fixedSize(horizontal: false, vertical: true)
}
}
if !showPasteAccessSuccess {
Button(action: performClipboardGuideAction) {
guideRow(
titleKey: clipboardGuideActionTitle,
systemImage: clipboardGuideActionIcon,
trailing: clipboardGuideActionTrailing
)
}
.buttonStyle(.plain)
.accessibilityIdentifier(clipboardGuideActionIdentifier)
}
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(Spacing.md)
.background(palette.surface, in: skillCardShape)
.accessibilityIdentifier("skills.clipboard.guide")
}
private func guideRow(
titleKey: LocalizedStringKey,
systemImage: String,
trailing: String
) -> some View {
HStack(spacing: Spacing.sm) {
Image(systemName: systemImage)
.font(.system(size: 14, weight: .semibold))
.foregroundStyle(palette.textPrimary)
.symbolRenderingMode(.monochrome)
.frame(width: 22)
Text(titleKey)
.font(TypeStyle.body)
.foregroundStyle(palette.textPrimary)
Spacer(minLength: 0)
Image(systemName: trailing)
.font(.system(size: 12, weight: .semibold))
.foregroundStyle(palette.textTertiary)
}
.padding(.horizontal, Spacing.sm)
.frame(minHeight: 44)
.background(
palette.surfaceElevated,
in: RoundedRectangle(cornerRadius: Radius.medium, style: .continuous)
)
.contentShape(Rectangle())
}
private var showsClipboardAccessGuide: Bool {
!config.clipboardHistoryEnabled || !pasteAccessVerified || showPasteAccessSuccess
}
private var clipboardGuideTitle: LocalizedStringKey {
if showPasteAccessSuccess {
return "skills.clipboard.guide.success.title"
}
if !config.clipboardHistoryEnabled {
return "skills.clipboard.guide.title"
}
if pasteAccessNeedsRecovery {
return "skills.clipboard.guide.recovery.title"
}
return "skills.clipboard.guide.verify.title"
}
private var clipboardGuideBody: LocalizedStringKey {
if showPasteAccessSuccess {
return "skills.clipboard.guide.success.body"
}
if !config.clipboardHistoryEnabled {
return "skills.clipboard.guide.body"
}
if pasteAccessNeedsRecovery {
return "skills.clipboard.guide.recovery.body"
}
return "skills.clipboard.guide.verify.body"
}
private var clipboardGuideIcon: String {
if showPasteAccessSuccess {
return "checkmark"
}
return pasteAccessNeedsRecovery ? "exclamationmark" : "clipboard"
}
private var clipboardGuideActionTitle: LocalizedStringKey {
if !config.clipboardHistoryEnabled {
return "skills.clipboard.guide.enableHistory"
}
if pasteAccessNeedsRecovery {
return "skills.clipboard.guide.openSystemSettings"
}
return "skills.clipboard.guide.verify.action"
}
private var clipboardGuideActionIcon: String {
if !config.clipboardHistoryEnabled {
return "clock.arrow.circlepath"
}
return pasteAccessNeedsRecovery ? "gearshape" : "checkmark.shield"
}
private var clipboardGuideActionTrailing: String {
pasteAccessNeedsRecovery ? "arrow.up.right" : "arrow.right"
}
private var clipboardGuideActionIdentifier: String {
if !config.clipboardHistoryEnabled {
return "skills.clipboard.guide.enableHistory"
}
if pasteAccessNeedsRecovery {
return "skills.clipboard.guide.openSystemSettings"
}
return "skills.clipboard.guide.verifyPaste"
}
private func performClipboardGuideAction() {
if !config.clipboardHistoryEnabled {
withAnimation(Motion.soft) {
config.clipboardHistoryEnabled = true
}
return
}
if pasteAccessNeedsRecovery {
AppPermissions.openSystemSettings()
return
}
verifyPasteAccess()
}
private func verifyPasteAccess() {
switch AppPermissions.requestPasteAccess() {
case .verified:
withAnimation(Motion.soft) {
pasteAccessVerified = true
pasteAccessNeedsRecovery = false
showPasteAccessSuccess = true
}
Task { @MainActor in
try? await Task.sleep(for: .milliseconds(700))
withAnimation(Motion.soft) {
showPasteAccessSuccess = false
}
}
case .noTextAvailable:
showPasteNoTextAlert = true
case .unavailable:
withAnimation(Motion.soft) {
pasteAccessVerified = false
pasteAccessNeedsRecovery = true
}
}
}
private func refreshPasteAccessState() {
pasteAccessVerified = AppPermissions.hasVerifiedPasteAccess
}
private func refreshOfficialSkillCatalog(reason: String, force: Bool) {
Task {
let outcome = await OfficialSkillCatalogRefreshService.shared.refreshIfNeeded(
reason: reason,
force: force
)
if outcome.didUpdateCache {
store.reload()
}
}
}
private var installedSection: some View {
CardSection(
title: AppL10n.format(
"skills.installed.section",
language: config.uiLanguage,
store.skillManagementEnabledSkills.count
)
) {
if store.skillManagementEnabledSkills.isEmpty {
Text("skills.installed.empty")
.font(TypeStyle.caption)
.foregroundStyle(palette.textTertiary)
.frame(maxWidth: .infinity, alignment: .leading)
} else {
LazyVStack(spacing: CardLayoutMetrics.compactItemSpacing) {
ForEach(store.skillManagementEnabledSkills) { skill in
skillListItem(skill)
}
}
}
}
}
private var uninstalledSection: some View {
CardSection("skills.uninstalled.section") {
LazyVStack(spacing: CardLayoutMetrics.compactItemSpacing) {
ForEach(store.skillManagementAvailableSkills) { skill in
skillListItem(skill)
}
}
}
}
private func skillListItem(_ skill: AIClipboardSkill) -> some View {
NavigationLink(value: SkillRoute.detail(skill.id)) {
skillListRow(skill)
}
.buttonStyle(.plain)
.contextMenu {
if skill.isUserCreated {
Button("common.delete", role: .destructive) {
store.deleteUserSkill(id: skill.id)
}
}
}
}
private func skillListRow(_ skill: AIClipboardSkill) -> some View {
HStack(spacing: Spacing.md) {
skillIcon(systemImage: skill.systemImage)
VStack(alignment: .leading, spacing: 3) {
Text(skillTitle(skill))
.font(TypeStyle.body)
.foregroundStyle(palette.textPrimary)
.lineLimit(1)
Text(skillDescription(skill))
.font(TypeStyle.caption2)
.foregroundStyle(palette.textTertiary)
.lineLimit(2)
}
.frame(maxWidth: .infinity, alignment: .leading)
Image(systemName: "chevron.right")
.font(.system(size: 12, weight: .semibold))
.foregroundStyle(palette.textTertiary)
.frame(width: 18)
}
.frame(maxWidth: .infinity, minHeight: 64, alignment: .leading)
.padding(.horizontal, Spacing.md)
.padding(.vertical, Spacing.xs)
.background(palette.surface, in: skillCardShape)
.clipShape(skillCardShape)
.contentShape(Rectangle())
}
private func skillIcon(systemImage: String) -> some View {
Image(systemName: systemImage)
.font(.system(size: 16, weight: .semibold))
.foregroundStyle(palette.textPrimary)
.symbolRenderingMode(.monochrome)
.frame(width: 38, height: 38)
.background(
palette.textPrimary.opacity(0.08),
in: RoundedRectangle(cornerRadius: Radius.medium, style: .continuous)
)
}
private func skillDetailView(_ skill: AIClipboardSkill) -> some View {
SkillDetailView(
store: store,
skill: skill,
onInstall: addOrWarn,
onConfirmInstall: confirmShortcutInstall,
onEdit: {
guard let user = store.userSkill(id: skill.id) else { return }
editingDraft = .from(user)
}
)
}
private func skillTitle(_ skill: AIClipboardSkill) -> String {
if let name = skill.customName?.trimmingCharacters(in: .whitespacesAndNewlines),
!name.isEmpty {
return name
}
return AppL10n.string(skill.cardTitleKey, language: config.uiLanguage)
}
private func skillDescription(_ skill: AIClipboardSkill) -> String {
let summary = skill.customSummary?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
if !summary.isEmpty { return summary }
if skill.isUserCreated {
return AppL10n.string("skills.custom.description", language: config.uiLanguage)
}
return AppL10n.string(skill.descriptionKey, language: config.uiLanguage)
}
private func addOrWarn(_ skill: AIClipboardSkill) {
switch store.enable(skill.id) {
case .enabled, .alreadyEnabled:
break
case .needsShortcut, .unknown:
break
}
}
private func confirmShortcutInstall(_ skill: AIClipboardSkill) {
switch store.confirmShortcutAndEnable(skill.id) {
case .enabled, .alreadyEnabled:
break
case .needsShortcut, .unknown:
break
}
}
private func saveDraft(_ draft: SkillEditorDraft) throws {
let rawShortcutLink = draft.shortcutLink.trimmingCharacters(
in: .whitespacesAndNewlines
)
let shortcutURL: URL?
if rawShortcutLink.isEmpty {
shortcutURL = nil
} else {
guard let parsedURL = AIShortcutShareLink.parse(rawShortcutLink) else {
throw AIUserSkillValidationError.invalidShortcutLink
}
shortcutURL = parsedURL
}
let skill = AIUserSkill(
id: draft.id,
name: draft.name,
summary: draft.summary,
systemImage: draft.systemImage,
prompt: draft.prompt,
shortcutICloudURL: shortcutURL,
shortcutName: draft.shortcutName,
thinkingEnabled: draft.thinkingEnabled
)
try store.saveUserSkill(skill)
editingDraft = nil
}
}
private struct SkillEditorDraft: Identifiable, Equatable {
let id: String
let isNew: Bool
var name: String
var summary: String
var systemImage: String
var prompt: String
var shortcutLink: String
var shortcutName: String
var thinkingEnabled: Bool
static func blank() -> SkillEditorDraft {
SkillEditorDraft(
id: "user.\(UUID().uuidString.lowercased())",
isNew: true,
name: "",
summary: "",
systemImage: AIUserSkillLimits.defaultSystemImage,
prompt: AIUserSkillLimits.newPromptTemplate,
shortcutLink: "",
shortcutName: "",
thinkingEnabled: false
)
}
static func from(_ skill: AIUserSkill) -> SkillEditorDraft {
SkillEditorDraft(
id: skill.id,
isNew: false,
name: skill.name,
summary: skill.summary,
systemImage: skill.systemImage,
prompt: skill.prompt,
shortcutLink: skill.shortcutICloudURL?.absoluteString ?? "",
shortcutName: skill.shortcutName,
thinkingEnabled: skill.thinkingEnabled
)
}
}
private struct SkillDetailView: View {
@Environment(\.dismiss) private var dismiss
@Environment(\.themePalette) private var palette
@ObservedObject var store: AIAgentSkillLayoutStore
@ObservedObject private var config = ProviderConfig.shared
let skill: AIClipboardSkill
let onInstall: (AIClipboardSkill) -> Void
let onConfirmInstall: (AIClipboardSkill) -> Void
let onEdit: () -> Void
var body: some View {
ScrollView {
CardPageContent(
topPadding: Spacing.md,
bottomPadding: Spacing.xl
) {
header
Text(skillDescription)
.font(TypeStyle.body)
.foregroundStyle(palette.textSecondary)
.frame(maxWidth: .infinity, alignment: .leading)
promptBlock
thinkingRow
skillActions
}
}
.background(palette.background)
.navigationTitle("skills.detail.title")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
if skill.isUserCreated {
ToolbarItem(placement: .topBarTrailing) {
Button("skills.edit", action: onEdit)
}
}
}
}
private var skillDescription: String {
let summary = skill.customSummary?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
if !summary.isEmpty { return summary }
if skill.isUserCreated {
return AppL10n.string("skills.custom.description", language: config.uiLanguage)
}
if skill.requiresShortcut, !store.layout.hasConfirmedShortcut(skill.id) {
return AppL10n.string("skills.install.lead", language: config.uiLanguage)
}
return AppL10n.string(skill.descriptionKey, language: config.uiLanguage)
}
private var promptText: String {
AIClipboardSkillCatalog.instruction(
for: skill,
locale: config.uiLanguage.resolvedLanguageCode() == "zh-Hans" ? "zh" : "en",
translationTargetLocaleId: config.translationTargetLocaleId
)
}
private var header: some View {
HStack(alignment: .center, spacing: Spacing.md) {
Image(systemName: skill.systemImage)
.font(.system(size: 16, weight: .semibold))
.foregroundStyle(palette.textPrimary)
.frame(width: 38, height: 38)
.background(
palette.textPrimary.opacity(0.08),
in: RoundedRectangle(cornerRadius: Radius.medium, style: .continuous)
)
VStack(alignment: .leading, spacing: 2) {
Text(headerTitle)
.font(TypeStyle.title3)
.foregroundStyle(palette.textPrimary)
if skill.isDefault {
Text("skills.badge.default")
.font(TypeStyle.caption2)
.foregroundStyle(palette.textSecondary)
}
}
Spacer(minLength: 0)
}
}
private var headerTitle: String {
if let name = skill.customName?.trimmingCharacters(in: .whitespacesAndNewlines),
!name.isEmpty {
return name
}
return AppL10n.string(skill.cardTitleKey, language: config.uiLanguage)
}
private var promptBlock: some View {
VStack(alignment: .leading, spacing: Spacing.xs) {
Text("skills.editor.prompt")
.font(TypeStyle.caption)
.foregroundStyle(palette.textTertiary)
Text(promptText)
.font(.body.monospaced())
.foregroundStyle(palette.textPrimary)
.textSelection(.enabled)
.frame(maxWidth: .infinity, alignment: .leading)
.padding(Spacing.md)
.surfaceCard()
}
}
private var thinkingRow: some View {
Toggle("skills.editor.thinking", isOn: .constant(skill.thinkingEnabled))
.disabled(true)
.font(TypeStyle.body)
}
@ViewBuilder
private var skillActions: some View {
if skill.requiresShortcut {
if !store.layout.hasConfirmedShortcut(skill.id) {
shortcutInstallBlock
} else {
if isInstalled {
fullWidthButton("skills.action.uninstall", prominent: false) {
store.disable(skill.id)
dismiss()
}
} else {
fullWidthButton("skills.action.install", prominent: true) {
onInstall(skill)
}
}
fullWidthButton("skills.action.reinstallShortcut", prominent: false) {
openShortcutInstall()
}
}
} else if isInstalled {
fullWidthButton("skills.action.uninstall", prominent: false) {
store.disable(skill.id)
dismiss()
}
} else {
fullWidthButton("skills.action.install", prominent: true) {
onInstall(skill)
}
}
}
private var isInstalled: Bool {
store.layout.isEnabled(skill.id)
}
private var shortcutInstallBlock: some View {
VStack(alignment: .leading, spacing: Spacing.sm) {
fullWidthButton("skills.install.openShortcuts", prominent: true) {
openShortcutInstall()
}
fullWidthButton("skills.install.confirmAdded", prominent: false) {
onConfirmInstall(skill)
}
if store.layout.isEnabled(skill.id) {
fullWidthButton("skills.action.uninstall", prominent: false) {
store.disable(skill.id)
dismiss()
}
}
}
}
@ViewBuilder
private func fullWidthButton(
_ titleKey: LocalizedStringKey,
prominent: Bool,
action: @escaping () -> Void
) -> some View {
let shape = RoundedRectangle(cornerRadius: Radius.large, style: .continuous)
Button(action: action) {
Text(titleKey)
.font(TypeStyle.bodyEmph)
.frame(maxWidth: .infinity, minHeight: 44)
.foregroundStyle(prominent ? palette.textOnAccent : palette.textPrimary)
.background(
prominent ? palette.accent : palette.surfaceElevated,
in: shape
)
}
.buttonStyle(.plain)
}
private func openShortcutInstall() {
AIAgentShortcutInstaller.openInstallPage(for: skill)
}
}
private struct SkillEditorSheet: View {
let draft: SkillEditorDraft
let onSave: (SkillEditorDraft) throws -> Void
let onDelete: (String) -> Void
@Environment(\.dismiss) private var dismiss
@Environment(\.themePalette) private var palette
@State private var name: String
@State private var summary: String
@State private var systemImage: String
@State private var prompt: String
@State private var shortcutLink: String
@State private var shortcutName: String
@State private var thinkingEnabled: Bool
@State private var showSymbolPicker = false
@State private var isLookingUp = false
@State private var lookupMessage: String?
@State private var lookupTask: Task<Void, Never>?
@State private var saveError: String?
@State private var confirmDelete = false
init(
draft: SkillEditorDraft,
onSave: @escaping (SkillEditorDraft) throws -> Void,
onDelete: @escaping (String) -> Void
) {
self.draft = draft
self.onSave = onSave
self.onDelete = onDelete
_name = State(initialValue: draft.name)
_summary = State(initialValue: draft.summary)
_systemImage = State(initialValue: draft.systemImage)
_prompt = State(initialValue: draft.prompt)
_shortcutLink = State(initialValue: draft.shortcutLink)
_shortcutName = State(initialValue: draft.shortcutName)
_thinkingEnabled = State(initialValue: draft.thinkingEnabled)
}
var body: some View {
NavigationStack {
Form {
Section("skills.editor.name") {
TextField("skills.editor.namePlaceholder", text: $name)
}
Section("skills.editor.summary") {
TextField("skills.editor.summaryPlaceholder", text: $summary, axis: .vertical)
.lineLimit(6...12)
.frame(minHeight: 108, alignment: .top)
}
Section("skills.editor.icon") {
Button {
showSymbolPicker = true
} label: {
HStack(spacing: Spacing.md) {
Image(systemName: systemImage)
.font(.system(size: 22, weight: .semibold))
.foregroundStyle(palette.accent)
.frame(width: 44, height: 44)
.background(
palette.accentMuted,
in: RoundedRectangle(cornerRadius: Radius.medium, style: .continuous)
)
VStack(alignment: .leading, spacing: 2) {
Text("skills.editor.iconChoose")
.foregroundStyle(palette.textPrimary)
Text(systemImage)
.font(TypeStyle.caption)
.foregroundStyle(palette.textSecondary)
.lineLimit(1)
}
Spacer(minLength: 0)
Image(systemName: "chevron.right")
.font(.system(size: 12, weight: .semibold))
.foregroundStyle(palette.textTertiary)
}
}
}
Section {
TextEditor(text: $prompt)
.font(.body.monospaced())
.frame(minHeight: 180)
} header: {
HStack {
Text("skills.editor.prompt")
Spacer()
Text("\(prompt.count)/\(AIUserSkillLimits.maximumPromptCharacters)")
.foregroundStyle(
prompt.count > AIUserSkillLimits.maximumPromptCharacters
? palette.danger
: palette.textTertiary
)
}
}
Section {
TextField("skills.editor.linkPlaceholder", text: $shortcutLink)
.textInputAutocapitalization(.never)
.keyboardType(.URL)
.autocorrectionDisabled()
TextField("skills.editor.shortcutNamePlaceholder", text: $shortcutName)
if isLookingUp {
Text("skills.editor.lookingUp")
.font(TypeStyle.caption)
.foregroundStyle(palette.textTertiary)
} else if let lookupMessage {
Text(lookupMessage)
.font(TypeStyle.caption)
.foregroundStyle(palette.textTertiary)
}
} header: {
Text("skills.editor.shortcut")
} footer: {
Text("skills.editor.shortcutHint")
}
Section {
Toggle("skills.editor.thinking", isOn: $thinkingEnabled)
} footer: {
Text("skills.editor.thinkingHint")
}
if !draft.isNew {
Section {
Button("common.delete", role: .destructive) {
confirmDelete = true
}
}
}
}
.navigationTitle(draft.isNew ? "skills.add" : "skills.edit")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("common.cancel") { dismiss() }
}
ToolbarItem(placement: .confirmationAction) {
Button("common.save") {
do {
try save()
dismiss()
} catch {
saveError = localizedSaveError(error)
}
}
.disabled(!canSave)
}
}
.sheet(isPresented: $showSymbolPicker) {
SkillSymbolPicker(selection: $systemImage)
}
.alert("skills.delete.title", isPresented: $confirmDelete) {
Button("common.cancel", role: .cancel) {}
Button("common.delete", role: .destructive) {
onDelete(draft.id)
dismiss()
}
} message: {
Text("skills.delete.message")
}
.alert(
Text("skills.error.title"),
isPresented: Binding(
get: { saveError != nil },
set: { if !$0 { saveError = nil } }
)
) {
Button("common.done") { saveError = nil }
} message: {
Text(saveError ?? "")
}
.onChange(of: shortcutLink) { _, newValue in
lookupShortcutNameIfNeeded(from: newValue)
}
.onDisappear {
lookupTask?.cancel()
}
}
}
private var canSave: Bool {
let trimmedShortcutLink = shortcutLink.trimmingCharacters(
in: .whitespacesAndNewlines
)
let validShortcutConfiguration = trimmedShortcutLink.isEmpty
|| (
AIShortcutShareLink.parse(trimmedShortcutLink) != nil
&& !shortcutName.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
)
return !name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
&& !prompt.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
&& prompt.count <= AIUserSkillLimits.maximumPromptCharacters
&& validShortcutConfiguration
}
private var currentDraft: SkillEditorDraft {
var next = draft
next.name = name
next.summary = summary
next.systemImage = systemImage
next.prompt = prompt
next.shortcutLink = shortcutLink
next.shortcutName = shortcutName
next.thinkingEnabled = thinkingEnabled
return next
}
private func save() throws {
try onSave(currentDraft)
}
private func localizedSaveError(_ error: Error) -> String {
switch error as? AIUserSkillValidationError {
case .emptyName: return AppL10n.string("skills.error.emptyName")
case .emptyPrompt: return AppL10n.string("skills.error.emptyPrompt")
case .emptyShortcutName: return AppL10n.string("skills.error.emptyShortcutName")
case .invalidShortcutLink: return AppL10n.string("skills.error.invalidLink")
case .emptyIcon: return AppL10n.string("skills.error.emptyIcon")
case .promptTooLong: return AppL10n.string("skills.error.promptTooLong")
case nil: return AppL10n.string("skills.error.generic")
}
}
private func lookupShortcutNameIfNeeded(from raw: String) {
lookupTask?.cancel()
guard let url = AIShortcutShareLink.parse(raw) else {
lookupMessage = nil
isLookingUp = false
return
}
isLookingUp = true
lookupMessage = nil
lookupTask = Task {
do {
let fetched = try await AIShortcutShareMetadata.fetchName(from: url)
try Task.checkCancellation()
await MainActor.run {
isLookingUp = false
if shortcutName.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
shortcutName = fetched
}
lookupMessage = AppL10n.format(
"skills.editor.resolvedName",
language: nil,
fetched
)
}
} catch is CancellationError {
return
} catch {
await MainActor.run {
isLookingUp = false
lookupMessage = AppL10n.string("skills.editor.lookupFailed")
}
}
}
}
}
private struct SkillSymbolPicker: View {
@Binding var selection: String
@Environment(\.dismiss) private var dismiss
@Environment(\.themePalette) private var palette
@State private var query = ""
private let columns = [
GridItem(.adaptive(minimum: 44), spacing: 6)
]
private var filtered: [String] {
let trimmed = query.trimmingCharacters(in: .whitespacesAndNewlines)
if trimmed.isEmpty { return SFSymbolCatalog.names }
return SFSymbolCatalog.names.filter { $0.localizedCaseInsensitiveContains(trimmed) }
}
var body: some View {
NavigationStack {
ScrollView {
LazyVGrid(columns: columns, spacing: 6) {
ForEach(filtered, id: \.self) { symbol in
Button {
selection = symbol
dismiss()
} label: {
Image(systemName: symbol)
.font(.system(size: 18, weight: .medium))
.foregroundStyle(
symbol == selection ? palette.textOnAccent : palette.textPrimary
)
.frame(width: 44, height: 44)
.background(
symbol == selection ? palette.accent : palette.surfaceElevated,
in: RoundedRectangle(cornerRadius: 8, style: .continuous)
)
}
.buttonStyle(.plain)
.accessibilityLabel(Text(symbol))
}
}
.padding(.horizontal, Spacing.md)
.padding(.vertical, Spacing.sm)
}
.background(palette.background)
.navigationTitle("skills.editor.icon")
.navigationBarTitleDisplayMode(.inline)
.searchable(text: $query, prompt: Text("skills.editor.iconSearch"))
.toolbar {
ToolbarItem(placement: .confirmationAction) {
Button("common.done") { dismiss() }
}
}
}
.presentationDetents([.large])
}
}