From d9f56954ee7388a64b1e9e20f6b504e23d967cbb Mon Sep 17 00:00:00 2001 From: Adam Date: Fri, 23 Oct 2026 00:05:55 +0000 Subject: [PATCH] Start a debate on the first Generate instead of asking (MD-10, design choice) With no Current Debate, Generate creates a debate for its motion and says so in a toast, instead of opening "Start a debate with this motion?". The strip offers Use once beside Start debate, so a one-off run is still a choice. A second submit while the debate is being created does nothing, and a failed create falls back to the dialog. Other tools keep the dialog. Adam to confirm. This commit holds the whole change, including tests, recipes, docs and the production canary, so it reverts alone. Co-Authored-By: Claude Opus 5.5 --- .claude/skills/verify-debatelab/SKILL.md | 6 +-- .../verify-debatelab/features/README.md | 2 +- .../features/case-generation.md | 6 +-- .../features/current-debate.md | 7 +-- .../recipes/case-generation.spec.ts | 8 ++-- .../recipes/current-debate.spec.ts | 18 ++++---- .../recipes/model-defaults.spec.ts | 5 ++- docs/architecture.md | 2 +- docs/testing.md | 2 +- e2e/first-run.spec.ts | 16 ++++--- e2e/full-stack/auth.spec.ts | 5 ++- e2e/full-stack/case-generation.spec.ts | 5 ++- e2e/full-stack/first-run.spec.ts | 13 +++--- e2e/full-stack/stack.ts | 10 +++++ e2e/live/research.spec.ts | 5 ++- e2e/model-matrix/generate.spec.ts | 5 ++- e2e/production/canary.spec.ts | 20 +++++++-- .../features/debate/CurrentDebateStrip.tsx | 23 ++++++++-- .../features/debate/DebateMotionGate.tsx | 45 ++++++++++++++++--- src/locales/en.json | 6 ++- src/locales/es.json | 6 ++- src/locales/hu.json | 6 ++- src/pages/GeneratePage.tsx | 7 +-- src/test/debate/debate-gates.test.tsx | 41 ++++++++++++++++- 24 files changed, 198 insertions(+), 71 deletions(-) diff --git a/.claude/skills/verify-debatelab/SKILL.md b/.claude/skills/verify-debatelab/SKILL.md index 2baa3efcc..814216366 100644 --- a/.claude/skills/verify-debatelab/SKILL.md +++ b/.claude/skills/verify-debatelab/SKILL.md @@ -78,7 +78,7 @@ It also prints the commit the stack started at and whether there are uncommitted **Scripted (preferred for proof).** Recipes are Playwright specs in `.claude/skills/verify-debatelab/recipes/`. They import from `./fixtures`, which re-exports `e2e/full-stack/stack.ts` and `e2e/helpers/voice.ts` and adds the following: - A fresh demo-code user per test, signed in through the real Login page and `demo-code-login`, and deleted afterwards. Use the `signedIn` fixture for the page and `user` for `user.email` and `await user.userId()`. The user has seen the onboarding tour and has no Current Debate. -- The Current Debate helpers: `startDebate(page, motion)` starts one from the header, `chooseForMotion(page, "create" | "once")` answers the motion dialog, and `debateControl(page)` is the visible header control on either viewport. +- The Current Debate helpers: `startDebate(page, motion)` starts one from the header, `chooseForMotion(page, "create" | "once")` answers the motion dialog, `chooseOneOff(page)` picks one-off mode on the debate strip before a submit, and `debateControl(page)` is the visible header control on either viewport. - The voice helpers: `stubSpeech(page)` installs a scripted microphone, recogniser and browser voice; `say(page, words)` speaks; `spokenByBrowser(page)` reads back what the browser voice said. - `admin`: a service-role client for reading rows as a second view. - `fake` / `providerCall(feature)`: what the Edge Function actually sent upstream. Use `fake.fail(feature, status)` to force a provider error and `fake.truncate(feature)` (fake mode only) to cut the next reply off at its length cap. Each call records the `maxTokens` it asked for. @@ -88,7 +88,7 @@ It also prints the commit the stack started at and whether there are uncommitted - `evidence`: `step(line)`, `aria(name, locator?)`, `shot(name)` and `json(name, value)`. - Automatic failure on any uncaught page error, horizontal overflow, or remote Supabase request. -Every result, source and practice round belongs to a Current Debate or is explicitly one-off. A tool given a motion when no debate exists opens `Start a debate with this motion?`; a motion that differs from the Current Debate's opens `This is a different motion`. Both open after the submit click and before the AI request, so create the `aiResponse()` promise, click, answer with `chooseForMotion`, then await. Or call `startDebate` first, after which tools prefill its motion and open no dialog. The Library's `Add Source` is disabled until a debate exists. See `features/current-debate.md`. +Every result, source and practice round belongs to a Current Debate or is explicitly one-off. A tool given a motion when no debate exists opens `Start a debate with this motion?`, except Generate, which creates the debate for its motion without asking and shows the toast `Started a debate for this motion` (to keep a Generate run one-off, call `chooseOneOff(page)` before clicking `Generate Debate Case`); a motion that differs from the Current Debate's opens `This is a different motion`. Both open after the submit click and before the AI request, so create the `aiResponse()` promise, click, answer with `chooseForMotion`, then await. Or call `startDebate` first, after which tools prefill its motion and open no dialog. The Library's `Add Source` is disabled until a debate exists. See `features/current-debate.md`. ```bash $H drive all # every recipe, both viewports @@ -105,7 +105,7 @@ Fake mode also runs `e2e/full-stack/`. Live mode runs the recipes, `e2e/live/`, To prove a feature without a recipe, copy `recipes/case-generation.spec.ts` to `recipes/.spec.ts` and use the selectors from the matching file in `features/`. Recipes are maintained files. Keep the ones worth keeping. -**Interactive.** Run `$H user` to mint a code. It prints `DEMO_CODE=...`, and `down` deletes that user. Then point a browser tool (Playwright MCP `browser_navigate` etc.) at `http://127.0.0.1:18081/login`, choose the `Demo code` tab, fill the `Demo code` textbox, and click `Sign in with demo code`. Save your own screenshots into `.verify/artifacts//`. A new account shows the onboarding tour on `/app`. Its first step offers `Start debate`; to skip the tour, click `Skip Tour` (`[data-test-id="button-skip"]`). Then start a debate from the header (`Start debate`, test id `current-debate-desktop` or `current-debate-mobile`) or answer the motion dialog the first tool opens: `Start debate` files the work in a new debate, `Not now, use it once` keeps it one-off. Interactive runs have no remote-Supabase guard. Doctor's local-mode check is what protects you. +**Interactive.** Run `$H user` to mint a code. It prints `DEMO_CODE=...`, and `down` deletes that user. Then point a browser tool (Playwright MCP `browser_navigate` etc.) at `http://127.0.0.1:18081/login`, choose the `Demo code` tab, fill the `Demo code` textbox, and click `Sign in with demo code`. Save your own screenshots into `.verify/artifacts//`. A new account shows the onboarding tour on `/app`. Its first step offers `Start debate`; to skip the tour, click `Skip Tour` (`[data-test-id="button-skip"]`). Then start a debate from the header (`Start debate`, test id `current-debate-desktop` or `current-debate-mobile`) or answer the motion dialog the first tool opens: `Start debate` files the work in a new debate, `Not now, use it once` keeps it one-off. Generate skips that dialog and starts the debate itself; its strip's `Use once` keeps a run one-off. Interactive runs have no remote-Supabase guard. Doctor's local-mode check is what protects you. Selectors: use accessible names and roles as the feature files list them (`getByRole("button", { name: "Generate Debate Case" })`). The exceptions use `data-testid`: Crossfire's controls, the Current Debate control, strip and dashboard, and the voice controls (`voice-mode`, `voice-mic`). Use `exact: true` for short names such as `Start` or `Save`, because the header's `Start debate` also matches `Start`. Never use coordinates. diff --git a/.claude/skills/verify-debatelab/features/README.md b/.claude/skills/verify-debatelab/features/README.md index 24b834fdd..d4873d66d 100644 --- a/.claude/skills/verify-debatelab/features/README.md +++ b/.claude/skills/verify-debatelab/features/README.md @@ -7,7 +7,7 @@ This directory is the maintained source for verifying DebateLab's user-facing be - `bin/verify.sh up` has printed `READY http://127.0.0.1:18081`, and `bin/verify.sh doctor` exits 0. - The browser client is the `up` instance at `http://127.0.0.1:18081`. Never use the `debatelab` container on `:8080` or a plain `npm run dev`, because both reach production. - Each scripted test signs in with its own fresh demo-code user (a Pro account with a daily limit of 999) and deletes it afterwards. The fixture marks the onboarding tour as seen, and the user starts with no Current Debate. -- Most tools work inside a Current Debate (see [current-debate](./current-debate.md)). Start one with `startDebate(page, motion)` first, or answer the motion dialog that opens after submit with `chooseForMotion(page, "create" | "once")`. The Library's `Add Source` stays disabled until a debate exists. +- Most tools work inside a Current Debate (see [current-debate](./current-debate.md)). Start one with `startDebate(page, motion)` first, or answer the motion dialog that opens after submit with `chooseForMotion(page, "create" | "once")`. Generate opens no dialog when there is no debate: it starts one for its motion, so call `chooseOneOff(page)` before submitting to keep a run one-off. The Library's `Add Source` stays disabled until a debate exists. - In fake mode, model answers come from the fake provider and contain the marker `FAKE-PROVIDER`. In live mode (`up --live`), every chat call goes to real OpenRouter pinned to `meta/muse-spark-1.3-contributor`, research uses real Exa, and answers are real model text. - The feature files quote fake-mode text such as `FAKE-PROVIDER case thesis`. Recipes never assert it: they read the `ai` response body with `aiResponse()` and check that its fields are on screen, so the same recipe passes in both modes. diff --git a/.claude/skills/verify-debatelab/features/case-generation.md b/.claude/skills/verify-debatelab/features/case-generation.md index c2dfbdfea..a6560df5f 100644 --- a/.claude/skills/verify-debatelab/features/case-generation.md +++ b/.claude/skills/verify-debatelab/features/case-generation.md @@ -8,7 +8,7 @@ A signed-in user enters a motion on Generate and receives a structured debate ca - `gen-grounded` includes active Library sources in the system prompt (see [library-sources](./library-sources.md)). - `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-save` saves a one-off case (`Use once` chosen on the debate strip before generating) 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
…`; 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`). - `gen-flowchart` builds a flowchart from the Current Debate's case. `/app/flowchart` opens the template; the button `Build from current case` replaces it with the thesis, one node per contention (claim and evidence), the predicted attacks, and the counters, fitted to the canvas. Node titles the app wrote (`Thesis`, `Argument 1`) follow the UI language. @@ -25,10 +25,10 @@ Preconditions: - Doctor passes. The recipe `recipes/case-generation.spec.ts` covers every sub-feature below: `bin/verify.sh drive recipes/case-generation`. - **Start the debate.** `await startDebate(page, motion)`. On `/app/generate`, the textbox named `Motion / Topic *` already holds the motion. -- **Generate.** Click the button named `Generate Debate Case`. The text `FAKE-PROVIDER case thesis` and `FAKE-PROVIDER contention 3` appear, along with a toast `Generate complete`. Without a Current Debate, fill the motion yourself and answer the dialog `Start a debate with this motion?` with `chooseForMotion`. +- **Generate.** Click the button named `Generate Debate Case`. The text `FAKE-PROVIDER case thesis` and `FAKE-PROVIDER contention 3` appear, along with a toast `Generate complete`. Without a Current Debate, fill the motion yourself: Generate starts a debate for it without a dialog and shows the toast `Started a debate for this motion`. To keep the run one-off, call `chooseOneOff(page)` before clicking. - **Provider side effect.** `providerCall("generate")`'s `user` contains `Motion: `. `ai_requests` for the user contains `{feature: "generate", status: "success"}`. - **Filed in the debate.** `cases` for the user is one row whose `debates(motion)` is the motion. On a fresh browser context, `/app/history` shows it as a heading in the `Cases` tab panel, and `Load` reopens it. -- **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. +- **Save (one-off).** With no debate, call `chooseOneOff(page)`, then generate. 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. An `alert` holding `Generation Failed` stays above the results with a `Retry` button; clicking `Retry` succeeds and removes it. - **Cut off.** Call `await fake.truncate("generate")`, start the debate, and generate. The first reply stops halfway with `finish_reason: "length"`; the Edge Function retries once with a larger cap, and the case appears with no error. `fake.requests()` has two `generate` entries, the second with a larger `maxTokens`. Fake mode only. - **Cancel.** While a generation runs, the results column shows `Writing your case…` and a `Cancel` button. Cancel stops the request and shows no error. diff --git a/.claude/skills/verify-debatelab/features/current-debate.md b/.claude/skills/verify-debatelab/features/current-debate.md index 77128c8eb..c74d79b91 100644 --- a/.claude/skills/verify-debatelab/features/current-debate.md +++ b/.claude/skills/verify-debatelab/features/current-debate.md @@ -5,7 +5,7 @@ Each student works in one Current Debate at a time: a motion with its own Librar ## Sub-features - `debate-start` starts a debate from the header. The control's `aria-label` becomes `Current debate: `, and `debates` and `current_debates` get the row. -- `debate-offer` opens the dialog `Start a debate with this motion?` when a tool runs with a motion and no Current Debate exists. `Start debate` creates the debate and the run lands in it. `Not now, use it once` runs without one. +- `debate-autostart`: Generate with no Current Debate creates one for its motion without asking, files the case in it, and shows the toast `Started a debate for this motion`. Its strip offers `Use once` beside `Start debate`. Other tools still open the dialog `Start a debate with this motion?`: `Start debate` creates the debate and the run lands in it, `Not now, use it once` runs without one. - `debate-once` opens the dialog `This is a different motion` when a Current Debate exists and the tool has another motion. `Use once without saving to a debate` runs with no debate material and saves nothing to a debate. `Create a new debate for this motion` switches to a new one. - `debate-scope` keeps sources and results in their debate. The Library, the dashboard at `/app/debate`, History, and every AI request's system prompt see only the Current Debate's material. - `debate-switch` switches back to an earlier debate through `Switch debate`, and the Library and History follow. @@ -24,11 +24,12 @@ Each student works in one Current Debate at a time: a motion with its own Librar Preconditions: - Doctor passes. The recipe `recipes/current-debate.spec.ts` covers every sub-feature: `bin/verify.sh drive recipes/current-debate`. The maintained specs are `e2e/full-stack/current-debate.spec.ts`, `first-run.spec.ts`, and `debate-context.spec.ts`. -- Helpers, re-exported from `recipes/fixtures.ts`: `startDebate(page, motion)`, `chooseForMotion(page, "create" | "once")`, and `debateControl(page)` (the visible header control on either viewport). +- Helpers, re-exported from `recipes/fixtures.ts`: `startDebate(page, motion)`, `chooseForMotion(page, "create" | "once")`, `chooseOneOff(page)` (one-off mode from the debate strip, before a Generate submit), and `debateControl(page)` (the visible header control on either viewport). - **Start.** `await startDebate(page, motion)`. It clicks the header control, fills the dialog field labelled `Motion`, and clicks the dialog's `Start debate`. End state: `debateControl(page)` has `aria-label` `Current debate: `, and `current_debates.debate_id` for the user is that `debates` row. - **Scope.** Add a source on `/app/library` with a unique marker (see [library-sources](./library-sources.md)); the test id `library-debate` names the motion. Run Brainstorm or Generate: the motion field already holds the debate's motion and no dialog opens. End state: the newest `providerCalls()` entry for the feature has this debate's marker in `system` and not another debate's. `sources.debate_id`, `cases.debate_id`, and `brainstorms.debate_id` point at the debate they were made in. -- **Offer.** With no debate, fill a tool's motion and submit. The dialog `Start a debate with this motion?` contains the motion. Click its `Start debate`. End state: the result shows, the strip reads `Working in: `, and `debates` holds exactly that motion. +- **Offer.** With no debate, fill Brainstorm's motion and submit. The dialog `Start a debate with this motion?` contains the motion. Click its `Start debate` (`chooseForMotion(page, "create")`). +- **Autostart.** With no debate, fill Generate's motion and click `Generate Debate Case`. No dialog opens; the toast `Started a debate for this motion` shows. End state: the result shows, the strip reads `Working in: `, and `debates` holds exactly that motion. - **Use once.** With a debate, submit a different motion. The dialog `This is a different motion` opens. `chooseForMotion(page, "once")`. End state: the provider call's `system` has no debate marker, and the header still names the old debate. - **Dashboard.** `/app/debate`: `current-debate-motion` has the motion, `debate-side-pro` reads `In favor`, `debate-side-con` reads `Against`, and `debate-sources` and `debate-recent` list only this debate's work. - **Switch.** Click `debateControl(page)`, then the dialog's `Switch debate`, then `Switch to `. End state: the Library shows only that debate's sources. In History, the `Brainstorms` and `Cases` tabs show only its results. diff --git a/.claude/skills/verify-debatelab/recipes/case-generation.spec.ts b/.claude/skills/verify-debatelab/recipes/case-generation.spec.ts index e09c0baf8..13d73c809 100644 --- a/.claude/skills/verify-debatelab/recipes/case-generation.spec.ts +++ b/.claude/skills/verify-debatelab/recipes/case-generation.spec.ts @@ -1,5 +1,5 @@ import { readFile } from "node:fs/promises"; -import { admin, AI_TIMEOUT, aiResponse, chooseForMotion, type Evidence, expect, expectProviderCalls, fake, LIVE, signIn, startDebate, test } from "./fixtures"; +import { admin, AI_TIMEOUT, aiResponse, type Evidence, expect, expectProviderCalls, fake, LIVE, signIn, startDebate, test, chooseOneOff } from "./fixtures"; // Feature map: features/case-generation.md. const motion = "This House would make public transport free at the point of use"; @@ -94,11 +94,11 @@ test("gen-save: a one-off case is saved under a title and reopens from History o await page.goto("/app/generate"); await expect(page.getByRole("heading", { name: "Ready to Generate" })).toBeVisible(); await page.getByRole("textbox", { name: "Motion / Topic *" }).fill(motion); + // No Current Debate: use the motion once, so the case belongs to no debate until saved. + await chooseOneOff(page); const response = aiResponse(page, "generate"); await page.getByRole("button", { name: "Generate Debate Case" }).click(); - // No Current Debate: decline the offer, so the case belongs to no debate until saved. - await chooseForMotion(page, "once"); - await evidence.step(`Generate: motion "${motion}", chose Not now, use it once`); + await evidence.step(`Generate: motion "${motion}", chose Use once`); const output = (await response).output as unknown as CaseOutput; await expect(page.getByText(output.caseStructure.thesis).first()).toBeVisible(); await evidence.shot("01-generated-one-off"); diff --git a/.claude/skills/verify-debatelab/recipes/current-debate.spec.ts b/.claude/skills/verify-debatelab/recipes/current-debate.spec.ts index fadeb463a..d44fbf5e6 100644 --- a/.claude/skills/verify-debatelab/recipes/current-debate.spec.ts +++ b/.claude/skills/verify-debatelab/recipes/current-debate.spec.ts @@ -152,20 +152,22 @@ test("debate-start, debate-scope, debate-once, debate-switch: two debates keep t await evidence.json("07-rows", { debates, sources, cases, brainstorms, current }); }); -test("debate-offer: with no Current Debate, a tool offers to start one for the motion it was given", async ({ signedIn: page, user, evidence }) => { +test("debate-autostart: with no Current Debate, Generate starts one for its motion without asking", async ({ signedIn: page, user, evidence }) => { const motion = "This House would abolish zoos"; + // Generate starts a debate only once the debate list has loaded; until then it asks. + const listed = page.waitForResponse((response) => response.url().includes("/rest/v1/current_debates") && response.request().method() === "GET"); await page.goto("/app/generate"); + await listed; await page.getByRole("textbox", { name: "Motion / Topic *" }).fill(motion); const run = aiResponse(page, "generate"); await page.getByRole("button", { name: "Generate Debate Case" }).click(); - const offer = page.getByRole("dialog", { name: "Start a debate with this motion?" }); - await expect(offer).toContainText(motion); - await evidence.shot("01-offer"); - await evidence.aria("01-offer", offer); - await offer.getByRole("button", { name: "Start debate" }).click(); - await expect(offer).toBeHidden(); - await evidence.step("Generate with no Current Debate → Start a debate with this motion? → Start debate"); + // Design choice (MD-10): no motion dialog; a toast says the debate was started. + const started = page.getByText("Started a debate for this motion").first(); + await expect(started).toBeVisible(); + await expect(page.getByRole("dialog")).toHaveCount(0); + await evidence.shot("01-started-toast"); + await evidence.step("Generate with no Current Debate → toast Started a debate for this motion, no dialog"); const body = await run; const thesis = (body.output as { caseStructure: { thesis: string } }).caseStructure.thesis; diff --git a/.claude/skills/verify-debatelab/recipes/model-defaults.spec.ts b/.claude/skills/verify-debatelab/recipes/model-defaults.spec.ts index bd90b9168..b95a5704e 100644 --- a/.claude/skills/verify-debatelab/recipes/model-defaults.spec.ts +++ b/.claude/skills/verify-debatelab/recipes/model-defaults.spec.ts @@ -1,5 +1,5 @@ import type { Page } from "@playwright/test"; -import { admin, AI_TIMEOUT, aiResponse, chooseForMotion, expect, providerCalls, test } from "./fixtures"; +import { admin, AI_TIMEOUT, aiResponse, expect, providerCalls, test, chooseOneOff } from "./fixtures"; // Feature map: features/model-defaults.md. const motion = "This House would make public transport free at the point of use"; @@ -19,9 +19,10 @@ async function updateSettings(userId: string, values: Record) { async function generateOnce(page: Page) { await page.getByRole("textbox", { name: "Motion / Topic *" }).fill(motion); + // Generate starts a debate for a new motion, so choose one-off before submitting. + await chooseOneOff(page); const response = aiResponse(page, "generate"); await page.getByRole("button", { name: "Generate Debate Case" }).click(); - await chooseForMotion(page, "once"); return response; } diff --git a/docs/architecture.md b/docs/architecture.md index 41aaeffbe..fe922df23 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -31,7 +31,7 @@ The repository is an npm workspace. The web app is the root package and still li [`src/lib/stores/session-store.ts`](../src/lib/stores/session-store.ts) owns active AI results, conversations, saved work, sources, folders, flowcharts, autosave state, and persisted form state. It persists selected local state and hydrates authenticated rows from Supabase. -[`src/lib/stores/debate-store.ts`](../src/lib/stores/debate-store.ts) owns the signed-in user's debates, the Current Debate pointer, session-only one-off mode, and the shared Switch and Start dialogs. Every source and every automatically recorded result carries a `debateId`; one-off work and unsorted legacy rows have none. The Library, History, the AI source context, and discovered-source autosave all read from one debate at a time, so material never crosses debates. Motion-bearing tools confirm a motion that differs from the Current Debate (switch, create, or use once) before submitting; with no Current Debate they instead offer to start one from the typed motion. The first-run tour starts with the Current Debate card, then points at Library and Generate. The app blocks with Continue, Start new, or Switch after five days without work in the Current Debate. +[`src/lib/stores/debate-store.ts`](../src/lib/stores/debate-store.ts) owns the signed-in user's debates, the Current Debate pointer, session-only one-off mode, and the shared Switch and Start dialogs. Every source and every automatically recorded result carries a `debateId`; one-off work and unsorted legacy rows have none. The Library, History, the AI source context, and discovered-source autosave all read from one debate at a time, so material never crosses debates. Motion-bearing tools confirm a motion that differs from the Current Debate (switch, create, or use once) before submitting; with no Current Debate they instead offer to start one from the typed motion, except Generate, which starts it without asking and says so in a toast (its debate strip offers Use once). The first-run tour starts with the Current Debate card, then points at Library and Generate. The app blocks with Continue, Start new, or Switch after five days without work in the Current Debate. [`src/lib/stores/settings-store.ts`](../src/lib/stores/settings-store.ts) owns language, theme, feature provider settings, prompts, and research preferences. It persists locally and synchronizes authenticated settings. diff --git a/docs/testing.md b/docs/testing.md index d7835a81d..7b9f7ac98 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -196,7 +196,7 @@ Each test signs in through the Login page with a fresh demo code and deletes tha - sign-in, reload, sign-out, a rejected code, and an expired token that refreshes before an AI request; - case generation grounded in a 250 KB Library source, provider failure with retry, a zero quota, and a saved case that a fresh browser loads from History; - Attack, Brainstorm, Factoids, streamed Ask (it fails if the client falls back to the non-streaming path), and Ask with citations; -- a fresh account's first run in English and Hungarian: the tour starts the first debate, the language picked before sign-up survives the first sign-in, and a tool offers a debate from the typed motion when none is current; +- a fresh account's first run in English and Hungarian: the tour starts the first debate, the language picked before sign-up survives the first sign-in, and the first Generate starts a debate from the typed motion when none is current; - Current Debate context: every request carries the debate's motion, instructions, and sources; Review drops a source and pins a saved case for exactly one request; the pin is stored; Crossfire leaves instructions out; and one-off work sends nothing; - a judged Lincoln-Douglas sparring round and a Crossfire debrief after a Jev signal attaches to an answer; - spoken Sparring and Crossfire rounds against the real `ai` and `voice` functions with no ElevenLabs key: the opponent prompt carries the voice rules, the reply comes back clean with signed segments, `voice` answers `voice_unavailable`, the browser voice reads the clean text, the judge gets the measured facts, and the saved round holds no audio tag (the recogniser and the browser voice are scripted); diff --git a/e2e/first-run.spec.ts b/e2e/first-run.spec.ts index 9e8bf45f5..71ac1b320 100644 --- a/e2e/first-run.spec.ts +++ b/e2e/first-run.spec.ts @@ -18,7 +18,7 @@ const copy = { finish: 'Get Started!', generateMotion: 'Motion / Topic *', generateSubmit: 'Generate Debate Case', - offerTitle: 'Start a debate with this motion?', + startedToast: 'Started a debate for this motion', workingIn: 'Working in:', tourMotion: 'This House would ban homework', gateMotion: 'This House would abolish zoos', @@ -34,7 +34,7 @@ const copy = { finish: 'Kezdjük!', generateMotion: 'Indítvány / téma *', generateSubmit: 'Vitaeset generálása', - offerTitle: 'Indítasz egy vitát ezzel a tétellel?', + startedToast: 'Vitát indítottunk ezzel a tétellel', workingIn: 'Munka ebben:', tourMotion: 'Ez a Ház betiltaná a házi feladatot', gateMotion: 'Ez a Ház bezárná az állatkerteket', @@ -162,17 +162,19 @@ for (const locale of ['en', 'hu'] as const) { await expect(tooltip).toHaveCount(0); }); - test(`[${locale}] a tool offers a first debate from the typed motion instead of the switch dialog`, locale === 'en' ? { tag: '@smoke' } : {}, async ({ page }) => { + test(`[${locale}] the first Generate starts a debate from the typed motion without asking`, locale === 'en' ? { tag: '@smoke' } : {}, async ({ page }) => { const backend = await freshStart(page, locale, true); + // Generate starts a debate only once the debate list has loaded; until then it asks. + const listed = page.waitForResponse((response) => response.url().includes('/rest/v1/current_debates') && response.request().method() === 'GET'); await page.goto('/app/generate', { waitUntil: 'domcontentloaded' }); + await listed; await page.getByRole('textbox', { name: text.generateMotion }).fill(text.gateMotion); await page.getByRole('button', { name: text.generateSubmit }).click(); - const offer = page.getByRole('dialog', { name: text.offerTitle }); - await expect(offer).toContainText(text.gateMotion); - await offer.getByRole('button', { name: text.startDebate }).click(); - await expect(offer).toBeHidden(); + // Design choice (MD-10): no motion dialog; a toast says the debate was started. + await expect(page.getByText(text.startedToast).first()).toBeVisible(); + await expect(page.getByRole('dialog')).toHaveCount(0); await expect(page.getByTestId('current-debate-strip')).toContainText(`${text.workingIn} ${text.gateMotion}`); expect(backend.debates.map((debate) => debate.motion)).toEqual([text.gateMotion]); diff --git a/e2e/full-stack/auth.spec.ts b/e2e/full-stack/auth.spec.ts index d82825b71..f08da681d 100644 --- a/e2e/full-stack/auth.spec.ts +++ b/e2e/full-stack/auth.spec.ts @@ -1,4 +1,4 @@ -import { admin, chooseForMotion, expect, expectResult, MARK, stackReady, test } from "./stack"; +import { admin, expect, expectResult, MARK, stackReady, test, chooseOneOff } from "./stack"; test.skip(!stackReady, "Local full stack is required (run ./.superset/db-gate.sh)"); @@ -39,7 +39,8 @@ test("an expired access token refreshes before an AI request", async ({ signedIn }); await page.goto("/app/generate"); await page.getByRole("textbox", { name: "Motion / Topic *" }).fill("This House would make public transport free"); + // Generate starts a debate for a new motion, so choose one-off before submitting. + await chooseOneOff(page); await page.getByRole("button", { name: "Generate Debate Case" }).click(); - await chooseForMotion(page, "once"); await expectResult(page, `${MARK} case thesis`); }); diff --git a/e2e/full-stack/case-generation.spec.ts b/e2e/full-stack/case-generation.spec.ts index e1d85a3a2..50e7e9a22 100644 --- a/e2e/full-stack/case-generation.spec.ts +++ b/e2e/full-stack/case-generation.spec.ts @@ -1,5 +1,5 @@ import { readFile } from "node:fs/promises"; -import { admin, chooseForMotion, expect, expectResult, fake, MARK, providerCall, signIn, stackReady, startDebate, test } from "./stack"; +import { admin, expect, expectResult, fake, MARK, providerCall, signIn, stackReady, startDebate, test, chooseOneOff } from "./stack"; test.skip(!stackReady, "Local full stack is required (run ./.superset/db-gate.sh)"); @@ -67,8 +67,9 @@ test("an account without quota is told why generation is blocked", async ({ sign test("a saved one-off case persists to the account and reloads from History", async ({ signedIn: page, user }) => { await page.goto("/app/generate"); await page.getByRole("textbox", { name: "Motion / Topic *" }).fill(motion); + // Generate starts a debate for a new motion, so choose one-off before submitting. + await chooseOneOff(page); await page.getByRole("button", { name: "Generate Debate Case" }).click(); - await chooseForMotion(page, "once"); await expectResult(page, `${MARK} case thesis`); const title = `Saved case ${Date.now()}`; diff --git a/e2e/full-stack/first-run.spec.ts b/e2e/full-stack/first-run.spec.ts index 509e67a93..1282e7154 100644 --- a/e2e/full-stack/first-run.spec.ts +++ b/e2e/full-stack/first-run.spec.ts @@ -20,7 +20,7 @@ const copy = { skip: "Skip Tour", generateMotion: "Motion / Topic *", generateSubmit: "Generate Debate Case", - offerTitle: "Start a debate with this motion?", + startedToast: "Started a debate for this motion", workingIn: "Working in:", tourMotion: "This House would ban homework", gateMotion: "This House would abolish zoos", @@ -39,7 +39,7 @@ const copy = { skip: "Túra kihagyása", generateMotion: "Indítvány / téma *", generateSubmit: "Vitaeset generálása", - offerTitle: "Indítasz egy vitát ezzel a tétellel?", + startedToast: "Vitát indítottunk ezzel a tétellel", workingIn: "Munka ebben:", tourMotion: "Ez a Ház betiltaná a házi feladatot", gateMotion: "Ez a Ház bezárná az állatkerteket", @@ -133,7 +133,7 @@ for (const locale of ["en", "hu"] as const) { }).toEqual([debates[0].id]); }); - test(`[${locale}] a fresh account that skips the tour is offered a debate from its motion`, async ({ page, user }) => { + test(`[${locale}] a fresh account that skips the tour gets a debate from its first motion`, async ({ page, user }) => { test.setTimeout(90_000); await signUpFresh(page, user, locale); await welcomeTourSettled(page); @@ -144,10 +144,9 @@ for (const locale of ["en", "hu"] as const) { await page.getByRole("textbox", { name: text.generateMotion }).fill(text.gateMotion); await page.getByRole("button", { name: text.generateSubmit }).click(); - const offer = page.getByRole("dialog", { name: text.offerTitle }); - await expect(offer).toContainText(text.gateMotion); - await offer.getByRole("button", { name: text.startDebate }).click(); - await expect(offer).toBeHidden(); + // Design choice (MD-10): no motion dialog; a toast says the debate was started. + await expect(page.getByText(text.startedToast).first()).toBeVisible(); + await expect(page.getByRole("dialog")).toHaveCount(0); await expectResult(page, `${MARK} case thesis`); await expect(page.getByTestId("current-debate-strip")).toContainText(`${text.workingIn} ${text.gateMotion}`); diff --git a/e2e/full-stack/stack.ts b/e2e/full-stack/stack.ts index e47a95432..df53edf7e 100644 --- a/e2e/full-stack/stack.ts +++ b/e2e/full-stack/stack.ts @@ -45,6 +45,16 @@ export async function startDebate(page: Page, motion: string) { await expect(debateControl(page)).toHaveAttribute("aria-label", `Current debate: ${motion}`); } +/** + * Chooses one-off mode from the debate strip before submitting. Generate starts + * a debate for a new motion without asking, so "use once" is chosen up front. + */ +export async function chooseOneOff(page: Page) { + const strip = page.getByTestId("current-debate-strip"); + await strip.getByRole("button", { name: "Use once", exact: true }).click(); + await expect(strip).toContainText("One-off mode"); +} + /** * Answers the motion check after submitting a motion that is not the Current * Debate: the first-debate offer when none is current, otherwise the switch choice. diff --git a/e2e/live/research.spec.ts b/e2e/live/research.spec.ts index fdf7905ee..58c3425a1 100644 --- a/e2e/live/research.spec.ts +++ b/e2e/live/research.spec.ts @@ -1,4 +1,4 @@ -import { admin, chooseForMotion, expect, stackReady, startDebate, test } from "../full-stack/stack"; +import { admin, expect, stackReady, startDebate, test, chooseOneOff } from "../full-stack/stack"; import { aiResponse, expectProviderCallsHealthy, LIVE_TIMEOUT } from "./live"; test.skip(!stackReady, "Live gate is required (LIVE_PROVIDERS=1 ./.superset/db-gate.sh)"); @@ -9,9 +9,10 @@ const httpLink = /^https?:\/\//; test("case generation researches the web and cites what it found", async ({ signedIn: page, user }) => { await page.goto("/app/generate"); await page.getByRole("textbox", { name: "Motion / Topic *" }).fill("This House would make public transport free at the point of use"); + // Generate starts a debate for a new motion, so choose one-off before submitting. + await chooseOneOff(page); const response = aiResponse(page, "generate"); await page.getByRole("button", { name: "Generate Debate Case" }).click(); - await chooseForMotion(page, "once"); const body = await response; // Standard length asks for exactly three contentions. diff --git a/e2e/model-matrix/generate.spec.ts b/e2e/model-matrix/generate.spec.ts index 87e63171c..b72c7be00 100644 --- a/e2e/model-matrix/generate.spec.ts +++ b/e2e/model-matrix/generate.spec.ts @@ -1,5 +1,5 @@ import { MODEL_CATALOG } from "../../supabase/functions/_shared/model-catalog"; -import { chooseForMotion, expect, fake, stackReady, test } from "../full-stack/stack"; +import { expect, fake, stackReady, test, chooseOneOff } from "../full-stack/stack"; import { aiResponse, LIVE_TIMEOUT } from "../live/live"; // Every model a user can pick must generate a case. The regular live suite pins @@ -14,9 +14,10 @@ for (const model of MODEL_CATALOG) { await page.getByRole("combobox", { name: "Select AI model for generate" }).click(); await page.getByRole("option", { name: new RegExp(`^${model.name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}`) }).click(); await page.getByRole("textbox", { name: "Motion / Topic *" }).fill("This House would make public transport free at the point of use"); + // Generate starts a debate for a new motion, so choose one-off before submitting. + await chooseOneOff(page); const response = aiResponse(page, "generate"); await page.getByRole("button", { name: "Generate Debate Case" }).click(); - await chooseForMotion(page, "once"); const body = await response; expect(body.output.contentions).toHaveLength(3); diff --git a/e2e/production/canary.spec.ts b/e2e/production/canary.spec.ts index 028917654..75b7284a7 100644 --- a/e2e/production/canary.spec.ts +++ b/e2e/production/canary.spec.ts @@ -114,12 +114,24 @@ test("the canary account generates a case", async ({ page }) => { await page.goto("/app/generate"); await pickBasicDefault(page, "generate"); await page.getByRole("textbox", { name: "Motion / Topic *" }).fill("This House would make public transport free"); + // Use the motion once so the canary account does not collect production debates. + // Generate starts a debate for a new motion without asking, so choose one-off on the + // debate strip first. A build without that button still asks after the click. + const strip = page.getByTestId("current-debate-strip"); + await expect(strip).toBeVisible(); + const useOnce = strip.getByRole("button", { name: "Use once", exact: true }); + const choseUpFront = await useOnce.isVisible(); + if (choseUpFront) { + await useOnce.click(); + await expect(strip).toContainText("One-off mode"); + } const generated = page.waitForResponse((response) => response.url().endsWith("/functions/v1/ai") && response.request().method() === "POST", { timeout: 150_000 }); await page.getByRole("button", { name: "Generate Debate Case" }).click(); - // Use the motion once so the canary account does not collect production debates. - await page.getByRole("dialog", { name: /^(Start a debate with this motion\?|This is a different motion)$/ }) - .getByRole("button", { name: /^(Not now, use it once|Use once without saving to a debate)$/ }) - .click(); + if (!choseUpFront) { + await page.getByRole("dialog", { name: /^(Start a debate with this motion\?|This is a different motion)$/ }) + .getByRole("button", { name: /^(Not now, use it once|Use once without saving to a debate)$/ }) + .click(); + } const { status, body } = await readAIResponse(await generated); expect(status, "ai generate status").toBe(200); expect(body.output.contentions, "generated contentions").toHaveLength(3); diff --git a/src/components/features/debate/CurrentDebateStrip.tsx b/src/components/features/debate/CurrentDebateStrip.tsx index 5d2ab46da..cb7bae7a2 100644 --- a/src/components/features/debate/CurrentDebateStrip.tsx +++ b/src/components/features/debate/CurrentDebateStrip.tsx @@ -51,7 +51,13 @@ export function useDebateMotionPrefill(getMotion: () => string | undefined, setM } /** Compact “Working in” line directly below each feature title. */ -export function CurrentDebateStrip({ side, hidden = false, className = 'mb-6' }: { side?: DebateSide; hidden?: boolean; className?: string }) { +export function CurrentDebateStrip({ side, hidden = false, autoStart = false, className = 'mb-6' }: { + side?: DebateSide; + hidden?: boolean; + /** This tool starts a debate for its motion on first use, so one-off is a choice made here. */ + autoStart?: boolean; + className?: string; +}) { const { t } = useTranslation(); const debate = useCurrentDebate(); const oneOff = useDebateStore((state) => state.oneOff); @@ -65,7 +71,9 @@ export function CurrentDebateStrip({ side, hidden = false, className = 'mb-6' }: return (
{t('debate.oneOffStrip', 'One-off mode: nothing here uses or saves to a debate.')} - {debate && } + {debate + ? + : }
); } @@ -73,8 +81,15 @@ export function CurrentDebateStrip({ side, hidden = false, className = 'mb-6' }: if (!debate) { return (
- {t('debate.noCurrentStrip', 'No current debate. Results stay one-off until you start one.')} - + + {autoStart + ? t('debate.noCurrentAutoStrip', 'No current debate. Your first result starts one for its motion.') + : t('debate.noCurrentStrip', 'No current debate. Results stay one-off until you start one.')} + +
+ {autoStart && } + +
); } diff --git a/src/components/features/debate/DebateMotionGate.tsx b/src/components/features/debate/DebateMotionGate.tsx index bee0f8700..b7ad0e9e6 100644 --- a/src/components/features/debate/DebateMotionGate.tsx +++ b/src/components/features/debate/DebateMotionGate.tsx @@ -5,7 +5,7 @@ import { Button } from '@/components/ui/button'; import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '@/components/ui/dialog'; import { useToast } from '@/hooks/use-toast'; import { decideMotionTarget } from '@/lib/debate/motion'; -import { useCurrentDebate, useDebateStore, type Debate } from '@/lib/stores/debate-store'; +import { selectCurrentDebate, useCurrentDebate, useDebateStore, type Debate } from '@/lib/stores/debate-store'; /** Where one submission's work belongs. `debateId: null` is one-off work. */ export interface DebateTarget { @@ -23,12 +23,15 @@ interface PendingChoice { * to. A motion other than the Current Debate's asks the user to switch to a * matching debate, create a new one, or use the motion once. With no Current * Debate there is nothing to switch away from, so the user is offered a debate - * for the motion they typed instead. + * for the motion they typed instead, or with `startImplicitly` the debate is + * created without asking and a toast says so. */ -export function useDebateMotionGate(options: { disabled?: boolean } = {}): { +export function useDebateMotionGate(options: { disabled?: boolean; startImplicitly?: boolean } = {}): { /** Calls `proceed` right away when the target is clear, or after the user chooses. Cancelling never calls it. */ run: (motion: string, proceed: (target: DebateTarget) => void) => void; dialog: ReactNode; + /** True while a debate is being created without a dialog. */ + busy: boolean; } { const { t } = useTranslation(); const { toast } = useToast(); @@ -38,6 +41,10 @@ export function useDebateMotionGate(options: { disabled?: boolean } = {}): { const [busy, setBusy] = useState<'switch' | 'create' | null>(null); const pendingRef = useRef(null); const disabled = Boolean(options.disabled); + const startImplicitly = Boolean(options.startImplicitly); + // One submit is one create, even when the button is clicked again before it disables. + const [starting, setStarting] = useState(false); + const startingRef = useRef(false); const run = useCallback((motion: string, proceed: (target: DebateTarget) => void) => { if (disabled) return proceed({ debateId: null }); @@ -45,9 +52,33 @@ export function useDebateMotionGate(options: { disabled?: boolean } = {}): { if (decision.kind === 'attach') return proceed({ debateId: decision.debateId }); if (decision.kind === 'one-off') return proceed({ debateId: null }); const choice = { motion, matches: decision.matches, resolve: (target: DebateTarget | null) => { if (target) proceed(target); } }; - pendingRef.current = choice; - setPending(choice); - }, [disabled]); + const ask = () => { + pendingRef.current = choice; + setPending(choice); + }; + // With no Current Debate and no debate for this motion, start one instead of asking. + // Only once the list has loaded: an empty list before that may hide a Current Debate, + // and creating one would move the server's pointer away from it without asking. + const debates = useDebateStore.getState(); + if (!startImplicitly || !debates.hydrated || decision.matches.length > 0 || selectCurrentDebate(debates)) return ask(); + if (startingRef.current) return; + startingRef.current = true; + setStarting(true); + useDebateStore.getState().createDebate({ motion }) + .then((debate) => { + toast({ + title: t('debate.startedForMotion', 'Started a debate for this motion'), + description: t('debate.startedForMotionDesc', 'Its sources and results stay together. Rename or switch it from Current debate.'), + }); + proceed({ debateId: debate.id }); + }) + // Creating failed: fall back to asking, which can still use the motion once. + .catch(ask) + .finally(() => { + startingRef.current = false; + setStarting(false); + }); + }, [disabled, startImplicitly, t, toast]); const finish = (target: DebateTarget | null) => { pendingRef.current?.resolve(target); @@ -121,5 +152,5 @@ export function useDebateMotionGate(options: { disabled?: boolean } = {}): { ); - return { run, dialog }; + return { run, dialog, busy: starting }; } diff --git a/src/locales/en.json b/src/locales/en.json index 3e88bc115..219ce32b4 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -2431,7 +2431,11 @@ "switching": "Switching…", "deleting": "Deleting…", "creating": "Creating…", - "loadingCurrent": "Loading your current debate…" + "loadingCurrent": "Loading your current debate…", + "startedForMotion": "Started a debate for this motion", + "startedForMotionDesc": "Its sources and results stay together. Rename or switch it from Current debate.", + "noCurrentAutoStrip": "No current debate. Your first result starts one for its motion.", + "leaveOneOff": "Turn off one-off mode" }, "debateContext": { "using": "Using {{list}}", diff --git a/src/locales/es.json b/src/locales/es.json index ebef0f7fb..c83c10892 100644 --- a/src/locales/es.json +++ b/src/locales/es.json @@ -2431,7 +2431,11 @@ "switching": "Cambiando…", "deleting": "Eliminando…", "creating": "Creando…", - "loadingCurrent": "Cargando tu debate actual…" + "loadingCurrent": "Cargando tu debate actual…", + "startedForMotion": "Se inició un debate con esta moción", + "startedForMotionDesc": "Sus fuentes y resultados quedan juntos. Puedes renombrarlo o cambiarlo desde Debate actual.", + "noCurrentAutoStrip": "No hay debate actual. Tu primer resultado inicia uno con su moción.", + "leaveOneOff": "Desactivar el modo puntual" }, "debateContext": { "using": "Usando {{list}}", diff --git a/src/locales/hu.json b/src/locales/hu.json index 8a5e5402e..e7d686c70 100644 --- a/src/locales/hu.json +++ b/src/locales/hu.json @@ -2431,7 +2431,11 @@ "switching": "Váltás…", "deleting": "Törlés…", "creating": "Létrehozás…", - "loadingCurrent": "Az aktuális vitád betöltése…" + "loadingCurrent": "Az aktuális vitád betöltése…", + "startedForMotion": "Vitát indítottunk ezzel a tétellel", + "startedForMotionDesc": "A forrásai és eredményei együtt maradnak. Az Aktuális vita oldalon átnevezheted vagy válthatsz.", + "noCurrentAutoStrip": "Nincs aktuális vita. Az első eredményed elindít egyet a tételével.", + "leaveOneOff": "Egyszeri mód kikapcsolása" }, "debateContext": { "using": "Felhasználva: {{list}}", diff --git a/src/pages/GeneratePage.tsx b/src/pages/GeneratePage.tsx index 86adec542..11e802d36 100644 --- a/src/pages/GeneratePage.tsx +++ b/src/pages/GeneratePage.tsx @@ -53,7 +53,8 @@ export default function GeneratePage() { recordResult: state.recordResult, updateRecordedResult: state.updateRecordedResult, }))); - const debateGate = useDebateMotionGate({ disabled: Boolean(workspace.context) }); + // Design choice (MD-10): the first Generate starts a debate for its motion instead of asking. + const debateGate = useDebateMotionGate({ disabled: Boolean(workspace.context), startImplicitly: true }); // The debate row this page's current result was recorded into, if any. const [recordedCaseId, setRecordedCaseId] = useState(null); // Undo shows an autosaved snapshot, which is not the recorded case. @@ -275,12 +276,12 @@ export default function GeneratePage() {
-