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:
@@ -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")
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user