feat(chat): embedded chat — view and reply to instance conversations (ADR-0016) #11
No reviewers
Labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set.
Reference
Cloonar/coding-lab!11
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "worktree-feat-embedded-chat"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Implements issue #7: the phone-first Chat view promised by ADR-0005's roadmap. A
/runs/:idsurface renders an instance's conversation and lets the operator reply, answer interactive dialogs, and interrupt — for manual instances and AFK runs alike. It complements the claude.ai deep link, never replaces it. Claude Code is the only provider implementation; the message schema and every mechanism sit behind theAgentProviderseam so a future Codex/Gemini chat is one new implementation, zero refactor.Design was settled with the operator on 2026-07-06 and is recorded in ADR-0016 (issue #1's GitHub-tracker PR claimed 0015, so this took the next free number as the issue instructed).
What's here
text | tool | dialog | lifecycleschema. One new nullable columnruns.transcript_path(migration0003, sqlite + postgres), captured async likedeep_link_urlso ended runs stay readable. No message table.tmux send-keys— bracketed-paste reply + Enter; pinned dialog keystroke recipes (single-questionAskUserQuestion; multi-question andExitPlanModeplan approval degrade to the deep-link hint since their options aren't in the transcript); Escape interrupt.internal/tmuxxgainsSendNamedKeys+PasteText.internal/chat— the read/act brain plus a self-managing tailer (bus subscriber) that derives conversational state for the instance list and publishes a debouncedrun.messages.changed; the chat view refetchesGET /runs/{id}/messageswith an append-only cursor.internal/compat(§5–§8) against Claude Code 2.1.198, with a captured transcript fixture and snapshot tests./runs/:idchat view (fixed composer, native dialog buttons, thinking toggle, confirm-guarded interrupt, composer states) + live state badges and Chat links on Dashboard and Runs rows.Docs
ADR-0016; CONTEXT.md (Chat / Transcript / Conversational state); ADR-0005 +
agent-brief.mdevent list now includerun.messages.changed; definition-of-done item 10.Verification
nix flake checkpasses — Go build/tests (real git+tmux+prlimit), golangci-lint, SPA vitest (266 tests incl. the new RunChat component test), NixOS module eval. Real-tmux integration exercises the new send primitives.Known boundary (documented, not a gap)
Dialog answering is wired for single-question
AskUserQuestiononly. Multi-questionAskUserQuestionand plan approval degrade to an "open in claude.ai" deep-link hint — their option widgets aren't in the structured transcript input, and the issue's own "never scrape the TUI widget" rule makes that the correct degradation. Recorded in ADR-0016 (Considered options → deferred) and compat.md §7 as revisitable once the widgets are captured live.Closes #7
🤖 Generated with Claude Code
/land-pr validation — verdict: FAIL (changes requested)
What was checked. CI has no runner (
ci/flake-checkpends forever), so nothing vouched for the PR: rannix flake checklocally on heada946701— all checks pass (Go build+tests, golangci-lint, web build, NixOS module eval). Full diff reviewed (46 files, +4231) against issue #7's settled decisions. Conventions OK: Conventional Commits title, ADR-0016 is the next free number, migration 0003 mirrored sqlite/postgres,Closes #7present, no conflicts. Each blocker below was verified by hand (code reading + live-transcript checks), not just flagged.Blockers
Failed tools render "ok".
internal/provider/claudecode/chat_types.go:65-74,163-192— real Claude Code 2.x transcripts putis_erroron thetool_resultblock, withcontenta plain string (verified against a live transcript on this host). The decoder readsis_erroronly from inner content items and hardcodesfalseon the string path, so every failing tool shows status "ok" — violating settled decision 9 ("errors always surfaced"). The fixtureinternal/compat/testdata/transcript-2.1.198.jsonlcarries the flag in both places, which is why the compat tests pass while masking this. Fix: addIsError booltotBlock, use it inpatchToolResult, and correct the fixture to the real shape.No scroll management in the chat view.
web/src/routes/RunChat.tsx+web/src/base.css:1333,1371—.chat-pageismin-height:100dvh, so.chat-stream'soverflow-y:autonever engages: the document scrolls and the view opens at the top, i.e. the oldest message of the latest window — contradicting the settled "latest window first". Appended messages land below the fold with no follow-bottom (live monitoring means manual scrolling after every event), and "Load earlier" prepends up to 60 messages with no scroll anchoring (iOS Safari, the phone-first target, has nooverflow-anchor). Needs: a bounded stream (height, notmin-height), scroll-to-bottom on mount, follow-bottom on append when already at bottom, prepend compensation.Opening an ended run's chat can permanently capture the successor run's transcript.
internal/chat/chat.go:128-163+internal/provider/claudecode/chat.go:52-62—Read→resolvePathlocates and persists for any outcome, andLocateTranscriptignores the session name, matching the newest live claude whose cwd is the worktree. For an ended run any hit is by definition a different, newer run (worktrees are reused across runs, as deeplink.go's own comment notes) — R1's row then permanently points at R2's transcript. Fix: don't locate (or at least don't persist) whenrun.Outcome != active; serve the "transcript no longer available" state instead.Concerns (should fix — several are one-liners)
internal/chat/chat.go:199-205—PendingDialogscans for any dialog message (lastDialog, despite its own "if the tail is one" comment) instead of requiringState == StateQuestionlike the Reply guard at :225-231. A rare abandoned mid-transcript dialog passes the tool_id match and fires picker keystrokes into a composer. (Trigger is rare — checked 30 live transcripts, interrupts do write synthetic tool_results — but the guard asymmetry is real and the fix is one line.)internal/httpapi/chat.go:186— the tool_id staleness check is opt-in (req.ToolID != ""); a caller omitting it answers whatever dialog is live by blind index, contradicting the handler's docstring. 400 on emptytool_id.internal/provider/claudecode/interact.go:108-156— multi-select validation gaps: empty or entirely out-of-rangeSelectedsilently produces a zero-selection confirm and returns 204; nothing rejects selecting the synthesized "Other" row in multi-select (Space there plausibly opens the free-text input and desyncs the recipe). Should be 400s like the single-select range check.web/src/routes/RunChat.tsx:61-71—loadLatestnever uses theafter=<cursor>protocol that ADR-0016 itself pins; more than 60 messages between refetches (the SSE watchdog tolerates 65s of silence) leaves a silent, unfillable gap mid-stream ("Load earlier" pages before the oldest message, not into the hole).web/src/routes/RunChat.tsx:61-98— concurrentloadLatestcalls have no sequencing; a stale response applying last can revert an answered dialog to pending and re-lock the composer, with possibly no further event to unstick it. Request token or AbortController.web/src/routes/RunChat.tsx:372-395—DialogPanelselection state isn't keyed todialog.tool_id; if the pending dialog changes identity while mounted, stale indices answer the new dialog — and pass the server guard, sinceprops.dialog.tool_idis read live.<Show keyed>on tool_id or reset on change.internal/chat/tailer.go:77,169-174— the deferredremove(sessionName)isn't generation-aware; session-name reuse (stop→start in the same minute reproduces the name) can delete the successor tailer's cancel, leaving an unmanaged tailer double-polling and double-publishing until shutdown. Store a generation token/pointer and delete only on identity match.internal/chat/tailer.go:27-49— the tailer set syncs only onrun.changed; one dropped bus event (16-slot buffer shared with chatty event types) desyncs it indefinitely — an ended run keeps a tailer, a new run gets none. A periodic resync ticker bounds the staleness.internal/provider/claudecode/chat.go:116-139—sc.Err()is never checked; a single line over the scanner cap silently truncates the transcript at that point forever (andCursorgoes backwards vs. what clients saw).internal/httpapi/chat.go:223-233— a dead tmux session under a still-active run surfaces as an opaque 500 ("internal error"); map to 409/404 with a "session is gone" message.StateIdleforever — the outcome override atinternal/chat/chat.go:141-144is unreachable from thepath == ""return at :133-136; an active run withtranscript: goneshows "This instance has ended" copy (RunChat.tsx:333); an ended never-located run reportstranscript:"available"with zero messages (internal/httpapi/chat.go:72-85).interact.go— each is 2..N tmux calls); concurrent POSTs interleave keystrokes at the tmux level (merged replies, wrong option selected).Server.crMuis the in-repo precedent for a keyed mutex.docs/definition-of-done.md:53— §10 claims Dashboard badge coverage under vitest; RunChat.test.tsx doesn't exercise badges, there's no InstanceList badge test, andweb/src/lib/conversation.tshas no test (every otherlib/*.tshas one).Nits (fix opportunistically)
validateReplyrejects\r, so CRLF API clients get a confusing 400 (interact.go:181) · tmux named buffer leaks when paste fails after load (tmuxx.go:296) · the bracketed-paste integration test passes identically without-p— it doesn't actually pin §6's property (integration_test.go:366) ·truncatecuts mid-UTF-8-rune → U+FFFD garbage (chat_types.go:206) · unfilteredrun.changedsubscription refetches this chat on every run's change fleet-wide (RunChat.tsx:93) · tool chips are 36px and the back button ~20px wide vs the settled 44px targets (base.css:1425) · the chat header drops the repo half ofrepo~labeland has no claude.ai fallback link whendeep_link_urlis null — the "open in claude.ai" dialog hint then points nowhere (RunChat.tsx:173,220,404) · the seq back-patching vs append-only-cursor tension (tool status / dialog flips mutate seqs ≤ cursor; the client compensates only within its 60-message window) deserves a note in ADR-0016 ·dialogPendingreads unders.ctxinstead of the request context and fails open (chat.go:226).Verified clean
Escaping is solid (argv-only exec, reply bytes via
load-bufferstdin — quotes/newlines/semicolons/unicode safe) · all chat routesrequireAuthinside the CSRF-guarded operator tree · intervention neutrality confirmed — the only store write on any chat path istranscript_path; nothing touches outcome, budget, claim, or strikes · migrations 0003 sqlite/postgres parity · no XSS (all message content renders as JSX text nodes) · SSE subscriptions cleaned up on route leave · ADR-0016 follows house style and records issue #7's decisions faithfully · CONTEXT.md gains Chat/Transcript/Conversational state · compat §5–§8 pins are real snapshot tests, with §7's fixture-only provenance honestly documented.Overall this is a well-built feature — the seam design, neutrality, and compat discipline are exactly as settled in issue #7. The three blockers are small, targeted fixes; re-invoke
/land-pronce they land.Review findings addressed —
fb75ccdAll three blockers, every concern, and the nits from the validation review are fixed in
fb75ccd.Blockers
is_erroris now read from thetool_resultblock (where real 2.x transcripts put it, string content); the compat fixture was corrected to the real grammar (it previously carried the flag in both places, masking the bug) and compat.md §5 documents the placement..chat-pageis a bounded viewport (height, notmin-height) so.chat-streamscrolls; scroll-to-bottom on the initial window, follow-bottom only when already near the end, manual prepend anchoring for "Load earlier". Scroll math lives inweb/src/lib/chatStream.tswith unit tests.resolvePathguards on the active outcome); an ended run that never captured a path serves the "gone" state. Covered byTestRead_endedRunNeverLocatesandTestAPI_ChatEndedRun_neverLocatedIsGone.Concerns — dialog answering re-reads the live dialog under a new per-session lock with a mandatory
tool_idmatch (400 missing, 409 mismatch;PendingDialoggates on the question state); reply/answer/interrupt serialize per session; multi-select answers validate (empty/out-of-range → 400, Other row → 409); the tailer's removal is generation-aware and a periodic resync bounds droppedrun.changedevents; transcript scan errors surface; a dead tmux session maps to 409 viatmuxx.ErrSessionNotFound; the SPA now usesafter=<cursor>tail batches (gap-free past the window limit) plus a monotonic fetch token against stale responses; DialogPanel state keys ontool_id; chat state resets on route-param change; state labels fixed (ended-without-transcript → gone; live-but-gone gets its own copy); DoD §10 now names real tests (newInstanceList.test.tsxbadge tests,conversation.test.ts,chatStream.test.ts).One deliberate deviation from the review text:
run.changedcannot be filtered byrunID— the wire envelope is repo-scoped ({type, repoID}from all three publishers) — so the chat filters by the run's repo instead.Nits — CRLF replies normalize; the paste buffer is reaped on a failed paste; the paste integration test now genuinely pins the ESC[200~ framing (the pane enables mode 2004); rune-safe truncation; repo-qualified header title + generic claude.ai fallback link; 44px tool chips and back button; "Load earlier" no longer resurrects; ADR-0016 records the back-patch/cursor tension.
Verification —
nix flake checkgreen onfb75ccd: Go suite (real git/tmux/prlimit), golangci-lint, SPA vitest (294 tests, 28 new), NixOS module eval.