feat(keyboard): unify assistant voice and AI workflows

Merge dictation and AI controls into one assistant surface, preserve safe insertion and clipboard actions, and align settings and tests with the new flow.
This commit is contained in:
Rocky
2026-08-16 12:07:06 +08:00
parent 46f6d818b8
commit fb97ec2937
45 changed files with 1969 additions and 2143 deletions
+22 -43
View File
@@ -2,7 +2,7 @@
// OSGKeyboard · Keyboard Extension
//
// Principal class for the Custom Keyboard Extension. Hosts a single
// SwiftUI tree (`KeyboardRootView`) and wires Flow voice input:
// SwiftUI tree (`KeyboardSurfaceRoot`) and wires Flow voice / AI input:
//
// host app Flow session App Group transcript insertText
//
@@ -71,10 +71,6 @@ public final class KeyboardViewController: UIInputViewController {
private var aiKeyboardCoordinator: AIKeyboardCoordinator!
private var clipboardCapture: ClipboardCaptureCoordinator!
private var configSync: KeyboardConfigSync!
/// UIKit may synchronously lay out the view during `viewDidLoad`.
/// Keep this optional so an early layout pass is harmless.
private var cursorDrag: CursorDragController?
/// iPad-scale keys require both an iPad host and regular horizontal space.
/// This keeps compact iPad multitasking on phone metrics and prevents wide
/// iPhones from being mistaken for iPads.
@@ -191,10 +187,8 @@ public final class KeyboardViewController: UIInputViewController {
if state.surface == .typing {
TypingInputConfiguration.persistLastTypingLanguage(typingSession.language)
}
if state.surface == .ai {
// AI context never survives a keyboard presentation, but the
// selected surface itself is restored on the next open.
TypingInputConfiguration.persistLastSurface(.ai)
if state.aiSession.isActive {
// Unified assistant conversations never survive a keyboard presentation.
aiKeyboardCoordinator.leave()
}
if !preserve {
@@ -353,7 +347,6 @@ public final class KeyboardViewController: UIInputViewController {
// portraitlandscape resize. `refreshLayoutMode` no-ops unless the
// layout bucket actually changed, so this cannot loop.
refreshLayoutMode()
cursorDrag?.layoutChrome()
enforcePresentedKeyboardHeightIfNeeded()
logLayoutSnapshotIfChanged()
}
@@ -428,6 +421,9 @@ public final class KeyboardViewController: UIInputViewController {
},
performReturn: { [weak self] in
self?.textDocumentProxy.insertText("\n")
},
captureInsertionFingerprint: { [weak self] in
self?.captureFieldContext().deliveryFingerprint
}
)
clipboardCapture = ClipboardCaptureCoordinator(state: state)
@@ -474,13 +470,6 @@ public final class KeyboardViewController: UIInputViewController {
self?.state.aiSession.cancelCurrentWork()
}
_ = textInserter.recoverPendingEditTransactionIfNeeded()
cursorDrag = CursorDragController(
state: state,
adjustTextPosition: { [weak self] offset in
self?.textDocumentProxy.adjustTextPosition(byCharacterOffset: offset)
}
)
}
// MARK: - Wiring
@@ -513,8 +502,14 @@ public final class KeyboardViewController: UIInputViewController {
state.cancelAIInput = { [weak self] in
self?.aiKeyboardCoordinator.cancel()
}
state.sendAIAnswer = { [weak self] in
self?.aiKeyboardCoordinator.sendLatestAnswer()
state.confirmPendingAIAnswer = { [weak self] in
self?.aiKeyboardCoordinator.confirmPendingAnswer()
}
state.discardPendingAIAnswer = { [weak self] in
self?.aiKeyboardCoordinator.discardPendingAnswer()
}
state.sendAssistantAction = { [weak self] in
self?.aiKeyboardCoordinator.sendCurrentFieldAction()
}
state.submitAIHint = { [weak self] card in
self?.aiKeyboardCoordinator.submitHintCard(card)
@@ -567,6 +562,7 @@ public final class KeyboardViewController: UIInputViewController {
state.setEngineMode = { [weak self] m in self?.configSync.persistEngineMode(m) }
state.setTranslationTargetLocaleId = { [weak self] id in
self?.configSync.persistTranslationTargetLocaleId(id)
self?.aiKeyboardCoordinator.resetConversationForConfigurationChange()
}
state.insertNewline = { [weak self] in self?.textDocumentProxy.insertText("\n") }
state.insertSpace = { [weak self] in self?.textDocumentProxy.insertText(" ") }
@@ -575,15 +571,6 @@ public final class KeyboardViewController: UIInputViewController {
state.redoLastInsertion = { [weak self] in self?.textInserter.redoLastInsertion() }
state.copySelection = { [weak self] in self?.textInserter.copySelection() }
state.cutSelection = { [weak self] in self?.textInserter.cutSelection() }
state.moveCursorHorizontal = { [weak self] steps in
self?.cursorDrag?.moveCursorHorizontally(by: steps)
}
state.moveCursorVertical = { [weak self] steps in
self?.cursorDrag?.moveCursorVertically(by: steps)
}
state.setCursorDragActive = { [weak self] active in
self?.cursorDrag?.setCursorDragActive(active)
}
state.setSurface = { [weak self] surface in
self?.applySurface(surface)
}
@@ -608,7 +595,10 @@ public final class KeyboardViewController: UIInputViewController {
.store(in: &cancellables)
}
private func applySurface(_ surface: State.Surface) {
private func applySurface(_ requestedSurface: State.Surface) {
// `.ai` is retained only to decode preferences written by older builds.
// The product now has one unified assistant surface.
let surface: State.Surface = requestedSurface == .ai ? .voice : requestedSurface
if surface == .typing, state.locksTypingSurface {
OSGDiag.log("applySurface blocked typing (locksTypingSurface)", category: "boot")
return
@@ -618,9 +608,6 @@ public final class KeyboardViewController: UIInputViewController {
return
}
guard state.surface != surface else {
if surface == .ai {
aiKeyboardCoordinator.enterIfNeeded()
}
refreshKeyboardHeight()
return
}
@@ -628,10 +615,6 @@ public final class KeyboardViewController: UIInputViewController {
"applySurface \(state.surface.rawValue)\(surface.rawValue) \(OSGDiag.memoryTag())",
category: "boot"
)
let previousSurface = state.surface
if previousSurface == .ai, surface != .ai {
aiKeyboardCoordinator.leave()
}
state.surface = surface
if surface == .typing {
typingSession.enterTypingMode()
@@ -639,9 +622,6 @@ public final class KeyboardViewController: UIInputViewController {
} else {
typingSession.leaveTypingMode()
}
if surface == .ai {
aiKeyboardCoordinator.enterIfNeeded()
}
refreshKeyboardHeight()
}
@@ -662,9 +642,6 @@ public final class KeyboardViewController: UIInputViewController {
if resolved == .typing, let language = preference.typingLanguage {
_ = typingSession.setLanguage(language)
}
if resolved == .ai {
aiKeyboardCoordinator.beginNewPresentation()
}
}
/// When not remembering, snap to the static open preference while hidden
@@ -735,6 +712,9 @@ public final class KeyboardViewController: UIInputViewController {
private func refreshReturnKeyRole() {
state.returnKeyRole = returnKeyRole(for: textDocumentProxy.returnKeyType ?? .default)
if !state.returnKeyRole.usesActionFill {
state.assistantSendAvailable = false
}
let isSecure = textDocumentProxy.isSecureTextEntry ?? false
state.setSecureTextEntry(isSecure)
clipboardCapture?.secureEntryDidChange(isSecure: isSecure)
@@ -925,7 +905,6 @@ public final class KeyboardViewController: UIInputViewController {
])
host.didMove(toParent: self)
hosting = host
cursorDrag?.install(on: view)
}
// MARK: - App context