Skip to content

Desktop: the chat-tab dot follows the chat's own store, not just the session list (M8) - #215

Merged
Broccolito merged 1 commit into
mainfrom
claude/intelligent-colden-23f4cf
Sep 10, 2026
Merged

Desktop: the chat-tab dot follows the chat's own store, not just the session list (M8)#215
Broccolito merged 1 commit into
mainfrom
claude/intelligent-colden-23f4cf

Conversation

@Broccolito

@Broccolito Broccolito commented Sep 10, 2026

Copy link
Copy Markdown
Collaborator

Fixes finding M8 of the 2026-09-10 test drive of merged main. Renderer only.

The measurement

New chat tab, one turn on versa_azure / gpt-5.5-2026-04-24, and 52.9 s after the turn ended:

Surface Value
sqlite sessions.privacy_tier private
model chip aria-label … Private chat. …
sidebar [data-testid="recent-chat-glyph-20260910_2"] data-privacy="private"
the ACTIVE TAB's [data-testid="chat-kind-icon"] data-privacy="public", aria-label="Chat"

Why it could never have worked, and a correction to the brief

The tab dot read useSessionPrivacyTiers, built from getCachedSessionList(). refreshSessionBinding patches that cache only for an entry it already holds (deliberately — list membership is the list channel's).

The brief's second remedy was to announce from the create path. That cannot fix it, and the reason is measurable: GET /sessions INNER JOINs messages (SessionStorage::list_sessions_by_types_maybe_empty, include_empty = false), so a session that has recorded no message is not listable at all. A notifySessionListChanged() fired at createSession refreshes a list that still does not contain the new chat. The first moment the daemon will list it is after its first turn.

ChatGroupsShell's comment said the gap was closed "twice over". Both mechanisms it named are no-ops for exactly this chat: the reply stream's classification lands on the store, and the list patch is a no-op for a session the list has never carried. The comment is rewritten to say what now holds.

What changed

The strip reads the chat's own store

ChatStreamRegistry publishes the classification of every chat this window holds a store for — the same snapshot.session.privacy_tier the header pill and the composer read, which applyTurnBinding writes from the reply stream. ChatGroupsShell folds it over the list cache; the cache is now the fallback, for tabs never opened in this window (only a group's ACTIVE tab mounts a BaseChat, so those genuinely have no store).

  • O(1) per notification. handleControllerActivity runs once per animation frame per chat during a turn; this compares one id's tier and allocates only when one moved. Called FIRST, outside the running-list early returns — a session load and a post-turn refresh both take the arm that returns without emitting.
  • The registry's map only ever rises, mirroring privacy::raise. A controller whose session momentarily goes null must not retract a private it has already reported, or the strip falls back to a cached public.

The merge is max, not "freshest wins"

New components/privacy/sessionTier.tsraiseTier / mergeSessionTiers / sessionTiersDiffer, React-free and unit-tested. The tier is a permanent ratchet server-side, so a private from any source is a fact that still holds and a public is only a lower bound. Order-independence is asserted, so neither source is privileged. undefined stays unmarked — asserting Public for a chat nobody has read is the same lie pointing the other way.

The safe-direction invariant is now a gate rather than a comment: private-from-either-source or unmarked, never public over a source that has seen private.

A classification reported before the row exists is kept

applyTurnBinding used to drop the tier when prev.session was undefined — which is the whole life of a just-created chat, and also any window in which loadSession has blanked the row while it fetches. The pin survived because it has a home of its own. The classification is now held and applied by updateSnapshot to the first snapshot that carries a row — one-shot, so a turn's statement can never re-assert itself over a row read later (a declassification is the one legitimate private → public move). Applied at the single snapshot choke point rather than at each of the several updaters that can set a row.

A chat born in this window tells the list it exists

refreshSessionBinding announces once, when a fetched list turns out not to hold the chat — the first moment the daemon will list it. A null cache is "nobody has asked yet" and is left alone; ChatGroupsShell warms it on mount. SessionListView's import handler announces too (an imported chat carries its transcript, so it is listable immediately). This is for Home recents and See-all; it is not what fixes the dot, which needs no request at all.

Tests

Gate Result
npm run test:run 442 files, 4969 passed, 1 skipped, 0 failed
npx vitest run src/components/privacy/sessionTier.test.ts 14 passed (new file)
npx vitest run src/components/chatGroups/ChatGroupsShell.privacy.test.tsx 8 passed (3 → 8)
npx vitest run src/hooks/chatStreamStore.binding.test.tsx 28 passed (18 → 25 from this PR, plus 3 from #220 after the rebase)
src/components/chatGroups + src/hooks/chatStreamStore* + src/components/privacy 56 files, 697 passed
npm run lint:check clean
npx prettier --check on all 9 changed files All matched files use Prettier code style

Fail-before, measured by reverting the production change and re-running

Ten gates fail without it. The two that name the finding:

FAIL  ChatGroupsShell — the tab dot follows the live store … > marks a chat the store says is private but the list has never carried
      AssertionError: expected {} to deeply equal { 'sess-1': 'private' }
FAIL  a turn states what it runs on … > keeps a classification the observer reported before the row loaded

Also failing before: the live-store raise over a cached public, the no-fetch case, the registry's four gates, and the announce-once gate.

keyboardResubmitGuard.test.tsx replaces the whole store module and needed the new export added to its stub — a missing export there is a render-time throw, not an undefined.

Runtime verification

Own sandboxed instance from this worktree (~/biorouter-runs/m8-tabdot, CDP 9361, seam-built daemon), model versa_azure / gpt-5.5-2026-04-24. Repro run three times; probes removed and the instance stopped afterwards.

Session 20260910_5, sampled every animation frame, times relative to the Send click:

Δ store tier published tab dot sidebar
+411 ms no row unmarked
+432 ms public unmarked
+797 ms public public unmarked private
turn ends private private private private

The dot flips in the same frame the store learns, aria-label moves from Chat to Private chat, and all three surfaces agree. A second real BrowserWindow opened on the same chat shows data-privacy="private" on both its tab and its sidebar row. Before this change the dot never moved at all.

⚠ What the run found that is NOT fixed here

PrivacyProviderPinned does not reach the renderer during a turn on merged main. Wrapping applyTurnBinding on every controller before a turn (both the empty-id and the session-id controller, both confirmed wrapped) recorded zero calls across a whole turn, while the daemon's row was raised and the sidebar followed. The store's tier therefore moves only at the post-turn re-read — measured at 28 ms after the turn ended — so the dot is correct within a frame of that, not from the turn's first frames.

That is upstream of everything in this PR and out of a renderer-only scope, so it is reported rather than fixed. Its consequence for this change: the pre-row stash is live today on the observer path (GET /sessions/{id}/events, which map_bus_event does forward — the path its test uses), and becomes live on the reply path the moment that frame is delivered again. It is worth its own investigation; agent.rs yields the frame right after the ratchet and session_events.rs maps it, so the loss is somewhere between.

Two other things the run needed, neither a defect in this branch: every turn wedged until CONTEXT_WORKSPACE_SUMMARY: false was set (the known $HOME walk), and vite under BIOROUTER_NO_HMR=1 serves a cached transform, so the instance must be restarted to pick up a source change — a stale bundle otherwise reads exactly like a broken fix.

🤖 Generated with Claude Code

… the list

Finding M8 of the 2026-09-10 test drive: a chat created in this window ratchets
to Private, sqlite says `private`, the sidebar row and the model chip say
private — and the ACTIVE TAB's own glyph still reads `data-privacy="public"`,
52.9 s after the turn ended.

The tab dot read the session-list cache and nothing else, and that cache can
never hold a chat born in this window: `GET /sessions` INNER JOINs `messages`
(`SessionStorage::list_sessions_by_types_maybe_empty`), so a row that has
recorded none is not listable, and `refreshSessionBinding` patches only entries
the cache already holds. `ChatGroupsShell`'s comment claimed the gap was closed
"twice over"; both mechanisms it named are no-ops for exactly this chat.

The store already knew. The strip now reads the same live tier the header pill
and the composer read, published by `ChatStreamRegistry` and folded over the
list cache with `max` over `public < private` — the daemon's own ratchet — so a
`private` from either source wins and neither can lower the other.

Two smaller repairs in the same finding:

- a classification the daemon reports before the row has loaded is kept and
  applied to the first row that appears, instead of being dropped. That window
  is the whole life of a just-created chat, and it is reachable today on the
  observer path.
- a chat missing from a fetched list announces itself once, after its first
  turn — the first moment the daemon will list it — so Home recents and See-all
  learn about it. `SessionListView`'s import handler announces too.

Fail-before evidence: 10 of the new gates fail with the production change
reverted, including "marks a chat the store says is private but the list has
never carried" and "keeps a classification the observer reported before the row
loaded".

Verified in a sandboxed dev instance on versa_azure: new chat, one turn, the
tab dot, the sidebar glyph and the row all read private and agree, in this
window and in a second window. Before the change the dot never moved.
@Broccolito
Broccolito force-pushed the claude/intelligent-colden-23f4cf branch from 072269e to b7c74c4 Compare September 10, 2026 20:17
@Broccolito
Broccolito merged commit 7c96d79 into main Sep 10, 2026
16 checks passed
@Broccolito
Broccolito deleted the claude/intelligent-colden-23f4cf branch September 10, 2026 20:40
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