fix(keyboard): synchronize adaptive field actions
Enable the correct host action immediately after keyboard edits and advance the shared build number to 75.
This commit is contained in:
@@ -12,6 +12,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
- **Unified assistant keyboard**: merge Voice and AI into one Assistant tab with tap-to-dictate, hold-to-ask-AI, a liquid-glass capsule microphone, contextual hotwords, one-row paged clipboard skills, and shared Send / undo / edit actions; delete, space, undo, and edit remain available while clipboard skills are visible. / **统一助手键盘**:将语音与 AI 合并为一个助手入口,支持轻点听写、长按问 AI、液态玻璃胶囊麦克风、情境热词、单行分页剪贴板技能,以及共用的发送 / 撤销 / 编辑操作;剪贴板技能出现时仍保留删除、空格、撤销与编辑按钮。
|
- **Unified assistant keyboard**: merge Voice and AI into one Assistant tab with tap-to-dictate, hold-to-ask-AI, a liquid-glass capsule microphone, contextual hotwords, one-row paged clipboard skills, and shared Send / undo / edit actions; delete, space, undo, and edit remain available while clipboard skills are visible. / **统一助手键盘**:将语音与 AI 合并为一个助手入口,支持轻点听写、长按问 AI、液态玻璃胶囊麦克风、情境热词、单行分页剪贴板技能,以及共用的发送 / 撤销 / 编辑操作;剪贴板技能出现时仍保留删除、空格、撤销与编辑按钮。
|
||||||
|
- **Adaptive field action**: the assistant action now follows the focused field’s current content and Return semantics, refreshes immediately after keyboard-generated edits, and shows Send, Search, Go, Done, Next, newline, and other matching icons. / **自适应输入框动作**:助手动作现根据焦点输入框的当前内容与 Return 语义显示发送、搜索、前往、完成、下一步、换行等对应图标,并在键盘主动编辑后立即刷新。
|
||||||
- **Clipboard setup guidance**: replace the Skills permission wall with a compact next-step card that enables history inline, verifies paste access, hides completed steps, and shortens Clipboard settings copy. / **剪贴板设置指引**:技能页权限墙改为紧凑的下一步卡片,可直接开启历史、验证粘贴访问并隐藏已完成步骤,同时精简剪贴板设置文案。
|
- **Clipboard setup guidance**: replace the Skills permission wall with a compact next-step card that enables history inline, verifies paste access, hides completed steps, and shortens Clipboard settings copy. / **剪贴板设置指引**:技能页权限墙改为紧凑的下一步卡片,可直接开启历史、验证粘贴访问并隐藏已完成步骤,同时精简剪贴板设置文案。
|
||||||
- **AI output language**: apply the global translation target to AI answers while letting an explicit language request override it and preserving source-language structured export data. / **AI 输出语言**:AI 回答遵循全局翻译目标,但明确的语言请求优先,结构化导出数据保留源语言。
|
- **AI output language**: apply the global translation target to AI answers while letting an explicit language request override it and preserving source-language structured export data. / **AI 输出语言**:AI 回答遵循全局翻译目标,但明确的语言请求优先,结构化导出数据保留源语言。
|
||||||
|
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ struct AssistantKeyboardUITestHarness: View {
|
|||||||
case pending
|
case pending
|
||||||
case skillFailure
|
case skillFailure
|
||||||
case skills
|
case skills
|
||||||
|
case search
|
||||||
}
|
}
|
||||||
|
|
||||||
@StateObject private var state = KeyboardState()
|
@StateObject private var state = KeyboardState()
|
||||||
@@ -96,8 +97,8 @@ struct AssistantKeyboardUITestHarness: View {
|
|||||||
keyboardState.aiSession.beginPreparing(utteranceID: utteranceID)
|
keyboardState.aiSession.beginPreparing(utteranceID: utteranceID)
|
||||||
keyboardState.aiSession.beginListening(utteranceID: utteranceID)
|
keyboardState.aiSession.beginListening(utteranceID: utteranceID)
|
||||||
}
|
}
|
||||||
state.sendAssistantAction = { [weak keyboardState] in
|
state.performAssistantFieldAction = { [weak keyboardState] in
|
||||||
keyboardState?.assistantSendAvailable = false
|
keyboardState?.assistantActionAvailable = false
|
||||||
}
|
}
|
||||||
state.undoLastInsertion = { [weak keyboardState] in
|
state.undoLastInsertion = { [weak keyboardState] in
|
||||||
keyboardState?.undoAvailable = false
|
keyboardState?.undoAvailable = false
|
||||||
@@ -149,7 +150,7 @@ struct AssistantKeyboardUITestHarness: View {
|
|||||||
AIKeyboardView.debugPreviewSkills = nil
|
AIKeyboardView.debugPreviewSkills = nil
|
||||||
state.undoAvailable = true
|
state.undoAvailable = true
|
||||||
state.editAvailable = true
|
state.editAvailable = true
|
||||||
state.assistantSendAvailable = true
|
state.assistantActionAvailable = true
|
||||||
case .pending:
|
case .pending:
|
||||||
AIKeyboardView.debugPreviewSkills = nil
|
AIKeyboardView.debugPreviewSkills = nil
|
||||||
let utteranceID = UUID()
|
let utteranceID = UUID()
|
||||||
@@ -164,7 +165,7 @@ struct AssistantKeyboardUITestHarness: View {
|
|||||||
keyboardState.aiSession.markAnswerInserted(offersSend: true)
|
keyboardState.aiSession.markAnswerInserted(offersSend: true)
|
||||||
keyboardState.undoAvailable = true
|
keyboardState.undoAvailable = true
|
||||||
keyboardState.editAvailable = true
|
keyboardState.editAvailable = true
|
||||||
keyboardState.assistantSendAvailable = true
|
keyboardState.assistantActionAvailable = true
|
||||||
}
|
}
|
||||||
state.discardPendingAIAnswer = { [weak keyboardState] in
|
state.discardPendingAIAnswer = { [weak keyboardState] in
|
||||||
keyboardState?.aiSession.discardReadyAnswer()
|
keyboardState?.aiSession.discardReadyAnswer()
|
||||||
@@ -176,6 +177,10 @@ struct AssistantKeyboardUITestHarness: View {
|
|||||||
AIKeyboardView.debugPreviewSkills = AIClipboardSkillCatalog.catalog
|
AIKeyboardView.debugPreviewSkills = AIClipboardSkillCatalog.catalog
|
||||||
state.undoAvailable = true
|
state.undoAvailable = true
|
||||||
state.editAvailable = true
|
state.editAvailable = true
|
||||||
|
case .search:
|
||||||
|
AIKeyboardView.debugPreviewSkills = nil
|
||||||
|
state.returnKeyRole = .search
|
||||||
|
state.assistantActionAvailable = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -63,6 +63,8 @@ public final class KeyboardViewController: UIInputViewController {
|
|||||||
/// changes instead of every pass.
|
/// changes instead of every pass.
|
||||||
private var lastLoggedLayoutSnapshot: String?
|
private var lastLoggedLayoutSnapshot: String?
|
||||||
private var cancellables = Set<AnyCancellable>()
|
private var cancellables = Set<AnyCancellable>()
|
||||||
|
/// Coalesces host-document refreshes after mutations issued by this keyboard.
|
||||||
|
private var assistantFieldActionRefreshTask: Task<Void, Never>?
|
||||||
|
|
||||||
private var editHintScheduler: EditHintScheduler!
|
private var editHintScheduler: EditHintScheduler!
|
||||||
private var textInserter: KeyboardTextInserter!
|
private var textInserter: KeyboardTextInserter!
|
||||||
@@ -166,6 +168,8 @@ public final class KeyboardViewController: UIInputViewController {
|
|||||||
|
|
||||||
public override func viewWillDisappear(_ animated: Bool) {
|
public override func viewWillDisappear(_ animated: Bool) {
|
||||||
super.viewWillDisappear(animated)
|
super.viewWillDisappear(animated)
|
||||||
|
assistantFieldActionRefreshTask?.cancel()
|
||||||
|
assistantFieldActionRefreshTask = nil
|
||||||
clipboardCapture?.keyboardWillDisappear()
|
clipboardCapture?.keyboardWillDisappear()
|
||||||
// Presentation-scoped hints must never survive a reused extension
|
// Presentation-scoped hints must never survive a reused extension
|
||||||
// controller, including an active Flow handoff.
|
// controller, including an active Flow handoff.
|
||||||
@@ -274,16 +278,16 @@ public final class KeyboardViewController: UIInputViewController {
|
|||||||
state: state,
|
state: state,
|
||||||
host: WhatsNewDemoDriver.HostHooks(
|
host: WhatsNewDemoDriver.HostHooks(
|
||||||
insertText: { [weak self] text in
|
insertText: { [weak self] text in
|
||||||
self?.textDocumentProxy.insertText(text)
|
self?.insertTextIntoDocument(text)
|
||||||
},
|
},
|
||||||
deleteBackward: { [weak self] in
|
deleteBackward: { [weak self] in
|
||||||
self?.textDocumentProxy.deleteBackward()
|
self?.deleteBackwardFromDocument()
|
||||||
},
|
},
|
||||||
contextBeforeInput: { [weak self] in
|
contextBeforeInput: { [weak self] in
|
||||||
self?.textDocumentProxy.documentContextBeforeInput
|
self?.textDocumentProxy.documentContextBeforeInput
|
||||||
},
|
},
|
||||||
performReturn: { [weak self] in
|
performReturn: { [weak self] in
|
||||||
self?.textDocumentProxy.insertText("\n")
|
self?.performDocumentReturn()
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
@@ -304,6 +308,7 @@ public final class KeyboardViewController: UIInputViewController {
|
|||||||
|
|
||||||
public override func selectionDidChange(_ textInput: UITextInput?) {
|
public override func selectionDidChange(_ textInput: UITextInput?) {
|
||||||
super.selectionDidChange(textInput)
|
super.selectionDidChange(textInput)
|
||||||
|
refreshReturnKeyRole()
|
||||||
typingSession.synchronizeEnglishDocumentContext(caretMoved: true)
|
typingSession.synchronizeEnglishDocumentContext(caretMoved: true)
|
||||||
textInserter?.refreshEditingAvailability()
|
textInserter?.refreshEditingAvailability()
|
||||||
lastInputEditCoordinator?.refreshContext()
|
lastInputEditCoordinator?.refreshContext()
|
||||||
@@ -357,8 +362,8 @@ public final class KeyboardViewController: UIInputViewController {
|
|||||||
editHintScheduler = EditHintScheduler(state: state)
|
editHintScheduler = EditHintScheduler(state: state)
|
||||||
textInserter = KeyboardTextInserter(
|
textInserter = KeyboardTextInserter(
|
||||||
state: state,
|
state: state,
|
||||||
insertText: { [weak self] text in self?.textDocumentProxy.insertText(text) },
|
insertText: { [weak self] text in self?.insertTextIntoDocument(text) },
|
||||||
deleteBackward: { [weak self] in self?.textDocumentProxy.deleteBackward() },
|
deleteBackward: { [weak self] in self?.deleteBackwardFromDocument() },
|
||||||
contextBeforeInput: { [weak self] in self?.textDocumentProxy.documentContextBeforeInput },
|
contextBeforeInput: { [weak self] in self?.textDocumentProxy.documentContextBeforeInput },
|
||||||
fieldContextProvider: { [weak self] in self?.captureFieldContext() },
|
fieldContextProvider: { [weak self] in self?.captureFieldContext() },
|
||||||
selectedText: { [weak self] in self?.textDocumentProxy.selectedText },
|
selectedText: { [weak self] in self?.textDocumentProxy.selectedText },
|
||||||
@@ -420,7 +425,7 @@ public final class KeyboardViewController: UIInputViewController {
|
|||||||
self?.textInserter.insertAIAnswer(answer) ?? false
|
self?.textInserter.insertAIAnswer(answer) ?? false
|
||||||
},
|
},
|
||||||
performReturn: { [weak self] in
|
performReturn: { [weak self] in
|
||||||
self?.textDocumentProxy.insertText("\n")
|
self?.performDocumentReturn()
|
||||||
},
|
},
|
||||||
captureInsertionFingerprint: { [weak self] in
|
captureInsertionFingerprint: { [weak self] in
|
||||||
self?.captureFieldContext().deliveryFingerprint
|
self?.captureFieldContext().deliveryFingerprint
|
||||||
@@ -508,8 +513,8 @@ public final class KeyboardViewController: UIInputViewController {
|
|||||||
state.discardPendingAIAnswer = { [weak self] in
|
state.discardPendingAIAnswer = { [weak self] in
|
||||||
self?.aiKeyboardCoordinator.discardPendingAnswer()
|
self?.aiKeyboardCoordinator.discardPendingAnswer()
|
||||||
}
|
}
|
||||||
state.sendAssistantAction = { [weak self] in
|
state.performAssistantFieldAction = { [weak self] in
|
||||||
self?.aiKeyboardCoordinator.sendCurrentFieldAction()
|
self?.aiKeyboardCoordinator.performCurrentFieldAction()
|
||||||
}
|
}
|
||||||
state.submitAIHint = { [weak self] card in
|
state.submitAIHint = { [weak self] card in
|
||||||
self?.aiKeyboardCoordinator.submitHintCard(card)
|
self?.aiKeyboardCoordinator.submitHintCard(card)
|
||||||
@@ -564,9 +569,9 @@ public final class KeyboardViewController: UIInputViewController {
|
|||||||
self?.configSync.persistTranslationTargetLocaleId(id)
|
self?.configSync.persistTranslationTargetLocaleId(id)
|
||||||
self?.aiKeyboardCoordinator.resetConversationForConfigurationChange()
|
self?.aiKeyboardCoordinator.resetConversationForConfigurationChange()
|
||||||
}
|
}
|
||||||
state.insertNewline = { [weak self] in self?.textDocumentProxy.insertText("\n") }
|
state.insertNewline = { [weak self] in self?.insertTextIntoDocument("\n") }
|
||||||
state.insertSpace = { [weak self] in self?.textDocumentProxy.insertText(" ") }
|
state.insertSpace = { [weak self] in self?.insertTextIntoDocument(" ") }
|
||||||
state.deleteBackward = { [weak self] in self?.textDocumentProxy.deleteBackward() }
|
state.deleteBackward = { [weak self] in self?.deleteBackwardFromDocument() }
|
||||||
state.undoLastInsertion = { [weak self] in self?.textInserter.undoLastInsertion() }
|
state.undoLastInsertion = { [weak self] in self?.textInserter.undoLastInsertion() }
|
||||||
state.redoLastInsertion = { [weak self] in self?.textInserter.redoLastInsertion() }
|
state.redoLastInsertion = { [weak self] in self?.textInserter.redoLastInsertion() }
|
||||||
state.copySelection = { [weak self] in self?.textInserter.copySelection() }
|
state.copySelection = { [weak self] in self?.textInserter.copySelection() }
|
||||||
@@ -710,11 +715,49 @@ public final class KeyboardViewController: UIInputViewController {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func refreshReturnKeyRole() {
|
/// Keeps field-action UI current even when a host app does not immediately
|
||||||
state.returnKeyRole = returnKeyRole(for: textDocumentProxy.returnKeyType ?? .default)
|
/// echo this keyboard's own document mutation through `textDidChange`.
|
||||||
if !state.returnKeyRole.usesActionFill {
|
private func insertTextIntoDocument(_ text: String) {
|
||||||
state.assistantSendAvailable = false
|
guard !text.isEmpty else { return }
|
||||||
|
textDocumentProxy.insertText(text)
|
||||||
|
// A non-empty insertion makes content actions available immediately.
|
||||||
|
// The next host callback remains the authoritative correction.
|
||||||
|
refreshAssistantFieldAction(hasTextOverride: true)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func deleteBackwardFromDocument() {
|
||||||
|
textDocumentProxy.deleteBackward()
|
||||||
|
refreshAssistantFieldAction()
|
||||||
|
scheduleAssistantFieldActionRefresh()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func performDocumentReturn() {
|
||||||
|
textDocumentProxy.insertText("\n")
|
||||||
|
scheduleAssistantFieldActionRefresh()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func scheduleAssistantFieldActionRefresh() {
|
||||||
|
assistantFieldActionRefreshTask?.cancel()
|
||||||
|
assistantFieldActionRefreshTask = Task { @MainActor [weak self] in
|
||||||
|
try? await Task.sleep(for: .milliseconds(100))
|
||||||
|
guard !Task.isCancelled, let self else { return }
|
||||||
|
self.refreshAssistantFieldAction()
|
||||||
|
self.assistantFieldActionRefreshTask = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func refreshAssistantFieldAction(hasTextOverride: Bool? = nil) {
|
||||||
|
let role = returnKeyRole(for: textDocumentProxy.returnKeyType ?? .default)
|
||||||
|
state.returnKeyRole = role
|
||||||
|
state.assistantActionAvailable = role.assistantActionAvailable(
|
||||||
|
hasText: hasTextOverride ?? textDocumentProxy.hasText
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func refreshReturnKeyRole() {
|
||||||
|
assistantFieldActionRefreshTask?.cancel()
|
||||||
|
assistantFieldActionRefreshTask = nil
|
||||||
|
refreshAssistantFieldAction()
|
||||||
let isSecure = textDocumentProxy.isSecureTextEntry ?? false
|
let isSecure = textDocumentProxy.isSecureTextEntry ?? false
|
||||||
state.setSecureTextEntry(isSecure)
|
state.setSecureTextEntry(isSecure)
|
||||||
clipboardCapture?.secureEntryDidChange(isSecure: isSecure)
|
clipboardCapture?.secureEntryDidChange(isSecure: isSecure)
|
||||||
@@ -883,10 +926,10 @@ public final class KeyboardViewController: UIInputViewController {
|
|||||||
state: state,
|
state: state,
|
||||||
typing: typingSession,
|
typing: typingSession,
|
||||||
onInsert: { [weak self] text in
|
onInsert: { [weak self] text in
|
||||||
self?.textDocumentProxy.insertText(text)
|
self?.insertTextIntoDocument(text)
|
||||||
},
|
},
|
||||||
onDeleteBackward: { [weak self] in
|
onDeleteBackward: { [weak self] in
|
||||||
self?.textDocumentProxy.deleteBackward()
|
self?.deleteBackwardFromDocument()
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
let host = KeyboardHostingController(rootView: root)
|
let host = KeyboardHostingController(rootView: root)
|
||||||
|
|||||||
@@ -186,9 +186,8 @@ final class AIKeyboardCoordinator {
|
|||||||
requestInsertionFingerprint = nil
|
requestInsertionFingerprint = nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func sendCurrentFieldAction() {
|
func performCurrentFieldAction() {
|
||||||
guard state.assistantSendAvailable else { return }
|
guard state.assistantActionAvailable else { return }
|
||||||
state.assistantSendAvailable = false
|
|
||||||
if state.aiSession.canSend {
|
if state.aiSession.canSend {
|
||||||
state.aiSession.markAnswerSent()
|
state.aiSession.markAnswerSent()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -187,7 +187,6 @@ final class KeyboardTextInserter {
|
|||||||
lastInsertedText = nil
|
lastInsertedText = nil
|
||||||
state.undoAvailable = false
|
state.undoAvailable = false
|
||||||
state.editAvailable = false
|
state.editAvailable = false
|
||||||
state.assistantSendAvailable = false
|
|
||||||
EditableInputReferenceStore.clear()
|
EditableInputReferenceStore.clear()
|
||||||
OSGLog.keyboardExt.info("undo length=\(text.count, privacy: .public)")
|
OSGLog.keyboardExt.info("undo length=\(text.count, privacy: .public)")
|
||||||
}
|
}
|
||||||
@@ -532,7 +531,6 @@ final class KeyboardTextInserter {
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
state.editAvailable = true
|
state.editAvailable = true
|
||||||
state.assistantSendAvailable = state.returnKeyRole.usesActionFill
|
|
||||||
state.assistantInsertionSucceeded = true
|
state.assistantInsertionSucceeded = true
|
||||||
successPulseTask?.cancel()
|
successPulseTask?.cancel()
|
||||||
successPulseTask = Task { @MainActor [weak state] in
|
successPulseTask = Task { @MainActor [weak state] in
|
||||||
@@ -556,7 +554,6 @@ final class KeyboardTextInserter {
|
|||||||
lastInsertedText = nil
|
lastInsertedText = nil
|
||||||
state.undoAvailable = false
|
state.undoAvailable = false
|
||||||
state.editAvailable = false
|
state.editAvailable = false
|
||||||
state.assistantSendAvailable = false
|
|
||||||
EditableInputReferenceStore.clear()
|
EditableInputReferenceStore.clear()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,8 +15,8 @@ struct AIKeyboardView: View {
|
|||||||
static let compactPrimaryHeight: CGFloat = 56
|
static let compactPrimaryHeight: CGFloat = 56
|
||||||
static let compactPrimaryWidth: CGFloat = 148
|
static let compactPrimaryWidth: CGFloat = 148
|
||||||
static let secondaryHeight: CGFloat = 52
|
static let secondaryHeight: CGFloat = 52
|
||||||
static let sendWidth: CGFloat = 132
|
static let fieldActionWidth: CGFloat = 132
|
||||||
static let compactIPadSendWidth: CGFloat = 112
|
static let compactIPadFieldActionWidth: CGFloat = 112
|
||||||
static let circleSize: CGFloat = 48
|
static let circleSize: CGFloat = 48
|
||||||
static let compactIPadCircleSize: CGFloat = 44
|
static let compactIPadCircleSize: CGFloat = 44
|
||||||
static let sideButtonEdgeInset: CGFloat = 8
|
static let sideButtonEdgeInset: CGFloat = 8
|
||||||
@@ -64,7 +64,7 @@ struct AIKeyboardView: View {
|
|||||||
@State private var debugSkillsDismissed = false
|
@State private var debugSkillsDismissed = false
|
||||||
@State private var micLongPressConsumed = false
|
@State private var micLongPressConsumed = false
|
||||||
@State private var micIsHoldingForAI = false
|
@State private var micIsHoldingForAI = false
|
||||||
@State private var sendConfirmationVisible = false
|
@State private var fieldActionConfirmationVisible = false
|
||||||
|
|
||||||
private var palette: ThemePalette {
|
private var palette: ThemePalette {
|
||||||
colorScheme == .dark ? Palette.dark : Palette.light
|
colorScheme == .dark ? Palette.dark : Palette.light
|
||||||
@@ -184,7 +184,7 @@ struct AIKeyboardView: View {
|
|||||||
Image(systemName: "plus")
|
Image(systemName: "plus")
|
||||||
.font(.system(size: 20, weight: .semibold))
|
.font(.system(size: 20, weight: .semibold))
|
||||||
.foregroundStyle(.white)
|
.foregroundStyle(.white)
|
||||||
.frame(width: Layout.sendWidth, height: 44)
|
.frame(width: Layout.fieldActionWidth, height: 44)
|
||||||
.background(palette.accent, in: Capsule())
|
.background(palette.accent, in: Capsule())
|
||||||
}
|
}
|
||||||
.buttonStyle(.plain)
|
.buttonStyle(.plain)
|
||||||
@@ -827,7 +827,7 @@ struct AIKeyboardView: View {
|
|||||||
.allowsHitTesting(sideButtonsVisible)
|
.allowsHitTesting(sideButtonsVisible)
|
||||||
.accessibilityHidden(!sideButtonsVisible)
|
.accessibilityHidden(!sideButtonsVisible)
|
||||||
|
|
||||||
sendButton
|
fieldActionButton
|
||||||
}
|
}
|
||||||
.frame(maxWidth: Layout.actionClusterMaxWidth)
|
.frame(maxWidth: Layout.actionClusterMaxWidth)
|
||||||
.frame(maxWidth: .infinity)
|
.frame(maxWidth: .infinity)
|
||||||
@@ -891,49 +891,58 @@ struct AIKeyboardView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private var sendButton: some View {
|
private var fieldActionButton: some View {
|
||||||
Button(action: performSend) {
|
Button(action: performFieldAction) {
|
||||||
Image(systemName: sendConfirmationVisible ? "checkmark" : "paperplane.fill")
|
Image(systemName: fieldActionSystemImage)
|
||||||
.font(.system(size: 20, weight: .semibold))
|
.font(.system(size: 20, weight: .semibold))
|
||||||
.foregroundStyle(sendButtonForeground)
|
.foregroundStyle(fieldActionButtonForeground)
|
||||||
.frame(
|
.frame(
|
||||||
width: sendButtonWidth,
|
width: fieldActionButtonWidth,
|
||||||
height: KeyboardChromeLayout.assistantActionCapsuleHeight
|
height: KeyboardChromeLayout.assistantActionCapsuleHeight
|
||||||
)
|
)
|
||||||
.background(sendButtonFill, in: Capsule())
|
.background(fieldActionButtonFill, in: Capsule())
|
||||||
.contentShape(Capsule())
|
.contentShape(Capsule())
|
||||||
}
|
}
|
||||||
.buttonStyle(.plain)
|
.buttonStyle(.plain)
|
||||||
.disabled(!state.assistantSendAvailable)
|
.disabled(!state.assistantActionAvailable)
|
||||||
.accessibilityIdentifier("assistant.send")
|
.accessibilityIdentifier(
|
||||||
.accessibilityLabel(ExtL10n.text("keyboard.ai.send"))
|
"assistant.action.\(state.returnKeyRole.assistantActionIdentifier)"
|
||||||
.accessibilityHint(ExtL10n.text("keyboard.assistant.sendHint"))
|
)
|
||||||
|
.accessibilityLabel(ExtL10n.text(state.returnKeyRole.titleKey))
|
||||||
}
|
}
|
||||||
|
|
||||||
private var sendButtonFill: Color {
|
private var fieldActionSystemImage: String {
|
||||||
state.assistantSendAvailable
|
fieldActionConfirmationVisible
|
||||||
|
? "checkmark"
|
||||||
|
: state.returnKeyRole.assistantActionSystemImage
|
||||||
|
}
|
||||||
|
|
||||||
|
private var fieldActionButtonFill: Color {
|
||||||
|
state.assistantActionAvailable
|
||||||
? NativeKeyboardKeyColors.fill(for: colorScheme)
|
? NativeKeyboardKeyColors.fill(for: colorScheme)
|
||||||
: NativeKeyboardKeyColors.pressedFill(for: colorScheme)
|
: NativeKeyboardKeyColors.pressedFill(for: colorScheme)
|
||||||
}
|
}
|
||||||
|
|
||||||
private var sendButtonForeground: Color {
|
private var fieldActionButtonForeground: Color {
|
||||||
state.assistantSendAvailable
|
state.assistantActionAvailable
|
||||||
? NativeKeyboardKeyColors.text(for: colorScheme)
|
? NativeKeyboardKeyColors.text(for: colorScheme)
|
||||||
: NativeKeyboardKeyColors.text(for: colorScheme).opacity(0.58)
|
: NativeKeyboardKeyColors.text(for: colorScheme).opacity(0.58)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func performSend() {
|
private func performFieldAction() {
|
||||||
guard state.assistantSendAvailable else { return }
|
guard state.assistantActionAvailable else { return }
|
||||||
state.sendAssistantAction()
|
state.performAssistantFieldAction()
|
||||||
sendConfirmationVisible = true
|
fieldActionConfirmationVisible = true
|
||||||
Task { @MainActor in
|
Task { @MainActor in
|
||||||
try? await Task.sleep(for: .milliseconds(550))
|
try? await Task.sleep(for: .milliseconds(550))
|
||||||
sendConfirmationVisible = false
|
fieldActionConfirmationVisible = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private var sendButtonWidth: CGFloat {
|
private var fieldActionButtonWidth: CGFloat {
|
||||||
compactIPadLayout ? Layout.compactIPadSendWidth : Layout.sendWidth
|
compactIPadLayout
|
||||||
|
? Layout.compactIPadFieldActionWidth
|
||||||
|
: Layout.fieldActionWidth
|
||||||
}
|
}
|
||||||
|
|
||||||
private var lowerCircleSize: CGFloat {
|
private var lowerCircleSize: CGFloat {
|
||||||
|
|||||||
@@ -187,9 +187,9 @@ public final class KeyboardState: ObservableObject {
|
|||||||
@Published public var editSession: EditSessionState = .inactive
|
@Published public var editSession: EditSessionState = .inactive
|
||||||
/// AI conversation UI state for the keyboard surface. The host owns the actual messages.
|
/// AI conversation UI state for the keyboard surface. The host owns the actual messages.
|
||||||
@Published public var aiSession: AISessionState = .inactive
|
@Published public var aiSession: AISessionState = .inactive
|
||||||
/// The latest generated insertion can be submitted through the host's
|
/// Whether the assistant's field action can invoke the host Return key.
|
||||||
/// action-style Return key (Send / Search / Done / Go).
|
/// Derived from the focused field's role and `UITextDocumentProxy.hasText`.
|
||||||
@Published public var assistantSendAvailable: Bool = false
|
@Published public var assistantActionAvailable: Bool = false
|
||||||
/// Brief success pulse rendered on the unified assistant microphone.
|
/// Brief success pulse rendered on the unified assistant microphone.
|
||||||
@Published public var assistantInsertionSucceeded: Bool = false
|
@Published public var assistantInsertionSucceeded: Bool = false
|
||||||
@Published public var editCanReplaceOriginal: Bool = false
|
@Published public var editCanReplaceOriginal: Bool = false
|
||||||
@@ -212,7 +212,6 @@ public final class KeyboardState: ObservableObject {
|
|||||||
clipboardSuggestionText = nil
|
clipboardSuggestionText = nil
|
||||||
clipboardSuggestionChangeCount = nil
|
clipboardSuggestionChangeCount = nil
|
||||||
clipboardOverlay = .none
|
clipboardOverlay = .none
|
||||||
assistantSendAvailable = false
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Host-app onboarding gate
|
// MARK: - Host-app onboarding gate
|
||||||
@@ -257,6 +256,57 @@ public final class KeyboardState: ObservableObject {
|
|||||||
public var usesActionFill: Bool {
|
public var usesActionFill: Bool {
|
||||||
self != .newline
|
self != .newline
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// SF Symbol matching the semantic Return action exposed by the host.
|
||||||
|
public var assistantActionSystemImage: String {
|
||||||
|
switch self {
|
||||||
|
case .newline:
|
||||||
|
return "arrow.turn.down.left"
|
||||||
|
case .send:
|
||||||
|
return "paperplane.fill"
|
||||||
|
case .search, .google, .yahoo:
|
||||||
|
return "magnifyingglass"
|
||||||
|
case .go, .route:
|
||||||
|
return "arrow.right.circle.fill"
|
||||||
|
case .join:
|
||||||
|
return "person.badge.plus"
|
||||||
|
case .done:
|
||||||
|
return "checkmark"
|
||||||
|
case .next, .continue:
|
||||||
|
return "arrow.right"
|
||||||
|
case .emergencyCall:
|
||||||
|
return "phone.fill"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Stable suffix for UI automation and diagnostics.
|
||||||
|
public var assistantActionIdentifier: String {
|
||||||
|
switch self {
|
||||||
|
case .newline: return "newline"
|
||||||
|
case .send: return "send"
|
||||||
|
case .go: return "go"
|
||||||
|
case .search: return "search"
|
||||||
|
case .join: return "join"
|
||||||
|
case .done: return "done"
|
||||||
|
case .next: return "next"
|
||||||
|
case .continue: return "continue"
|
||||||
|
case .route: return "route"
|
||||||
|
case .google: return "google"
|
||||||
|
case .yahoo: return "yahoo"
|
||||||
|
case .emergencyCall: return "emergencyCall"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Text-producing actions need content; navigation actions and newline
|
||||||
|
/// remain useful in an empty field.
|
||||||
|
public func assistantActionAvailable(hasText: Bool) -> Bool {
|
||||||
|
switch self {
|
||||||
|
case .send, .go, .search, .join, .route, .google, .yahoo:
|
||||||
|
return hasText
|
||||||
|
case .newline, .done, .next, .continue, .emergencyCall:
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Action hooks — injected by the view controller at install time.
|
// Action hooks — injected by the view controller at install time.
|
||||||
@@ -276,8 +326,8 @@ public final class KeyboardState: ObservableObject {
|
|||||||
/// Explicitly inserts a retained AI result after target validation failed.
|
/// Explicitly inserts a retained AI result after target validation failed.
|
||||||
public var confirmPendingAIAnswer: () -> Void = {}
|
public var confirmPendingAIAnswer: () -> Void = {}
|
||||||
public var discardPendingAIAnswer: () -> Void = {}
|
public var discardPendingAIAnswer: () -> Void = {}
|
||||||
/// Performs the host's action-style Return after generated text was inserted.
|
/// Performs the focused field's semantic Return action.
|
||||||
public var sendAssistantAction: () -> Void = {}
|
public var performAssistantFieldAction: () -> Void = {}
|
||||||
/// Sends a tapped idle hint card as the AI question (skip microphone).
|
/// Sends a tapped idle hint card as the AI question (skip microphone).
|
||||||
public var submitAIHint: (AIHintCard) -> Void = { _ in }
|
public var submitAIHint: (AIHintCard) -> Void = { _ in }
|
||||||
/// Sends a clipboard skill (reply / summarize / translate / export).
|
/// Sends a clipboard skill (reply / summarize / translate / export).
|
||||||
|
|||||||
@@ -0,0 +1,51 @@
|
|||||||
|
import XCTest
|
||||||
|
@testable import OSGKeyboardShared
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
final class AssistantFieldActionTests: XCTestCase {
|
||||||
|
func testContentActionsRequireText() {
|
||||||
|
let roles: [KeyboardState.ReturnKeyRole] = [
|
||||||
|
.send,
|
||||||
|
.go,
|
||||||
|
.search,
|
||||||
|
.join,
|
||||||
|
.route,
|
||||||
|
.google,
|
||||||
|
.yahoo
|
||||||
|
]
|
||||||
|
|
||||||
|
for role in roles {
|
||||||
|
XCTAssertFalse(role.assistantActionAvailable(hasText: false))
|
||||||
|
XCTAssertTrue(role.assistantActionAvailable(hasText: true))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func testNavigationActionsRemainAvailableWithoutText() {
|
||||||
|
let roles: [KeyboardState.ReturnKeyRole] = [
|
||||||
|
.newline,
|
||||||
|
.done,
|
||||||
|
.next,
|
||||||
|
.continue,
|
||||||
|
.emergencyCall
|
||||||
|
]
|
||||||
|
|
||||||
|
for role in roles {
|
||||||
|
XCTAssertTrue(role.assistantActionAvailable(hasText: false))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func testSearchRolesUseSearchSymbol() {
|
||||||
|
XCTAssertEqual(
|
||||||
|
KeyboardState.ReturnKeyRole.search.assistantActionSystemImage,
|
||||||
|
"magnifyingglass"
|
||||||
|
)
|
||||||
|
XCTAssertEqual(
|
||||||
|
KeyboardState.ReturnKeyRole.google.assistantActionSystemImage,
|
||||||
|
"magnifyingglass"
|
||||||
|
)
|
||||||
|
XCTAssertEqual(
|
||||||
|
KeyboardState.ReturnKeyRole.yahoo.assistantActionSystemImage,
|
||||||
|
"magnifyingglass"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -24,7 +24,7 @@ final class AssistantKeyboardUITests: XCTestCase {
|
|||||||
element("assistant.mic.dictationRecording", in: app)
|
element("assistant.mic.dictationRecording", in: app)
|
||||||
.waitForExistence(timeout: 3)
|
.waitForExistence(timeout: 3)
|
||||||
)
|
)
|
||||||
XCTAssertFalse(element("assistant.send", in: app).isHittable)
|
XCTAssertFalse(element("assistant.action.send", in: app).isHittable)
|
||||||
}
|
}
|
||||||
|
|
||||||
func testIdleHintHasBalancedSpacingAndFullCapsuleHitTarget() {
|
func testIdleHintHasBalancedSpacingAndFullCapsuleHitTarget() {
|
||||||
@@ -84,7 +84,7 @@ final class AssistantKeyboardUITests: XCTestCase {
|
|||||||
let space = requiredElement("assistant.space", in: app)
|
let space = requiredElement("assistant.space", in: app)
|
||||||
let undo = requiredElement("assistant.undo", in: app)
|
let undo = requiredElement("assistant.undo", in: app)
|
||||||
let edit = requiredElement("assistant.edit", in: app)
|
let edit = requiredElement("assistant.edit", in: app)
|
||||||
let send = requiredElement("assistant.send", in: app)
|
let send = requiredElement("assistant.action.send", in: app)
|
||||||
|
|
||||||
XCTAssertTrue(send.isEnabled)
|
XCTAssertTrue(send.isEnabled)
|
||||||
assertNoIntersection(delete, mic)
|
assertNoIntersection(delete, mic)
|
||||||
@@ -126,7 +126,7 @@ final class AssistantKeyboardUITests: XCTestCase {
|
|||||||
element("assistant.mic.idle", in: app)
|
element("assistant.mic.idle", in: app)
|
||||||
.waitForExistence(timeout: 3)
|
.waitForExistence(timeout: 3)
|
||||||
)
|
)
|
||||||
XCTAssertTrue(requiredElement("assistant.send", in: app).isEnabled)
|
XCTAssertTrue(requiredElement("assistant.action.send", in: app).isEnabled)
|
||||||
XCTAssertTrue(element("assistant.undo", in: app).exists)
|
XCTAssertTrue(element("assistant.undo", in: app).exists)
|
||||||
XCTAssertTrue(element("assistant.edit", in: app).exists)
|
XCTAssertTrue(element("assistant.edit", in: app).exists)
|
||||||
}
|
}
|
||||||
@@ -160,6 +160,14 @@ final class AssistantKeyboardUITests: XCTestCase {
|
|||||||
XCTAssertFalse(element("assistant.skills.pager", in: app).exists)
|
XCTAssertFalse(element("assistant.skills.pager", in: app).exists)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func testSearchFieldShowsEnabledSearchAction() {
|
||||||
|
let app = launch(scenario: "search")
|
||||||
|
let search = requiredElement("assistant.action.search", in: app)
|
||||||
|
|
||||||
|
XCTAssertTrue(search.isEnabled)
|
||||||
|
XCTAssertTrue(search.isHittable)
|
||||||
|
}
|
||||||
|
|
||||||
func testActionGeometrySurvivesLandscapeRotation() {
|
func testActionGeometrySurvivesLandscapeRotation() {
|
||||||
let app = launch(scenario: "completed")
|
let app = launch(scenario: "completed")
|
||||||
_ = requiredElement("assistant.mic.idle", in: app)
|
_ = requiredElement("assistant.mic.idle", in: app)
|
||||||
@@ -171,7 +179,7 @@ final class AssistantKeyboardUITests: XCTestCase {
|
|||||||
let space = requiredElement("assistant.space", in: app)
|
let space = requiredElement("assistant.space", in: app)
|
||||||
let undo = requiredElement("assistant.undo", in: app)
|
let undo = requiredElement("assistant.undo", in: app)
|
||||||
let edit = requiredElement("assistant.edit", in: app)
|
let edit = requiredElement("assistant.edit", in: app)
|
||||||
let send = requiredElement("assistant.send", in: app)
|
let send = requiredElement("assistant.action.send", in: app)
|
||||||
assertNoIntersection(delete, mic)
|
assertNoIntersection(delete, mic)
|
||||||
assertNoIntersection(space, mic)
|
assertNoIntersection(space, mic)
|
||||||
assertNoIntersection(undo, send)
|
assertNoIntersection(undo, send)
|
||||||
|
|||||||
+1
-1
@@ -49,7 +49,7 @@ settings:
|
|||||||
STRING_CATALOG_GENERATE_SYMBOLS: YES
|
STRING_CATALOG_GENERATE_SYMBOLS: YES
|
||||||
CLANG_CXX_LANGUAGE_STANDARD: c++17
|
CLANG_CXX_LANGUAGE_STANDARD: c++17
|
||||||
MARKETING_VERSION: "1.8.0"
|
MARKETING_VERSION: "1.8.0"
|
||||||
CURRENT_PROJECT_VERSION: "74"
|
CURRENT_PROJECT_VERSION: "75"
|
||||||
# 签名配置来自 Signing.local.xcconfig(gitignored,不会被覆盖)
|
# 签名配置来自 Signing.local.xcconfig(gitignored,不会被覆盖)
|
||||||
|
|
||||||
# 项目级签名 xcconfig,适用于所有 target
|
# 项目级签名 xcconfig,适用于所有 target
|
||||||
|
|||||||
Reference in New Issue
Block a user