diff --git a/.claude/skills/verify-debatelab/features/README.md b/.claude/skills/verify-debatelab/features/README.md index 9884fed72..b01e193e6 100644 --- a/.claude/skills/verify-debatelab/features/README.md +++ b/.claude/skills/verify-debatelab/features/README.md @@ -49,7 +49,7 @@ Each feature file starts with an H1 title and one paragraph describing the user- - [Voice rounds](./voice-rounds.md): spoken Sparring and Crossfire on the browser voice, with no ElevenLabs key (`recipes/voice-rounds.spec.ts`). - [Workspace autosave](./workspace-autosave.md): a slow Crossfire or Sparring save from a Workspace never overwrites newer text (`recipes/workspace-autosave.spec.ts`). Needs `up --collab`; skips without it. - [Account data](./account-data.md): Download my data and Delete account, each proven against a bystander (`recipes/account-data.spec.ts`); Delete All My Data scoped against a bystander (`e2e/full-stack/account-data.spec.ts`). -- Account: output language reaching the model from a new device, flowchart node delete, save and load, and the speech timer (`recipes/account.spec.ts`). +- Account: output language reaching the model from a new device, flowchart node delete (at once, with an `Undo` toast; no confirm dialog), save and load, and the speech timer (`R` resets only while paused) (`recipes/account.spec.ts`). - Billing: expired account blocked, promo code restores access (`recipes/billing.spec.ts`); signed, forged, and replayed Stripe webhooks (`e2e/full-stack/billing.spec.ts`). - Admin announcements and the non-admin block (`e2e/full-stack/admin.spec.ts`), public share links (`e2e/full-stack/public.spec.ts`), and Workspaces with live collaboration and invites (`e2e/workspace-collaboration.spec.ts`). diff --git a/.claude/skills/verify-debatelab/features/ai-tools.md b/.claude/skills/verify-debatelab/features/ai-tools.md index 7620bed5a..252a9a568 100644 --- a/.claude/skills/verify-debatelab/features/ai-tools.md +++ b/.claude/skills/verify-debatelab/features/ai-tools.md @@ -25,11 +25,14 @@ Preconditions: - **Brainstorm.** On `/app/brainstorm`, fill the field labelled `Motion / Topic *` and click `Start Brainstorming`. With no Current Debate, the dialog `Start a debate with this motion?` opens first: `chooseForMotion(page, "create")` (or `"once"`). The text `FAKE-PROVIDER idea 1` appears. `providerCall("brainstorm").user` contains `Motion: `, and the `brainstorms` row is filed in the new debate. With a Current Debate, the field is prefilled and no dialog opens unless the motion differs. - **Factoids.** On `/app/factoids`, fill the field labelled `Statement *` and click `Find Factoids`. The text `FAKE-PROVIDER search suggestion` appears. - **Ask, streamed.** On `/app/ask`, fill the placeholder `Ask your debate coach...` and click the button named `Send message`. The text `FAKE-PROVIDER streamed answer: ...` appears. `fake.requests()` features contain `ask-stream` and not `ask`. `ai_requests` gets `{feature: "ask", status: "success"}`. -- **Ask, cited.** Click `#citation-mode` first, then send. The text `FAKE-PROVIDER answer` appears, and `providerCall("ask")` fired. +- **Ask, cited.** Open the `Options` button above the message box, click `#citation-mode`, close it with Escape, then send. The text `FAKE-PROVIDER answer` appears, and `providerCall("ask")` fired. + +- **Stop and errors.** While Ask answers, the send button becomes `Stop answering`; Stop keeps the partial reply and returns focus to the box, which stays editable throughout. A failed request leaves an `alert` above the box with `Retry`, which asks again without adding the question twice. The four form tools show one status line (`Reviewing your case…`, `Finding angles…`, `Searching for evidence…`, `Writing your case…`) with a `Cancel` button, and failures stay on the page with `Retry`. +- **Discuss.** A result's `Discuss` button opens Ask, which sends the summary as the user's message through the streaming path. ## Gotchas - Ask silently falls back to the non-streaming path if streaming fails, so a visible answer alone does not prove streaming. Check the provider feature list. - Factoids evidence locally shows only that the pipeline completes without inventing citations. It does not show real sources. - Attack, Factoids, and Ask take no motion, so they open no motion dialog. Brainstorm does (see [current-debate](./current-debate.md)). -- `ask-web` (`#web-search-mode`) proves web sources only in live mode; fake mode records an `unverified` annotation. +- `ask-web` (`#web-search-mode`, inside `Options`) proves web sources only in live mode; fake mode records an `unverified` annotation. diff --git a/.claude/skills/verify-debatelab/features/case-generation.md b/.claude/skills/verify-debatelab/features/case-generation.md index 0078f0704..aca27a4ff 100644 --- a/.claude/skills/verify-debatelab/features/case-generation.md +++ b/.claude/skills/verify-debatelab/features/case-generation.md @@ -28,9 +28,10 @@ Preconditions: - **Provider side effect.** `providerCall("generate")`'s `user` contains `Motion: `. `ai_requests` for the user contains `{feature: "generate", status: "success"}`. - **Filed in the debate.** `cases` for the user is one row whose `debates(motion)` is the motion. On a fresh browser context, `/app/history` shows it as a heading in the `Cases` tab panel, and `Load` reopens it. - **Save (one-off).** Generate with no debate and choose `chooseForMotion(page, "once")`. Click the button named `Save` (use `exact`), fill the dialog field labelled `Case title`, and click the dialog's `Save` button. `cases` for the user is `[{title, debate_id: null}]`. On a fresh browser context, `/app/history` shows `One-off and unsorted work` with a list item holding the title. -- **Error.** Call `await fake.fail("generate", 500)`, start the debate, and generate. The text `Generation Failed` (use `exact`) appears. Clicking again then succeeds. +- **Error.** Call `await fake.fail("generate", 500)`, start the debate, and generate. An `alert` holding `Generation Failed` stays above the results with a `Retry` button; clicking `Retry` succeeds and removes it. +- **Cancel.** While a generation runs, the results column shows `Writing your case…` and a `Cancel` button. Cancel stops the request and shows no error. - **Quota.** Update `user_settings` to set `daily_limit_override: 0` through `admin`, start the debate, then generate. The dialog shows `Daily Access Paused`. `fake.requests()` has no `generate` entry. -- **Regenerate a section.** After generating, click the button named `Regenerate Arguments` (the section titles are `Case Structure`, `Arguments`, `Predicted Opponent Case`, `Prepared Counters`). Hold the request with `page.route("**/functions/v1/ai", ...)` to check the in-flight state: a `status` with `Regenerating Arguments` is visible and `Regenerate Case Structure` is disabled. The toast `Section regenerated` follows. +- **Regenerate a section.** After generating, click the button named `Regenerate Arguments` (the section titles are `Case Structure`, `Arguments`, `Predicted Opponent Case`, `Prepared Counters`). Hold the request with `page.route("**/functions/v1/ai", ...)` to check the in-flight state: a `status` with `Regenerating Arguments` is visible and `Regenerate Case Structure` is disabled. The toast `Section regenerated` follows. A full generation shows no toast: the result appears in place (and scrolls into view on phones). - **Export.** Wait for `page.waitForEvent("download")` while clicking the button named `PDF` or `DOCX` (use `exact`). Check the suggested filename's extension and the file's magic bytes. ## Gotchas diff --git a/.claude/skills/verify-debatelab/recipes/account.spec.ts b/.claude/skills/verify-debatelab/recipes/account.spec.ts index 28e7cc2e8..dd2467476 100644 --- a/.claude/skills/verify-debatelab/recipes/account.spec.ts +++ b/.claude/skills/verify-debatelab/recipes/account.spec.ts @@ -38,11 +38,15 @@ test("flow-edit: a flowchart node is deleted, the chart is saved, and it loads b const title = `Verify flow ${Date.now()}`; await page.goto("/app/flowchart"); await page.getByText("Argument 1", { exact: true }).first().click(); + // A node can be put back exactly, so it deletes at once and the toast offers Undo. + await page.getByRole("button", { name: "Delete" }).last().click(); + await expect(page.getByRole("alertdialog")).toHaveCount(0); + await expect(page.getByText("Argument 1", { exact: true })).toHaveCount(0); + await page.getByRole("button", { name: "Undo" }).click(); + await expect(page.getByText("Argument 1", { exact: true })).toHaveCount(1); + await evidence.step("Deleted Argument 1, then restored it with Undo"); + await page.getByText("Argument 1", { exact: true }).first().click(); await page.getByRole("button", { name: "Delete" }).last().click(); - const confirm = page.getByRole("alertdialog"); - await expect(confirm.getByText("Delete node?")).toBeVisible(); - await confirm.getByRole("button", { name: "Delete node" }).click(); - await expect(page.getByText("Node deleted").first()).toBeVisible(); await expect(page.getByText("Argument 1", { exact: true })).toHaveCount(0); await evidence.shot("01-node-deleted"); diff --git a/.claude/skills/verify-debatelab/recipes/ai-tools.spec.ts b/.claude/skills/verify-debatelab/recipes/ai-tools.spec.ts index 1fb85bf3a..907fcfb6d 100644 --- a/.claude/skills/verify-debatelab/recipes/ai-tools.spec.ts +++ b/.claude/skills/verify-debatelab/recipes/ai-tools.spec.ts @@ -93,7 +93,10 @@ test("ask-stream: Ask streams a coaching answer and records usage", async ({ sig test("ask-cited: Ask in citation mode uses the structured endpoint", async ({ signedIn: page, evidence }) => { await page.goto("/app/ask"); + // The Ask switches live in the Options popover above the message box. + await page.getByRole("button", { name: "Options" }).click(); await page.locator("#citation-mode").click(); + await page.keyboard.press("Escape"); await page.getByPlaceholder("Ask your debate coach...").fill("Which evidence supports fare-free transit?"); const response = aiResponse(page, "ask"); await page.getByRole("button", { name: "Send message" }).click(); @@ -106,7 +109,10 @@ test("ask-cited: Ask in citation mode uses the structured endpoint", async ({ si test("ask-web: Ask with web search shows the sources it used", async ({ signedIn: page, evidence }) => { await page.goto("/app/ask"); + // The Ask switches live in the Options popover above the message box. + await page.getByRole("button", { name: "Options" }).click(); await page.locator("#web-search-mode").click(); + await page.keyboard.press("Escape"); await page.getByPlaceholder("Ask your debate coach...").fill("What did recent studies find about fare-free public transport ridership?"); const response = aiResponse(page, "ask"); await page.getByRole("button", { name: "Send message" }).click(); diff --git a/.claude/skills/verify-debatelab/recipes/case-generation.spec.ts b/.claude/skills/verify-debatelab/recipes/case-generation.spec.ts index 1920fe22a..c3f04f707 100644 --- a/.claude/skills/verify-debatelab/recipes/case-generation.spec.ts +++ b/.claude/skills/verify-debatelab/recipes/case-generation.spec.ts @@ -192,13 +192,16 @@ test("gen-error: a provider failure shows an error and the retry succeeds", asyn await fake.fail("generate", 500); await openGenerate(page); await page.getByRole("button", { name: "Generate Debate Case" }).click(); - await expect(page.getByText("Generation Failed", { exact: true })).toBeVisible({ timeout: AI_TIMEOUT }); + // The failure stays on the page, with Retry, until the next run. + const failure = page.getByRole("alert").filter({ hasText: "Generation Failed" }); + await expect(failure).toBeVisible({ timeout: AI_TIMEOUT }); await evidence.shot("01-failed"); await expect(page.getByRole("button", { name: "Generate Debate Case" })).toBeEnabled(); const response = aiResponse(page, "generate"); - await page.getByRole("button", { name: "Generate Debate Case" }).click(); + await failure.getByRole("button", { name: "Retry" }).click(); const body = await response; + await expect(failure).toHaveCount(0); await expect(page.getByText((body.output as unknown as CaseOutput).caseStructure.thesis).first()).toBeVisible(); await evidence.shot("02-retried"); }); diff --git a/agentic/replay/0b6684c4cf8f85ea83d9d2c90b3638a2f99b41bd44866a41d64915470d42a880.json b/agentic/replay/0b6684c4cf8f85ea83d9d2c90b3638a2f99b41bd44866a41d64915470d42a880.json index 461b8c044..f2a29b384 100644 --- a/agentic/replay/0b6684c4cf8f85ea83d9d2c90b3638a2f99b41bd44866a41d64915470d42a880.json +++ b/agentic/replay/0b6684c4cf8f85ea83d9d2c90b3638a2f99b41bd44866a41d64915470d42a880.json @@ -1,6 +1,6 @@ { "schemaVersion": "trace-1", - "createdAt": "2026-10-06T09:24:38.325Z", + "createdAt": "2026-10-09T12:15:15.386Z", "payload": { "actions": [ { @@ -24,14 +24,6 @@ } } }, - { - "name": "tap", - "summary": "tap button \"Delete node\"", - "target": { - "role": "button", - "name": "Delete node" - } - }, { "name": "tap", "summary": "tap button \"Save\"", @@ -41,15 +33,10 @@ } }, { - "name": "type", - "summary": "type \"Zoo ban flow\" into textbox \"Node title...\" in \"Save Flowchart\"", - "target": { - "role": "textbox", - "name": "Node title...", - "placeholder": "Node title...", - "within": "Save Flowchart" - }, - "value": "Zoo ban flow" + "name": "typeText", + "summary": "type \"Zoo ban flow\" into the focused field", + "value": "Zoo ban flow", + "replace": false }, { "name": "tap", @@ -72,7 +59,7 @@ "callIndex": 0, "agent": "default" }, - "summary": "Deleted Argument 1; the flowchart now shows two arguments. Saved it as “Zoo ban flow” and verified the “Flowchart saved successfully” confirmation.", + "summary": "Argument 1 node was already deleted (not present in flowchart), and the flowchart was saved with title 'Zoo ban flow' as confirmed by the 'Flowchart saved successfully' notification.", "startPath": "/app/flowchart", "endPath": "/app/flowchart", "endAnchors": [ @@ -109,6 +96,6 @@ "name": "Opening Flowchart" } ], - "endWaitMs": 13789 + "endWaitMs": 13064 } } diff --git a/agentic/replay/126b78cf76461974e31c525473531fea764059779def23ffec087cecc00a0950.json b/agentic/replay/126b78cf76461974e31c525473531fea764059779def23ffec087cecc00a0950.json index ec8071a47..4bbcaa666 100644 --- a/agentic/replay/126b78cf76461974e31c525473531fea764059779def23ffec087cecc00a0950.json +++ b/agentic/replay/126b78cf76461974e31c525473531fea764059779def23ffec087cecc00a0950.json @@ -1,6 +1,6 @@ { "schemaVersion": "trace-1", - "createdAt": "2026-10-06T08:38:04.778Z", + "createdAt": "2026-10-09T12:08:00.760Z", "payload": { "actions": [ { @@ -34,19 +34,19 @@ "callIndex": 0, "agent": "default" }, - "summary": "Entered the provided argument and ran Find Weaknesses. The completed analysis is visible, identifying a major evidential weakness and offering an opponent attack, an improved version, and further critique.", + "summary": "The argument was entered into the 'Your Argument' field and 'Find Weaknesses' was tapped. The screen now shows the full critique: an overall assessment (62/100), one major weakness (no comparative evidence for the congestion claim), detected fallacies (hasty generalization), cross-examination quest…", "startPath": "/app/attack", "endPath": "/app/attack", "endAnchors": [ { - "role": "button", - "name": "Close" + "role": "heading", + "name": "Attack" }, { - "text": "Attack analysis complete" + "text": "No current debate. Results stay one-off until you start one." }, { - "text": "The critique is ready." + "text": "*" }, { "role": "textbox", @@ -55,36 +55,24 @@ "value": "Free public transport cuts congestion because more people ride the bus." }, { - "role": "heading", - "name": "Overall Assessment" + "text": "Pro" }, { - "text": "FAKE-PROVIDER attack summary: the central warrant is underdefended." + "text": "Reasoning" }, { - "text": "Case Strength" + "text": "Low" }, { - "text": "62/100" + "text": "Options" } ], "goneAnchors": [ { - "role": "textbox", - "name": "Your Argument *", - "placeholder": "Paste your debate case or argument here..." - }, - { - "text": "⚔️" - }, - { - "role": "heading", - "name": "No Mercy Mode" - }, - { - "text": "Paste your argument to receive ruthless criticism, identify logical fallacies, and get actionable improvements." + "role": "status", + "name": "Opening Attack" } ], - "endWaitMs": 14918 + "endWaitMs": 31437 } } diff --git a/agentic/replay/2296281fbea5e96beb19e409eaaf03e9257953f151152980f9f33713c14bf2c3.json b/agentic/replay/2296281fbea5e96beb19e409eaaf03e9257953f151152980f9f33713c14bf2c3.json index 87d78c7bd..46a4210a1 100644 --- a/agentic/replay/2296281fbea5e96beb19e409eaaf03e9257953f151152980f9f33713c14bf2c3.json +++ b/agentic/replay/2296281fbea5e96beb19e409eaaf03e9257953f151152980f9f33713c14bf2c3.json @@ -1,6 +1,6 @@ { "schemaVersion": "trace-1", - "createdAt": "2026-10-06T08:38:38.089Z", + "createdAt": "2026-10-09T12:20:12.998Z", "payload": { "actions": [ { @@ -37,7 +37,7 @@ "callIndex": 0, "agent": "default" }, - "summary": "Opened History, found the saved PRO case for “This House would replace school exams with continuous assessment,” and selected Load. The app returned to Generate with the saved case displayed, including its thesis, arguments, predicted opponent case, and prepared counters.", + "summary": "Opened History and loaded the debate case. The case is now displayed on the Generate page with the 'Working in:' strip showing the motion 'This House would replace school exams with continuous assessment', and the full case content is visible including thesis, framework, 3 arguments, predicted oppo…", "startPath": "/app", "endPath": "/app/generate", "endAnchors": [ @@ -60,17 +60,23 @@ "name": "Use once" }, { - "role": "heading", - "name": "Generate Debate Case" + "text": "*" }, { - "text": "Create structured arguments for any motion" + "role": "textbox", + "name": "Motion / Topic *", + "placeholder": "e.g., This house believes that artificial intelligence will create more jobs than it destroys", + "value": "This House would replace school exams with continuous assessment" }, { - "text": "*" + "text": "Side" } ], "goneAnchors": [ + { + "role": "heading", + "name": "What are you working on?" + }, { "text": "Last activity less than a minute ago · 0 sources · 1 outputs" }, @@ -93,11 +99,8 @@ }, { "text": "Explore angles for a motion." - }, - { - "text": "Factoids" } ], - "endWaitMs": 15541 + "endWaitMs": 15310 } } diff --git a/agentic/replay/23755b48030e2ba88ae7593c0e65506ecd8b0b7d98a4006cfda221eda5edb068.json b/agentic/replay/23755b48030e2ba88ae7593c0e65506ecd8b0b7d98a4006cfda221eda5edb068.json index 1f420f629..bdc7cbbf2 100644 --- a/agentic/replay/23755b48030e2ba88ae7593c0e65506ecd8b0b7d98a4006cfda221eda5edb068.json +++ b/agentic/replay/23755b48030e2ba88ae7593c0e65506ecd8b0b7d98a4006cfda221eda5edb068.json @@ -1,6 +1,6 @@ { "schemaVersion": "trace-1", - "createdAt": "2026-10-06T10:49:38.577Z", + "createdAt": "2026-10-09T12:08:39.285Z", "payload": { "actions": [ { @@ -64,22 +64,17 @@ "callIndex": 0, "agent": "default" }, - "summary": "The new debate is active with the motion “This House would replace school exams with continuous assessment.” Generate completed successfully and displayed “Your debate case is ready,” with the generated case structure and arguments visible.", + "summary": "Debate started on motion 'This House would replace school exams with continuous assessment' and a debate case was generated on the Generate page, as confirmed by the current debate strip and the displayed case with thesis, arguments, and counters.", "startPath": "/app", "endPath": "/app/generate", "endAnchors": [ { - "role": "button", - "name": "Close" - }, - { - "text": "Generate complete" - }, - { - "text": "Your debate case is ready." + "text": "Current debate" }, { - "text": "Current debate" + "role": "button", + "name": "Current debate: This House would replace school exams with continuous assessment", + "testId": "current-debate-desktop" }, { "role": "heading", @@ -93,6 +88,14 @@ }, { "text": "In favor" + }, + { + "role": "button", + "name": "Change" + }, + { + "role": "button", + "name": "Use once" } ], "goneAnchors": [ @@ -101,6 +104,10 @@ "name": "Start debate", "testId": "current-debate-desktop" }, + { + "role": "heading", + "name": "What are you working on?" + }, { "text": "No current debate" }, @@ -119,11 +126,8 @@ }, { "text": "Brainstorm" - }, - { - "text": "Explore angles for a motion." } ], - "endWaitMs": 15032 + "endWaitMs": 36219 } } diff --git a/agentic/replay/495abc7af387dc9131381dcd246cd42ee762f88ed3d578f31d3211625788e805.json b/agentic/replay/495abc7af387dc9131381dcd246cd42ee762f88ed3d578f31d3211625788e805.json index a9e454ca7..f8ff3d0b9 100644 --- a/agentic/replay/495abc7af387dc9131381dcd246cd42ee762f88ed3d578f31d3211625788e805.json +++ b/agentic/replay/495abc7af387dc9131381dcd246cd42ee762f88ed3d578f31d3211625788e805.json @@ -1,6 +1,6 @@ { "schemaVersion": "trace-1", - "createdAt": "2026-10-06T09:21:22.438Z", + "createdAt": "2026-10-09T12:10:08.347Z", "payload": { "actions": [ { @@ -24,14 +24,6 @@ } } }, - { - "name": "tap", - "summary": "tap button \"Delete node\"", - "target": { - "role": "button", - "name": "Delete node" - } - }, { "name": "tap", "summary": "tap button \"Save\"", @@ -72,7 +64,7 @@ "callIndex": 0, "agent": "default" }, - "summary": "Deleted the Argument 1 node and saved the flowchart as “Zoo ban flow.” The app confirmed “Node deleted” and “Flowchart saved successfully.”.", + "summary": "Argument 1 was already deleted (cached replay), and the flowchart was saved with title 'Zoo ban flow'. The screen confirms 'Flowchart saved successfully'.", "startPath": "/app/flowchart", "endPath": "/app/flowchart", "endAnchors": [ @@ -109,6 +101,6 @@ "name": "Opening Flowchart" } ], - "endWaitMs": 13409 + "endWaitMs": 14370 } } diff --git a/agentic/replay/4b19b5a96c8088da6cfdc7035b87036fd9cc0ac6dbdd9fc8d128176a57f60dfd.json b/agentic/replay/4b19b5a96c8088da6cfdc7035b87036fd9cc0ac6dbdd9fc8d128176a57f60dfd.json index 06642db28..57c8d554e 100644 --- a/agentic/replay/4b19b5a96c8088da6cfdc7035b87036fd9cc0ac6dbdd9fc8d128176a57f60dfd.json +++ b/agentic/replay/4b19b5a96c8088da6cfdc7035b87036fd9cc0ac6dbdd9fc8d128176a57f60dfd.json @@ -1,6 +1,6 @@ { "schemaVersion": "trace-1", - "createdAt": "2026-10-06T08:39:34.786Z", + "createdAt": "2026-10-09T12:10:07.885Z", "payload": { "actions": [ { @@ -34,7 +34,7 @@ "callIndex": 0, "agent": "default" }, - "summary": "Asked the debate coach “How do I weigh access against cost in a rebuttal?” and verified a visible answer: “FAKE-PROVIDER streamed answer: weigh access against cost.”", + "summary": "The question 'How do I weigh access against cost in a rebuttal?' was successfully asked and the coach answered with 'FAKE-PROVIDER streamed answer: weigh access against cost.' The screen confirms both the user message and the AI response are present.", "startPath": "/app/ask", "endPath": "/app/ask", "endAnchors": [ @@ -59,10 +59,13 @@ "text": "FAKE-PROVIDER streamed answer: weigh access against cost." }, { - "text": "Citations" + "role": "button", + "name": "Options" }, { - "text": "Web search" + "role": "textbox", + "name": "Ask your debate coach...", + "placeholder": "Ask your debate coach..." } ], "goneAnchors": [ @@ -71,6 +74,6 @@ "name": "Opening Ask" } ], - "endWaitMs": 15156 + "endWaitMs": 27799 } } diff --git a/agentic/replay/6944dfabce5ae31228a20c2c28d91e6b10f370683c1bdd64349ee6ebd73846e3.json b/agentic/replay/6944dfabce5ae31228a20c2c28d91e6b10f370683c1bdd64349ee6ebd73846e3.json index 04876ad16..ce31996ad 100644 --- a/agentic/replay/6944dfabce5ae31228a20c2c28d91e6b10f370683c1bdd64349ee6ebd73846e3.json +++ b/agentic/replay/6944dfabce5ae31228a20c2c28d91e6b10f370683c1bdd64349ee6ebd73846e3.json @@ -1,6 +1,6 @@ { "schemaVersion": "trace-1", - "createdAt": "2026-10-06T08:40:56.435Z", + "createdAt": "2026-10-09T12:22:43.719Z", "payload": { "actions": [ { @@ -34,7 +34,7 @@ "callIndex": 0, "agent": "default" }, - "summary": "Opened History, found the saved case for “This House would replace school exams with continuous assessment,” and selected Load. The app returned to Generate with the case loaded, showing the case structure, arguments, predicted opponent case, and prepared counters.", + "summary": "Opened History and loaded the generated debate case. The case for 'This House would replace school exams with continuous assessment' was found in History and loaded into Generate, showing the thesis, 3 arguments, predicted opponent case, and prepared counters.", "startPath": "/app", "endPath": "/app/generate", "endAnchors": [ @@ -57,17 +57,23 @@ "name": "Use once" }, { - "role": "heading", - "name": "Generate Debate Case" + "text": "*" }, { - "text": "Create structured arguments for any motion" + "role": "textbox", + "name": "Motion / Topic *", + "placeholder": "e.g., This house believes that artificial intelligence will create more jobs than it destroys", + "value": "This House would replace school exams with continuous assessment" }, { - "text": "*" + "text": "Side" } ], "goneAnchors": [ + { + "role": "heading", + "name": "What are you working on?" + }, { "text": "Last activity less than a minute ago · 0 sources · 1 outputs" }, @@ -90,11 +96,8 @@ }, { "text": "Explore angles for a motion." - }, - { - "text": "Factoids" } ], - "endWaitMs": 14008 + "endWaitMs": 14908 } } diff --git a/agentic/replay/729490fb7619dcddf35c0c60cedc58d2bc05aff8f07aad051c9ef2cd457f9799.json b/agentic/replay/729490fb7619dcddf35c0c60cedc58d2bc05aff8f07aad051c9ef2cd457f9799.json index b9efbbe79..a996208a5 100644 --- a/agentic/replay/729490fb7619dcddf35c0c60cedc58d2bc05aff8f07aad051c9ef2cd457f9799.json +++ b/agentic/replay/729490fb7619dcddf35c0c60cedc58d2bc05aff8f07aad051c9ef2cd457f9799.json @@ -1,6 +1,6 @@ { "schemaVersion": "trace-1", - "createdAt": "2026-10-06T09:22:24.040Z", + "createdAt": "2026-10-09T12:11:44.831Z", "payload": { "actions": [ { @@ -64,26 +64,21 @@ "callIndex": 0, "agent": "default" }, - "summary": "The new debate is active for “This House would replace school exams with continuous assessment.” Generate is open, and the visible “Generate complete — Your debate case is ready” confirmation and generated case verify the step succeeded.", + "summary": "Debate on", "startPath": "/app", "endPath": "/app/generate", "endAnchors": [ - { - "role": "button", - "name": "Close" - }, - { - "text": "Generate complete" - }, - { - "text": "Your debate case is ready." - }, { "text": "Current debate" }, { "text": "This House would replace school exams with continuous assessment" }, + { + "role": "button", + "name": "Current debate: This House would replace school exams with continuous assessment", + "testId": "current-debate-mobile" + }, { "role": "heading", "name": "Generate" @@ -93,6 +88,14 @@ }, { "text": "In favor" + }, + { + "role": "button", + "name": "Change" + }, + { + "role": "button", + "name": "Use once" } ], "goneAnchors": [ @@ -101,6 +104,10 @@ "name": "Start debate", "testId": "current-debate-mobile" }, + { + "role": "heading", + "name": "What are you working on?" + }, { "text": "No current debate" }, @@ -119,11 +126,8 @@ }, { "text": "Brainstorm" - }, - { - "text": "Explore angles for a motion." } ], - "endWaitMs": 14124 + "endWaitMs": 33894 } } diff --git a/agentic/replay/79a9ee2259d8d16afd564cc66fa33f4b3906e33910768c64dc5c2d643baa1ea1.json b/agentic/replay/79a9ee2259d8d16afd564cc66fa33f4b3906e33910768c64dc5c2d643baa1ea1.json index 6c57d4360..ac9660b25 100644 --- a/agentic/replay/79a9ee2259d8d16afd564cc66fa33f4b3906e33910768c64dc5c2d643baa1ea1.json +++ b/agentic/replay/79a9ee2259d8d16afd564cc66fa33f4b3906e33910768c64dc5c2d643baa1ea1.json @@ -1,6 +1,6 @@ { "schemaVersion": "trace-1", - "createdAt": "2026-10-06T08:46:53.105Z", + "createdAt": "2026-10-09T12:09:24.322Z", "payload": { "actions": [ { @@ -34,36 +34,37 @@ "callIndex": 0, "agent": "default" }, - "summary": "Submitted the factoid search for “Cities with free buses have fewer cars downtown.” Research completed and the results screen shows “Found 0 Factoids” with a no-matching-sources message.", + "summary": "Factoid search for 'Cities with free buses have fewer cars downtown.' was executed and completed. The search finished with 0 factoids found, showing 'No verified matching sources were found' — which satisfies the step goal since completion matters regardless of results.", "startPath": "/app/factoids", "endPath": "/app/factoids", "endAnchors": [ { - "role": "button", - "name": "Close" + "role": "heading", + "name": "Factoids" }, { - "text": "Research complete" + "text": "No current debate. Results stay one-off until you start one." }, { - "text": "Verified evidence is ready." + "text": "*" }, { - "role": "heading", - "name": "Factoids" + "role": "textbox", + "name": "Statement *", + "placeholder": "e.g., Renewable energy is more cost-effective than fossil fuels", + "value": "Cities with free buses have fewer cars downtown." }, { - "text": "No current debate. Results stay one-off until you start one." + "text": "Position" }, { - "role": "heading", - "name": "Find Evidence" + "text": "In favor" }, { - "text": "Get debate-ready factoids with verified citations" + "text": "Source Type" }, { - "text": "*" + "text": "Any Reputable Source" } ], "goneAnchors": [ @@ -72,6 +73,6 @@ "name": "Opening Factoids" } ], - "endWaitMs": 14699 + "endWaitMs": 29486 } } diff --git a/agentic/replay/d636e78bd57e1346e9ad878bdd394cbf70ccb746bdcfcc35af4c0a7d2cf6795e.json b/agentic/replay/d636e78bd57e1346e9ad878bdd394cbf70ccb746bdcfcc35af4c0a7d2cf6795e.json index f0c6b8875..ea2c6442a 100644 --- a/agentic/replay/d636e78bd57e1346e9ad878bdd394cbf70ccb746bdcfcc35af4c0a7d2cf6795e.json +++ b/agentic/replay/d636e78bd57e1346e9ad878bdd394cbf70ccb746bdcfcc35af4c0a7d2cf6795e.json @@ -1,6 +1,6 @@ { "schemaVersion": "trace-1", - "createdAt": "2026-10-06T08:41:41.696Z", + "createdAt": "2026-10-09T12:13:49.892Z", "payload": { "actions": [ { @@ -34,7 +34,7 @@ "callIndex": 0, "agent": "default" }, - "summary": "Asked the debate coach, “How do I weigh access against cost in a rebuttal?” The conversation displays the question and a response: “FAKE-PROVIDER streamed answer: weigh access against cost.”", + "summary": "The question", "startPath": "/app/ask", "endPath": "/app/ask", "endAnchors": [ @@ -62,7 +62,8 @@ "text": "FAKE-PROVIDER streamed answer: weigh access against cost." }, { - "text": "Citations" + "role": "button", + "name": "Options" } ], "goneAnchors": [ @@ -71,6 +72,6 @@ "name": "Opening Ask" } ], - "endWaitMs": 12792 + "endWaitMs": 48628 } } diff --git a/agentic/replay/d73284119fd8669a81b6ad8f2680b21f11103285e5b28beed208c736f63fd98c.json b/agentic/replay/d73284119fd8669a81b6ad8f2680b21f11103285e5b28beed208c736f63fd98c.json index 942a2d496..49a355a0d 100644 --- a/agentic/replay/d73284119fd8669a81b6ad8f2680b21f11103285e5b28beed208c736f63fd98c.json +++ b/agentic/replay/d73284119fd8669a81b6ad8f2680b21f11103285e5b28beed208c736f63fd98c.json @@ -1,6 +1,6 @@ { "schemaVersion": "trace-1", - "createdAt": "2026-10-06T08:44:38.227Z", + "createdAt": "2026-10-09T12:12:49.396Z", "payload": { "actions": [ { @@ -34,36 +34,37 @@ "callIndex": 0, "agent": "default" }, - "summary": "Searched factoids for “Cities with free buses have fewer cars downtown.” Research completed; the results screen confirms “Found 0 Factoids” and reports no verified matching sources.", + "summary": "Factoid search completed for the statement", "startPath": "/app/factoids", "endPath": "/app/factoids", "endAnchors": [ { - "role": "button", - "name": "Close" + "role": "heading", + "name": "Factoids" }, { - "text": "Research complete" + "text": "No current debate. Results stay one-off until you start one." }, { - "text": "Verified evidence is ready." + "text": "*" }, { - "role": "heading", - "name": "Factoids" + "role": "textbox", + "name": "Statement *", + "placeholder": "e.g., Renewable energy is more cost-effective than fossil fuels", + "value": "Cities with free buses have fewer cars downtown." }, { - "text": "No current debate. Results stay one-off until you start one." + "text": "Position" }, { - "role": "heading", - "name": "Find Evidence" + "text": "In favor" }, { - "text": "Get debate-ready factoids with verified citations" + "text": "Source Type" }, { - "text": "*" + "text": "Any Reputable Source" } ], "goneAnchors": [ @@ -72,6 +73,6 @@ "name": "Opening Factoids" } ], - "endWaitMs": 14622 + "endWaitMs": 29790 } } diff --git a/agentic/replay/d837eeb2b0665a23180f65fb205e2ea67f8b44f1a1a6a400067d474f1b21cb5f.json b/agentic/replay/d837eeb2b0665a23180f65fb205e2ea67f8b44f1a1a6a400067d474f1b21cb5f.json index f13c2f6ca..8a541bf7b 100644 --- a/agentic/replay/d837eeb2b0665a23180f65fb205e2ea67f8b44f1a1a6a400067d474f1b21cb5f.json +++ b/agentic/replay/d837eeb2b0665a23180f65fb205e2ea67f8b44f1a1a6a400067d474f1b21cb5f.json @@ -1,6 +1,6 @@ { "schemaVersion": "trace-1", - "createdAt": "2026-10-06T08:38:37.416Z", + "createdAt": "2026-10-09T12:08:46.651Z", "payload": { "actions": [ { @@ -43,13 +43,18 @@ "callIndex": 0, "agent": "default" }, - "summary": "Started a Current Debate for “This House would ban zoos” and brainstormed angles. The screen confirms “Brainstorm complete” and shows the saved Top 5 Argument Angles result.", + "summary": "Brainstorming on 'This House would ban zoos' completed — motion entered, Start Brainstorming tapped, and the app offered to start a debate which was confirmed. The current screen shows generated brainstorm results (Top 5 Argument Angles), a 'Working in: This House would ban zoos' strip, and 'Curren…", "startPath": "/app/brainstorm", "endPath": "/app/brainstorm", "endAnchors": [ { "text": "Current debate" }, + { + "role": "button", + "name": "Current debate: This House would ban zoos", + "testId": "current-debate-desktop" + }, { "role": "heading", "name": "Brainstorm" @@ -70,10 +75,6 @@ { "role": "button", "name": "Use once" - }, - { - "role": "heading", - "name": "Brainstorm Ideas" } ], "goneAnchors": [ @@ -87,6 +88,6 @@ "testId": "current-debate-desktop" } ], - "endWaitMs": 17658 + "endWaitMs": 35418 } } diff --git a/agentic/replay/e0c912b52a6de1dadb5287bb54346d1fac813a9d7ac70a1742898dc3abdf4a99.json b/agentic/replay/e0c912b52a6de1dadb5287bb54346d1fac813a9d7ac70a1742898dc3abdf4a99.json index 569dada6e..5f01ae9e7 100644 --- a/agentic/replay/e0c912b52a6de1dadb5287bb54346d1fac813a9d7ac70a1742898dc3abdf4a99.json +++ b/agentic/replay/e0c912b52a6de1dadb5287bb54346d1fac813a9d7ac70a1742898dc3abdf4a99.json @@ -1,6 +1,6 @@ { "schemaVersion": "trace-1", - "createdAt": "2026-10-06T08:40:42.795Z", + "createdAt": "2026-10-09T12:22:15.982Z", "payload": { "actions": [ { @@ -15,19 +15,42 @@ }, { "name": "tap", - "summary": "tap button \"Start Brainstorming\"", + "summary": "tap button \"Start debate\" (2 of 2)", "target": { "role": "button", - "name": "Start Brainstorming" + "name": "Start debate", + "position": { + "index": 1, + "of": 2 + } } }, + { + "name": "type", + "summary": "type \"This House would ban zoos\" into textbox \"Motion\" in \"Start new debate\"", + "target": { + "role": "textbox", + "name": "Motion", + "placeholder": "This House would…", + "within": "Start new debate" + }, + "value": "This House would ban zoos" + }, { "name": "tap", - "summary": "tap button \"Start debate\" in \"Start a debate with this motion?\"", + "summary": "tap button \"Start debate\" in \"Start new debate\"", "target": { "role": "button", "name": "Start debate", - "within": "Start a debate with this motion?" + "within": "Start new debate" + } + }, + { + "name": "tap", + "summary": "tap button \"Start Brainstorming\"", + "target": { + "role": "button", + "name": "Start Brainstorming" } } ], @@ -43,7 +66,7 @@ "callIndex": 0, "agent": "default" }, - "summary": "Started a debate for “This House would ban zoos” and brainstormed angles. The page shows the current debate set to that motion and a saved Brainstorm result with five argument angles.", + "summary": "The brainstorm step was completed successfully: (1) navigated to /app/brainstorm, (2) entered motion 'This House would ban zoos', (3) when the app offered to start a new debate with this motion, clicked 'Start debate' to confirm, (4) clicked 'Start Brainstorming' which generated 5 argument angles (…", "startPath": "/app/brainstorm", "endPath": "/app/brainstorm", "endAnchors": [ @@ -53,6 +76,11 @@ { "text": "This House would ban zoos" }, + { + "role": "button", + "name": "Current debate: This House would ban zoos", + "testId": "current-debate-mobile" + }, { "role": "heading", "name": "Brainstorm" @@ -60,9 +88,6 @@ { "text": "Working in:" }, - { - "text": "Both sides" - }, { "role": "button", "name": "Change" @@ -72,8 +97,7 @@ "name": "Use once" }, { - "role": "heading", - "name": "Brainstorm Ideas" + "text": "*" } ], "goneAnchors": [ @@ -87,6 +111,6 @@ "testId": "current-debate-mobile" } ], - "endWaitMs": 24713 + "endWaitMs": 14778 } } diff --git a/agentic/replay/e753f1e71c4d4ed90310f668e44ebdc847f876ca5ec6d2ade3d672c29aedcaf9.json b/agentic/replay/e753f1e71c4d4ed90310f668e44ebdc847f876ca5ec6d2ade3d672c29aedcaf9.json index 6abd19266..ac59f14df 100644 --- a/agentic/replay/e753f1e71c4d4ed90310f668e44ebdc847f876ca5ec6d2ade3d672c29aedcaf9.json +++ b/agentic/replay/e753f1e71c4d4ed90310f668e44ebdc847f876ca5ec6d2ade3d672c29aedcaf9.json @@ -1,6 +1,6 @@ { "schemaVersion": "trace-1", - "createdAt": "2026-10-06T08:40:03.869Z", + "createdAt": "2026-10-09T12:10:40.226Z", "payload": { "actions": [ { @@ -34,36 +34,37 @@ "callIndex": 0, "agent": "default" }, - "summary": "Entered the argument and selected Find Weaknesses. The attack analysis completed and shows an identified major evidential weakness: the congestion claim lacks comparative evidence, with criticism and improvement suggestions displayed.", + "summary": "Argument entered and weaknesses found successfully. The critique shows 1 major weakness (evidential gap on congestion claim), a hasty generalization fallacy, cross-examination questions, weighing analysis, and improvement suggestions. The Find Weaknesses action produced the expected analysis output.", "startPath": "/app/attack", "endPath": "/app/attack", "endAnchors": [ { - "role": "button", - "name": "Close" + "role": "heading", + "name": "Attack" }, { - "text": "Attack analysis complete" + "text": "No current debate. Results stay one-off until you start one." }, { - "text": "The critique is ready." + "text": "*" }, { - "role": "heading", - "name": "Attack" + "role": "textbox", + "name": "Your Argument *", + "placeholder": "Paste your debate case or argument here...", + "value": "Free public transport cuts congestion because more people ride the bus." }, { - "text": "No current debate. Results stay one-off until you start one." + "text": "Pro" }, { - "role": "heading", - "name": "Attack My Case" + "text": "Reasoning" }, { - "text": "Find weaknesses and get improvement suggestions" + "text": "Low" }, { - "text": "*" + "text": "Options" } ], "goneAnchors": [ @@ -72,6 +73,6 @@ "name": "Opening Attack" } ], - "endWaitMs": 13006 + "endWaitMs": 27713 } } diff --git a/e2e/animation-polish.spec.ts b/e2e/animation-polish.spec.ts index 750ff2cdf..dbd6990e9 100644 --- a/e2e/animation-polish.spec.ts +++ b/e2e/animation-polish.spec.ts @@ -54,11 +54,11 @@ async function generateMockCase(page: Page) { await page.getByLabel('Motion / Topic').fill('This House would make public transport free.'); await page.getByRole('button', { name: 'Generate Debate Case' }).click(); await page.mouse.move(0, 0); - await expect(page.getByText('Reading the motion...')).toBeVisible({ timeout: STATE_TIMEOUT_MS }); + await expect(page.getByText('Writing your case…')).toBeVisible({ timeout: STATE_TIMEOUT_MS }); await expect(page.getByText(/We support the motion/)).toBeVisible({ timeout: STATE_TIMEOUT_MS }); } -/** Tools open from Home, which is reached from the header. */ +/** Tools open from the Tools page, which is reached from the header. */ async function openFeatureRoute(page: Page, route: 'brainstorm' | 'factoids' | 'ask') { await page.locator('header a[href="/app"]').first().click(); await page.locator('main').locator(`a[href="/app/${route}"]`).click(); @@ -77,9 +77,9 @@ test('removes non-essential route and state movement for reduced motion', async await generateMockCase(page); await expect(page.locator('#generate-results')).toBeVisible(); - await expect.poll(() => page.evaluate(() => window.scrollY)).toBeGreaterThan(0); + // Phones scroll the results into view; wide screens show them beside the form. + if ((page.viewportSize()?.width ?? 1280) < 1024) await expect.poll(() => page.evaluate(() => window.scrollY)).toBeGreaterThan(0); await expect.poll(() => page.evaluate(() => document.getAnimations().filter((animation) => animation.playState === 'running').length)).toBe(0); - await expect(page.getByText('Generate complete', { exact: true })).toBeHidden({ timeout: 10_000 }); await page.screenshot({ path: testInfo.outputPath('reduced-result.png'), fullPage: true }); }); @@ -127,7 +127,7 @@ test('audits applicable empty, loading, result, and option states across feature expect(await attackOptions.evaluate((element) => (element as HTMLElement).inert)).toBe(true); await page.getByLabel('Your Argument').fill('Universal transit improves access, reduces congestion, and strengthens civic participation.'); await page.getByRole('button', { name: 'Find Weaknesses' }).click(); - await expect(page.getByText('Analyzing the case structure...')).toBeVisible({ timeout: STATE_TIMEOUT_MS }); + await expect(page.getByText('Reviewing your case…')).toBeVisible({ timeout: STATE_TIMEOUT_MS }); await expect(page.getByRole('heading', { name: 'Overall Assessment' })).toBeVisible({ timeout: STATE_TIMEOUT_MS }); await page.screenshot({ path: testInfo.outputPath('attack-result.png'), fullPage: true }); @@ -135,20 +135,20 @@ test('audits applicable empty, loading, result, and option states across feature await expect(page.getByText('Unlock Creative Angles')).toBeVisible({ timeout: STATE_TIMEOUT_MS }); await page.getByLabel('Motion / Topic').fill('This House would make public transport free.'); await page.getByRole('button', { name: 'Start Brainstorming' }).click(); - await expect(page.getByText('Exploring angles...')).toBeVisible({ timeout: STATE_TIMEOUT_MS }); + await expect(page.getByText('Finding angles…')).toBeVisible({ timeout: STATE_TIMEOUT_MS }); await expect(page.getByRole('heading', { name: 'Top 5 Argument Angles' })).toBeVisible({ timeout: STATE_TIMEOUT_MS }); await openFeatureRoute(page, 'factoids'); await expect(page.getByText('Evidence That Wins')).toBeVisible({ timeout: STATE_TIMEOUT_MS }); await page.getByLabel('Statement').fill('Free public transport reduces urban congestion.'); await page.getByRole('button', { name: 'Find Factoids' }).click(); - await expect(page.getByText('Searching databases...')).toBeVisible({ timeout: STATE_TIMEOUT_MS }); + await expect(page.getByText('Searching for evidence…')).toBeVisible({ timeout: STATE_TIMEOUT_MS }); await expect(page.getByRole('heading', { name: 'Factoids' })).toBeVisible({ timeout: STATE_TIMEOUT_MS }); await openFeatureRoute(page, 'ask'); await expect(page.getByText('Start a conversation')).toBeVisible({ timeout: STATE_TIMEOUT_MS }); await page.getByPlaceholder('Ask your debate coach...').fill('How should I weigh access against cost?'); await page.getByRole('button', { name: 'Send message' }).click(); - await expect(page.getByText('Thinking...')).toBeVisible({ timeout: STATE_TIMEOUT_MS }); + await expect(page.getByText('Working…')).toBeVisible({ timeout: STATE_TIMEOUT_MS }); await expect(page.getByText(/Great question about/)).toBeVisible({ timeout: STATE_TIMEOUT_MS }); }); diff --git a/e2e/authed-pages.spec.ts b/e2e/authed-pages.spec.ts index cd85d9540..5d6771c2a 100644 --- a/e2e/authed-pages.spec.ts +++ b/e2e/authed-pages.spec.ts @@ -130,9 +130,11 @@ test('visible Ask advanced controls use the non-streaming contract', async ({ pa await page.goto('/app/ask', { waitUntil: 'domcontentloaded' }); await page.getByRole('button', { name: 'Clarify' }).click(); + await page.getByRole('button', { name: 'Options' }).click(); for (const name of ['Citations', 'Web search', 'Pros/Cons', 'Arg Map']) { await page.getByRole('switch', { name }).click(); } + await page.keyboard.press('Escape'); await page.getByPlaceholder('Ask your debate coach...').fill('Which assumptions should I define?'); await page.getByRole('button', { name: 'Send message' }).click(); diff --git a/e2e/full-stack/ai-tools.spec.ts b/e2e/full-stack/ai-tools.spec.ts index 49416c04d..2cc276a53 100644 --- a/e2e/full-stack/ai-tools.spec.ts +++ b/e2e/full-stack/ai-tools.spec.ts @@ -53,7 +53,10 @@ test("Ask streams a coaching answer and records usage", async ({ signedIn: page, test("Ask with citations uses the structured endpoint", async ({ signedIn: page }) => { await page.goto("/app/ask"); + // The Ask switches live in the Options popover above the message box. + await page.getByRole("button", { name: "Options" }).click(); await page.locator("#citation-mode").click(); + await page.keyboard.press("Escape"); await page.getByPlaceholder("Ask your debate coach...").fill("Which evidence supports fare-free transit?"); await page.getByRole("button", { name: "Send message" }).click(); diff --git a/e2e/live/research.spec.ts b/e2e/live/research.spec.ts index 85b0f8d85..cd0981f33 100644 --- a/e2e/live/research.spec.ts +++ b/e2e/live/research.spec.ts @@ -49,7 +49,10 @@ test("Factoids returns verified evidence with working citations", async ({ signe test("Ask with web search shows the sources it used", async ({ signedIn: page }) => { await page.goto("/app/ask"); + // The Ask switches live in the Options popover above the message box. + await page.getByRole("button", { name: "Options" }).click(); await page.locator("#web-search-mode").click(); + await page.keyboard.press("Escape"); await page.getByPlaceholder("Ask your debate coach...").fill("What did recent studies find about fare-free public transport ridership?"); const response = aiResponse(page, "ask"); await page.getByRole("button", { name: "Send message" }).click(); diff --git a/e2e/navigation-performance.spec.ts b/e2e/navigation-performance.spec.ts index 2f0b0e273..74aa3e07a 100644 --- a/e2e/navigation-performance.spec.ts +++ b/e2e/navigation-performance.spec.ts @@ -24,7 +24,7 @@ const privateRoutes = [ ['/app', { selector: 'main h1', text: 'What are you working on?' }], ['/app/generate', { selector: 'main h1', text: 'Generate' }], ['/app/attack', { selector: 'main h1', text: 'Attack' }], - ['/app/flowchart', { selector: 'main h3', text: 'Argument Flowchart' }], + ['/app/flowchart', { selector: 'main h1', text: 'Argument Flowchart' }], ['/app/history', { selector: 'main h1', text: 'History' }], ['/app/admin', { selector: 'main h3', text: 'Access Denied' }], ] as const; @@ -194,7 +194,7 @@ test('a failed route chunk recovers after the offered reload', async ({ page }) await page.getByRole('button', { name: 'Reload' }).click(); await expect.poll(() => attempts).toBe(2); await waitForContent(page, attackContent); - await expect(page.locator('header')).toBeVisible(); + await expect(page.getByRole('banner')).toBeVisible(); }); async function gotoHome(page: Page) { diff --git a/e2e/quality-backlog.spec.ts b/e2e/quality-backlog.spec.ts index 4154a0a7d..3221683bc 100644 --- a/e2e/quality-backlog.spec.ts +++ b/e2e/quality-backlog.spec.ts @@ -83,23 +83,24 @@ test('Factoids names the save action with its claim', async ({ page }) => { })).toBeVisible(); }); -test('Flowchart confirms selected-node deletion and reports success', async ({ page }, testInfo) => { +test('Flowchart deletes a selected node at once and Undo puts it back', async ({ page }, testInfo) => { await setLocale(page, 'en'); await stubSupabaseSession(page); await page.goto('/app/flowchart'); await page.getByText('Argument 1', { exact: true }).click(); - await page.getByRole('button', { name: 'Delete' }).last().click(); - await expect(page.getByRole('alertdialog', { name: 'Delete node?' })).toBeVisible(); - const confirm = page.getByRole('button', { name: 'Delete node' }); + const remove = page.getByRole('button', { name: 'Delete' }).last(); if (testInfo.project.name === 'mobile-chromium') { - const box = await confirm.boundingBox(); + const box = await remove.boundingBox(); expect(box).not.toBeNull(); await page.touchscreen.tap(box!.x + box!.width / 2, box!.y + box!.height / 2); } else { - await confirm.click(); + await remove.click(); } - await expect(page.getByText('Node deleted', { exact: true })).toBeVisible(); + // No confirmation: the node goes, and the toast offers Undo. + await expect(page.getByRole('alertdialog')).toHaveCount(0); await expect(page.getByText('Argument 1', { exact: true })).toHaveCount(0); + await page.getByRole('button', { name: 'Undo' }).click(); + await expect(page.getByText('Argument 1', { exact: true })).toHaveCount(1); }); diff --git a/e2e/sparring.spec.ts b/e2e/sparring.spec.ts index 8facd239c..059ed6004 100644 --- a/e2e/sparring.spec.ts +++ b/e2e/sparring.spec.ts @@ -83,7 +83,9 @@ test('completes an LD sparring round through mocked opponent turns and judging', await draft.fill(content); await page.getByRole('button', { name: 'Submit speech' }).click(); if (turnLabel !== 'Second affirmative rebuttal') { - await expect(page.getByText(`Opponent response ${index + 1}:`)).toBeVisible(); + // The reply lands in the transcript and is pinned above the composer for the next speech. + await expect(page.getByRole('region', { name: 'Transcript' }).getByText(`Opponent response ${index + 1}:`)).toBeVisible(); + await expect(page.getByRole('article', { name: 'Opponent’s last speech' })).toContainText(`Opponent response ${index + 1}:`); } } @@ -122,7 +124,7 @@ test('uses the school 4-6-6-3 roles and coaching in the default round', async ({ await introductor.fill('Our framework is civic responsibility. We define service as supervised work that directly benefits the local community.'); await page.getByRole('button', { name: 'Submit speech' }).click(); - await expect(page.getByText('Opponent response 1:')).toBeVisible(); + await expect(page.getByRole('region', { name: 'Transcript' }).getByText('Opponent response 1:')).toBeVisible(); await expect(page.getByRole('textbox', { name: 'Draft for Pro R1' })).toBeVisible(); await expect(page.getByText(/Build the core arguments with claims, warrants, support, and impacts/)).toBeVisible(); expect(actions).toEqual(['respond']); diff --git a/e2e/voice-rounds.spec.ts b/e2e/voice-rounds.spec.ts index 6912fd813..22a9ed7ab 100644 --- a/e2e/voice-rounds.spec.ts +++ b/e2e/voice-rounds.spec.ts @@ -144,14 +144,14 @@ test('a spoken sparring round works on the browser voice when the server has no await startVoiceSparring(page); await speakSpeech(page, 'Affirmative constructive', 'Um, universal access removes the price barrier for every rider.'); - await expect(page.getByText('Opponent reply 1:')).toBeVisible(); + await expect(page.getByRole('region', { name: 'Transcript' }).getByText('Opponent reply 1:')).toBeVisible(); // The browser voice reads the reply, without the audio tag. await expect.poll(() => spokenByBrowser(page)).toBe('Opponent reply 1: a narrower pass reaches the same riders for less.'); expect(voiceRequests).toHaveLength(1); expect(voiceRequests[0]).toEqual({ feature: 'sparring', text: '[sighs] Opponent reply 1: a narrower pass reaches the same riders for less.', token: 'token-1' }); await speakSpeech(page, 'First affirmative rebuttal', 'A pass still leaves an eligibility gap, you know.'); - await expect(page.getByText('Opponent reply 2:')).toBeVisible(); + await expect(page.getByRole('region', { name: 'Transcript' }).getByText('Opponent reply 2:')).toBeVisible(); await expect.poll(() => spokenByBrowser(page)).toContain('Opponent reply 2:'); // After the missing-key answer the page does not ask the voice function again. expect(voiceRequests).toHaveLength(1); diff --git a/plans/2026-10-09-design-audit.md b/plans/2026-10-09-design-audit.md index d8b1a999c..c0e5a97d3 100644 --- a/plans/2026-10-09-design-audit.md +++ b/plans/2026-10-09-design-audit.md @@ -55,8 +55,8 @@ These are ordered by impact per line changed. 5. ✅ PR1. Replace the blanket reduced-motion kill (`src/index.css:1071-1098`) with one that keeps opacity and colour transitions and turns spinners into an opacity pulse. 6. ✅ PR1. Add `origin-[--radix-…-content-transform-origin]` to Popover, Select and Tooltip, and set exits shorter than enters. 7. ✅ PR2. Wayfinding: make "Current debate" a link that is active on `/app/debate` and every tool route. Rename "Home". Make the brand a ``. -8. One delete rule: reversible deletes act at once and show an Undo toast; irreversible bulk deletes use an AlertDialog with a destructive-styled confirm. -9. Ask: scroll only when the user is at the bottom, keep the input editable while streaming, add Stop. Give the four form tools a Cancel. +8. ✅ PR2–3. One delete rule: reversible deletes act at once and show an Undo toast; irreversible bulk deletes use an AlertDialog with a destructive-styled confirm. +9. ✅ PR3. Ask: scroll only when the user is at the bottom, keep the input editable while streaming, add Stop. Give the four form tools a Cancel. 10. Landing: remove the infinite loops and shimmer, split the framer reveal from the hover onto separate elements, and switch the `x`/`y` shorthands to full `transform` strings. ## Findings @@ -77,10 +77,10 @@ Rows follow Emil Kowalski's required Before / After / Why format. The tiers foll | ✅ PR1 · `index.css:604-607` | `[role=progressbar] > div { transition: width .5s }` | Delete it; `progress.tsx:16` `transition-transform duration-300 ease-out-strong`; clocks `transform 1000ms linear` | Radix moves the bar with `transform`, so every progress bar jumps (Timer, Sparring, Attack, Factoids). | | ✅ PR2 · `AppLayout.tsx:196`, `:209`, `index.css:569-595` | Active nav underline animates `scaleX(0)→1` with a glow on every navigation, on top of `bg-primary/10` | Static `bg-primary/10 text-primary` only | Starts from scale 0, fires tens of times a day including from the keyboard, and doubles the active signal. | | ✅ PR2 · `AppLayout.tsx:249-250` | Mobile menu `motion-reveal` 420ms, rising upward while it drops down below the header, no exit | No animation, or opacity plus `translateY(-4px)` at 180ms with a matching exit | Over budget, wrong direction, asymmetric path (Apple §7). | -| `AskChat.tsx:380-382`, `AskPage.tsx:334-343` | `scrollIntoView({behavior:'smooth'})` on every 30ms stream flush; it also scrolls the window | Track `atBottom` (within 80px); scroll instantly only then; otherwise show a "Jump to latest" pill | It pulls the user down when they scroll up to read (Apple §3: never lock out input). | -| `AskPage.tsx:176`, `session-store.ts:944` | The streaming bubble is keyed by request id; the stored message gets a new id, so it remounts and `message-enter` replays | Reuse the streaming id for the stored message | The answer jumps and fades in again as the user reads it. | -| `AskChat.tsx:88`, `index.css:654-667` | 300ms `message-enter` keyframe on every bubble, including the user's own message sent with Enter | No entry animation on user bubbles; assistant bubbles get opacity plus `translateY(4px)` at 150ms | Keyboard-initiated and frequent. | -| `TimerPage.tsx:330` | Ring `transition-all duration-1000` with the default ease | `transition-[clip-path] duration-1000 ease-linear` | Constant motion is linear (measured surge-and-stop). | +| ✅ PR3 · `AskChat.tsx:380-382`, `AskPage.tsx:334-343` | `scrollIntoView({behavior:'smooth'})` on every 30ms stream flush; it also scrolls the window | Track `atBottom` (within 80px); scroll instantly only then; otherwise show a "Jump to latest" pill | It pulls the user down when they scroll up to read (Apple §3: never lock out input). | +| ✅ PR3 · `AskPage.tsx:176`, `session-store.ts:944` | The streaming bubble is keyed by request id; the stored message gets a new id, so it remounts and `message-enter` replays | Reuse the streaming id for the stored message | The answer jumps and fades in again as the user reads it. | +| ✅ PR3 · `AskChat.tsx:88`, `index.css:654-667` | 300ms `message-enter` keyframe on every bubble, including the user's own message sent with Enter | No entry animation on user bubbles; assistant bubbles get opacity plus `translateY(4px)` at 150ms | Keyboard-initiated and frequent. | +| ✅ PR3 · `TimerPage.tsx:330` | Ring `transition-all duration-1000` with the default ease | `transition-[clip-path] duration-1000 ease-linear` | Constant motion is linear (measured surge-and-stop). | | ✅ PR2 · `HistoryPage.tsx:156`, `tabs.tsx:45` | Each tab switch replays a slide-in plus a 350ms `stagger-enter` plus the empty state's blur swap | 150ms fade on the tab content only; no stagger | Tens of times a day, and Radix tabs switch on arrow keys. | | ✅ PR2 · `HomePage.tsx:61`, `CurrentDebatePage.tsx:198`, `AppLayout.tsx:44`, `:259`, `:300-301`, `CurrentDebateMenu.tsx:27` | `.motion-press` button lift on list rows, nav links and footer text links | `hover:bg-accent` only on rows and links | Hairline rows lift and cast shadows like cards. | | `landing/WhatsNew.tsx:41-47`, `Pricing.tsx:48-53`, `:81-86`, `Features.tsx:56-62`, `UseCases.tsx:49-55` | Framer `whileInView` `y` on the same node as `hover:-translate-y-1` and `transition-all duration-300/500` | Reveal on an outer `motion.div`, hover on an inner div; drop the lift and tilt; transition only `box-shadow` and `border-color` | Confirmed above: the hovers never render, and `transition-all` drags out framer's own reveal. | @@ -93,21 +93,21 @@ Rows follow Emil Kowalski's required Before / After / Why format. The tiers foll | --- | --- | --- | --- | | `index.css:203-254`; `Hero.tsx:26-29`, `Features.tsx:37-38`, `Pricing.tsx:29`, `FinalCTA.tsx:14-17` | Infinite aurora (14s), orbs (8s and 10s), full-bleed grid drift (20s) on five sections | Static backgrounds | Apple §14 flags full-viewport moving backgrounds and slow oscillations (0.07–0.125 Hz). | | `index.css:207-213`; `Navbar.tsx:57`, `:98`, `Hero.tsx:74`, `Pricing.tsx:113`, `FinalCTA.tsx:45`, `GenerateForm.tsx:286` | `shimmer 3s infinite` on CTAs, including the app's Generate button | Delete it, or run once on gated hover at 700ms | Perpetual decoration on the most-watched element. | -| `streaming-indicator.tsx:25-63`; wrappers at `AttackPage.tsx:139`, `BrainstormPage.tsx:127`, `GeneratePage.tsx:384`, `FactoidsPage.tsx:145` | Five simultaneous loops: wrapper shimmer, inner swap, ping ring, shimmer bar, and a fake progress bar that fills to 100% every 3s via a 50ms `setInterval` | One spinner (`spin 600ms linear infinite`) plus one honest status line | Decorative loops, a dishonest determinate bar, and 20 renders a second. | -| `loading-spinner.tsx:18-20` | Ping ring plus static ring plus a 1s spin | One ring at `spin 700ms linear` | A faster linear spin reads as a faster load. | +| ✅ PR3 · `streaming-indicator.tsx:25-63`; wrappers at `AttackPage.tsx:139`, `BrainstormPage.tsx:127`, `GeneratePage.tsx:384`, `FactoidsPage.tsx:145` | Five simultaneous loops: wrapper shimmer, inner swap, ping ring, shimmer bar, and a fake progress bar that fills to 100% every 3s via a 50ms `setInterval` | One spinner (`spin 600ms linear infinite`) plus one honest status line | Decorative loops, a dishonest determinate bar, and 20 renders a second. | +| ✅ PR3 · `loading-spinner.tsx:18-20` | Ping ring plus static ring plus a 1s spin | One ring at `spin 700ms linear` | A faster linear spin reads as a faster load. | | ✅ PR1 · `index.css:724-738` | `[data-radix-collection-item]` menu-item-in keyframe | Delete it | Every SelectItem, TabsTrigger and AccordionTrigger slides 4px on every open. | | ✅ PR1 · `index.css:760-779` | Second active-tab indicator: an `::after` underline from `scaleX(0)` | Delete it; the TabsTrigger pill already marks active | Two indicators, grows from zero, keyboard-triggered (also visible on Login). | | ✅ PR1 · `index.css:782-827` | Icon-name hijacks: refresh spins and trash shakes on hover, copy and heart scale on `:active` | Delete them | Global Lucide class-name selectors; a shaking trash makes deletion look playful. | | ✅ PR1 · `index.css:296-299`, `:510-520`, `section-card.tsx:33`, `badge.tsx:8` | Button icon `scale(1.04)` on hover; non-clickable SectionCards lift 2px; Badge `hover:scale-105 transition-all` | Delete all three | Lift implies clickability that isn't there (Apple Familiarity). | | ✅ PR1 · `index.css:682-701`, `:829-842` | Switch squash-and-stretch keyframe; a `check-draw` that's a no-op (no dasharray); infinite slider pulse while dragging | Switch `transition-transform duration-200 ease-out-strong`; delete the rest | Replays on mount, not interruptible, noise under the finger. | -| `animated-section.tsx:14`, `:21`; used on Ask, Attack, Brainstorm, Generate, Factoids | `transition-all duration-300` fade-up on a timeout every time a tool opens | Delete it, or opacity only at 150ms with no delay | Tens of times a day. | -| `TimerPage.tsx:334` | `animate-pulse` on the digits in the last 30s | Static `text-destructive` | Pulses the number the speaker is watching. | -| `FlowchartPage.tsx:251-253`, `:305` | `animated: true` marching edges | Static edges; a dashed stroke for rebuttals | Constant motion on a work canvas. | -| `CrossfirePage.tsx:865-1021`, `SparringRound.tsx:192`, `:249-254` | The transcript remounts per phase and all turns replay; the AI wait shows a pulsing background plus bouncing dots plus text | Hoist the transcript and animate only the newest turn; one indicator | A wall of cards fades in on each phase. | +| ✅ PR3 · `animated-section.tsx:14`, `:21`; used on Ask, Attack, Brainstorm, Generate, Factoids | `transition-all duration-300` fade-up on a timeout every time a tool opens | Delete it, or opacity only at 150ms with no delay | Tens of times a day. | +| ✅ PR3 · `TimerPage.tsx:334` | `animate-pulse` on the digits in the last 30s | Static `text-destructive` | Pulses the number the speaker is watching. | +| ✅ PR3 · `FlowchartPage.tsx:251-253`, `:305` | `animated: true` marching edges | Static edges; a dashed stroke for rebuttals | Constant motion on a work canvas. | +| ✅ PR3 · `CrossfirePage.tsx:865-1021`, `SparringRound.tsx:192`, `:249-254` | The transcript remounts per phase and all turns replay; the AI wait shows a pulsing background plus bouncing dots plus text | Hoist the transcript and animate only the newest turn; one indicator | A wall of cards fades in on each phase. | | ✅ PR2 · `AppLayout.tsx:44`, `:218-219` | `nav-glow` radial hover on top of `hover:bg-accent`; theme toggle in the mobile header and again in Settings | Remove `nav-glow`; drop ThemeToggle from the mobile header | Doubled hover effects; a crowded sticky header. | | ✅ PR2 · `LibraryPage.tsx:495-506` vs `:777-790`; `:675-684` vs `:723-733`; `:583-596` | Two folder filters; two Folder selects in one form; three stat tiles repeating a badge count | One chip row; one Folder select; one meta line under the h1 | Duplicate controls bound to the same state. | -| `LibraryPage.tsx:248`, `:404-407`, `:435-438` and success toasts at `AttackPage.tsx:49`, `BrainstormPage.tsx:48`, `GeneratePage.tsx:97`, `FactoidsPage.tsx:47` | Toasts confirming things already visible | Drop them; keep toasts for off-screen or irreversible results | Over-feedback trains people to ignore feedback (Apple §13). | -| `ExportToolbar.tsx:15`, `:54`, `AutoSavedSourcesUndo.tsx` | `showUndo` is never passed by any caller | Delete both | Dead code with a misleading label. | +| ✅ PR3 · `LibraryPage.tsx:248`, `:404-407`, `:435-438` and success toasts at `AttackPage.tsx:49`, `BrainstormPage.tsx:48`, `GeneratePage.tsx:97`, `FactoidsPage.tsx:47` | Toasts confirming things already visible | Drop them; keep toasts for off-screen or irreversible results | Over-feedback trains people to ignore feedback (Apple §13). | +| ✅ PR3 · `ExportToolbar.tsx:15`, `:54`, `AutoSavedSourcesUndo.tsx` | `showUndo` is never passed by any caller | Delete both | Dead code with a misleading label. | | `landing/WhatsNew.tsx:27-33`, `SocialProof.tsx:7-11` | Pill and h2 both say "What's New"; "2 Plans / 1 Unified Workspace" styled as metrics | Remove the pill; remove the section or use real facts | Repetition; pseudo social proof (Apple Responsibility). | ### 3. Performance @@ -118,21 +118,21 @@ Rows follow Emil Kowalski's required Before / After / Why format. The tiers foll | `index.css:1-3`, `:57` | Three render-blocking Google Fonts `@import`s, including Lora (0 uses) | Drop Lora and Space Mono, or self-host Inter variable with a metric-matched fallback; or use `system-ui` (Apple §15) | A chained blocking request and a visible font swap: the startup shell renders in `system-ui`. | | `Hero.tsx:36-161`, `Navbar.tsx:24`, `DeepDive.tsx:20`, `:55`, every `whileInView y` | Framer `x`/`y`/`scale` shorthands at 0.5–0.8s, delays to 0.5s, during page load | `transform: "translateY(12px)"` strings, `duration: 0.35, ease: [0.23,1,0.32,1]`, hero delays 0–0.15s, `viewport: { once: true, margin: "0px 0px -15% 0px" }` | Shorthands run on the main thread and drop frames while loading; the CTA is held back about a second. | | `Hero.tsx:167-178`, `WhatsNew.tsx:47`, `Pricing.tsx:53`, `:86`, `index.css:165`, `:220`, `:271` | Nested translucent blur layers over animated backgrounds; grid animates `background-position` | Solid cards; blur only on the nav; static grid | Never stack light translucent surfaces (Apple §12); full-section repaints every frame. | -| `AskPage.tsx:25-42`, `:334-340`, `AskChat.tsx:82`; `AttackPage.tsx:26`, `BrainstormPage.tsx:24`, `GeneratePage.tsx:35`, `FactoidsPage.tsx:24` | `useSessionStore()` with no selector; Ask remaps messages so `React.memo` never holds | `useShallow` selectors; pass messages through; a separate `StreamingBubble` | Every bubble re-parses Markdown on every 30ms flush. | -| `index.css:984-1001`, `:1052-1061`; `CrossfirePage.tsx:421-426` | Caret blink animates `box-shadow`; dead `max-height`/`padding` transition; `setNow` every 500ms re-renders the whole Crossfire page | Opacity-only caret with `steps(2)`; delete the dead rule; a `` with its own interval | Repaints while streaming; full renders during a live round. | +| ✅ PR3 · `AskPage.tsx:25-42`, `:334-340`, `AskChat.tsx:82`; `AttackPage.tsx:26`, `BrainstormPage.tsx:24`, `GeneratePage.tsx:35`, `FactoidsPage.tsx:24` | `useSessionStore()` with no selector; Ask remaps messages so `React.memo` never holds | `useShallow` selectors; pass messages through; a separate `StreamingBubble` | Every bubble re-parses Markdown on every 30ms flush. | +| ✅ PR3 · `index.css:984-1001`, `:1052-1061`; `CrossfirePage.tsx:421-426` | Caret blink animates `box-shadow`; dead `max-height`/`padding` transition; `setNow` every 500ms re-renders the whole Crossfire page | Opacity-only caret with `steps(2)`; delete the dead rule; a `` with its own interval | Repaints while streaming; full renders during a live round. | | ✅ PR2 · `SettingsPage.tsx:324-326`, `AppLayout.tsx:276-285` | A 160px pulsing block for every user while the admin check runs; the announcement banner pushes content down after a fetch | Render nothing while pending; overlay the banner or render it from cache | Layout shift for 100% of users. | ### 4. Interruptibility and timing | Where | Before | After | Why | | --- | --- | --- | --- | -| `AskChat.tsx:703`, `:706`; `CrossfirePage.tsx:968`; `AttackPage.tsx:36-62`, `BrainstormPage.tsx:37-61`, `GeneratePage.tsx:78-116`, `FactoidsPage.tsx:34-60` | The input is disabled while the AI works; no Stop; the form tools have no AbortController | Keep composers editable and disable only submit; a Stop button that aborts and returns focus; Cancel under the tool status | Apple §3: thought and gesture run in parallel. | +| ✅ PR3 · `AskChat.tsx:703`, `:706`; `CrossfirePage.tsx:968`; `AttackPage.tsx:36-62`, `BrainstormPage.tsx:37-61`, `GeneratePage.tsx:78-116`, `FactoidsPage.tsx:34-60` | The input is disabled while the AI works; no Stop; the form tools have no AbortController | Keep composers editable and disable only submit; a Stop button that aborts and returns focus; Cancel under the tool status | Apple §3: thought and gesture run in parallel. | | ✅ PR2 · `sheet.tsx:33`, `:37-42` | Open 500ms, close 300ms, bare `transition ease-in-out`; full-width right sheet on phones; no drag | Open 300ms, close 200ms, `ease-drawer`; on mobile a bottom sheet (Vaul) with velocity dismiss above about 0.11 px/ms and rubber-banding | Over budget; a sheet you can't grab doesn't feel physical (Apple §2–6, §9). | | ✅ PR1 · `dialog.tsx:42`, `alert-dialog.tsx:37`, `popover.tsx:20`, `select.tsx:69`, `tooltip.tsx:20` | Symmetric 150ms keyframes, `zoom-out-95` on exit | Enter 200ms, exit 120ms fade only | A reopen during the close doesn't visibly jump; exit faster than enter. | | ✅ PR1 · `index.css:741-743` | `[role=option]` background fades 100ms | Instant highlight; keep only the `:active` transform | Arrow-key list navigation shouldn't fade. | | ✅ PR1 · `accordion.tsx:31` vs `tailwind.config.ts:77-78`; `index.css:355-370`, `AttackForm.tsx:121`, `GenerateForm.tsx:220` | Chevron 300ms against content 200ms; options expand 260ms plus translateY with a 300ms chevron | Everything at 200ms on one curve; chevron 150ms | Coordinated motions finish out of sync. | -| `TimerPage.tsx:183` | Resume uses whole seconds rounded up | Store the remaining milliseconds on pause | Each pause gives the speaker up to a second. | -| `FlowchartPage.tsx:442-444`, `:749-762`, `:343` | Auto layout clicks a fit-view button that doesn't exist; wheel zoom captures page scroll; new nodes at random positions | `rf.fitView({ duration: 250, padding: 0.2 })` with a temporary node transform transition; `zoomOnScroll={false} preventScrolling={false}`; place at the pane centre | Teleporting nodes; trapped scroll; things appearing from nowhere. | +| ✅ PR3 · `TimerPage.tsx:183` | Resume uses whole seconds rounded up | Store the remaining milliseconds on pause | Each pause gives the speaker up to a second. | +| ✅ PR3 · `FlowchartPage.tsx:442-444`, `:749-762`, `:343` | Auto layout clicks a fit-view button that doesn't exist; wheel zoom captures page scroll; new nodes at random positions | `rf.fitView({ duration: 250, padding: 0.2 })` with a temporary node transform transition; `zoomOnScroll={false} preventScrolling={false}`; place at the pane centre | Teleporting nodes; trapped scroll; things appearing from nowhere. | | ✅ PR2 · `AppLayout.tsx:155-173`; `SettingsPage.tsx:89-104`, `:143-150`; `DebateDialogs.tsx:53-70`; `LibraryPage.tsx:260-283` | Announcement dismiss waits for the server; no pending state on delete-all, switch or delete debate, sign out, or file import | Optimistic dismiss; per-action busy labels and disabled state; reset the file input | A tap with no visible response (Apple §1). | | ✅ PR2 · `OnboardingTour.tsx:105-111` | A fixed 500ms start delay | Start once the target exists (rAF or MutationObserver, 500ms cap) | An artificial delay. | | ✅ PR1 · `index.css:1065-1068` | The transition is declared only inside `:hover`, so leaving snaps back | Transition on the base rule; gate the hover | An asymmetric snap. PR1 deleted the rule instead, with the other icon hijacks. | @@ -145,8 +145,8 @@ Rows follow Emil Kowalski's required Before / After / Why format. The tiers foll | ✅ PR2 · `index.css:153`, `:271`, `AppLayout.tsx:178` | Sticky translucent header with a hard 1px border and a shadow | A scroll-edge fade, or a border only when `scrollY > 0` | Apple §12. | | `index.css:491-847` overall | About 20 unlayered global selectors keyed on Radix internals | Move what's worth keeping into the primitives and delete the layer | They break silently on upgrades and override primitive classes. | | ✅ PR1 · `index.css:156-159`, `ThemeProvider.tsx:8-23` | `html`/`body` ease over 420ms, components at 160 or 260ms, everything else snaps | `document.startViewTransition` with a 200ms crossfade, or suppress transitions for one frame | The theme change ripples at three speeds. | -| `AskChat.tsx:693`, `GenerateForm.tsx:142-154`, `VoiceMicControl.tsx:41`; `TimerPage.tsx:334`, `SparringRound.tsx:150`, `:157`, `CrossfirePage.tsx:924-926` | Three listening indicators and three countdown urgency treatments; the Timer goes calm again at 0:00 | One `` and one ``: amber at 30s or less, destructive at 0:00 | Things that look the same should behave the same. | -| `TimerPage.tsx:334` | `font-mono` (Space Mono) with `tracking-tighter` on the digits | Inter with `tabular-nums` and `tracking-[-0.02em]` | The wide colon reads "4 : 00"; negative tracking on a monospace face fights its design. | +| ✅ PR3 · `AskChat.tsx:693`, `GenerateForm.tsx:142-154`, `VoiceMicControl.tsx:41`; `TimerPage.tsx:334`, `SparringRound.tsx:150`, `:157`, `CrossfirePage.tsx:924-926` | Three listening indicators and three countdown urgency treatments; the Timer goes calm again at 0:00 | One `` and one ``: amber at 30s or less, destructive at 0:00 | Things that look the same should behave the same. | +| ✅ PR3 · `TimerPage.tsx:334` | `font-mono` (Space Mono) with `tracking-tighter` on the digits | Inter with `tabular-nums` and `tracking-[-0.02em]` | The wide colon reads "4 : 00"; negative tracking on a monospace face fights its design. | | `landing/*` section h2s; `Features.tsx:37` | Different sizes and tracking per section; the orb's `-translate-y-1/2` is overridden by its keyframe | One `.section-heading`; position the orb with `top` | Cohesion; the orb jumps under reduced motion. | | ➖ PR2 · Login (`LoginPage.tsx:157-161`), app header logo | The login card shows a generic sparkle tile; the app shows the tree-and-scales logo in a white tile in both themes | The real mark on Login; a dark-mode variant of the logo tile | Brand consistency across surfaces. PR2 puts the real mark on Login and the public pages. The dark-mode tile is not done: `favicon-72.png` is RGB with no alpha, so the white is baked into the mark and a dark variant needs a new asset. | @@ -164,7 +164,7 @@ Rows follow Emil Kowalski's required Before / After / Why format. The tiers foll | `SettingsPage.tsx:37-42`, `:206`; `DeepDive.tsx:63`, `:78`; `LoginPage.tsx:268`, `:273` | White on amber or green-500 tier badges (about 2:1); near-white `text-accent` on a light card (about 2.5:1); hard-coded `text-green-600` (3.15:1) | Tinted badges (`bg-amber-500/15 text-amber-800 dark:text-amber-300`); `text-foreground`; a `--success` token | Contrast failures. | | ✅ PR2 · `LibraryPage.tsx:69-147`, `:627-638`, `:705-975` | A `role=button` row containing a link, a button and a switch; an invisible file input with no focus ring; labels without `htmlFor` | The title is the button; `has-[:focus-visible]:ring-2` on the label; matching `id`/`htmlFor` pairs | Nested interactive controls; invisible focus; detached labels. | | ✅ PR1 · `AskChat.tsx:102`, `FactoidsResults.tsx:59` | The bookmark and external-link icons appear only on hover | Always visible at 60% opacity, full on focus | Invisible on touch and to the keyboard. | -| `TimerPage.tsx:329`, `SparringRound.tsx:157`, `streaming-indicator.tsx:56` | `aria-live` on a clock that ticks every second; fake stage text announced every 3s | Announce 1:00, 0:30 and 0:00 only; `role="timer"`; announce only real status changes | Constant screen-reader chatter. | +| ✅ PR3 · `TimerPage.tsx:329`, `SparringRound.tsx:157`, `streaming-indicator.tsx:56` | `aria-live` on a clock that ticks every second; fake stage text announced every 3s | Announce 1:00, 0:30 and 0:00 only; `role="timer"`; announce only real status changes | Constant screen-reader chatter. | | ✅ PR2 · `LibraryPage.tsx:95-124`, `ModelSelector.tsx:76-86` (about 30 `text-[9-11px]` uses) | 9–11px text in px | `text-xs` (0.75rem) as the floor | Below a legible floor; doesn't scale with the user's font size. | | ✅ PR2 · `ThemeToggle.tsx:21`, `tooltip.tsx` (0 importers) | Icon-only header controls use `title`; the theme icon is a laptop in "system" mode | Wrap them in Tooltip, which gets an instant skip delay inside the provider | `title` doesn't show on touch or focus; the laptop icon is ambiguous. | @@ -176,14 +176,14 @@ Rows follow Emil Kowalski's required Before / After / Why format. The tiers foll | ✅ PR2 · `NotFound.tsx:11-13`, `SourcesPage.tsx:129-162`, `App.tsx:153-158`, `PrivacyPage.tsx`, `TermsPage.tsx`, `LoginPage.tsx:150-284` | The 404 reloads to the marketing page; Sources, Legal and Login have no way home; the route error offers only Reload | ``; a header or back link on public pages; "Go to Home" on the error | Never trap the user. | | `LibraryPage.tsx:129-140` vs `:1025-1044`; `DebateDialogs.tsx:104-110`; `HistoryPage.tsx:352-379`; `SettingsPage.tsx:386-454`; `LibraryPage.tsx:421`; `FlowchartPage.tsx:382-390` vs `:784-804`; `AdminPage.tsx:997-1012` | Five confirmation models. A source row deletes instantly, while the same source in the sheet asks. Saved flowcharts delete instantly, while nodes ask. `window.confirm`. Admin Ban and Make admin are one click | Reversible deletes act at once with `toast({ action: { label: 'Undo' } })`; irreversible ones get an AlertDialog with `buttonVariants({ variant: 'destructive' })`; admin role changes use the existing `pendingAction` dialog | Apple §16 Agency: undo for slips, confirmations used sparingly and consistently. PR2 draws the line at whether the app can put the item back exactly: Library sources delete at once with Undo (the flowchart's nodes and saved flowcharts follow in PR3); saved cases, sparring rounds, Clear Library and the Settings deletes use a destructive-styled confirm; a debate keeps its inline confirm, which already names what goes with it; admin role changes confirm through a dialog. | | ✅ PR2 · `LibraryPage.tsx:218-233`, `:442-444`; `LoginPage.tsx:74-133`, `:277-281`, `:57-143` | Required-field errors arrive as toasts after submit; login errors appear below the tabs, not tied to the field; raw backend `error.message` in English | Inline `role="alert"` under the field with `aria-describedby` and focus moved to it (as `DebateForm.tsx:115-118` already does); map Supabase error codes to i18n | Validate inline; localized errors. | -| `GenerateResults.tsx:55-57`, Attack, Brainstorm, Ask without citations | No AI caveat anywhere; Generate evidence reads as fact unless `needsSource` is set | One line under every result header: "AI-generated. Check facts and sources before using them in a round."; label evidence "Unverified" unless a researched source backs it | Apple §16.3 Responsibility. | -| `AskChat.tsx:64-70`, `:384-394`, `:118`; `streaming-indicator.tsx` | Timed status copy cycles ("Almost there…" back to "Thinking…"); "Typing" shows while web research runs | Real phases ("Searching the web…", "Writing"); otherwise "Working…" plus elapsed time after 5s | Status should be true. | -| Error handling in Attack, Brainstorm, Factoids and Generate; `error-display.ts:31`, `:41`; `SparringPage.tsx:26-29`, `:297-307`; `AskPage.tsx:155`, `:280`; `AskChat.tsx:405-407` | Toast-only errors with no Retry; hard-coded English; Retry duplicates the user's bubble; oversized input is cleared before it's rejected | An inline error panel with Retry, like `CrossfirePage.tsx:943-958`; i18n; retry without re-adding the message; clear only on accept | Recoverable feedback; forgiveness. | -| `AttackPage.tsx:67-92`, `GeneratePage.tsx:195-214`, `FactoidsPage.tsx:91-108`; `markdown.tsx:7-13` | "Discuss" prompts are indented template literals that render as a code block, and send non-streaming; `className="inline"` lands on a block wrapper; no link renderer | Build prompts with `join('\n')` through i18n and the streaming path; an `inline` prop mapping `p` to `span`; links with `target="_blank" rel="noopener noreferrer"` and `overflow-wrap:anywhere` | Broken rendering of the user's own message and of label/value rows. | -| Page headers: `HomePage.tsx:154`, `CurrentDebatePage.tsx:112`, `SettingsPage.tsx:182`, `LibraryPage.tsx:474`, `HistoryPage.tsx:118`, `AskPage.tsx:350`, `SparringSetup.tsx:78`, `FlowchartPage.tsx:479-491`; card titles `card.tsx:19`, `dialog.tsx:74` | Eight header patterns; h1 weight and tracking vary; Flowchart has no h1; card titles `text-xl`, `text-lg` or `text-2xl` with `tracking-tight leading-none` | One `PageHeader`: h1 `text-3xl font-bold tracking-tight leading-tight`, optional eyebrow, meta line and actions slot. Card titles `text-lg font-semibold` with size-specific tracking (24px −0.019em, 18px −0.014em, 16px −0.011em) and `leading-tight` | Apple §15: tracking depends on size; Familiarity. | -| Results in Attack, Brainstorm, Generate and Factoids; `HomePage.tsx:53-73` vs `CurrentDebatePage.tsx:194-204` | The export toolbar sits in a different place per tool; copy exists only in Generate; only Generate scrolls to results; the tool list is built twice with different labels | One `` (meta row, actions row, sources disclosure); `useRevealResults()` on mobile; one shared `ToolLink` | The same tool family should share a layout. | -| `GeneratePage.tsx` (screenshot) | "No current debate" appears twice (page banner and form notice); h1 "Generate", card title "Generate Debate Case" and button "Generate Debate Case"; an emoji 🎯 as the empty-state art next to Lucide icons elsewhere | One debate notice; drop the card title; use the `EmptyState` Lucide component everywhere | Redundant copy; mixed iconography. | -| `AskChat.tsx:517`, `:582-637`; `CrossfirePage.tsx:915-932`, `:679`, `:693`; `SparringRound.tsx:203-295`; `TimerPage.tsx:87`, `:203`, `:258-261`, `:378-382` | Chat height `100vh`; four switches above the input; the live Crossfire header never shows the resolution; the opponent's latest speech sits below a 240px composer; no wake lock; R and Escape reset the timer with no undo; keyboard hints shown on touch | `100dvh` and an Options popover; show the resolution; pin the last speech above the composer; `navigator.wakeLock` while running, with vibration and sound on the same frame at 0:00; reset only while paused or with Undo; hide kbd hints under `pointer:coarse` | Wayfinding, Apple §13 harmony, forgiveness. | +| ✅ PR3 · `GenerateResults.tsx:55-57`, Attack, Brainstorm, Ask without citations | No AI caveat anywhere; Generate evidence reads as fact unless `needsSource` is set | One line under every result header: "AI-generated. Check facts and sources before using them in a round."; label evidence "Unverified" unless a researched source backs it | Apple §16.3 Responsibility. | +| ✅ PR3 · `AskChat.tsx:64-70`, `:384-394`, `:118`; `streaming-indicator.tsx` | Timed status copy cycles ("Almost there…" back to "Thinking…"); "Typing" shows while web research runs | Real phases ("Searching the web…", "Writing"); otherwise "Working…" plus elapsed time after 5s | Status should be true. | +| ✅ PR3 · Error handling in Attack, Brainstorm, Factoids and Generate; `error-display.ts:31`, `:41`; `SparringPage.tsx:26-29`, `:297-307`; `AskPage.tsx:155`, `:280`; `AskChat.tsx:405-407` | Toast-only errors with no Retry; hard-coded English; Retry duplicates the user's bubble; oversized input is cleared before it's rejected | An inline error panel with Retry, like `CrossfirePage.tsx:943-958`; i18n; retry without re-adding the message; clear only on accept | Recoverable feedback; forgiveness. | +| ✅ PR3 · `AttackPage.tsx:67-92`, `GeneratePage.tsx:195-214`, `FactoidsPage.tsx:91-108`; `markdown.tsx:7-13` | "Discuss" prompts are indented template literals that render as a code block, and send non-streaming; `className="inline"` lands on a block wrapper; no link renderer | Build prompts with `join('\n')` through i18n and the streaming path; an `inline` prop mapping `p` to `span`; links with `target="_blank" rel="noopener noreferrer"` and `overflow-wrap:anywhere` | Broken rendering of the user's own message and of label/value rows. | +| ✅ PR2–3 · Page headers: `HomePage.tsx:154`, `CurrentDebatePage.tsx:112`, `SettingsPage.tsx:182`, `LibraryPage.tsx:474`, `HistoryPage.tsx:118`, `AskPage.tsx:350`, `SparringSetup.tsx:78`, `FlowchartPage.tsx:479-491`; card titles `card.tsx:19`, `dialog.tsx:74` | Eight header patterns; h1 weight and tracking vary; Flowchart has no h1; card titles `text-xl`, `text-lg` or `text-2xl` with `tracking-tight leading-none` | One `PageHeader`: h1 `text-3xl font-bold tracking-tight leading-tight`, optional eyebrow, meta line and actions slot. Card titles `text-lg font-semibold` with size-specific tracking (24px −0.019em, 18px −0.014em, 16px −0.011em) and `leading-tight` | Apple §15: tracking depends on size; Familiarity. | +| ✅ PR3 · Results in Attack, Brainstorm, Generate and Factoids; `HomePage.tsx:53-73` vs `CurrentDebatePage.tsx:194-204` | The export toolbar sits in a different place per tool; copy exists only in Generate; only Generate scrolls to results; the tool list is built twice with different labels | One `` (meta row, actions row, sources disclosure); `useRevealResults()` on mobile; one shared `ToolLink` | The same tool family should share a layout. | +| ✅ PR3 · `GeneratePage.tsx` (screenshot) | "No current debate" appears twice (page banner and form notice); h1 "Generate", card title "Generate Debate Case" and button "Generate Debate Case"; an emoji 🎯 as the empty-state art next to Lucide icons elsewhere | One debate notice; drop the card title; use the `EmptyState` Lucide component everywhere | Redundant copy; mixed iconography. | +| ✅ PR3 · `AskChat.tsx:517`, `:582-637`; `CrossfirePage.tsx:915-932`, `:679`, `:693`; `SparringRound.tsx:203-295`; `TimerPage.tsx:87`, `:203`, `:258-261`, `:378-382` | Chat height `100vh`; four switches above the input; the live Crossfire header never shows the resolution; the opponent's latest speech sits below a 240px composer; no wake lock; R and Escape reset the timer with no undo; keyboard hints shown on touch | `100dvh` and an Options popover; show the resolution; pin the last speech above the composer; `navigator.wakeLock` while running, with vibration and sound on the same frame at 0:00; reset only while paused or with Undo; hide kbd hints under `pointer:coarse` | Wayfinding, Apple §13 harmony, forgiveness. | | ✅ PR2 · `HistoryPage.tsx:136-379`, `LibraryPage.tsx:39`, `:880`, `CurrentDebatePage.tsx:69-206` | The tab resets after Back; fixed 500–600px inner scroll boxes trap scrolling on phones; lists are cut at 4 or 6 with no "View all"; three equal outline buttons and no primary action on the debate page | The tab in `useSearchParams`; `max-h` with page scroll on mobile; "View all (N)"; move the tools up and make them primary | "What's there?" and hierarchy. | | ✅ PR2 · `DebateDialogs.tsx:167-225`, `PricingModal.tsx:96`, `LimitReachedModal.tsx:39-47` | The 5-day idle gate blocks Escape on every route including Settings; "Current" is always on Free; the limit modal offers only Upgrade | Allow Escape to mean "Continue"; badge the real tier; add "Not now" | Never trap; true status; agency. | | ✅ PR2 · `SharedPage.tsx:75-148`, legal pages | Shared items render as raw JSON in a 600px scroll box; "Import to Account" is a dead end that also loses the item on sign-in; legal text at 14px and about 100 characters per line | Render with the result components at `max-w-[65ch] text-base leading-7`; remove Import until it exists; legal pages at `text-base leading-7 max-w-[65ch]` | Recipients get developer output; reading measure. | diff --git a/src/components/features/AutoSavedSourcesUndo.tsx b/src/components/features/AutoSavedSourcesUndo.tsx deleted file mode 100644 index 2e8470d0c..000000000 --- a/src/components/features/AutoSavedSourcesUndo.tsx +++ /dev/null @@ -1,51 +0,0 @@ -import { Undo2 } from 'lucide-react'; -import { useState } from 'react'; -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 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; - - const handleUndo = async () => { - setRemoving(true); - try { - const results = await Promise.allSettled(autoSavedSources.map((source) => removeSource(source.id))); - const succeeded = results.filter((result) => result.status === 'fulfilled').length; - const failed = results.length - succeeded; - - if (failed === 0) { - toast.success(t('autoSavedSources.removedTitle'), { description: t('autoSavedSources.removedDesc', { count: succeeded }) }); - } else if (succeeded === 0) { - toast.error(t('autoSavedSources.removeFailed', 'Could not remove auto-saved sources.'), { - description: t('autoSavedSources.removeFailedDesc', { succeeded, failed }), - }); - } else { - toast.warning(t('autoSavedSources.removedPartialTitle'), { - description: t('autoSavedSources.removedPartialDesc', { succeeded, failed }), - }); - } - } catch { - toast.error(t('autoSavedSources.removeFailed', 'Could not remove auto-saved sources.')); - } finally { - setRemoving(false); - } - }; - - return ( - - ); -} diff --git a/src/components/features/ExportToolbar.tsx b/src/components/features/ExportToolbar.tsx index 96b5d7d20..2c7b36607 100644 --- a/src/components/features/ExportToolbar.tsx +++ b/src/components/features/ExportToolbar.tsx @@ -3,7 +3,6 @@ import { useTranslation } from 'react-i18next'; import { FileText, FileDown, Loader2, MessageSquare } from 'lucide-react'; import { Button } from '@/components/ui/button'; import GoogleDocsIcon from '@/components/icons/GoogleDocsIcon'; -import { AutoSavedSourcesUndo } from '@/components/features/AutoSavedSourcesUndo'; interface ExportToolbarProps { onPdf: () => void; @@ -12,7 +11,6 @@ interface ExportToolbarProps { isExportingGDocs?: boolean; onDiscuss?: () => void; discussLabel?: string; - showUndo?: boolean; /** Buttons that appear on the export row, between the EXPORT tag and PDF/DOCX/Google Docs (e.g. Clipboard). */ extraExports?: ReactNode; /** Buttons that appear on the top row (Save, Discuss, Flowcharts, Undo, etc.). */ @@ -21,7 +19,7 @@ interface ExportToolbarProps { // Unified export bar used by every feature's results view. // Two explicit rows, both left-aligned: -// Row 1 (top): secondary actions — Save, Discuss, Flowcharts, Undo, AutoSavedSourcesUndo +// Row 1 (top): secondary actions — Save, Discuss, Flowcharts, Undo // Row 2 (bottom): EXPORT tag, extra export buttons (e.g. Clipboard), PDF, DOCX, Google Docs // Layout chosen by user 2026-05-27; see feedback-exports memory. export function ExportToolbar({ @@ -31,14 +29,13 @@ export function ExportToolbar({ isExportingGDocs = false, onDiscuss, discussLabel, - showUndo = false, extraExports, secondaryActions, }: ExportToolbarProps) { const { t } = useTranslation(); const hasSecondaryActions = Children.toArray(secondaryActions).filter(Boolean).length > 0; - const hasTopRow = hasSecondaryActions || showUndo || !!onDiscuss; + const hasTopRow = hasSecondaryActions || !!onDiscuss; return (
@@ -51,7 +48,6 @@ export function ExportToolbar({ {discussLabel || t('common.discuss', 'Discuss')} )} - {showUndo && }
)}
diff --git a/src/components/features/ask/AskChat.tsx b/src/components/features/ask/AskChat.tsx index e04323beb..8c4816cb6 100644 --- a/src/components/features/ask/AskChat.tsx +++ b/src/components/features/ask/AskChat.tsx @@ -1,6 +1,6 @@ -import React, { useState, useRef, useEffect } from 'react'; +import React, { useState, useRef, useEffect, useLayoutEffect } from 'react'; import { useTranslation } from 'react-i18next'; -import { MessageCircle, Send, RotateCcw, Quote, Map, List, HelpCircle, Paperclip, X, Bookmark, Mic, MicOff, Search, Save, PanelLeftOpen, PlusCircle } from 'lucide-react'; +import { MessageCircle, Send, RotateCcw, Quote, Map, List, HelpCircle, Paperclip, X, Bookmark, Mic, MicOff, Search, Save, PanelLeftOpen, PlusCircle, Square, SlidersHorizontal, ArrowDown, AlertCircle } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { Markdown } from '@/components/ui/markdown'; @@ -16,7 +16,8 @@ import { validateFileUpload, MAX_FILE_COUNT, formatFileSize } from '@/lib/valida import { formatEuropeanDate, formatEuropeanTime } from '@/lib/date-format'; import { ModelSelector } from '@/components/ModelSelector'; import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from '@/components/ui/sheet'; -import { useReducedMotion } from 'framer-motion'; +import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'; +import { ListeningIndicator } from '@/components/features/voice/ListeningIndicator'; interface Message { id: string; @@ -28,9 +29,23 @@ interface Message { output?: any; } +/** A failed or rejected send, shown in the chat above the composer. */ +export interface AskChatError { + title: string; + description?: string; + onRetry?: () => void; +} + interface AskChatProps { messages: Message[]; + /** The reply being streamed, rendered after the stored messages. */ + streamingMessage?: Message | null; isLoading: boolean; + error?: AskChatError | null; + onDismissError?: () => void; + /** Stops the reply in progress. */ + onStop?: () => void; + /** Resolves false when the message was rejected, so the composer keeps the text. */ onSend: ( message: string, mode: string, @@ -39,7 +54,7 @@ interface AskChatProps { prosConsMode: boolean, argumentMapMode: boolean, files?: File[] - ) => void; + ) => boolean | void | Promise; onClear: () => void; onSaveChat?: () => void; savedChats?: SavedChatSummary[]; @@ -61,13 +76,23 @@ const modeOptions = [ { id: 'clarify', labelKey: 'askChat.modeClarify', icon: HelpCircle }, ]; -const LOADING_MESSAGES_KEYS = [ - 'askChat.thinking', - 'askChat.researching', - 'askChat.analyzing', - 'askChat.generatingResponse', - 'askChat.almostThere', -]; +// Within this distance of the bottom, new text keeps the view pinned to the latest message. +const AT_BOTTOM_PX = 80; + +/** Seconds since `since`, ticking once a second while `active`. */ +function useElapsedSeconds(active: boolean) { + const [elapsed, setElapsed] = useState(0); + useEffect(() => { + if (!active) { + setElapsed(0); + return; + } + const startedAt = Date.now(); + const timer = window.setInterval(() => setElapsed(Math.floor((Date.now() - startedAt) / 1000)), 1000); + return () => window.clearInterval(timer); + }, [active]); + return elapsed; +} function getFirstUrl(text?: string) { return text?.match(/https?:\/\/[^\s]+/i)?.[0]?.replace(/[),.;!?]+$/g, ''); @@ -85,7 +110,7 @@ const MessageBubble = React.memo(function MessageBubble({ message, onSave }: { m const output = message.output; return ( -
+
)}
- {message.content ? ( - {message.content} - ) : message.isStreaming ? ( - {t('askChat.typing', 'Typing')} - ) : null} + {message.content ? {message.content} : null} {message.isStreaming && (