Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ import { describe, expect, it, vi, beforeEach } from 'vitest';
const dispatch = vi.fn();
const preloadSessionList = vi.fn();
let cachedList: Array<{ id: string; privacy_tier?: string }> | null = null;
let liveTiers: Record<string, string> = {};

// The strip is not a child of the shell — it reaches the DOM through BaseChat's
// `renderSessionTitle` render prop, so a BaseChat stub that ignores its props
Expand All @@ -54,6 +55,14 @@ vi.mock('../../utils/sessionListCache', () => ({
preloadSessionList: () => preloadSessionList(),
}));

// The LIVE half of the tier map: what the chat stores in this window hold right
// now. Stubbed here so this file stays about the MERGE — the registry's own
// side, a controller's row becoming an entry in that map, is pinned in
// `hooks/chatStreamStore.binding.test.tsx`.
vi.mock('../../hooks/chatStreamStore', () => ({
useLiveSessionTiers: () => liveTiers,
}));

vi.mock('../../contexts/ChatGroupsContext', () => ({
useChatGroups: () => ({
dispatch,
Expand All @@ -79,6 +88,7 @@ describe('ChatGroupsShell — the strip warms the list it reads', () => {
beforeEach(() => {
preloadSessionList.mockClear();
cachedList = null;
liveTiers = {};
lastStripProps = {};
});

Expand All @@ -102,3 +112,64 @@ describe('ChatGroupsShell — the strip warms the list it reads', () => {
expect(lastStripProps.privacyTiers).toEqual({});
});
});

/**
* Finding M8, measured on merged main 2026-09-10: new chat tab, one turn on
* `versa_azure`, and 52.9 s later sqlite read `privacy_tier=private`, the model
* chip said "Private chat", the sidebar row said `data-privacy="private"` — and
* the ACTIVE TAB's own glyph still said `data-privacy="public"`.
*
* The cause is structural rather than a race: a chat CREATED in this window is
* not in the session list at all. `GET /sessions` INNER JOINs `messages`, so a
* row that has recorded none is not listable, and `refreshSessionBinding`
* patches only entries the cache already holds. Waiting longer never fixed it.
*
* The store, meanwhile, had the answer from the reply stream's first frames —
* which is why the chip and the header pill were right. These tests pin that
* the strip now reads the same source, and that the two sources combine in the
* safe direction.
*/
describe('ChatGroupsShell — the tab dot follows the live store, not just the list', () => {
it('marks a chat the store says is private but the list has never carried', () => {
cachedList = [{ id: 'someone-else', privacy_tier: 'public' }];
liveTiers = { 'sess-1': 'private' };
render(<ChatGroupsShell onChatChange={() => {}} />);
expect(lastStripProps.privacyTiers).toEqual({
'someone-else': 'public',
'sess-1': 'private',
});
});

it('marks it even when the list has not been fetched at all', () => {
cachedList = null;
liveTiers = { 'sess-1': 'private' };
render(<ChatGroupsShell onChatChange={() => {}} />);
expect(lastStripProps.privacyTiers).toEqual({ 'sess-1': 'private' });
});

it('lets the live store raise a chat the cache still calls public', () => {
// The ratchet fired during this turn; the cached list was fetched before it.
cachedList = [{ id: 'sess-1', privacy_tier: 'public' }];
liveTiers = { 'sess-1': 'private' };
render(<ChatGroupsShell onChatChange={() => {}} />);
expect(lastStripProps.privacyTiers).toEqual({ 'sess-1': 'private' });
});

it('never lets a store that has not caught up LOWER a private the list knows', () => {
// The safe-direction invariant, stated as its own gate. The tier is a
// permanent ratchet server-side, so `private` from either source is a fact
// that still holds — and a surface may only render private-or-unmarked,
// never public over a source that has seen private.
cachedList = [{ id: 'sess-1', privacy_tier: 'private' }];
liveTiers = { 'sess-1': 'public' };
render(<ChatGroupsShell onChatChange={() => {}} />);
expect(lastStripProps.privacyTiers).toEqual({ 'sess-1': 'private' });
});

it('still marks nothing when neither source has an opinion', () => {
cachedList = [];
liveTiers = {};
render(<ChatGroupsShell onChatChange={() => {}} />);
expect(lastStripProps.privacyTiers).toEqual({});
});
});
100 changes: 62 additions & 38 deletions ui/desktop/src/components/chatGroups/ChatGroupsShell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,8 @@ import {
preloadSessionList,
subscribeSessionList,
} from '../../utils/sessionListCache';
import { useLiveSessionTiers } from '../../hooks/chatStreamStore';
import { mergeSessionTiers, sessionTiersDiffer } from '../privacy/sessionTier';
import type { SessionClassification } from '../../api';

interface ChatGroupsShellProps {
Expand Down Expand Up @@ -73,8 +75,51 @@ function renderLayout(
/**
* Privacy tier per session id, for the tab strips (issue #56, R10).
*
* Reads the shared session-list cache and asks it to fill itself. The earlier
* version of this comment claimed AppSidebar warmed the cache "at module
* # Two sources, and the cache is the FALLBACK
*
* A tab whose chat has been opened in this window has a `ChatStreamController`
* holding the row, and that store is the same one the header pill and the
* composer read — `applyTurnBinding` patches the post-ratchet classification
* onto it from the reply stream's FIRST frames. That is the live source, and it
* is read here through `useLiveSessionTiers`.
*
* Only the ACTIVE tab of each pane mounts a `BaseChat`, so a tab never yet
* visited in this window has no store at all. Those get their tier from the
* shared session-list cache, exactly as every tab used to.
*
* # Why the cache alone was wrong (finding M8, measured 2026-09-10)
*
* A chat CREATED in this window is not in that cache and cannot be put there by
* announcing at create time: `GET /sessions` INNER JOINs `messages`
* (`SessionStorage::list_sessions_by_types_maybe_empty`), so a row that has
* recorded no message is not listable. `refreshSessionBinding` patches only an
* entry the cache already holds — deliberately, since list membership belongs
* to the list channel. So: new chat, one turn on a private model, sqlite
* `privacy_tier=private`, the sidebar row private (it reads a freshly-fetched
* list), the model chip private — and the active tab's own dot still
* `data-privacy="public"` 52.9 seconds later.
*
* The comment this replaces claimed the gap was closed "twice over". Both
* mechanisms it named are real and neither reaches this map: the reply stream's
* classification lands on the STORE, and `refreshSessionBinding`'s list patch is
* a no-op for a session the list has never carried.
*
* # The merge is `max`, not "freshest wins"
*
* The tier is a permanent ratchet server-side
* (`crates/biorouter/src/privacy/mod.rs`) — public → private, never back — so a
* `private` from ANY source is a fact that still holds, and a `public` is only
* a lower bound. {@link mergeSessionTiers} folds the two with `max` and
* `undefined` stays unmarked. The invariant, which
* `ChatGroupsShell.privacy.test.tsx` pins: this map may render private-from-
* either-source or unmarked, and can never render public over a source that has
* seen private. There is no failure mode in which it over-marks — no source
* here invents a tier, they only report a row. `ChatTabStrip`'s `privacyTiers`
* prop doc states the same thing, and the two must not drift apart again.
*
* # The cache still has to be warmed here
*
* An earlier version of this comment claimed AppSidebar warmed it "at module
* scope"; it does not. `preloadSessionList` lives inside AppSidebar's
* `preloadHome()`, wired to `onFocus`/`onPointerEnter` on the Home nav entry,
* so it fires only if the user points at Home. What actually warmed the cache
Expand All @@ -85,40 +130,22 @@ function renderLayout(
* non-null and swallows its own errors, costing one fetch on a cold start.
*
* In jsdom, where the module is mocked or the fetch fails, the cache stays null
* and every tab is simply unmarked — silence, never an assertion of Public.
* and a tab with no store is simply unmarked — silence, never an assertion of
* Public.
*
* ⚠ What re-emits through `subscribeSessionList` is narrower than it looks.
* Any `emitChange` reaches this hook — a completed `refreshSessionList`, the
* name-channel patch, `updateCachedSessionList` (SessionListView's rename and
* delete), `clearSessionListCache`. But `notifySessionListChanged`, the signal
* whose own doc-comment says "call after create, diverge, delete or import",
* has exactly ONE production caller: `useDiverge.ts`. Create and import do not
* announce, so this cache learns of them only when some list surface mounts.
*
* ⚠ A stale cache fails in the UNSAFE direction, not the safe one. The tier is
* a permanent ratchet server-side (`crates/biorouter/src/privacy/mod.rs`) — it
* only ever rises public → private — so a cached `public`, or a session the
* cache has never seen, leaves a now-private chat with no dot. There is no
* failure mode in which this over-marks. `ChatTabStrip`'s `privacyTiers` prop
* doc states the same thing, and the two must not drift apart again.
*
* ⚠ This USED to record a known gap, and the gap is closed — the note is kept
* because the shape of the fix is what a future reader needs. It read: the
* header pill is not live either, `privacy_tier` is never re-read on the turn
* path, so a chat that ratchets to Private DURING its life shows no marker on
* either chat-side surface until something reloads it; "closing this needs the
* escalation to announce itself from the provider-bind path".
*
* It does now, twice over. The reply stream states the post-ratchet
* classification in its own first frames, and `ChatStreamController.refresh
* SessionBinding` patches the same four fields onto THIS cache as well as onto
* its own snapshot — so the tab dot, the header pill and the composer read one
* answer. A row rewritten by another process arrives the same way, through
* `utils/sessionMetaSubscription`. History rows and the sidebar rail, which read
* freshly-fetched lists, were always correct and are unchanged.
* delete), `clearSessionListCache`, `notifySessionListChanged`. That last one
* now has three production callers rather than one: `useDiverge`, the import
* handler in `SessionListView`, and `refreshSessionBinding` the first time a
* chat finds itself missing from a list that has been fetched — which is how a
* chat born in this window reaches Home recents and See-all. It is NOT how the
* tab dot gets fixed; the live store above is, and it costs no request.
*/
function useSessionPrivacyTiers(): Record<string, SessionClassification> {
const [tiers, setTiers] = useState<Record<string, SessionClassification>>({});
const [cachedTiers, setCachedTiers] = useState<Record<string, SessionClassification>>({});
const liveTiers = useLiveSessionTiers();

useEffect(() => {
const read = () => {
Expand All @@ -128,13 +155,7 @@ function useSessionPrivacyTiers(): Record<string, SessionClassification> {
}
// Identity-stable when nothing changed, so a list refresh that touched
// no tier does not re-render every strip in every pane.
setTiers((prev) => {
const prevKeys = Object.keys(prev);
const same =
prevKeys.length === Object.keys(next).length &&
prevKeys.every((id) => prev[id] === next[id]);
return same ? prev : next;
});
setCachedTiers((prev) => (sessionTiersDiffer(prev, next) ? next : prev));
};
read();
// Subscribe BEFORE asking for the fetch. `preloadSessionList` is async but
Expand All @@ -146,7 +167,10 @@ function useSessionPrivacyTiers(): Record<string, SessionClassification> {
return unsubscribe;
}, []);

return tiers;
// Memoised on the two inputs, both of which are identity-stable while
// unchanged: the merged object is a prop on every strip in every pane, so a
// fresh one per render would re-render all of them once per streamed token.
return useMemo(() => mergeSessionTiers(cachedTiers, liveTiers), [cachedTiers, liveTiers]);
}

export function ChatGroupsShell({ onChatChange }: ChatGroupsShellProps) {
Expand Down
13 changes: 11 additions & 2 deletions ui/desktop/src/components/chatGroups/ChatTabStrip.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -99,8 +99,17 @@ export interface ChatTabStripProps {
* `ChatTab` is persisted by chatGroupsStorage, and a tier persisted with a tab
* is a tier that can be read back stale. The unsafe direction is the likely
* one: the tier only ever RISES during a session, so a cached `public` would
* leave a now-private chat unmarked. A session-keyed map recomputed from the
* live session list has no such state to go stale.
* leave a now-private chat unmarked. A session-keyed map recomputed from
* sources with no state of their own has no such thing to go stale.
*
* ⚠ **Recomputed from TWO sources, folded with `max`** — the chat stores this
* window holds (live, and where a ratchet during a turn first appears) over
* the session-list cache (which covers tabs never opened here, and which
* cannot carry a chat created in this window until it has recorded a
* message). `ChatGroupsShell.useSessionPrivacyTiers` builds it, its doc gives
* the measurement, and the two must not drift apart again: reading the list
* cache ALONE is finding M8 — the active tab's dot stuck on public while the
* sidebar, the chip and sqlite all read private.
*
* Optional with a `{}` default, like `tabAnnotations`, so the four suites that
* mount this strip bare keep compiling untouched. An unknown session is
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,11 @@ vi.mock('../ui/sidebar', () => ({
vi.mock('../../hooks/chatStreamStore', () => ({
useRunningChats: () => [],
defaultChatStreamRegistry: { peekController: () => undefined },
// The shell reads the live per-chat privacy tiers from the registry (finding
// M8). Nothing here is about privacy, so an empty map is the whole stub —
// but it has to be PRESENT: this file replaces the module wholesale, and a
// missing export is a render-time throw, not an `undefined`.
useLiveSessionTiers: () => ({}),
}));
vi.mock('../../utils/sessionNameSync', () => ({
subscribeSessionNameChanges: () => () => undefined,
Expand Down
91 changes: 91 additions & 0 deletions ui/desktop/src/components/privacy/sessionTier.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
import { describe, expect, it } from 'vitest';
import { mergeSessionTiers, raiseTier, sessionTiersDiffer } from './sessionTier';

/**
* The one rule every surface that draws a tier depends on: two readings of the
* same chat combine with `max`, never with "whichever is fresher".
*
* The safe direction is asserted directly rather than left as a property of the
* caller — a merge that let a stale `public` win is finding M8 in one line, and
* a merge that invented `private` for an unknown chat would be the same lie
* pointing the other way.
*/
describe('raiseTier — max over public < private', () => {
it('raises public to private', () => {
expect(raiseTier('public', 'private')).toBe('private');
});

it('never lowers private back to public, whichever side the public is on', () => {
expect(raiseTier('private', 'public')).toBe('private');
expect(raiseTier('public', 'private')).toBe('private');
});

it('keeps private when the other side has no opinion', () => {
expect(raiseTier('private', undefined)).toBe('private');
expect(raiseTier(undefined, 'private')).toBe('private');
});

it('leaves an id nobody has read unmarked, rather than calling it public', () => {
expect(raiseTier(undefined, undefined)).toBeUndefined();
});

it('keeps public when that is all either side has', () => {
expect(raiseTier('public', 'public')).toBe('public');
expect(raiseTier(undefined, 'public')).toBe('public');
});
});

describe('mergeSessionTiers — folding a live map over a cached one', () => {
it('takes private from the LIVE source over a stale cached public — finding M8', () => {
const cached = { chat: 'public' } as const;
const live = { chat: 'private' } as const;
expect(mergeSessionTiers(cached, live)).toEqual({ chat: 'private' });
});

it('takes private from the CACHED source when the live store has not seen it', () => {
// A tab never opened in this window has no store, so the list is the only
// source it has — and it must still be marked.
expect(mergeSessionTiers({ chat: 'private' }, {})).toEqual({ chat: 'private' });
});

it('is order-independent, so neither source is privileged', () => {
const cached = { a: 'public', b: 'private' } as const;
const live = { a: 'private', b: 'public' } as const;
expect(mergeSessionTiers(cached, live)).toEqual(mergeSessionTiers(live, cached));
expect(mergeSessionTiers(cached, live)).toEqual({ a: 'private', b: 'private' });
});

it('omits an id no source has an opinion about', () => {
expect(mergeSessionTiers({}, {})).toEqual({});
expect(mergeSessionTiers(undefined, null)).toEqual({});
});

it('carries ids that appear in only one source', () => {
expect(mergeSessionTiers({ a: 'public' }, { b: 'private' })).toEqual({
a: 'public',
b: 'private',
});
});
});

describe('sessionTiersDiffer — the identity-stability test', () => {
it('is false for equal maps, so nothing re-renders', () => {
expect(sessionTiersDiffer({ a: 'private' }, { a: 'private' })).toBe(false);
expect(sessionTiersDiffer({}, {})).toBe(false);
});

it('is true when a tier moved', () => {
expect(sessionTiersDiffer({ a: 'public' }, { a: 'private' })).toBe(true);
});

it('is true when the SET of ids changed, both ways', () => {
expect(sessionTiersDiffer({}, { a: 'public' })).toBe(true);
expect(sessionTiersDiffer({ a: 'public' }, {})).toBe(true);
});

it('is true for two same-sized maps that name different chats', () => {
// The cheap test — comparing lengths — passes here, so the per-key walk is
// what makes this correct rather than merely fast.
expect(sessionTiersDiffer({ a: 'private' }, { b: 'private' })).toBe(true);
});
});
Loading
Loading