Desktop: the chat-tab dot follows the chat's own store, not just the session list (M8) - #215
Merged
Merged
Conversation
… 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
force-pushed
the
claude/intelligent-colden-23f4cf
branch
from
September 10, 2026 20:17
072269e to
b7c74c4
Compare
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.
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:sessions.privacy_tierprivatearia-label… Private chat. …[data-testid="recent-chat-glyph-20260910_2"]data-privacy="private"[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 fromgetCachedSessionList().refreshSessionBindingpatches 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 /sessionsINNER JOINsmessages(SessionStorage::list_sessions_by_types_maybe_empty,include_empty = false), so a session that has recorded no message is not listable at all. AnotifySessionListChanged()fired atcreateSessionrefreshes 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
ChatStreamRegistrypublishes the classification of every chat this window holds a store for — the samesnapshot.session.privacy_tierthe header pill and the composer read, whichapplyTurnBindingwrites from the reply stream.ChatGroupsShellfolds 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 aBaseChat, so those genuinely have no store).handleControllerActivityruns 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.privacy::raise. A controller whose session momentarily goes null must not retract aprivateit has already reported, or the strip falls back to a cachedpublic.The merge is
max, not "freshest wins"New
components/privacy/sessionTier.ts—raiseTier/mergeSessionTiers/sessionTiersDiffer, React-free and unit-tested. The tier is a permanent ratchet server-side, so aprivatefrom any source is a fact that still holds and apublicis only a lower bound. Order-independence is asserted, so neither source is privileged.undefinedstays 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
applyTurnBindingused to drop the tier whenprev.sessionwas undefined — which is the whole life of a just-created chat, and also any window in whichloadSessionhas blanked the row while it fetches. The pin survived because it has a home of its own. The classification is now held and applied byupdateSnapshotto 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
refreshSessionBindingannounces 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;ChatGroupsShellwarms 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
npm run test:runnpx vitest run src/components/privacy/sessionTier.test.tsnpx vitest run src/components/chatGroups/ChatGroupsShell.privacy.test.tsxnpx vitest run src/hooks/chatStreamStore.binding.test.tsxsrc/components/chatGroups+src/hooks/chatStreamStore*+src/components/privacynpm run lint:checknpx prettier --checkon all 9 changed filesFail-before, measured by reverting the production change and re-running
Ten gates fail without it. The two that name the finding:
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.tsxreplaces the whole store module and needed the new export added to its stub — a missing export there is a render-time throw, not anundefined.Runtime verification
Own sandboxed instance from this worktree (
~/biorouter-runs/m8-tabdot, CDP 9361, seam-built daemon), modelversa_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:publicpublicpublicprivateprivateprivateprivateprivateThe dot flips in the same frame the store learns,
aria-labelmoves fromChattoPrivate chat, and all three surfaces agree. A second realBrowserWindowopened on the same chat showsdata-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
PrivacyProviderPinneddoes not reach the renderer during a turn on merged main. WrappingapplyTurnBindingon 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, whichmap_bus_eventdoes 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.rsyields the frame right after the ratchet andsession_events.rsmaps 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: falsewas set (the known$HOMEwalk), and vite underBIOROUTER_NO_HMR=1serves 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