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
@@ -4,7 +4,7 @@
// Verifies that the transient suggestion belongs to one pasteboard generation
// and one keyboard presentation; clipboard history remains independently usable.
import Combine
import Observation
@testable import OSGKeyboardShared
import XCTest
@@ -136,17 +136,19 @@ final class ClipboardSuggestionLifecycleTests: XCTestCase {
func testUnchangedPollingDoesNotRepublishSuggestion() {
copy("stable clipboard")
coordinator.captureIfNeeded()
var publications = 0
let cancellable = state.$clipboardSuggestionText
.dropFirst()
.sink { _ in publications += 1 }
defer { cancellable.cancel() }
let republished = expectation(description: "suggestion republished")
republished.isInverted = true
withObservationTracking {
_ = state.clipboardSuggestionText
} onChange: {
republished.fulfill()
}
coordinator.captureIfNeeded()
coordinator.captureIfNeeded()
coordinator.captureIfNeeded()
XCTAssertEqual(publications, 0)
wait(for: [republished], timeout: 0.1)
XCTAssertEqual(pasteboard.stringReadCount, 1)
}
+7 -9
View File
@@ -13,7 +13,7 @@
// *don't* attempt that here — the State class is what we care about
// for correctness.
import Combine
import Observation
@testable import OSGKeyboardShared
import XCTest
@@ -189,19 +189,17 @@ final class KeyboardStateTests: XCTestCase {
)
state.enabledClipboardSkills = [first]
let published = expectation(description: "resolved skill snapshot published")
var cancellable: AnyCancellable?
cancellable = state.$enabledClipboardSkills
.dropFirst()
.sink { skills in
XCTAssertEqual(skills.first?.customPrompt, "Second prompt")
published.fulfill()
}
withObservationTracking {
_ = state.enabledClipboardSkills
} onChange: {
published.fulfill()
}
state.enabledClipboardSkills = [second]
wait(for: [published], timeout: 1)
XCTAssertEqual(state.enabledClipboardSkills.first?.customPrompt, "Second prompt")
XCTAssertEqual(state.enabledClipboardSkillIDs, ["official.rewrite"])
withExtendedLifetime(cancellable) {}
}
func testReturnKeyRoleActionFillAndTitles() {