Archilyzer · Source

archilyzer

Archilyzer
git clone https://archilyzer.pages.dev/source/archilyzer.git
Log | Files | Refs | README | LICENSE

commit 2f6cbc930b94f917179c25505ea68a197a807a95
parent 150f3df28779adea1554fe6af71f194d4364cd93
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date:   Thu, 16 Jul 2026 16:26:21 -0400

Ask chat: fix must-fix UX dead-ends + first-use friction

- Settings panel no longer collapses while you type your key (seed the
  <details> open state once, then let the user control it — don't recompute
  it from apiKey every render).
- Enter sends; Shift+Enter inserts a newline (was ⌘/Ctrl+Enter only).
- Stopped and errored turns keep Copy/Retry and still show the "Searched:"
  recap, and render Markdown when settled — no more raw-text dead end.
- A mid-stream error is appended to whatever streamed instead of being
  dropped whenever any content had arrived.
- An empty answer shows a "no answer returned" note + Retry instead of
  rendering nothing.
- Guard a rapid double-submit with a ref (busy flips async).
- Drop the duplicate pre-token "writing…" indicator (the empty caret bubble
  during "answering" is gone; PipelineStatus covers it), and mark the
  streaming answer aria-live for screen readers.

Verified: tsc + lint clean; 13/13 ask-chat e2e green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

Diffstat:
Mexport/CHANGELOG.md | 1+
Mexport/app/ask/Composer.tsx | 5+++--
Mexport/app/ask/MessageBubble.tsx | 43+++++++++++++++++++++++++++----------------
Mexport/app/ask/ProviderSettings.tsx | 11++++++++++-
Mexport/app/ask/useAskChat.ts | 19+++++++++++++++----
Mexport/e2e/ask-chat.spec.ts | 4++--
6 files changed, 58 insertions(+), 25 deletions(-)

diff --git a/export/CHANGELOG.md b/export/CHANGELOG.md @@ -1,6 +1,7 @@ # Changelog ## [Unreleased] +- **"Ask AI" chat polish — fewer dead ends, clearer feedback.** Several first-use rough edges are fixed: the provider settings panel no longer collapses out from under you the moment you start typing your API key; **Enter** now sends (with **Shift+Enter** for a new line); a message that stops or fails part-way through streaming now keeps its actions (Copy, Retry) and still shows what was searched, instead of freezing as raw text with no way forward; a request that errors mid-answer now shows *what* went wrong appended to whatever streamed, rather than silently dropping the error; an empty answer says so (with a Retry) instead of rendering nothing; the pre-answer "writing…" indicator no longer double-renders with an empty bubble; a fast double-press can no longer fire two turns at once; and the streaming answer is announced to screen readers. See `export/app/ask/{ProviderSettings,Composer,MessageBubble,useAskChat}.tsx`. - **The "Ask AI" chat no longer balloons its own context.** A long conversation — especially one grounded in a big set of search results — used to re-send *every* previous turn's excerpts inside *every* new turn, so the context grew quadratically until answers stalled or a provider rejected the request. Excerpts are now carried forward once, in a deduplicated pool (each video's excerpts merged across turns and sent a single time), while the replayed history is just the questions and answers. Three related fixes ride along: a "context length exceeded" error from your provider is now recognised and shown as a clear, actionable message instead of being mistaken for "this model doesn't support tools" (which pointlessly retried the oversized request); a conversation that outgrows your browser's storage quota now trims its oldest turns and warns you, instead of silently failing to save so newer turns vanished on reload; and reloading the page mid-answer no longer leaves a message spinning forever — the interrupted turn shows a **Retry**. See `export/app/lib/{askConversation,searchAgent,askProvider}.ts`, `export/app/lib/nativeTools/shared.ts`, and `export/app/ask/{useAskChat.ts,AskChat.tsx}`. ## [0.7.6] - 2026-07-16 diff --git a/export/app/ask/Composer.tsx b/export/app/ask/Composer.tsx @@ -37,7 +37,8 @@ export function Composer(props: Props) { value={input} onChange={(e) => setInput(e.target.value)} onKeyDown={(e) => { - if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) { + // Enter sends; Shift+Enter inserts a newline. + if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); send(); } @@ -45,7 +46,7 @@ export function Composer(props: Props) { rows={2} placeholder={ summariesReady - ? "Ask about the transcripts… (⌘/Ctrl+Enter to send)" + ? "Ask about the transcripts… (Enter to send · Shift+Enter for a new line)" : "Loading transcripts…" } disabled={!summariesReady} diff --git a/export/app/ask/MessageBubble.tsx b/export/app/ask/MessageBubble.tsx @@ -47,9 +47,18 @@ export function MessageBubble({ onRetry?: () => void; }) { const isUser = message.role === "user"; - const streaming = message.phase === "answering" || message.phase === "streaming"; - const renderMarkdown = - !isUser && markdownOn && !message.error && message.phase === "done"; + // Only true streaming shows the caret bubble; the pre-token "answering" state is + // covered solely by PipelineStatus so there aren't two "it's coming" indicators. + const streaming = message.phase === "streaming"; + const settled = message.phase === "done" || message.phase === "stopped"; + const emptyAnswer = !isUser && message.phase === "done" && !message.error && !message.content; + const renderMarkdown = !isUser && markdownOn && !message.error && settled && !!message.content; + const nonFetchSteps = message.searchSteps?.filter((s) => s.kind !== "fetch") ?? []; + // Keep the "Searched:" recap on stopped/errored turns too, so stopping or a + // failure after searching doesn't erase what was looked up. + const showSearched = !isUser && (settled || message.error) && nonFetchSteps.length > 0; + const showRetry = + !isUser && !!onRetry && (message.error || message.phase === "stopped" || emptyAnswer); return ( <div className="flex flex-col gap-2 animate-in fade-in slide-in-from-bottom-2 motion-reduce:animate-none"> @@ -57,6 +66,8 @@ export function MessageBubble({ {(message.content || streaming) && ( <div + role={streaming ? "status" : undefined} + aria-live={streaming ? "polite" : undefined} className={ isUser ? "self-end rounded-lg bg-primary/10 px-3 py-2 text-sm text-foreground" @@ -76,25 +87,25 @@ export function MessageBubble({ </div> )} - {!isUser && - message.phase === "done" && - (message.searchSteps?.filter((s) => s.kind !== "fetch").length ?? 0) > 0 && ( - <p className="font-mono text-xs text-muted-foreground/70"> - Searched:{" "} - {message - .searchSteps!.filter((s) => s.kind !== "fetch") - .map((s) => s.query) - .join(" · ")} - </p> - )} + {emptyAnswer && ( + <p className="text-sm italic text-muted-foreground/80"> + No answer was returned — retry, or rephrase your question. + </p> + )} + + {showSearched && ( + <p className="font-mono text-xs text-muted-foreground/70"> + Searched: {nonFetchSteps.map((s) => s.query).join(" · ")} + </p> + )} - {!isUser && message.phase === "done" && message.content && ( + {!isUser && settled && message.content && ( <div className="flex items-center"> <CopyButton text={message.content} /> </div> )} - {!isUser && message.error && onRetry && ( + {showRetry && ( <div className="flex items-center gap-2 text-xs"> <button type="button" diff --git a/export/app/ask/ProviderSettings.tsx b/export/app/ask/ProviderSettings.tsx @@ -1,5 +1,6 @@ "use client"; +import { useState } from "react"; import { PROVIDERS, type Provider } from "../lib/askProvider"; import type { AgentMode } from "../lib/searchAgent"; @@ -42,9 +43,17 @@ export function ProviderSettings(props: Props) { persistKey, } = props; const info = PROVIDERS[provider]; + // Seed the panel open when there's no key yet, then hand control to the user. + // Must NOT be recomputed from `apiKey` on every render — otherwise typing the + // first character of the key flips it closed and yanks the panel away mid-entry. + const [open, setOpen] = useState(!apiKey); return ( - <details className="rounded-lg border border-border bg-card/40" open={!apiKey}> + <details + className="rounded-lg border border-border bg-card/40" + open={open} + onToggle={(e) => setOpen((e.currentTarget as HTMLDetailsElement).open)} + > <summary className="cursor-pointer px-4 py-2.5 text-sm font-medium text-foreground"> {apiKey ? `${info.label} · key set` : "Set up your AI provider"} </summary> diff --git a/export/app/ask/useAskChat.ts b/export/app/ask/useAskChat.ts @@ -107,6 +107,10 @@ export function useAskChat() { const [input, setInput] = useState(""); const [busy, setBusy] = useState(false); const abortRef = useRef<AbortController | null>(null); + // Guards against a rapid double-submit: `busy` flips asynchronously inside + // runTurn, so two fast Enter presses could both pass the `!busy` check before + // it settled. This ref is set synchronously in send() and cleared in runTurn. + const sendingRef = useRef(false); // Always-current mirrors so send()/runTurn() never act on a stale snapshot // (e.g. sending immediately after applying an edited context). @@ -410,17 +414,21 @@ export function useAskChat() { phase: "stopped", })); } else { - // Preserve any grounding already captured at answer_start so Retry can - // skip the search. + // Surface the failure even mid-stream: if tokens already arrived, keep + // them AND append the error (previously the error was dropped whenever + // any content had streamed, so the user never learned what went wrong). + // Grounding captured at answer_start is preserved so Retry can skip the + // search. patchAt(assistantIndex, (m) => ({ ...m, - content: m.content || err.message, + content: m.content ? `${m.content}\n\n_${err.message}_` : err.message, error: true, phase: "error", })); } } finally { setBusy(false); + sendingRef.current = false; abortRef.current = null; } }, @@ -429,7 +437,10 @@ export function useAskChat() { const send = useCallback(async () => { const question = input.trim(); - if (!question || busy || !apiKey.trim() || !summariesReady) return; + if (!question || busy || sendingRef.current || !apiKey.trim() || !summariesReady) { + return; + } + sendingRef.current = true; persistKey(provider, apiKey, model, remember); const prior = messagesRef.current; diff --git a/export/e2e/ask-chat.spec.ts b/export/e2e/ask-chat.spec.ts @@ -91,8 +91,8 @@ async function setup(page: Page, mode: "Scripted" | "Native tools" = "Scripted") await installRoutes(page); await mockAnthropic(page); await page.goto("/ask/"); - // Pick the search mode while the settings panel is still open — filling the - // key collapses it (open={!apiKey}). + // Pick the search mode while the settings panel is open (it starts open with no + // key set, and no longer collapses while you type the key). await page.getByRole("button", { name: mode }).click(); await page.locator('input[placeholder^="sk-ant"]').fill("sk-ant-test"); // Guard against the one-time restore effect clearing the freshly-typed key.