Show unanswered decision counts on Projects sidebar rows #378
- LA
Closes #345
## Why
The Projects sidebar gave no hint of which documents were waiting on a decision. You had to open each document and check its Decisions tab. This PR puts the unanswered count on document, child, and project rows, using the same quiet `Count` the Decisions tab uses.
## What changed
### Server and storage
- Migration `017` adds `channel_state.unanswered_decisions`. It counts open questions without an answer, plus every question of a reopened card. It's a plain column now, not a generated one. The adapter writes it with every sidecar, and the migration backfills it in JavaScript. JSONB rejects the escaped NUL and lone surrogates that sidecar text can contain, and the old generated column counted those documents as zero. `apps/server/src/questions/unanswered.ts` is the one counter for both adapters and for live frames. PostgreSQL parity tests commit the NUL and surrogate cases and run the backfill. - Every channel in `GET /api/repositories/:owner/:repository/channels` carries `unansweredDecisions`. The unfiltered active listing also returns a repository total, so the project row includes documents on pages the client hasn't loaded. Search and archived listings leave the total out. - Archive returns the new total, and the client applies it. Restore reloads the active catalogue. Permanent deletion publishes the deleted document at zero and the repository total read after the commit.
### Live updates
- The sidebar has its own socket at `/ws/sidebar`. It's admitted on the browser session and origin and bound to no room, so counts stay live with no document open. Room sockets no longer carry counts. - New `Sidebar` frames in `packages/protocol`: `sidebar:watch`, `sidebar:unwatch`, `sidebar:watched`, `sidebar:decisions`, and `sidebar:snapshot`. - The client watches every repository the sidebar shows, with its loaded document IDs. The server rechecks GitHub read access and the repository node ID before subscribing, then sends a snapshot per repository. That snapshot is how a reconnected socket, or a row that loaded after the first watch, catches up. - Watches are additive and chunked: 50 repositories per frame, 500 document IDs per entry, 200 repositories per socket. The reply separates refused from unavailable. Unavailable repositories subscribe nothing, and the client retries them with a backoff from 2 seconds to a minute. A recheck that can't reach GitHub keeps an existing subscription, and a denial drops it. - Frames for one repository are read and sent in order, and the client drops a document frame older than the revision it holds. - Frame revisions are per document, so repository totals have no ordering key of their own. When the client drops a live frame as stale, or discards an HTTP total because a live update arrived during the request, it asks the socket for a fresh snapshot of that repository, at most once a second. The snapshot is ordered after every earlier frame, so the project total settles on the server's value. An older frame never rolls a newer total back. - The 200-repository watch cap goes to the open document's project first, then projects in sidebar order. Projects past the cap reload their first page and total over HTTP, in batches of 10, on window focus or visibility (at most every 30 seconds) and every 60 seconds while the window is visible. - The sidebar count code loads after first paint, with a dynamic import behind its own error boundary. That keeps the initial JavaScript within main's unchanged budget (255,399 B raw / 80,505 B gzip against 256,000 / 81,500; main alone is 251,596 / 79,505). The watch plan lives in its own module so only the deferred code imports it. A failed load leaves the sidebar working without counts.
### Sidebar
- Each row's count sits where its hover action goes. On hover, keyboard focus, or while the row's actions menu is open, the `…` menu (or **New document** on project rows) replaces the count. - The project row's action collapses to zero width at rest, like document actions, so counts end at the same edge on pointer devices. It stays in the accessibility tree and Tab still reaches it. - Accessible names follow the tab's wording, for example `Postgres writer lease, 2 unanswered decisions`. The count itself is `aria-hidden`. A row with zero shows no count. - On touch the action is always visible and the count sits to its left. Counts end at the same edge on project, document, and child rows. - The archived catalogue shows no counts. - Count rises reuse the tab's motion feedback through a shared `useDecisionAttention` hook.
### Tests, docs, and contracts
- `e2e/sidebar-decision-counts.e2e.ts` has nine tests now, including counts with no document open, a row loaded after the first snapshot, reconnect, and deleting an archived child while a viewer stays in the parent. Unit tests cover the counter, routes, the server watch (limits, outages, shared checks, rename, recheck), the client watch planner, and the deletion announcement. - E2E tests that clicked a project's **New document** button now hover the row first, and the `score` disclosure lookups, including `e2e/smoke.e2e.ts`, accept the count suffix. - `docs/channels.md`, `docs/authentication.md`, `docs/storage.md`, and `AGENTS.md` describe the sidebar socket, its limits, and the column. - `scripts/design-contract/exceptions/dynamic-web.json` renews the `navigation-shell.tsx` hash. Its className data flow didn't change.
## Review
GPT-6.1 Sol fast and GPT-6 Luna fast reviewed this in four rounds. Every finding is fixed, with a regression test for each:
- Counts didn't refresh for other repositories' projects, or after a reconnect. That led to the sidebar socket, the authorized watches, and the snapshots. - Sidecars containing a NUL or a lone surrogate counted as zero in PostgreSQL. The column and backfill are now written from JavaScript. - Stale frames could drop or roll back the project total, and archive and permanent deletion didn't update it. The sidebar on routes with no document open was missing counts, and refused watches were never retried. - Touch counts didn't line up, projects past the watch cap went stale, and the merged bundle went over budget.
## Verification
Ran on 8a49dda4, rebased onto main 7d7d2014. `main` had added its own migration 016, so this one is now 017.
- GitHub CI run 37852323443: format, lint, types, and tests; e2e; and container all pass. - `bun run build`: initial JavaScript is 255,399 B raw / 80,505 B gzip, within main's unchanged budget. `apps/web/bundle-budget.ts` is identical to main. - `bun run fix`, `bun run types`, `bun run ci`: pass. The `navigation-shell.tsx` and `project-sidebar.tsx` design-contract hashes were renewed after the rebase; their reviewed dynamic class expressions are the same ones main reviewed. - `bun test` with `commit.gpgsign=false` for that command only: 4404 pass, 3 skip, 0 fail. Without the override, the `scripts/pr-maintenance` tests fail because this host signs commits. - `TEST_DATABASE_URL=<disposable postgres:17> bun test apps/server/src/storage/postgres`: 76/76 with migration 017, including the NUL and surrogate parity cases. - `bun run e2e e2e/sidebar-decision-counts.e2e.ts`: 12/12. The touch test now uses a 1280px window, because main shows the inline sidebar only from 1198px. - `bun run e2e e2e/smoke.e2e.ts e2e/document-navigation.e2e.ts e2e/chat-mentions.e2e.ts e2e/responsive-activity.e2e.ts e2e/harness.e2e.ts`: 71/71. - CI's first run after the rebase failed main's new header test (`document-navigation:281`), which looked up the `score` project by its exact name. It now accepts the count suffix like the other `score` lookups. In a single-worker local run, `document-navigation:748` (overlapping workspaces) failed once and then passed 5/5; it passed in CI. - Each regression test added earlier in this PR fails without its fix.
The screenshots below are from db4170cd. The pointer-device layout hasn't changed since.
## Screenshots
Running app, project total 7. The second image shows the hover swap on "Postgres writer lease".


Assistant-model: Claude Opus 5.5 (fast) Assistant-workflow: inline rebase onto main; earlier issue-to-pr (runs 060f8b4e-b3b7-4852-bccb-8a89103cbdc4, 9e295d5f-ce88-404b-8711-e7d1cfc6bbba), inline review fixes, pr-rebase-ci-green (run ca90f557-f458-4af4-8acb-e40c865582d2) Assistant-duration: 4h05m converged, estimated 2h; rebase onto main 40m converged, estimated unmeasured Assistant-verification: GitHub CI passed: run 37852323443 on 8a49dda4, all three jobs (format/lint/types/tests, e2e, container) Assistant-verification: bun test passed: 4404 pass, 3 skip, 0 fail with commit.gpgsign=false for the command only Assistant-verification: bun run fix, types, ci, build passed: build within main's unchanged bundle budget (255399 B raw / 80505 B gzip) Assistant-verification: postgres contract tests passed: 76/76 on 8a49dda4's tree against a disposable postgres:17, migration renumbered to 017 Assistant-verification: Playwright E2E passed: sidebar-decision-counts 12/12; smoke, document-navigation, chat-mentions, responsive-activity, harness 71/71 Assistant-verification: Playwright E2E failed in GitHub CI run 37850621091 before the fix: main's document-navigation:281 looked up score by exact name User-preference: Use claude-opus-5-5-fast, gpt-6.1-sol-fast and gpt-6-luna-fast (-fast variants) for agent work User-preference: One sibling git worktree and one PR per assigned issue User-preference: keep sidebar counts fresh across repositories with authorized socket subscriptions User-preference: show an aggregate decision count on the project row and keep the count and button UI consistent with the Decisions tab Co-authored-by: Alex Lavaee <lavaman131@github.com> Co-authored-by: lavaman131
lavaman131referenced 19ee18a472felast month on GitHub
lavaman131convert-to-draftlast month on GitHub
lavaman131referenced 31a061562e56last month on GitHub
lavaman131referenced 96a354f05821last month on GitHub
lavaman131head-ref-force-pushed 2c0253bc9633last month on GitHub
lavaman131referenced 2becfdc20a75last month on GitHub
lavaman131referenced 9ca0477161a1last month on GitHub
lavaman131referenced 20c9029829f8last month on GitHub
lavaman131referenced 95159cd0e445last month on GitHub
lavaman131referenced 1f023331bb79last month on GitHub
lavaman131referenced a541695ddffflast month on GitHub
lavaman131referenced 1a3169638a9alast month on GitHub
lavaman131head-ref-force-pushed 534dd9bdc606last month on GitHub
lavaman131referenced 53ac11c4c82alast month on GitHub
lavaman131referenced 509e3324f6c1last month on GitHub
lavaman131referenced af42cbd729cdlast month on GitHub
lavaman131referenced 16c6ad9b152dlast month on GitHub
lavaman131head-ref-force-pushed 509f25e702f0last month on GitHub
lavaman131referenced dad5ac8effd6last month on GitHub
lavaman131ready-for-reviewlast month on GitHub
lavaman131referenced 7df0e9f3833elast month on GitHub
lavaman131referenced 373eff4ccaf1last month on GitHub
lavaman131head-ref-force-pushed df094d476271last month on GitHub
lavaman131referenced e8bc7e4c92aelast month on GitHub
lavaman131referenced c607719afb83last month on GitHub
lavaman131head-ref-force-pushed c6a60d3ff79elast month on GitHub
lavaman131merged 87d6bdcbc565last month on GitHub
lavaman131referenced e0759d4d536elast month on GitHub
lavaman131referenced b95c7a20eaf7last month on GitHub
lavaman131referenced 1d25117ffe41last month on GitHub
lavaman131referenced 4ac8297cbe0alast month on GitHub
lavaman131head-ref-deletedlast month on GitHub