diff --git a/docs/architecture.md b/docs/architecture.md index daa95d22e..a9a91b862 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -17,7 +17,7 @@ Browser or Capacitor [`src/bootstrap.ts`](../src/bootstrap.ts) imports the application entry immediately, then schedules Jam after window load and idle time. [`src/main.tsx`](../src/main.tsx) mounts the application before starting native runtime handling, observability, native URL handling, and service-worker registration. Vercel browser telemetry is disabled inside the native shell and loaded behind a separate Suspense boundary. -[`src/App.tsx`](../src/App.tsx) owns routes and top-level providers. Pages are lazy loaded. Public routes cover marketing, legal pages, login, sources, and shared content. `/app/*` routes pass through `RequireAuth` and include the main tools, history, library, timer, sparring, flowchart, settings, and administration. +[`src/App.tsx`](../src/App.tsx) owns routes and top-level providers. Pages are lazy loaded. Public routes cover marketing, legal pages, login, sources, and shared content. `/app/*` routes pass through `RequireAuth` and include the main tools, the Current Debate dashboard (`/app/debate`), history, library, timer, sparring, flowchart, settings, and administration. The header holds only Home, the Current debate menu, Library, History, and the language, theme, and Settings controls; tools are launched from Home and the dashboard. `AuthProvider`, TanStack Query, theme and motion providers, toast systems, the offline indicator, and the route error boundary wrap the routed application. @@ -25,6 +25,8 @@ Browser or Capacitor [`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, and 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. [`src/lib/stores/sparring-store.ts`](../src/lib/stores/sparring-store.ts) owns active sparring rounds in session storage and hydrates completed sessions from Supabase. @@ -68,9 +70,9 @@ Shared research, source ranking, URL validation, and prompt construction live un The import endpoint validates public HTTP URLs, resolves redirect targets, blocks private networks, and enforces response timeout, content type, and size limits. Research paths bound source count and content before prompt construction. -Authenticated personal AI requests carry stable source IDs instead of source content. The Edge Function loads matching active rows for the authenticated user, bounds their title, content, origin, and type, then passes that context to ranking and prompt construction. Jev scores relevance, importance, and evidence strength after the existing deterministic quality floor, while failures preserve the prior order. Generate also records a bounded source-use snapshot in the saved case output. Semantic counts and the latest case-use flag live in the existing source `metadata` JSON, so the Library can default to Smart ordering without a schema migration. +Authenticated personal AI requests carry stable source IDs instead of source content, drawn only from the target debate's active sources; one-off requests send none. The Edge Function loads matching active rows for the authenticated user, bounds their title, content, origin, and type, then passes that context to ranking and prompt construction. Jev scores relevance, importance, and evidence strength after the existing deterministic quality floor, while failures preserve the prior order. Generate also records a bounded source-use snapshot in the saved case output. Semantic counts and the latest case-use flag live in the existing source `metadata` JSON, so the Library can default to Smart ordering without a schema migration. -Ordinary Ask remains stateless while a conversation is in progress. The browser sends only the newest eight messages within a UTF-8 byte budget. Pressing Save persists the chat and its messages in Supabase; Workspace threads use their separate durable server-side context path. Legacy local-only sources are not sent to the authenticated AI endpoint because they have no persisted UUID to authorize and load. +Ordinary Ask remains stateless while a conversation is in progress. The browser sends only the newest eight messages within a UTF-8 byte budget. Inside a debate, each finished exchange is recorded into one chat for that debate; in one-off mode, pressing Save persists the chat and its messages; Workspace threads use their separate durable server-side context path. Legacy local-only sources are not sent to the authenticated AI endpoint because they have no persisted UUID to authorize and load. ## Database boundary diff --git a/e2e/admin-announcements.spec.ts b/e2e/admin-announcements.spec.ts index 438c8f063..3e9089dfb 100644 --- a/e2e/admin-announcements.spec.ts +++ b/e2e/admin-announcements.spec.ts @@ -110,8 +110,8 @@ test('keeps announcements stable across routes and refreshes on a full reload', await page.getByRole('button', { name: 'Dismiss announcement: Scheduled maintenance' }).click(); await expect(page.getByRole('heading', { name: 'Scheduled maintenance' })).toHaveCount(0); - await openAppNav(page, 'Attack'); - await expect(page.getByRole('heading', { name: 'Attack', exact: true })).toBeVisible(); + await openAppNav(page, 'Library'); + await expect(page.getByRole('heading', { name: 'Source Library', exact: true })).toBeVisible(); await openAppNav(page, 'Home'); await expect(page.getByRole('main').getByRole('heading', { name: 'DebateLab', exact: true })).toBeVisible(); expect(listRequestCount()).toBe(1); diff --git a/e2e/animation-polish.spec.ts b/e2e/animation-polish.spec.ts index 17886e968..9061985a7 100644 --- a/e2e/animation-polish.spec.ts +++ b/e2e/animation-polish.spec.ts @@ -1,5 +1,5 @@ import { expect, test, type Page } from '@playwright/test'; -import { stubSupabaseSession } from './helpers/auth'; +import { seedCurrentDebate, stubSupabaseSession } from './helpers/auth'; import { stubAIResponses } from './fixtures/ai-responses'; const featureConfig = { provider: 'openrouter', model: 'z-ai/glm-5.3-flash', temperature: 0.7 }; @@ -8,6 +8,10 @@ const STATE_TIMEOUT_MS = 15_000; async function prepareMotionFixture(page: Page, reducedMotion: 'no-preference' | 'reduce') { await page.emulateMedia({ reducedMotion }); await stubSupabaseSession(page); + await seedCurrentDebate(page, 'This House would make public transport free.'); + await page.addInitScript(() => { + window.localStorage.setItem('debate-trainer-onboarding', JSON.stringify({ state: { hasCompletedOnboarding: true, isTourRunning: false }, version: 0 })); + }); await stubAIResponses(page); await page.addInitScript((config) => { window.localStorage.setItem('debate-trainer-settings', JSON.stringify({ @@ -54,19 +58,10 @@ async function generateMockCase(page: Page) { await expect(page.getByText(/We support the motion/)).toBeVisible({ timeout: STATE_TIMEOUT_MS }); } +/** Tools open from Home, which is reached from the header. */ async function openFeatureRoute(page: Page, route: 'brainstorm' | 'factoids' | 'ask') { - const desktopLink = page.locator(`[data-tour="nav-${route}"]`).first(); - - if ((page.viewportSize()?.width ?? 1280) < 1024) { - await page.getByRole('button', { name: 'Open menu' }).click(); - await page.getByRole('navigation', { name: 'Mobile navigation' }) - .locator(`a[href="/app/${route}"]`) - .click(); - } else { - if (!await desktopLink.isVisible()) await page.locator('summary[data-tour="nav-more"]').click(); - await desktopLink.click(); - } - + await page.locator('header a[href="/app"]').first().click(); + await page.locator('main').locator(`a[href="/app/${route}"]`).click(); await expect(page).toHaveURL(new RegExp(`/app/${route}$`)); } @@ -106,8 +101,8 @@ test('removes non-essential route and state movement for reduced motion', async test('keeps a delayed route skeleton static under reduced motion', async ({ page }, testInfo) => { await prepareMotionFixture(page, 'reduce'); - await page.goto('/app/generate', { waitUntil: 'domcontentloaded' }); - await expect(page.getByRole('heading', { name: 'Generate', exact: true })).toBeVisible({ timeout: STATE_TIMEOUT_MS }); + await page.goto('/app', { waitUntil: 'domcontentloaded' }); + await expect(page.getByRole('heading', { name: 'Prepare' })).toBeVisible({ timeout: STATE_TIMEOUT_MS }); await page.screenshot({ path: testInfo.outputPath('reduced-route-before.png'), fullPage: true }); let destinationRequested = false; @@ -117,13 +112,7 @@ test('keeps a delayed route skeleton static under reduced motion', async ({ page await route.continue(); }); - const attackLink = (page.viewportSize()?.width ?? 1280) < 1024 - ? await (async () => { - await page.getByRole('button', { name: 'Open menu' }).click(); - return page.getByRole('navigation', { name: 'Mobile navigation' }).getByRole('link', { name: /^Attack/ }); - })() - : page.getByRole('navigation', { name: 'Main navigation' }).getByRole('link', { name: 'Attack' }); - await attackLink.click(); + await page.locator('main').locator('a[href="/app/attack"]').click(); await expect.poll(() => destinationRequested).toBe(true); const pendingStatus = page.getByRole('status', { name: 'Opening Attack', exact: true }); diff --git a/e2e/crossfire.spec.ts b/e2e/crossfire.spec.ts index 60ec74fc1..4826c6cda 100644 --- a/e2e/crossfire.spec.ts +++ b/e2e/crossfire.spec.ts @@ -1,5 +1,8 @@ import { expect, test, type Page } from '@playwright/test'; -import { setLocale, stubSupabaseSession } from './helpers/auth'; +import { seedCurrentDebate, setLocale, stubSupabaseSession } from './helpers/auth'; + +const MOTION = 'Resolved: cities should ban private cars downtown.'; +const SEEDED_DEBATE_ID = '00000000-0000-4000-8000-00000000d001'; type CrossfireRequest = { feature: string; @@ -23,6 +26,16 @@ async function installDeterministicBackend(page: Page, requests: CrossfireReques return; } + // Serve the seeded debate so hydration keeps it as the Current Debate. + const debateRow = url.pathname.endsWith('/rest/v1/debates') + ? { id: SEEDED_DEBATE_ID, motion: MOTION, settings: {}, instructions: '', last_activity_at: new Date().toISOString(), created_at: new Date().toISOString() } + : url.pathname.endsWith('/rest/v1/current_debates') ? { debate_id: SEEDED_DEBATE_ID } : null; + if (debateRow && route.request().method() === 'GET') { + const single = (route.request().headers().accept ?? '').includes('vnd.pgrst.object'); + await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(single ? debateRow : [debateRow]) }); + return; + } + if (url.pathname.includes('/rest/v1/')) { await route.fulfill({ status: 200, @@ -102,6 +115,7 @@ async function installDeterministicBackend(page: Page, requests: CrossfireReques async function prepare(page: Page, requests: CrossfireRequest[], failFirstAnswer = false) { await setLocale(page, 'en'); await stubSupabaseSession(page); + await seedCurrentDebate(page, MOTION); await page.addInitScript(() => { for (const key of Object.keys(window.localStorage)) { if (key.startsWith('debatelab-crossfire-draft:')) window.localStorage.removeItem(key); diff --git a/e2e/full-stack/account-data.spec.ts b/e2e/full-stack/account-data.spec.ts index acff3e971..f55851b1e 100644 --- a/e2e/full-stack/account-data.spec.ts +++ b/e2e/full-stack/account-data.spec.ts @@ -1,4 +1,4 @@ -import { admin, createDemoUser, deleteDemoUser, expect, MARK, providerCall, signIn, stackReady, test } from "./stack"; +import { admin, createDemoUser, deleteDemoUser, expect, MARK, providerCall, signIn, stackReady, startDebate, test } from "./stack"; import type { Page } from "@playwright/test"; test.skip(!stackReady, "Local full stack is required (run ./.superset/db-gate.sh)"); @@ -12,6 +12,7 @@ async function addSource(page: Page, title: string, content: string) { } test("only active Library sources ground AI requests, and deletions persist", async ({ signedIn: page, user }) => { + await startDebate(page, "Public transport should be free"); await page.goto("/app/library"); await addSource(page, "Active study", "ACTIVE-SOURCE-MARKER ridership rose after fares were removed."); await addSource(page, "Paused study", "PAUSED-SOURCE-MARKER congestion did not change."); @@ -44,10 +45,12 @@ test("Delete All My Data erases the account's saved work and nobody else's", asy const bystander = await (await page.context().browser()!.newContext()).newPage(); await bystander.addInitScript(() => localStorage.setItem("debate-trainer-onboarding", JSON.stringify({ state: { hasCompletedOnboarding: true, isTourRunning: false }, version: 0 }))); await signIn(bystander, other); + await startDebate(bystander, "Bystander motion"); await bystander.goto("/app/library"); await addSource(bystander, "Bystander study", "Kept after another account erases its data."); await bystander.context().close(); + await startDebate(page, "My motion"); await page.goto("/app/library"); await addSource(page, "My study", "Erased with the account data."); const userId = await user.userId(); @@ -62,6 +65,8 @@ test("Delete All My Data erases the account's saved work and nobody else's", asy const { data: mine } = await admin.from("sources").select("id").eq("user_id", userId); expect(mine).toEqual([]); + const { data: myDebates } = await admin.from("debates").select("id").eq("user_id", userId); + expect(myDebates).toEqual([]); const { data: theirs } = await admin.from("sources").select("title").eq("user_id", otherId); expect(theirs?.map((row) => row.title)).toEqual(["Bystander study"]); diff --git a/e2e/full-stack/ai-tools.spec.ts b/e2e/full-stack/ai-tools.spec.ts index d86712c62..49416c04d 100644 --- a/e2e/full-stack/ai-tools.spec.ts +++ b/e2e/full-stack/ai-tools.spec.ts @@ -1,4 +1,4 @@ -import { admin, expect, MARK, fake, providerCall, stackReady, test } from "./stack"; +import { admin, chooseForMotion, expect, MARK, fake, providerCall, stackReady, test } from "./stack"; test.skip(!stackReady, "Local full stack is required (run ./.superset/db-gate.sh)"); @@ -15,6 +15,7 @@ test("Brainstorm returns ranked angles", async ({ signedIn: page }) => { await page.goto("/app/brainstorm"); await page.getByLabel("Motion / Topic *").fill("Universal basic income should replace targeted welfare"); await page.getByRole("button", { name: "Start Brainstorming" }).click(); + await chooseForMotion(page, "create"); await expect(page.getByText(`${MARK} idea 1`)).toBeVisible({ timeout: 30_000 }); expect((await providerCall("brainstorm")).user).toContain("Motion: Universal basic income should replace targeted welfare"); diff --git a/e2e/full-stack/auth.spec.ts b/e2e/full-stack/auth.spec.ts index 398216e79..5f89e1bfd 100644 --- a/e2e/full-stack/auth.spec.ts +++ b/e2e/full-stack/auth.spec.ts @@ -1,4 +1,4 @@ -import { admin, expect, MARK, stackReady, test } from "./stack"; +import { admin, chooseForMotion, expect, MARK, stackReady, test } from "./stack"; test.skip(!stackReady, "Local full stack is required (run ./.superset/db-gate.sh)"); @@ -40,5 +40,6 @@ 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"); await page.getByRole("button", { name: "Generate Debate Case" }).click(); + await chooseForMotion(page, "once"); await expect(page.getByText(`${MARK} case thesis`)).toBeVisible({ timeout: 30_000 }); }); diff --git a/e2e/full-stack/billing.spec.ts b/e2e/full-stack/billing.spec.ts index 7d40b3ec4..dd41cce36 100644 --- a/e2e/full-stack/billing.spec.ts +++ b/e2e/full-stack/billing.spec.ts @@ -1,6 +1,6 @@ import { createHmac, randomUUID } from "node:crypto"; import type { Page } from "@playwright/test"; -import { admin, expect, fake, MARK, providerCall, stackReady, test } from "./stack"; +import { admin, chooseForMotion, expect, fake, MARK, providerCall, stackReady, test } from "./stack"; test.skip(!stackReady, "Local full stack is required (run ./.superset/db-gate.sh)"); @@ -24,6 +24,7 @@ async function brainstorm(page: Page) { await page.goto("/app/brainstorm"); await page.getByLabel("Motion / Topic *").fill("Public transport should be free"); await page.getByRole("button", { name: "Start Brainstorming" }).click(); + await chooseForMotion(page, "once"); } function sendWebhook(event: object, signature = true) { diff --git a/e2e/full-stack/case-generation.spec.ts b/e2e/full-stack/case-generation.spec.ts index a50b8ccb5..bc7397bdf 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, expect, fake, MARK, providerCall, signIn, stackReady, test } from "./stack"; +import { admin, chooseForMotion, expect, fake, MARK, providerCall, signIn, stackReady, startDebate, test } from "./stack"; test.skip(!stackReady, "Local full stack is required (run ./.superset/db-gate.sh)"); @@ -7,6 +7,7 @@ const motion = "This House would make public transport free"; test("a signed-in user generates a case grounded in a saved Library source", async ({ signedIn: page, user }) => { const marker = `SOURCE-${Date.now()}`; + await startDebate(page, motion); await page.goto("/app/library"); await page.getByRole("button", { name: "Add Source" }).click(); await page.getByRole("textbox", { name: "Source title..." }).fill("Tallinn fare study"); @@ -40,6 +41,7 @@ test("a signed-in user generates a case grounded in a saved Library source", asy test("a provider failure shows an error and lets the user retry", async ({ signedIn: page }) => { await fake.fail("generate", 500); + await startDebate(page, motion); await page.goto("/app/generate"); await page.getByRole("textbox", { name: "Motion / Topic *" }).fill(motion); await page.getByRole("button", { name: "Generate Debate Case" }).click(); @@ -53,6 +55,7 @@ test("a provider failure shows an error and lets the user retry", async ({ signe test("an account without quota is told why generation is blocked", async ({ signedIn: page, user }) => { await admin.from("user_settings").update({ daily_limit_override: 0 }).eq("user_id", await user.userId()); + await startDebate(page, motion); await page.goto("/app/generate"); await page.getByRole("textbox", { name: "Motion / Topic *" }).fill(motion); await page.getByRole("button", { name: "Generate Debate Case" }).click(); @@ -61,10 +64,11 @@ test("an account without quota is told why generation is blocked", async ({ sign expect((await fake.requests()).filter((request) => request.feature === "generate")).toEqual([]); }); -test("a saved case persists to the account and reloads from History", async ({ signedIn: page, user }) => { +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); await page.getByRole("button", { name: "Generate Debate Case" }).click(); + await chooseForMotion(page, "once"); await expect(page.getByText(`${MARK} case thesis`)).toBeVisible({ timeout: 30_000 }); const title = `Saved case ${Date.now()}`; @@ -87,6 +91,7 @@ test("a saved case persists to the account and reloads from History", async ({ s }); test("a generated case exports to PDF and DOCX files", async ({ signedIn: page }) => { + await startDebate(page, motion); await page.goto("/app/generate"); await page.getByRole("textbox", { name: "Motion / Topic *" }).fill(motion); await page.getByRole("button", { name: "Generate Debate Case" }).click(); diff --git a/e2e/full-stack/current-debate.spec.ts b/e2e/full-stack/current-debate.spec.ts new file mode 100644 index 000000000..6ca93a13f --- /dev/null +++ b/e2e/full-stack/current-debate.spec.ts @@ -0,0 +1,128 @@ +import type { Page } from "@playwright/test"; +import { admin, chooseForMotion, debateControl, expect, fake, MARK, stackReady, startDebate, test } from "./stack"; + +test.skip(!stackReady, "Local full stack is required (run ./.superset/db-gate.sh)"); + +// EVIDENCE_DIR= records video and key screenshots for review. +const evidenceDir = process.env.EVIDENCE_DIR; +test.use({ video: evidenceDir ? "on" : "retain-on-failure" }); + +const HOMEWORK = "This House would ban homework"; +const ZOOS = "This House would abolish zoos"; + +async function snap(page: Page, name: string) { + if (!evidenceDir) return; + await page.screenshot({ path: `${evidenceDir}/${test.info().project.name}-${name}.png`, fullPage: true }); +} + +async function addSource(page: Page, title: string, content: string) { + await page.goto("/app/library"); + await page.getByRole("button", { name: "Add Source" }).click(); + await page.getByRole("textbox", { name: "Source title..." }).fill(title); + await page.getByRole("textbox", { name: "Paste source content..." }).fill(content); + await page.getByRole("button", { name: "Save to Library" }).click(); + await expect(page.getByRole("switch", { name: `Toggle ${title}` })).toBeChecked(); +} + +async function switchDebate(page: Page, motion: string) { + await debateControl(page).click(); + await page.getByRole("dialog").getByRole("button", { name: "Switch debate" }).click(); + await page.getByRole("dialog").getByRole("button", { name: `Switch to ${motion}` }).click(); + await expect(page.getByRole("dialog")).toBeHidden(); + await expect(debateControl(page)).toHaveAttribute("aria-label", `Current debate: ${motion}`); +} + +async function latestCall(feature: string) { + let call: Awaited>[number] | undefined; + await expect.poll(async () => { + call = (await fake.requests()).filter((request) => request.feature === feature).at(-1); + return Boolean(call); + }).toBe(true); + return call!; +} + +test("two debates keep their motions, sources, and results apart", async ({ signedIn: page, user }) => { + test.setTimeout(120_000); + const userId = await user.userId(); + + // First debate: its source grounds a Brainstorm that fills in the motion by itself. + await expect(page.getByTestId("home-current-debate")).toContainText("No current debate"); + await startDebate(page, HOMEWORK); + await addSource(page, "Homework study", "HOMEWORK-SOURCE pupils sleep less when homework runs late."); + + await page.goto("/app/brainstorm"); + await expect(page.getByTestId("current-debate-strip")).toContainText(`Working in: ${HOMEWORK}`); + await expect(page.getByLabel("Motion / Topic *")).toHaveValue(HOMEWORK); + await page.getByRole("button", { name: "Start Brainstorming" }).click(); + await expect(page.getByText(`${MARK} idea 1`)).toBeVisible({ timeout: 30_000 }); + const homeworkCall = await latestCall("brainstorm"); + expect(homeworkCall.user).toContain(`Motion: ${HOMEWORK}`); + expect(homeworkCall.system).toContain("HOMEWORK-SOURCE"); + await snap(page, "1-brainstorm-in-homework-debate"); + + // Second debate: a fresh Library and a case grounded only in its own source. + await startDebate(page, ZOOS); + await page.goto("/app/library"); + await expect(page.getByTestId("library-debate")).toContainText(ZOOS); + await expect(page.getByRole("switch", { name: "Toggle Homework study" })).toHaveCount(0); + await addSource(page, "Zoo study", "ZOO-SOURCE conservation breeding saved several species."); + await snap(page, "2-library-in-zoo-debate"); + + await page.goto("/app/generate"); + await expect(page.getByRole("textbox", { name: "Motion / Topic *" })).toHaveValue(ZOOS); + await page.getByRole("button", { name: "Generate Debate Case" }).click(); + await expect(page.getByText(`${MARK} case thesis`)).toBeVisible({ timeout: 30_000 }); + const zooCall = await latestCall("generate"); + expect(zooCall.user).toContain(`Motion: ${ZOOS}`); + expect(zooCall.system).toContain("ZOO-SOURCE"); + expect(zooCall.system).not.toContain("HOMEWORK-SOURCE"); + + // A different motion asks first; "use once" keeps it out of every debate. + await page.goto("/app/brainstorm"); + await page.getByLabel("Motion / Topic *").fill("This House would lower the voting age"); + await page.getByRole("button", { name: "Start Brainstorming" }).click(); + await expect(page.getByRole("dialog")).toContainText("This is a different motion"); + await snap(page, "3-different-motion-choice"); + await chooseForMotion(page, "once"); + await expect(page.getByText(`${MARK} idea 1`)).toBeVisible({ timeout: 30_000 }); + const oneOffCall = await latestCall("brainstorm"); + expect(oneOffCall.system).not.toContain("ZOO-SOURCE"); + expect(oneOffCall.system).not.toContain("HOMEWORK-SOURCE"); + + // The dashboard shows only this debate's work, with both sides as equals. + await page.goto("/app/debate"); + await expect(page.getByTestId("current-debate-motion")).toHaveText(ZOOS); + await expect(page.getByTestId("debate-side-pro")).toContainText("In favor"); + await expect(page.getByTestId("debate-side-con")).toContainText("Against"); + await expect(page.getByTestId("debate-sources")).toContainText("Zoo study"); + await expect(page.getByTestId("debate-sources")).not.toContainText("Homework study"); + await expect(page.getByTestId("debate-recent")).toContainText(ZOOS); + await snap(page, "4-zoo-dashboard"); + + // Switching back switches the Library and History with it. + await switchDebate(page, HOMEWORK); + await page.goto("/app/library"); + await expect(page.getByRole("switch", { name: "Toggle Homework study" })).toBeVisible(); + await expect(page.getByRole("switch", { name: "Toggle Zoo study" })).toHaveCount(0); + await page.goto("/app/history"); + await page.getByRole("tab", { name: /Brainstorms/ }).click(); + await expect(page.getByRole("tabpanel").getByText(HOMEWORK)).toBeVisible(); + await page.getByRole("tab", { name: /Cases/ }).click(); + await expect(page.getByRole("tabpanel").getByText("No saved cases yet")).toBeVisible(); + await snap(page, "5-history-back-in-homework"); + + // The database agrees: every row sits in exactly the debate it came from. + const { data: debates } = await admin.from("debates").select("id,motion").eq("user_id", userId); + const idFor = (motion: string) => debates!.find((debate) => debate.motion === motion)!.id; + const { data: sources } = await admin.from("sources").select("title,debate_id").eq("user_id", userId).order("title"); + expect(sources).toEqual([ + { title: "Homework study", debate_id: idFor(HOMEWORK) }, + { title: "Zoo study", debate_id: idFor(ZOOS) }, + ]); + const { data: cases } = await admin.from("cases").select("debate_id,side").eq("user_id", userId); + expect(cases).toEqual([{ debate_id: idFor(ZOOS), side: "pro" }]); + const { data: brainstorms } = await admin.from("brainstorms").select("debate_id").eq("user_id", userId); + expect(brainstorms).toEqual([{ debate_id: idFor(HOMEWORK) }]); + const { data: current } = await admin.from("current_debates").select("debate_id").eq("user_id", userId).single(); + expect(current?.debate_id).toBe(idFor(HOMEWORK)); +}); diff --git a/e2e/full-stack/practice.spec.ts b/e2e/full-stack/practice.spec.ts index 4367c0479..2845bbc89 100644 --- a/e2e/full-stack/practice.spec.ts +++ b/e2e/full-stack/practice.spec.ts @@ -1,4 +1,4 @@ -import { admin, expect, MARK, providerCall, stackReady, test } from "./stack"; +import { admin, chooseForMotion, expect, MARK, providerCall, stackReady, test } from "./stack"; test.skip(!stackReady, "Local full stack is required (run ./.superset/db-gate.sh)"); @@ -8,6 +8,7 @@ test("a Lincoln-Douglas sparring round is judged and saved", async ({ signedIn: await page.getByRole("combobox", { name: "Round format" }).click(); await page.getByRole("option", { name: /Lincoln-Douglas · 5 speeches/ }).click(); await page.getByRole("button", { name: "Start round" }).click(); + await chooseForMotion(page, "create"); const speeches = [ ["Affirmative constructive", "Universal access removes the direct price barrier to participation."], @@ -31,14 +32,20 @@ test("a Lincoln-Douglas sparring round is judged and saved", async ({ signedIn: const { data } = await admin.from("sparring_sessions").select("status,final_evaluation").eq("user_id", await user.userId()); return data?.map((row) => row.status); }).toEqual(["complete"]); + // The judged round is filed in the debate created for its motion, with the user's side. + await expect.poll(async () => { + const { data } = await admin.from("sparring_sessions").select("side,debates(motion)").eq("user_id", await user.userId()).single(); + return data; + }).toEqual({ side: "pro", debates: { motion: "This House would make public transport free at the point of use." } }); }); -test("Crossfire answers a question and produces a debrief", async ({ signedIn: page }) => { +test("Crossfire answers a question and produces a debrief", async ({ signedIn: page, user }) => { await page.goto("/app/crossfire"); await page.getByTestId("crossfire-resolution").fill("Resolved: cities should ban private cars downtown."); await page.getByTestId("crossfire-argument").fill("Private cars create congestion and unsafe streets."); await page.getByTestId("mode-question-ai").click(); await page.getByTestId("start-crossfire").click(); + await chooseForMotion(page, "create"); await page.getByTestId("crossfire-input").fill("What evidence supports the safety mechanism?"); await page.getByTestId("submit-turn").click(); @@ -50,4 +57,9 @@ test("Crossfire answers a question and produces a debrief", async ({ signedIn: p await page.getByTestId("end-crossfire").click(); await expect(page.getByTestId("crossfire-debrief")).toBeVisible({ timeout: 30_000 }); + // The finished round is recorded in its debate. + await expect.poll(async () => { + const { data } = await admin.from("crossfires").select("title,debates(motion)").eq("user_id", await user.userId()); + return data; + }).toEqual([{ title: "Resolved: cities should ban private cars downtown.", debates: { motion: "Resolved: cities should ban private cars downtown." } }]); }); diff --git a/e2e/full-stack/stack.ts b/e2e/full-stack/stack.ts index 33b94ce60..4176368a7 100644 --- a/e2e/full-stack/stack.ts +++ b/e2e/full-stack/stack.ts @@ -75,6 +75,38 @@ export async function signIn(page: Page, user: DemoUser) { await expect(page).toHaveURL(/\/app$/, { timeout: 20_000 }); } +/** The visible Current Debate control: the header menu on desktop, the second header row on mobile. */ +export function debateControl(page: Page) { + return page.locator('[data-testid="current-debate-desktop"]:visible, [data-testid="current-debate-mobile"]:visible').first(); +} + +/** Starts a new Current Debate from the header, as a student would. */ +export async function startDebate(page: Page, motion: string) { + if (!page.url().includes("/app")) await page.goto("/app"); + const control = debateControl(page); + if ((await control.textContent())?.includes("Start debate")) { + await control.click(); + } else { + await control.click(); + await page.getByRole("dialog").getByRole("button", { name: "Start new debate" }).click(); + } + const dialog = page.getByRole("dialog"); + await dialog.getByLabel("Motion").fill(motion); + await dialog.getByRole("button", { name: "Start debate" }).click(); + await expect(dialog).toBeHidden(); + await expect(debateControl(page)).toHaveAttribute("aria-label", `Current debate: ${motion}`); +} + +/** Answers "Which debate is this for?" after submitting a motion that is not the Current Debate. */ +export async function chooseForMotion(page: Page, choice: "create" | "once") { + // Scope to the motion choice: the next step may open its own dialog, such as the paywall. + const dialog = page.getByRole("dialog", { name: /^(Which debate is this for\?|This is a different motion)$/ }); + await dialog.getByRole("button", { + name: choice === "create" ? "Create a new debate for this motion" : "Use once without saving to a debate", + }).click(); + await expect(dialog).toBeHidden(); +} + type Fixtures = { user: DemoUser; signedIn: Page }; export const test = base.extend({ diff --git a/e2e/helpers/auth.ts b/e2e/helpers/auth.ts index f9eb835b5..4a6e17918 100644 --- a/e2e/helpers/auth.ts +++ b/e2e/helpers/auth.ts @@ -70,3 +70,27 @@ export async function setLocale(page: Page, locale: 'en' | 'es' | 'hu') { } }, locale); } + +/** + * Seed a Current Debate for a stub session. Stub sessions cannot reach the + * server, so the app keeps this persisted copy; it proves rendering only. + */ +export async function seedCurrentDebate(page: Page, motion: string, opts: { userId?: string } = {}) { + const userId = opts.userId ?? '00000000-0000-0000-0000-000000000001'; + const now = Date.now(); + const debate = { + id: '00000000-0000-4000-8000-00000000d001', + motion, + settings: {}, + instructions: '', + lastActivityAt: now, + createdAt: now, + }; + await page.addInitScript(({ value }) => { + try { + window.localStorage.setItem('debatelab-current-debate', JSON.stringify(value)); + } catch { + /* ignore */ + } + }, { value: { state: { ownerUserId: userId, debates: [debate], currentDebateId: debate.id }, version: 1 } }); +} diff --git a/e2e/home-navigation.spec.ts b/e2e/home-navigation.spec.ts index 7fc83f153..f607a0170 100644 --- a/e2e/home-navigation.spec.ts +++ b/e2e/home-navigation.spec.ts @@ -39,11 +39,6 @@ test('onboarding follows visible home targets when secondary navigation is close }); await page.goto('/app', { waitUntil: 'domcontentloaded' }); - const desktopMoreMenu = page.locator('details').filter({ has: page.locator('summary[aria-label="More"]') }); - if ((page.viewportSize()?.width ?? 0) >= 1024) { - await expect(desktopMoreMenu).not.toHaveAttribute('open'); - } - await expect(page.locator('.react-joyride__tooltip')).toBeVisible({ timeout: 15_000 }); await page.locator('[data-test-id="button-primary"]').click(); @@ -113,11 +108,12 @@ test('header keeps the brand and controls inside a 320px viewport', async ({ pag await page.goto('/app', { waitUntil: 'domcontentloaded' }); await expect(page.getByRole('heading', { name: 'Prepare' })).toBeVisible({ timeout: 15_000 }); + // Mobile keeps the logo, language, theme, and menu on the first row; Settings lives in the menu. const languageControl = page.getByRole('button', { name: 'Language' }); const themeControl = page.getByRole('button', { name: 'Toggle theme' }); - const settingsControl = page.getByRole('link', { name: 'Settings' }); const menuControl = page.getByRole('button', { name: 'Open menu' }); - const controls = [languageControl, themeControl, settingsControl, menuControl]; + const debateControl = page.getByTestId('current-debate-mobile'); + const controls = [languageControl, themeControl, menuControl, debateControl]; await expect(page.getByAltText('DebateLab logo')).toBeVisible(); for (const control of controls) await expect(control).toBeVisible(); @@ -127,22 +123,17 @@ test('header keeps the brand and controls inside a 320px viewport', async ({ pag expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBe(320); }); -test('desktop More menu exposes secondary destinations without horizontal scrolling', async ({ page }) => { - test.skip((page.viewportSize()?.width ?? 0) < 1024, 'Desktop-only More menu check'); +test('desktop header holds Home, Current debate, Library, and History with no tool links', async ({ page }) => { + test.skip((page.viewportSize()?.width ?? 0) < 1024, 'Desktop-only header check'); await page.goto('/app', { waitUntil: 'domcontentloaded' }); - await page.locator('summary[aria-label="More"]').click(); - - const moreMenu = page.locator('details').filter({ has: page.locator('summary[aria-label="More"]') }); - for (const [label, href] of [ - ['Brainstorm', '/app/brainstorm'], - ['Factoids', '/app/factoids'], - ['Flowchart', '/app/flowchart'], - ['Library', '/app/library'], - ['Timer', '/app/timer'], - ['History', '/app/history'], - ] as const) { - await expect(moreMenu.getByRole('link', { name: label })).toHaveAttribute('href', href); + + const navigation = page.getByRole('navigation', { name: 'Main navigation' }); + await expect(navigation.getByRole('link')).toHaveText(['Home', 'Library', 'History']); + await expect(navigation.getByTestId('current-debate-desktop')).toHaveText('Start debate'); + for (const tool of ['Generate', 'Attack', 'Ask', 'Crossfire', 'Sparring', 'More']) { + await expect(navigation.getByText(tool, { exact: true })).toHaveCount(0); } + await expect(page.getByRole('link', { name: 'Settings' })).toBeVisible(); expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBeLessThanOrEqual(await page.evaluate(() => window.innerWidth)); }); @@ -159,17 +150,16 @@ test('desktop header fits without internal or document overflow at supported wid } }); -test('mobile menu exposes every tool destination and closes after navigation', async ({ page }) => { +test('mobile menu holds only Home, Current debate, Library, History, and Settings', async ({ page }) => { await page.setViewportSize({ width: 390, height: 844 }); await page.goto('/app', { waitUntil: 'domcontentloaded' }); await page.getByRole('button', { name: 'Open menu' }).click(); const mobileNavigation = page.getByRole('navigation', { name: 'Mobile navigation' }); - for (const label of ['Home', 'Generate', 'Attack', 'Brainstorm', 'Factoids', 'Ask', 'Crossfire', 'Library', 'Timer', 'Sparring', 'Flowchart', 'History']) { - await expect(mobileNavigation.getByRole('link', { name: new RegExp(`^${label}`) })).toBeVisible(); - } + const hrefs = await mobileNavigation.getByRole('link').evaluateAll((links) => links.map((link) => link.getAttribute('href'))); + expect(hrefs).toEqual(['/app', '/app/debate', '/app/library', '/app/history', '/app/settings']); - await mobileNavigation.getByRole('link', { name: /^Brainstorm/ }).click(); - await expect(page).toHaveURL(/\/app\/brainstorm$/); + await mobileNavigation.getByRole('link', { name: /^Library/ }).click(); + await expect(page).toHaveURL(/\/app\/library$/); await expect(page.getByRole('navigation', { name: 'Mobile navigation' })).toHaveCount(0); }); diff --git a/e2e/hungarian-parity.spec.ts b/e2e/hungarian-parity.spec.ts index 0043fd241..d2fedeb0f 100644 --- a/e2e/hungarian-parity.spec.ts +++ b/e2e/hungarian-parity.spec.ts @@ -1,5 +1,5 @@ import { expect, test, type Page } from '@playwright/test'; -import { setLocale, stubSupabaseSession } from './helpers/auth'; +import { seedCurrentDebate, setLocale, stubSupabaseSession } from './helpers/auth'; function collectRuntimeErrors(page: Page) { const errors: string[] = []; @@ -58,7 +58,7 @@ test.describe('Hungarian parity', () => { expect(errors, errors.join('\n')).toEqual([]); }); - test('renders Hungarian workspace navigation, route titles, and onboarding', async ({ page }, testInfo) => { + test('renders Hungarian workspace navigation, route titles, and onboarding', async ({ page }) => { const errors = collectRuntimeErrors(page); await stubSupabaseSession(page); await page.addInitScript(() => { @@ -87,18 +87,10 @@ test.describe('Hungarian parity', () => { ['factoids', 'Tények'], ['ask', 'Kérdezés'], ] as const) { - const link = page.locator(`[data-tour="nav-${route}"]`).first(); - if (testInfo.project.name === 'mobile-chromium') { - await page.getByRole('button', { name: 'Menü megnyitása' }).click(); - await page.getByRole('navigation', { name: 'Mobil navigáció' }) - .getByRole('link') - .filter({ hasText: heading }) - .first() - .click(); - } else { - if (!await link.isVisible()) await page.locator('summary[data-tour="nav-more"]').click(); - await link.click(); - } + // Tools open from Home; the header keeps only Home, Current debate, Library and History. + // Navigate in-app: a reload would rerun the init script and restart the tour. + await page.locator('header a[href="/app"]').first().click(); + await page.locator(`main [data-tour="mode-${route}"]`).click(); await expect(page).toHaveURL(new RegExp(`/app/${route}$`)); await expect(page.getByRole('heading', { name: heading, exact: true }).last()).toBeVisible(); } @@ -111,6 +103,8 @@ test.describe('Hungarian parity', () => { test('localizes saved chats, library controls, timer, and close labels', async ({ page }, testInfo) => { const errors = collectRuntimeErrors(page); await stubSupabaseSession(page); + // Library needs a Current Debate before sources can be added. + await seedCurrentDebate(page, 'Ez a Ház eltörölné a házi feladatot.'); await page.addInitScript(() => { try { window.localStorage.setItem('debate-trainer-onboarding', JSON.stringify({ diff --git a/e2e/loading-performance.spec.ts b/e2e/loading-performance.spec.ts index a5bba585c..4dfb1a311 100644 --- a/e2e/loading-performance.spec.ts +++ b/e2e/loading-performance.spec.ts @@ -1,12 +1,6 @@ -import { expect, test, type Page } from '@playwright/test'; +import { expect, test } from '@playwright/test'; import { setLocale, stubSupabaseSession } from './helpers/auth'; -async function openMobileNavigation(page: Page) { - if ((page.viewportSize()?.width ?? 1280) < 1024) { - await page.getByRole('button', { name: 'Open menu' }).click(); - } -} - test('a stalled Jam recorder cannot delay the real application', async ({ page, browserName }) => { test.skip(browserName === 'webkit', 'Jam recording requires MediaRecorder, which this WebKit runtime does not expose.'); await page.route('https://js.jam.dev/**', async () => new Promise(() => undefined)); @@ -70,7 +64,13 @@ test('landing CTA enters the app without replacing the document', async ({ page test('protected sibling navigation keeps AppLayout mounted while the page loads', async ({ page }) => { await setLocale(page, 'en'); await stubSupabaseSession(page); - await page.goto('/app/generate'); + await page.addInitScript(() => { + window.localStorage.setItem('debate-trainer-onboarding', JSON.stringify({ + state: { hasCompletedOnboarding: true, isTourRunning: false }, + version: 0, + })); + }); + await page.goto('/app'); const header = page.locator('header'); await expect(header).toBeVisible(); await header.evaluate((element) => { element.dataset.e2eLayout = 'original'; }); @@ -84,8 +84,8 @@ test('protected sibling navigation keeps AppLayout mounted while the page loads' await route.continue(); }); - await openMobileNavigation(page); - await page.getByRole('link', { name: 'Attack' }).click(); + // Tools open from Home; the header no longer lists them. + await page.locator('main [data-tour="mode-attack"]').click(); await expect.poll(() => attackModuleRequested).toBe(true); await page.waitForTimeout(250); diff --git a/e2e/navigation-performance.spec.ts b/e2e/navigation-performance.spec.ts index 463874066..9d3b914c1 100644 --- a/e2e/navigation-performance.spec.ts +++ b/e2e/navigation-performance.spec.ts @@ -41,20 +41,28 @@ async function waitForContent(page: Page, marker: string | ContentMarker) { await expect(locator).toBeVisible({ timeout: 15_000 }); } -async function openAppNav(page: Page, label: string) { +async function skipOnboarding(page: Page) { + await page.addInitScript(() => { + window.localStorage.setItem('debate-trainer-onboarding', JSON.stringify({ + state: { hasCompletedOnboarding: true, isTourRunning: false }, + version: 0, + })); + }); +} + +// Tools open from Home; the header carries only Home, Current debate, Library and History. +function homeToolLink(page: Page, tool: string) { + return page.locator(`main [data-tour="mode-${tool}"]`); +} + +async function openHome(page: Page) { if ((page.viewportSize()?.width ?? 1280) < 1024) { await page.getByRole('button', { name: 'Open menu' }).click(); - await page.getByRole('navigation', { name: 'Mobile navigation' }).getByRole('link', { name: new RegExp(`^${label}`) }).click(); - return; - } - - if (['Flowchart', 'History'].includes(label)) { - await page.locator('summary[aria-label="More"]').click(); - await page.locator('details').getByRole('link', { name: label }).click(); - return; + await page.getByRole('navigation', { name: 'Mobile navigation' }).getByRole('link', { name: /^Home/ }).click(); + } else { + await page.getByRole('navigation', { name: 'Main navigation' }).getByRole('link', { name: 'Home' }).click(); } - - await page.getByRole('navigation', { name: 'Main navigation' }).getByRole('link', { name: label }).click(); + await expect(page).toHaveURL(/\/app$/); } test('cold routes expose real content on both browser profiles', async ({ page }) => { @@ -110,8 +118,9 @@ test('a delayed public route keeps the main landmark', async ({ page }) => { test('a delayed destination chunk exposes the route fallback without replacing the shell', async ({ page }) => { await setLocale(page, 'en'); await stubSupabaseSession(page); - await page.goto('/app/generate', { waitUntil: 'domcontentloaded' }); - await waitForContent(page, { selector: 'main h1', text: 'Generate' }); + await skipOnboarding(page); + await page.goto('/app', { waitUntil: 'domcontentloaded' }); + await waitForContent(page, { selector: 'main h1', text: 'DebateLab' }); await page.evaluate(() => { window.__baselineAppLayout = document.querySelector('header')?.parentElement; @@ -132,7 +141,7 @@ test('a delayed destination chunk exposes the route fallback without replacing t }); await startGenericLoaderObservation(page); - await openAppNav(page, 'Attack'); + await homeToolLink(page, 'attack').click(); await expect.poll(() => destinationRequested).toBe(true); const pendingStatus = page.locator('main').getByRole('status', { name: 'Opening Attack', exact: true }); await expect(pendingStatus).toHaveCount(1); @@ -205,9 +214,10 @@ test('a failed route chunk recovers after the offered reload', async ({ page }) await route.continue(); }); - await page.goto('/app/generate', { waitUntil: 'domcontentloaded' }); - await waitForContent(page, { selector: 'main h1', text: 'Generate' }); - await openAppNav(page, 'Attack'); + await skipOnboarding(page); + await page.goto('/app', { waitUntil: 'domcontentloaded' }); + await waitForContent(page, { selector: 'main h1', text: 'DebateLab' }); + await homeToolLink(page, 'attack').click(); await expect(page.getByRole('heading', { name: 'Unable to load this page' })).toBeVisible(); await page.getByRole('button', { name: 'Reload' }).click(); @@ -216,15 +226,16 @@ test('a failed route chunk recovers after the offered reload', async ({ page }) await expect(page.locator('header')).toBeVisible(); }); -for (const [locale, attackLabel, menuLabel, mainNavigationLabel, mobileNavigationLabel, openingLabel] of [ - ['es', 'Atacar', 'Abrir menú', 'Navegación principal', 'Navegación móvil', 'Abriendo Atacar'], - ['hu', 'Támadás', 'Menü megnyitása', 'Fő navigáció', 'Mobil navigáció', 'Támadás megnyitása'], +for (const [locale, openingLabel] of [ + ['es', 'Abriendo Atacar'], + ['hu', 'Támadás megnyitása'], ] as const) { test(`${locale} delayed route announces the localized destination`, async ({ page }) => { await setLocale(page, locale); await stubSupabaseSession(page); - await page.goto('/app/generate', { waitUntil: 'domcontentloaded' }); - await expect(page.locator('header')).toBeVisible(); + await skipOnboarding(page); + await page.goto('/app', { waitUntil: 'domcontentloaded' }); + await expect(homeToolLink(page, 'attack')).toBeVisible(); let destinationRequested = false; await page.route('**/src/pages/AttackPage.tsx*', async (route) => { @@ -233,12 +244,7 @@ for (const [locale, attackLabel, menuLabel, mainNavigationLabel, mobileNavigatio await route.continue(); }); - if ((page.viewportSize()?.width ?? 1280) < 1024) { - await page.getByRole('button', { name: menuLabel }).click(); - await page.getByRole('navigation', { name: mobileNavigationLabel }).getByRole('link', { name: new RegExp(`^${attackLabel}`) }).click(); - } else { - await page.getByRole('navigation', { name: mainNavigationLabel }).getByRole('link', { name: attackLabel }).click(); - } + await homeToolLink(page, 'attack').click(); await expect.poll(() => destinationRequested).toBe(true); const status = page.getByRole('status', { name: openingLabel, exact: true }); @@ -249,19 +255,18 @@ for (const [locale, attackLabel, menuLabel, mainNavigationLabel, mobileNavigatio }); } -async function gotoGenerate(page: Page) { +async function gotoHome(page: Page) { await setLocale(page, 'en'); await stubSupabaseSession(page); - await page.goto('/app/generate', { waitUntil: 'domcontentloaded' }); - await waitForContent(page, { selector: 'main h1', text: 'Generate' }); + await skipOnboarding(page); + await page.goto('/app', { waitUntil: 'domcontentloaded' }); + await waitForContent(page, { selector: 'main h1', text: 'DebateLab' }); } async function attackLink(page: Page) { - if ((page.viewportSize()?.width ?? 1280) < 1024) { - await page.getByRole('button', { name: 'Open menu' }).click(); - return page.getByRole('navigation', { name: 'Mobile navigation' }).getByRole('link', { name: /^Attack/ }); - } - return page.getByRole('navigation', { name: 'Main navigation' }).getByRole('link', { name: 'Attack' }); + const link = homeToolLink(page, 'attack'); + await expect(link).toBeVisible(); + return link; } async function startGenericLoaderObservation(page: Page) { @@ -312,7 +317,7 @@ async function signalAllowedIntent(page: Page, link: Awaited { const requestCount = countAttackRequests(page); - await gotoGenerate(page); + await gotoHome(page); const link = await attackLink(page); expect(requestCount()).toBe(0); @@ -337,7 +342,7 @@ test('hover intent is limited to fine pointers and click stays in-document', asy test('keyboard focus fetches one destination chunk before click', async ({ page }) => { const requestCount = countAttackRequests(page); - await gotoGenerate(page); + await gotoHome(page); const link = await attackLink(page); expect(requestCount()).toBe(0); @@ -355,7 +360,7 @@ test('keyboard focus fetches one destination chunk before click', async ({ page test('touch intent fetches one destination chunk before click', async ({ page }) => { const requestCount = countAttackRequests(page); - await gotoGenerate(page); + await gotoHome(page); const link = await attackLink(page); expect(requestCount()).toBe(0); @@ -378,7 +383,7 @@ for (const [label, connection] of [ test(`${label} skips speculative route requests for the same intent`, async ({ page }) => { await setConnection(page, connection); const requestCount = countAttackRequests(page); - await gotoGenerate(page); + await gotoHome(page); const link = await attackLink(page); expect(requestCount()).toBe(0); @@ -441,7 +446,8 @@ test('subscription reads stay cached across sibling tool routes', async ({ page await waitForContent(page, { selector: 'main h1', text: 'Generate' }); await expect.poll(() => subscriptionRequests.filter((name) => name === 'ai_requests').length).toBe(1); - await openAppNav(page, 'Attack'); + await openHome(page); + await homeToolLink(page, 'attack').click(); await waitForContent(page, attackContent); await page.waitForTimeout(100); diff --git a/e2e/quality-backlog.spec.ts b/e2e/quality-backlog.spec.ts index 392fb0508..4154a0a7d 100644 --- a/e2e/quality-backlog.spec.ts +++ b/e2e/quality-backlog.spec.ts @@ -1,12 +1,12 @@ import { expect, test } from '@playwright/test'; -import { setLocale, stubSupabaseSession } from './helpers/auth'; +import { seedCurrentDebate, setLocale, stubSupabaseSession } from './helpers/auth'; const locales = ['en', 'es', 'hu'] as const; const titledRoutes = ['generate', 'attack', 'brainstorm', 'factoids', 'ask'] as const; -const mobileNavigationNames = { - en: 'Mobile navigation', - es: 'Navegación móvil', - hu: 'Mobil navigáció', +const toolsHeadings = { + en: 'Prepare and practice', + es: 'Preparar y practicar', + hu: 'Felkészülés és gyakorlás', } as const; test('Sources does not show the stale consultation date', async ({ page }) => { @@ -17,7 +17,7 @@ test('Sources does not show the stale consultation date', async ({ page }) => { }); for (const locale of locales) { - test(`navigation and page titles agree in ${locale}`, async ({ page }, testInfo) => { + test(`navigation and page titles agree in ${locale}`, async ({ page }) => { await setLocale(page, locale); await stubSupabaseSession(page); await page.addInitScript(() => { @@ -26,27 +26,17 @@ for (const locale of locales) { version: 0, })); }); - await page.goto('/app'); + await seedCurrentDebate(page, 'This House would make public transport free.'); + // The Current Debate dashboard lists every tool under its navigation label. for (const route of titledRoutes) { - const desktopLink = page.locator(`[data-tour="nav-${route}"]`).first(); - const label = await desktopLink.getAttribute('aria-label'); + await page.goto('/app/debate'); + const toolLink = page.getByRole('region', { name: toolsHeadings[locale] }).locator(`a[href="/app/${route}"]`); + const label = (await toolLink.innerText()).trim(); expect(label).toBeTruthy(); - if (testInfo.project.name === 'mobile-chromium') { - await page.locator('header button.lg\\:hidden[aria-expanded]').click(); - await page.getByRole('navigation', { name: mobileNavigationNames[locale] }) - .getByRole('link') - .filter({ hasText: label! }) - .first() - .click(); - } else { - if (!await desktopLink.isVisible()) { - await page.locator('summary[data-tour="nav-more"]').click(); - } - await desktopLink.click(); - } + await toolLink.click(); await expect(page).toHaveURL(new RegExp(`/app/${route}$`)); - await expect(page.getByRole('heading', { level: 1, name: label!, exact: true }).last()).toBeVisible(); + await expect(page.getByRole('heading', { level: 1, name: label, exact: true }).last()).toBeVisible(); } }); } diff --git a/e2e/sparring.spec.ts b/e2e/sparring.spec.ts index 9d88550fe..64ff22b1d 100644 --- a/e2e/sparring.spec.ts +++ b/e2e/sparring.spec.ts @@ -68,6 +68,8 @@ test('completes an LD sparring round through mocked opponent turns and judging', await page.getByRole('combobox', { name: 'Round format' }).click(); await page.getByRole('option', { name: /Lincoln-Douglas · 5 speeches/ }).click(); await page.getByRole('button', { name: 'Start round' }).click(); + // No Current Debate yet: run this round once without saving it to a debate. + await page.getByRole('button', { name: 'Use once without saving to a debate' }).click(); const userSpeeches = [ ['Affirmative constructive', 'Universal access removes the direct price barrier to participation.'], @@ -110,6 +112,8 @@ test('uses the school 4-6-6-3 roles and coaching in the default round', async ({ await page.goto('/app/sparring', { waitUntil: 'domcontentloaded' }); await page.getByLabel('Motion').fill('This House would require community service for graduation.'); await page.getByRole('button', { name: 'Start round' }).click(); + // No Current Debate yet: run this round once without saving it to a debate. + await page.getByRole('button', { name: 'Use once without saving to a debate' }).click(); await expect.poll(() => page.evaluate(() => window.scrollY)).toBe(0); const introductor = page.getByRole('textbox', { name: 'Draft for Pro Introductor' }); diff --git a/scripts/test-service-worker-offline.mjs b/scripts/test-service-worker-offline.mjs index 433abcaf2..70420d5b6 100644 --- a/scripts/test-service-worker-offline.mjs +++ b/scripts/test-service-worker-offline.mjs @@ -90,7 +90,7 @@ async function cachePaths(page) { async function main() { const manifest = JSON.parse(await readFile('dist/.vite/manifest.json', 'utf8')); const entryPath = `/${Object.values(manifest).find((entry) => entry.isEntry).file}`; - const generatePath = `/${manifest['src/pages/GeneratePage.tsx'].file}`; + const homePath = `/${manifest['src/pages/HomePage.tsx'].file}`; const attackPath = `/${manifest['src/pages/AttackPage.tsx'].file}`; const port = await freePort(); const origin = `http://127.0.0.1:${port}`; @@ -167,34 +167,34 @@ async function main() { const page = await context.newPage(); await prepare(page); - await page.goto(`${origin}/app/generate`, { waitUntil: 'networkidle' }); - await page.getByRole('heading', { name: 'Generate', exact: true }).waitFor({ timeout: CONTENT_TIMEOUT_MS }); + await page.goto(`${origin}/app`, { waitUntil: 'networkidle' }); + await page.locator('main h1', { hasText: 'DebateLab' }).waitFor({ timeout: CONTENT_TIMEOUT_MS }); await page.evaluate(async () => navigator.serviceWorker.ready); await page.waitForFunction(async (paths) => { const urls = (await Promise.all((await caches.keys()).map(async (key) => (await (await caches.open(key)).keys()).map((request) => new URL(request.url).pathname) ))).flat(); return paths.every((path) => urls.includes(path)); - }, [entryPath, generatePath, '/favicon-72.png'], { timeout: CONTENT_TIMEOUT_MS }); + }, [entryPath, homePath, '/favicon-72.png'], { timeout: CONTENT_TIMEOUT_MS }); const firstVisitCache = await cachePaths(page); assert.equal(firstVisitCache.includes(attackPath), false, 'unvisited Attack chunk was cached'); await context.setOffline(true); await page.reload({ waitUntil: 'domcontentloaded' }); - await page.getByRole('heading', { name: 'Generate', exact: true }).waitFor({ timeout: CONTENT_TIMEOUT_MS }); + await page.locator('main h1', { hasText: 'DebateLab' }).waitFor({ timeout: CONTENT_TIMEOUT_MS }); const logo = page.locator('header a[href="/app"] img'); assert.equal(await logo.getAttribute('src'), '/favicon-72.png'); assert.ok(await logo.evaluate((image) => image.naturalWidth > 0), 'offline logo did not load'); - await page.getByRole('navigation', { name: 'Main navigation' }).getByRole('link', { name: 'Attack' }).click(); + await page.locator('main [data-tour="mode-attack"]').click(); await page.getByRole('heading', { name: 'Unable to load this page' }).waitFor({ timeout: CONTENT_TIMEOUT_MS }); await context.setOffline(false); const prefetchedPage = await context.newPage(); await prepare(prefetchedPage); - await prefetchedPage.goto(`${origin}/app/generate`, { waitUntil: 'networkidle' }); - await prefetchedPage.getByRole('heading', { name: 'Generate', exact: true }).waitFor({ timeout: CONTENT_TIMEOUT_MS }); - const attackLink = prefetchedPage.getByRole('navigation', { name: 'Main navigation' }).getByRole('link', { name: 'Attack' }); + await prefetchedPage.goto(`${origin}/app`, { waitUntil: 'networkidle' }); + await prefetchedPage.locator('main h1', { hasText: 'DebateLab' }).waitFor({ timeout: CONTENT_TIMEOUT_MS }); + const attackLink = prefetchedPage.locator('main [data-tour="mode-attack"]'); await attackLink.hover(); await prefetchedPage.waitForFunction(async (path) => { const urls = (await Promise.all((await caches.keys()).map(async (key) => diff --git a/src/App.tsx b/src/App.tsx index 0370e716a..14bf0fae4 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -24,6 +24,7 @@ const LandingPage = lazy(routeLoaders["/"]); const TermsPage = lazy(routeLoaders["/terms"]); const PrivacyPage = lazy(routeLoaders["/privacy"]); const HomePage = lazy(routeLoaders["/app"]); +const CurrentDebatePage = lazy(routeLoaders["/app/debate"]); const WorkspacesPage = lazy(routeLoaders["/app/workspaces"]); const WorkspaceInvitePage = lazy(routeLoaders["/app/workspaces/invite"]); const WorkspacePage = lazy(routeLoaders["/app/workspaces/:workspaceId"]); @@ -97,6 +98,7 @@ const ROUTE_FALLBACKS: Record = { "/expocit/sources": { variant: "public", label: "Sources", labelKey: "crossfire.sources" }, "/login": { variant: "public", label: "Sign in", labelKey: "login.title" }, "/app": { variant: "home-grid", label: "Home", labelKey: "nav.home" }, + "/app/debate": { variant: "settings/list", label: "Current debate", labelKey: "nav.currentDebate" }, "/app/workspaces": { variant: "settings/list", label: "Workspaces", labelKey: "nav.workspaces" }, "/app/generate": { variant: "tool-form", label: "Generate", labelKey: "nav.generate" }, "/app/attack": { variant: "tool-form", label: "Attack", labelKey: "nav.attack" }, @@ -240,6 +242,7 @@ function AppRoutes() { } > } /> + } /> {COLLABORATION_URL && ( <> } /> diff --git a/src/components/features/AutoSavedSourcesUndo.tsx b/src/components/features/AutoSavedSourcesUndo.tsx index f35bcd760..2e8470d0c 100644 --- a/src/components/features/AutoSavedSourcesUndo.tsx +++ b/src/components/features/AutoSavedSourcesUndo.tsx @@ -4,13 +4,16 @@ import { useTranslation } from 'react-i18next'; import { toast } from 'sonner'; import { Button } from '@/components/ui/button'; import { useSessionStore } from '@/lib/stores/session-store'; +import { useDebateStore } from '@/lib/stores/debate-store'; export function AutoSavedSourcesUndo() { const { t } = useTranslation(); const sourceLibrary = useSessionStore((state) => state.sourceLibrary); const removeSource = useSessionStore((state) => state.removeSource); const [removing, setRemoving] = useState(false); - const autoSavedSources = sourceLibrary.filter((source) => source.folder === 'Auto-saved'); + const currentDebateId = useDebateStore((state) => state.currentDebateId); + // Undo only this debate's auto-saved research; other debates keep theirs. + const autoSavedSources = sourceLibrary.filter((source) => source.folder === 'Auto-saved' && source.debateId === currentDebateId); if (autoSavedSources.length === 0) return null; diff --git a/src/components/features/brainstorm/BrainstormForm.tsx b/src/components/features/brainstorm/BrainstormForm.tsx index 3422aa7de..d3faac497 100644 --- a/src/components/features/brainstorm/BrainstormForm.tsx +++ b/src/components/features/brainstorm/BrainstormForm.tsx @@ -17,6 +17,7 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/com import { BrainstormInputSchema, type BrainstormInput } from '@/lib/ai/schemas/brainstorm'; import { LoadingSpinner } from '@/components/ui/loading-spinner'; +import { useDebateMotionPrefill } from '@/components/features/debate/CurrentDebateStrip'; import { useSessionStore } from '@/lib/stores/session-store'; import { useEffect } from 'react'; import { ModelSelector } from '@/components/ModelSelector'; @@ -24,13 +25,15 @@ import { ModelSelector } from '@/components/ModelSelector'; interface BrainstormFormProps { onSubmit: (data: BrainstormInput) => void; isLoading: boolean; + /** Fill the motion from the Current Debate. */ + debatePrefill?: boolean; } -export function BrainstormForm({ onSubmit, isLoading }: BrainstormFormProps) { +export function BrainstormForm({ onSubmit, isLoading, debatePrefill = true }: BrainstormFormProps) { const { t } = useTranslation(); const { brainstormForm, setBrainstormForm } = useSessionStore(); - const { register, handleSubmit, setValue, watch, formState: { errors } } = useForm({ + const { register, handleSubmit, setValue, getValues, watch, formState: { errors } } = useForm({ resolver: zodResolver(BrainstormInputSchema), defaultValues: { motion: '', @@ -49,6 +52,8 @@ export function BrainstormForm({ onSubmit, isLoading }: BrainstormFormProps) { return () => subscription.unsubscribe(); }, [watch, setBrainstormForm]); + useDebateMotionPrefill(() => getValues('motion'), (motion) => setValue('motion', motion, { shouldValidate: true }), !debatePrefill); + const currentValues = watch(); return ( diff --git a/src/components/features/debate/CurrentDebateMenu.tsx b/src/components/features/debate/CurrentDebateMenu.tsx new file mode 100644 index 000000000..f41ed2148 --- /dev/null +++ b/src/components/features/debate/CurrentDebateMenu.tsx @@ -0,0 +1,81 @@ +import { useState } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { useTranslation } from 'react-i18next'; +import { ChevronDown } from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'; +import { formatRelativeDate } from '@/lib/date-format'; +import { cn } from '@/lib/utils'; +import { useCurrentDebate, useDebateStore } from '@/lib/stores/debate-store'; +import { useDebateCounts } from './CurrentDebateStrip'; + +/** + * Header control for the Current Debate. Desktop shows only the label; the + * mobile row also shows the motion on one truncated line. + */ +export function CurrentDebateMenu({ variant }: { variant: 'desktop' | 'mobile' }) { + const { t } = useTranslation(); + const navigate = useNavigate(); + const debate = useCurrentDebate(); + const oneOff = useDebateStore((state) => state.oneOff); + const openDialog = useDebateStore((state) => state.openDialog); + const setOneOff = useDebateStore((state) => state.setOneOff); + const counts = useDebateCounts(debate?.id); + const [open, setOpen] = useState(false); + + const triggerClass = cn( + 'motion-press nav-glow flex min-w-0 items-center gap-1.5 rounded-md px-2 py-2 text-sm font-medium text-muted-foreground hover:bg-accent hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring', + variant === 'desktop' && 'xl:px-3 whitespace-nowrap', + variant === 'mobile' && 'w-full', + ); + + if (!debate) { + return ( + + ); + } + + const act = (action: () => void) => { + setOpen(false); + action(); + }; + + return ( + + + + + +
+

{debate.motion}

+

+ {t('debate.lastActivity', { when: formatRelativeDate(debate.lastActivityAt), defaultValue: 'Last activity {{when}}' })} +

+

+ {t('debate.counts', { sources: counts.sources, outputs: counts.outputs, defaultValue: '{{sources}} sources · {{outputs}} outputs' })} +

+ {oneOff &&

{t('debate.oneOffActive', 'One-off mode is on')}

} +
+
+ + + + +
+
+
+ ); +} diff --git a/src/components/features/debate/CurrentDebateStrip.tsx b/src/components/features/debate/CurrentDebateStrip.tsx new file mode 100644 index 000000000..5833b1a5e --- /dev/null +++ b/src/components/features/debate/CurrentDebateStrip.tsx @@ -0,0 +1,89 @@ +import { useEffect, useRef } from 'react'; +import { useTranslation } from 'react-i18next'; +import { Badge } from '@/components/ui/badge'; +import { Button } from '@/components/ui/button'; +import { shouldPrefillMotion } from '@/lib/debate/motion'; +import { useCurrentDebate, useDebateStore, type DebateSide } from '@/lib/stores/debate-store'; +import { useSessionStore } from '@/lib/stores/session-store'; +import { useSparringStore } from '@/lib/stores/sparring-store'; + +// eslint-disable-next-line react-refresh/only-export-components +export function sideLabelKey(side: DebateSide): string { + return `debate.side.${side}`; +} + +/** Sources and saved outputs that belong to one debate. */ +// eslint-disable-next-line react-refresh/only-export-components +export function useDebateCounts(debateId: string | null | undefined) { + const sources = useSessionStore((state) => debateId ? state.sourceLibrary.filter((source) => source.debateId === debateId).length : 0); + const outputs = useSessionStore((state) => debateId ? [ + state.savedCases, state.savedAttacks, state.savedBrainstorms, state.savedFactoids, + state.savedChats, state.savedFlowcharts, state.savedCrossfires, + ].reduce((count, list) => count + list.filter((item) => item.debateId === debateId).length, 0) : 0); + const sparring = useSparringStore((state) => debateId ? state.savedSessions.filter((session) => session.debateId === debateId).length : 0); + return { sources, outputs: outputs + sparring }; +} + +/** + * Fills a feature's motion from the Current Debate on open and after a switch, + * without overwriting text the user typed themselves. + */ +// eslint-disable-next-line react-refresh/only-export-components +export function useDebateMotionPrefill(getMotion: () => string | undefined, setMotion: (motion: string) => void, disabled = false) { + const debate = useCurrentDebate(); + const oneOff = useDebateStore((state) => state.oneOff); + const getRef = useRef(getMotion); + const setRef = useRef(setMotion); + getRef.current = getMotion; + setRef.current = setMotion; + + useEffect(() => { + if (disabled || oneOff || !debate) return; + const known = useDebateStore.getState().debates.map((item) => item.motion); + if (shouldPrefillMotion(getRef.current(), debate.motion, known)) setRef.current(debate.motion); + }, [debate, disabled, oneOff]); +} + +/** Compact “Working in” line directly below each feature title. */ +export function CurrentDebateStrip({ side, hidden = false, className = 'mb-6' }: { side?: DebateSide; hidden?: boolean; className?: string }) { + const { t } = useTranslation(); + const debate = useCurrentDebate(); + const oneOff = useDebateStore((state) => state.oneOff); + const setOneOff = useDebateStore((state) => state.setOneOff); + const openDialog = useDebateStore((state) => state.openDialog); + if (hidden) return null; + + const base = `${className} flex flex-wrap items-center gap-x-3 gap-y-2 rounded-lg border px-3 py-2 text-sm`; + + if (oneOff) { + return ( +
+ {t('debate.oneOffStrip', 'One-off mode: nothing here uses or saves to a debate.')} + {debate && } +
+ ); + } + + if (!debate) { + return ( +
+ {t('debate.noCurrentStrip', 'No current debate. Results stay one-off until you start one.')} + +
+ ); + } + + return ( +
+ + {t('debate.workingIn', 'Working in:')} + {debate.motion} + + {side && {t(sideLabelKey(side))}} +
+ + +
+
+ ); +} diff --git a/src/components/features/debate/DebateDialogs.tsx b/src/components/features/debate/DebateDialogs.tsx new file mode 100644 index 000000000..15ea8fec8 --- /dev/null +++ b/src/components/features/debate/DebateDialogs.tsx @@ -0,0 +1,226 @@ +import { useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { Check, Trash2 } from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '@/components/ui/dialog'; +import { + AlertDialog, + AlertDialogContent, + AlertDialogDescription, + AlertDialogHeader, + AlertDialogTitle, +} from '@/components/ui/alert-dialog'; +import { useToast } from '@/hooks/use-toast'; +import { formatRelativeDate } from '@/lib/date-format'; +import { cn } from '@/lib/utils'; +import { isDebateInactive, useCurrentDebate, useDebateStore, type Debate } from '@/lib/stores/debate-store'; +import { useSessionStore } from '@/lib/stores/session-store'; +import { DebateForm, type DebateFormValues } from './DebateForm'; + +/** Switching or starting a debate from navigation clears open tool results so debates never mix. */ +// eslint-disable-next-line react-refresh/only-export-components +export function useDebateNavigationActions() { + const switchDebate = useDebateStore((state) => state.switchDebate); + const createDebate = useDebateStore((state) => state.createDebate); + const clearSession = useSessionStore((state) => state.clearSession); + return { + switchTo: async (id: string) => { + await switchDebate(id); + clearSession(); + }, + create: async (values: DebateFormValues) => { + const debate = await createDebate(values); + clearSession(); + return debate; + }, + }; +} + +export function DebatePicker({ onPicked, excludeCurrent = false }: { onPicked: () => void; excludeCurrent?: boolean }) { + const { t } = useTranslation(); + const { toast } = useToast(); + const debates = useDebateStore((state) => state.debates); + const currentDebateId = useDebateStore((state) => state.currentDebateId); + const deleteDebate = useDebateStore((state) => state.deleteDebate); + const { switchTo } = useDebateNavigationActions(); + const [confirmingId, setConfirmingId] = useState(null); + const visible = excludeCurrent ? debates.filter((debate) => debate.id !== currentDebateId) : debates; + + if (visible.length === 0) { + return

{t('debate.noOtherDebates', 'You have no other debates yet.')}

; + } + + const pick = async (debate: Debate) => { + try { + await switchTo(debate.id); + onPicked(); + } catch { + toast({ title: t('debate.switchFailed', 'Could not switch debates'), variant: 'destructive' }); + } + }; + + const remove = async (debate: Debate) => { + try { + await deleteDebate(debate.id); + setConfirmingId(null); + toast({ title: t('debate.deleted', 'Debate deleted') }); + } catch { + toast({ title: t('debate.deleteFailed', 'Could not delete the debate'), variant: 'destructive' }); + } + }; + + return ( +
    + {visible.map((debate) => { + const isCurrent = debate.id === currentDebateId; + return ( +
  • +
    + + {isCurrent ? ( + + ) : ( + + )} +
    + {confirmingId === debate.id && ( +
    + {t('debate.deleteWarning', 'Deletes its sources and saved work.')} + + +
    + )} +
  • + ); + })} +
+ ); +} + +/** Global Switch and Start-new dialogs, opened from the header, strip, Home, or dashboard. */ +export function DebateDialogs() { + const { t } = useTranslation(); + const dialog = useDebateStore((state) => state.dialog); + const dialogMotion = useDebateStore((state) => state.dialogMotion); + const openDialog = useDebateStore((state) => state.openDialog); + const { create } = useDebateNavigationActions(); + const close = () => openDialog(null); + + return ( + { if (!open) close(); }}> + + {dialog === 'create' ? ( + <> + + {t('debate.startNew', 'Start new debate')} + {t('debate.startNewDesc', 'Your tools, sources, and results will follow this motion. Older debates stay available.')} + + { + await create(values); + close(); + }} + /> + + ) : ( + <> + + {t('debate.switch', 'Switch debate')} + {t('debate.switchDesc', 'Pick the motion you are preparing now.')} + + + + + )} + + + ); +} + +/** + * After five days without meaningful work the app is blocked until the user + * confirms the Current Debate, starts a new one, or switches. + */ +export function InactiveDebateGate() { + const { t } = useTranslation(); + const { toast } = useToast(); + const debate = useCurrentDebate(); + const hydrated = useDebateStore((state) => state.hydrated); + const continueDebate = useDebateStore((state) => state.continueDebate); + const { create } = useDebateNavigationActions(); + const [mode, setMode] = useState<'ask' | 'create' | 'switch'>('ask'); + + const open = Boolean(hydrated && debate && isDebateInactive(debate)); + if (!open || !debate) return null; + + const handleContinue = async () => { + try { + await continueDebate(); + } catch { + toast({ title: t('debate.continueFailed', 'Could not update the debate'), variant: 'destructive' }); + } + }; + + return ( + + event.preventDefault()}> + + {t('debate.gateTitle', 'Still preparing this debate?')} + +
+

{debate.motion}

+

{t('debate.gateDesc', { when: formatRelativeDate(debate.lastActivityAt), defaultValue: 'Last activity {{when}}. Choose how to continue.' })}

+
+
+
+ {mode === 'ask' && ( +
+ + + +
+ )} + {mode === 'create' && ( + setMode('ask')} + onSubmit={async (values) => { + await create(values); + setMode('ask'); + }} + /> + )} + {mode === 'switch' && ( +
+ setMode('ask')} /> + +
+ )} +
+
+ ); +} diff --git a/src/components/features/debate/DebateForm.tsx b/src/components/features/debate/DebateForm.tsx new file mode 100644 index 000000000..d21e628b6 --- /dev/null +++ b/src/components/features/debate/DebateForm.tsx @@ -0,0 +1,122 @@ +import { useState, type FormEvent } from 'react'; +import { useTranslation } from 'react-i18next'; +import { Button } from '@/components/ui/button'; +import { Label } from '@/components/ui/label'; +import { Textarea } from '@/components/ui/textarea'; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; +import { MAX_INSTRUCTIONS_LENGTH, MAX_MOTION_LENGTH, type DebateSettings } from '@/lib/stores/debate-store'; + +export interface DebateFormValues { + motion: string; + settings: DebateSettings; + instructions: string; +} + +const FORMATS = ['custom', 'bp', 'policy', 'classroom'] as const; +const ROLES = ['general', 'intro', 'r1', 'r2', 'conc'] as const; +const ROLE_LABEL_KEYS: Record<(typeof ROLES)[number], string> = { + general: 'debate.roleAny', + intro: 'generateForm.roleIntro', + r1: 'generateForm.roleR1', + r2: 'generateForm.roleR2', + conc: 'generateForm.roleConc', +}; + +interface DebateFormProps { + initial?: Partial; + submitLabel: string; + onSubmit: (values: DebateFormValues) => Promise; + onCancel?: () => void; + /** Motion is fixed when editing only settings. */ + showDetails?: boolean; + idPrefix?: string; +} + +export function DebateForm({ initial, submitLabel, onSubmit, onCancel, showDetails = true, idPrefix = 'debate' }: DebateFormProps) { + const { t } = useTranslation(); + const [motion, setMotion] = useState(initial?.motion ?? ''); + const [format, setFormat] = useState(initial?.settings?.format ?? 'custom'); + const [role, setRole] = useState(initial?.settings?.role ?? 'general'); + const [instructions, setInstructions] = useState(initial?.instructions ?? ''); + const [saving, setSaving] = useState(false); + const [error, setError] = useState(''); + + const submit = async (event: FormEvent) => { + event.preventDefault(); + if (!motion.trim()) { + setError(t('debate.motionRequired', 'Enter the motion you are preparing.')); + return; + } + setSaving(true); + setError(''); + try { + await onSubmit({ motion: motion.trim(), settings: { format, role }, instructions: instructions.trim() }); + } catch (submitError) { + setError(submitError instanceof Error ? submitError.message : t('debate.saveFailed', 'Could not save the debate.')); + } finally { + setSaving(false); + } + }; + + return ( +
+
+ +