From 333c61421b7a8acfeedc8bee025392b6b555f0be Mon Sep 17 00:00:00 2001 From: sionic-khope Date: Tue, 21 Jul 2026 16:13:54 +0900 Subject: [PATCH] Keep agent runs alive when the workspace closes and make notices selectable Clicking Miku to close the workspace was cancelling in-flight agent runs (store.stop() cancelled every session's run), surfacing "The agent run was stopped" errors mid-task. Closing now only stops the shells; runs keep streaming into their resident models and the transcript is waiting when the workspace reopens. Cancellation moves to terminate(), the app-quit path, where it still prevents orphaned subprocesses (thread deletion is unchanged). Error notices carry CLI diagnostics the user needs to copy out (e.g. codex version-mismatch errors), so notice and prompt text are now selectable like assistant messages. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_015peMZyaHEmxDzyzNyhYeR5 --- .../Agent/WorkspaceSessionStore.swift | 16 ++++++++++---- Sources/MikuCodeApp/AgentWorkspaceView.swift | 15 ++++++++++--- .../Companion/MikuPanelCoordinator.swift | 6 +++--- .../WorkspaceSessionStoreTests.swift | 21 ++++++++++++++++++- 4 files changed, 47 insertions(+), 11 deletions(-) diff --git a/Sources/MikuCodeApp/Agent/WorkspaceSessionStore.swift b/Sources/MikuCodeApp/Agent/WorkspaceSessionStore.swift index cbb0327..03afa9e 100644 --- a/Sources/MikuCodeApp/Agent/WorkspaceSessionStore.swift +++ b/Sources/MikuCodeApp/Agent/WorkspaceSessionStore.swift @@ -327,12 +327,20 @@ extension WorkspaceSessionStore: PresentationTerminalLifecycle { selected?.terminal.start() } + /// Closing the workspace stops the shells (saving their snapshots) but + /// deliberately keeps agent runs alive: the models stay resident, so a run + /// keeps streaming into its transcript and the result is waiting when the + /// workspace reopens. func stop() { isStarted = false - sessions.forEach { entry in - entry.agent.cancelRun() - entry.terminal.stop() - } + sessions.forEach { $0.terminal.stop() } persist() } + + /// App termination: additionally cancel active agent runs — once the app + /// exits nothing can observe or stop those subprocesses. + func terminate() { + sessions.forEach { $0.agent.cancelRun() } + stop() + } } diff --git a/Sources/MikuCodeApp/AgentWorkspaceView.swift b/Sources/MikuCodeApp/AgentWorkspaceView.swift index c686f89..d1620e1 100644 --- a/Sources/MikuCodeApp/AgentWorkspaceView.swift +++ b/Sources/MikuCodeApp/AgentWorkspaceView.swift @@ -1000,6 +1000,7 @@ private struct AgentPromptCard: View { Text(prompt.text) .font(.system(size: 14, weight: .regular)) .foregroundStyle(.white) + .textSelection(.enabled) } .padding(15) .frame(maxWidth: .infinity, alignment: .leading) @@ -1128,9 +1129,17 @@ private struct RunNoticeRow: View { let notice: AgentRunNotice var body: some View { - Label(notice.text, systemImage: notice.isError ? "exclamationmark.circle" : "info.circle") - .font(.system(size: 11, weight: .medium)) - .foregroundStyle(notice.isError ? AgentWorkspacePalette.error : AgentWorkspacePalette.muted) + HStack(alignment: .firstTextBaseline, spacing: 6) { + Image(systemName: notice.isError ? "exclamationmark.circle" : "info.circle") + .font(.system(size: 11, weight: .medium)) + // Error notices carry CLI diagnostics the user needs to copy out + // (version mismatches, denied tools), so the text is selectable. + Text(notice.text) + .font(.system(size: 11, weight: .medium)) + .textSelection(.enabled) + } + .foregroundStyle(notice.isError ? AgentWorkspacePalette.error : AgentWorkspacePalette.muted) + .frame(maxWidth: .infinity, alignment: .leading) } } diff --git a/Sources/MikuCodeApp/Companion/MikuPanelCoordinator.swift b/Sources/MikuCodeApp/Companion/MikuPanelCoordinator.swift index 557b579..0326cfb 100644 --- a/Sources/MikuCodeApp/Companion/MikuPanelCoordinator.swift +++ b/Sources/MikuCodeApp/Companion/MikuPanelCoordinator.swift @@ -136,9 +136,9 @@ final class MikuPanelCoordinator: NSObject, NSWindowDelegate { func shutdown() { _ = presentation.requestClose() // Termination does not run the closing animation task, so stop the - // threads directly: each terminal saves its snapshot and the store - // persists the thread index. - sessionStore.stop() + // threads directly: each terminal saves its snapshot, active agent + // runs are cancelled, and the store persists the thread index. + sessionStore.terminate() } func windowShouldClose(_ sender: NSWindow) -> Bool { diff --git a/Tests/MikuCodeAppTests/WorkspaceSessionStoreTests.swift b/Tests/MikuCodeAppTests/WorkspaceSessionStoreTests.swift index 840ddf3..51f228e 100644 --- a/Tests/MikuCodeAppTests/WorkspaceSessionStoreTests.swift +++ b/Tests/MikuCodeAppTests/WorkspaceSessionStoreTests.swift @@ -254,7 +254,7 @@ final class WorkspaceSessionStoreTests: XCTestCase { XCTAssertEqual(spy.cancelCount, 1) } - func testStopCancelsActiveAgentRunsInAllSessions() throws { + func testWorkspaceCloseKeepsActiveAgentRunsAlive() throws { let spy = StoreRunnerSpy() let store = WorkspaceSessionStore( repository: WorkspaceSessionsRepository(directoryURL: nil), @@ -265,8 +265,27 @@ final class WorkspaceSessionStoreTests: XCTestCase { entry.agent.prompt = "long run" entry.agent.submit(workingDirectory: nil) + // Closing the workspace must not kill the run — it keeps streaming and + // the transcript is waiting when the workspace reopens. store.stop() + XCTAssertEqual(spy.cancelCount, 0) + XCTAssertEqual(entry.agent.runLifecycle, .running) + } + + func testAppTerminationCancelsActiveAgentRunsInAllSessions() throws { + let spy = StoreRunnerSpy() + let store = WorkspaceSessionStore( + repository: WorkspaceSessionsRepository(directoryURL: nil), + shell: "/bin/sh", + makeAgent: { AgentWorkspaceModel(makeRunner: { _, _ in spy.makeRunner() }) } + ) + let entry = store.createSession() + entry.agent.prompt = "long run" + entry.agent.submit(workingDirectory: nil) + + store.terminate() + XCTAssertEqual(spy.cancelCount, 1) }