fix(ui): deliver reader replies on resolved threads and signal presence truthfully - #25
Merged
Conversation
A reader asked a question in a thread, got silence, wrote "Hey", and got silence again. The thread was `resolved` with `needsAgent: false`, so `wait` never saw it and `threads list --open` reported none. Three changes: - A message from the reviewer forces the thread back to `open`. A reply that left it resolved sat outside `needsAgent`, invisible to every surface an agent reads, while the page still offered a Reply button. - `wait` writes a heartbeat while it runs, and the page reads it back: each thread says whether it is held, queued, delivered or answered, and the panel says whether an agent is listening at all. Presence is never inferred, so a queued question says it is queued. - The comment box submits with one click that names its consequence - "Send to the agent" or "Add to the review" - instead of a mode toggle plus a button labelled "Save". Claude-Session: https://claude.ai/code/session_018uReZ5twzaPNJTYqJdsVCP
… listening banner can't stay stale
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Intent
Fix a reported defect in thurview: a reader asked a question in a published document's thread, got silence, wrote 'Hey', got silence again. Forensics on the operator's evidence (document f90d2474, thread add40960) showed the thread was status=resolved with needsAgent=false, so
thurview waitnever reported it andthreads list --opensaid '0 open threads' while a real unanswered question sat there. The task brief demanded: (1) find the actual cause rather than guess between candidates, (2) write a failing test first, (3) give the reader a truthful signal after sending that distinguishes 'delivered, waiting' from 'nothing is listening', without faking presence, (4) fix the submit control, which said 'Save' - the operator explicitly invited a better interaction, not just a relabel, and (5) document the state machine where the repo documents thread rules.Cause established by elimination, not guessed: threads.json is written only by src/threads.ts; setThreadStatus has exactly three callers (CLI
threads resolve, API /resolve, API /reopen); no agent transcript ever ranthreads resolve add40960; createThread hardcodes status 'open' (the first new test proves it); and publish never writes threads.json. So the resolve came from the browser. The unrecoverable part is a real code defect: replyThread never reopened, so a reviewer message on a resolved thread left needsAgent=false - a message that provably reaches nobody behind a Reply button that implies otherwise. That is the failing test (it fails 'resolved' vs 'open' on the old code).Deliberate decisions a diff reader would not know:
thurview wait, to ~/.thurview/agents/.json (deliberately outside reviewDir, which the server fs.watch()es for SSE - a file rewritten every 3s there would reload the page under the reader's hands and clear their textarea). TTL 15s. Deleted in a finally so the page says 'not listening' within seconds of wait returning. Presence is never inferred from anything else, per the brief's 'do not fake presence'.Local gate is green: pnpm typecheck, prettier, rumdl, shellcheck, 56 vitest tests, bats. The new UI was verified in a real headless browser against a real server and a real
thurview wait: green 'An agent is listening' with wait running, amber 'Queued' without it.What Changed
replyThread(src/threads.ts) now reopens a thread whenever a reviewer sends a message, so a reply on aresolvedthread is no longer silently dropped from the agent's queue (needsAgent/threads list --open/waitpreviously never saw it); agent replies do not change status.needsAgentmoved into a new shared pure modulesrc/thread-state.ts, which also addsdeliveryOf(held/queued/listening/answered/closed) so the CLI queue and the browser cannot disagree on a thread's state.src/presence.ts: a heartbeat-based liveness signal written only by an activethurview wait(attach/presenceOf, 15s TTL, 3s beat, stored under~/.thurview/agents/<id>.json, outside the server's watched review directory) so the UI can truthfully show "an agent is listening" vs. "queued" without inferring presence from anything else. Wired throughsrc/server/server.ts,src/store.ts,src/cli.ts, andsrc/ui/api.ts.src/ui/threads.ts,src/ui/app.css,src/ui/app.ts): replaced the mode-toggle + "Save" popover with two one-click actions ("Send to the agent" / "Add to the review" or "Add to my notes"), each showing what happens; added a receipt line per thread and a document-level listening indicator; Cmd+Enter now sends instead of defaulting to review mode; the "Resolve" button reads "Withdraw" while an answer is still owed.test/e2e.test.tswith new coverage (createThread defaults toopen, reply reopens a resolved thread, presence attach/detach, delivery states) and updatedscripts/demo/record-browser.mjsto click the new "Add to the review" control by text.needsAgentformula, and presence mechanism inskills/thurview/references/lifecycle.md, updated the agent workflow inskills/thurview/SKILL.md, and refreshed the reader-facing feature list inREADME.md.Risk Assessment
✅ Low: The fix-round commit adds a small, well-scoped 5s presence-polling loop plus a matching /presence endpoint that closes the exact staleness gap identified in round 1 (an open tab's 'listening' banner going stale after
waittimes out with no thread activity), verified against real HTTP endpoints in the new test; it reuses existing state-merge/emit patterns so it does not reintroduce the draft-clearing risk the original architecture was built to avoid, and the rest of the diff (already reviewed in round 1) remains internally consistent with the stated intent.Testing
Ran the smallest relevant automated test (the new e2e describe block covering thread reopen-on-reply and the presence API) on the target commit — all 5 pass — and reproduced the regression by running the identical tests against the base commit in a disposable worktree, where 3 fail for exactly the reasons the intent describes (resolved-vs-open, missing presence endpoints). Because the core of this change is reader-facing UI (a redesigned submit control and a live presence banner), I also built the app for real and drove it end-to-end in a headless Chromium against a live server and a live
thurview waitprocess: captured the new two-button submit control, the amber 'Queued'/'Withdraw' state with no agent listening, the banner going green 'listening' live via polling (the specific fix for the previously-flagged stale-banner finding, verified without any page reload), and the banner correctly reverting to 'not listening' once the heartbeat TTL lapsed. All behavior matches the required acceptance criteria; no code defects found. All test processes, the headless browser, and build/demo artifacts were cleaned up, and the worktree is git-clean./home/magicletur/.no-mistakes/evidence/01M23TGQGNDBG0KJEDDDGN7EJ0/02-popover-no-agent.png)/home/magicletur/.no-mistakes/evidence/01M23TGQGNDBG0KJEDDDGN7EJ0/03-thread-queued-withdraw.png)thurview waitrunning: banner turns green 'An agent is listening' via polling (no reload), thread answered so button is 'Resolve' (local file:/home/magicletur/.no-mistakes/evidence/01M23TGQGNDBG0KJEDDDGN7EJ0/04-thread-listening-live.png)waitstopped and heartbeat TTL lapsed: banner truthfully reverts to 'No agent is listening' (local file:/home/magicletur/.no-mistakes/evidence/01M23TGQGNDBG0KJEDDDGN7EJ0/05-banner-reverts-not-listening.png)Pipeline
Updates from git push no-mistakes
✅ **intent** - passed
✅ No issues found.
✅ **Rebase** - passed
✅ No issues found.
🔧 **Review** - 1 issue found → auto-fixed ✅
src/ui/threads.ts:210- The 'agent is listening' banner and the pre-send hint in the comment popover (src/ui/threads.ts:36 listeningLine(), rendered at :93 and :210) are computed from state.data.agent, refreshed only by reload() — triggered by the reader's own actions (create/reply/resolve/reopen) or by the SSE 'change' event from server.ts:157's watch(reviewDir(id), ...). The presence heartbeat file is deliberately written outside reviewDir (store.ts agentFile()) specifically so its 3s rewrite cadence does not fire that watcher and reload the page under the reader (clearing their draft). Concrete trace: reader opens the document whilethurview waitis running — banner correctly shows 'An agent is listening to this document now.' The agent'swaitlater reaches its timeout (or is killed) with no thread activity in between, so no reviewDir write ever occurs and no SSE event fires. The reader's already-open tab keeps showing 'An agent is listening to this document now.' indefinitely after the agent has actually stopped, until the reader performs an action or reloads manually. That is the over-claiming direction ('presence faked') this feature exists to prevent, per the intent's requirement to 'give the reader a truthful signal ... without faking presence.' The per-thread receipt shown right after actually sending a message is still correct (fetched fresh at that moment, so no message is silently lost), but the standing banner a reader relies on before deciding whether to ask can be stale and wrong for as long as the tab stays open with no other activity.🔧 Fix: Poll live agent presence so an open tab's listening banner can't stay stale
✅ Re-checked - no issues remain.
✅ **Test** - passed
✅ No issues found.
pnpm test && bats scripts/ci/*.batsvitest run test/e2e.test.ts -t "a question the reader asks reaches an agent"on target commit 544cbcd — 5/5 passSame 5 tests run against base commit 1aafabe (disposable git worktree, no modification to the actual worktree) — 3/5 fail: 'reopens a resolved thread...' fails resolved vs open, and both presence-reporting tests fail (undefined/'not found'), matching the exact regression described in the intentManual browser verification: built src/ui via node scripts/build-ui.mjs + tsc, launched a realnode dist/main.js serve, scaffolded/published a fresh demo review under an isolated THURVIEW_HOME (never touched the user's real evidence document f90d2474), then drove it with a real headless Chromium via chrome-devtools-axiOpened the comment popover and confirmed the redesigned submit control: 'Send to the agent ⌘↵' and 'Add to the review' one-click buttons, each with a line describing its consequence, replacing the old mode-toggle + 'Save' buttonSubmitted a question with no agent running: thread card shows the amber 'Queued. Nobody is listening right now — it is delivered the next time an agent checks this document.' receipt and a 'Withdraw' (not 'Resolve') button since an answer is still owedStarted a realthurview waitprocess and, without reloading the page, watched the banner flip to green 'An agent is listening to this document now.' within one 5s poll cycle — this is the live behavior of the presence-poll fix that landed in the second (review-round) commit for the previously-flagged stale-banner findingReplied viathurview threads replyand confirmed the card updated to 'The agent answered. Reply, or resolve it.' with the button reverting to 'Resolve'Killed thewaitprocess and confirmed the banner correctly reverted to 'No agent is listening right now...' after the 15s heartbeat TTL lapsed, proving presence never falsely persists✅ **Document** - passed
✅ No issues found.
✅ **Lint** - passed
✅ No issues found.
✅ **Push** - passed
✅ No issues found.