Files
OSGKeyboard/OSGKeyboard/Views/AIAgentSkillsView.swift
T
Rocky e5a83843db Expand onboarding and adaptive keyboard intelligence
Add resilient usage analytics, OOBE gateway flows, clipboard semantic ranking, purchase recovery, style learning, and managed current-information search.
2026-08-22 16:33:18 +08:00

1246 lines
45 KiB
Swift

// AIAgentSkillsView.swift
// OSGKeyboard · Main App
//
// Catalog of AI Agent clipboard skills. Enabled cards appear on
// the keyboard after a copy; long-press drag rearranges that row live,
// like Home Screen icons. Export skills confirm a companion Shortcut
// before they can occupy a slot. Users can add custom export skills.
import OSGKeyboardShared
import SwiftUI
import UIKit
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 viewingSkill: AIClipboardSkill?
@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
/// True while a skill card is lifted; locks the page scroll like SpringBoard.
@State private var isReordering = false
private var skillCardShape: RoundedRectangle {
RoundedRectangle(cornerRadius: Radius.xl, style: .continuous)
}
var body: some View {
NavigationStack {
ScrollView {
CardPageContent(spacing: Spacing.xl) {
if showsClipboardAccessGuide {
clipboardAccessGuide
.transition(.opacity.combined(with: .move(edge: .top)))
}
enabledSection
if !store.availableSkills.isEmpty {
availableSection
}
}
.tabBarScrollBottomPadding()
}
.scrollDisabled(isReordering)
.background(palette.background)
.navigationTitle("skills.title")
.navigationBarTitleDisplayMode(.large)
.toolbar {
ToolbarItem(placement: .topBarTrailing) {
Button {
editingDraft = .blank()
} label: {
Image(systemName: "plus")
}
.accessibilityLabel(Text("skills.add"))
}
}
}
.sheet(item: $viewingSkill) { skill in
SkillDetailSheet(
store: store,
skill: skill,
onDismiss: { viewingSkill = nil },
onAddOrWarn: addOrWarn,
onConfirmInstall: confirmShortcutInstall
)
}
.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) {
Image(systemName: clipboardGuideIcon)
.font(.system(size: 17, weight: .semibold))
.foregroundStyle(clipboardGuideTint)
.frame(width: 36, height: 36)
.background(clipboardGuideTint.opacity(0.12), in: Circle())
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)
.overlay(skillCardShape.stroke(palette.divider, lineWidth: 0.5))
.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.accent)
.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 clipboardGuideTint: Color {
pasteAccessNeedsRecovery ? palette.warning : palette.accent
}
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 enabledSection: some View {
CardSection(
title: AppL10n.format(
"skills.enabled.section",
language: config.uiLanguage,
store.enabledSkills.count
)
) {
if store.enabledSkills.isEmpty {
Text("skills.enabled.empty")
.font(TypeStyle.caption)
.foregroundStyle(palette.textTertiary)
.frame(maxWidth: .infinity, alignment: .leading)
} else {
EnabledSkillsReorderGrid(
skills: store.enabledSkills,
isReordering: $isReordering,
onTap: handleCardTap,
onEdit: handleEdit,
onMoveToIndex: { id, index in
store.moveEnabled(id: id, toIndex: index)
},
card: { skill in
skillCardVisual(skill, isEnabled: true)
}
)
.background(palette.surface, in: skillCardShape)
.overlay(skillCardShape.stroke(palette.divider, lineWidth: 0.5))
.clipShape(skillCardShape)
}
}
}
private var availableSection: some View {
CardSection("skills.available.section") {
LazyVStack(spacing: 0) {
ForEach(store.availableSkills) { skill in
skillCardInteractive(skill, isEnabled: false)
}
}
.background(palette.surface, in: skillCardShape)
.overlay(skillCardShape.stroke(palette.divider, lineWidth: 0.5))
.clipShape(skillCardShape)
}
}
private func skillCardInteractive(_ skill: AIClipboardSkill, isEnabled: Bool) -> some View {
ZStack(alignment: .topTrailing) {
Button {
handleCardTap(skill)
} label: {
skillCardVisual(skill, isEnabled: isEnabled)
}
.buttonStyle(.plain)
Button {
handleEdit(skill)
} label: {
Color.clear
.frame(
width: CatalogCardChrome.editHitSize,
height: CatalogCardChrome.editHitSize
)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.accessibilityLabel(Text("skills.edit"))
}
.contextMenu {
if skill.isUserCreated {
Button("common.delete", role: .destructive) {
store.deleteUserSkill(id: skill.id)
}
}
}
}
private func skillCardVisual(_ skill: AIClipboardSkill, isEnabled: Bool) -> some View {
HStack(spacing: Spacing.md) {
Image(systemName: skill.systemImage)
.font(.system(size: 16, weight: .semibold))
.foregroundStyle(palette.accent)
.frame(width: 38, height: 38)
.background(palette.accentMuted, in: RoundedRectangle(
cornerRadius: Radius.medium,
style: .continuous
))
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)
if isEnabled {
Image(systemName: "checkmark.circle.fill")
.font(.system(size: 16, weight: .semibold))
.foregroundStyle(palette.accent)
}
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)
.overlay(alignment: .bottom) {
Rectangle()
.fill(palette.divider)
.frame(height: 0.5)
.padding(.leading, 38 + Spacing.md * 2)
}
.contentShape(Rectangle())
}
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 handleCardTap(_ skill: AIClipboardSkill) {
if store.layout.isEnabled(skill.id) {
store.disable(skill.id)
return
}
if skill.requiresShortcut, !store.layout.hasConfirmedShortcut(skill.id) {
viewingSkill = skill
return
}
addOrWarn(skill)
}
private func handleEdit(_ skill: AIClipboardSkill) {
if skill.isUserCreated, let user = store.userSkill(id: skill.id) {
editingDraft = .from(user)
} else {
viewingSkill = skill
}
}
private func addOrWarn(_ skill: AIClipboardSkill) {
switch store.enable(skill.id) {
case .enabled, .alreadyEnabled:
viewingSkill = nil
case .needsShortcut, .unknown:
break
}
}
private func confirmShortcutInstall(_ skill: AIClipboardSkill) {
switch store.confirmShortcutAndEnable(skill.id) {
case .enabled, .alreadyEnabled:
viewingSkill = nil
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
}
}
/// List-style reorder: long-press lifts the row, other rows slide into the
/// gap as the finger crosses midpoints, and drop settles in place.
private struct EnabledSkillsReorderGrid<Card: View>: View {
let skills: [AIClipboardSkill]
@Binding var isReordering: Bool
let onTap: (AIClipboardSkill) -> Void
let onEdit: (AIClipboardSkill) -> Void
let onMoveToIndex: (String, Int) -> Void
let card: (AIClipboardSkill) -> Card
@State private var draggingID: String?
@State private var dragTranslation: CGSize = .zero
@State private var originFrame: CGRect = .zero
@State private var cellFrames: [String: CGRect] = [:]
@State private var ignoreTap = false
private static var spaceName: String { "enabledSkillsGrid" }
private let columns = [
GridItem(.flexible(), spacing: 0)
]
var body: some View {
ZStack(alignment: .topLeading) {
LazyVGrid(columns: columns, spacing: 0) {
ForEach(skills) { skill in
gridCell(skill)
}
}
.animation(
.interactiveSpring(response: 0.28, dampingFraction: 0.86),
value: skills.map(\.id)
)
if let draggingID, let skill = skills.first(where: { $0.id == draggingID }) {
card(skill)
.frame(width: originFrame.width, height: originFrame.height)
.scaleEffect(1.07)
.shadow(color: .black.opacity(0.28), radius: 16, y: 10)
.offset(
x: originFrame.minX + dragTranslation.width,
y: originFrame.minY + dragTranslation.height
)
.allowsHitTesting(false)
}
}
.coordinateSpace(.named(Self.spaceName))
.onPreferenceChange(SkillCellFrameKey.self) { cellFrames = $0 }
}
private func gridCell(_ skill: AIClipboardSkill) -> some View {
ZStack(alignment: .topTrailing) {
Button {
guard draggingID == nil, !ignoreTap else { return }
onTap(skill)
} label: {
card(skill)
.opacity(draggingID == skill.id ? 0 : 1)
}
.buttonStyle(.plain)
Button {
guard draggingID == nil, !ignoreTap else { return }
onEdit(skill)
} label: {
Color.clear
.frame(
width: CatalogCardChrome.editHitSize,
height: CatalogCardChrome.editHitSize
)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.accessibilityLabel(Text("skills.edit"))
.opacity(draggingID == skill.id ? 0 : 1)
}
.background {
GeometryReader { proxy in
Color.clear.preference(
key: SkillCellFrameKey.self,
value: [skill.id: proxy.frame(in: .named(Self.spaceName))]
)
}
}
.simultaneousGesture(reorderGesture(for: skill))
}
private func reorderGesture(for skill: AIClipboardSkill) -> some Gesture {
LongPressGesture(minimumDuration: 0.22)
.sequenced(
before: DragGesture(
minimumDistance: 0,
coordinateSpace: .named(Self.spaceName)
)
)
.onChanged { value in
switch value {
case .second(_, let drag):
liftIfNeeded(skill)
guard let drag else { return }
dragTranslation = drag.translation
moveSlotIfNeeded(at: drag.location)
default:
break
}
}
.onEnded { _ in
endDrag()
}
}
private func liftIfNeeded(_ skill: AIClipboardSkill) {
guard draggingID == nil else { return }
originFrame = cellFrames[skill.id] ?? .zero
draggingID = skill.id
isReordering = true
UIImpactFeedbackGenerator(style: .medium).impactOccurred(intensity: 0.9)
}
private func moveSlotIfNeeded(at point: CGPoint) {
guard let draggingID else { return }
guard let target = nearestIndex(to: point) else { return }
guard skills[target].id != draggingID else { return }
UISelectionFeedbackGenerator().selectionChanged()
onMoveToIndex(draggingID, target)
}
private func nearestIndex(to point: CGPoint) -> Int? {
guard !skills.isEmpty else { return nil }
var bestIndex: Int?
var bestDistance = CGFloat.greatestFiniteMagnitude
for (index, skill) in skills.enumerated() {
guard let frame = cellFrames[skill.id] else { continue }
let dx = point.x - frame.midX
let dy = point.y - frame.midY
let distance = dx * dx + dy * dy
if distance < bestDistance {
bestDistance = distance
bestIndex = index
}
}
return bestIndex
}
private func endDrag() {
let wasDragging = draggingID != nil
if let draggingID, let slot = cellFrames[draggingID], originFrame.width > 0 {
withAnimation(.snappy(duration: 0.2)) {
dragTranslation = CGSize(
width: slot.minX - originFrame.minX,
height: slot.minY - originFrame.minY
)
} completion: {
clearDragState()
}
} else {
clearDragState()
}
guard wasDragging else { return }
ignoreTap = true
DispatchQueue.main.async {
ignoreTap = false
}
}
private func clearDragState() {
draggingID = nil
dragTranslation = .zero
isReordering = false
}
}
private struct SkillCellFrameKey: PreferenceKey {
static let defaultValue: [String: CGRect] = [:]
static func reduce(value: inout [String: CGRect], nextValue: () -> [String: CGRect]) {
value.merge(nextValue(), uniquingKeysWith: { _, new in new })
}
}
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 SkillDetailSheet: View {
@Environment(\.themePalette) private var palette
@ObservedObject var store: AIAgentSkillLayoutStore
@ObservedObject private var config = ProviderConfig.shared
let skill: AIClipboardSkill
let onDismiss: () -> Void
let onAddOrWarn: (AIClipboardSkill) -> Void
let onConfirmInstall: (AIClipboardSkill) -> Void
/// Inner stack height; nav chrome + home indicator are added for the detent.
@State private var contentHeight: CGFloat = 280
private let navigationChrome: CGFloat = 72
var body: some View {
NavigationStack {
ScrollView {
CardPageContent(
spacing: Spacing.md,
topPadding: Spacing.md,
bottomPadding: Spacing.xl
) {
header
Text(skillDescription)
.font(TypeStyle.body)
.foregroundStyle(palette.textSecondary)
.frame(maxWidth: .infinity, alignment: .leading)
promptBlock
thinkingRow
skillActions
}
.fixedSize(horizontal: false, vertical: true)
.onGeometryChange(for: CGFloat.self) { proxy in
proxy.size.height
} action: { newHeight in
let next = newHeight + navigationChrome
if abs(contentHeight - next) > 1 {
contentHeight = next
}
}
}
.scrollBounceBehavior(.basedOnSize)
.background(palette.background)
.navigationTitle("skills.detail.title")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .confirmationAction) {
Button("common.done", action: onDismiss)
}
}
}
.presentationDetents([.height(clampedSheetHeight)])
.presentationDragIndicator(.visible)
.presentationContentInteraction(.resizes)
.animation(.easeInOut(duration: 0.2), value: clampedSheetHeight)
}
private var clampedSheetHeight: CGFloat {
let screen = UIScreen.main.bounds.height
let maximum = screen * 0.92
return min(max(contentHeight, 280), maximum)
}
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)
}
if skill.requiresShortcut, store.layout.isEnabled(skill.id) {
return AppL10n.string("skills.action.turnOffHint", 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.accent)
.frame(width: 36, height: 36)
.background(palette.accentMuted, in: Circle())
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 {
let enabled = store.layout.isEnabled(skill.id)
if skill.requiresShortcut {
if !store.layout.hasConfirmedShortcut(skill.id) {
shortcutInstallBlock
} else {
if enabled {
fullWidthButton("skills.action.turnOff", prominent: false) {
store.disable(skill.id)
onDismiss()
}
} else {
fullWidthButton("skills.action.addToKeyboard", prominent: true) {
onAddOrWarn(skill)
}
}
fullWidthButton("skills.action.reinstallShortcut", prominent: false) {
openShortcutInstall()
}
}
} else if enabled {
fullWidthButton("skills.action.turnOff", prominent: false) {
store.disable(skill.id)
onDismiss()
}
} else {
fullWidthButton("skills.action.addToKeyboard", prominent: true) {
onAddOrWarn(skill)
}
}
}
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)
}
}
}
@ViewBuilder
private func fullWidthButton(
_ titleKey: LocalizedStringKey,
prominent: Bool,
action: @escaping () -> Void
) -> some View {
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: Capsule()
)
.overlay(
Capsule()
.stroke(prominent ? Color.clear : palette.dividerStrong, lineWidth: 0.5)
)
}
.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])
}
}