Skip to content

fix(web): pin the Claude response viewer to the pane's own conversation - #203

Merged
Ark0N merged 2 commits into
Ark0N:masterfrom
shenlvkang-collab:contrib/claude-viewer-session-pin
Aug 5, 2026
Merged

fix(web): pin the Claude response viewer to the pane's own conversation#203
Ark0N merged 2 commits into
Ark0N:masterfrom
shenlvkang-collab:contrib/claude-viewer-session-pin

Conversation

@shenlvkang-collab

@shenlvkang-collabshenlvkang-collab commented Aug 3, 2026

Copy link
Copy Markdown
Contributor

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 claude running 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.jsonl entry whose project equals the pane's cwd. That exists for a good reason: after /clear Claude writes to a fresh <uuid>.jsonl and the interactive PTY announces nothing, so the stored id would stay pinned to the pre-/clear transcript.

But project is just a cwd, and a cwd is shared by:

  • every other Codeman tab open on it (only excluded while they are live and already have a resolved claudeSessionId),
  • every tab that has since been closed,
  • any plain claude the 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 showResponseViewer on: a pane whose own conversation was d61621da… reported claudeSessionId = c4732758… — a conversation belonging to a claude process running in a separate WSL terminal in the same directory. GET /api/sessions/:id/last-response returned that other conversation's reply.

Fix

Credit a history entry to a pane only when the pane's own Enter vouches for it:

  • an entry must land within CLAUDE_SUBMIT_MATCH_MS (10s) of Session.lastSubmitAt, and
  • no other pane on the same cwd may have submitted closer (cross-pane arbitration), and
  • ids another live pane is already pinned to are still excluded.

This is the same last-submit correlation the Codex locator has used since #152; submit tracking simply moves from the codex-only _codexLastSubmitAt to a mode-agnostic Session.lastSubmitAt (a shell pane can run claude too, 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. lastSubmitAt is in-memory, so after a server restart a pane re-pins on its next Enter.

Tests

test/routes/session-routes-claude-last-response.test.ts gains four cases — the first three fail on master:

  • does not adopt a conversation from another claude process sharing the cwd (the reported bug)
  • stays put when the pane has never submitted through Codeman
  • credits a shared-cwd entry to the pane whose Enter is closest to it
  • still follows /clear onto the new conversation the pane submitted into (regression guard, passes both ways)

npm run typecheck, npm run lint and npm run format:check are clean. Full suite on this branch: 194 files pass, 9 fail — the 7 test/mobile/* files plus perf-browser and opencode-resize, all browser/CLI-driven and failing for environment reasons here (no WebKit, no visual baselines, no opencode). Re-running exactly those 9 files against unmodified master in 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 || id on every launch — including the branch
that re-attaches to a mux session which outlived the restart — so a pane whose
CLI had moved on via /clear came back pointing the viewer at its pre-/clear
transcript. And with lastSubmitAt living only in memory, nothing could correct
that 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 lastSubmitAt now round-trips through SessionState and is restored in
restoreMuxSessions(), and the viewer flushes it when it adopts (a /clear
emits no completion event, which is the trigger that would otherwise have
persisted 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.ts covers the round-trip; two of its four
cases fail without this commit.

shenlvkang-collaband others added 2 commits August 3, 2026 14:53
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.
@Ark0N
Ark0N merged commit b641560 into Ark0N:masterAug 5, 2026
2 checks passed
@Ark0N

Ark0N commented Aug 5, 2026

Copy link
Copy Markdown
Owner

Merged, thank you! 🙏

This was an excellent report and an even better fix. The root-cause writeup was the most valuable part: project being just a cwd, shared with closed tabs and with plain claude processes Codeman knows nothing about, is exactly the kind of thing that looks fine until it silently is not. Reusing the last-submit correlation the Codex locator already had (rather than inventing a second mechanism) is the right call, and moving submit tracking to a mode-agnostic Session.lastSubmitAt so shell panes participate in arbitration is a detail that would have been easy to miss.

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 /clear and the next prompt made the case better than any argument could have.

Verified here before merging:

The staleness guard comparing against the current conversation instead of session.id was a real latent bug on its own. Nice catch.

Thanks again for the depth here.

Sign up for freeto 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.

2 participants

@shenlvkang-collab@Ark0N