chore(release): bump version to 2.1.6 (build 131)

Version: CURRENT_PROJECT_VERSION 130 -> 131 (project.yml, AGENTS.md, READMEs,
docs/llms.txt).

fix(flow): host-app identification now compiles in every build configuration
(Debug, TestFlight, Release). The arbiter probe and HostAppIdentity were
DEBUG-only, so TestFlight/App Store builds never knew the host bundle id and
could not auto-return after a startflow handoff. Debug-only logging and the
Home test card are unchanged.

Also includes the pending work in the tree:
- Home: single account/setup card surface (HomeAccountSurfacePolicy/Views)
- Wide screens: voice key row and centred mic placement, iPhone Duo layout
- Keyboard: clipboard skill ranking and AI skill layout updates
- Debug: account session handoff to the Simulator (+ import script)
- Docs: portfolio assets, privacy policy URL, site/README refresh
This commit is contained in:
Rocky
2026-10-29 11:54:13 +08:00
parent 055a40976f
commit 4dce67df84
65 changed files with 3682 additions and 787 deletions
+89 -8
View File
@@ -106,6 +106,25 @@ public final class KeyboardViewController: UIInputViewController {
)
}
/// Regular × regular on a phone host is the unfolded iPhone Duo; every
/// other iPhone posture has at least one compact axis.
private var isExpandedPhoneLayout: Bool {
KeyboardChromeLayout.usesExpandedPhoneMetrics(
isPad: UIDevice.current.userInterfaceIdiom == .pad,
hasRegularWidth: traitCollection.horizontalSizeClass == .regular,
hasRegularHeight: traitCollection.verticalSizeClass == .regular
)
}
/// Compact height on a phone host is a landscape iPhone (folded Duo
/// included). `.unspecified` before the first trait pass is not compact.
private var isCompactHeightPhoneLayout: Bool {
KeyboardChromeLayout.usesCompactHeightPhoneMetrics(
isPad: UIDevice.current.userInterfaceIdiom == .pad,
hasCompactHeight: traitCollection.verticalSizeClass == .compact
)
}
/// Width the layout should be sized against. `view.bounds` is empty before
/// the first layout pass, so fall back to the screen the keyboard is on.
private var currentLayoutWidth: CGFloat {
@@ -120,7 +139,9 @@ public final class KeyboardViewController: UIInputViewController {
KeyboardSurfaceRoot.height(
for: state.surface,
isIPad: state.usesIPadLayoutMetrics,
width: state.layoutWidth
width: state.layoutWidth,
isExpandedPhone: state.usesExpandedPhoneLayoutMetrics,
isCompactHeightPhone: state.usesCompactHeightPhoneLayoutMetrics
)
}
@@ -158,7 +179,6 @@ public final class KeyboardViewController: UIInputViewController {
#if DEBUG
HostBundleProbe.installLogging()
#endif
state.showsSystemGlobeKey = UIDevice.current.userInterfaceIdiom == .pad
// Voice-first keyboard — hide the misleading "English" subtitle in Settings.
primaryLanguage = "mis"
let preferred = TypingInputConfiguration.preferredSurfaceOnOpen()
@@ -359,7 +379,11 @@ public final class KeyboardViewController: UIInputViewController {
self.flowCoordinator.ensurePiPReadyOnKeyboardOpen()
}
#if DEBUG
HostBundleProbe.recordPresentationAndPing()
HostBundleProbe.recordPresentation()
#endif
// Host identification feeds the cold-start auto-return; runs in every build.
HostAppIdentity.refreshOnPresentation()
#if DEBUG
WhatsNewDemoDriver.resetForNewPresentation()
WhatsNewDemoDriver.startIfNeeded(
state: state,
@@ -404,7 +428,8 @@ public final class KeyboardViewController: UIInputViewController {
public override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) {
super.traitCollectionDidChange(previousTraitCollection)
guard previousTraitCollection?.horizontalSizeClass != traitCollection.horizontalSizeClass else {
guard previousTraitCollection?.horizontalSizeClass != traitCollection.horizontalSizeClass
|| previousTraitCollection?.verticalSizeClass != traitCollection.verticalSizeClass else {
return
}
refreshLayoutMode()
@@ -949,24 +974,76 @@ public final class KeyboardViewController: UIInputViewController {
}
private func refreshLayoutMode() {
// Folding moves the keyboard between a display with a system switch
// below it and one without, so re-read this on every layout pass.
refreshInputModeSwitchKey()
let usesIPadMetrics = isIPadLayout
let usesExpandedPhoneMetrics = isExpandedPhoneLayout
let usesCompactHeightPhoneMetrics = isCompactHeightPhoneLayout
let width = currentLayoutWidth
// `state.layoutWidth` tracks the width that last changed the layout
// bucket, not every intermediate width: republishing on each frame of
// a Stage Manager drag would rebuild the SwiftUI grid continuously.
let bucketChanged = KeyboardChromeLayout.usesWideIPadMetrics(
let bucketChanged = Self.layoutWidthBucket(
isIPad: usesIPadMetrics,
isExpandedPhone: usesExpandedPhoneMetrics,
isCompactHeightPhone: usesCompactHeightPhoneMetrics,
width: width
) != KeyboardChromeLayout.usesWideIPadMetrics(
) != Self.layoutWidthBucket(
isIPad: state.usesIPadLayoutMetrics,
isExpandedPhone: state.usesExpandedPhoneLayoutMetrics,
isCompactHeightPhone: state.usesCompactHeightPhoneLayoutMetrics,
width: state.layoutWidth
)
guard state.usesIPadLayoutMetrics != usesIPadMetrics || bucketChanged else { return }
guard state.usesIPadLayoutMetrics != usesIPadMetrics
|| state.usesExpandedPhoneLayoutMetrics != usesExpandedPhoneMetrics
|| state.usesCompactHeightPhoneLayoutMetrics != usesCompactHeightPhoneMetrics
|| bucketChanged else { return }
state.usesIPadLayoutMetrics = usesIPadMetrics
state.usesExpandedPhoneLayoutMetrics = usesExpandedPhoneMetrics
state.usesCompactHeightPhoneLayoutMetrics = usesCompactHeightPhoneMetrics
state.layoutWidth = width
refreshKeyboardHeight()
}
/// Every width threshold the SwiftUI surfaces branch on. The first layout
/// pass can report a transient width (0, or the folded display's), so a
/// threshold missing here would keep that stale width for the session.
private static func layoutWidthBucket(
isIPad: Bool,
isExpandedPhone: Bool,
isCompactHeightPhone: Bool,
width: CGFloat
) -> LayoutWidthBucket {
LayoutWidthBucket(
wideIPad: KeyboardChromeLayout.usesWideIPadMetrics(isIPad: isIPad, width: width),
voiceKeyRow: VoiceKeyRowLayout.isAvailable(
usesIPadMetrics: isIPad,
usesExpandedPhoneMetrics: isExpandedPhone,
usesCompactHeightPhoneMetrics: isCompactHeightPhone,
hostWidth: width
),
compactVoice: KeyboardChromeLayout.usesCompactVoiceMetrics(width: width),
compactIPadVoice: KeyboardChromeLayout.usesCompactIPadVoiceMetrics(isIPad: isIPad, width: width)
)
}
private struct LayoutWidthBucket: Equatable {
let wideIPad: Bool
let voiceKeyRow: Bool
let compactVoice: Bool
let compactIPadVoice: Bool
}
private func refreshInputModeSwitchKey() {
let shows = KeyboardChromeLayout.showsInputModeSwitchKey(
isPad: UIDevice.current.userInterfaceIdiom == .pad,
systemRequiresSwitchKey: needsInputModeSwitchKey
)
guard state.showsSystemGlobeKey != shows else { return }
state.showsSystemGlobeKey = shows
}
private var heightPhaseLog: String {
switch heightPhase {
case .idle: return "idle"
@@ -1209,7 +1286,11 @@ public final class KeyboardViewController: UIInputViewController {
+ "view=\(Int(view.bounds.height)) "
+ "host=\(Int(hosting?.view.bounds.height ?? -1)) "
+ "constraint=\(Int(keyboardHeightConstraint?.constant ?? -1)) "
+ "target=\(Int(targetKeyboardHeight))"
+ "target=\(Int(targetKeyboardHeight)) "
+ "width=\(Int(view.bounds.width)) "
+ "sizeClass=\(traitCollection.horizontalSizeClass.rawValue)x"
+ "\(traitCollection.verticalSizeClass.rawValue) "
+ "needsGlobe=\(needsInputModeSwitchKey ? 1 : 0)"
guard snapshot != lastLoggedLayoutSnapshot else { return }
lastLoggedLayoutSnapshot = snapshot
OSGDiag.log("KVC.layout \(snapshot)", category: "boot")
@@ -38,9 +38,16 @@ struct KeyboardSurfaceRoot: View {
static func height(
for surface: KeyboardState.Surface,
isIPad: Bool = false,
width: CGFloat = 0
width: CGFloat = 0,
isExpandedPhone: Bool = false,
isCompactHeightPhone: Bool = false
) -> CGFloat {
TypingSurfaceMetrics.contentHeight(isIPad: isIPad, width: width)
TypingSurfaceMetrics.contentHeight(
isIPad: isIPad,
width: width,
isExpandedPhone: isExpandedPhone,
isCompactHeightPhone: isCompactHeightPhone
)
}
var body: some View {
+45 -8
View File
@@ -27,12 +27,32 @@ enum TypingLayoutMetrics {
// MARK: - iPad (regular size class) metrics
static func metrics(isIPad: Bool, width: CGFloat) -> TypingKeyLayoutBuilder.Metrics {
TypingSurfaceMetrics.metrics(isIPad: isIPad, width: width)
static func metrics(
isIPad: Bool,
width: CGFloat,
isExpandedPhone: Bool = false,
isCompactHeightPhone: Bool = false
) -> TypingKeyLayoutBuilder.Metrics {
TypingSurfaceMetrics.metrics(
isIPad: isIPad,
width: width,
isExpandedPhone: isExpandedPhone,
isCompactHeightPhone: isCompactHeightPhone
)
}
static func contentHeight(isIPad: Bool, width: CGFloat) -> CGFloat {
TypingSurfaceMetrics.contentHeight(isIPad: isIPad, width: width)
static func contentHeight(
isIPad: Bool,
width: CGFloat,
isExpandedPhone: Bool = false,
isCompactHeightPhone: Bool = false
) -> CGFloat {
TypingSurfaceMetrics.contentHeight(
isIPad: isIPad,
width: width,
isExpandedPhone: isExpandedPhone,
isCompactHeightPhone: isCompactHeightPhone
)
}
}
@@ -50,8 +70,18 @@ struct TypingRootView: View {
/// Keys currently under a finger (grid-level touch pad, multi-touch).
@State private var highlightedKeyIDs: Set<String> = []
static func totalHeight(isIPad: Bool = false, width: CGFloat = 0) -> CGFloat {
TypingLayoutMetrics.contentHeight(isIPad: isIPad, width: width)
static func totalHeight(
isIPad: Bool = false,
width: CGFloat = 0,
isExpandedPhone: Bool = false,
isCompactHeightPhone: Bool = false
) -> CGFloat {
TypingLayoutMetrics.contentHeight(
isIPad: isIPad,
width: width,
isExpandedPhone: isExpandedPhone,
isCompactHeightPhone: isCompactHeightPhone
)
}
private var palette: ThemePalette {
@@ -95,7 +125,9 @@ struct TypingRootView: View {
.frame(
height: Self.totalHeight(
isIPad: state.usesIPadLayoutMetrics,
width: state.layoutWidth
width: state.layoutWidth,
isExpandedPhone: state.usesExpandedPhoneLayoutMetrics,
isCompactHeightPhone: state.usesCompactHeightPhoneLayoutMetrics
)
)
.background(Color.clear)
@@ -569,7 +601,12 @@ struct TypingRootView: View {
// keyboard. Using `size` here would compare the grid's own width to
// its height (always landscape) and could pick a different bucket than
// the height constraint, clipping the bottom row.
let metrics = TypingLayoutMetrics.metrics(isIPad: isIPad, width: state.layoutWidth)
let metrics = TypingLayoutMetrics.metrics(
isIPad: isIPad,
width: state.layoutWidth,
isExpandedPhone: state.usesExpandedPhoneLayoutMetrics,
isCompactHeightPhone: state.usesCompactHeightPhoneLayoutMetrics
)
let pageLabel = typing.page == .letters ? "123" : "ABC"
let spaceLabel = typing.language == .chinese ? "空格" : "space"
let returnLabel: String = {
+11 -12
View File
@@ -2,8 +2,9 @@
// OSGKeyboard · Keyboard Extension
//
// 识别当前宿主 App 的 bundle ID,供冷启动跳转主 App 后自动跳回。
// 目前依赖仅在 DEBUG 编译的私有 arbiter 探针(`OSGHostArbiterProbe`),
// Release 构建里识别结果恒为 nil,行为与接入前一致。
// 依赖私有 arbiter 探针(`OSGHostArbiterProbe`),Debug / TestFlight / Release 均生效;
// 探针装不上(系统 < 26.4、私有符号改名)时识别结果为 nil,退化为“不自动跳回”。
// 调试日志与首页测试卡片仍只在 DEBUG 编译。
import Foundation
import OSGKeyboardShared
@@ -11,15 +12,16 @@ import OSGKeyboardShared
enum HostAppIdentity {
/// 最近一次识别到的宿主;识别不到、或宿主是 OSG 自己时为 nil。
static var currentBundleId: String? {
#if DEBUG
guard let bundleId = OSGHostArbiterProbe.lastCapturedBundleId,
!HostAppURLRegistry.isOwnBundleId(bundleId) else {
return nil
}
return bundleId
#else
return nil
#endif
}
/// 键盘弹出时主动让 arbiter 回调一次,避免宿主没变时拿不到最新 ID。
static func refreshOnPresentation() {
OSGHostArbiterProbe.pingArbiter()
}
/// 跳转主 App(`startflow`)前调用:把当前宿主写进 App Group,主 App 就绪后据此跳回。
@@ -39,14 +41,12 @@ enum HostAppIdentity {
/// 键盘收起时作废识别结果,下次弹出由新回调重新填充。
static func invalidate() {
#if DEBUG
OSGHostArbiterProbe.invalidateCapturedBundleId()
#endif
}
}
#if DEBUG
/// 临时调试:把探针回调写进 App Group,主 App 首页的测试卡片读取展示。
/// 调试:把探针回调写进 App Group,主 App 首页的测试卡片读取展示。
enum HostBundleProbe {
/// 只需安装一次;回调可能来自非主线程,闭包必须是 nonisolated。
static func installLogging() {
@@ -55,14 +55,13 @@ enum HostBundleProbe {
}
}
/// 键盘弹出时记一条标记,再主动触发回调,便于把记录和每次弹出对应起来。
static func recordPresentationAndPing() {
/// 键盘弹出时记一条标记,便于把记录和每次弹出对应起来(ping 由 `HostAppIdentity` 负责)。
static func recordPresentation() {
HostBundleProbeLog.append(
event: "appear",
bundleId: nil,
detail: OSGHostArbiterProbe.isInstalled ? "hook=installed" : "hook=missing"
)
OSGHostArbiterProbe.pingArbiter()
}
}
#endif
@@ -1,14 +1,14 @@
// OSGHostArbiterProbe.h
// OSGKeyboard · Keyboard Extension
//
// 临时调试探针(仅 DEBUG):iOS 26.4+ 通过 _UIKeyboardArbiterClient 回调
// 读取宿主 App 的 bundle ID。原理参考 editorss/KeyboardHostBundleID(MIT)。
// 宿主识别探针(Debug / TestFlight / Release 均编译):iOS 26.4+ 通过
// _UIKeyboardArbiterClient 回调读取宿主 App 的 bundle ID,供冷启动后自动跳回。
// 原理参考 editorss/KeyboardHostBundleID(MIT)。
// 必须在 +load 安装,否则可能错过键盘首次切换宿主时的回调。
// 所有私有符号运行时按字符串查找,任何一步失败都静默退化为“识别不到宿主”。
#import <Foundation/Foundation.h>
#ifdef DEBUG
NS_ASSUME_NONNULL_BEGIN
/// 回调可能在 arbiter 的私有队列上触发,不保证主线程。
@@ -34,5 +34,3 @@ typedef void (^OSGHostArbiterCaptureHandler)(NSString *_Nullable bundleId, NSStr
@end
NS_ASSUME_NONNULL_END
#endif
@@ -1,13 +1,11 @@
// OSGHostArbiterProbe.m
// OSGKeyboard · Keyboard Extension
//
// 临时调试探针(仅 DEBUG),见头文件说明。所有私有符号都在运行时按字符串查找,
// 宿主识别探针(所有构建配置均编译),见头文件说明。所有私有符号都在运行时按字符串查找,
// 任何一步找不到都静默放弃,并保证原始实现照常执行。
#import "OSGHostArbiterProbe.h"
#ifdef DEBUG
#import <UIKit/UIKit.h>
#import <objc/message.h>
#import <objc/runtime.h>
@@ -129,5 +127,3 @@ static BOOL OSGAlwaysEnabled(__unused id self, __unused SEL _cmd) {
}
@end
#endif
+243 -209
View File
@@ -22,7 +22,9 @@ struct AIKeyboardView: View {
static let compactIPadCircleSize: CGFloat = 44
static let sideButtonEdgeInset: CGFloat = 8
static let actionClusterMaxWidth: CGFloat = 430
static let primaryToSecondaryGap: CGFloat = 25
static let surfaceVerticalPadding: CGFloat = 4
/// The 3-line live transcript plus a little air above and below it.
static let minimumContextHeight: CGFloat = statusViewportHeight + 12
static let hotwordHeight: CGFloat = 28
static let hotwordMaxWidth: CGFloat = 180
static let skillTipMaxWidth: CGFloat = 300
@@ -30,12 +32,6 @@ struct AIKeyboardView: View {
/// belongs visually to the label-to-tab gap, so compensate by 3.5 pt.
static let contextVisualOffset: CGFloat = -3.5
static let carouselInterval: TimeInterval = 4
static let skillButtonSize: CGFloat = 48
static let skillCellWidth: CGFloat = 60
static let skillCellSpacing: CGFloat = 10
static let pageDotSize: CGFloat = 5
static let skillPaginationBottomInset: CGFloat = 6
static let maximumSemanticSkills = 5
static let statusViewportHeight = UIFont.systemFont(ofSize: 15).lineHeight * 3
}
@@ -52,7 +48,8 @@ struct AIKeyboardView: View {
}
#if DEBUG
/// Layout preview for `--ai-skills-demo`. Nil keeps production clipboard-window gating.
/// Top-bar skill capsules for `--ai-skills-demo` / harness / demo recordings.
/// Nil keeps production clipboard-window gating.
static var debugPreviewSkills: [AIClipboardSkill]?
/// Deterministic intent/domain labels for the assistant UI harness.
static var debugPreviewSemanticBadgeKeys: (intent: String?, domain: String?)?
@@ -77,7 +74,6 @@ struct AIKeyboardView: View {
@State private var hintOpacity: Double = 1
@State private var carouselBag = AIHintCarouselBag()
@State private var poolCards: [AIHintCard] = []
@State private var selectedSkillPage = 0
@State private var debugSkillsDismissed = false
@State private var micLongPressConsumed = false
@State private var micIsHoldingForAI = false
@@ -124,16 +120,11 @@ struct AIKeyboardView: View {
.onChange(of: clipboardHistory.entries.first?.id) { _, _ in
state.dismissedClipboardSkillEntryID = nil
debugSkillsDismissed = false
selectedSkillPage = 0
resetCarousel()
}
.onChange(of: state.clipboardSkillCatalog) { _, _ in
selectedSkillPage = 0
resetCarousel()
}
.onChange(of: semanticRanking.snapshot) { _, _ in
selectedSkillPage = 0
}
.onChange(of: state.skillTipText) { _, tip in
guard let tip, !tip.isEmpty else { return }
#if DEBUG
@@ -152,7 +143,7 @@ struct AIKeyboardView: View {
) { _ in
guard assistantIsResting else { return }
reloadHintPool(resetBag: false)
guard !showsClipboardSkills, !showsLongPressCoach else { return }
guard !showsLongPressCoach else { return }
if reduceMotion, let hint = currentHint,
poolCards.contains(where: { $0.id == hint.id }) {
return
@@ -178,13 +169,17 @@ struct AIKeyboardView: View {
contextArea
.frame(maxHeight: .infinity)
.offset(y: Layout.contextVisualOffset)
primaryActionRow.frame(height: Layout.primaryHeight)
Color.clear.frame(height: Layout.primaryToSecondaryGap)
secondaryActionRow.frame(height: Layout.secondaryHeight)
primaryActionRow.frame(height: primaryRowHeight)
Color.clear.frame(height: primaryToSecondaryGap)
if state.usesVoiceKeyRow {
voiceKeyRow.frame(height: voiceKeyRowHeight)
} else {
secondaryActionRow.frame(height: Layout.secondaryHeight)
}
}
.padding(.vertical, 4)
.padding(.vertical, Layout.surfaceVerticalPadding)
.padding(.horizontal, KeyboardChromeLayout.horizontalInset)
.frame(maxWidth: KeyboardChromeLayout.voiceContentMaxWidth)
.frame(maxWidth: voiceContentWidthCap)
.frame(maxWidth: .infinity)
.frame(height: resolvedHeight)
}
@@ -233,9 +228,9 @@ struct AIKeyboardView: View {
.scrollBounceBehavior(.basedOnSize)
}
}
.padding(.vertical, 4)
.padding(.vertical, Layout.surfaceVerticalPadding)
.padding(.horizontal, KeyboardChromeLayout.horizontalInset)
.frame(maxWidth: KeyboardChromeLayout.voiceContentMaxWidth)
.frame(maxWidth: voiceContentWidthCap)
.frame(maxWidth: .infinity)
.frame(height: resolvedHeight)
}
@@ -288,9 +283,9 @@ struct AIKeyboardView: View {
.scrollBounceBehavior(.basedOnSize)
.accessibilityIdentifier("assistant.replyVariants")
}
.padding(.vertical, 4)
.padding(.vertical, Layout.surfaceVerticalPadding)
.padding(.horizontal, KeyboardChromeLayout.horizontalInset)
.frame(maxWidth: KeyboardChromeLayout.voiceContentMaxWidth)
.frame(maxWidth: voiceContentWidthCap)
.frame(maxWidth: .infinity)
.frame(height: resolvedHeight)
}
@@ -348,10 +343,48 @@ struct AIKeyboardView: View {
private var resolvedHeight: CGFloat {
TypingSurfaceMetrics.contentHeight(
isIPad: state.usesIPadLayoutMetrics,
width: state.layoutWidth
width: state.layoutWidth,
isExpandedPhone: state.usesExpandedPhoneLayoutMetrics,
isCompactHeightPhone: state.usesCompactHeightPhoneLayoutMetrics
)
}
private var primaryRowHeight: CGFloat {
state.usesCompactHeightPhoneLayoutMetrics ? Layout.compactPrimaryHeight : Layout.primaryHeight
}
/// Gap below the mic. The context slot above it is flexible, so whatever
/// this gap doesn't take piles up there; `VoiceMicPlacement` splits the
/// free height so the mic sits centred on every shell height.
private var primaryToSecondaryGap: CGFloat {
let bottomRow = state.usesVoiceKeyRow ? voiceKeyRowHeight : Layout.secondaryHeight
let contextAndGap = resolvedHeight
- 2 * Layout.surfaceVerticalPadding
- Layout.topBarHeight
- primaryRowHeight
- bottomRow
let context = VoiceMicPlacement.contextHeight(
contextAndGap: contextAndGap,
bottomRowHeight: bottomRow,
minimumContextHeight: Layout.minimumContextHeight
)
return max(0, contextAndGap - context)
}
private var voiceContentWidthCap: CGFloat {
KeyboardChromeLayout.voiceContentWidthCap(usesVoiceKeyRow: state.usesVoiceKeyRow)
}
/// Same height as the typing bottom row so the two rows coincide.
private var voiceKeyRowHeight: CGFloat {
TypingSurfaceMetrics.metrics(
isIPad: state.usesIPadLayoutMetrics,
width: state.layoutWidth,
isExpandedPhone: state.usesExpandedPhoneLayoutMetrics,
isCompactHeightPhone: state.usesCompactHeightPhoneLayoutMetrics
).bottomRowHeight
}
// MARK: - Top bar
@ViewBuilder
@@ -375,16 +408,9 @@ struct AIKeyboardView: View {
hintKey: "keyboard.voice.cancelHint"
)
} else if showsClipboardSkillBar {
// Fresh copy, no AI result yet: the top bar becomes the skill entry
// point (paste + every applicable skill + dismiss).
// Fresh copy (or an OOBE lesson), no AI result yet: the top bar becomes
// the only skill entry point (paste + every applicable skill + dismiss).
clipboardSkillTopBar
} else if showsClipboardSkills {
// OOBE / debug chip pager keeps its dismiss bar.
cancelTopBar(
action: dismissClipboardPresentation,
labelKey: "keyboard.assistant.dismissClipboard",
hintKey: "keyboard.assistant.dismissClipboardHint"
)
} else {
ZStack {
KeyboardTopControls(
@@ -405,28 +431,34 @@ struct AIKeyboardView: View {
/// Fresh-copy top bar (no active result): paste + scrollable skills + dismiss.
private var clipboardSkillTopBar: some View {
HStack(spacing: 6) {
leadingScrollRow
KeyboardCancelButton(
action: dismissClipboardPresentation,
accessibilityLabel: ExtL10n.text("keyboard.assistant.dismissClipboard"),
accessibilityHint: ExtL10n.text("keyboard.assistant.dismissClipboardHint"),
accessibilityIdentifier: "assistant.clipboard.dismiss"
)
leadingScrollRow(skills: restingBarSkills)
// An OOBE lesson has no real copy to dismiss; its skill stays put.
if !showsOOBEClipboardSkill {
KeyboardCancelButton(
action: dismissClipboardPresentation,
accessibilityLabel: ExtL10n.text("keyboard.assistant.dismissClipboard"),
accessibilityHint: ExtL10n.text("keyboard.assistant.dismissClipboardHint"),
accessibilityIdentifier: "assistant.clipboard.dismiss"
)
}
}
.padding(.horizontal, KeyboardTopBarMetrics.nestedHorizontalInset)
}
/// Fresh copy with applicable skills and no active AI result to defer to.
private var showsClipboardSkillBar: Bool {
guard assistantIsResting,
state.oobePracticeSession == nil,
guard assistantIsResting, !restingBarSkills.isEmpty else { return false }
#if DEBUG
if Self.debugPreviewSkills?.isEmpty == false { return true }
#endif
if showsOOBEClipboardSkill { return true }
guard state.oobePracticeSession == nil,
state.clipboardHistoryEnabled,
state.aiServiceAvailable,
!state.aiSession.canInsert,
!state.aiSession.canSelectReplyVariant,
let newest = clipboardHistory.newestEntry,
newest.id != state.dismissedClipboardSkillEntryID,
!topBarSkills.isEmpty else {
newest.id != state.dismissedClipboardSkillEntryID else {
return false
}
return AIHintPool.isClipboardSkillWindowActive(
@@ -470,7 +502,7 @@ struct AIKeyboardView: View {
) -> some View {
HStack(spacing: 6) {
// Paste + every applicable skill scroll together; the X stays put.
leadingScrollRow
leadingScrollRow(skills: topBarSkills)
KeyboardCancelButton(
action: action,
accessibilityLabel: ExtL10n.text(labelKey),
@@ -486,9 +518,8 @@ struct AIKeyboardView: View {
/// Paste capsule followed by every applicable skill — so Paste scrolls along
/// with the skills instead of staying pinned.
@ViewBuilder
private var leadingScrollRow: some View {
private func leadingScrollRow(skills: [AIClipboardSkill]) -> some View {
let paste = shouldShowClipboardSuggestion
let skills = topBarSkills
HStack(spacing: 6) {
if !paste {
KeyboardBrandLogo(action: state.openSettings)
@@ -534,6 +565,20 @@ struct AIKeyboardView: View {
.accessibilityLabel(Text(clipboardSkillTitle(skill)))
}
/// Capsules for the resting skill bar: the DEBUG preview, the one skill an
/// OOBE lesson practises, or the ranked production list.
private var restingBarSkills: [AIClipboardSkill] {
#if DEBUG
if let preview = Self.debugPreviewSkills, !preview.isEmpty {
return debugSkillsDismissed ? [] : preview
}
#endif
if let expectedSkillID = oobeExpectedSkillID {
return AIClipboardSkillCatalog.catalog.filter { $0.id == expectedSkillID }
}
return topBarSkills
}
/// Skills that apply to the current copy, most-relevant first. This is the
/// sole skill entry point now that the circular chips are gone; it shows with
/// or without an active result (the invoked skill is excluded when active).
@@ -572,7 +617,8 @@ struct AIKeyboardView: View {
sourceText: newest.text,
analysis: snapshot.analysis,
uiLanguage: state.uiLanguage,
limit: 6
limit: 6,
translationTargetLocaleId: state.translationTargetLocaleId
)
)
}
@@ -604,9 +650,7 @@ struct AIKeyboardView: View {
@ViewBuilder
private var contextArea: some View {
ZStack {
if showsOOBEClipboardSkill, !hasActiveStatus {
clipboardSkillPager
} else if let onboardingPracticeHint, !hasActiveStatus {
if let onboardingPracticeHint, !hasActiveStatus {
Text(onboardingPracticeHint)
.font(TypeStyle.footnote.weight(.semibold))
.foregroundStyle(palette.textPrimary)
@@ -627,13 +671,6 @@ struct AIKeyboardView: View {
.glassEffect(.regular, in: Capsule())
}
.accessibilityIdentifier("assistant.skillTip")
} else if showsClipboardSkills {
VStack(spacing: Spacing.xs) {
if let content = semanticBadgeContent {
semanticBadge(content)
}
clipboardSkillPager
}
} else if let content = semanticBadgeContent {
semanticBadge(content)
} else if hasActiveStatus {
@@ -848,74 +885,6 @@ struct AIKeyboardView: View {
// MARK: - Clipboard skills
private var clipboardSkillPager: some View {
GeometryReader { proxy in
let skillsPerPage = proxy.size.width < 352 ? 4 : 5
let pages = skillPages(from: visibleClipboardSkills, count: skillsPerPage)
VStack(spacing: 5) {
TabView(selection: $selectedSkillPage) {
ForEach(Array(pages.enumerated()), id: \.offset) { page, skills in
HStack(spacing: Layout.skillCellSpacing) {
ForEach(skills) { skill in
skillChip(skill)
}
}
.frame(maxWidth: .infinity)
.tag(page)
}
}
.tabViewStyle(.page(indexDisplayMode: .never))
.accessibilityIdentifier("assistant.skills.pager")
HStack(spacing: Layout.pageDotSize) {
ForEach(pages.indices, id: \.self) { page in
Circle()
.fill(page == selectedSkillPage
? palette.textPrimary
: palette.textTertiary.opacity(0.45))
.frame(width: Layout.pageDotSize, height: Layout.pageDotSize)
}
}
.opacity(pages.count > 1 ? 1 : 0)
.accessibilityHidden(true)
}
.padding(.bottom, Layout.skillPaginationBottomInset)
}
}
private func skillPages(
from skills: [AIClipboardSkill],
count: Int
) -> [[AIClipboardSkill]] {
stride(from: 0, to: skills.count, by: count).map { start in
Array(skills[start ..< min(start + count, skills.count)])
}
}
private func skillChip(_ skill: AIClipboardSkill) -> some View {
Button {
state.submitAIClipboardSkill(skill, replyScene(for: 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.caption)
.foregroundStyle(palette.textSecondary)
.lineLimit(1)
.minimumScaleFactor(0.75)
}
.frame(width: Layout.skillCellWidth)
}
.buttonStyle(.plain)
.disabled(!state.aiServiceAvailable || state.aiSession.isBusy)
.accessibilityIdentifier("assistant.skill.\(skill.id)")
.accessibilityLabel(Text(clipboardSkillTitle(skill)))
}
private func replyScene(for skill: AIClipboardSkill) -> AIClipboardReplyScene? {
guard skill.id == AIClipboardSkillCatalog.replyID,
state.oobePracticeSession == nil,
@@ -937,51 +906,17 @@ struct AIKeyboardView: View {
!custom.isEmpty {
return custom
}
// "Translate to <target>" names the user's configured target language.
if skill.id == AIClipboardSkillCatalog.translateToTargetID,
let title = AIClipboardSkillCatalog.translateToTargetButtonTitle(
translationTargetLocaleId: state.translationTargetLocaleId,
uiLanguage: state.uiLanguage
) {
return title
}
return ExtL10n.string(skill.titleKey)
}
private var showsClipboardSkills: Bool {
#if DEBUG
if let preview = Self.debugPreviewSkills,
!preview.isEmpty,
!debugSkillsDismissed {
return assistantIsResting
}
#endif
if showsOOBEClipboardSkill {
return assistantIsResting
}
// The idle circular skill chips are retired: auto mode runs one skill and
// the result panel's top-bar capsules offer the rest. Only OOBE / debug
// still present the chip pager above.
return false
}
private var visibleClipboardSkills: [AIClipboardSkill] {
#if DEBUG
if let preview = Self.debugPreviewSkills, !preview.isEmpty {
return preview
}
#endif
if let expectedSkillID = oobeExpectedSkillID {
return AIClipboardSkillCatalog.catalog.filter { $0.id == expectedSkillID }
}
guard let newest = clipboardHistory.newestEntry else { return [] }
guard let snapshot = semanticRanking.snapshot,
snapshot.entryID == newest.id else {
return state.clipboardSkillCatalog.filter {
$0.id == AIClipboardSkillCatalog.replyID
}
}
return ClipboardSkillSemanticRanker.recommended(
skills: state.clipboardSkillCatalog,
sourceText: newest.text,
analysis: snapshot.analysis,
uiLanguage: state.uiLanguage,
limit: Layout.maximumSemanticSkills
)
}
private var showsOOBEClipboardSkill: Bool {
oobeExpectedSkillID != nil
}
@@ -1018,16 +953,19 @@ struct AIKeyboardView: View {
private var primaryActionRow: some View {
ZStack {
HStack {
primarySideButton(leading: true)
.offset(x: Layout.sideButtonEdgeInset)
Spacer(minLength: 0)
primarySideButton(leading: false)
.offset(x: -Layout.sideButtonEdgeInset)
// The key row already carries space / delete, so the mic stands alone.
if !state.usesVoiceKeyRow {
HStack {
primarySideButton(leading: true)
.offset(x: Layout.sideButtonEdgeInset)
Spacer(minLength: 0)
primarySideButton(leading: false)
.offset(x: -Layout.sideButtonEdgeInset)
}
.opacity(sideButtonsVisible ? 1 : 0)
.allowsHitTesting(sideButtonsVisible)
.accessibilityHidden(!sideButtonsVisible)
}
.opacity(sideButtonsVisible ? 1 : 0)
.allowsHitTesting(sideButtonsVisible)
.accessibilityHidden(!sideButtonsVisible)
microphoneButton
}
@@ -1035,11 +973,16 @@ struct AIKeyboardView: View {
.frame(maxWidth: .infinity)
}
/// Handedness rule shared by the circle cluster and the key row: space and
/// edit share one side, delete and undo the other.
private func placesSpaceAndEdit(leading: Bool) -> Bool {
let swapped = state.handednessPreference.swapsActionKeys
return leading ? swapped : !swapped
}
@ViewBuilder
private func primarySideButton(leading: Bool) -> some View {
let swapped = state.handednessPreference.swapsActionKeys
let showsSpace = leading ? swapped : !swapped
if showsSpace {
if placesSpaceAndEdit(leading: leading) {
circleActionButton(
systemName: "space",
label: ExtL10n.string("keyboard.assistant.space"),
@@ -1225,13 +1168,14 @@ struct AIKeyboardView: View {
}
private var primaryButtonWidth: CGFloat {
state.layoutWidth > 0 && state.layoutWidth < 350
KeyboardChromeLayout.usesCompactVoiceMetrics(width: state.layoutWidth)
? Layout.compactPrimaryWidth
: Layout.primaryWidth
}
private var primaryButtonHeight: CGFloat {
state.layoutWidth > 0 && state.layoutWidth < 350
KeyboardChromeLayout.usesCompactVoiceMetrics(width: state.layoutWidth)
|| state.usesCompactHeightPhoneLayoutMetrics
? Layout.compactPrimaryHeight
: Layout.primaryHeight
}
@@ -1313,38 +1257,17 @@ struct AIKeyboardView: View {
.accessibilityHidden(activePipeline)
}
@ViewBuilder
/// No globe here: switching input modes stays on the typing tab.
private func lowerActionCluster(leading: Bool) -> some View {
let button = lowerSideButton(leading: leading)
lowerSideButton(leading: leading)
.offset(x: leading
? Layout.sideButtonEdgeInset
: -Layout.sideButtonEdgeInset)
if state.showsSystemGlobeKey {
if leading {
HStack(spacing: 8) {
SystemGlobeKey(
state: state,
width: compactIPadLayout ? 40 : 44,
height: lowerCircleSize
)
button
}
} else {
HStack(spacing: 8) {
button
Color.clear.frame(width: compactIPadLayout ? 40 : 44)
}
}
} else {
button
}
}
@ViewBuilder
private func lowerSideButton(leading: Bool) -> some View {
let swapped = state.handednessPreference.swapsActionKeys
let showsEdit = leading ? swapped : !swapped
if showsEdit {
if placesSpaceAndEdit(leading: leading) {
circleActionButton(
systemName: "pencil.line",
label: ExtL10n.string("keyboard.edit.apply"),
@@ -1422,12 +1345,123 @@ struct AIKeyboardView: View {
: Layout.fieldActionWidth
}
// MARK: - Key row (iPad, unfolded iPhone Duo)
/// `[space][edit][ send ][undo][delete]` for a right-handed setting;
/// handedness mirrors the row. Every key calls the same action as its phone
/// circle — only placement and key face differ. No globe here: switching
/// input modes stays on the typing tab.
private var voiceKeyRow: some View {
GeometryReader { proxy in
let row = VoiceKeyRowLayout.make(rowWidth: proxy.size.width)
let height = proxy.size.height
ZStack(alignment: .topLeading) {
keyRowSlot(row.leadingSide, height: height) { keyRowSideKey(leading: true) }
keyRowSlot(row.leadingContext, height: height) { keyRowContextKey(leading: true) }
keyRowSlot(row.send, height: height) { keyRowSendKey }
keyRowSlot(row.trailingContext, height: height) { keyRowContextKey(leading: false) }
keyRowSlot(row.trailingSide, height: height) { keyRowSideKey(leading: false) }
}
}
}
private func keyRowSlot<Content: View>(
_ slot: VoiceKeyRowLayout.Slot,
height: CGFloat,
@ViewBuilder content: () -> Content
) -> some View {
content()
.frame(width: slot.width, height: height)
.position(x: slot.midX, y: height / 2)
}
/// Keys stay in place while dictation / AI runs; they grey out instead of
/// hiding so the row never jumps and a held mic cannot hit them.
private var keyRowKeysEnabled: Bool {
assistantIsResting
}
@ViewBuilder
private func keyRowSideKey(leading: Bool) -> some View {
if placesSpaceAndEdit(leading: leading) {
RectangularToolbarButton(
systemName: "space",
label: ExtL10n.string("keyboard.assistant.space"),
disabled: !keyRowKeysEnabled,
hapticIntensity: state.keyboardHapticIntensity,
action: state.insertSpace
)
.accessibilityIdentifier("assistant.space")
} else {
RepeatingDeleteButton(
disabled: !keyRowKeysEnabled,
hapticIntensity: state.keyboardHapticIntensity,
action: state.deleteBackward
)
.accessibilityIdentifier("assistant.delete")
.accessibilityLabel(Text(ExtL10n.string("keyboard.assistant.delete")))
}
}
/// Undo / edit are always shown here and grey out while unavailable.
@ViewBuilder
private func keyRowContextKey(leading: Bool) -> some View {
if placesSpaceAndEdit(leading: leading) {
RectangularToolbarButton(
systemName: "pencil.line",
label: ExtL10n.string("keyboard.edit.apply"),
disabled: !state.editAvailable || !keyRowKeysEnabled,
hapticIntensity: state.keyboardHapticIntensity,
onPressingChanged: state.setMicTouchActive,
action: state.beginEditLastInput
)
.accessibilityIdentifier("assistant.edit")
.accessibilityHidden(!state.editAvailable)
} else {
RectangularToolbarButton(
systemName: "arrow.uturn.backward",
label: ExtL10n.string("keyboard.undoA11y"),
disabled: !state.undoAvailable || !keyRowKeysEnabled,
hapticIntensity: state.keyboardHapticIntensity,
action: state.undoLastInsertion
)
.accessibilityIdentifier("assistant.undo")
.accessibilityHidden(!state.undoAvailable)
}
}
/// Same icon, enablement and check-mark confirmation as `fieldActionButton`.
private var keyRowSendKey: some View {
Button(action: performFieldAction) {
Image(systemName: fieldActionSystemImage)
.font(TypeStyle.title3)
.foregroundStyle(fieldActionButtonForeground)
}
.buttonStyle(
NativeKeyboardKeyStyle(
fill: fieldActionButtonFill,
pressedFill: NativeKeyboardKeyColors.pressedFill(for: colorScheme),
border: palette.divider,
cornerRadius: KeyboardChromeLayout.actionKeyCornerRadius
)
)
.disabled(!state.assistantActionAvailable || activePipeline)
.opacity(activePipeline ? 0.38 : 1)
.accessibilityIdentifier(
"assistant.action.\(state.returnKeyRole.assistantActionIdentifier)"
)
.accessibilityLabel(ExtL10n.text(state.returnKeyRole.titleKey))
}
private var lowerCircleSize: CGFloat {
compactIPadLayout ? Layout.compactIPadCircleSize : Layout.circleSize
}
private var compactIPadLayout: Bool {
state.usesIPadLayoutMetrics && state.layoutWidth > 0 && state.layoutWidth < 420
KeyboardChromeLayout.usesCompactIPadVoiceMetrics(
isIPad: state.usesIPadLayoutMetrics,
width: state.layoutWidth
)
}
private func circleActionButton(
@@ -1532,7 +1566,7 @@ struct AIKeyboardView: View {
private func resetCarousel() {
reloadHintPool(resetBag: true)
guard !showsClipboardSkills, semanticBadgeContent == nil else { return }
guard semanticBadgeContent == nil else { return }
showNextHint(animated: false)
}
@@ -243,9 +243,7 @@ struct ClipboardHistoryPanelView: View {
}
if history.entries.isEmpty {
ExtL10n.text("keyboard.clipboard.panel.empty")
.font(.system(size: 14))
.foregroundStyle(palette.textSecondary)
emptyState
.frame(maxWidth: .infinity, maxHeight: .infinity)
} else {
ScrollView {
@@ -277,6 +275,26 @@ struct ClipboardHistoryPanelView: View {
.animation(.easeOut(duration: 0.16), value: showClearConfirmation)
}
/// Icon + title + one-line hint, so an empty panel explains what fills it.
private var emptyState: some View {
VStack(spacing: 6) {
Image(systemName: "doc.on.clipboard")
.font(.system(size: 22, weight: .regular))
.foregroundStyle(palette.textTertiary)
.padding(.bottom, 4)
.accessibilityHidden(true)
ExtL10n.text("keyboard.clipboard.panel.empty")
.font(.system(size: 14, weight: .semibold))
.foregroundStyle(palette.textSecondary)
ExtL10n.text("keyboard.clipboard.panel.emptyHint")
.font(TypeStyle.caption.weight(.regular))
.foregroundStyle(palette.textTertiary)
.multilineTextAlignment(.center)
}
.padding(.horizontal, 24)
.accessibilityElement(children: .combine)
}
private var clearConfirmationOverlay: some View {
VStack(spacing: 12) {
Image(systemName: "trash")
+22 -3
View File
@@ -8,6 +8,25 @@ struct LastInputEditView: View {
private enum Layout {
static let primaryButtonHeight = KeyboardChromeLayout.assistantActionCapsuleHeight
static let primaryButtonWidth: CGFloat = primaryButtonHeight * 3
static let pagesHeight: CGFloat = 174
}
/// Phone and iPad keep the fixed 281 pt column; the shorter expanded-phone
/// and landscape-phone shells take the difference out of the review pages.
private var surfaceHeight: CGFloat {
guard state.usesExpandedPhoneLayoutMetrics || state.usesCompactHeightPhoneLayoutMetrics else {
return KeyboardChromeLayout.totalHeight
}
return TypingSurfaceMetrics.contentHeight(
isIPad: false,
width: state.layoutWidth,
isExpandedPhone: state.usesExpandedPhoneLayoutMetrics,
isCompactHeightPhone: state.usesCompactHeightPhoneLayoutMetrics
)
}
private var pagesHeight: CGFloat {
Layout.pagesHeight - (KeyboardChromeLayout.totalHeight - surfaceHeight)
}
@Environment(\.colorScheme) private var colorScheme
@@ -32,17 +51,17 @@ struct LastInputEditView: View {
}
.allowsHitTesting(false)
}
.frame(height: 174)
.frame(height: pagesHeight)
.contentShape(Rectangle())
.simultaneousGesture(reviewSwipeGesture)
primaryRow.frame(height: 55)
}
.frame(maxWidth: KeyboardChromeLayout.voiceContentMaxWidth)
.frame(maxWidth: KeyboardChromeLayout.voiceContentWidthCap(usesVoiceKeyRow: state.usesVoiceKeyRow))
}
.padding(.vertical, 4)
.padding(.horizontal, KeyboardChromeLayout.horizontalInset)
.frame(maxWidth: .infinity)
.frame(height: KeyboardChromeLayout.totalHeight)
.frame(height: surfaceHeight)
.environment(\.themePalette, palette)
.onChange(of: state.editSession) { _, newValue in
guard let review = newValue.review else {
+3
View File
@@ -210,6 +210,7 @@
"keyboard.clipboard.guide.cta" = "Open Settings";
"keyboard.clipboard.panel.title" = "Clipboard";
"keyboard.clipboard.panel.empty" = "No history yet";
"keyboard.clipboard.panel.emptyHint" = "Text you copy shows up here.";
"keyboard.clipboard.panel.fullAccessHint" = "New copies are paused because Allow Full Access is off. Existing history remains available.";
"keyboard.clipboard.panel.delete" = "Delete";
"keyboard.clipboard.panel.close" = "Close clipboard";
@@ -370,6 +371,8 @@
"keyboard.ai.skill.createContact" = "New contact";
"keyboard.ai.skill.extractConclusions" = "Conclusions";
"keyboard.ai.skill.translate" = "Translate";
"keyboard.ai.skill.translateToTarget" = "Translate";
"keyboard.ai.skill.polishOriginal" = "Polish";
"keyboard.ai.skill.acceptInvitation" = "Accept";
"keyboard.ai.skill.declineInvitation" = "Decline";
"keyboard.ai.skill.acceptTask" = "Acknowledge";
@@ -210,6 +210,7 @@
"keyboard.clipboard.guide.cta" = "去设置开启";
"keyboard.clipboard.panel.title" = "剪贴板";
"keyboard.clipboard.panel.empty" = "暂无历史记录";
"keyboard.clipboard.panel.emptyHint" = "复制的文字会出现在这里。";
"keyboard.clipboard.panel.fullAccessHint" = "「允许完全访问」已关闭,新复制内容将暂停采集,已有历史仍可使用。";
"keyboard.clipboard.panel.delete" = "删除";
"keyboard.clipboard.panel.close" = "关闭剪贴板";
@@ -370,6 +371,8 @@
"keyboard.ai.skill.createContact" = "新建联系人";
"keyboard.ai.skill.extractConclusions" = "提取结论";
"keyboard.ai.skill.translate" = "翻译";
"keyboard.ai.skill.translateToTarget" = "翻译";
"keyboard.ai.skill.polishOriginal" = "优化";
"keyboard.ai.skill.acceptInvitation" = "接受邀约";
"keyboard.ai.skill.declineInvitation" = "委婉拒绝";
"keyboard.ai.skill.acceptTask" = "确认任务";