feat(keyboard): add custom skills plus events and navigate Shortcuts

Ship user-defined Shortcut skills, companion Events/Navigate recipes, shared skill/style card chrome, and bump the build to 69.
This commit is contained in:
Rocky
2026-08-13 23:01:43 +08:00
parent 148ff807f2
commit 0a60006d2d
50 changed files with 11612 additions and 251 deletions
+73 -29
View File
@@ -15,9 +15,18 @@ struct AIKeyboardView: View {
static let actionButtonMaxWidth: CGFloat = 150
static let statusHeight: CGFloat = 20
static let carouselInterval: TimeInterval = 4
static let skillButtonSize: CGFloat = 52
static let skillButtonSize: CGFloat = 48
static let skillsPerRow = 4
static let skillRowSpacing: CGFloat = 8
static let skillCellSpacing: CGFloat = 16
static let skillCellWidth: CGFloat = 64
}
#if DEBUG
/// Layout preview for `--ai-skills-demo`. Nil keeps production clipboard-window gating.
static var debugPreviewSkills: [AIClipboardSkill]?
#endif
@Environment(\.colorScheme) private var colorScheme
@Environment(\.accessibilityReduceMotion) private var reduceMotion
@ObservedObject var state: KeyboardState
@@ -236,40 +245,52 @@ struct AIKeyboardView: View {
}
private var clipboardSkillRow: some View {
let skills = visibleClipboardSkills
let row = HStack(spacing: Spacing.lg) {
ForEach(skills) { skill in
Button {
state.submitAIClipboardSkill(skill)
} label: {
VStack(spacing: 6) {
Image(systemName: skill.systemImage)
.font(.system(size: 18, weight: .semibold))
.foregroundStyle(palette.textPrimary.opacity(0.85))
.frame(width: Layout.skillButtonSize, height: Layout.skillButtonSize)
.glassEffect(.regular.interactive(), in: Circle())
Text(clipboardSkillTitle(skill))
.font(TypeStyle.caption2)
.foregroundStyle(palette.textSecondary)
let rows = skillRows(from: visibleClipboardSkills)
return VStack(spacing: 0) {
Spacer(minLength: 0)
VStack(spacing: Layout.skillRowSpacing) {
ForEach(Array(rows.enumerated()), id: \.offset) { _, rowSkills in
HStack(spacing: Layout.skillCellSpacing) {
ForEach(rowSkills) { skill in
skillChip(skill)
}
}
}
.buttonStyle(.plain)
.disabled(!state.aiServiceAvailable || state.aiSession.isBusy)
.accessibilityLabel(Text(clipboardSkillTitle(skill)))
}
}
return Group {
if skills.count > 4 {
ScrollView(.horizontal, showsIndicators: false) {
row.padding(.horizontal, Spacing.md)
}
} else {
row
}
Spacer(minLength: 0)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
private func skillRows(from skills: [AIClipboardSkill]) -> [[AIClipboardSkill]] {
stride(from: 0, to: skills.count, by: Layout.skillsPerRow).map { start in
Array(skills[start ..< min(start + Layout.skillsPerRow, skills.count)])
}
}
private func skillChip(_ skill: AIClipboardSkill) -> some View {
Button {
state.submitAIClipboardSkill(skill)
} label: {
VStack(spacing: 6) {
Image(systemName: skill.systemImage)
.font(.system(size: 18, weight: .semibold))
.foregroundStyle(palette.textPrimary.opacity(0.85))
.frame(width: Layout.skillButtonSize, height: Layout.skillButtonSize)
.glassEffect(.regular.interactive(), in: Circle())
Text(clipboardSkillTitle(skill))
.font(TypeStyle.caption2)
.foregroundStyle(palette.textSecondary)
.lineLimit(1)
.minimumScaleFactor(0.8)
}
.frame(width: Layout.skillCellWidth)
}
.buttonStyle(.plain)
.disabled(!state.aiServiceAvailable || state.aiSession.isBusy)
.accessibilityLabel(Text(clipboardSkillTitle(skill)))
}
/// Translate follows the keyboard target; Reply / Summarize stay static.
private func clipboardSkillTitle(_ skill: AIClipboardSkill) -> String {
if skill.id == AIClipboardSkillCatalog.translateID {
@@ -278,11 +299,20 @@ struct AIKeyboardView: View {
uiLanguage: AppGroupStore().uiLanguage
)
}
if let custom = skill.customName?.trimmingCharacters(in: .whitespacesAndNewlines),
!custom.isEmpty {
return custom
}
return ExtL10n.string(skill.titleKey)
}
/// Copy-then-30s window: skill chips replace the rotating hint.
private var showsClipboardSkills: Bool {
#if DEBUG
if let preview = Self.debugPreviewSkills, !preview.isEmpty {
return showsPlaceholder
}
#endif
guard showsPlaceholder, state.clipboardHistoryEnabled else { return false }
guard !visibleClipboardSkills.isEmpty else { return false }
return AIHintPool.isClipboardSkillWindowActive(
@@ -292,7 +322,15 @@ struct AIKeyboardView: View {
}
private var visibleClipboardSkills: [AIClipboardSkill] {
AIClipboardSkillCatalog.visible(enabledIDs: state.enabledClipboardSkillIDs)
#if DEBUG
if let preview = Self.debugPreviewSkills, !preview.isEmpty {
return preview
}
#endif
return AIClipboardSkillCatalog.visible(
enabledIDs: state.enabledClipboardSkillIDs,
userCatalog: AppGroupStore().agentUserSkillCatalog
)
}
/// No draft/answer yet show the centered hint carousel instead of a scroll body.
@@ -474,6 +512,12 @@ struct AIKeyboardView: View {
if state.pendingClipboardSkillID == AIClipboardSkillCatalog.extractTodosID {
return ExtL10n.string("keyboard.ai.skill.extracting")
}
if state.pendingClipboardSkillID == AIClipboardSkillCatalog.extractEventsID {
return ExtL10n.string("keyboard.ai.skill.extractingEvents")
}
if state.pendingClipboardSkillID == AIClipboardSkillCatalog.navigateID {
return ExtL10n.string("keyboard.ai.skill.extractingAddress")
}
if let draft = state.aiSession.draftAnswerText, !draft.isEmpty {
return ExtL10n.string("keyboard.ai.generating")
}