diff --git a/src/components/features/debate/DebateContextControl.tsx b/src/components/features/debate/DebateContextControl.tsx
index e9e2b0c08..19af42a1b 100644
--- a/src/components/features/debate/DebateContextControl.tsx
+++ b/src/components/features/debate/DebateContextControl.tsx
@@ -9,6 +9,8 @@ import { useDebateContextPlan } from '@/lib/debate/context-state';
import type { ContextOutputItem, ContextSourceItem, DebateContextPlan } from '@/lib/debate/context';
import { useCurrentDebate, useDebateStore } from '@/lib/stores/debate-store';
import { outputKey, useDebateContextStore, type DebateContextUsage } from '@/lib/stores/debate-context-store';
+import { useSessionStore } from '@/lib/stores/session-store';
+import { useSparringStore } from '@/lib/stores/sparring-store';
import type { DebateContextFeature, DebateContextSide } from '../../../../supabase/functions/_shared/debate-context';
interface DebateContextControlProps {
@@ -42,6 +44,12 @@ export function DebateContextControl({ feature, side, includeSources = true, deb
const debateId = debateIdOverride !== undefined ? debateIdOverride : (oneOff ? null : debate?.id ?? null);
const plan = useDebateContextPlan(feature, side, debateId, { includeSources });
const pinsLoaded = useDebateContextStore((state) => (debateId ? state.pinsLoaded[debateId] : true));
+ // The pin limit discounts pins on results that are not listed. Until saved
+ // results have loaded, a missing result may just not be here yet; if a list
+ // failed to load, every pin counts.
+ const serverHydration = useSessionStore((state) => state.serverHydration);
+ const sparringHydrated = useSparringStore((state) => state.hydrated);
+ const sparringComplete = useSparringStore((state) => state.historyComplete);
const loadPins = useDebateContextStore((state) => state.loadPins);
const usage = useDebateContextStore((state) => state.lastUsage[feature]);
const [open, setOpen] = useState(false);
@@ -99,14 +107,26 @@ export function DebateContextControl({ feature, side, includeSources = true, deb
-
+
);
}
-function DebateContextReview({ plan, motion, usage }: { plan: DebateContextPlan; motion?: string; usage?: DebateContextUsage }) {
+function DebateContextReview({ plan, motion, usage, pinsReady, resultsComplete }: {
+ plan: DebateContextPlan;
+ motion?: string;
+ usage?: DebateContextUsage;
+ pinsReady: boolean;
+ resultsComplete: boolean;
+}) {
const { t } = useTranslation();
const setSourceChoice = useDebateContextStore((state) => state.setSourceChoice);
const setOutputChoice = useDebateContextStore((state) => state.setOutputChoice);
@@ -116,7 +136,7 @@ function DebateContextReview({ plan, motion, usage }: { plan: DebateContextPlan;
const onPin = async (output: ContextOutputItem) => {
try {
- await togglePin(plan.debateId, output.ref);
+ await togglePin(plan.debateId, output.ref, resultsComplete ? plan.resultKeys : undefined);
} catch (error) {
toast({
title: error instanceof Error && error.message === 'pin_limit'
@@ -180,6 +200,7 @@ function DebateContextReview({ plan, motion, usage }: { plan: DebateContextPlan;
output={output}
onChange={(include) => setOutputChoice(plan.feature, plan.debateId, output.ref, include)}
onPin={() => void onPin(output)}
+ pinDisabled={!pinsReady}
/>
))}
@@ -221,7 +242,7 @@ function SourceRow({ source, onChange }: { source: ContextSourceItem; onChange:
);
}
-function OutputRow({ output, onChange, onPin }: { output: ContextOutputItem; onChange: (include: boolean) => void; onPin: () => void }) {
+function OutputRow({ output, onChange, onPin, pinDisabled }: { output: ContextOutputItem; onChange: (include: boolean) => void; onPin: () => void; pinDisabled: boolean }) {
const { t } = useTranslation();
const id = `debate-context-output-${output.ref.kind}-${output.ref.id}`;
const note = output.included ? statusNote(output, t) : null;
@@ -247,6 +268,7 @@ function OutputRow({ output, onChange, onPin }: { output: ContextOutputItem; onC
? t('debateContext.unpinAria', { title: output.title || kind })
: t('debateContext.pinAria', { title: output.title || kind })}
onClick={onPin}
+ disabled={pinDisabled}
>
{output.pinned ? : }
diff --git a/src/lib/debate/context.ts b/src/lib/debate/context.ts
index 4cdfd36da..fecb09031 100644
--- a/src/lib/debate/context.ts
+++ b/src/lib/debate/context.ts
@@ -64,6 +64,8 @@ export interface DebateContextPlan {
policy: DebateContextPolicy;
sources: ContextSourceItem[];
outputs: ContextOutputItem[];
+ /** Every result in this debate, the open Ask chat included; pins on anything else no longer count. */
+ resultKeys: ReadonlySet;
request: DebateContextRequest;
}
@@ -153,6 +155,10 @@ export function planDebateContext(
const pinnedKeys = new Set(inputs.pins.map(outputKey));
const candidates = policy.outputs ? outputCandidates(debateId, inputs) : [];
+ // A pin on a result that isn't loaded here, because it was deleted or is
+ // older than local history keeps, is never shown or sent. It must not use up
+ // the limit, or the student could never free that slot.
+ const resultKeys = new Set(outputCandidates(debateId, { ...inputs, excludeChatId: null }).map((item) => outputKey(item.ref)));
const defaults = selectDefaultOutputs(
candidates.map((item) => ({ ...item.ref, side: item.side, createdAt: item.createdAt, pinned: pinnedKeys.has(outputKey(item.ref)) })),
feature,
@@ -194,6 +200,7 @@ export function planDebateContext(
policy,
sources,
outputs,
+ resultKeys,
request: {
debateId,
side,
diff --git a/src/lib/stores/debate-context-store.ts b/src/lib/stores/debate-context-store.ts
index 33846be2e..ea743827d 100644
--- a/src/lib/stores/debate-context-store.ts
+++ b/src/lib/stores/debate-context-store.ts
@@ -40,7 +40,8 @@ interface DebateContextState {
lastUsage: Partial>;
loadPins: (debateId: string) => Promise;
- togglePin: (debateId: string, ref: DebateOutputRef) => Promise;
+ /** Only pins on `resultKeys`, the debate's existing results, count toward the limit. */
+ togglePin: (debateId: string, ref: DebateOutputRef, resultKeys?: ReadonlySet) => Promise;
setSourceChoice: (feature: DebateContextFeature, debateId: string, sourceId: string, include: boolean) => void;
setOutputChoice: (feature: DebateContextFeature, debateId: string, ref: DebateOutputRef, include: boolean) => void;
resetOverride: (feature: DebateContextFeature) => void;
@@ -52,9 +53,20 @@ function ensureOverride(current: DebateContextOverride | undefined, debateId: st
return current && current.debateId === debateId ? current : { debateId, sources: {}, outputs: {} };
}
-// Counts pin changes per debate, so a load that started before a change
-// cannot bring back what the student just pinned or unpinned.
-const pinChanges: Record = {};
+// Every local pin change, per debate and in order. A load replays the changes
+// it may have missed: ones made after it started, and ones that were still
+// saving at any point during the load, because its read may predate them.
+// `before` is the result's state before this change; if the change fails, the
+// result goes back to it.
+interface PinChange { seq: number; ref: DebateOutputRef; pinned: boolean; before: boolean; saving: boolean }
+const pinChanges: Record = {};
+let pinSeq = 0;
+// Each debate's pin writes run one at a time, so the database ends in the
+// same state as the screen even after a quick pin and unpin.
+const pinWrites: Record> = {};
+// Bumped when local data is reset on sign-out. Pin work started before that
+// belongs to the previous account, so it must not write or change state.
+let pinGeneration = 0;
function withPin(pins: DebateOutputRef[], ref: DebateOutputRef, pinned: boolean): DebateOutputRef[] {
const without = pins.filter((item) => outputKey(item) !== outputKey(ref));
@@ -68,44 +80,73 @@ export const useDebateContextStore = create()((set, get) =>
lastUsage: {},
loadPins: async (debateId) => {
- const changesBefore = pinChanges[debateId] ?? 0;
+ const startedAt = pinSeq;
+ const generation = pinGeneration;
+ const savingAtStart = new Set((pinChanges[debateId] ?? []).filter((change) => change.saving));
const { data, error } = await supabase
.from('debate_context_pins')
.select('item_kind,item_id')
.eq('debate_id', debateId);
+ if (generation !== pinGeneration) return;
// Pins are an optional preference. Until the table exists everywhere, or
// if it cannot be read, requests fall back to the automatic selection.
- if (error || (pinChanges[debateId] ?? 0) !== changesBefore) {
+ if (error) {
set((state) => ({ pinsLoaded: { ...state.pinsLoaded, [debateId]: true } }));
return;
}
- const pins = ((data ?? []) as Array<{ item_kind: unknown; item_id: unknown }>).flatMap((row) =>
+ let pins = ((data ?? []) as Array<{ item_kind: unknown; item_id: unknown }>).flatMap((row) =>
isDebateContextOutputKind(row.item_kind) && typeof row.item_id === 'string'
? [{ kind: row.item_kind as DebateContextOutputKind, id: row.item_id }]
: []);
+ for (const change of pinChanges[debateId] ?? []) {
+ if (change.seq > startedAt || change.saving || savingAtStart.has(change)) pins = withPin(pins, change.ref, change.pinned);
+ }
set((state) => ({
pins: { ...state.pins, [debateId]: pins },
pinsLoaded: { ...state.pinsLoaded, [debateId]: true },
}));
},
- togglePin: async (debateId, ref) => {
+ togglePin: async (debateId, ref, resultKeys) => {
const current = get().pins[debateId] ?? [];
const pinned = current.some((item) => outputKey(item) === outputKey(ref));
- if (!pinned && current.length >= MAX_PINNED_OUTPUTS) throw new Error('pin_limit');
- pinChanges[debateId] = (pinChanges[debateId] ?? 0) + 1;
- set((state) => ({ pins: { ...state.pins, [debateId]: withPin(current, ref, !pinned) } }));
- const { data } = await supabase.auth.getUser();
- const userId = data.user?.id;
- const result = !userId
- ? { error: { message: 'Sign in to pin results' } }
- : pinned
+ const counted = resultKeys ? current.filter((item) => resultKeys.has(outputKey(item))) : current;
+ if (!pinned && counted.length >= MAX_PINNED_OUTPUTS) throw new Error('pin_limit');
+ const generation = pinGeneration;
+ const change: PinChange = { seq: ++pinSeq, ref, pinned: !pinned, before: pinned, saving: true };
+ (pinChanges[debateId] ??= []).push(change);
+ set((state) => ({ pins: { ...state.pins, [debateId]: withPin(state.pins[debateId] ?? [], ref, !pinned) } }));
+ const save = async (): Promise<{ error: { message?: string; code?: string } | null }> => {
+ if (generation !== pinGeneration) return { error: null };
+ const { data } = await supabase.auth.getUser();
+ const userId = data.user?.id;
+ if (generation !== pinGeneration) return { error: null };
+ if (!userId) return { error: { message: 'Sign in to pin results' } };
+ return pinned
? await supabase.from('debate_context_pins').delete()
.eq('user_id', userId).eq('debate_id', debateId).eq('item_kind', ref.kind).eq('item_id', ref.id)
: await supabase.from('debate_context_pins').insert({ user_id: userId, debate_id: debateId, item_kind: ref.kind, item_id: ref.id });
+ };
+ const write = (pinWrites[debateId] ?? Promise.resolve()).then(save);
+ pinWrites[debateId] = write.catch((): void => undefined);
+ const result: Awaited> = await write
+ .catch((error: unknown) => ({ error: { message: error instanceof Error ? error.message : undefined } }));
+ change.saving = false;
+ if (generation !== pinGeneration) return;
+ // The row already exists, so the result is pinned as asked.
+ if (change.pinned && result.error?.code === '23505') return;
if (result.error) {
- // Undo only this change; other pins may have changed in the meantime.
- set((state) => ({ pins: { ...state.pins, [debateId]: withPin(state.pins[debateId] ?? [], ref, pinned) } }));
+ // Undo only this change; other pins may have changed in the meantime. A
+ // later change to the same result is queued behind this one and decides
+ // its final state. It assumed this change was saved, so if it fails too
+ // it must go back to the state before this one.
+ const next = pinChanges[debateId].find((item) => item.seq > change.seq && outputKey(item.ref) === outputKey(ref));
+ pinChanges[debateId] = pinChanges[debateId].filter((item) => item !== change);
+ if (next) {
+ next.before = change.before;
+ return;
+ }
+ set((state) => ({ pins: { ...state.pins, [debateId]: withPin(state.pins[debateId] ?? [], ref, change.before) } }));
throw new Error(result.error.message ?? 'Could not save the pin');
}
},
@@ -134,7 +175,12 @@ export const useDebateContextStore = create()((set, get) =>
return { lastUsage };
}),
- resetLocalData: () => set({ pins: {}, pinsLoaded: {}, overrides: {}, lastUsage: {} }),
+ resetLocalData: () => {
+ pinGeneration += 1;
+ for (const debateId of Object.keys(pinChanges)) delete pinChanges[debateId];
+ for (const debateId of Object.keys(pinWrites)) delete pinWrites[debateId];
+ set({ pins: {}, pinsLoaded: {}, overrides: {}, lastUsage: {} });
+ },
}));
/** Reads the server's `meta.debateContext`, ignoring anything malformed. */
diff --git a/src/lib/stores/session-store.ts b/src/lib/stores/session-store.ts
index abea0a433..436398935 100644
--- a/src/lib/stores/session-store.ts
+++ b/src/lib/stores/session-store.ts
@@ -217,6 +217,11 @@ export interface SessionState {
clearLibrary: () => Promise;
hydrateFromServer: () => Promise;
+ /**
+ * This page load's server hydration: `partial` when a saved-result list
+ * failed to load and may be missing results. Not persisted.
+ */
+ serverHydration: 'pending' | 'partial' | 'complete';
clearAllData: () => Promise;
resetLocalData: () => void;
clearSession: () => void;
@@ -484,6 +489,7 @@ export const useSessionStore = create()(
setFactoidsForm: (data) => set((state) => ({ factoidsForm: mergeFormState(state.factoidsForm, data) })),
pendingFlowchartData: null as FlowchartData | null,
+ serverHydration: 'pending',
setPendingFlowchartData: (data) => set({ pendingFlowchartData: data }),
addSource: async (source) => {
@@ -1173,6 +1179,7 @@ export const useSessionStore = create()(
savedFlowcharts: flowchartsData ? serverFlowcharts : state.savedFlowcharts,
sourceFolders: mergeSourceFolders([...(sourceFoldersData ? [] : state.sourceFolders), ...serverSourceFolders], sourcesData ? serverSources : state.sourceLibrary),
sourceLibrary: sourcesData ? serverSources : state.sourceLibrary,
+ serverHydration: casesData && attacksData && brainstormsData && factoidsData && chatsData && crossfiresData ? 'complete' : 'partial',
}));
},
@@ -1207,6 +1214,7 @@ export const useSessionStore = create()(
});
},
resetLocalData: () => set({
+ serverHydration: 'pending',
savedCases: [],
savedAttacks: [],
savedBrainstorms: [],
@@ -1273,6 +1281,7 @@ export const useSessionStore = create()(
askLoading,
askStreamingAssistant,
pendingFlowchartData,
+ serverHydration,
...persisted
}) => persisted,
}
diff --git a/src/lib/stores/sparring-store.ts b/src/lib/stores/sparring-store.ts
index 045581514..1ecf0dcf9 100644
--- a/src/lib/stores/sparring-store.ts
+++ b/src/lib/stores/sparring-store.ts
@@ -55,6 +55,8 @@ interface SparringStoreState {
privateSessionBackup: SparringSession | null;
savedSessions: SparringSession[];
hydrated: boolean;
+ /** False until saved history has loaded successfully; hydration can finish without it. */
+ historyComplete: boolean;
beginWorkspaceSession: () => void;
endWorkspaceSession: () => void;
startSession: (input: StartSessionInput) => SparringSession;
@@ -300,6 +302,7 @@ export const useSparringStore = create()(
privateSessionBackup: null as SparringSession | null,
savedSessions: [] as SparringSession[],
hydrated: false,
+ historyComplete: false,
beginWorkspaceSession: () => set((state) => ({
currentSession: null,
@@ -462,7 +465,7 @@ export const useSparringStore = create()(
prepareForUser: (userId) => {
if (get().ownerUserId === userId) return;
hydrationEpoch += 1;
- set({ ownerUserId: userId, currentSession: null, privateSessionBackup: null, savedSessions: [], hydrated: false });
+ set({ ownerUserId: userId, currentSession: null, privateSessionBackup: null, savedSessions: [], hydrated: false, historyComplete: false });
},
hydrateSavedSessions: async () => {
@@ -484,7 +487,7 @@ export const useSparringStore = create()(
set({ hydrated: true });
return;
}
- set((state) => ({ savedSessions: mergeSessions(state.savedSessions, parseSavedRows(data)), hydrated: true }));
+ set((state) => ({ savedSessions: mergeSessions(state.savedSessions, parseSavedRows(data)), hydrated: true, historyComplete: true }));
},
loadSavedSession: (id) => {
@@ -511,7 +514,7 @@ export const useSparringStore = create()(
},
resetLocalData: () => {
hydrationEpoch += 1;
- set({ ownerUserId: null, currentSession: null, privateSessionBackup: null, savedSessions: [], hydrated: false });
+ set({ ownerUserId: null, currentSession: null, privateSessionBackup: null, savedSessions: [], hydrated: false, historyComplete: false });
},
}),
{
diff --git a/src/test/debate/debate-context.test.tsx b/src/test/debate/debate-context.test.tsx
index 90917d992..b10e29126 100644
--- a/src/test/debate/debate-context.test.tsx
+++ b/src/test/debate/debate-context.test.tsx
@@ -1,5 +1,5 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
-import { fireEvent, render, screen, waitFor, within } from '@testing-library/react';
+import { act, fireEvent, render, screen, waitFor, within } from '@testing-library/react';
import { resetMockState, setMockInvokeResponse, setMockSessionToken, supabaseMock } from '@/test/mocks/supabase';
vi.mock('@/lib/supabase/client', async () => {
@@ -19,6 +19,7 @@ import { brainstorm } from '@/lib/ai/client';
import { useDebateContextStore } from '@/lib/stores/debate-context-store';
import { useDebateStore } from '@/lib/stores/debate-store';
import { useSessionStore, type SavedItem, type Source } from '@/lib/stores/session-store';
+import { useSparringStore } from '@/lib/stores/sparring-store';
import { enableOneOffMode, resetDebates, seedCurrentDebate, TEST_DEBATE_ID } from '@/test/helpers/debate';
const OTHER_DEBATE = '99999999-2222-4333-8444-555555555555';
@@ -95,6 +96,16 @@ describe('Current Debate context plan', () => {
const plan = planDebateContext('ask', 'both', TEST_DEBATE_ID, inputs({ pins: [{ kind: 'chat', id: uuid(20) }], excludeChatId: uuid(20) }));
expect(plan.outputs.map((item) => item.ref.id)).not.toContain(uuid(20));
});
+
+ it('lists every result in the debate for the pin limit, including the chat open in Ask', () => {
+ const plan = planDebateContext('ask', 'both', TEST_DEBATE_ID, inputs({
+ pins: [{ kind: 'case', id: uuid(10) }, { kind: 'chat', id: uuid(20) }, { kind: 'case', id: uuid(40) }],
+ excludeChatId: uuid(20),
+ }));
+ expect(plan.resultKeys.has(`case:${uuid(10)}`)).toBe(true);
+ expect(plan.resultKeys.has(`chat:${uuid(20)}`)).toBe(true);
+ expect(plan.resultKeys.has(`case:${uuid(40)}`)).toBe(false);
+ });
});
describe('Current Debate context in requests and the Review control', () => {
@@ -107,7 +118,9 @@ describe('Current Debate context in requests and the Review control', () => {
sourceLibrary: [source(1), source(2, { isActive: false })],
savedCases: [result(10, caseData) as SavedItem],
savedAttacks: [], savedBrainstorms: [], savedFactoids: [], savedChats: [], savedCrossfires: [],
+ serverHydration: 'complete',
});
+ useSparringStore.setState({ hydrated: true, historyComplete: true });
});
afterEach(() => {
resetDebates();
@@ -178,6 +191,46 @@ describe('Current Debate context in requests and the Review control', () => {
await waitFor(() => expect(control).toHaveTextContent('Using 2 sources and 1 saved result'));
expect(within(sheet).getByRole('button', { name: /Pin Result 10/ })).toHaveAttribute('aria-pressed', 'false');
});
+
+ it('keeps pin buttons disabled until saved results have loaded', async () => {
+ // Pins on results that have not loaded yet cannot be counted toward the limit.
+ seedCurrentDebate();
+ useSessionStore.setState({ serverHydration: 'pending' });
+ useSparringStore.setState({ hydrated: false, historyComplete: false });
+ render();
+ fireEvent.click(within(screen.getByTestId('debate-context-control')).getByRole('button', { name: 'Review' }));
+ const sheet = await screen.findByRole('dialog');
+ await waitFor(() => expect(useDebateContextStore.getState().pinsLoaded[TEST_DEBATE_ID]).toBe(true));
+ const pin = within(sheet).getByRole('button', { name: /Pin Result 10/ });
+
+ expect(pin).toBeDisabled();
+ act(() => useSessionStore.setState({ serverHydration: 'complete' }));
+ expect(pin).toBeDisabled();
+ act(() => useSparringStore.setState({ hydrated: true, historyComplete: true }));
+ expect(pin).toBeEnabled();
+ });
+
+ it.each([
+ ['a saved-result query', () => useSessionStore.setState({ serverHydration: 'partial' })],
+ ['the sparring history query', () => useSparringStore.setState({ historyComplete: false })],
+ ])('counts every pin toward the limit when %s failed', async (_name, failHydration) => {
+ // A result missing because its list failed to load may still exist.
+ seedCurrentDebate();
+ failHydration();
+ render();
+ fireEvent.click(within(screen.getByTestId('debate-context-control')).getByRole('button', { name: 'Review' }));
+ const sheet = await screen.findByRole('dialog');
+ await waitFor(() => expect(useDebateContextStore.getState().pinsLoaded[TEST_DEBATE_ID]).toBe(true));
+ const unlisted = [40, 41, 42].map((n) => ({ kind: 'case' as const, id: uuid(n) }));
+ act(() => useDebateContextStore.setState((state) => ({ pins: { ...state.pins, [TEST_DEBATE_ID]: unlisted } })));
+
+ const pin = within(sheet).getByRole('button', { name: /Pin Result 10/ });
+ expect(pin).toBeEnabled();
+ fireEvent.click(pin);
+ await new Promise((done) => setTimeout(done, 0));
+
+ expect(useDebateContextStore.getState().pins[TEST_DEBATE_ID]).toEqual(unlisted);
+ });
});
describe('Pinned results', () => {
@@ -215,10 +268,158 @@ describe('Pinned results', () => {
useDebateContextStore.setState({ pins: { [TEST_DEBATE_ID]: [caseRef] } });
const pinning = useDebateContextStore.getState().togglePin(TEST_DEBATE_ID, attackRef);
- await useDebateContextStore.getState().togglePin(TEST_DEBATE_ID, caseRef);
+ const unpinning = useDebateContextStore.getState().togglePin(TEST_DEBATE_ID, caseRef);
insert.resolve({ error: { message: 'offline' } });
await expect(pinning).rejects.toThrow('offline');
+ await unpinning;
expect(useDebateContextStore.getState().pins[TEST_DEBATE_ID]).toEqual([]);
});
+
+ it('keeps saved pins when the student pins before the first load returns', async () => {
+ const load = deferred<{ data: unknown; error: null }>();
+ supabaseMock.from.mockImplementationOnce((() => ({ select: () => ({ eq: () => load.promise }) })) as never);
+
+ const loading = useDebateContextStore.getState().loadPins(TEST_DEBATE_ID);
+ await useDebateContextStore.getState().togglePin(TEST_DEBATE_ID, attackRef);
+ load.resolve({ data: [{ item_kind: 'case', item_id: caseRef.id }], error: null });
+ await loading;
+
+ expect(useDebateContextStore.getState().pins[TEST_DEBATE_ID]).toEqual([caseRef, attackRef]);
+ });
+
+ it('keeps a pin that finished saving while an older load was in flight', async () => {
+ const insert = deferred<{ error: null }>();
+ const load = deferred<{ data: unknown; error: null }>();
+ supabaseMock.from
+ .mockImplementationOnce((() => ({ insert: () => insert.promise })) as never)
+ .mockImplementationOnce((() => ({ select: () => ({ eq: () => load.promise }) })) as never);
+
+ const pinning = useDebateContextStore.getState().togglePin(TEST_DEBATE_ID, caseRef);
+ await new Promise((done) => setTimeout(done, 0));
+ // The load reads the table before the insert commits, then answers after it.
+ const loading = useDebateContextStore.getState().loadPins(TEST_DEBATE_ID);
+ insert.resolve({ error: null });
+ await pinning;
+ load.resolve({ data: [], error: null });
+ await loading;
+
+ expect(useDebateContextStore.getState().pins[TEST_DEBATE_ID]).toEqual([caseRef]);
+ });
+
+ it('saves a quick pin and unpin of the same result in the order they were made', async () => {
+ const insert = deferred<{ error: null }>();
+ const calls: string[] = [];
+ const deleted = { eq: () => deleted, then: (done: (value: { error: null }) => void) => { calls.push('delete'); done({ error: null }); } };
+ supabaseMock.from
+ .mockImplementationOnce((() => ({ insert: () => { calls.push('insert'); return insert.promise; } })) as never)
+ .mockImplementationOnce((() => ({ delete: () => deleted })) as never);
+
+ const pinning = useDebateContextStore.getState().togglePin(TEST_DEBATE_ID, caseRef);
+ const unpinning = useDebateContextStore.getState().togglePin(TEST_DEBATE_ID, caseRef);
+ await new Promise((done) => setTimeout(done, 0));
+ expect(calls).toEqual(['insert']);
+ insert.resolve({ error: null });
+ await Promise.all([pinning, unpinning]);
+
+ expect(calls).toEqual(['insert', 'delete']);
+ expect(useDebateContextStore.getState().pins[TEST_DEBATE_ID]).toEqual([]);
+ });
+
+ it('does not count pins on deleted results toward the limit', async () => {
+ const live = [12, 14].map((n) => ({ kind: 'case' as const, id: uuid(n) }));
+ const existing = new Set([...live, caseRef, attackRef].map((ref) => `${ref.kind}:${ref.id}`));
+ useDebateContextStore.setState({ pins: { [TEST_DEBATE_ID]: [{ kind: 'case', id: uuid(40) }, ...live] } });
+
+ // Two quick clicks share one rendered plan; the second must see the first.
+ const first = useDebateContextStore.getState().togglePin(TEST_DEBATE_ID, attackRef, existing);
+ const second = useDebateContextStore.getState().togglePin(TEST_DEBATE_ID, caseRef, existing);
+ await expect(first).resolves.toBeUndefined();
+ await expect(second).rejects.toThrow('pin_limit');
+ });
+
+ it('keeps a later pin when an earlier save of the same result fails', async () => {
+ const firstInsert = deferred<{ error: { message: string } }>();
+ const calls: string[] = [];
+ const deleted = { eq: () => deleted, then: (done: (value: { error: null }) => void) => { calls.push('delete'); done({ error: null }); } };
+ supabaseMock.from
+ .mockImplementationOnce((() => ({ insert: () => { calls.push('insert'); return firstInsert.promise; } })) as never)
+ .mockImplementationOnce((() => ({ delete: () => deleted })) as never)
+ .mockImplementationOnce((() => ({ insert: () => { calls.push('insert'); return Promise.resolve({ error: null }); } })) as never);
+
+ // Pin, unpin and pin again while the first save is still pending.
+ const toggles = [0, 1, 2].map(() => useDebateContextStore.getState().togglePin(TEST_DEBATE_ID, caseRef));
+ firstInsert.resolve({ error: { message: 'offline' } });
+ await Promise.all(toggles);
+
+ expect(calls).toEqual(['insert', 'delete', 'insert']);
+ expect(useDebateContextStore.getState().pins[TEST_DEBATE_ID]).toEqual([caseRef]);
+ });
+
+ it('ends unpinned when a quick pin and unpin both fail to save', async () => {
+ const deleted = { eq: () => deleted, then: (done: (value: { error: { message: string } }) => void) => done({ error: { message: 'offline' } }) };
+ supabaseMock.from
+ .mockImplementationOnce((() => ({ insert: () => Promise.resolve({ error: { message: 'offline' } }) })) as never)
+ .mockImplementationOnce((() => ({ delete: () => deleted })) as never);
+
+ const pinning = useDebateContextStore.getState().togglePin(TEST_DEBATE_ID, caseRef);
+ const unpinning = useDebateContextStore.getState().togglePin(TEST_DEBATE_ID, caseRef);
+ await Promise.allSettled([pinning, unpinning]);
+
+ // Neither save reached the database, so the result was never pinned.
+ expect(useDebateContextStore.getState().pins[TEST_DEBATE_ID]).toEqual([]);
+ });
+
+ it('treats pinning a result that is already saved as pinned', async () => {
+ const deleted = { eq: () => deleted, then: (done: (value: { error: { message: string } }) => void) => done({ error: { message: 'offline' } }) };
+ supabaseMock.from
+ .mockImplementationOnce((() => ({ delete: () => deleted })) as never)
+ .mockImplementationOnce((() => ({ insert: () => Promise.resolve({ error: { code: '23505', message: 'duplicate key' } }) })) as never);
+ useDebateContextStore.setState({ pins: { [TEST_DEBATE_ID]: [caseRef] } });
+
+ // The unpin fails, so the row stays; pinning again must not unpin locally.
+ const unpinning = useDebateContextStore.getState().togglePin(TEST_DEBATE_ID, caseRef);
+ const pinning = useDebateContextStore.getState().togglePin(TEST_DEBATE_ID, caseRef);
+ await Promise.all([unpinning, pinning]);
+
+ expect(useDebateContextStore.getState().pins[TEST_DEBATE_ID]).toEqual([caseRef]);
+ });
+
+ it('drops pin writes left over from before a sign-out', async () => {
+ const oldInsert = deferred<{ error: { message: string } }>();
+ const calls: string[] = [];
+ supabaseMock.from
+ .mockImplementationOnce((() => ({ insert: () => { calls.push('old'); return oldInsert.promise; } })) as never)
+ .mockImplementationOnce((() => ({ insert: () => { calls.push('new'); return Promise.resolve({ error: null }); } })) as never);
+
+ const oldPin = useDebateContextStore.getState().togglePin(TEST_DEBATE_ID, caseRef);
+ const oldQueued = useDebateContextStore.getState().togglePin(TEST_DEBATE_ID, attackRef);
+ await new Promise((done) => setTimeout(done, 0));
+ useDebateContextStore.getState().resetLocalData();
+
+ // The next account's change must not wait behind the old one.
+ await useDebateContextStore.getState().togglePin(TEST_DEBATE_ID, caseRef);
+ expect(calls).toEqual(['old', 'new']);
+
+ // The old failure must not undo the new account's pin, and the queued old
+ // write must never run under the new account.
+ oldInsert.resolve({ error: { message: 'offline' } });
+ await expect(oldPin).resolves.toBeUndefined();
+ await expect(oldQueued).resolves.toBeUndefined();
+ expect(calls).toEqual(['old', 'new']);
+ expect(useDebateContextStore.getState().pins[TEST_DEBATE_ID]).toEqual([caseRef]);
+ });
+
+ it('ignores a pin load that returns after a sign-out', async () => {
+ const load = deferred<{ data: unknown; error: null }>();
+ supabaseMock.from.mockImplementationOnce((() => ({ select: () => ({ eq: () => load.promise }) })) as never);
+
+ const loading = useDebateContextStore.getState().loadPins(TEST_DEBATE_ID);
+ useDebateContextStore.getState().resetLocalData();
+ load.resolve({ data: [{ item_kind: 'case', item_id: caseRef.id }], error: null });
+ await loading;
+
+ expect(useDebateContextStore.getState().pins[TEST_DEBATE_ID]).toBeUndefined();
+ expect(useDebateContextStore.getState().pinsLoaded[TEST_DEBATE_ID]).toBeUndefined();
+ });
});