Skip to content
Closed
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 @@ -9,6 +9,7 @@ const mocks = vi.hoisted(() => ({ loadPage: vi.fn(), onPage: vi.fn() }));
vi.mock('@/flow_chat/store/FlowChatStore', () => ({ flowChatStore: { loadSessionMetadataPage: mocks.loadPage } }));
vi.mock('@/shared/utils/logger', () => ({ createLogger: () => ({ warn: vi.fn(), info: vi.fn(), debug: vi.fn(), error: vi.fn() }) }));
import { useWorkspaceSessionCatalog } from './useWorkspaceSessionCatalog';
import { workspaceSessionCatalogTotal } from './workspaceSessionCatalogQuery';

const page = (nextCursor?: string): SessionMetadataPage => ({
sessions: [], totalTopLevelCount: 655, loadedTopLevelCount: 200, hasMore: Boolean(nextCursor), nextCursor,
Expand All @@ -21,10 +22,10 @@ const deferred = () => {
};
type Props = { workspaceId?: string; enabled?: boolean; refreshVersion?: number; agent?: string; query?: SessionCatalogQuery; targetCount?: number; firstPage?: SessionMetadataPage; getKnownCount?: () => number; captureQuery?: (query: SessionCatalogQuery) => SessionCatalogQuery };
let current: ReturnType<typeof useWorkspaceSessionCatalog>;
const renders: Array<{ workspaceId: string; complete: boolean }> = [];
const renders: Array<{ workspaceId: string; complete: boolean; paged: boolean; total?: number }> = [];
function Harness({ workspaceId = 'main', enabled = true, refreshVersion = 0, agent = 'all', query, targetCount, firstPage, getKnownCount, captureQuery }: Props) {
current = useWorkspaceSessionCatalog({ workspaceId, enabled, refreshVersion, onPage: mocks.onPage, query, targetCount, firstPage, getKnownCount, captureQuery });
renders.push({ workspaceId, complete: current.complete });
renders.push({ workspaceId, complete: current.complete, paged: current.paged, total: current.total });
return <output data-agent={agent}>{current.complete ? 'complete' : current.failed ? 'failed' : 'pending'}</output>;
}

Expand Down Expand Up @@ -131,6 +132,96 @@ describe('workspace metadata catalog', () => {
...page(nextCursor), loadedTopLevelCount: loaded, catalogQuery: applied,
});

it('keeps the settled catalog visible throughout a multi-page ordering-pin refresh', async () => {
let knownCount = 5;
const getKnownCount = () => knownCount;
mocks.loadPage.mockResolvedValueOnce(queriedPage(5, 'old-5'));
await render({ query, getKnownCount });
const pinned = { ...query, sortTimestamps: { running: 100 } };
const first = deferred();
const last = deferred();
mocks.loadPage.mockReturnValueOnce(first.promise).mockReturnValueOnce(last.promise);
const beforeRefresh = renders.length;
await render({ query: pinned, getKnownCount });
expect(current).toMatchObject({ complete: true, paged: true, loading: true, total: 655, loaded: 5, knownCount: 5 });
expect(mocks.loadPage).toHaveBeenLastCalledWith('main', 5, undefined,
'sessions_nav_catalog', { fresh: false, query: pinned });
mocks.onPage.mockImplementationOnce(() => { knownCount = 8; });
await act(async () => first.resolve({ ...queriedPage(3, 'new-3', pinned), totalTopLevelCount: 660 }));
expect(current).toMatchObject({ complete: true, paged: true, loading: true, total: 655, loaded: 5, knownCount: 8 });
expect(workspaceSessionCatalogTotal(knownCount, current)).toBe(655);
expect(renders.slice(beforeRefresh).every(value => value.complete && value.paged && value.total === 655)).toBe(true);
expect(mocks.loadPage).toHaveBeenLastCalledWith('main', 2, 'new-3',
'sessions_nav_catalog', { fresh: false, query: pinned });
mocks.onPage.mockImplementationOnce(() => { knownCount = 10; });
await act(async () => last.resolve({ ...queriedPage(2, 'new-5', pinned), totalTopLevelCount: 660 }));
expect(current).toMatchObject({ complete: true, paged: true, loading: false, total: 660,
loaded: 5, knownCount: 10, nextCursor: 'new-5', requestQuery: pinned });
});

it.each([false, true])('rejects stale pin refreshes when previous pins are restored: %s', async restorePrevious => {
const firstPins = { ...query, sortTimestamps: { running: 100 } };
const nextPins = { ...query, sortTimestamps: { running: 200 } };
mocks.loadPage.mockResolvedValueOnce(queriedPage(5, 'old-5', firstPins));
await render({ query: firstPins });
const stale = deferred();
const latest = deferred();
mocks.loadPage.mockReturnValueOnce(stale.promise).mockReturnValueOnce(latest.promise);
await render({ query: nextPins });
const latestQuery = restorePrevious ? firstPins : query;
await render({ query: latestQuery });
expect(current).toMatchObject({ complete: true, paged: true, loading: true, total: 655 });
await act(async () => stale.resolve({ ...queriedPage(5, 'stale-5', nextPins), totalTopLevelCount: 999 }));
expect(mocks.onPage).toHaveBeenCalledTimes(1);
expect(current.total).toBe(655);
await act(async () => latest.resolve({ ...queriedPage(5, 'latest-5', latestQuery), totalTopLevelCount: 660 }));
expect(current).toMatchObject({ complete: true, loading: false, total: 660, nextCursor: 'latest-5' });
});

it.each([
{ name: 'workspace', props: { workspaceId: 'other', query: { ...query, workspaceId: 'other' } } },
{ name: 'source', props: { query: { ...query, source: 'codex' } } },
{ name: 'ordering', props: { query: { ...query, ordering: 'created' as const } } },
{ name: 'client-side view', props: {} },
{ name: 'explicit refresh', props: { query, refreshVersion: 1 } },
{ name: 'peer device', props: { query }, peer: true },
])('invalidates a settled paged catalog immediately on a $name change', async ({ props, ...scenario }) => {
mocks.loadPage.mockResolvedValueOnce(queriedPage(5, 'old-5'));
await render({ query });
const pending = deferred();
mocks.loadPage.mockReturnValueOnce(pending.promise);
if ('peer' in scenario) activateSurface('peer');
const beforeChange = renders.length;
await render(props);
expect(current).toMatchObject({ complete: false, paged: false, loading: true });
expect(current.total).toBeUndefined();
expect(renders.slice(beforeChange).every(value => !value.complete && !value.paged && value.total === undefined)).toBe(true);
await act(async () => pending.resolve(page()));
});

it('does not resurrect a settled total after the host falls back to legacy traversal', async () => {
mocks.loadPage.mockResolvedValueOnce(queriedPage(5, 'old-5'));
await render({ query });
const pinned = { ...query, sortTimestamps: { running: 100 } };
mocks.loadPage.mockResolvedValueOnce(page()).mockResolvedValueOnce(page());
await render({ query: pinned });
expect(current).toMatchObject({ complete: true, paged: false, total: undefined });
const pending = deferred();
mocks.loadPage.mockReturnValueOnce(pending.promise);
await render({ query });
expect(current).toMatchObject({ complete: false, paged: false });
expect(current.total).toBeUndefined();
await act(async () => pending.resolve(queriedPage(5, 'new-5')));
});

it('surfaces a failed pin refresh instead of reporting a completed catalog', async () => {
mocks.loadPage.mockResolvedValueOnce(queriedPage(5, 'old-5'));
await render({ query });
mocks.loadPage.mockRejectedValueOnce(new Error('Host unavailable'));
await render({ query: { ...query, sortTimestamps: { running: 100 } } });
expect(current).toMatchObject({ complete: false, loading: false, failed: true });
});

it('restarts loaded counts after a host revision change without draining the catalog', async () => {
mocks.loadPage.mockResolvedValueOnce({ ...queriedPage(5, 'v1-5'), catalogRevision: 'v1' });
await render({ query });
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import type { SessionCatalogQuery, SessionMetadataPage } from '@/infrastructure/
import { getActiveSurfaceScope } from '@/infrastructure/peer-device/deviceSurface';
import { createLogger } from '@/shared/utils/logger';
import { loadWorkspaceSessionSourcePages } from './workspaceSessionSourcePages';
import { sessionCatalogQueryKey } from './workspaceSessionCatalogQuery';
import { sessionCatalogQueryKey, sessionCatalogViewKey } from './workspaceSessionCatalogQuery';

const log = createLogger('useWorkspaceSessionCatalog');
const PAGE_SIZE = 200;
Expand Down Expand Up @@ -45,7 +45,9 @@ export function useWorkspaceSessionCatalog({
const surface = getActiveSurfaceScope();
const queryKey = sessionCatalogQueryKey(query);
const scopeKey = surface.key(workspaceId, surface.epoch, refreshVersion, queryKey);
const viewScopeKey = surface.key(workspaceId, surface.epoch, refreshVersion, sessionCatalogViewKey(query));
const progress = useRef(initialState(''));
const settled = useRef<{ viewScopeKey: string; reader: CatalogState; snapshot: CatalogState } | undefined>(undefined);
const queryRef = useRef(query);
queryRef.current = query;
const [state, setState] = useState(initialState(''));
Expand All @@ -61,7 +63,18 @@ export function useWorkspaceSessionCatalog({
nextCursor: firstPage.nextCursor, hasMore: firstPage.hasMore, revision: firstPage.catalogRevision,
requestQuery: firstPage.catalogQuery };
}
const updateSettled = () => {
if (current.complete) {
settled.current = current.paged
? { viewScopeKey, reader: current, snapshot: { ...current } } : undefined;
} else if (current.paged && settled.current?.viewScopeKey === viewScopeKey) {
// Hydrated rows already belong to the settled total, not local additions.
const previous = settled.current;
settled.current = { ...previous, snapshot: { ...previous.snapshot, knownCount: current.knownCount } };
}
};
if (current.complete && (!current.paged || !current.hasMore || current.loaded >= targetCount)) {
updateSettled();
if (progress.current !== current) { progress.current = current; setState({ ...current }); }
return;
}
Expand All @@ -71,6 +84,7 @@ export function useWorkspaceSessionCatalog({
if (!isCurrent()) return;
Object.assign(current, updates);
progress.current = current;
updateSettled();
setState({ ...current });
};
let requestQuery = current.requestQuery ?? queryRef.current;
Expand Down Expand Up @@ -129,9 +143,17 @@ export function useWorkspaceSessionCatalog({
publish({ loading: false, failed: true, complete: false });
});
return () => { cancelled = true; };
}, [captureQuery, enabled, firstPage, getKnownCount, onPage, queryKey, refreshVersion, scopeKey, surface, targetCount, workspaceId]);
}, [captureQuery, enabled, firstPage, getKnownCount, onPage, queryKey, refreshVersion, scopeKey, surface, targetCount, viewScopeKey, workspaceId]);

return state.scopeKey === scopeKey
const visible = state.scopeKey === scopeKey
? state
: { ...initialState(scopeKey), loading: enabled && Boolean(workspaceId) };
const previous = settled.current;
// Keep the acknowledged view while new ordering pins start a fresh reader.
// Reader identity also covers pins reverting to an earlier query key.
if (previous?.viewScopeKey === viewScopeKey && !visible.complete && !visible.failed
&& (previous.reader !== progress.current || previous.snapshot.scopeKey !== scopeKey)) {
return { ...previous.snapshot, loading: visible.loading };
}
return visible;
}
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,11 @@ export function captureWorkspaceSessionCatalogQuery(
return Object.keys(sortTimestamps).length ? { ...query, sortTimestamps } : query;
}

/** Ordering pins change the reader, while the selected view stays the same. */
export function sessionCatalogViewKey(query?: SessionCatalogQuery): string {
return query ? JSON.stringify([query.workspaceId, query.ordering, query.source]) : '';
}

export function sessionCatalogQueryKey(query?: SessionCatalogQuery): string {
return query ? JSON.stringify([query.workspaceId, query.ordering, query.source,
Object.entries(query.sortTimestamps ?? {}).sort(([left], [right]) => left.localeCompare(right)),
Expand Down