Skip to content
30 changes: 26 additions & 4 deletions src/components/features/debate/DebateContextControl.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -99,14 +107,26 @@ export function DebateContextControl({ feature, side, includeSources = true, deb
</Button>
<Sheet open={open} onOpenChange={setOpen}>
<SheetContent className="flex w-full flex-col gap-0 overflow-y-auto p-0 sm:max-w-md">
<DebateContextReview plan={plan} motion={debate?.id === debateId ? debate.motion : undefined} usage={usage} />
<DebateContextReview
plan={plan}
motion={debate?.id === debateId ? debate.motion : undefined}
usage={usage}
pinsReady={Boolean(pinsLoaded) && serverHydration !== 'pending' && sparringHydrated}
resultsComplete={serverHydration === 'complete' && sparringComplete}
/>
</SheetContent>
</Sheet>
</div>
);
}

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);
Expand All @@ -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'
Expand Down Expand Up @@ -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}
/>
))}
</ul>
Expand Down Expand Up @@ -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;
Expand All @@ -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 ? <PinOff className="h-4 w-4" /> : <Pin className="h-4 w-4" />}
</Button>
Expand Down
7 changes: 7 additions & 0 deletions src/lib/debate/context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string>;
request: DebateContextRequest;
}

Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -194,6 +200,7 @@ export function planDebateContext(
policy,
sources,
outputs,
resultKeys,
request: {
debateId,
side,
Expand Down
84 changes: 65 additions & 19 deletions src/lib/stores/debate-context-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,8 @@ interface DebateContextState {
lastUsage: Partial<Record<DebateContextFeature, DebateContextUsage>>;

loadPins: (debateId: string) => Promise<void>;
togglePin: (debateId: string, ref: DebateOutputRef) => Promise<void>;
/** Only pins on `resultKeys`, the debate's existing results, count toward the limit. */
togglePin: (debateId: string, ref: DebateOutputRef, resultKeys?: ReadonlySet<string>) => Promise<void>;
setSourceChoice: (feature: DebateContextFeature, debateId: string, sourceId: string, include: boolean) => void;
setOutputChoice: (feature: DebateContextFeature, debateId: string, ref: DebateOutputRef, include: boolean) => void;
resetOverride: (feature: DebateContextFeature) => void;
Expand All @@ -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<string, number> = {};
// 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<string, PinChange[]> = {};
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<string, Promise<unknown>> = {};
// 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));
Expand All @@ -68,44 +80,73 @@ export const useDebateContextStore = create<DebateContextState>()((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<ReturnType<typeof save>> = 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');
}
},
Expand Down Expand Up @@ -134,7 +175,12 @@ export const useDebateContextStore = create<DebateContextState>()((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. */
Expand Down
9 changes: 9 additions & 0 deletions src/lib/stores/session-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -217,6 +217,11 @@ export interface SessionState {
clearLibrary: () => Promise<void>;

hydrateFromServer: () => Promise<void>;
/**
* 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<void>;
resetLocalData: () => void;
clearSession: () => void;
Expand Down Expand Up @@ -484,6 +489,7 @@ export const useSessionStore = create<SessionState>()(
setFactoidsForm: (data) => set((state) => ({ factoidsForm: mergeFormState(state.factoidsForm, data) })),

pendingFlowchartData: null as FlowchartData | null,
serverHydration: 'pending',
setPendingFlowchartData: (data) => set({ pendingFlowchartData: data }),

addSource: async (source) => {
Expand Down Expand Up @@ -1173,6 +1179,7 @@ export const useSessionStore = create<SessionState>()(
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',
}));
},

Expand Down Expand Up @@ -1207,6 +1214,7 @@ export const useSessionStore = create<SessionState>()(
});
},
resetLocalData: () => set({
serverHydration: 'pending',
savedCases: [],
savedAttacks: [],
savedBrainstorms: [],
Expand Down Expand Up @@ -1273,6 +1281,7 @@ export const useSessionStore = create<SessionState>()(
askLoading,
askStreamingAssistant,
pendingFlowchartData,
serverHydration,
...persisted
}) => persisted,
}
Expand Down
9 changes: 6 additions & 3 deletions src/lib/stores/sparring-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -300,6 +302,7 @@ export const useSparringStore = create<SparringStoreState>()(
privateSessionBackup: null as SparringSession | null,
savedSessions: [] as SparringSession[],
hydrated: false,
historyComplete: false,

beginWorkspaceSession: () => set((state) => ({
currentSession: null,
Expand Down Expand Up @@ -462,7 +465,7 @@ export const useSparringStore = create<SparringStoreState>()(
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 () => {
Expand All @@ -484,7 +487,7 @@ export const useSparringStore = create<SparringStoreState>()(
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) => {
Expand All @@ -511,7 +514,7 @@ export const useSparringStore = create<SparringStoreState>()(
},
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 });
},
}),
{
Expand Down
Loading
Loading