Chat: pending dialog vanishes from the browser (picker still up in the TUI) and in-progress dialog answers are wiped by refetches #89

Closed
opened 2026-07-10 02:55:48 +02:00 by dominik.polakovics · 1 comment

Operator report: "Sometimes a question dialog disappears but is still in Claude Code. Sometimes I type and the thing I type in the question as answer just disappears and I have to type again."

Diagnosed live (2026-07-10). Two independent root causes, one per symptom.

1. Server: the spool's mtime staleness guard suppresses genuinely pending dialogs

claudecode.PendingDialog (dialogspool.go) treated a dialog spool as stale (rotation, issue #34) whenever the transcript's mtime was newer than the spool's, on the compat §5 premise that the transcript is byte-frozen during a pending window. That premise is false: in the live grill transcript d4be520a, during the pending window of toolu_018wW6XQsVmBc4yuscy6fvfz (23:52:54→23:54:06), Claude Code appended queue-operation + attachment entries at 23:53:21 — a message queued while the picker was up is written live. From that write on, the server suppressed the still-pending dialog: pending_dialog = null, state fell back to needs_input, the card vanished from the chat (any refetch or browser refresh shows it gone) while the TUI picker stayed up. Bonus hazard: with state no longer question, the composer unlocks and a free-text Reply can land in the focused picker.

Fix: staleness keyed to the spool payload's own session identity (session_id, falling back to the transcript_path stem) compared against the current transcript's filename stem — exact for the /clear//rewind rotation case, immune to mid-window transcript writes. A payload with no session identity degrades to the old mtime backstop. compat §5/§9 updated with the corrected ground truth.

2. Client: dialog input state wiped on every refetch

Every GET /messages response is a fresh JSON parse, so the same pending dialog arrives as a NEW object each SSE tick. Three compounding effects in RunChat:

  • DialogPanel's reset effect (on(() => props.dialog.tool_id, reset, {defer:true})) — Solid's on does not equality-gate, so any upstream write re-ran the reset and wiped selected/otherText mid-typing;
  • the same pattern in MultiQuestionForm (resetKey) wiped all picked answers;
  • the fresh object identity rippled through the option <For>, recreating the rows and the Other <input> element (focus loss under the operator's fingers).

Fix: pendingDialogField keeps its object identity while the same tool_id stays pending (dialog content is immutable per tool_id — the hook spools it once), and both reset effects key on an equality-gated createMemo of the identity. Regression tests assert picks, typed text, and the input element itself survive a same-dialog refetch, and that a real identity change still resets.

Repro method (server side): scratch claude 2.1.198 in tmux with lab's exact injected hook settings, spool/transcript mtimes sampled during pending windows — simple and back-to-back questions hold the frozen premise; the queued-message write is what breaks it (recovered from the live grill transcript's mid-window entry timestamps).

Operator report: "Sometimes a question dialog disappears but is still in Claude Code. Sometimes I type and the thing I type in the question as answer just disappears and I have to type again." Diagnosed live (2026-07-10). Two independent root causes, one per symptom. ## 1. Server: the spool's mtime staleness guard suppresses genuinely pending dialogs `claudecode.PendingDialog` (dialogspool.go) treated a dialog spool as stale (rotation, issue #34) whenever the transcript's mtime was newer than the spool's, on the compat §5 premise that the transcript is byte-frozen during a pending window. **That premise is false**: in the live grill transcript `d4be520a`, during the pending window of `toolu_018wW6XQsVmBc4yuscy6fvfz` (23:52:54→23:54:06), Claude Code appended `queue-operation` + `attachment` entries at 23:53:21 — a message queued while the picker was up is written live. From that write on, the server suppressed the still-pending dialog: `pending_dialog` = null, state fell back to needs_input, the card vanished from the chat (any refetch or browser refresh shows it gone) while the TUI picker stayed up. Bonus hazard: with state no longer `question`, the composer unlocks and a free-text Reply can land in the focused picker. Fix: staleness keyed to the spool payload's own session identity (`session_id`, falling back to the `transcript_path` stem) compared against the current transcript's filename stem — exact for the /clear//rewind rotation case, immune to mid-window transcript writes. A payload with no session identity degrades to the old mtime backstop. compat §5/§9 updated with the corrected ground truth. ## 2. Client: dialog input state wiped on every refetch Every `GET /messages` response is a fresh JSON parse, so the same pending dialog arrives as a NEW object each SSE tick. Three compounding effects in RunChat: - `DialogPanel`'s reset effect (`on(() => props.dialog.tool_id, reset, {defer:true})`) — Solid's `on` does not equality-gate, so any upstream write re-ran the reset and wiped `selected`/`otherText` mid-typing; - the same pattern in `MultiQuestionForm` (`resetKey`) wiped all picked answers; - the fresh object identity rippled through the option `<For>`, recreating the rows and the Other `<input>` element (focus loss under the operator's fingers). Fix: `pendingDialogField` keeps its object identity while the same `tool_id` stays pending (dialog content is immutable per tool_id — the hook spools it once), and both reset effects key on an equality-gated `createMemo` of the identity. Regression tests assert picks, typed text, and the input element itself survive a same-dialog refetch, and that a real identity change still resets. Repro method (server side): scratch claude 2.1.198 in tmux with lab's exact injected hook settings, spool/transcript mtimes sampled during pending windows — simple and back-to-back questions hold the frozen premise; the queued-message write is what breaks it (recovered from the live grill transcript's mid-window entry timestamps).
Author
Owner

This was generated by AI while landing a PR.

PR #90 audit — verdict: PASS

  • Scope check: git diff origin/main..branch appears to delete ~1100 lines (ADR-0036 conformance suite, providertest/conformance.go, etc.). Verified this is a stale-base artifact, not a real removal — those files were added to main by PR #88 after this branch was cut from 670195f; the branch simply predates them. The branch's real change (merge-base..head) is exactly the 5 files the body describes: dialogspool.go(+test), RunChat.tsx(+test), compat.md.
  • Conventional Commits: title fix(chat): …
  • AFK/issue link: body carries Closes #89
  • Verification signal: Forgejo CI combined status not readable from this surface (no forge API token), and no commit-time gate is declared — so I ran the project's own Go checks on the merged tree (branch + current main), which is the real post-merge signal the branch CI never saw (main gained the conformance suite after the branch was cut). Results:
    • go build/go vet clean; go test green for claudecode, providertest, provider, compat — main's new conformance suite and this diff coexist (the suite uses a fake provider, never touches the changed PendingDialog).
    • Three named regression tests present and green: TestPendingDialog_survivesTranscriptWritesDuringPending, TestPendingDialog_staleAfterTranscriptRotation, TestPendingDialog_legacyPayloadFallsBackToMtime.
    • Web gates (tsc/eslint/prettier/560 vitest) relied on from the branch's declared run — sound here because the merge adds nothing to the web tree (main's additions were all Go).
  • Correctness: server change keys rotation-staleness to session identity (session_id, transcript-stem fallback) instead of transcript mtime — same session served through mid-window writes, rotated session suppressed, identity-less payload degrades to the old mtime backstop. Client change gives the pending dialog stable object identity per tool_id and equality-gates both reset effects. Both map directly to the two root causes in #89.
  • Mergeable: clean merge into main, no conflicts.

No blockers. Awaiting explicit free-text go-ahead to merge.

> *This was generated by AI while landing a PR.* **PR #90 audit — verdict: PASS** - **Scope check**: `git diff origin/main..branch` appears to delete ~1100 lines (ADR-0036 conformance suite, `providertest/conformance.go`, etc.). Verified this is a **stale-base artifact**, not a real removal — those files were added to `main` by PR #88 *after* this branch was cut from `670195f`; the branch simply predates them. The branch's real change (merge-base..head) is exactly the 5 files the body describes: `dialogspool.go`(+test), `RunChat.tsx`(+test), `compat.md`. - **Conventional Commits**: title `fix(chat): …` ✓ - **AFK/issue link**: body carries `Closes #89` ✓ - **Verification signal**: Forgejo CI combined status not readable from this surface (no forge API token), and no commit-time gate is declared — so I ran the project's own Go checks on the **merged tree** (branch + current `main`), which is the real post-merge signal the branch CI never saw (main gained the conformance suite after the branch was cut). Results: - `go build`/`go vet` clean; `go test` green for `claudecode`, `providertest`, `provider`, `compat` — main's new conformance suite and this diff coexist (the suite uses a fake provider, never touches the changed `PendingDialog`). - Three named regression tests present and green: `TestPendingDialog_survivesTranscriptWritesDuringPending`, `TestPendingDialog_staleAfterTranscriptRotation`, `TestPendingDialog_legacyPayloadFallsBackToMtime`. - Web gates (tsc/eslint/prettier/560 vitest) relied on from the branch's declared run — sound here because the merge adds nothing to the web tree (main's additions were all Go). - **Correctness**: server change keys rotation-staleness to session identity (`session_id`, transcript-stem fallback) instead of transcript mtime — same session served through mid-window writes, rotated session suppressed, identity-less payload degrades to the old mtime backstop. Client change gives the pending dialog stable object identity per `tool_id` and equality-gates both reset effects. Both map directly to the two root causes in #89. - **Mergeable**: clean merge into `main`, no conflicts. No blockers. Awaiting explicit free-text go-ahead to merge.
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
Cloonar/coding-lab#89
No description provided.