feat(keyboard): add clipboard AI skills, hint keywords, and voice session fixes

Idle chips show entities with category icons; a fresh copy surfaces Reply/Summarize/Translate; abort/cancel/empty-tap no longer leave the mic stuck.
This commit is contained in:
Rocky
2026-08-13 15:47:32 +08:00
parent 9f308fadd2
commit 631617ee53
48 changed files with 1627 additions and 345 deletions
+86 -24
View File
@@ -15,6 +15,7 @@ struct AIKeyboardView: View {
static let actionButtonMaxWidth: CGFloat = 150
static let statusHeight: CGFloat = 20
static let carouselInterval: TimeInterval = 4
static let skillButtonSize: CGFloat = 52
}
@Environment(\.colorScheme) private var colorScheme
@@ -61,6 +62,7 @@ struct AIKeyboardView: View {
// Reduce Motion stops the rotation, not the data: a card whose
// clipboard window has closed must still leave the carousel.
reloadHintPool(resetBag: false)
guard !showsClipboardSkills else { return }
if reduceMotion, let hint = currentHint,
poolCards.contains(where: { $0.id == hint.id }) {
return
@@ -127,8 +129,13 @@ struct AIKeyboardView: View {
private var answerArea: some View {
ZStack(alignment: .bottom) {
if showsPlaceholder {
hintCarousel
.frame(maxWidth: .infinity, maxHeight: .infinity)
if showsClipboardSkills {
clipboardSkillRow
.frame(maxWidth: .infinity, maxHeight: .infinity)
} else {
hintCarousel
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
} else {
ScrollViewReader { proxy in
ScrollView(.vertical) {
@@ -173,30 +180,82 @@ struct AIKeyboardView: View {
guard let hint = currentHint else { return }
state.submitAIHint(hint)
} label: {
Text(currentHint?.displayText ?? ExtL10n.string("keyboard.ai.placeholder"))
.font(TypeStyle.body)
.foregroundStyle(palette.textTertiary)
.multilineTextAlignment(.center)
.lineLimit(1)
.truncationMode(.tail)
.padding(.horizontal, Spacing.md)
.opacity(hintOpacity)
.frame(maxWidth: .infinity, maxHeight: .infinity)
.contentShape(Rectangle())
HStack(spacing: 6) {
if let hint = currentHint {
Image(systemName: hint.visualKind.systemImage)
.font(.system(size: 14, weight: .semibold))
.foregroundStyle(palette.textPrimary.opacity(0.55))
}
Text(currentHint.map(\.resolvedDisplayText) ?? ExtL10n.string("keyboard.ai.placeholder"))
.font(TypeStyle.bodyEmph)
.foregroundStyle(palette.textPrimary)
.lineLimit(1)
.truncationMode(.tail)
}
.padding(.horizontal, 16)
.frame(height: 44)
.opacity(hintOpacity)
.glassEffect(.regular.interactive(), in: Capsule())
}
.buttonStyle(.plain)
.fixedSize()
// A busy session already owns the surface; the status line explains a
// missing LLM. Both keep the hint from being a tap with no outcome.
.disabled(currentHint == nil || !state.aiServiceAvailable || state.aiSession.isBusy)
.accessibilityLabel(
Text(
currentHint.map {
"\(ExtL10n.string("keyboard.ai.hintA11yPrefix"))\($0.displayText)"
"\(ExtL10n.string("keyboard.ai.hintA11yPrefix"))\($0.resolvedDisplayText)"
} ?? ExtL10n.string("keyboard.ai.placeholder")
)
)
}
private var clipboardSkillRow: some View {
HStack(spacing: Spacing.lg) {
ForEach(AIClipboardSkillCatalog.visible()) { 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)
}
}
.buttonStyle(.plain)
.disabled(!state.aiServiceAvailable || state.aiSession.isBusy)
.accessibilityLabel(Text(clipboardSkillTitle(skill)))
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
/// Translate follows the keyboard target; Reply / Summarize stay static.
private func clipboardSkillTitle(_ skill: AIClipboardSkill) -> String {
if skill.id == AIClipboardSkillCatalog.translateID {
return AIClipboardSkillCatalog.translateButtonTitle(
translationTargetLocaleId: state.translationTargetLocaleId,
uiLanguage: AppGroupStore().uiLanguage
)
}
return ExtL10n.string(skill.titleKey)
}
/// Copy-then-30s window: skill chips replace the rotating hint.
private var showsClipboardSkills: Bool {
guard showsPlaceholder, state.clipboardHistoryEnabled else { return false }
return AIHintPool.isClipboardSkillWindowActive(
clipboardHistoryEnabled: true,
newestClipboard: clipboardHistory.newestEntry
)
}
/// No draft/answer yet show the centered hint carousel instead of a scroll body.
private var showsPlaceholder: Bool {
let hasDraft = !(state.aiSession.draftAnswerText?.isEmpty ?? true)
@@ -367,16 +426,20 @@ struct AIKeyboardView: View {
? ExtL10n.string("keyboard.ai.listening")
: state.aiSession.transcript
case .recognizing:
return state.aiSession.transcript.isEmpty
? ExtL10n.string("keyboard.ai.recognizing")
: state.aiSession.transcript
if state.aiSession.transcript.isEmpty
|| AIClipboardPrompt.isInternalPrompt(state.aiSession.transcript) {
return ExtL10n.string("keyboard.ai.recognizing")
}
return state.aiSession.transcript
case .generating:
if let draft = state.aiSession.draftAnswerText, !draft.isEmpty {
return ExtL10n.string("keyboard.ai.generating")
}
return state.aiSession.transcript.isEmpty
? ExtL10n.string("keyboard.ai.thinking")
: state.aiSession.transcript
if state.aiSession.transcript.isEmpty
|| AIClipboardPrompt.isInternalPrompt(state.aiSession.transcript) {
return ExtL10n.string("keyboard.ai.thinking")
}
return state.aiSession.transcript
case .failed:
return ExtL10n.string("keyboard.ai.error.requestFailed")
}
@@ -391,19 +454,18 @@ struct AIKeyboardView: View {
// MARK: - Carousel
/// Rebuild the pool and show a card right away, without a fade.
/// Skip advancing the chip while clipboard skills own the surface, so a
/// leftover clipboard sentence cannot replace the three buttons.
private func resetCarousel() {
reloadHintPool(resetBag: true)
guard !showsClipboardSkills else { return }
showNextHint(animated: false)
}
private func reloadHintPool(resetBag: Bool) {
let locale = AIHintLocaleResolver.packLocale()
let pack = AIHintStore.resolvedPack(locale: locale)
poolCards = AIHintPool.activeCards(
pack: pack,
clipboardHistoryEnabled: state.clipboardHistoryEnabled,
newestClipboard: clipboardHistory.newestEntry
)
poolCards = AIHintPool.activeCards(pack: pack)
if resetBag {
carouselBag.reset()
}
+4 -3
View File
@@ -22,8 +22,8 @@ import OSGKeyboardShared
private enum KeyboardLayoutMetrics {
static let micSize: CGFloat = 121
static let micToButtonGap: CGFloat = 8
/// Square undo key beside the mic (outer edge, aligned with delete).
static let undoButtonSize: CGFloat = 44
/// Circular undo / translation keys beside the mic (outer edge).
static let undoButtonSize: CGFloat = 52
static let bottomActionRowHeight: CGFloat = KeyboardChromeLayout.actionKeyHeight
static let bottomActionSpacing: CGFloat = KeyboardChromeLayout.actionKeySpacing
/// Gap between the top control row and the transcript / hint line.
@@ -419,7 +419,7 @@ public struct KeyboardRootView: View {
.frame(height: KeyboardLayoutMetrics.bottomActionRowHeight)
}
/// Square undo key on the outer drag pad same chrome / haptic / click
/// Circular undo key on the outer drag pad same chrome / haptic / click
/// as space & return. Vertically matches the mic disc.
private func undoButton(disabled: Bool, visible: Bool) -> some View {
RectangularToolbarButton(
@@ -427,6 +427,7 @@ public struct KeyboardRootView: View {
label: ExtL10n.string("keyboard.undoA11y"),
disabled: disabled,
usesLiquidGlass: true,
usesCircleGlass: true,
hapticIntensity: state.keyboardHapticIntensity
) {
state.undoLastInsertion()
+3 -10
View File
@@ -280,25 +280,18 @@ struct KeyboardTranslationMenuButton: View, Equatable {
}
}
} label: {
// Match the adjacent undo key: 44×44 rounded Liquid Glass control.
ZStack {
Color.clear
Image(systemName: isEnabled ? "character.bubble.fill" : "character.bubble")
.font(.system(size: 14, weight: .semibold))
.font(.system(size: 17, weight: .semibold))
.foregroundStyle(
isEnabled
? palette.accent
: palette.textSecondary
)
}
.contentShape(Rectangle())
.glassEffect(
.regular.interactive(),
in: RoundedRectangle(
cornerRadius: KeyboardChromeLayout.actionKeyCornerRadius,
style: .continuous
)
)
.contentShape(Circle())
.glassEffect(.regular.interactive(), in: Circle())
}
.menuStyle(.button)
.accessibilityLabel(Text(SharedL10n.string("keyboard.translation.a11y")))
@@ -10,6 +10,8 @@ import OSGKeyboardShared
private enum ToolbarButtonMetrics {
static let iconSize: CGFloat = 14
/// Slightly larger glyph so a 52 pt circular glass key does not look sparse.
static let circleIconSize: CGFloat = 17
static let titleSize: CGFloat = 16
static let cornerRadius: CGFloat = KeyboardChromeLayout.actionKeyCornerRadius
static let spaceBarCapsuleWidth: CGFloat = 31
@@ -201,6 +203,7 @@ struct RectangularToolbarButton: View {
let disabled: Bool
let isSend: Bool
let usesLiquidGlass: Bool
let usesCircleGlass: Bool
/// Settings General Haptics; space / return use `.action` role.
var hapticIntensity: KeyboardHapticIntensity = .off
let action: () -> Void
@@ -210,6 +213,7 @@ struct RectangularToolbarButton: View {
label: String,
disabled: Bool = false,
usesLiquidGlass: Bool = false,
usesCircleGlass: Bool = false,
hapticIntensity: KeyboardHapticIntensity = .off,
action: @escaping () -> Void
) {
@@ -220,6 +224,7 @@ struct RectangularToolbarButton: View {
self.disabled = disabled
self.isSend = false
self.usesLiquidGlass = usesLiquidGlass
self.usesCircleGlass = usesCircleGlass
self.hapticIntensity = hapticIntensity
self.action = action
}
@@ -239,6 +244,7 @@ struct RectangularToolbarButton: View {
self.disabled = disabled
self.isSend = isSend
self.usesLiquidGlass = usesLiquidGlass
self.usesCircleGlass = false
self.hapticIntensity = hapticIntensity
self.action = action
self.title = title
@@ -259,6 +265,7 @@ struct RectangularToolbarButton: View {
self.disabled = disabled
self.isSend = false
self.usesLiquidGlass = usesLiquidGlass
self.usesCircleGlass = false
self.hapticIntensity = hapticIntensity
self.action = action
}
@@ -267,7 +274,7 @@ struct RectangularToolbarButton: View {
var body: some View {
buttonSurface
.contentShape(Rectangle())
.contentShape(usesCircleGlass ? AnyShape(Circle()) : AnyShape(Rectangle()))
.gesture(pressGesture)
.opacity(disabled ? 0.38 : 1)
.allowsHitTesting(!disabled)
@@ -282,13 +289,7 @@ struct RectangularToolbarButton: View {
Color.clear
buttonContent
}
.glassEffect(
.regular.interactive(),
in: RoundedRectangle(
cornerRadius: ToolbarButtonMetrics.cornerRadius,
style: .continuous
)
)
.modifier(ToolbarLiquidGlass(isCircle: usesCircleGlass))
// The custom press gesture fires on touch-down; mirror that state
// visually while Liquid Glass supplies its native light response.
.scaleEffect(isPressing ? 0.97 : 1)
@@ -312,7 +313,12 @@ struct RectangularToolbarButton: View {
.frame(width: ToolbarButtonMetrics.spaceBarCapsuleWidth, height: 3)
} else if let systemName {
Image(systemName: systemName)
.font(.system(size: ToolbarButtonMetrics.iconSize, weight: .semibold))
.font(.system(
size: usesCircleGlass
? ToolbarButtonMetrics.circleIconSize
: ToolbarButtonMetrics.iconSize,
weight: .semibold
))
.foregroundStyle(buttonForeground)
} else if let title {
Text(title)
@@ -340,3 +346,21 @@ struct RectangularToolbarButton: View {
}
}
}
private struct ToolbarLiquidGlass: ViewModifier {
let isCircle: Bool
func body(content: Content) -> some View {
if isCircle {
content.glassEffect(.regular.interactive(), in: Circle())
} else {
content.glassEffect(
.regular.interactive(),
in: RoundedRectangle(
cornerRadius: ToolbarButtonMetrics.cornerRadius,
style: .continuous
)
)
}
}
}