Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 24 additions & 1 deletion src/lib/stores/session-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -170,6 +170,12 @@ export interface SessionState {
setFactoidsForm: (data: Partial<FactoidsInput>) => 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<void>;
Expand Down Expand Up @@ -485,11 +491,13 @@ export const useSessionStore = create<SessionState>()(
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) })),
Expand Down Expand Up @@ -845,7 +853,20 @@ export const useSessionStore = create<SessionState>()(
},
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) => {
Expand Down Expand Up @@ -1261,6 +1282,7 @@ export const useSessionStore = create<SessionState>()(
// browser must not see them prefilled.
pendingFlowchartData: null,
generateForm: {},
generateResultDebateId: undefined,
attackForm: {},
brainstormForm: {},
factoidsForm: {},
Expand All @@ -1278,6 +1300,7 @@ export const useSessionStore = create<SessionState>()(
askPersistedCount: 0,
pendingFlowchartData: null,
generateForm: {},
generateResultDebateId: undefined,
attackForm: {},
brainstormForm: {},
factoidsForm: {},
Expand Down
28 changes: 18 additions & 10 deletions src/pages/GeneratePage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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 })));
Expand Down Expand Up @@ -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);
Expand Down
30 changes: 30 additions & 0 deletions src/test/pages/GeneratePage.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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>();
Expand All @@ -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,
Expand Down Expand Up @@ -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' };
});

Expand Down Expand Up @@ -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(<BrowserRouter><GeneratePage /></BrowserRouter>);
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: [] });
Expand Down
46 changes: 46 additions & 0 deletions src/test/stores/load-saved-case.test.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
Loading