From 4fa6e7492a08aa9e3d0f3184f77ccca310578a38 Mon Sep 17 00:00:00 2001 From: Adam Date: Fri, 9 Oct 2026 08:11:43 +0000 Subject: [PATCH 01/13] AI tools: Stop and Cancel, honest status, inline errors, one result header MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes the AI tools rows of the 2026-10-09 design audit (Ask and the four form tools; the practice tools follow in the next commit). - Ask: the stored reply keeps the streaming bubble's id, so it doesn't remount mid-read. New text scrolls only a reader at the bottom, inside the chat, never the window; otherwise "Jump to latest" appears. The box stays editable while answering, Send becomes Stop (which keeps the partial), and failures stay above the box with a Retry that doesn't duplicate the question. The four switches and the model picker move into an Options popover. The status is one true line ("Searching the web…", "Working…") plus elapsed time, and the height uses dvh. - Attack, Brainstorm, Factoids, Generate: requests take an AbortSignal and show Cancel. One spinner and one status line replace five loops and a fake progress bar. Errors are an inline panel with Retry. The success toasts are gone, results scroll into view on phones, and the empty states use the Lucide EmptyState. - One ResultHeader: title, meta, actions, and the AI caveat. Generate marks evidence "Unverified" when no web research backs it. Researched sources sit in a disclosure under the header. - "Discuss" prompts are built line by line through i18n and sent from Ask on the streaming path. Markdown gets an inline mode and safe links. - Deleted: StreamingIndicator, AnimatedSection, StaggeredContainer, AutoSavedSourcesUndo and the unused showUndo prop, the stagger and message keyframes, the typing dots, the streaming glow and token fade. The caret blinks by opacity only. Error copy is localized, the form cards drop their duplicate titles, and the no-debate notice appears once. Co-Authored-By: Claude Opus 5.5 --- .../verify-debatelab/features/ai-tools.md | 7 +- .../features/case-generation.md | 5 +- .../verify-debatelab/recipes/ai-tools.spec.ts | 6 + .../recipes/case-generation.spec.ts | 7 +- e2e/animation-polish.spec.ts | 16 +- e2e/authed-pages.spec.ts | 2 + e2e/full-stack/ai-tools.spec.ts | 3 + e2e/live/research.spec.ts | 3 + .../features/AutoSavedSourcesUndo.tsx | 51 --- src/components/features/ExportToolbar.tsx | 8 +- src/components/features/ask/AskChat.tsx | 294 +++++++++++------- src/components/features/attack/AttackForm.tsx | 15 +- .../features/attack/AttackResults.tsx | 55 ++-- .../features/brainstorm/BrainstormForm.tsx | 13 +- .../features/brainstorm/BrainstormResults.tsx | 37 +-- .../features/debate/DebateContextControl.tsx | 6 +- .../features/factoids/FactoidsForm.tsx | 13 +- .../features/factoids/FactoidsResults.tsx | 42 +-- .../features/generate/GenerateForm.tsx | 20 +- .../features/generate/GenerateResults.tsx | 54 ++-- .../features/tools/ResearchedSources.tsx | 22 ++ .../features/tools/ResultHeader.tsx | 31 ++ src/components/features/tools/discuss.ts | 65 ++++ src/components/features/tools/tool-run.tsx | 108 +++++++ src/components/ui/animated-section.tsx | 68 ---- src/components/ui/empty-state.tsx | 2 +- src/components/ui/loading-spinner.tsx | 19 +- src/components/ui/markdown.tsx | 19 +- src/components/ui/streaming-indicator.tsx | 67 ---- src/index.css | 182 +---------- src/lib/ai/client.ts | 12 +- src/lib/ai/error-display.ts | 17 +- src/lib/stores/session-store.ts | 5 +- src/locales/en.json | 128 ++++---- src/locales/es.json | 128 ++++---- src/locales/hu.json | 128 ++++---- src/pages/AskPage.tsx | 135 +++++--- src/pages/AttackPage.tsx | 118 +++---- src/pages/BrainstormPage.tsx | 115 +++---- src/pages/FactoidsPage.tsx | 108 +++---- src/pages/GeneratePage.tsx | 133 +++----- src/test/pages/GeneratePage.test.tsx | 3 +- 42 files changed, 1075 insertions(+), 1195 deletions(-) delete mode 100644 src/components/features/AutoSavedSourcesUndo.tsx create mode 100644 src/components/features/tools/ResearchedSources.tsx create mode 100644 src/components/features/tools/ResultHeader.tsx create mode 100644 src/components/features/tools/discuss.ts create mode 100644 src/components/features/tools/tool-run.tsx delete mode 100644 src/components/ui/animated-section.tsx delete mode 100644 src/components/ui/streaming-indicator.tsx 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 6c83cee79..ebd4991b9 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/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/e2e/animation-polish.spec.ts b/e2e/animation-polish.spec.ts index 772e8458c..889f193c7 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 d4d7f3741..dddc8cc91 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/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..be3f892b2 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,7 @@ 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'; interface Message { id: string; @@ -28,9 +28,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 +53,7 @@ interface AskChatProps { prosConsMode: boolean, argumentMapMode: boolean, files?: File[] - ) => void; + ) => boolean | void | Promise; onClear: () => void; onSaveChat?: () => void; savedChats?: SavedChatSummary[]; @@ -61,13 +75,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 +109,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 && (