Skip to content

Commit a55f06c

Browse files
centdixclaude
andauthored
fix: live web-chat updates for terminal-initiated Claude turns (#275)
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 3d35c34 commit a55f06c

2 files changed

Lines changed: 94 additions & 0 deletions

File tree

frontend/src/lib/MobileChatSurface.svelte

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@
4444
lastSignature: string | null;
4545
sawProgress: boolean;
4646
unchangedTicks: number;
47+
stopWhenIdle: boolean;
4748
} | null>(null);
4849
let streamConnection: {
4950
conversationId: string;
@@ -162,6 +163,7 @@
162163
163164
function startRefreshPolling(
164165
baselineConversation: AgentsUiConversationState | null = conversation,
166+
stopWhenIdle = false,
165167
): void {
166168
const baselineSignature = buildConversationProgressSignature(baselineConversation);
167169
refreshPollingState = {
@@ -170,6 +172,7 @@
170172
lastSignature: baselineSignature,
171173
sawProgress: false,
172174
unchangedTicks: 0,
175+
stopWhenIdle,
173176
};
174177
nextRefreshPollingToken += 1;
175178
}
@@ -181,6 +184,10 @@
181184
const currentState = refreshPollingState;
182185
if (!currentState || currentState.token !== token) return;
183186
187+
// Terminal-owned turns settle when the worktree agent goes idle (handled by the
188+
// busy-poll effect below), not via the message-progress heuristic used for sends.
189+
if (currentState.stopWhenIdle) return;
190+
184191
const nextSignature = buildConversationProgressSignature(nextConversation);
185192
const sawProgress = currentState.sawProgress || nextSignature !== currentState.baselineSignature;
186193
const unchangedTicks = nextSignature === currentState.lastSignature
@@ -256,6 +263,27 @@
256263
};
257264
});
258265
266+
$effect(() => {
267+
// A Claude turn started in the terminal (the initial worktree prompt, or anything
268+
// typed in the pane) is not a backend-owned run, so there is no stream to subscribe
269+
// to and the snapshot reports running:false. While the worktree agent is busy, poll
270+
// history so the terminal claude's flushed messages appear live; stop once it idles.
271+
const agentBusy = worktree.agent === "working";
272+
const isTerminalOwnedClaudeTurn =
273+
conversation?.provider === "claudeCode" && conversation.running !== true;
274+
275+
if (agentBusy && isTerminalOwnedClaudeTurn) {
276+
if (refreshPollingState === null) {
277+
startRefreshPolling(conversation, true);
278+
}
279+
return;
280+
}
281+
282+
if (refreshPollingState?.stopWhenIdle === true) {
283+
refreshPollingState = null;
284+
}
285+
});
286+
259287
$effect(() => {
260288
const pollingState = refreshPollingState;
261289
if (!pollingState) return;

frontend/src/lib/MobileChatSurface.test.ts

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -535,4 +535,70 @@ describe("MobileChatSurface", () => {
535535
expect(text.indexOf("Completed shell")).toBeLessThan(text.indexOf("Second assistant"));
536536
});
537537

538+
it("polls Claude history for a busy terminal-owned turn and stops when the agent goes idle", async () => {
539+
const userMessage = {
540+
id: "user-1",
541+
turnId: "turn-1",
542+
order: 0,
543+
role: "user" as const,
544+
kind: "text" as const,
545+
text: "Build the feature",
546+
status: "completed" as const,
547+
createdAt: "2026-05-28T10:00:00.000Z",
548+
};
549+
vi.mocked(attachWorktreeConversation).mockResolvedValue(
550+
createConversationResponse("claudeCode", {
551+
running: false,
552+
activeTurnId: null,
553+
messages: [userMessage],
554+
}),
555+
);
556+
vi.mocked(fetchWorktreeConversationHistory).mockResolvedValue(
557+
createConversationResponse("claudeCode", {
558+
running: false,
559+
activeTurnId: null,
560+
messages: [
561+
userMessage,
562+
{
563+
id: "assistant-1",
564+
turnId: "turn-1",
565+
order: 1,
566+
role: "assistant",
567+
kind: "text",
568+
text: "Done from terminal",
569+
status: "completed",
570+
createdAt: "2026-05-28T10:00:01.000Z",
571+
},
572+
],
573+
}),
574+
);
575+
576+
const { rerender } = render(MobileChatSurface, {
577+
props: {
578+
worktree: createWorktree({ agent: "working", status: "running" }),
579+
},
580+
});
581+
582+
await screen.findByText("Build the feature");
583+
// A terminal-owned turn has no backend stream to subscribe to.
584+
expect(connectWorktreeConversationStream).not.toHaveBeenCalled();
585+
586+
// Polling surfaces the terminal claude's flushed response live.
587+
await vi.advanceTimersByTimeAsync(1000);
588+
await waitFor(() => {
589+
expect(fetchWorktreeConversationHistory).toHaveBeenCalledWith("feature/mobile-chat");
590+
});
591+
await screen.findByText("Done from terminal");
592+
593+
// Polling keeps running while the agent is busy (it must not settle early).
594+
await vi.advanceTimersByTimeAsync(5000);
595+
const callsWhileBusy = vi.mocked(fetchWorktreeConversationHistory).mock.calls.length;
596+
expect(callsWhileBusy).toBeGreaterThan(1);
597+
598+
// When the run settles and the agent goes idle, polling stops.
599+
await rerender({ worktree: createWorktree({ agent: "waiting", status: "idle" }) });
600+
await vi.advanceTimersByTimeAsync(5000);
601+
expect(vi.mocked(fetchWorktreeConversationHistory).mock.calls.length).toBe(callsWhileBusy);
602+
});
603+
538604
});

0 commit comments

Comments
 (0)