From 0d5ee064be18cfd89cbae662023f1d4460738b9e Mon Sep 17 00:00:00 2001 From: Adam Date: Thu, 22 Oct 2026 23:58:09 +0000 Subject: [PATCH 1/4] =?UTF-8?q?Polish=20the=20prep=20modes=20(T8:=20MD-04,?= =?UTF-8?q?=20MD-06=E2=80=9309,=20MD-11,=20MD-12,=20MD-14,=20AX-05,=20AX-0?= =?UTF-8?q?6)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Start debate: motion counter on the shared 1,200 limit, and a Creating… spinner that keeps one click to one create. - Generate: offers Any role and takes the debate's role with its motion. - Context line: "Using 7 of 14 sources (7 didn't fit)" instead of a separate left-out line; results show Saved to History once saved. - PDF export: one Case Structure heading, no repeated Framework label. - Brainstorm and Factoids: Clipboard and Save to History. - Sparring: each turn waits on a ready step before the clock runs, labelled and locale-formatted word counts, no nested main. - Attack: a critique left over from a failed or refused rerun is labelled as the previous argument's. Co-Authored-By: Claude Opus 5.5 --- .../verify-debatelab/features/practice.md | 2 +- .../features/workspace-autosave.md | 2 +- .../recipes/workspace-autosave.spec.ts | 5 +- docs/architecture.md | 2 +- packages/core/src/ai/schemas/sparring.ts | 3 +- packages/core/src/validation/motion.ts | 2 + .../features/brainstorm/BrainstormResults.tsx | 10 ++- .../features/debate/DebateContextControl.tsx | 21 +++--- .../features/debate/DebateDialogs.tsx | 2 + src/components/features/debate/DebateForm.tsx | 24 +++++- .../features/debate/DebateMotionGate.tsx | 27 ++++--- .../features/factoids/FactoidsResults.tsx | 10 ++- .../features/generate/GenerateForm.tsx | 20 ++++- .../features/generate/GenerateResults.tsx | 54 +++---------- .../features/sparring/SparringRound.tsx | 59 ++++++++++++--- .../features/sparring/SparringSetup.tsx | 6 +- .../features/tools/result-actions.tsx | 75 +++++++++++++++++++ src/components/features/tools/result-text.ts | 74 ++++++++++++++++++ src/lib/pdf-export-utils.ts | 7 +- src/lib/stores/debate-store.ts | 4 +- src/lib/stores/sparring-store.ts | 21 +++--- src/locales/en.json | 36 ++++++--- src/locales/es.json | 36 ++++++--- src/locales/hu.json | 36 ++++++--- src/pages/AttackPage.tsx | 22 +++++- src/pages/BrainstormPage.tsx | 29 ++++++- src/pages/FactoidsPage.tsx | 28 ++++++- src/pages/GeneratePage.tsx | 17 ++++- src/pages/SparringPage.tsx | 16 +--- src/test/debate/debate-context.test.tsx | 8 ++ src/test/generate/generate-form-role.test.tsx | 29 +++++++ src/test/pages/AskAccountSwitch.test.tsx | 4 +- src/test/pages/AskStreamFallback.test.tsx | 2 +- src/test/pages/SparringVoice.test.tsx | 35 ++++----- src/test/sparring/sparring-round.test.tsx | 61 +++++++++++++++ 35 files changed, 602 insertions(+), 187 deletions(-) create mode 100644 packages/core/src/validation/motion.ts create mode 100644 src/components/features/tools/result-actions.tsx create mode 100644 src/components/features/tools/result-text.ts create mode 100644 src/test/generate/generate-form-role.test.tsx create mode 100644 src/test/sparring/sparring-round.test.tsx diff --git a/.claude/skills/verify-debatelab/features/practice.md b/.claude/skills/verify-debatelab/features/practice.md index 8d371d75c..f968f5014 100644 --- a/.claude/skills/verify-debatelab/features/practice.md +++ b/.claude/skills/verify-debatelab/features/practice.md @@ -26,7 +26,7 @@ Preconditions: - Doctor passes. The user is signed in. The recipe is `recipes/practice.spec.ts`. - **Start a sparring round.** On `/app/sparring`, fill the field labelled `Motion`. Open the combobox named `Round format` and choose the option matching `Lincoln-Douglas · 5 speeches`. Click `Start round`. A new account has no Current Debate, so `Start a debate with this motion?` opens: `chooseForMotion(page, "create")`. -- **Speeches.** For each user turn, fill the textbox named `Draft for ` (`Affirmative constructive`, `First affirmative rebuttal`, `Second affirmative rebuttal`) and click `Submit speech`. After each of the first two, the last `FAKE-PROVIDER opponent speech` is visible. +- **Speeches.** For each user turn, fill the textbox named `Draft for ` (`Affirmative constructive`, `First affirmative rebuttal`, `Second affirmative rebuttal`) and click `Submit speech`. Each turn opens with a stopped clock (test id `sparring-ready`, button `Start speaking`); filling the draft starts it. After each of the first two, the last `FAKE-PROVIDER opponent speech` is visible. - **Verdict.** The heading `Here is what happened in the room.` and the text `FAKE-PROVIDER verdict: the user had the edge.` appear. `providerCall("sparring").user` contains `SPARRING ACTION:`. `sparring_sessions` statuses for the user equal `["complete"]`, and `select("side,debates(motion)")` gives `{side: "pro", debates: {motion}}`. - **Start Crossfire.** On `/app/crossfire`, fill test id `crossfire-resolution` and `crossfire-argument`. Click `mode-question-ai` (or `mode-ai-questions-user`), then `start-crossfire`, then `chooseForMotion(page, "create")`. - **Crossfire turn.** Fill `crossfire-input` and click `submit-turn`. The text `FAKE-PROVIDER crossfire answer` and `Direct answer` appear. `providerCall("crossfire")` and `providerCall("jev")` both fired. diff --git a/.claude/skills/verify-debatelab/features/workspace-autosave.md b/.claude/skills/verify-debatelab/features/workspace-autosave.md index d3e618600..ab87eeda8 100644 --- a/.claude/skills/verify-debatelab/features/workspace-autosave.md +++ b/.claude/skills/verify-debatelab/features/workspace-autosave.md @@ -21,7 +21,7 @@ Preconditions: - **Open the tool.** Create a workspace for the motion, click `R1`, and on a phone click `Working panel` first (one pane at a time). Open the `Practice` details if closed and click `Crossfire` or `Sparring` (`exact: true`). - **Hold one save.** `page.route("**/rest/v1/rpc/save_workspace_artifact")` holds the first request whose `p_progress` matches a partial value (Crossfire: a resolution ending in ` n`; Sparring: a draft of `Coding`). The recipe types up to that value and waits until the save is held before typing the rest: the queue keeps only the newest pending save, so a value typed straight through can be merged away and never sent. The page keeps one save in flight, so no other save may be sent while it is held (`sentWhileHeld()` is 0); newer changes wait in the queue. - **Release and check.** Let the held save through and wait for its answer. Polling `admin.from("workspace_artifacts")` for the workspace and tool must return one row with the full text, and it must still hold that text once every save the page sent has been answered. -- **Sparring.** Click `Start round` and wait for the textbox `Draft for ` (`AI_TIMEOUT`). Click `Pause clock` before typing, because a running clock saves every tick and would hide a stale save. +- **Sparring.** Click `Start round` and wait for the textbox `Draft for ` (`AI_TIMEOUT`). Each turn opens with a stopped clock and a `Start speaking` button; typing would start it. Click `Start speaking`, then `Pause clock`, before typing, because a running clock saves every tick and would hide a stale save. ## Gotchas diff --git a/.claude/skills/verify-debatelab/recipes/workspace-autosave.spec.ts b/.claude/skills/verify-debatelab/recipes/workspace-autosave.spec.ts index 623485010..9b222ab0a 100644 --- a/.claude/skills/verify-debatelab/recipes/workspace-autosave.spec.ts +++ b/.claude/skills/verify-debatelab/recipes/workspace-autosave.spec.ts @@ -107,8 +107,9 @@ test("ws-sparring-autosave: a slow Sparring save does not overwrite a newer draf const draft = page.getByRole("textbox", { name: /^Draft for / }); await expect(draft).toBeVisible({ timeout: AI_TIMEOUT }); // A running clock saves the session every tick, which would hide a stale save. - const pause = page.getByRole("button", { name: "Pause clock" }); - if (await pause.isVisible()) await pause.click(); + // The turn opens on its ready step; start it, then pause, so typing does not restart the clock. + await page.getByRole("button", { name: "Start speaking" }).click(); + await page.getByRole("button", { name: "Pause clock" }).click(); await expect(page.getByRole("button", { name: "Resume clock" })).toBeVisible(); const saves = await holdSave(page, (progress) => progress.draft === "Coding"); diff --git a/docs/architecture.md b/docs/architecture.md index 070f59cd8..41aaeffbe 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -86,7 +86,7 @@ The student's speech never reaches DebateLab as audio. [`src/hooks/use-speech-ca A voice request adds a top-level `voice: true` beside `input`, where a server from before voice rounds ignores it. The opponent prompt then allows a short allowlist of inline audio tags such as `[sighs]` and `[sarcastic]`. [`supabase/functions/_shared/voice-tags.ts`](../supabase/functions/_shared/voice-tags.ts) splits the model's reply in two: the clean text, with every tag removed, is what the response's `output` carries, what is signed, shown, saved, and read by the fallback voice; the spoken text keeps allowlisted tags, turns `[pause]` into the ellipsis Eleven v4 documents for pauses, rations tags to at most one per sentence and never two sentences running, and is cut into segments of at most 1,000 characters. Up to 12 segments are returned in a top-level `voice.segments` list, each with a 30-minute HMAC token from [`_shared/voice-token.ts`](../supabase/functions/_shared/voice-token.ts) that binds the text to the user and the feature; the verifier allows 30 seconds of clock difference between the two functions. Text past the twelfth segment comes back unsigned and without tags in `voice.overflow`, which only the browser voice reads, so a long reply is heard in full without raising the ElevenLabs cost of one reply. The spoken text lives only in memory in the browser. -[`src/lib/voice/opponent-voice.ts`](../src/lib/voice/opponent-voice.ts) plays a reply segment by segment through the [`voice`](../supabase/functions/voice/index.ts) Edge Function, fetching one segment ahead and decoding with Web Audio. The function authenticates the caller, verifies the token so it speaks only text the server wrote, and returns `voice_unavailable` when `ELEVENLABS_API_KEY` is not set, before it reserves anything. It then checks the account, reserves one clip from the per-user daily voice cap through `consume_daily_quota` under the `voice` key (60 clips for paid access, 12 on a trial, overridable with a `daily_voice_clips` row in `rate_limits`), calls ElevenLabs (`eleven_v4` for Sparring, `eleven_v4_turbo` for Crossfire), and logs an `ai_requests` row with the billed characters. Every attempt keeps its clip, including one ElevenLabs refuses, so replayed tokens cannot reach the provider without limit while it fails. Whenever a segment cannot be had, because the key is missing, the cap is reached, or the call fails, the browser's `speechSynthesis` reads the rest of the reply from the clean text and the round continues. After any refusal from the function other than an invalid token, the browser stops asking for the rest of that round and tries ElevenLabs again when the next round starts; a network error is retried on the next reply. In Sparring the student's clock waits while the opponent speaks. Starting to speak stops the opponent's audio. +[`src/lib/voice/opponent-voice.ts`](../src/lib/voice/opponent-voice.ts) plays a reply segment by segment through the [`voice`](../supabase/functions/voice/index.ts) Edge Function, fetching one segment ahead and decoding with Web Audio. The function authenticates the caller, verifies the token so it speaks only text the server wrote, and returns `voice_unavailable` when `ELEVENLABS_API_KEY` is not set, before it reserves anything. It then checks the account, reserves one clip from the per-user daily voice cap through `consume_daily_quota` under the `voice` key (60 clips for paid access, 12 on a trial, overridable with a `daily_voice_clips` row in `rate_limits`), calls ElevenLabs (`eleven_v4` for Sparring, `eleven_v4_turbo` for Crossfire), and logs an `ai_requests` row with the billed characters. Every attempt keeps its clip, including one ElevenLabs refuses, so replayed tokens cannot reach the provider without limit while it fails. Whenever a segment cannot be had, because the key is missing, the cap is reached, or the call fails, the browser's `speechSynthesis` reads the rest of the reply from the clean text and the round continues. After any refusal from the function other than an invalid token, the browser stops asking for the rest of that round and tries ElevenLabs again when the next round starts; a network error is retried on the next reply. In Sparring each of the student's turns opens with a stopped clock, so it waits while the opponent speaks; it runs once the student presses `Start speaking`, starts the microphone, or starts typing. Starting to speak stops the opponent's audio. [`vercel.json`](../vercel.json) allows the microphone for the app's own origin only (`microphone=(self)`); dictation and voice rounds both depend on it. diff --git a/packages/core/src/ai/schemas/sparring.ts b/packages/core/src/ai/schemas/sparring.ts index 54a9504c5..1f497778c 100644 --- a/packages/core/src/ai/schemas/sparring.ts +++ b/packages/core/src/ai/schemas/sparring.ts @@ -1,5 +1,6 @@ import { z } from 'zod'; import { SpeechDeliverySchema } from '../../voice/delivery'; +import { MAX_MOTION_LENGTH } from '../../validation/motion'; export const SparringSideSchema = z.enum(['pro', 'con']); export const SparringFormatSchema = z.enum(['standard', 'ld', 'pf']); @@ -44,7 +45,7 @@ export const SparringSpeechSchema = z.object({ /** Shared, bounded session context carried by both sparring actions. */ export const SparringSessionSchema = z.object({ sessionId: z.string().uuid(), - motion: z.string().trim().min(1).max(1_200), + motion: z.string().trim().min(1).max(MAX_MOTION_LENGTH), userSide: SparringSideSchema, format: SparringFormatSchema, difficulty: SparringDifficultySchema, diff --git a/packages/core/src/validation/motion.ts b/packages/core/src/validation/motion.ts new file mode 100644 index 000000000..1a1fa2500 --- /dev/null +++ b/packages/core/src/validation/motion.ts @@ -0,0 +1,2 @@ +/** The longest motion a debate, Sparring round, or tool form accepts. */ +export const MAX_MOTION_LENGTH = 1200; diff --git a/src/components/features/brainstorm/BrainstormResults.tsx b/src/components/features/brainstorm/BrainstormResults.tsx index 3901e801c..c0d61960f 100644 --- a/src/components/features/brainstorm/BrainstormResults.tsx +++ b/src/components/features/brainstorm/BrainstormResults.tsx @@ -10,6 +10,8 @@ import { toast } from 'sonner'; import { EmptyState } from '@/components/ui/empty-state'; import { ExportToolbar } from '@/components/features/ExportToolbar'; import { ResultHeader } from '@/components/features/tools/ResultHeader'; +import { CopyResultButton, SaveResultAction } from '@/components/features/tools/result-actions'; +import { brainstormText } from '@/components/features/tools/result-text'; import { ResearchedSources } from '@/components/features/tools/ResearchedSources'; import { GoogleDriveAuthModal } from '@/components/features/export/GoogleDriveAuthModal'; import { SourceQualityList } from '@/components/features/shared/SourceQualityList'; @@ -20,6 +22,10 @@ interface BrainstormResultsProps { result: BrainstormOutput; discoveredSources?: DiscoveredSource[]; onDiscuss?: () => void; + onSave?: () => void; + /** The result is already in History; shows “Saved” instead of Save. */ + saved?: boolean; + saving?: boolean; } const strengthColors = { @@ -65,7 +71,7 @@ function IdeaCard({ idea }: { idea: Idea }) { ); } -export function BrainstormResults({ result, discoveredSources, onDiscuss }: BrainstormResultsProps) { +export function BrainstormResults({ result, discoveredSources, onDiscuss, onSave, saved = false, saving = false }: BrainstormResultsProps) { const { t } = useTranslation(); const [isExportingGDocs, setIsExportingGDocs] = useState(false); const [showDriveAuth, setShowDriveAuth] = useState(false); @@ -155,6 +161,8 @@ export function BrainstormResults({ result, discoveredSources, onDiscuss }: Brai isExportingGDocs={isExportingGDocs} onDiscuss={onDiscuss} discussLabel={t('brainstormResults.discuss')} + extraExports={ brainstormText(result, t)} />} + secondaryActions={onSave || saved ? : undefined} /> )} /> diff --git a/src/components/features/debate/DebateContextControl.tsx b/src/components/features/debate/DebateContextControl.tsx index 23fd0bd01..3d264e3e6 100644 --- a/src/components/features/debate/DebateContextControl.tsx +++ b/src/components/features/debate/DebateContextControl.tsx @@ -60,23 +60,27 @@ export function DebateContextControl({ feature, side, includeSources = true, deb const summary = useMemo(() => { if (!plan) return null; - const sentSources = plan.sources.filter((source) => source.included && !source.overLimit).length; - const sentOutputs = plan.outputs.filter((output) => output.included && !output.overLimit && !output.empty); + // "Sent" means it reaches the model; anything chosen but over the limit or too large did not fit. + const sent = (item: { included: boolean; overLimit: boolean; omittedForSize: boolean }) => item.included && !item.overLimit && !item.omittedForSize; + const chosenSources = plan.sources.filter((source) => source.included).length; + const sentSources = plan.sources.filter(sent).length; + const chosenOutputs = plan.outputs.filter((output) => output.included && !output.empty); + const sentOutputs = chosenOutputs.filter(sent); + const count = (key: 'sourceCount' | 'resultCount', used: number, chosen: number) => used === chosen + ? t(`debateContext.${key}`, { count: used }) + : `${t(`debateContext.${key}Of`, { used, count: chosen })} ${t('debateContext.didntFit', { count: chosen - used })}`; const parts = [ - sentSources > 0 && t('debateContext.sourceCount', { count: sentSources }), - sentOutputs.length > 0 && t('debateContext.resultCount', { count: sentOutputs.length }), + chosenSources > 0 && count('sourceCount', sentSources, chosenSources), + chosenOutputs.length > 0 && count('resultCount', sentOutputs.length, chosenOutputs.length), ].filter((part): part is string => Boolean(part)); const names = sentOutputs.map((output) => output.side === 'pro' || output.side === 'con' ? t('debateContext.resultName', { kind: t(`debate.kinds.${output.ref.kind}`), side: t(`debate.side.${output.side}`) }) : t(`debate.kinds.${output.ref.kind}`)); - const leftOut = plan.sources.filter((source) => source.omittedForSize || source.overLimit).length - + plan.outputs.filter((output) => output.omittedForSize || output.overLimit).length; return { text: parts.length > 0 ? t('debateContext.using', { list: listFormat(i18n.resolvedLanguage ?? 'en', parts) }) : t('debateContext.usingMotionOnly'), names: names.join(', '), - leftOut, }; }, [i18n.resolvedLanguage, plan, t]); @@ -98,9 +102,6 @@ export function DebateContextControl({ feature, side, includeSources = true, deb

{summary.text} {summary.names && : {summary.names}} - {summary.leftOut > 0 && ( - {t('debateContext.leftOut', { count: summary.leftOut })} - )}

} - + ); diff --git a/src/components/features/debate/DebateMotionGate.tsx b/src/components/features/debate/DebateMotionGate.tsx index 5f82b19a2..bee0f8700 100644 --- a/src/components/features/debate/DebateMotionGate.tsx +++ b/src/components/features/debate/DebateMotionGate.tsx @@ -1,5 +1,6 @@ import { useCallback, useRef, useState, type ReactNode } from 'react'; import { useTranslation } from 'react-i18next'; +import { Loader2 } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '@/components/ui/dialog'; import { useToast } from '@/hooks/use-toast'; @@ -33,7 +34,8 @@ export function useDebateMotionGate(options: { disabled?: boolean } = {}): { const { toast } = useToast(); const current = useCurrentDebate(); const [pending, setPending] = useState(null); - const [busy, setBusy] = useState(false); + // Which choice is in flight; every choice waits for it, so one click is one create. + const [busy, setBusy] = useState<'switch' | 'create' | null>(null); const pendingRef = useRef(null); const disabled = Boolean(options.disabled); @@ -53,14 +55,15 @@ export function useDebateMotionGate(options: { disabled?: boolean } = {}): { setPending(null); }; - const choose = async (action: () => Promise) => { - setBusy(true); + const choose = async (kind: 'switch' | 'create', action: () => Promise) => { + if (busy) return; + setBusy(kind); try { finish(await action()); } catch { toast({ title: t('debate.saveFailed', 'Could not save the debate.'), variant: 'destructive' }); } finally { - setBusy(false); + setBusy(null); } }; @@ -87,9 +90,9 @@ export function useDebateMotionGate(options: { disabled?: boolean } = {}): { ))} - diff --git a/src/components/features/factoids/FactoidsResults.tsx b/src/components/features/factoids/FactoidsResults.tsx index 98e7138ac..65e630953 100644 --- a/src/components/features/factoids/FactoidsResults.tsx +++ b/src/components/features/factoids/FactoidsResults.tsx @@ -11,6 +11,8 @@ import { cn } from '@/lib/utils'; import { validateUrl } from '@/lib/validation/url'; import { toast } from 'sonner'; import { ExportToolbar } from '@/components/features/ExportToolbar'; +import { CopyResultButton, SaveResultAction } from '@/components/features/tools/result-actions'; +import { factoidsText } from '@/components/features/tools/result-text'; import { ResultHeader } from '@/components/features/tools/ResultHeader'; import { ResearchedSources } from '@/components/features/tools/ResearchedSources'; import { GoogleDriveAuthModal } from '@/components/features/export/GoogleDriveAuthModal'; @@ -23,6 +25,10 @@ interface FactoidsResultsProps { result: FactoidsOutput; discoveredSources?: DiscoveredSource[]; onDiscuss?: () => void; + onSave?: () => void; + /** The result is already in History; shows “Saved” instead of Save. */ + saved?: boolean; + saving?: boolean; onSaveSource?: (title: string, content: string, url?: string) => void; onUseSuggestion?: (query: string) => void; } @@ -140,7 +146,7 @@ function FactoidCard({ factoid, onSave }: { factoid: Factoid, onSave?: FactoidsR ); } -export function FactoidsResults({ result, discoveredSources, onDiscuss, onSaveSource, onUseSuggestion }: FactoidsResultsProps) { +export function FactoidsResults({ result, discoveredSources, onDiscuss, onSave, saved = false, saving = false, onSaveSource, onUseSuggestion }: FactoidsResultsProps) { const { t } = useTranslation(); const [isExportingGDocs, setIsExportingGDocs] = useState(false); const [showDriveAuth, setShowDriveAuth] = useState(false); @@ -214,6 +220,8 @@ export function FactoidsResults({ result, discoveredSources, onDiscuss, onSaveSo isExportingGDocs={isExportingGDocs} onDiscuss={onDiscuss} discussLabel={t('factoidsResults.discuss')} + extraExports={ factoidsText(result, t)} />} + secondaryActions={onSave || saved ? : undefined} /> )} /> diff --git a/src/components/features/generate/GenerateForm.tsx b/src/components/features/generate/GenerateForm.tsx index d3dfd761b..3c4a48082 100644 --- a/src/components/features/generate/GenerateForm.tsx +++ b/src/components/features/generate/GenerateForm.tsx @@ -20,12 +20,19 @@ import { LoadingSpinner } from '@/components/ui/loading-spinner'; import { ListeningIndicator } from '@/components/features/voice/ListeningIndicator'; import { useDebateMotionPrefill } from '@/components/features/debate/CurrentDebateStrip'; import { useSessionStore } from '@/lib/stores/session-store'; +import { MAX_MOTION_LENGTH, selectCurrentDebate, useDebateStore } from '@/lib/stores/debate-store'; import { useSpeechToText } from '@/hooks/use-speech-to-text'; import { ModelSelector } from '@/components/ModelSelector'; import { DebateContextControl } from '@/components/features/debate/DebateContextControl'; import { requestSide } from '../../../../supabase/functions/_shared/debate-context'; +const GENERATE_ROLES: readonly string[] = GenerateInputSchema.shape.role.options; + +function isGenerateRole(value: unknown): value is GenerateInput['role'] { + return typeof value === 'string' && GENERATE_ROLES.includes(value); +} + interface GenerateFormProps { onSubmit: (data: GenerateInput) => void; isLoading: boolean; @@ -42,7 +49,9 @@ function OptionSelect({ id, labelId, value, onValueChange, opt triggerClassName?: string; }) { return ( - { if (next) onValueChange(next as V); }}> @@ -87,7 +96,12 @@ export function GenerateForm({ onSubmit, isLoading, debatePrefill = true }: Gene return () => subscription.unsubscribe(); }, [watch, setGenerateForm]); - useDebateMotionPrefill(() => getValues('motion'), (motion) => setValue('motion', motion, { shouldValidate: true }), !debatePrefill); + // Filling in a debate's motion also takes its speaker role, including "Any role". + useDebateMotionPrefill(() => getValues('motion'), (motion) => { + setValue('motion', motion, { shouldValidate: true }); + const role = selectCurrentDebate(useDebateStore.getState())?.settings.role; + if (isGenerateRole(role)) setValue('role', role); + }, !debatePrefill); // Voice input integration const { isListening, transcript, error: voiceError, startListening, stopListening, resetTranscript, hasSupport: hasVoiceSupport } = useSpeechToText(); @@ -123,6 +137,7 @@ export function GenerateForm({ onSubmit, isLoading, debatePrefill = true }: Gene @@ -181,6 +196,7 @@ export function GenerateForm({ onSubmit, isLoading, debatePrefill = true }: Gene value={currentValues.role} onValueChange={(value) => setValue('role', value)} options={[ + { value: 'general', label: t('debate.roleAny', 'Any role') }, { value: 'intro', label: t('generateForm.roleIntro') }, { value: 'r1', label: t('generateForm.roleR1') }, { value: 'r2', label: t('generateForm.roleR2') }, diff --git a/src/components/features/generate/GenerateResults.tsx b/src/components/features/generate/GenerateResults.tsx index 2385128e8..7a03fa408 100644 --- a/src/components/features/generate/GenerateResults.tsx +++ b/src/components/features/generate/GenerateResults.tsx @@ -1,6 +1,6 @@ import { useState } from 'react'; import { useTranslation } from 'react-i18next'; -import { Target, Shield, Swords, AlertTriangle, MessageSquare, Save, GitBranch, Sparkles, Clipboard, Undo2, Flame } from 'lucide-react'; +import { Target, Shield, Swords, AlertTriangle, MessageSquare, GitBranch, Sparkles, Undo2, Flame } from 'lucide-react'; import { Badge } from '@/components/ui/badge'; import { Button } from '@/components/ui/button'; import { SectionCard } from '@/components/ui/section-card'; @@ -11,6 +11,8 @@ import { toast } from 'sonner'; import { formatEuropeanDateTime } from '@/lib/date-format'; import { ExportToolbar } from '@/components/features/ExportToolbar'; import { ResultHeader } from '@/components/features/tools/ResultHeader'; +import { CopyResultButton, SaveResultAction } from '@/components/features/tools/result-actions'; +import { caseText } from '@/components/features/tools/result-text'; import { ResearchedSources } from '@/components/features/tools/ResearchedSources'; import { GoogleDriveAuthModal } from '@/components/features/export/GoogleDriveAuthModal'; import { SourceQualityList } from '@/components/features/shared/SourceQualityList'; @@ -25,6 +27,8 @@ interface GenerateResultsProps { regeneratingSection?: string | null; onDiscuss?: () => void; onSave?: () => void; + /** The case is already in History; shows “Saved” instead of Save. */ + saved?: boolean; onUndo?: () => void; onImportFlowchart?: () => void; onPracticeCrossfire?: () => void; @@ -83,7 +87,7 @@ function formatDuration(ms: number | undefined, t: (key: string, options?: Recor return t('generateResults.durationS', { value }); } -export function GenerateResults({ result, discoveredSources, onRegenerateSection, regeneratingSection = null, onDiscuss, onSave, onUndo, onImportFlowchart, onPracticeCrossfire }: GenerateResultsProps) { +export function GenerateResults({ result, discoveredSources, onRegenerateSection, regeneratingSection = null, onDiscuss, onSave, saved = false, onUndo, onImportFlowchart, onPracticeCrossfire }: GenerateResultsProps) { const { t } = useTranslation(); const [isExportingGDocs, setIsExportingGDocs] = useState(false); const [showDriveAuth, setShowDriveAuth] = useState(false); @@ -94,36 +98,6 @@ export function GenerateResults({ result, discoveredSources, onRegenerateSection regenerateDisabled: regeneratingSection !== null, }); - const handleCopyToClipboard = async () => { - const lines: string[] = []; - lines.push(`THESIS: ${result.caseStructure?.thesis || ''}`); - lines.push(`FRAMEWORK: ${result.caseStructure?.framework || ''}`); - lines.push(''); - result.contentions?.forEach((c, i) => { - lines.push(`CONTENTION #${i + 1}: ${c.claim}`); - lines.push(` Reasoning: ${c.reasoning}`); - lines.push(` Evidence: ${c.evidence}`); - lines.push(` Impact: ${c.impact}`); - lines.push(''); - }); - if (result.counters?.length) { - lines.push('PREPARED COUNTERS:'); - result.counters.forEach(c => { - lines.push(` vs "${c.targetArgument}": ${c.counterpoint}`); - }); - } - if (!navigator.clipboard?.writeText) { - toast.error(t('generateResults.copyFailed', 'Unable to copy to clipboard')); - return; - } - try { - await navigator.clipboard.writeText(lines.join('\n')); - toast.success(t('generateResults.copiedToClipboard', 'Copied to clipboard')); - } catch { - toast.error(t('generateResults.copyFailed', 'Unable to copy to clipboard')); - } - }; - const handleGoogleDocsExport = async () => { setIsExportingGDocs(true); try { @@ -208,13 +182,8 @@ export function GenerateResults({ result, discoveredSources, onRegenerateSection onDocx={handleDocxExport} onGoogleDocs={handleGoogleDocsExport} isExportingGDocs={isExportingGDocs} - extraExports={( - - )} - secondaryActions={(onSave || onDiscuss || onImportFlowchart || onUndo || onPracticeCrossfire) ? ( + extraExports={ caseText(result)} />} + secondaryActions={(onSave || saved || onDiscuss || onImportFlowchart || onUndo || onPracticeCrossfire) ? ( <> {onPracticeCrossfire && ( )} - {onSave && ( - - )} + {onDiscuss && ( + )} + {t('sparring.round.readyNote', 'Your clock starts when you start speaking or typing.')} + + )} + {!judging && userTurn && !awaitingStart && (
- @@ -234,14 +268,19 @@ export function SparringRound({

{roleGuidance}

- {t('sparring.round.wordCount', '{{count}} words', { count: currentWordCount })} + {t('sparring.round.wordCount', { count: currentWordCount, formatted: formatNumber(currentWordCount) })} {voice && void stopCapture()} disabled={timeExpired} />}