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:
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.