Skip to content

Commit cf89554

Browse files
authored
Merge pull request #89 from Programcode/fix/ultrareview-ui-findings
Fix section regeneration and workspace autosave ordering
2 parents 8279a30 + d393781 commit cf89554

22 files changed

Lines changed: 471 additions & 139 deletions

‎.claude/skills/verify-debatelab/features/README.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,7 @@ Each feature file starts with an H1 title and one paragraph describing the user-
4040
## Features
4141

4242
- [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`).
43-
- [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`).
43+
- [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`).
4444
- [Library sources](./library-sources.md): add, pause, delete, grounding, and URL import (`recipes/library.spec.ts`).
4545
- [AI tools](./ai-tools.md): Attack, Brainstorm, Factoids, and Ask streamed, cited, and with web search (`recipes/ai-tools.spec.ts`).
4646
- [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`).

‎.claude/skills/verify-debatelab/features/case-generation.md‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ A signed-in user enters a motion on Generate and receives a structured debate ca
99
- `gen-error` shows `Generation Failed` on a provider error, and the button becomes enabled again for a retry.
1010
- `gen-quota` shows the dialog `Daily Access Paused` when the account has no quota, without calling the provider.
1111
- `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.
12+
- `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.
1213
- `gen-export` downloads a `.pdf` (starting with `%PDF`) and a `.docx` (starting with `PK`).
1314

1415
## How to get to it (user POV)
@@ -29,10 +30,13 @@ Preconditions:
2930
- **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.
3031
- **Error.** Call `await fake.fail("generate", 500)`, start the debate, and generate. The text `Generation Failed` (use `exact`) appears. Clicking again then succeeds.
3132
- **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.
33+
- **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.
3234
- **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.
3335

3436
## Gotchas
3537

38+
- 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.
39+
3640
- 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.
3741
- `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.
3842
- 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`.

‎.claude/skills/verify-debatelab/recipes/case-generation.spec.ts‎

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -152,6 +152,42 @@ test("gen-export: a generated case exports to PDF and DOCX", async ({ signedIn:
152152
}
153153
});
154154

155+
test("gen-regenerate: regenerating one section keeps the rest of the case and its toolbar on screen", async ({ signedIn: page, evidence }) => {
156+
const { output } = await generate(page);
157+
const thesis = output.caseStructure.thesis;
158+
await evidence.shot("01-generated");
159+
160+
// Hold the regenerate request so the in-flight screen can be checked.
161+
let release!: () => void;
162+
const held = new Promise<void>((resolve) => { release = resolve; });
163+
await page.route("**/functions/v1/ai", async (route) => {
164+
await held;
165+
await route.continue();
166+
});
167+
const response = aiResponse(page, "generate");
168+
await page.getByRole("button", { name: "Regenerate Arguments" }).click();
169+
await evidence.step("Clicked Regenerate Arguments; request held");
170+
171+
const overlay = page.getByRole("status").filter({ hasText: "Regenerating Arguments" });
172+
await expect(overlay).toBeVisible();
173+
await expect(page.getByText(thesis).first()).toBeVisible();
174+
await expect(page.getByRole("button", { name: "PDF", exact: true })).toBeVisible();
175+
await expect(page.getByRole("button", { name: "Regenerate Case Structure" })).toBeDisabled();
176+
// The form button reads "Generating..." while busy, so find it by its tour anchor.
177+
await expect(page.locator('[data-tour="generate-button"]')).toBeDisabled();
178+
await evidence.shot("02-regenerating-one-section");
179+
await evidence.aria("02-regenerating-one-section");
180+
181+
release();
182+
await response;
183+
await page.unroute("**/functions/v1/ai");
184+
await expect(page.getByText("Section regenerated").first()).toBeVisible({ timeout: AI_TIMEOUT });
185+
await expect(overlay).toHaveCount(0);
186+
await expect(page.getByText(thesis).first()).toBeVisible();
187+
await expect(page.getByRole("button", { name: "Regenerate Case Structure" })).toBeEnabled();
188+
await evidence.shot("03-regenerated");
189+
});
190+
155191
test("gen-error: a provider failure shows an error and the retry succeeds", async ({ signedIn: page, evidence }) => {
156192
await fake.fail("generate", 500);
157193
await openGenerate(page);

‎src/components/OfflineIndicator.tsx‎

Lines changed: 2 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,10 @@
1-
import { useEffect, useState } from 'react';
21
import { WifiOff } from 'lucide-react';
32
import { useTranslation } from 'react-i18next';
3+
import { useOnlineStatus } from '@/hooks/use-online-status';
44

55
export function OfflineIndicator() {
66
const { t } = useTranslation();
7-
const [offline, setOffline] = useState(() => typeof navigator !== 'undefined' && !navigator.onLine);
8-
9-
useEffect(() => {
10-
const update = () => setOffline(!navigator.onLine);
11-
window.addEventListener('online', update);
12-
window.addEventListener('offline', update);
13-
return () => {
14-
window.removeEventListener('online', update);
15-
window.removeEventListener('offline', update);
16-
};
17-
}, []);
7+
const offline = !useOnlineStatus();
188

199
if (!offline) return null;
2010

‎src/components/features/generate/GenerateForm.tsx‎

Lines changed: 63 additions & 60 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,26 @@ interface GenerateFormProps {
3232
debatePrefill?: boolean;
3333
}
3434

35+
function OptionSelect<V extends string>({ value, onValueChange, options, triggerClassName }: {
36+
value: V;
37+
onValueChange: (value: V) => void;
38+
options: { value: V; label: string }[];
39+
triggerClassName?: string;
40+
}) {
41+
return (
42+
<Select value={value} onValueChange={(next) => onValueChange(next as V)}>
43+
<SelectTrigger className={triggerClassName}>
44+
<SelectValue />
45+
</SelectTrigger>
46+
<SelectContent>
47+
{options.map((option) => (
48+
<SelectItem key={option.value} value={option.value}>{option.label}</SelectItem>
49+
))}
50+
</SelectContent>
51+
</Select>
52+
);
53+
}
54+
3555
export function GenerateForm({ onSubmit, isLoading, debatePrefill = true }: GenerateFormProps) {
3656
const { t } = useTranslation();
3757
const { generateForm, setGenerateForm } = useSessionStore();
@@ -154,37 +174,29 @@ export function GenerateForm({ onSubmit, isLoading, debatePrefill = true }: Gene
154174
{/* Side */}
155175
<div className="space-y-2">
156176
<Label className="text-sm font-medium">{t('generateForm.side')}</Label>
157-
<Select
177+
<OptionSelect
158178
value={currentValues.side}
159-
onValueChange={(value) => setValue('side', value as GenerateInput['side'])}
160-
>
161-
<SelectTrigger>
162-
<SelectValue />
163-
</SelectTrigger>
164-
<SelectContent>
165-
<SelectItem value="pro">{t('generateForm.sidePro')}</SelectItem>
166-
<SelectItem value="con">{t('generateForm.sideCon')}</SelectItem>
167-
</SelectContent>
168-
</Select>
179+
onValueChange={(value) => setValue('side', value)}
180+
options={[
181+
{ value: 'pro', label: t('generateForm.sidePro') },
182+
{ value: 'con', label: t('generateForm.sideCon') },
183+
]}
184+
/>
169185
</div>
170186

171187
{/* Role */}
172188
<div className="space-y-2">
173189
<Label className="text-sm font-medium">{t('generateForm.role')}</Label>
174-
<Select
190+
<OptionSelect
175191
value={currentValues.role}
176-
onValueChange={(value) => setValue('role', value as GenerateInput['role'])}
177-
>
178-
<SelectTrigger>
179-
<SelectValue />
180-
</SelectTrigger>
181-
<SelectContent>
182-
<SelectItem value="intro">{t('generateForm.roleIntro')}</SelectItem>
183-
<SelectItem value="r1">{t('generateForm.roleR1')}</SelectItem>
184-
<SelectItem value="r2">{t('generateForm.roleR2')}</SelectItem>
185-
<SelectItem value="conc">{t('generateForm.roleConc')}</SelectItem>
186-
</SelectContent>
187-
</Select>
192+
onValueChange={(value) => setValue('role', value)}
193+
options={[
194+
{ value: 'intro', label: t('generateForm.roleIntro') },
195+
{ value: 'r1', label: t('generateForm.roleR1') },
196+
{ value: 'r2', label: t('generateForm.roleR2') },
197+
{ value: 'conc', label: t('generateForm.roleConc') },
198+
]}
199+
/>
188200
</div>
189201

190202
</div>
@@ -220,55 +232,46 @@ export function GenerateForm({ onSubmit, isLoading, debatePrefill = true }: Gene
220232
{/* Length */}
221233
<div className="space-y-2 min-w-0">
222234
<Label className="text-sm font-medium truncate block">{t('generateForm.length')}</Label>
223-
<Select
235+
<OptionSelect
224236
value={currentValues.length}
225-
onValueChange={(value) => setValue('length', value as GenerateInput['length'])}
226-
>
227-
<SelectTrigger className="min-w-0">
228-
<SelectValue />
229-
</SelectTrigger>
230-
<SelectContent>
231-
<SelectItem value="brief">{t('generateForm.lengthBrief')}</SelectItem>
232-
<SelectItem value="standard">{t('generateForm.lengthStandard')}</SelectItem>
233-
<SelectItem value="comprehensive">{t('generateForm.lengthComprehensive')}</SelectItem>
234-
</SelectContent>
235-
</Select>
237+
onValueChange={(value) => setValue('length', value)}
238+
triggerClassName="min-w-0"
239+
options={[
240+
{ value: 'brief', label: t('generateForm.lengthBrief') },
241+
{ value: 'standard', label: t('generateForm.lengthStandard') },
242+
{ value: 'comprehensive', label: t('generateForm.lengthComprehensive') },
243+
]}
244+
/>
236245
</div>
237246

238247
{/* Audience */}
239248
<div className="space-y-2 min-w-0">
240249
<Label className="text-sm font-medium truncate block">{t('generateForm.audience')}</Label>
241-
<Select
250+
<OptionSelect
242251
value={currentValues.audience}
243-
onValueChange={(value) => setValue('audience', value as GenerateInput['audience'])}
244-
>
245-
<SelectTrigger className="min-w-0">
246-
<SelectValue />
247-
</SelectTrigger>
248-
<SelectContent>
249-
<SelectItem value="beginner">{t('generateForm.audienceBeginner')}</SelectItem>
250-
<SelectItem value="intermediate">{t('generateForm.audienceIntermediate')}</SelectItem>
251-
<SelectItem value="advanced">{t('generateForm.audienceAdvanced')}</SelectItem>
252-
</SelectContent>
253-
</Select>
252+
onValueChange={(value) => setValue('audience', value)}
253+
triggerClassName="min-w-0"
254+
options={[
255+
{ value: 'beginner', label: t('generateForm.audienceBeginner') },
256+
{ value: 'intermediate', label: t('generateForm.audienceIntermediate') },
257+
{ value: 'advanced', label: t('generateForm.audienceAdvanced') },
258+
]}
259+
/>
254260
</div>
255261

256262
{/* Tone */}
257263
<div className="space-y-2 min-w-0">
258264
<Label className="text-sm font-medium truncate block">{t('generateForm.tone')}</Label>
259-
<Select
265+
<OptionSelect
260266
value={currentValues.tone}
261-
onValueChange={(value) => setValue('tone', value as GenerateInput['tone'])}
262-
>
263-
<SelectTrigger className="min-w-0">
264-
<SelectValue />
265-
</SelectTrigger>
266-
<SelectContent>
267-
<SelectItem value="formal">{t('generateForm.toneFormal')}</SelectItem>
268-
<SelectItem value="conversational">{t('generateForm.toneConversational')}</SelectItem>
269-
<SelectItem value="aggressive">{t('generateForm.toneAggressive')}</SelectItem>
270-
</SelectContent>
271-
</Select>
267+
onValueChange={(value) => setValue('tone', value)}
268+
triggerClassName="min-w-0"
269+
options={[
270+
{ value: 'formal', label: t('generateForm.toneFormal') },
271+
{ value: 'conversational', label: t('generateForm.toneConversational') },
272+
{ value: 'aggressive', label: t('generateForm.toneAggressive') },
273+
]}
274+
/>
272275
<p className="text-[11px] text-muted-foreground">{t('generateForm.styleNote')}</p>
273276
</div>
274277
</div>

‎src/components/features/generate/GenerateResults.tsx‎

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,8 @@ interface GenerateResultsProps {
2020
result: GenerateOutput;
2121
discoveredSources?: DiscoveredSource[];
2222
onRegenerateSection?: (section: string) => void;
23+
/** The section being regenerated; the rest of the results stay on screen. */
24+
regeneratingSection?: string | null;
2325
onDiscuss?: () => void;
2426
onSave?: () => void;
2527
onUndo?: () => void;
@@ -75,10 +77,14 @@ function formatDuration(ms: number | undefined, t: (key: string, options?: Recor
7577
return t('generateResults.durationS', { value });
7678
}
7779

78-
export function GenerateResults({ result, discoveredSources, onRegenerateSection, onDiscuss, onSave, onUndo, onImportFlowchart, onPracticeCrossfire }: GenerateResultsProps) {
80+
export function GenerateResults({ result, discoveredSources, onRegenerateSection, regeneratingSection = null, onDiscuss, onSave, onUndo, onImportFlowchart, onPracticeCrossfire }: GenerateResultsProps) {
7981
const { t } = useTranslation();
8082
const [isExportingGDocs, setIsExportingGDocs] = useState(false);
8183
const [showDriveAuth, setShowDriveAuth] = useState(false);
84+
const regenerateState = (section: string) => ({
85+
isLoading: regeneratingSection === section,
86+
regenerateDisabled: regeneratingSection !== null,
87+
});
8288

8389
const handleCopyToClipboard = async () => {
8490
const lines: string[] = [];
@@ -233,7 +239,7 @@ export function GenerateResults({ result, discoveredSources, onRegenerateSection
233239
</Button>
234240
)}
235241
{onUndo && (
236-
<Button variant="outline" size="sm" onClick={onUndo}>
242+
<Button variant="outline" size="sm" onClick={onUndo} disabled={regeneratingSection !== null}>
237243
<Undo2 className="mr-2 h-4 w-4" />
238244
{t('generateResults.undo', 'Undo')}
239245
</Button>
@@ -251,6 +257,7 @@ export function GenerateResults({ result, discoveredSources, onRegenerateSection
251257
description={t('generateResults.exordioDesc')}
252258
icon={<Sparkles className="h-5 w-5" />}
253259
onRegenerate={() => onRegenerateSection?.('exordio')}
260+
{...regenerateState('exordio')}
254261
>
255262
<div className="space-y-4">
256263
<div>
@@ -272,6 +279,7 @@ export function GenerateResults({ result, discoveredSources, onRegenerateSection
272279
description={t('generateResults.caseStructureDesc')}
273280
icon={<Target className="h-5 w-5" />}
274281
onRegenerate={() => onRegenerateSection?.('structure')}
282+
{...regenerateState('structure')}
275283
>
276284
<div className="space-y-4">
277285
<div>
@@ -305,6 +313,7 @@ export function GenerateResults({ result, discoveredSources, onRegenerateSection
305313
description={`${result.contentions?.length || 0} ${t('generateResults.structuredArguments')}`}
306314
icon={<Shield className="h-5 w-5" />}
307315
onRegenerate={() => onRegenerateSection?.('contentions')}
316+
{...regenerateState('contentions')}
308317
>
309318
<div className="space-y-4">
310319
{result.contentions?.map((contention, index) => (
@@ -319,6 +328,7 @@ export function GenerateResults({ result, discoveredSources, onRegenerateSection
319328
description={t('generateResults.predictedOpponentDesc')}
320329
icon={<Swords className="h-5 w-5" />}
321330
onRegenerate={() => onRegenerateSection?.('opponent')}
331+
{...regenerateState('opponent')}
322332
>
323333
<div className="space-y-4">
324334
<div>
@@ -361,6 +371,7 @@ export function GenerateResults({ result, discoveredSources, onRegenerateSection
361371
title={t('generateResults.preparedCounters')}
362372
description={t('generateResults.preparedCountersDesc')}
363373
onRegenerate={() => onRegenerateSection?.('counters')}
374+
{...regenerateState('counters')}
364375
>
365376
<div className="space-y-4">
366377
{result.counters?.map((counter, i) => (

0 commit comments

Comments
 (0)