Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 5 additions & 3 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,14 +17,16 @@ 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.

## Client state

[`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.
Expand Down Expand Up @@ -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

Expand Down
4 changes: 2 additions & 2 deletions e2e/admin-announcements.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
33 changes: 11 additions & 22 deletions e2e/animation-polish.spec.ts
Original file line number Diff line number Diff line change
@@ -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 };
Expand All @@ -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({
Expand Down Expand Up @@ -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}$`));
}

Expand Down Expand Up @@ -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;
Expand All @@ -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 });
Expand Down
16 changes: 15 additions & 1 deletion e2e/crossfire.spec.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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,
Expand Down Expand Up @@ -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);
Expand Down
7 changes: 6 additions & 1 deletion e2e/full-stack/account-data.spec.ts
Original file line number Diff line number Diff line change
@@ -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)");
Expand All @@ -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.");
Expand Down Expand Up @@ -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();
Expand All @@ -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"]);

Expand Down
3 changes: 2 additions & 1 deletion e2e/full-stack/ai-tools.spec.ts
Original file line number Diff line number Diff line change
@@ -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)");

Expand All @@ -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");
Expand Down
3 changes: 2 additions & 1 deletion e2e/full-stack/auth.spec.ts
Original file line number Diff line number Diff line change
@@ -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)");

Expand Down Expand Up @@ -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 });
});
3 changes: 2 additions & 1 deletion e2e/full-stack/billing.spec.ts
Original file line number Diff line number Diff line change
@@ -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)");

Expand All @@ -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) {
Expand Down
9 changes: 7 additions & 2 deletions e2e/full-stack/case-generation.spec.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,13 @@
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)");

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");
Expand Down Expand Up @@ -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();
Expand All @@ -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();
Expand All @@ -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()}`;
Expand All @@ -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();
Expand Down
Loading
Loading