Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 12 additions & 4 deletions Sources/MikuCodeApp/Agent/WorkspaceSessionStore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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()
}
}
15 changes: 12 additions & 3 deletions Sources/MikuCodeApp/AgentWorkspaceView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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)
}
}

Expand Down
6 changes: 3 additions & 3 deletions Sources/MikuCodeApp/Companion/MikuPanelCoordinator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
21 changes: 20 additions & 1 deletion Tests/MikuCodeAppTests/WorkspaceSessionStoreTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand All @@ -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)
}

Expand Down