From 55ad91e129bb16da1fd20b73e1ce3dde4903788d Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 22 Oct 2026 10:52:31 +0000 Subject: [PATCH] Regenerate a loaded case with its own motion and debate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Opening a saved case from History left the Generate form on whatever motion was typed last. Regenerating a section then rewrote that case for the wrong motion, and could pull in the current debate's sources. The loaded case now keeps its motion, side, and debate for that request. Co-authored-by: Gáborjáni-Szabó Ádám --- src/lib/stores/session-store.ts | 25 +++++++++++++- src/pages/GeneratePage.tsx | 28 +++++++++------ src/test/pages/GeneratePage.test.tsx | 30 ++++++++++++++++ src/test/stores/load-saved-case.test.ts | 46 +++++++++++++++++++++++++ 4 files changed, 118 insertions(+), 11 deletions(-) create mode 100644 src/test/stores/load-saved-case.test.ts 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(); + }); +});