Skip to content

Commit 55ad91e

Browse files
Regenerate a loaded case with its own motion and debate
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 <Programcode@users.noreply.github.com>
1 parent 4f9e0a0 commit 55ad91e

4 files changed

Lines changed: 118 additions & 11 deletions

File tree

‎src/lib/stores/session-store.ts‎

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -170,6 +170,12 @@ export interface SessionState {
170170
setFactoidsForm: (data: Partial<FactoidsInput>) => void;
171171

172172
setGenerateResult: (result: GenerateOutput | null) => void;
173+
/**
174+
* Debate the current Generate result belongs to. Null is one-off work.
175+
* Undefined means a result restored before this was recorded.
176+
*/
177+
generateResultDebateId: string | null | undefined;
178+
setGenerateResultDebateId: (debateId: string | null) => void;
173179
setGenerateLoading: (loading: boolean) => void;
174180
setGenerateRegeneratingSection: (section: string | null) => void;
175181
saveCurrentCase: (title: string) => Promise<void>;
@@ -485,11 +491,13 @@ export const useSessionStore = create<SessionState>()(
485491
sourceLibrary: [] as Source[],
486492

487493
generateForm: {},
494+
generateResultDebateId: undefined as string | null | undefined,
488495
attackForm: {},
489496
brainstormForm: {},
490497
factoidsForm: {},
491498

492499
setGenerateForm: (data) => set((state) => ({ generateForm: mergeFormState(state.generateForm, data) })),
500+
setGenerateResultDebateId: (debateId) => set({ generateResultDebateId: debateId }),
493501
setAttackForm: (data) => set((state) => ({ attackForm: mergeFormState(state.attackForm, data) })),
494502
setBrainstormForm: (data) => set((state) => ({ brainstormForm: mergeFormState(state.brainstormForm, data) })),
495503
setFactoidsForm: (data) => set((state) => ({ factoidsForm: mergeFormState(state.factoidsForm, data) })),
@@ -845,7 +853,20 @@ export const useSessionStore = create<SessionState>()(
845853
},
846854
loadSavedCase: (id) => {
847855
const item = get().savedCases.find((c) => c.id === id);
848-
if (item) set({ generateResult: item.data });
856+
if (!item) return;
857+
const motion = item.data.metadata?.motion?.trim();
858+
const side = item.data.metadata?.side;
859+
set({
860+
generateResult: item.data,
861+
// Null means this case is one-off, so a later regenerate does not
862+
// borrow whichever debate happens to be open.
863+
generateResultDebateId: item.debateId ?? null,
864+
generateForm: {
865+
...get().generateForm,
866+
...(motion ? { motion } : {}),
867+
...(side ? { side } : {}),
868+
},
869+
});
849870
},
850871

851872
setAttackResult: (result) => {
@@ -1261,6 +1282,7 @@ export const useSessionStore = create<SessionState>()(
12611282
// browser must not see them prefilled.
12621283
pendingFlowchartData: null,
12631284
generateForm: {},
1285+
generateResultDebateId: undefined,
12641286
attackForm: {},
12651287
brainstormForm: {},
12661288
factoidsForm: {},
@@ -1278,6 +1300,7 @@ export const useSessionStore = create<SessionState>()(
12781300
askPersistedCount: 0,
12791301
pendingFlowchartData: null,
12801302
generateForm: {},
1303+
generateResultDebateId: undefined,
12811304
attackForm: {},
12821305
brainstormForm: {},
12831306
factoidsForm: {},

‎src/pages/GeneratePage.tsx‎

Lines changed: 18 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -36,14 +36,16 @@ import { useDebateMotionGate } from '@/components/features/debate/DebateMotionGa
3636

3737
export default function GeneratePage() {
3838
const workspace = useWorkspaceToolScope('generate');
39-
const { generateResult, generateLoading, generateRegeneratingSection: regeneratingSection, generateForm, setGenerateResult, setGenerateLoading, setGenerateRegeneratingSection, saveCurrentCase, setPendingFlowchartData, autosavedCases, recordResult, updateRecordedResult } = useSessionStore(useShallow((state) => ({
39+
const { generateResult, generateLoading, generateRegeneratingSection: regeneratingSection, generateForm, generateResultDebateId, setGenerateResult, setGenerateLoading, setGenerateRegeneratingSection, setGenerateResultDebateId, saveCurrentCase, setPendingFlowchartData, autosavedCases, recordResult, updateRecordedResult } = useSessionStore(useShallow((state) => ({
4040
generateResult: state.generateResult,
4141
generateLoading: state.generateLoading,
4242
generateRegeneratingSection: state.generateRegeneratingSection,
4343
generateForm: state.generateForm,
44+
generateResultDebateId: state.generateResultDebateId,
4445
setGenerateResult: state.setGenerateResult,
4546
setGenerateLoading: state.setGenerateLoading,
4647
setGenerateRegeneratingSection: state.setGenerateRegeneratingSection,
48+
setGenerateResultDebateId: state.setGenerateResultDebateId,
4749
saveCurrentCase: state.saveCurrentCase,
4850
setPendingFlowchartData: state.setPendingFlowchartData,
4951
autosavedCases: state.autosavedCases,
@@ -107,6 +109,7 @@ export default function GeneratePage() {
107109
setGenerateResult(result);
108110
setRecordedCaseId(null);
109111
setResultDebateId(target.debateId);
112+
setGenerateResultDebateId(target.debateId);
110113
await workspace.attach(data, result);
111114
if (target.debateId) {
112115
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() {
135138

136139
const handleRegenerateSection = (section: string) => {
137140
if (generateLoading || regeneratingSection) return;
138-
if (!generateForm?.motion || !generateResult) {
141+
const motion = generateResult?.metadata?.motion?.trim() || generateForm?.motion;
142+
if (!motion || !generateResult) {
139143
toast({ title: t('generate.regenerateUnavailable', 'Cannot regenerate yet'), description: t('generate.regenerateUnavailableDesc', 'Generate a case first, then retry a section.'), variant: 'destructive' });
140144
return;
141145
}
142146

143147
const data: GenerateInput = {
144-
motion: generateForm.motion,
145-
side: generateForm.side || 'pro',
146-
format: generateForm.format || 'custom',
147-
role: generateForm.role || 'r1',
148-
length: generateForm.length || 'standard',
149-
audience: generateForm.audience || 'intermediate',
150-
tone: generateForm.tone || 'formal',
148+
motion,
149+
side: generateResult.metadata?.side || generateForm?.side || 'pro',
150+
format: generateForm?.format || 'custom',
151+
role: generateForm?.role || 'r1',
152+
length: generateForm?.length || 'standard',
153+
audience: generateForm?.audience || 'intermediate',
154+
tone: generateForm?.tone || 'formal',
151155
};
152156

153157
// Regenerate against the result's own debate, never whichever is current now.
158+
// A loaded case records that debate on the store; this page's state only
159+
// knows it until the student leaves and comes back.
154160
const current = selectCurrentDebate(useDebateStore.getState());
155161
const debateId = resultDebateId !== undefined
156162
? resultDebateId
157-
: sameMotion(current?.motion, data.motion) ? activeDebateId() : null;
163+
: generateResultDebateId !== undefined
164+
? generateResultDebateId
165+
: sameMotion(current?.motion, data.motion) ? activeDebateId() : null;
158166
// One section at a time: each regeneration merges into the result it started from.
159167
const base = generateResult;
160168
setGenerateRegeneratingSection(section);

‎src/test/pages/GeneratePage.test.tsx‎

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,10 @@ const mockAddAskMessage = vi.fn();
4545
const mockSetAskLoading = vi.fn();
4646
let mockGenerateResult: any = null;
4747
let mockGenerateForm: any = { motion: 'Existing motion', side: 'pro', format: 'bp', role: 'intro', length: 'brief', audience: 'beginner', tone: 'formal' };
48+
let mockGenerateResultDebateId: string | null | undefined;
49+
const mockSetGenerateResultDebateId = vi.fn((debateId: string | null) => {
50+
mockGenerateResultDebateId = debateId;
51+
});
4852

4953
let mockRegeneratingSection: string | null = null;
5054
const mockStoreListeners = new Set<() => void>();
@@ -60,10 +64,12 @@ vi.mock('@/lib/stores/session-store', async () => {
6064
generateLoading: false,
6165
generateRegeneratingSection: mockRegeneratingSection,
6266
generateForm: mockGenerateForm,
67+
generateResultDebateId: mockGenerateResultDebateId,
6368
autosavedCases: [] as any[],
6469
setGenerateResult: mockSetGenerateResult,
6570
setGenerateLoading: mockSetGenerateLoading,
6671
setGenerateRegeneratingSection: mockSetRegeneratingSection,
72+
setGenerateResultDebateId: mockSetGenerateResultDebateId,
6773
saveCurrentCase: mockSaveCurrentCase,
6874
addAskMessage: mockAddAskMessage,
6975
setAskLoading: mockSetAskLoading,
@@ -127,6 +133,7 @@ describe('GeneratePage Integration', () => {
127133
vi.clearAllMocks();
128134
mockGenerateResult = null;
129135
mockRegeneratingSection = null;
136+
mockGenerateResultDebateId = undefined;
130137
mockGenerateForm = { motion: 'Existing motion', side: 'pro', format: 'bp', role: 'intro', length: 'brief', audience: 'beginner', tone: 'formal' };
131138
});
132139

@@ -331,6 +338,29 @@ describe('GeneratePage Integration', () => {
331338
expect((generateCase as Mock).mock.calls[1][3]).toBeNull();
332339
});
333340

341+
it('regenerates a loaded case with that case\'s motion and debate', async () => {
342+
seedCurrentDebate({ motion: 'Existing motion' });
343+
mockGenerateResultDebateId = 'loaded-debate';
344+
mockGenerateResult = {
345+
caseStructure: { thesis: 'Loaded thesis' },
346+
contentions: [] as any[],
347+
predictedOpponentCase: {},
348+
counters: [] as any[],
349+
metadata: { side: 'con', format: 'custom', motion: 'Loaded motion', generatedAt: '2026-01-01T00:00:00Z' },
350+
};
351+
(generateCase as Mock).mockResolvedValue({ result: mockGenerateResult, discoveredSources: [] });
352+
353+
render(<BrowserRouter><GeneratePage /></BrowserRouter>);
354+
fireEvent.click(screen.getByTestId('regenerate-section'));
355+
356+
await waitFor(() => expect(generateCase).toHaveBeenCalledTimes(1));
357+
expect((generateCase as Mock).mock.calls[0][0]).toEqual(expect.objectContaining({
358+
motion: 'Loaded motion',
359+
side: 'con',
360+
}));
361+
expect((generateCase as Mock).mock.calls[0][3]).toBe('loaded-debate');
362+
});
363+
334364
it('after a reload, regenerates in the Current Debate only when the motion matches', async () => {
335365
const result = { caseStructure: { thesis: 'Restored' }, contentions: [] as any[], predictedOpponentCase: {}, counters: [] as any[], metadata: {} };
336366
(generateCase as Mock).mockResolvedValue({ result, discoveredSources: [] });
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
import { beforeEach, describe, expect, it } from 'vitest';
2+
import type { GenerateOutput } from '@/lib/ai/schemas/generate';
3+
import { useSessionStore } from '@/lib/stores/session-store';
4+
5+
const loaded = {
6+
caseStructure: { thesis: 'Loaded thesis', framework: '', keyDefinitions: [] as string[], burdenOfProof: '' },
7+
contentions: [] as GenerateOutput['contentions'],
8+
predictedOpponentCase: {
9+
likelyArguments: [] as string[],
10+
strategicWeaknesses: [] as string[],
11+
keyClashes: [] as string[],
12+
},
13+
counters: [] as GenerateOutput['counters'],
14+
metadata: { side: 'con' as const, format: 'custom', motion: 'Loaded motion', generatedAt: '2026-01-01T00:00:00Z' },
15+
} satisfies GenerateOutput;
16+
17+
describe('loading a saved case', () => {
18+
beforeEach(() => {
19+
useSessionStore.getState().resetLocalData();
20+
});
21+
22+
it('restores the case motion and the debate it belongs to', () => {
23+
useSessionStore.setState({
24+
generateForm: { motion: 'Whatever I was typing', side: 'pro' },
25+
savedCases: [{ id: 'case-1', title: 'Loaded motion', createdAt: 1, debateId: 'debate-b', data: loaded }],
26+
});
27+
28+
useSessionStore.getState().loadSavedCase('case-1');
29+
30+
const state = useSessionStore.getState();
31+
expect(state.generateResult).toBe(loaded);
32+
expect(state.generateResultDebateId).toBe('debate-b');
33+
expect(state.generateForm.motion).toBe('Loaded motion');
34+
expect(state.generateForm.side).toBe('con');
35+
});
36+
37+
it('marks a case with no debate as one-off', () => {
38+
useSessionStore.setState({
39+
savedCases: [{ id: 'case-1', title: 'One-off', createdAt: 1, data: loaded }],
40+
});
41+
42+
useSessionStore.getState().loadSavedCase('case-1');
43+
44+
expect(useSessionStore.getState().generateResultDebateId).toBeNull();
45+
});
46+
});

0 commit comments

Comments
 (0)