diff --git a/src/lib/stores/session-store.ts b/src/lib/stores/session-store.ts index dcdea8e78..34fb15637 100644 --- a/src/lib/stores/session-store.ts +++ b/src/lib/stores/session-store.ts @@ -170,6 +170,12 @@ export interface SessionState { setFactoidsForm: (data: Partial) => void; setGenerateResult: (result: GenerateOutput | null) => void; + /** + * Debate the current Generate result belongs to. Null is one-off work. + * Undefined means a result restored before this was recorded. + */ + generateResultDebateId: string | null | undefined; + setGenerateResultDebateId: (debateId: string | null) => void; setGenerateLoading: (loading: boolean) => void; setGenerateRegeneratingSection: (section: string | null) => void; saveCurrentCase: (title: string) => Promise; @@ -485,11 +491,13 @@ export const useSessionStore = create()( sourceLibrary: [] as Source[], generateForm: {}, + generateResultDebateId: undefined as string | null | undefined, attackForm: {}, brainstormForm: {}, factoidsForm: {}, setGenerateForm: (data) => set((state) => ({ generateForm: mergeFormState(state.generateForm, data) })), + setGenerateResultDebateId: (debateId) => set({ generateResultDebateId: debateId }), setAttackForm: (data) => set((state) => ({ attackForm: mergeFormState(state.attackForm, data) })), setBrainstormForm: (data) => set((state) => ({ brainstormForm: mergeFormState(state.brainstormForm, data) })), setFactoidsForm: (data) => set((state) => ({ factoidsForm: mergeFormState(state.factoidsForm, data) })), @@ -845,7 +853,20 @@ export const useSessionStore = create()( }, loadSavedCase: (id) => { const item = get().savedCases.find((c) => c.id === id); - if (item) set({ generateResult: item.data }); + if (!item) return; + const motion = item.data.metadata?.motion?.trim(); + const side = item.data.metadata?.side; + set({ + generateResult: item.data, + // Null means this case is one-off, so a later regenerate does not + // borrow whichever debate happens to be open. + generateResultDebateId: item.debateId ?? null, + generateForm: { + ...get().generateForm, + ...(motion ? { motion } : {}), + ...(side ? { side } : {}), + }, + }); }, setAttackResult: (result) => { @@ -1261,6 +1282,7 @@ export const useSessionStore = create()( // browser must not see them prefilled. pendingFlowchartData: null, generateForm: {}, + generateResultDebateId: undefined, attackForm: {}, brainstormForm: {}, factoidsForm: {}, @@ -1278,6 +1300,7 @@ export const useSessionStore = create()( askPersistedCount: 0, pendingFlowchartData: null, generateForm: {}, + generateResultDebateId: undefined, attackForm: {}, brainstormForm: {}, factoidsForm: {}, diff --git a/src/pages/GeneratePage.tsx b/src/pages/GeneratePage.tsx index 477baa19a..46cb3d1be 100644 --- a/src/pages/GeneratePage.tsx +++ b/src/pages/GeneratePage.tsx @@ -36,14 +36,16 @@ import { useDebateMotionGate } from '@/components/features/debate/DebateMotionGa export default function GeneratePage() { const workspace = useWorkspaceToolScope('generate'); - const { generateResult, generateLoading, generateRegeneratingSection: regeneratingSection, generateForm, setGenerateResult, setGenerateLoading, setGenerateRegeneratingSection, saveCurrentCase, setPendingFlowchartData, autosavedCases, recordResult, updateRecordedResult } = useSessionStore(useShallow((state) => ({ + const { generateResult, generateLoading, generateRegeneratingSection: regeneratingSection, generateForm, generateResultDebateId, setGenerateResult, setGenerateLoading, setGenerateRegeneratingSection, setGenerateResultDebateId, saveCurrentCase, setPendingFlowchartData, autosavedCases, recordResult, updateRecordedResult } = useSessionStore(useShallow((state) => ({ generateResult: state.generateResult, generateLoading: state.generateLoading, generateRegeneratingSection: state.generateRegeneratingSection, generateForm: state.generateForm, + generateResultDebateId: state.generateResultDebateId, setGenerateResult: state.setGenerateResult, setGenerateLoading: state.setGenerateLoading, setGenerateRegeneratingSection: state.setGenerateRegeneratingSection, + setGenerateResultDebateId: state.setGenerateResultDebateId, saveCurrentCase: state.saveCurrentCase, setPendingFlowchartData: state.setPendingFlowchartData, autosavedCases: state.autosavedCases, @@ -107,6 +109,7 @@ export default function GeneratePage() { setGenerateResult(result); setRecordedCaseId(null); setResultDebateId(target.debateId); + setGenerateResultDebateId(target.debateId); await workspace.attach(data, result); if (target.debateId) { setRecordedCaseId(await recordInDebate(() => recordResult('case', { title: data.motion, input: data, output: result, debateId: target.debateId, side: data.side }))); @@ -135,26 +138,31 @@ export default function GeneratePage() { const handleRegenerateSection = (section: string) => { if (generateLoading || regeneratingSection) return; - if (!generateForm?.motion || !generateResult) { + const motion = generateResult?.metadata?.motion?.trim() || generateForm?.motion; + if (!motion || !generateResult) { toast({ title: t('generate.regenerateUnavailable', 'Cannot regenerate yet'), description: t('generate.regenerateUnavailableDesc', 'Generate a case first, then retry a section.'), variant: 'destructive' }); return; } const data: GenerateInput = { - motion: generateForm.motion, - side: generateForm.side || 'pro', - format: generateForm.format || 'custom', - role: generateForm.role || 'r1', - length: generateForm.length || 'standard', - audience: generateForm.audience || 'intermediate', - tone: generateForm.tone || 'formal', + motion, + side: generateResult.metadata?.side || generateForm?.side || 'pro', + format: generateForm?.format || 'custom', + role: generateForm?.role || 'r1', + length: generateForm?.length || 'standard', + audience: generateForm?.audience || 'intermediate', + tone: generateForm?.tone || 'formal', }; // Regenerate against the result's own debate, never whichever is current now. + // A loaded case records that debate on the store; this page's state only + // knows it until the student leaves and comes back. const current = selectCurrentDebate(useDebateStore.getState()); const debateId = resultDebateId !== undefined ? resultDebateId - : sameMotion(current?.motion, data.motion) ? activeDebateId() : null; + : generateResultDebateId !== undefined + ? generateResultDebateId + : sameMotion(current?.motion, data.motion) ? activeDebateId() : null; // One section at a time: each regeneration merges into the result it started from. const base = generateResult; setGenerateRegeneratingSection(section); diff --git a/src/test/pages/GeneratePage.test.tsx b/src/test/pages/GeneratePage.test.tsx index d2cd306ec..bd67b2d29 100644 --- a/src/test/pages/GeneratePage.test.tsx +++ b/src/test/pages/GeneratePage.test.tsx @@ -45,6 +45,10 @@ const mockAddAskMessage = vi.fn(); const mockSetAskLoading = vi.fn(); let mockGenerateResult: any = null; let mockGenerateForm: any = { motion: 'Existing motion', side: 'pro', format: 'bp', role: 'intro', length: 'brief', audience: 'beginner', tone: 'formal' }; +let mockGenerateResultDebateId: string | null | undefined; +const mockSetGenerateResultDebateId = vi.fn((debateId: string | null) => { + mockGenerateResultDebateId = debateId; +}); let mockRegeneratingSection: string | null = null; const mockStoreListeners = new Set<() => void>(); @@ -60,10 +64,12 @@ vi.mock('@/lib/stores/session-store', async () => { generateLoading: false, generateRegeneratingSection: mockRegeneratingSection, generateForm: mockGenerateForm, + generateResultDebateId: mockGenerateResultDebateId, autosavedCases: [] as any[], setGenerateResult: mockSetGenerateResult, setGenerateLoading: mockSetGenerateLoading, setGenerateRegeneratingSection: mockSetRegeneratingSection, + setGenerateResultDebateId: mockSetGenerateResultDebateId, saveCurrentCase: mockSaveCurrentCase, addAskMessage: mockAddAskMessage, setAskLoading: mockSetAskLoading, @@ -127,6 +133,7 @@ describe('GeneratePage Integration', () => { vi.clearAllMocks(); mockGenerateResult = null; mockRegeneratingSection = null; + mockGenerateResultDebateId = undefined; mockGenerateForm = { motion: 'Existing motion', side: 'pro', format: 'bp', role: 'intro', length: 'brief', audience: 'beginner', tone: 'formal' }; }); @@ -331,6 +338,29 @@ describe('GeneratePage Integration', () => { expect((generateCase as Mock).mock.calls[1][3]).toBeNull(); }); + it('regenerates a loaded case with that case\'s motion and debate', async () => { + seedCurrentDebate({ motion: 'Existing motion' }); + mockGenerateResultDebateId = 'loaded-debate'; + mockGenerateResult = { + caseStructure: { thesis: 'Loaded thesis' }, + contentions: [] as any[], + predictedOpponentCase: {}, + counters: [] as any[], + metadata: { side: 'con', format: 'custom', motion: 'Loaded motion', generatedAt: '2026-01-01T00:00:00Z' }, + }; + (generateCase as Mock).mockResolvedValue({ result: mockGenerateResult, discoveredSources: [] }); + + render(); + fireEvent.click(screen.getByTestId('regenerate-section')); + + await waitFor(() => expect(generateCase).toHaveBeenCalledTimes(1)); + expect((generateCase as Mock).mock.calls[0][0]).toEqual(expect.objectContaining({ + motion: 'Loaded motion', + side: 'con', + })); + expect((generateCase as Mock).mock.calls[0][3]).toBe('loaded-debate'); + }); + it('after a reload, regenerates in the Current Debate only when the motion matches', async () => { const result = { caseStructure: { thesis: 'Restored' }, contentions: [] as any[], predictedOpponentCase: {}, counters: [] as any[], metadata: {} }; (generateCase as Mock).mockResolvedValue({ result, discoveredSources: [] }); diff --git a/src/test/stores/load-saved-case.test.ts b/src/test/stores/load-saved-case.test.ts new file mode 100644 index 000000000..2d42a3fae --- /dev/null +++ b/src/test/stores/load-saved-case.test.ts @@ -0,0 +1,46 @@ +import { beforeEach, describe, expect, it } from 'vitest'; +import type { GenerateOutput } from '@/lib/ai/schemas/generate'; +import { useSessionStore } from '@/lib/stores/session-store'; + +const loaded = { + caseStructure: { thesis: 'Loaded thesis', framework: '', keyDefinitions: [] as string[], burdenOfProof: '' }, + contentions: [] as GenerateOutput['contentions'], + predictedOpponentCase: { + likelyArguments: [] as string[], + strategicWeaknesses: [] as string[], + keyClashes: [] as string[], + }, + counters: [] as GenerateOutput['counters'], + metadata: { side: 'con' as const, format: 'custom', motion: 'Loaded motion', generatedAt: '2026-01-01T00:00:00Z' }, +} satisfies GenerateOutput; + +describe('loading a saved case', () => { + beforeEach(() => { + useSessionStore.getState().resetLocalData(); + }); + + it('restores the case motion and the debate it belongs to', () => { + useSessionStore.setState({ + generateForm: { motion: 'Whatever I was typing', side: 'pro' }, + savedCases: [{ id: 'case-1', title: 'Loaded motion', createdAt: 1, debateId: 'debate-b', data: loaded }], + }); + + useSessionStore.getState().loadSavedCase('case-1'); + + const state = useSessionStore.getState(); + expect(state.generateResult).toBe(loaded); + expect(state.generateResultDebateId).toBe('debate-b'); + expect(state.generateForm.motion).toBe('Loaded motion'); + expect(state.generateForm.side).toBe('con'); + }); + + it('marks a case with no debate as one-off', () => { + useSessionStore.setState({ + savedCases: [{ id: 'case-1', title: 'One-off', createdAt: 1, data: loaded }], + }); + + useSessionStore.getState().loadSavedCase('case-1'); + + expect(useSessionStore.getState().generateResultDebateId).toBeNull(); + }); +});