From e84a3ff57407f05cf0d0e1dde68367df9b047b0b Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Mon, 15 Jun 2026 14:31:37 +0700 Subject: [PATCH] Clear terminal attention when the user views the terminal A finished or input-waiting terminal shows an attention indicator. The focus heartbeat already asked the daemon to clear it, but the clear was a no-op: it guarded on activity state === "attention", while the real tracker encodes attention as state "idle" plus an attentionReason ("finished" / "needs_input"). The state is never literally "attention", so the indicator only ever cleared when the agent started a new turn. The worker proxy also had no way to reach the tracker's clearAttention(): it only spoke setActivity, and setActivity("idle") is short-circuited by the tracker while a "finished" reason is set. Add a clearAttention worker message that calls the existing manager.clearTerminalAttention(), and guard both clear paths on attentionReason instead of state. --- .../src/terminal/terminal-worker-process.ts | 6 +++ .../src/terminal/terminal-worker-protocol.ts | 5 ++ .../terminal/worker-terminal-manager.test.ts | 46 ++++++++++++++++--- .../src/terminal/worker-terminal-manager.ts | 10 ++-- 4 files changed, 55 insertions(+), 12 deletions(-) diff --git a/packages/server/src/terminal/terminal-worker-process.ts b/packages/server/src/terminal/terminal-worker-process.ts index cf21ee279..2afb31ea2 100644 --- a/packages/server/src/terminal/terminal-worker-process.ts +++ b/packages/server/src/terminal/terminal-worker-process.ts @@ -258,6 +258,12 @@ async function handleRequest(message: TerminalWorkerRequest): Promise { return; } + case "clearAttention": { + await manager.clearTerminalAttention(message.terminalId); + sendToParent({ type: "response", requestId: message.requestId, ok: true }); + return; + } + case "killTerminal": { const session = manager.getTerminal(message.terminalId); const cwd = session?.cwd; diff --git a/packages/server/src/terminal/terminal-worker-protocol.ts b/packages/server/src/terminal/terminal-worker-protocol.ts index 4de6438e0..cb82a23c5 100644 --- a/packages/server/src/terminal/terminal-worker-protocol.ts +++ b/packages/server/src/terminal/terminal-worker-protocol.ts @@ -54,6 +54,11 @@ export type TerminalWorkerRequest = terminalId: string; state: TerminalActivityState; } + | { + type: "clearAttention"; + requestId: string; + terminalId: string; + } | { type: "killTerminal"; requestId: string; diff --git a/packages/server/src/terminal/worker-terminal-manager.test.ts b/packages/server/src/terminal/worker-terminal-manager.test.ts index 3ed00c349..3fb2c936c 100644 --- a/packages/server/src/terminal/worker-terminal-manager.test.ts +++ b/packages/server/src/terminal/worker-terminal-manager.test.ts @@ -665,7 +665,7 @@ it("sets terminal activity through a worker request", async () => { await expect(result).resolves.toBe(true); }); -it("clears terminal attention through a worker activity update", async () => { +it("clears terminal attention through a worker request", async () => { const worker = new FakeTerminalWorker(); manager = createWorkerTerminalManager({ requestTimeoutMs: 50, @@ -677,19 +677,16 @@ it("clears terminal attention through a worker activity update", async () => { id: "terminal-a", name: "Shell", cwd: "/workspace", - activity: { state: "attention", changedAt: 1000 }, + activity: { state: "idle", attentionReason: "finished", changedAt: 1000 }, }, state: createTerminalState(), }); const result = manager.clearTerminalAttention("terminal-a"); - const request = worker.sentMessages.find( - (message) => message.type === "setActivity" && message.state === "idle", - ); + const request = worker.sentMessages.find((message) => message.type === "clearAttention"); expect(request).toMatchObject({ - type: "setActivity", + type: "clearAttention", terminalId: "terminal-a", - state: "idle", }); if (!request) { throw new Error("attention clear request not sent"); @@ -699,6 +696,41 @@ it("clears terminal attention through a worker activity update", async () => { await expect(result).resolves.toBe(true); }); +it("clears finished attention on a real terminal", async () => { + const cwd = mkdtempSync(join(tmpdir(), "worker-terminal-manager-attention-")); + temporaryDirs.push(cwd); + manager = createWorkerTerminalManager(); + const session = trackTerminal( + await manager.createTerminal({ + cwd, + ...nodeTerminalCommand("setInterval(() => {}, 1000);"), + }), + ); + + // A working -> idle transition is how the real tracker records a "finished" + // attention: { state: "idle", attentionReason: "finished" }. The state is + // never literally "attention", so a clear that checks state === "attention" + // would never fire — the bug this reproduces. + await manager.setTerminalActivity(session.id, "working"); + await manager.setTerminalActivity(session.id, "idle"); + await waitForCondition( + () => manager?.getTerminal(session.id)?.getActivity()?.attentionReason === "finished", + 5000, + ); + + const cleared = await manager.clearTerminalAttention(session.id); + + expect(cleared).toBe(true); + await waitForCondition( + () => manager?.getTerminal(session.id)?.getActivity()?.attentionReason == null, + 5000, + ); + expect(manager.getTerminal(session.id)?.getActivity()).toEqual({ + state: "idle", + changedAt: expect.any(Number), + }); +}); + it("removes worker terminals after killAndWait", async () => { const cwd = mkdtempSync(join(tmpdir(), "worker-terminal-manager-kill-")); temporaryDirs.push(cwd); diff --git a/packages/server/src/terminal/worker-terminal-manager.ts b/packages/server/src/terminal/worker-terminal-manager.ts index 591123708..ed23868dd 100644 --- a/packages/server/src/terminal/worker-terminal-manager.ts +++ b/packages/server/src/terminal/worker-terminal-manager.ts @@ -294,11 +294,11 @@ export function createWorkerTerminalManager( sendBestEffortRequest({ type: "setActivity", terminalId: record.info.id, state }); }, clearActivityAttention(): boolean { - if (record.activity?.state !== "attention") { + if (record.activity?.attentionReason == null) { return false; } - record.activity = { state: "idle", changedAt: Date.now() }; - sendBestEffortRequest({ type: "setActivity", terminalId: record.info.id, state: "idle" }); + record.activity = { state: record.activity.state, changedAt: Date.now() }; + sendBestEffortRequest({ type: "clearAttention", terminalId: record.info.id }); return true; }, getSize(): { rows: number; cols: number } { @@ -753,10 +753,10 @@ export function createWorkerTerminalManager( async clearTerminalAttention(id: string): Promise { const record = recordsById.get(id); - if (!record || record.activity?.state !== "attention") { + if (!record || record.activity?.attentionReason == null) { return false; } - await sendRequest({ type: "setActivity", terminalId: id, state: "idle" }); + await sendRequest({ type: "clearAttention", terminalId: id }); return true; },