Skip to content

fix: live web-chat updates for terminal-initiated Claude turns - #275

Merged
centdix merged 1 commit into
mainfrom
fix-webchat-terminal-turn-polling
Jun 20, 2026
Merged

fix: live web-chat updates for terminal-initiated Claude turns#275
centdix merged 1 commit into
mainfrom
fix-webchat-terminal-turn-polling

Conversation

@centdix

@centdix centdix commented Jun 19, 2026

Copy link
Copy Markdown
Contributor

Summary

The Claude web chat (MobileChatSurface.svelte) only live-streams turns sent from the web chat. Those run as a backend-owned claude -p tracked by claudeConversationStreamService, which broadcasts WS stream events the frontend subscribes to.

The initial worktree-creation prompt (and anything typed in the terminal pane) instead launches an interactive claude -- <prompt> in the tmux pane, so the stream service has no run for it. On attach, the conversation snapshot reports running: false, syncConversationStream opens nothing, and (since #262 removed the history-polling fallback on attach) the chat shows only the static attach-time snapshot and never updates live.

This PR restores message-level live updates for terminal-owned Claude turns by reusing the existing refresh-polling machinery — no second polling system, no backend changes.

Root cause

  • Terminal-initiated turns are not backend-owned runs → no stream events → conversation.running stays false.
  • syncConversationStream only opens a stream when running === true (or on a forced send), so a fresh attach to a busy terminal turn subscribes to nothing.
  • There is no longer a history-polling fallback on attach, so the response never appears until a manual refresh.

Fix (frontend-only)

  • New $effect that, while the worktree agent is busy (worktree.agent === "working") and the conversation is a Claude conversation that is not backend-streaming (provider === "claudeCode" and running !== true), starts the existing startRefreshPolling loop so fetchWorktreeConversationHistory is polled and the terminal claude's flushed messages appear live.
  • Polling stops when the agent goes idle (the prop flips away from "working"), so it never runs forever on an idle worktree.
  • startRefreshPolling gains a stopWhenIdle flag; for these terminal-owned polls updateRefreshPollingState skips the message-progress settle heuristic (which can stop early mid-turn) and instead relies on the agent-busy signal to gate the lifecycle. Send/interrupt-initiated polls are unchanged (stopWhenIdle defaults to false).

Orthogonal to #274 (keep-stream-open across turns): this change does not touch syncConversationStream.

Changes

  • frontend/src/lib/MobileChatSurface.svelte
    • Add stopWhenIdle to refreshPollingState and startRefreshPolling.
    • Skip the settle heuristic for stopWhenIdle polls in updateRefreshPollingState.
    • Add the busy-worktree controller $effect that starts/stops history polling for terminal-owned Claude turns.
  • frontend/src/lib/MobileChatSurface.test.ts
    • Add a test that lands on a busy Claude worktree with no backend stream, asserts history is polled (so the response appears) and keeps polling while busy, then stops once the agent goes idle.

Test plan

  • New test fails before the fix (history never polled → waitFor times out) and passes after.
  • bun run test in frontend — 118 passed (15 files), including existing stream/keep-open and terminal-routed-send tests.
  • bun run check in frontend — 0 errors, 0 warnings.
  • Svelte autofixer on the component — no issues.
  • Manual: create a worktree with a prompt, open its web chat while the terminal claude runs → assistant response appears live without manual refresh; polling stops after the run settles.
  • Manual: type a prompt directly in the terminal pane of an existing worktree → web chat updates live.

Generated with Claude Code

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@claude

claude Bot commented Jun 19, 2026

Copy link
Copy Markdown

Claude finished @centdix's task in 2m 39s —— View job


Code review: PR #275 — live web-chat updates for terminal-initiated Claude turns

  • Read changed files & surrounding context
  • Analyze the polling lifecycle logic
  • Review the test
  • Post review feedback

Overall this is a clean, well-scoped frontend-only fix. The stopWhenIdle flag cleanly separates the two polling lifecycles (send/interrupt-owned vs. terminal-owned), the gating signal is correct, and the effect interactions converge without loops. I traced the lifecycle in detail and didn't find a blocking bug. A few notes below.

What's correct

  • Gating signal is right. worktree.agent === "working" maps from backend status running/starting/creating (api.ts:24-39), so it covers both the initial worktree-creation prompt and anything typed in the pane, and flips to "waiting" when the terminal claude returns to its prompt. Good choice.
  • No conflict with backend-owned (web-chat) runs. A web send sets running: true via markConversationTurnStarted (worktree-conversation.ts:181), so isTerminalOwnedClaudeTurn (MobileChatSurface.svelte:272-273) is false for those turns and the terminal poll never starts alongside the stream. The premise (terminal turns stay running:false) holds.
  • No effect loop. The busy effect both reads and writes refreshPollingState, but each write drives the next re-run into an early-return branch (start path → !== null returns; idle path → ?.stopWhenIdle is undefined → no write). It converges.
  • Idle open-stream coexistence is safe. Since fix: keep claude chat stream open across turns to preserve message order #274 keeps a stream open across turns, a terminal poll can run while an idle streamConnection exists, but syncConversationStream short-circuits on hasActiveConversationStream (MobileChatSurface.svelte:120), so the poll's applyConversationResponse won't double-open or fight the stream.
  • Test is solid. It asserts no stream is opened, history is polled, the flushed response appears, polling continues while busy, and stops on idle — covering the full lifecycle.

Non-blocking considerations

  1. Unbounded 1 req/s history fetch for long terminal turns. REFRESH_POLL_INTERVAL_MS = 1000 is shared, and for terminal-owned turns the settle heuristic is bypassed (MobileChatSurface.svelte:187-189), so a long-running terminal claude (e.g. a multi-minute task) will fetch the full conversation history once per second for the entire turn. That's heavier than the send-poll case, which settles after a few unchanged ticks. Worth considering a longer interval or light back-off for stopWhenIdle polls as a follow-up. Not blocking.

  2. 1-tick handoff gap (cosmetic). If a send-initiated poll (stopWhenIdle: false) is still active when a terminal turn begins, the busy effect sees refreshPollingState !== null and defers (MobileChatSurface.svelte:276). If that send poll then settles via the heuristic mid-terminal-turn, there's a one-tick gap before the busy effect restarts a terminal poll on the next conversation change. Harmless (next poll catches up), just noting the seam.

  3. Optional: a negative test to lock the gate. The current test proves terminal turns do poll. Consider adding the inverse — a claudeCode conversation with running: true and agent: "working" should not start this poll (it relies on the stream). That would guard against a future regression where the running !== true guard is loosened. Add negative test →

Parity note (per AGENTS.md)

This is a web-chat live-update fix with no CLI-facing surface, so the frontend/CLI parity rule doesn't apply here. ✅

No changes requested — the implementation looks correct and the reasoning in the PR description matches the code. The two items above are follow-up suggestions, not blockers.

@cloudflare-workers-and-pages

Copy link
Copy Markdown

Deploying with  Cloudflare Workers  Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

Status Name Latest Commit Preview URL Updated (UTC)
✅ Deployment successful!
View logs
webmux f20fef9 Commit Preview URL

Branch Preview URL
Jun 19 2026, 11:53 PM

@centdix
centdix marked this pull request as ready for review June 20, 2026 07:12
@centdix
centdix merged commit a55f06c into main Jun 20, 2026
4 checks passed
@centdix
centdix deleted the fix-webchat-terminal-turn-polling branch June 20, 2026 07:12
@claude

claude Bot commented Jun 20, 2026

Copy link
Copy Markdown

Claude finished @centdix's task in 2m 5s —— View job


Code review: PR #275 — live web-chat updates for terminal-initiated Claude turns

  • Read changed files & surrounding context
  • Analyze the polling lifecycle logic
  • Review the test
  • Post review feedback

This is a clean, correctly-scoped frontend-only fix. I traced the full polling lifecycle and the effect interactions against the surrounding code (api.ts, worktree-conversation.ts) and found no blocking bug. The stopWhenIdle flag is the right seam to keep the two polling lifecycles (send/interrupt-owned vs. terminal-owned) from interfering. (Note: PR is already merged — feedback is for follow-up.)

What's correct

  • Gating signal is accurate. mapAgentStatus (api.ts:24-39) maps backend creating/running/starting"working" and idle"waiting", so worktree.agent === "working" covers the initial worktree-creation prompt and anything typed in the pane, and flips off as soon as the terminal claude returns to its prompt. Good lifecycle anchor.
  • Premise holds — terminal turns stay running:false. A history fetch returns running:false; mergeConversationSnapshot keeps it false unless there's a preserved optimistic turn (worktree-conversation.ts:162), which only web sends create. So isTerminalOwnedClaudeTurn (MobileChatSurface.svelte:272-273) is correctly true only for terminal-owned turns.
  • No conflict with backend-owned web-chat runs. A web send sets running:true via markConversationTurnStarted (worktree-conversation.ts:181), so the terminal effect's running !== true guard excludes those — the poll never races the stream.
  • No effect loop. The busy effect reads and writes refreshPollingState, but each write drives the next re-run into an early-return branch (start path → !== null returns; idle path → ?.stopWhenIdle is undefined, no write). It converges.
  • Interval effect identity is stable for terminal polls. Because updateRefreshPollingState early-returns for stopWhenIdle polls (:187-189) without reassigning refreshPollingState, the interval $effect doesn't tear down/recreate the timer on each conversation update — the timer runs steadily for the whole turn.
  • Stream coexistence is safe. syncConversationStream short-circuits on hasActiveConversationStream (:120), so a poll's applyConversationResponse won't double-open or fight a stream left open by fix: keep claude chat stream open across turns to preserve message order #274.
  • Test is thorough. It lands on a busy Claude worktree, asserts no stream opens, history is polled, the flushed response appears, polling continues while busy (guarding against early settle), and stops on the idle rerender. Full lifecycle coverage.

Non-blocking considerations

  1. Unbounded 1 req/s full-history fetch for long terminal turns. REFRESH_POLL_INTERVAL_MS = 1000 is shared, and stopWhenIdle polls bypass the settle heuristic (:187-189), so a multi-minute terminal task fetches the entire conversation history once per second for the whole turn — heavier than send polls, which settle after a few unchanged ticks. Consider a longer interval or light back-off for stopWhenIdle polls. Add back-off →

  2. Codex terminal turns aren't covered. The guard is provider === "claudeCode", so a Codex turn started in the terminal pane has the same no-stream/no-live-update gap and won't get this polling. Likely intentional given the PR's Claude scope, but worth confirming Codex doesn't share the symptom (or noting it as a known follow-up).

  3. 1-tick handoff seam (cosmetic). If a send-initiated poll (stopWhenIdle:false) is still active when a terminal turn begins, the busy effect defers (refreshPollingState !== null). If that send poll then settles mid-terminal-turn, there's a one-tick gap before the busy effect restarts a terminal poll on the next conversation change. Harmless — the next poll catches up.

  4. Optional negative test. The test proves terminal turns do poll; consider the inverse — a claudeCode conversation with running:true + agent:"working" should not start this poll (it relies on the stream) — to lock the running !== true guard against future loosening.

Parity note (per AGENTS.md)

Web-chat live-update fix with no CLI-facing surface, so the frontend/CLI parity rule doesn't apply. ✅

Verdict: Implementation is correct and the reasoning in the PR description matches the code. All four items above are follow-ups, not blockers.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant