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.
This commit is contained in:
@@ -258,6 +258,12 @@ async function handleRequest(message: TerminalWorkerRequest): Promise<void> {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
case "clearAttention": {
|
||||||
|
await manager.clearTerminalAttention(message.terminalId);
|
||||||
|
sendToParent({ type: "response", requestId: message.requestId, ok: true });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
case "killTerminal": {
|
case "killTerminal": {
|
||||||
const session = manager.getTerminal(message.terminalId);
|
const session = manager.getTerminal(message.terminalId);
|
||||||
const cwd = session?.cwd;
|
const cwd = session?.cwd;
|
||||||
|
|||||||
@@ -54,6 +54,11 @@ export type TerminalWorkerRequest =
|
|||||||
terminalId: string;
|
terminalId: string;
|
||||||
state: TerminalActivityState;
|
state: TerminalActivityState;
|
||||||
}
|
}
|
||||||
|
| {
|
||||||
|
type: "clearAttention";
|
||||||
|
requestId: string;
|
||||||
|
terminalId: string;
|
||||||
|
}
|
||||||
| {
|
| {
|
||||||
type: "killTerminal";
|
type: "killTerminal";
|
||||||
requestId: string;
|
requestId: string;
|
||||||
|
|||||||
@@ -665,7 +665,7 @@ it("sets terminal activity through a worker request", async () => {
|
|||||||
await expect(result).resolves.toBe(true);
|
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();
|
const worker = new FakeTerminalWorker();
|
||||||
manager = createWorkerTerminalManager({
|
manager = createWorkerTerminalManager({
|
||||||
requestTimeoutMs: 50,
|
requestTimeoutMs: 50,
|
||||||
@@ -677,19 +677,16 @@ it("clears terminal attention through a worker activity update", async () => {
|
|||||||
id: "terminal-a",
|
id: "terminal-a",
|
||||||
name: "Shell",
|
name: "Shell",
|
||||||
cwd: "/workspace",
|
cwd: "/workspace",
|
||||||
activity: { state: "attention", changedAt: 1000 },
|
activity: { state: "idle", attentionReason: "finished", changedAt: 1000 },
|
||||||
},
|
},
|
||||||
state: createTerminalState(),
|
state: createTerminalState(),
|
||||||
});
|
});
|
||||||
|
|
||||||
const result = manager.clearTerminalAttention("terminal-a");
|
const result = manager.clearTerminalAttention("terminal-a");
|
||||||
const request = worker.sentMessages.find(
|
const request = worker.sentMessages.find((message) => message.type === "clearAttention");
|
||||||
(message) => message.type === "setActivity" && message.state === "idle",
|
|
||||||
);
|
|
||||||
expect(request).toMatchObject({
|
expect(request).toMatchObject({
|
||||||
type: "setActivity",
|
type: "clearAttention",
|
||||||
terminalId: "terminal-a",
|
terminalId: "terminal-a",
|
||||||
state: "idle",
|
|
||||||
});
|
});
|
||||||
if (!request) {
|
if (!request) {
|
||||||
throw new Error("attention clear request not sent");
|
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);
|
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 () => {
|
it("removes worker terminals after killAndWait", async () => {
|
||||||
const cwd = mkdtempSync(join(tmpdir(), "worker-terminal-manager-kill-"));
|
const cwd = mkdtempSync(join(tmpdir(), "worker-terminal-manager-kill-"));
|
||||||
temporaryDirs.push(cwd);
|
temporaryDirs.push(cwd);
|
||||||
|
|||||||
@@ -294,11 +294,11 @@ export function createWorkerTerminalManager(
|
|||||||
sendBestEffortRequest({ type: "setActivity", terminalId: record.info.id, state });
|
sendBestEffortRequest({ type: "setActivity", terminalId: record.info.id, state });
|
||||||
},
|
},
|
||||||
clearActivityAttention(): boolean {
|
clearActivityAttention(): boolean {
|
||||||
if (record.activity?.state !== "attention") {
|
if (record.activity?.attentionReason == null) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
record.activity = { state: "idle", changedAt: Date.now() };
|
record.activity = { state: record.activity.state, changedAt: Date.now() };
|
||||||
sendBestEffortRequest({ type: "setActivity", terminalId: record.info.id, state: "idle" });
|
sendBestEffortRequest({ type: "clearAttention", terminalId: record.info.id });
|
||||||
return true;
|
return true;
|
||||||
},
|
},
|
||||||
getSize(): { rows: number; cols: number } {
|
getSize(): { rows: number; cols: number } {
|
||||||
@@ -753,10 +753,10 @@ export function createWorkerTerminalManager(
|
|||||||
|
|
||||||
async clearTerminalAttention(id: string): Promise<boolean> {
|
async clearTerminalAttention(id: string): Promise<boolean> {
|
||||||
const record = recordsById.get(id);
|
const record = recordsById.get(id);
|
||||||
if (!record || record.activity?.state !== "attention") {
|
if (!record || record.activity?.attentionReason == null) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
await sendRequest({ type: "setActivity", terminalId: id, state: "idle" });
|
await sendRequest({ type: "clearAttention", terminalId: id });
|
||||||
return true;
|
return true;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user