From 3f7f98165fb5f06f266deb6a9510a262d834f5ba Mon Sep 17 00:00:00 2001 From: Junwei Zhang <79990049+salathleizhang@users.noreply.github.com> Date: Tue, 25 Aug 2026 20:38:10 +0800 Subject: [PATCH 1/2] fix(recall): allow capture to resume after stopping --- apps/AfterRay/Sources/AfterRayApp.swift | 47 +++++++++-------- .../Sources/AfterRayControlModel.swift | 8 ++- .../Tests/AfterRayControlModelTests.swift | 50 +++++++++++++++++++ 3 files changed, 84 insertions(+), 21 deletions(-) diff --git a/apps/AfterRay/Sources/AfterRayApp.swift b/apps/AfterRay/Sources/AfterRayApp.swift index 1251f08b..d40a9648 100644 --- a/apps/AfterRay/Sources/AfterRayApp.swift +++ b/apps/AfterRay/Sources/AfterRayApp.swift @@ -321,7 +321,8 @@ private final class AfterRayMenuBar: NSObject { private var statusItem: NSStatusItem? private var pauseItem: NSMenuItem? private var computeItem: NSMenuItem? - private var isRecording = false + private var recordingState: DaemonRecordingState? + private var isChangingRecording = false private var shortcut = RecallHotKeyStore.shared.hotKey private var preferenceObserver: NSObjectProtocol? @@ -437,8 +438,8 @@ private final class AfterRayMenuBar: NSObject { statusItem = nil } - func setRecording(_ isRecording: Bool) { - self.isRecording = isRecording + func setRecordingState(_ recordingState: DaemonRecordingState?) { + self.recordingState = recordingState refresh() } @@ -463,19 +464,20 @@ private final class AfterRayMenuBar: NSObject { @objc private func toggleCapture() { Task { - guard !AfterRayTerminationState.shared.isTerminating else { return } - let daemon = UnixSocketDaemonClient(socketPath: DaemonSupervisor.shared.socketPath) - do { - if isRecording { - _ = try await daemon.recordStop(reason: "menu") - isRecording = false - } else { - _ = try await daemon.recordStart() - isRecording = true - } + guard !AfterRayTerminationState.shared.isTerminating, + !isChangingRecording + else { return } + isChangingRecording = true + refresh() + defer { + isChangingRecording = false refresh() - } catch { - AfterRayLog.error(error.localizedDescription, source: "menu") + } + let control = AfterRayServices.shared.control + let changed = await control.toggleRecording() + recordingState = control.status?.recordingState + if !changed, let message = control.message { + AfterRayLog.error(message, source: "menu") } } } @@ -502,11 +504,16 @@ private final class AfterRayMenuBar: NSObject { guard let button = statusItem?.button else { return } statusItem?.isVisible = true button.image = Self.icon() - button.alphaValue = isRecording ? 1 : 0.46 + let captureIsActive = switch recordingState { + case .waiting, .recording, .stopping: true + case .idle, .failed, nil: false + } + button.alphaValue = captureIsActive ? 1 : 0.46 let copy = AfterRayLocalization.shared.copy - let state = isRecording ? copy.menu.recording : copy.menu.paused + let state = captureIsActive ? copy.menu.recording : copy.menu.paused button.toolTip = copy.menu.tooltip(state, shortcut.displayString) - pauseItem?.title = isRecording ? copy.menu.pauseCapture : copy.menu.resumeCapture + pauseItem?.title = captureIsActive ? copy.menu.pauseCapture : copy.menu.resumeCapture + pauseItem?.isEnabled = !isChangingRecording && recordingState != .stopping } private static func icon() -> NSImage { @@ -1515,8 +1522,8 @@ private struct AfterRayRootView: View { .onChange(of: isLive) { _, live in if live { audioPlayer.stop() } } - .onChange(of: control.isRecording, initial: true) { _, isRecording in - AfterRayMenuBar.shared.setRecording(isRecording) + .onChange(of: control.status?.recordingState, initial: true) { _, recordingState in + AfterRayMenuBar.shared.setRecordingState(recordingState) } .task { await bootstrap() diff --git a/swift/AfterRayRecall/Sources/AfterRayControlModel.swift b/swift/AfterRayRecall/Sources/AfterRayControlModel.swift index ee669fc5..aca1b45e 100644 --- a/swift/AfterRayRecall/Sources/AfterRayControlModel.swift +++ b/swift/AfterRayRecall/Sources/AfterRayControlModel.swift @@ -29,7 +29,7 @@ public final class AfterRayControlModel: ObservableObject { } } public var canToggleRecording: Bool { - !isChangingRecording && status?.recordingState != .stopping + !isChangingRecording } public func refreshStatus() async { @@ -74,6 +74,12 @@ public final class AfterRayControlModel: ObservableObject { isChangingRecording = true defer { isChangingRecording = false } do { + // Menu-bar and overlay actions share this model, but the daemon can + // also change state after lock/sleep or another surface's command. + // Refresh before choosing start versus stop so a stale UI snapshot + // can never send the same command twice and strand capture paused. + status = try await daemon.status() + guard status?.recordingState != .stopping else { return false } if isCaptureSessionActive { _ = try await daemon.recordStop(reason: "pause") } else { diff --git a/swift/AfterRayRecall/Tests/AfterRayControlModelTests.swift b/swift/AfterRayRecall/Tests/AfterRayControlModelTests.swift index 3d2a0521..74558096 100644 --- a/swift/AfterRayRecall/Tests/AfterRayControlModelTests.swift +++ b/swift/AfterRayRecall/Tests/AfterRayControlModelTests.swift @@ -20,6 +20,56 @@ final class AfterRayControlModelTests: XCTestCase { XCTAssertEqual(commands, ["start", "stop"]) } + func testToggleRefreshesStaleRecordingStateBeforeChoosingTheCommand() async { + let daemon = ControlDaemon() + await daemon.setRecordingState(.recording) + let model = AfterRayControlModel(daemon: daemon) + await model.refreshStatus() + + // Capture was stopped elsewhere after this model last refreshed. The + // next click must start from the daemon's idle state, not issue stop a + // second time from the stale local snapshot. + await daemon.setRecordingState(.idle) + let changed = await model.toggleRecording() + + XCTAssertTrue(changed) + XCTAssertTrue(model.isRecording) + let commands = await daemon.recordCommands + XCTAssertEqual(commands, ["start"]) + } + + func testToggleRefreshesStaleIdleStateBeforeChoosingTheCommand() async { + let daemon = ControlDaemon() + let model = AfterRayControlModel(daemon: daemon) + await model.refreshStatus() + + // The inverse race matters too: capture started from another surface, + // so the stale idle model must stop that live session instead of + // sending a duplicate start. + await daemon.setRecordingState(.recording) + let changed = await model.toggleRecording() + + XCTAssertTrue(changed) + XCTAssertFalse(model.isRecording) + let commands = await daemon.recordCommands + XCTAssertEqual(commands, ["stop"]) + } + + func testToggleDoesNotStayDisabledByAStaleStoppingState() async { + let daemon = ControlDaemon() + await daemon.setRecordingState(.stopping) + let model = AfterRayControlModel(daemon: daemon) + await model.refreshStatus() + + await daemon.setRecordingState(.idle) + let changed = await model.toggleRecording() + + XCTAssertTrue(changed) + XCTAssertTrue(model.isRecording) + let commands = await daemon.recordCommands + XCTAssertEqual(commands, ["start"]) + } + func testWaitingIsNotRecordingButSessionIsActive() async { let daemon = ControlDaemon() await daemon.setRecordingState(.waiting) From ad8bed5741eb7e38a4f9796987dd7e79380f8161 Mon Sep 17 00:00:00 2001 From: Zixuan Chen Date: Thu, 27 Aug 2026 20:11:37 +0800 Subject: [PATCH 2/2] fix(recall): resume capture from a single control path Menu extra no longer talks to the daemon or keeps its own recording snapshot. Toggle refreshes daemon status before choosing start versus stop, and a failed start rolls back optimistic waiting so the next click actually starts capture. Model: grok-4.6 Harness: lody --- apps/AfterRay/Sources/AfterRayApp.swift | 33 +++++++------------ .../Sources/AfterRayControlModel.swift | 29 ++++++++++++---- .../Tests/AfterRayControlModelTests.swift | 24 ++++++++++---- 3 files changed, 52 insertions(+), 34 deletions(-) diff --git a/apps/AfterRay/Sources/AfterRayApp.swift b/apps/AfterRay/Sources/AfterRayApp.swift index d40a9648..1fe1dc15 100644 --- a/apps/AfterRay/Sources/AfterRayApp.swift +++ b/apps/AfterRay/Sources/AfterRayApp.swift @@ -321,8 +321,6 @@ private final class AfterRayMenuBar: NSObject { private var statusItem: NSStatusItem? private var pauseItem: NSMenuItem? private var computeItem: NSMenuItem? - private var recordingState: DaemonRecordingState? - private var isChangingRecording = false private var shortcut = RecallHotKeyStore.shared.hotKey private var preferenceObserver: NSObjectProtocol? @@ -438,8 +436,7 @@ private final class AfterRayMenuBar: NSObject { statusItem = nil } - func setRecordingState(_ recordingState: DaemonRecordingState?) { - self.recordingState = recordingState + func captureStatusDidChange() { refresh() } @@ -464,18 +461,11 @@ private final class AfterRayMenuBar: NSObject { @objc private func toggleCapture() { Task { - guard !AfterRayTerminationState.shared.isTerminating, - !isChangingRecording - else { return } - isChangingRecording = true - refresh() - defer { - isChangingRecording = false - refresh() - } + guard !AfterRayTerminationState.shared.isTerminating else { return } let control = AfterRayServices.shared.control + guard control.canToggleRecording else { return } let changed = await control.toggleRecording() - recordingState = control.status?.recordingState + refresh() if !changed, let message = control.message { AfterRayLog.error(message, source: "menu") } @@ -502,18 +492,16 @@ private final class AfterRayMenuBar: NSObject { private func refresh() { guard let button = statusItem?.button else { return } + let control = AfterRayServices.shared.control + let captureIsActive = control.isCaptureSessionActive statusItem?.isVisible = true button.image = Self.icon() - let captureIsActive = switch recordingState { - case .waiting, .recording, .stopping: true - case .idle, .failed, nil: false - } button.alphaValue = captureIsActive ? 1 : 0.46 let copy = AfterRayLocalization.shared.copy let state = captureIsActive ? copy.menu.recording : copy.menu.paused button.toolTip = copy.menu.tooltip(state, shortcut.displayString) pauseItem?.title = captureIsActive ? copy.menu.pauseCapture : copy.menu.resumeCapture - pauseItem?.isEnabled = !isChangingRecording && recordingState != .stopping + pauseItem?.isEnabled = control.canToggleRecording } private static func icon() -> NSImage { @@ -1522,8 +1510,11 @@ private struct AfterRayRootView: View { .onChange(of: isLive) { _, live in if live { audioPlayer.stop() } } - .onChange(of: control.status?.recordingState, initial: true) { _, recordingState in - AfterRayMenuBar.shared.setRecordingState(recordingState) + .onChange(of: control.status?.recordingState, initial: true) { _, _ in + AfterRayMenuBar.shared.captureStatusDidChange() + } + .onChange(of: control.isChangingRecording) { _, _ in + AfterRayMenuBar.shared.captureStatusDidChange() } .task { await bootstrap() diff --git a/swift/AfterRayRecall/Sources/AfterRayControlModel.swift b/swift/AfterRayRecall/Sources/AfterRayControlModel.swift index aca1b45e..57dfeed4 100644 --- a/swift/AfterRayRecall/Sources/AfterRayControlModel.swift +++ b/swift/AfterRayRecall/Sources/AfterRayControlModel.swift @@ -64,6 +64,7 @@ public final class AfterRayControlModel: ObservableObject { } catch { AfterRayLog.error("ensureRecording: \(error.localizedDescription)") message = error.localizedDescription + await restoreStatusPreservingMessage() return false } } @@ -74,17 +75,20 @@ public final class AfterRayControlModel: ObservableObject { isChangingRecording = true defer { isChangingRecording = false } do { - // Menu-bar and overlay actions share this model, but the daemon can - // also change state after lock/sleep or another surface's command. - // Refresh before choosing start versus stop so a stale UI snapshot - // can never send the same command twice and strand capture paused. + // Menu and overlay share this model. Refresh before choosing the + // command so a stale local snapshot cannot send stop (or start) + // twice and leave capture paused. status = try await daemon.status() - guard status?.recordingState != .stopping else { return false } if isCaptureSessionActive { _ = try await daemon.recordStop(reason: "pause") } else { markWaitingOptimistically() - _ = try await daemon.recordStart() + do { + _ = try await daemon.recordStart() + } catch { + await restoreStatusPreservingMessage() + throw error + } } status = try await daemon.status() message = nil @@ -197,8 +201,19 @@ public final class AfterRayControlModel: ObservableObject { protocolVersion: status.protocolVersion, schemaVersion: status.schemaVersion, recordingState: .waiting, - activeSessionId: status.activeSessionId + activeSessionId: status.activeSessionId, + hostBuild: status.hostBuild, + cliEvidenceUntilMs: status.cliEvidenceUntilMs ) } } + + /// `refreshStatus()` would clear `message`. A failed start has already + /// recorded the error; we only want the daemon's real recording state back + /// so the next click does not treat optimistic `.waiting` as a live session. + private func restoreStatusPreservingMessage() async { + if let latest = try? await daemon.status() { + status = latest + } + } } diff --git a/swift/AfterRayRecall/Tests/AfterRayControlModelTests.swift b/swift/AfterRayRecall/Tests/AfterRayControlModelTests.swift index 74558096..9f329b5d 100644 --- a/swift/AfterRayRecall/Tests/AfterRayControlModelTests.swift +++ b/swift/AfterRayRecall/Tests/AfterRayControlModelTests.swift @@ -55,19 +55,23 @@ final class AfterRayControlModelTests: XCTestCase { XCTAssertEqual(commands, ["stop"]) } - func testToggleDoesNotStayDisabledByAStaleStoppingState() async { + func testToggleFailedStartDoesNotLeaveAnOptimisticWaitingState() async { let daemon = ControlDaemon() - await daemon.setRecordingState(.stopping) let model = AfterRayControlModel(daemon: daemon) await model.refreshStatus() - await daemon.setRecordingState(.idle) - let changed = await model.toggleRecording() + await daemon.setStartShouldFail(true) + let failed = await model.toggleRecording() + XCTAssertFalse(failed) + XCTAssertFalse(model.isCaptureSessionActive) + XCTAssertEqual(model.message, "start exploded") - XCTAssertTrue(changed) + await daemon.setStartShouldFail(false) + let retried = await model.toggleRecording() + XCTAssertTrue(retried) XCTAssertTrue(model.isRecording) let commands = await daemon.recordCommands - XCTAssertEqual(commands, ["start"]) + XCTAssertEqual(commands, ["start", "start"]) } func testWaitingIsNotRecordingButSessionIsActive() async { @@ -204,6 +208,7 @@ private actor ControlDaemon: AfterRayDaemonServing { var lastAskQuestion: String? var modelMissing = false var askShouldFail = false + var startShouldFail = false func setModelMissing(_ value: Bool) { modelMissing = value @@ -213,6 +218,10 @@ private actor ControlDaemon: AfterRayDaemonServing { askShouldFail = value } + func setStartShouldFail(_ value: Bool) { + startShouldFail = value + } + func setRecordingState(_ value: DaemonRecordingState) { recordingState = value } @@ -229,6 +238,9 @@ private actor ControlDaemon: AfterRayDaemonServing { func recordStart() async throws -> RecordStartResult { recordCommands.append("start") + if startShouldFail { + throw DaemonClientError.rejected("start exploded") + } recordingState = .recording return RecordStartResult(sessionId: "s1") }