Resolved threads stop nagging - #184
Conversation
An agent that replies to a batch of comments and then resolves them left a pile of unread inbox rows behind — eleven on one plan, twenty on another — every one of them pointing at a thread whose highlight the doc view hides. Clicking through landed on an apparently empty plan. A closed thread (resolved/discarded) now carries no unread rows: - Closing a thread sweeps its unread notifications read, via an after_commit on CommentThread rather than in the two controllers that resolve/discard, so every path is covered - agent_response and status_change don't notify at all on an already-closed thread; human replies and mentions still do, because somebody deliberately reopening a settled conversation is news - A data migration clears the rows already accumulated in production The workspace "Needs attention" strip gains a per-row Clear, for the rows you want gone without reading them: it marks that plan's rows read, re-renders the strip in place (clearing one plan can promote the next), drops the plan row's own badge, and refreshes the bell. The strip moved into a partial behind Notifications::NeedsAttention so both surfaces render the same thing, and the badge broadcast that was copy-pasted in three places became Notifications::BroadcastBadges. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 2b4e2d5313
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
Two findings from the Codex review, both real. Clear had no in-flight feedback — on a slow request the row sat there looking unclicked. The button now carries turbo_submits_with, so its label swaps to "Clearing…" the moment it's pressed, and the row pulses while Turbo has the form marked aria-busy. Both revert on failure with no JS of our own. Pagination frames render rows and return before the strip, but routing unread_by_plan through NeedsAttention made them pay for its per-plan lookups and visibility query too — up to seven queries where one grouped count would do. The grouped count is its own memoized query again, and NeedsAttention takes it as an argument so the workspace still runs it once. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
🤖 Both Codex findings addressed in e40929b. P1 — no feedback while clearing. Fair catch against the native-feel bar in AGENTS.md. The P2 — extra queries on pagination frames. Correct, and a regression I introduced: Full suite green: 1521 examples, 0 failures. @codex review |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: e40929b2ec
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
"Clearing…" was a progress state for something that has no duration. Dismissing a nudge should just dismiss it: the row now hides the instant Turbo marks the form aria-busy, and the response replaces the strip behind it. Clearing the last row takes the strip with it rather than flashing an empty bordered box with a stale count. Turbo clears aria-busy on failure, so a rejected request puts the row back — still no JS of ours. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
🤖 Correction on the P1 fix in 11e153c — I took the wrong half of that suggestion. "Clearing…" was a progress state for an action that has no duration. Dismissing a nudge should just dismiss it, so the row now hides the instant Turbo marks the form Failure still self-heals: Turbo clears Verified in a browser: with two rows, the clicked row goes |
Codex caught a real hole: an agent_response job that loaded an open thread could pass the closed check, have a concurrent resolve commit and sweep, then insert — leaving a row unread forever on a closed thread, with a push already on its way (WebPushDeliveryJob doesn't re-check read state). Exactly the reply-then-resolve batch this PR exists to fix. Reading the status and inserting are now one step under with_lock, which reloads the thread: either we see the close and stay silent, or the close waits for us and its own sweep catches the rows. The badge broadcast stays outside the lock. The spec fails without it — two stuck rows. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
🤖 Good catch on the race — fixed in bb4c7ed, and it was worse than a stuck row.
Reading the status and inserting are now one step inside
The badge broadcast stays outside the lock, and the notification's The spec reproduces it deterministically — a stale in-memory thread plus a committed resolve — and fails without the lock with two stuck rows. Suite: 1522 examples, 0 failures. |
Dismissing a row was the escape hatch; the thing you actually do is open the plan. Now that clears its unread rows, and the ✕ is the quieter version for plans you don't want to open. "Opened" can't be the GET, though: workspace rows prefetch on hover (Turbo 8), so resting the cursor on a row fetched the page and — before this — cleared its notifications and burned its changed-section highlights for a visit nobody made. Worse, Turbo serves the real click from its prefetch cache, so the server never sees the navigation. So the signal is PlanPresenceChannel#subscribed, which only happens when the page is really open in front of somebody. #show still does it for navigations Turbo didn't prefetch, and skips X-Sec-Purpose: prefetch — which also stops a hover from advancing your last-seen mark. Notifications::MarkPlanRead is shared by both paths and the ✕. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
🤖 > I was mostly hoping that opening the plan would clear them.... though an "x" is good too. Flipped it round — opening the plan is now the main way rows clear, and the dismiss is a quiet The wrinkle worth knowing about: "opened the plan" can't be the
So the "you actually looked at it" signal is Verified in a browser on a seeded DB: hover a row for 2s → request fires, 9 unread stays 9; click it → plan opens, its 3 rows clear, bell goes 9 → 6 live over the existing notifications stream with no reload;
|
The bug
Open the workspace in production today and the "Needs attention" strip claims eleven unread comments on one plan and twenty on another. Click through and the plan looks empty — because those comments were all resolved. An agent replied to a batch of threads and then resolved them, and each reply plus each resolve minted an inbox row that outlived the thread it pointed at. The doc view hides a closed thread's highlight (
.anchor-highlight--resolvedispointer-events: noneuntil you presss), so the rows sent you somewhere with nothing to act on.The rule
A closed thread —
resolvedordiscarded— carries no unread notifications.CommentThreadgained anafter_committhat callsNotifications::MarkThreadReadwhen a status write lands in a closed status. It lives on the model rather than in the two controllers that resolve/discard (web + API), so seeds, admin, and any future path are covered too. Rows stay in the inbox history; they just stop counting as unread.Notifications::Create::SILENT_ON_CLOSED_THREADdropsagent_responseandstatus_changeon an already-closed thread. Humanreplyandmentionstill notify — somebody deliberately re-engaging with a settled thread is news, and that's the one signal I didn't want to swallow. Flagging this as the judgement call in the PR: the trade is that when an agent resolves a thread, you no longer get told. Say the word and I'll makestatus_change-on-resolve survive.MarkClosedThreadNotificationsReadmarks read every unread row whose thread is already closed. Data-only, idempotent, sodb/schema.rbmoves by one version line.reopensetspendingbefore the job runs, so the thread is open and thestatus_changelands.Getting rid of the rest
Opening a plan clears that plan's unread rows. You looked; the nudge is done.
Notifications::MarkPlanRead.The signal for "opened" is
PlanPresenceChannel#subscribed, not theGET. Workspace rows opt into Turbo 8 hover prefetch, which makes the request unreliable in both directions: resting the cursor on a row fetches the page (so clearing there would quietly empty the strip for a visit nobody made — I hit this in the browser), and a click within 10s is served from Turbo's prefetch cache without touching the server at all. The presence subscribe only happens when the page is live in front of somebody.PlansController#showclears too, for navigations Turbo didn't prefetch, and skipsX-Sec-Purpose: prefetch— which also stops a hover from advancing your last-seen mark and burning the changed-section highlights, a pre-existing bug of the same shape.And a
✕per row, for plans you don't want to open. It:No navigation, no scroll jump, no full reload; the row hides the moment you press it rather than narrating "Clearing…". Verified in a browser against a seeded worktree DB: hover a row → nothing clears; click it → its rows clear and the bell drops live over the existing notifications stream;
✕on the rest → strip empties and disappears rather than leaving an empty bordered box.Refactors this pulled in
plans/index.html.erbintoplans/_needs_attention.html.erbbehindNotifications::NeedsAttention, so the workspace and the Clear response render the same thing from the same query object. It also now drops a row whose notification vanished mid-request instead of raising onfetch.Notifications::Create,Comments::ProcessMentions, andNotificationsControlleris nowNotifications::BroadcastBadges.Testing
bundle exec rspec— 1538 examples, 0 failures. New coverage: the close sweep (model, web resolve/discard, API resolve), the reply-then-resolve race (fails without the thread lock), the silencing rule including the reply/mention/reopen exceptions,NeedsAttention(ordering, cap, visibility predicate, mid-request clear, not built for pagination frames), clear-on-open (plan show clears the viewer's rows and nobody else's, ignores a prefetch, doesn't advance last-seen on a prefetch, presence subscribe clears, rejected subscription doesn't),mark_plan_read(scoping to the acting user, turbo-stream targets, no-op without a plan_id), and the migration (idempotent, leaves open threads and already-read rows alone). Also confirmed the reflectedplan_idis escaped in theturbo-streamtarget attribute.Still open
The bigger question you raised — whether a local agent writing one comment should push at all — isn't touched here. Web Push still fans out per notification created; the only change is that fewer notifications exist to push. Worth its own pass on per-reason delivery rules.
🤖 Generated with Claude Code