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:
Rocky
2026-08-16 14:22:48 +08:00
parent fb97ec2937
commit 805b01a68e
10 changed files with 227 additions and 64 deletions
+1
View File
@@ -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 fields 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
} }
} }
+60 -17
View File
@@ -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()
} }
} }
+35 -26
View File
@@ -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 {
+56 -6
View File
@@ -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
View File
@@ -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.xcconfiggitignored,不会被覆盖) # 签名配置来自 Signing.local.xcconfiggitignored,不会被覆盖)
# 项目级签名 xcconfig,适用于所有 target # 项目级签名 xcconfig,适用于所有 target