feat(keyboard): ship AI hint carousel, home library cards, and clipboard polish

Rotate AI idle suggestions with optional remote packs, move history/dictionary onto self-sizing Home preview cards, harden clipboard capture/prompting, and simplify keyboard chrome by dropping most liquid-glass shadows.
This commit is contained in:
Rocky
2026-08-13 01:00:51 +08:00
parent fd6e0d3e7e
commit 9f308fadd2
202 changed files with 10897 additions and 5962 deletions
+111 -25
View File
@@ -1,8 +1,8 @@
// AIKeyboardView.swift
// OSGKeyboard · Keyboard Extension
//
// Temporary voice-to-AI surface. The latest answer remains visible while a
// follow-up is running and is inserted only through the explicit Send action.
// Product voice-to-AI conversation surface. The latest answer remains visible
// while a follow-up runs and is inserted only through the explicit Send action.
import SwiftUI
import OSGKeyboardShared
@@ -14,13 +14,23 @@ struct AIKeyboardView: View {
static let actionButtonHeight: CGFloat = 50
static let actionButtonMaxWidth: CGFloat = 150
static let statusHeight: CGFloat = 20
static let carouselInterval: TimeInterval = 4
}
@Environment(\.colorScheme) private var colorScheme
@Environment(\.accessibilityReduceMotion) private var reduceMotion
@ObservedObject var state: KeyboardState
@ObservedObject var typing: TypingSessionController
/// A copy made while the keyboard is visible must reach the carousel
/// immediately, not on the next rotation tick.
@ObservedObject private var clipboardHistory = ClipboardHistoryStore.shared
let onInsert: (String) -> Void
@State private var currentHint: AIHintCard?
@State private var hintOpacity: Double = 1
@State private var carouselBag = AIHintCarouselBag()
@State private var poolCards: [AIHintCard] = []
private var palette: ThemePalette {
colorScheme == .dark ? Palette.dark : Palette.light
}
@@ -37,6 +47,26 @@ struct AIKeyboardView: View {
.frame(maxWidth: .infinity)
.frame(height: resolvedHeight)
.environment(\.themePalette, palette)
.onAppear { resetCarousel() }
.onChange(of: state.aiSession.phase) { _, phase in
guard phase == .idle || phase == .failed else { return }
resetCarousel()
}
.onChange(of: state.clipboardHistoryEnabled) { _, _ in resetCarousel() }
.onChange(of: clipboardHistory.entries.first?.id) { _, _ in resetCarousel() }
.onReceive(
Timer.publish(every: Layout.carouselInterval, on: .main, in: .common).autoconnect()
) { _ in
guard showsPlaceholder else { return }
// Reduce Motion stops the rotation, not the data: a card whose
// clipboard window has closed must still leave the carousel.
reloadHintPool(resetBag: false)
if reduceMotion, let hint = currentHint,
poolCards.contains(where: { $0.id == hint.id }) {
return
}
showNextHint(animated: !reduceMotion)
}
}
private var resolvedHeight: CGFloat {
@@ -69,7 +99,9 @@ struct AIKeyboardView: View {
)
}
.padding(.horizontal, KeyboardTopBarMetrics.nestedHorizontalInset)
} else if let suggestion = state.clipboardSuggestionText, !suggestion.isEmpty {
} else if state.canShowClipboardEntry,
let suggestion = state.clipboardSuggestionText,
!suggestion.isEmpty {
// Replaces logo + capsule tabs until dismissed.
ClipboardSuggestionBar(
text: suggestion,
@@ -95,12 +127,7 @@ struct AIKeyboardView: View {
private var answerArea: some View {
ZStack(alignment: .bottom) {
if showsPlaceholder {
// Empty-state tip: geometric center of the answer plane.
Text(ExtL10n.string("keyboard.ai.placeholder"))
.font(TypeStyle.body)
.foregroundStyle(palette.textTertiary)
.multilineTextAlignment(.center)
.padding(.horizontal, Spacing.md)
hintCarousel
.frame(maxWidth: .infinity, maxHeight: .infinity)
} else {
ScrollViewReader { proxy in
@@ -141,7 +168,36 @@ struct AIKeyboardView: View {
}
}
/// No draft/answer yet show the centered mic guidance instead of a scroll body.
private var hintCarousel: some View {
Button {
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())
}
.buttonStyle(.plain)
// 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.placeholder")
)
)
}
/// 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)
return !hasDraft && state.aiSession.answer == nil
@@ -173,7 +229,7 @@ struct AIKeyboardView: View {
private var aiMicrophoneButton: some View {
Button(action: state.tapAIMic) {
ZStack {
Capsule().fill(palette.accent)
Color.clear
if state.aiSession.phase == .listening {
Capsule()
.stroke(Color.white.opacity(0.28), lineWidth: 1.5)
@@ -187,6 +243,8 @@ struct AIKeyboardView: View {
minHeight: Layout.actionButtonHeight,
maxHeight: Layout.actionButtonHeight
)
//
.background(palette.accent, in: Capsule())
.contentShape(Capsule())
}
.buttonStyle(.plain)
@@ -228,11 +286,8 @@ struct AIKeyboardView: View {
minHeight: Layout.actionButtonHeight,
maxHeight: Layout.actionButtonHeight
)
.background(
answerActionFill,
in: Capsule()
)
.overlay(Capsule().stroke(answerActionBorder, lineWidth: 0.5))
//
.background(answerActionFill, in: Capsule())
.contentShape(Capsule())
}
.buttonStyle(.plain)
@@ -269,11 +324,11 @@ struct AIKeyboardView: View {
private var answerActionFill: Color {
guard state.aiSession.canPerformAnswerAction else {
return palette.surfaceElevated
return palette.surfaceElevated.opacity(0.55)
}
return state.aiSession.canSend
? palette.accent
: NativeKeyboardKeyColors.fill(for: colorScheme)
: palette.surfaceElevated
}
private var answerActionForeground: Color {
@@ -285,13 +340,6 @@ struct AIKeyboardView: View {
: NativeKeyboardKeyColors.text(for: colorScheme)
}
private var answerActionBorder: Color {
guard state.aiSession.canSend else {
return palette.divider
}
return Color.black.opacity(colorScheme == .dark ? 0.10 : 0.08)
}
private var microphoneDisabled: Bool {
switch state.aiSession.phase {
case .preparing, .recognizing, .generating:
@@ -339,4 +387,42 @@ struct AIKeyboardView: View {
? "keyboard.ai.stopA11y"
: "keyboard.ai.startA11y"
}
// MARK: - Carousel
/// Rebuild the pool and show a card right away, without a fade.
private func resetCarousel() {
reloadHintPool(resetBag: true)
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
)
if resetBag {
carouselBag.reset()
}
}
private func showNextHint(animated: Bool) {
guard let next = carouselBag.next(from: poolCards) else {
currentHint = nil
return
}
if animated, !reduceMotion {
withAnimation(Motion.soft) { hintOpacity = 0 }
DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) {
currentHint = next
withAnimation(Motion.soft) { hintOpacity = 1 }
}
} else {
currentHint = next
hintOpacity = 1
}
}
}