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
2 changes: 1 addition & 1 deletion .claude/skills/verify-debatelab/features/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ Each feature file starts with an H1 title and one paragraph describing the user-
## Features

- [Demo-code sign-in](./auth-demo-login.md): sign-in, reload, rejected code, sign-out (`e2e/full-stack/auth.spec.ts`), and email one-time-code sign-in through Mailpit (`recipes/auth.spec.ts`).
- [Case generation](./case-generation.md): generate, web research, save and reopen from History on a fresh device, PDF and DOCX export, provider failure and retry, quota block (`recipes/case-generation.spec.ts`).
- [Case generation](./case-generation.md): generate, web research, save and reopen from History on a fresh device, PDF and DOCX export, single-section regeneration, provider failure and retry, quota block (`recipes/case-generation.spec.ts`).
- [Library sources](./library-sources.md): add, pause, delete, grounding, and URL import (`recipes/library.spec.ts`).
- [AI tools](./ai-tools.md): Attack, Brainstorm, Factoids, and Ask streamed, cited, and with web search (`recipes/ai-tools.spec.ts`).
- [Current Debate](./current-debate.md): start, the first-debate offer and the different-motion choice, sources and results scoped to a debate, switching, and the first onboarding step (`recipes/current-debate.spec.ts`); debate context review and pins (`e2e/full-stack/debate-context.spec.ts`).
Expand Down
4 changes: 4 additions & 0 deletions .claude/skills/verify-debatelab/features/case-generation.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ A signed-in user enters a motion on Generate and receives a structured debate ca
- `gen-error` shows `Generation Failed` on a provider error, and the button becomes enabled again for a retry.
- `gen-quota` shows the dialog `Daily Access Paused` when the account has no quota, without calling the provider.
- `gen-save` saves a one-off case (the motion dialog answered with `Not now, use it once`) under a title into `cases` with `debate_id: null`. It appears in History under `One-off and unsorted work`, including from a fresh browser context.
- `gen-regenerate` regenerates one section. Only that section shows the overlay `Regenerating <section>…`; the rest of the case and the result toolbar stay on screen, and every regenerate button and the form's submit button are disabled until it finishes.
- `gen-export` downloads a `.pdf` (starting with `%PDF`) and a `.docx` (starting with `PK`).

## How to get to it (user POV)
Expand All @@ -29,10 +30,13 @@ Preconditions:
- **Save (one-off).** Generate with no debate and choose `chooseForMotion(page, "once")`. Click the button named `Save` (use `exact`), fill the dialog field labelled `Case title`, and click the dialog's `Save` button. `cases` for the user is `[{title, debate_id: null}]`. On a fresh browser context, `/app/history` shows `One-off and unsorted work` with a list item holding the title.
- **Error.** Call `await fake.fail("generate", 500)`, start the debate, and generate. The text `Generation Failed` (use `exact`) appears. Clicking again then succeeds.
- **Quota.** Update `user_settings` to set `daily_limit_override: 0` through `admin`, start the debate, then generate. The dialog shows `Daily Access Paused`. `fake.requests()` has no `generate` entry.
- **Regenerate a section.** After generating, click the button named `Regenerate Arguments` (the section titles are `Case Structure`, `Arguments`, `Predicted Opponent Case`, `Prepared Counters`). Hold the request with `page.route("**/functions/v1/ai", ...)` to check the in-flight state: a `status` with `Regenerating Arguments` is visible and `Regenerate Case Structure` is disabled. The toast `Section regenerated` follows.
- **Export.** Wait for `page.waitForEvent("download")` while clicking the button named `PDF` or `DOCX` (use `exact`). Check the suggested filename's extension and the file's magic bytes.

## Gotchas

- While any generation is running, the form's submit button reads `Generating Case...`, not `Generate Debate Case`. Find it by `[data-tour="generate-button"]` then.

- The Generate page shows the heading `Ready to Generate` before the first run. Wait for it after navigating, because lazy route chunks load slowly on a cold Vite.
- `Save` appears both on the result toolbar and in the dialog. Scope the second click to `getByRole("dialog")`. The toolbar `Save` exists only for one-off results.
- A case filed in a debate is titled with the motion, which the header control also shows (truncated and hidden). Look for it inside the History panel, not with a page-wide `getByText`.
Expand Down
36 changes: 36 additions & 0 deletions .claude/skills/verify-debatelab/recipes/case-generation.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -152,6 +152,42 @@ test("gen-export: a generated case exports to PDF and DOCX", async ({ signedIn:
}
});

test("gen-regenerate: regenerating one section keeps the rest of the case and its toolbar on screen", async ({ signedIn: page, evidence }) => {
const { output } = await generate(page);
const thesis = output.caseStructure.thesis;
await evidence.shot("01-generated");

// Hold the regenerate request so the in-flight screen can be checked.
let release!: () => void;
const held = new Promise<void>((resolve) => { release = resolve; });
await page.route("**/functions/v1/ai", async (route) => {
await held;
await route.continue();
});
const response = aiResponse(page, "generate");
await page.getByRole("button", { name: "Regenerate Arguments" }).click();
await evidence.step("Clicked Regenerate Arguments; request held");

const overlay = page.getByRole("status").filter({ hasText: "Regenerating Arguments" });
await expect(overlay).toBeVisible();
await expect(page.getByText(thesis).first()).toBeVisible();
await expect(page.getByRole("button", { name: "PDF", exact: true })).toBeVisible();
await expect(page.getByRole("button", { name: "Regenerate Case Structure" })).toBeDisabled();
// The form button reads "Generating..." while busy, so find it by its tour anchor.
await expect(page.locator('[data-tour="generate-button"]')).toBeDisabled();
await evidence.shot("02-regenerating-one-section");
await evidence.aria("02-regenerating-one-section");

release();
await response;
await page.unroute("**/functions/v1/ai");
await expect(page.getByText("Section regenerated").first()).toBeVisible({ timeout: AI_TIMEOUT });
await expect(overlay).toHaveCount(0);
await expect(page.getByText(thesis).first()).toBeVisible();
await expect(page.getByRole("button", { name: "Regenerate Case Structure" })).toBeEnabled();
await evidence.shot("03-regenerated");
});

test("gen-error: a provider failure shows an error and the retry succeeds", async ({ signedIn: page, evidence }) => {
await fake.fail("generate", 500);
await openGenerate(page);
Expand Down
14 changes: 2 additions & 12 deletions src/components/OfflineIndicator.tsx
Original file line number Diff line number Diff line change
@@ -1,20 +1,10 @@
import { useEffect, useState } from 'react';
import { WifiOff } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { useOnlineStatus } from '@/hooks/use-online-status';

export function OfflineIndicator() {
const { t } = useTranslation();
const [offline, setOffline] = useState(() => typeof navigator !== 'undefined' && !navigator.onLine);

useEffect(() => {
const update = () => setOffline(!navigator.onLine);
window.addEventListener('online', update);
window.addEventListener('offline', update);
return () => {
window.removeEventListener('online', update);
window.removeEventListener('offline', update);
};
}, []);
const offline = !useOnlineStatus();

if (!offline) return null;

Expand Down
123 changes: 63 additions & 60 deletions src/components/features/generate/GenerateForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,26 @@ interface GenerateFormProps {
debatePrefill?: boolean;
}

function OptionSelect<V extends string>({ value, onValueChange, options, triggerClassName }: {
value: V;
onValueChange: (value: V) => void;
options: { value: V; label: string }[];
triggerClassName?: string;
}) {
return (
<Select value={value} onValueChange={(next) => onValueChange(next as V)}>
<SelectTrigger className={triggerClassName}>
<SelectValue />
</SelectTrigger>
<SelectContent>
{options.map((option) => (
<SelectItem key={option.value} value={option.value}>{option.label}</SelectItem>
))}
</SelectContent>
</Select>
);
}

export function GenerateForm({ onSubmit, isLoading, debatePrefill = true }: GenerateFormProps) {
const { t } = useTranslation();
const { generateForm, setGenerateForm } = useSessionStore();
Expand Down Expand Up @@ -154,37 +174,29 @@ export function GenerateForm({ onSubmit, isLoading, debatePrefill = true }: Gene
{/* Side */}
<div className="space-y-2">
<Label className="text-sm font-medium">{t('generateForm.side')}</Label>
<Select
<OptionSelect
value={currentValues.side}
onValueChange={(value) => setValue('side', value as GenerateInput['side'])}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="pro">{t('generateForm.sidePro')}</SelectItem>
<SelectItem value="con">{t('generateForm.sideCon')}</SelectItem>
</SelectContent>
</Select>
onValueChange={(value) => setValue('side', value)}
options={[
{ value: 'pro', label: t('generateForm.sidePro') },
{ value: 'con', label: t('generateForm.sideCon') },
]}
/>
</div>

{/* Role */}
<div className="space-y-2">
<Label className="text-sm font-medium">{t('generateForm.role')}</Label>
<Select
<OptionSelect
value={currentValues.role}
onValueChange={(value) => setValue('role', value as GenerateInput['role'])}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="intro">{t('generateForm.roleIntro')}</SelectItem>
<SelectItem value="r1">{t('generateForm.roleR1')}</SelectItem>
<SelectItem value="r2">{t('generateForm.roleR2')}</SelectItem>
<SelectItem value="conc">{t('generateForm.roleConc')}</SelectItem>
</SelectContent>
</Select>
onValueChange={(value) => setValue('role', value)}
options={[
{ value: 'intro', label: t('generateForm.roleIntro') },
{ value: 'r1', label: t('generateForm.roleR1') },
{ value: 'r2', label: t('generateForm.roleR2') },
{ value: 'conc', label: t('generateForm.roleConc') },
]}
/>
</div>

</div>
Expand Down Expand Up @@ -220,55 +232,46 @@ export function GenerateForm({ onSubmit, isLoading, debatePrefill = true }: Gene
{/* Length */}
<div className="space-y-2 min-w-0">
<Label className="text-sm font-medium truncate block">{t('generateForm.length')}</Label>
<Select
<OptionSelect
value={currentValues.length}
onValueChange={(value) => setValue('length', value as GenerateInput['length'])}
>
<SelectTrigger className="min-w-0">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="brief">{t('generateForm.lengthBrief')}</SelectItem>
<SelectItem value="standard">{t('generateForm.lengthStandard')}</SelectItem>
<SelectItem value="comprehensive">{t('generateForm.lengthComprehensive')}</SelectItem>
</SelectContent>
</Select>
onValueChange={(value) => setValue('length', value)}
triggerClassName="min-w-0"
options={[
{ value: 'brief', label: t('generateForm.lengthBrief') },
{ value: 'standard', label: t('generateForm.lengthStandard') },
{ value: 'comprehensive', label: t('generateForm.lengthComprehensive') },
]}
/>
</div>

{/* Audience */}
<div className="space-y-2 min-w-0">
<Label className="text-sm font-medium truncate block">{t('generateForm.audience')}</Label>
<Select
<OptionSelect
value={currentValues.audience}
onValueChange={(value) => setValue('audience', value as GenerateInput['audience'])}
>
<SelectTrigger className="min-w-0">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="beginner">{t('generateForm.audienceBeginner')}</SelectItem>
<SelectItem value="intermediate">{t('generateForm.audienceIntermediate')}</SelectItem>
<SelectItem value="advanced">{t('generateForm.audienceAdvanced')}</SelectItem>
</SelectContent>
</Select>
onValueChange={(value) => setValue('audience', value)}
triggerClassName="min-w-0"
options={[
{ value: 'beginner', label: t('generateForm.audienceBeginner') },
{ value: 'intermediate', label: t('generateForm.audienceIntermediate') },
{ value: 'advanced', label: t('generateForm.audienceAdvanced') },
]}
/>
</div>

{/* Tone */}
<div className="space-y-2 min-w-0">
<Label className="text-sm font-medium truncate block">{t('generateForm.tone')}</Label>
<Select
<OptionSelect
value={currentValues.tone}
onValueChange={(value) => setValue('tone', value as GenerateInput['tone'])}
>
<SelectTrigger className="min-w-0">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="formal">{t('generateForm.toneFormal')}</SelectItem>
<SelectItem value="conversational">{t('generateForm.toneConversational')}</SelectItem>
<SelectItem value="aggressive">{t('generateForm.toneAggressive')}</SelectItem>
</SelectContent>
</Select>
onValueChange={(value) => setValue('tone', value)}
triggerClassName="min-w-0"
options={[
{ value: 'formal', label: t('generateForm.toneFormal') },
{ value: 'conversational', label: t('generateForm.toneConversational') },
{ value: 'aggressive', label: t('generateForm.toneAggressive') },
]}
/>
<p className="text-[11px] text-muted-foreground">{t('generateForm.styleNote')}</p>
</div>
</div>
Expand Down
15 changes: 13 additions & 2 deletions src/components/features/generate/GenerateResults.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@ interface GenerateResultsProps {
result: GenerateOutput;
discoveredSources?: DiscoveredSource[];
onRegenerateSection?: (section: string) => void;
/** The section being regenerated; the rest of the results stay on screen. */
regeneratingSection?: string | null;
onDiscuss?: () => void;
onSave?: () => void;
onUndo?: () => void;
Expand Down Expand Up @@ -75,10 +77,14 @@ function formatDuration(ms: number | undefined, t: (key: string, options?: Recor
return t('generateResults.durationS', { value });
}

export function GenerateResults({ result, discoveredSources, onRegenerateSection, onDiscuss, onSave, onUndo, onImportFlowchart, onPracticeCrossfire }: GenerateResultsProps) {
export function GenerateResults({ result, discoveredSources, onRegenerateSection, regeneratingSection = null, onDiscuss, onSave, onUndo, onImportFlowchart, onPracticeCrossfire }: GenerateResultsProps) {
const { t } = useTranslation();
const [isExportingGDocs, setIsExportingGDocs] = useState(false);
const [showDriveAuth, setShowDriveAuth] = useState(false);
const regenerateState = (section: string) => ({
isLoading: regeneratingSection === section,
regenerateDisabled: regeneratingSection !== null,
});

const handleCopyToClipboard = async () => {
const lines: string[] = [];
Expand Down Expand Up @@ -233,7 +239,7 @@ export function GenerateResults({ result, discoveredSources, onRegenerateSection
</Button>
)}
{onUndo && (
<Button variant="outline" size="sm" onClick={onUndo}>
<Button variant="outline" size="sm" onClick={onUndo} disabled={regeneratingSection !== null}>
<Undo2 className="mr-2 h-4 w-4" />
{t('generateResults.undo', 'Undo')}
</Button>
Expand All @@ -251,6 +257,7 @@ export function GenerateResults({ result, discoveredSources, onRegenerateSection
description={t('generateResults.exordioDesc')}
icon={<Sparkles className="h-5 w-5" />}
onRegenerate={() => onRegenerateSection?.('exordio')}
{...regenerateState('exordio')}
>
<div className="space-y-4">
<div>
Expand All @@ -272,6 +279,7 @@ export function GenerateResults({ result, discoveredSources, onRegenerateSection
description={t('generateResults.caseStructureDesc')}
icon={<Target className="h-5 w-5" />}
onRegenerate={() => onRegenerateSection?.('structure')}
{...regenerateState('structure')}
>
<div className="space-y-4">
<div>
Expand Down Expand Up @@ -305,6 +313,7 @@ export function GenerateResults({ result, discoveredSources, onRegenerateSection
description={`${result.contentions?.length || 0} ${t('generateResults.structuredArguments')}`}
icon={<Shield className="h-5 w-5" />}
onRegenerate={() => onRegenerateSection?.('contentions')}
{...regenerateState('contentions')}
>
<div className="space-y-4">
{result.contentions?.map((contention, index) => (
Expand All @@ -319,6 +328,7 @@ export function GenerateResults({ result, discoveredSources, onRegenerateSection
description={t('generateResults.predictedOpponentDesc')}
icon={<Swords className="h-5 w-5" />}
onRegenerate={() => onRegenerateSection?.('opponent')}
{...regenerateState('opponent')}
>
<div className="space-y-4">
<div>
Expand Down Expand Up @@ -361,6 +371,7 @@ export function GenerateResults({ result, discoveredSources, onRegenerateSection
title={t('generateResults.preparedCounters')}
description={t('generateResults.preparedCountersDesc')}
onRegenerate={() => onRegenerateSection?.('counters')}
{...regenerateState('counters')}
>
<div className="space-y-4">
{result.counters?.map((counter, i) => (
Expand Down
Loading
Loading