perf(keyboard): track KeyboardState per property with @Observable

- KeyboardState moves from ObservableObject/@Published to @Observable, so a view re-renders only for the properties it reads. Previously unpublished state and injected action hooks are @ObservationIgnored.
- KeyboardViewController replaces the $phase/$surface Combine sinks with Observations loops tied to the same cancellables.
- AIKeyboardView reads the 200 ms mic level and live ASR partials in small child views (AssistantWaveform, LiveTranscriptReader), so those ticks no longer rebuild the whole assistant surface.
- Tests observe changes with withObservationTracking instead of Combine publishers.
This commit is contained in:
2026-10-21 06:12:21 +02:00
parent 6e64f96dce
commit cfe14f67d4
17 changed files with 220 additions and 173 deletions
+52 -19
View File
@@ -64,7 +64,7 @@ struct AIKeyboardView: View {
@Environment(\.colorScheme) private var colorScheme
@Environment(\.accessibilityReduceMotion) private var reduceMotion
@ObservedObject var state: KeyboardState
var state: KeyboardState
@ObservedObject var typing: TypingSessionController
@ObservedObject private var clipboardHistory = ClipboardHistoryStore.shared
@ObservedObject private var semanticRanking = ClipboardSemanticRankingStore.shared
@@ -604,9 +604,9 @@ struct AIKeyboardView: View {
@ViewBuilder
private var contextArea: some View {
ZStack {
if showsOOBEClipboardSkill, activeStatus == nil {
if showsOOBEClipboardSkill, !hasActiveStatus {
clipboardSkillPager
} else if let onboardingPracticeHint, activeStatus == nil {
} else if let onboardingPracticeHint, !hasActiveStatus {
Text(onboardingPracticeHint)
.font(TypeStyle.footnote.weight(.semibold))
.foregroundStyle(palette.textPrimary)
@@ -636,8 +636,12 @@ struct AIKeyboardView: View {
}
} else if let content = semanticBadgeContent {
semanticBadge(content)
} else if let status = activeStatus {
statusText(status.text, color: status.color)
} else if hasActiveStatus {
LiveTranscriptReader(state: state) { transcript in
if let status = activeStatus(lastTranscript: transcript) {
statusText(status.text, color: status.color)
}
}
} else if showsLongPressCoach {
Text(ExtL10n.string("keyboard.assistant.longPressCoach"))
.font(TypeStyle.footnote.weight(.medium))
@@ -720,7 +724,14 @@ struct AIKeyboardView: View {
.frame(maxWidth: .infinity)
}
private var activeStatus: (text: String, color: Color)? {
/// Whether a status line replaces the idle content. Never depends on the
/// transcript text, so this surface does not re-render per ASR partial.
private var hasActiveStatus: Bool {
activeStatus(lastTranscript: "") != nil
}
/// `lastTranscript` is passed in so only `LiveTranscriptReader` tracks it.
private func activeStatus(lastTranscript: String) -> (text: String, color: Color)? {
if let error = state.aiSession.errorMessage,
!error.isEmpty,
!ordinaryPipelineIsActive {
@@ -757,18 +768,18 @@ struct AIKeyboardView: View {
return nil
case .requestingPermissions:
return (
state.lastTranscript.isEmpty
lastTranscript.isEmpty
? ExtL10n.string("keyboard.placeholder.preparing")
: state.lastTranscript,
: lastTranscript,
palette.textSecondary
)
case .recording:
return (state.lastTranscript.isEmpty ? " " : state.lastTranscript, palette.textPrimary)
return (lastTranscript.isEmpty ? " " : lastTranscript, palette.textPrimary)
case .processing:
return (
state.lastTranscript.isEmpty
lastTranscript.isEmpty
? ExtL10n.string("keyboard.placeholder.processing")
: state.lastTranscript,
: lastTranscript,
palette.textSecondary
)
case .error(_, let message):
@@ -1149,14 +1160,7 @@ struct AIKeyboardView: View {
}
private func waveform(color: Color) -> some View {
WaveformView(
level: state.level,
barCount: 7,
color: color,
active: true
)
.frame(width: 38, height: 26)
.clipped()
AssistantWaveform(state: state, color: color)
}
private var microphoneTint: Color {
@@ -1562,6 +1566,35 @@ struct AIKeyboardView: View {
/// Uses a subview's intrinsic width until it reaches a visual cap. Unlike
/// `frame(maxWidth:)`, this layout does not expand short labels to the cap,
/// so each capsule and its complete hit target follow the actual content.
/// Reads `state.level` in its own body so the 200 ms mic level tick re-renders
/// only these bars instead of the whole assistant surface.
private struct AssistantWaveform: View {
let state: KeyboardState
let color: Color
var body: some View {
WaveformView(
level: state.level,
barCount: 7,
color: color,
active: true
)
.frame(width: 38, height: 26)
.clipped()
}
}
/// Reads `state.lastTranscript` in its own body so per-word ASR partials
/// re-render only the status line.
private struct LiveTranscriptReader<Content: View>: View {
let state: KeyboardState
@ViewBuilder let content: (String) -> Content
var body: some View {
content(state.lastTranscript)
}
}
private struct IntrinsicWidthCap: Layout {
let maxWidth: CGFloat