Skip to content

fix(ui): deliver reader replies on resolved threads and signal presence truthfully - #25

Merged
LeTuR merged 2 commits into
mainfrom
fix/thread-delivery-and-send
Sep 9, 2026
Merged

fix(ui): deliver reader replies on resolved threads and signal presence truthfully#25
LeTuR merged 2 commits into
mainfrom
fix/thread-delivery-and-send

Conversation

@LeTuR

@LeTuR LeTuR commented Sep 9, 2026

Copy link
Copy Markdown
Contributor

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 wait never reported it and threads list --open said '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 ran threads 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:

  • replyThread now forces status='open' for any reviewer message. Agent replies deliberately do NOT change status.
  • Presence is a heartbeat written ONLY by a live 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'.
  • The brief forbade making the agent poll forever, so the model stays a queue: when nobody is listening the UI says 'Queued ... delivered the next time an agent checks this document', which is true because reviewRow already reports needsAgent to the next session.
  • The submit control: the old popover was a mode toggle (Add to review / Ask now) plus a button labelled 'Save' - two controls that had to be combined correctly. Replaced with two one-click actions that name their consequence, 'Send to the agent' (primary, also Cmd+Enter) and 'Add to the review' ('Add to my notes' for explainer documents), each with a line under it saying what happens; the mode state is gone. Cmd+Enter deliberately moved from the old default (review mode) to send, and this is a behaviour change on purpose.
  • The thread card's 'Resolve' button is relabelled 'Withdraw' while an answer is still owed, because resolving your own unanswered question is abandoning it, not resolving it. A new shared pure module src/thread-state.ts holds needsAgent plus deliveryOf so the CLI queue and the browser receipt cannot disagree; the UI cannot import presence.ts/threads.ts because those touch node fs.
  • scripts/demo/record-browser.mjs clicked '.comment-popover button.ok' expecting 'Save' in the default held mode; it now clicks 'Add to the review' by text so the recorded demo still tells the same story.
  • Docs updated per the repo's ownership rules: references/lifecycle.md owns the thread state machine (new transition table, the needsAgent formula, the presence section, the storage tree), SKILL.md the agent workflow, README.md the reader-facing feature list.
  • Hard constraint honoured: the operator's evidence document f90d2474 was read but never modified. The branch is rebased on origin/main so the sibling task's YAML-colon fix to the SKILL.md description frontmatter is kept intact and untouched.

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 a resolved thread is no longer silently dropped from the agent's queue (needsAgent/threads list --open/wait previously never saw it); agent replies do not change status. needsAgent moved into a new shared pure module src/thread-state.ts, which also adds deliveryOf (held/queued/listening/answered/closed) so the CLI queue and the browser cannot disagree on a thread's state.
  • Added src/presence.ts: a heartbeat-based liveness signal written only by an active thurview 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 through src/server/server.ts, src/store.ts, src/cli.ts, and src/ui/api.ts.
  • Reworked the comment/reply UI (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.
  • Updated test/e2e.test.ts with new coverage (createThread defaults to open, reply reopens a resolved thread, presence attach/detach, delivery states) and updated scripts/demo/record-browser.mjs to click the new "Add to the review" control by text.
  • Documented the thread state machine, needsAgent formula, and presence mechanism in skills/thurview/references/lifecycle.md, updated the agent workflow in skills/thurview/SKILL.md, and refreshed the reader-facing feature list in README.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 wait times 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 wait process: 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.

  • Evidence: Redesigned submit control — 'Send to the agent ⌘↵' / 'Add to the review', each with its consequence line, no agent listening (local file: /home/magicletur/.no-mistakes/evidence/01M23TGQGNDBG0KJEDDDGN7EJ0/02-popover-no-agent.png)
  • Evidence: After sending with nobody listening: amber 'Queued' receipt and 'Withdraw' button (answer still owed) (local file: /home/magicletur/.no-mistakes/evidence/01M23TGQGNDBG0KJEDDDGN7EJ0/03-thread-queued-withdraw.png)
  • Evidence: Live thurview wait running: 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)
  • Evidence: After wait stopped 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 while thurview wait is running — banner correctly shows 'An agent is listening to this document now.' The agent's wait later 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/*.bats
  • vitest run test/e2e.test.ts -t &#34;a question the reader asks reaches an agent&#34; on target commit 544cbcd — 5/5 pass
  • Same 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 intent
  • Manual browser verification: built src/ui via node scripts/build-ui.mjs + tsc, launched a real node 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-axi
  • Opened 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' button
  • Submitted 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 owed
  • Started a real thurview wait process 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 finding
  • Replied via thurview threads reply and confirmed the card updated to 'The agent answered. Reply, or resolve it.' with the button reverting to 'Resolve'
  • Killed the wait process 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.

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
@LeTuR LeTuR changed the title fix(thurview): deliver reader replies on resolved threads and signal presence truthfully fix(ui): deliver reader replies on resolved threads and signal presence truthfully Sep 9, 2026
@LeTuR
LeTuR merged commit 9d704c8 into main Sep 9, 2026
7 of 8 checks passed
@LeTuR
LeTuR deleted the fix/thread-delivery-and-send branch September 9, 2026 20:20
Sign up for free to 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.

1 participant