fix(web): pin the Claude response viewer to the pane's own conversation - #203
Conversation
The viewer re-derived a pane's live conversation from the newest ~/.claude/history.jsonl entry for the pane's cwd. A cwd is shared with every other Codeman tab on it, with tabs long since closed, and with any plain `claude` the user runs in their own terminal, so the eye followed whichever of those was typed into last — and since the match was written back through adoptClaudeSessionId(), the mispin stuck. Credit a history entry to a pane only when it lands within 10s of that pane's own Enter and no other pane on the same cwd submitted closer, reusing the last-submit correlation the Codex locator already relies on. Submit tracking moves from _codexLastSubmitAt to a mode-agnostic Session.lastSubmitAt. With no correlated entry the pane keeps the id it has: a viewer one turn behind beats a viewer showing someone else's conversation. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
start() reassigns _claudeSessionId to `resumeSessionId || id` on every launch, including the path that re-attaches to a mux session that outlived the restart. A pane whose CLI had moved on via /clear therefore came back pointing the response viewer at its pre-/clear transcript, and because Session.lastSubmitAt lived only in memory, the history correlation had nothing to correct it with until the user happened to type again — observed as hours of the eye showing a conversation the pane had long since left. Persist lastSubmitAt in SessionState, restore it in restoreMuxSessions(), and flush it when the viewer adopts (a /clear emits no completion event, which is the trigger that would otherwise have persisted it). Recovered panes now re-derive their live conversation on the viewer's first poll. Restoring a stale anchor is safe: the resolver already refuses a candidate transcript older than the one the pane is currently on, which is the shape of a respawn into a fresh conversation.
Uh oh!
There was an error while loading. Please reload this page.
Ark0N
commented
Aug 5, 2026
Merged, thank you! 🙏 This was an excellent report and an even better fix. The root-cause writeup was the most valuable part: The follow-up on restart survival is what turned this from a good fix into a complete one. The measured four-and-a-half-hour window between the Verified here before merging:
The staleness guard comparing against the current conversation instead of Thanks again for the depth here. |
Summary
The Claude response viewer (the eye) regularly shows a different session's conversation. On a machine with several tabs open on the same working directory — or with one plain
clauderunning in a normal terminal — the eye stops following its own pane and starts rendering someone else's transcript.Root cause
resolveActiveClaudeSessionIdFromHistory()re-derives a pane's live conversation by taking the newest~/.claude/history.jsonlentry whoseprojectequals the pane's cwd. That exists for a good reason: after/clearClaude writes to a fresh<uuid>.jsonland the interactive PTY announces nothing, so the stored id would stay pinned to the pre-/cleartranscript.But
projectis just a cwd, and a cwd is shared by:claudeSessionId),claudethe user runs in their own terminal, which Codeman knows nothing about.So the newest entry frequently belongs to someone else, and the mtime guard does not catch it — the impostor genuinely is the more recently written transcript. Worse, the result is written back through
session.adoptClaudeSessionId(), so a single mispin sticks and also leaks into subagent matching, ultracode window binding and the resume seed.Observed on 1.8.0 with
showResponseVieweron: a pane whose own conversation wasd61621da…reportedclaudeSessionId = c4732758…— a conversation belonging to aclaudeprocess running in a separate WSL terminal in the same directory.GET /api/sessions/:id/last-responsereturned that other conversation's reply.Fix
Credit a history entry to a pane only when the pane's own Enter vouches for it:
CLAUDE_SUBMIT_MATCH_MS(10s) ofSession.lastSubmitAt, andThis is the same last-submit correlation the Codex locator has used since #152; submit tracking simply moves from the codex-only
_codexLastSubmitAtto a mode-agnosticSession.lastSubmitAt(a shell pane can runclaudetoo, so every pane on the cwd participates in arbitration).The sanity check that follows now compares the candidate against the conversation we are currently on rather than against
session.id— on a pane that had already adopted once, the old baseline was a file that often did not exist, which silently disabled the guard entirely.When nothing correlates, the pane keeps the id it has. A viewer one turn behind is strictly better than a viewer showing another session's conversation.
lastSubmitAtis in-memory, so after a server restart a pane re-pins on its next Enter.Tests
test/routes/session-routes-claude-last-response.test.tsgains four cases — the first three fail onmaster:claudeprocess sharing the cwd (the reported bug)/clearonto the new conversation the pane submitted into (regression guard, passes both ways)npm run typecheck,npm run lintandnpm run format:checkare clean. Full suite on this branch: 194 files pass, 9 fail — the 7test/mobile/*files plusperf-browserandopencode-resize, all browser/CLI-driven and failing for environment reasons here (no WebKit, no visual baselines, noopencode). Re-running exactly those 9 files against unmodifiedmasterin the same environment reproduces the same 9 failures, so none of them are caused by this change.Follow-up: the anchor has to survive a restart
The correlation above only helps while the process lives.
start()reassigns_claudeSessionId = resumeSessionId || idon every launch — including the branchthat re-attaches to a mux session which outlived the restart — so a pane whose
CLI had moved on via
/clearcame back pointing the viewer at its pre-/cleartranscript. And with
lastSubmitAtliving only in memory, nothing could correctthat until the user happened to type again.
Measured on a real box: a pane
/cleared at 16:15, Codeman restarted at 16:36,and the eye showed the abandoned conversation until the next prompt at 21:04 —
four and a half hours of exactly the symptom this PR set out to fix.
So
lastSubmitAtnow round-trips throughSessionStateand is restored inrestoreMuxSessions(), and the viewer flushes it when it adopts (a/clearemits no
completionevent, which is the trigger that would otherwise havepersisted it). A recovered pane re-derives its live conversation on the viewer's
first poll, with no user input.
Restoring a stale anchor is safe: the resolver already refuses a candidate
transcript older than the one the pane is currently on, which is the shape of a
respawn into a fresh conversation.
test/session-submit-anchor.test.tscovers the round-trip; two of its fourcases fail without this commit.