commit c2591f8bcf4847863f706f56a6cac124bd0747ff
parent 2f6cbc930b94f917179c25505ea68a197a807a95
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date: Thu, 16 Jul 2026 16:33:16 -0400
Ask chat: regenerate, edit & resend, per-answer attribution
- Regenerate a completed answer (relabelled Retry, reuses gathered excerpts).
- Edit any earlier user turn: pulls it back into the composer and truncates
the thread so the next send re-asks from there.
- Store + show which model produced each answer (attribution when the
provider/model changes mid-conversation).
- Context panel shows a rough ~token estimate instead of only a char count.
- A hand-typed model survives switching providers and back within a session.
- Surface a "couldn't load" note when a Load-context fetch fails, instead of
the spinner silently stopping.
Verified: tsc + lint clean; 15 unit + 13 ask-chat e2e green.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Diffstat:
7 files changed, 94 insertions(+), 12 deletions(-)
diff --git a/export/CHANGELOG.md b/export/CHANGELOG.md
@@ -1,6 +1,7 @@
# Changelog
## [Unreleased]
+- **"Ask AI" chat — regenerate, edit & resend, and per-answer attribution.** You can now **Regenerate** a completed answer (reusing the excerpts it already gathered), **Edit** any earlier question to pull it back into the composer and re-ask from that point, and see **which model** produced each answer — useful when you switch providers mid-conversation. The Context panel now shows a rough **token estimate** (not just a character count) as a cost cue, a hand-typed model name survives switching providers and back within a session, and a failed "Load context" fetch now says so on that result instead of the spinner just quietly stopping. See `export/app/ask/{MessageBubble,AskChat,PinnedResultsPanel,ContextPanel,useAskChat}.tsx` and `export/app/lib/askConversation.ts`.
- **"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}`.
diff --git a/export/app/ask/AskChat.tsx b/export/app/ask/AskChat.tsx
@@ -125,6 +125,7 @@ export default function AskChat() {
strictGrounding={s.strictGrounding}
busy={busy}
expanding={s.expanding}
+ expandError={s.expandError}
onSetStrict={s.setStrictGrounding}
onClear={s.clearPinned}
onExpandVideo={s.expandPinnedVideo}
@@ -190,6 +191,9 @@ export default function AskChat() {
message={m}
markdownOn={markdownOn}
onRetry={m.role === "assistant" ? () => s.retry(i) : undefined}
+ onEdit={
+ m.role === "user" && !busy ? () => s.editUserMessage(i) : undefined
+ }
/>
))
)}
diff --git a/export/app/ask/ContextPanel.tsx b/export/app/ask/ContextPanel.tsx
@@ -52,8 +52,11 @@ export function ContextPanel({
edited
</span>
)}
- <span className="ml-auto font-mono text-xs text-muted-foreground/70">
- {contextText.length.toLocaleString()} chars
+ <span
+ className="ml-auto font-mono text-xs text-muted-foreground/70"
+ title="Rough estimate — actual token count and cost depend on your provider"
+ >
+ ~{Math.round(contextText.length / 4).toLocaleString()} tokens
</span>
</summary>
{open && (
diff --git a/export/app/ask/MessageBubble.tsx b/export/app/ask/MessageBubble.tsx
@@ -1,7 +1,7 @@
"use client";
import { useState } from "react";
-import { CheckIcon, CopyIcon, RotateCwIcon } from "lucide-react";
+import { CheckIcon, CopyIcon, PencilIcon, RotateCwIcon } from "lucide-react";
import { Markdown } from "yt-dlp-transcript-common/components/Markdown";
import type { UiMessage } from "../lib/askConversation";
import { PipelineStatus } from "./PipelineStatus";
@@ -41,10 +41,12 @@ export function MessageBubble({
message,
markdownOn,
onRetry,
+ onEdit,
}: {
message: UiMessage;
markdownOn: boolean;
onRetry?: () => void;
+ onEdit?: () => void;
}) {
const isUser = message.role === "user";
// Only true streaming shows the caret bubble; the pre-token "answering" state is
@@ -57,8 +59,16 @@ export function MessageBubble({
// 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);
+ // A completed answer can be regenerated; a failed/stopped/empty one retried.
+ const canRerun =
+ !isUser &&
+ !!onRetry &&
+ (message.error ||
+ message.phase === "stopped" ||
+ emptyAnswer ||
+ (message.phase === "done" && !!message.content));
+ const rerunLabel =
+ message.phase === "done" && message.content ? "Regenerate" : "Retry";
return (
<div className="flex flex-col gap-2 animate-in fade-in slide-in-from-bottom-2 motion-reduce:animate-none">
@@ -99,22 +109,37 @@ export function MessageBubble({
</p>
)}
+ {isUser && onEdit && (
+ <button
+ type="button"
+ onClick={onEdit}
+ className="inline-flex items-center gap-1 self-end rounded px-1.5 py-0.5 text-xs text-muted-foreground/70 transition-colors hover:text-foreground"
+ >
+ <PencilIcon className="size-3" /> Edit
+ </button>
+ )}
+
{!isUser && settled && message.content && (
- <div className="flex items-center">
+ <div className="flex items-center gap-2">
<CopyButton text={message.content} />
+ {message.model && (
+ <span className="font-mono text-xs text-muted-foreground/50">
+ {message.model}
+ </span>
+ )}
</div>
)}
- {showRetry && (
+ {canRerun && (
<div className="flex items-center gap-2 text-xs">
<button
type="button"
onClick={onRetry}
className="inline-flex items-center gap-1 rounded-md border border-border px-2 py-1 text-muted-foreground transition-colors hover:text-foreground"
>
- <RotateCwIcon className="size-3.5" /> Retry
+ <RotateCwIcon className="size-3.5" /> {rerunLabel}
</button>
- {(message.sources?.length ?? 0) > 0 && (
+ {rerunLabel === "Retry" && (message.sources?.length ?? 0) > 0 && (
<span className="text-muted-foreground/70">
reuses the excerpts already found
</span>
diff --git a/export/app/ask/PinnedResultsPanel.tsx b/export/app/ask/PinnedResultsPanel.tsx
@@ -19,6 +19,7 @@ export function PinnedResultsPanel({
strictGrounding,
busy,
expanding,
+ expandError,
onSetStrict,
onClear,
onExpandVideo,
@@ -27,6 +28,7 @@ export function PinnedResultsPanel({
strictGrounding: boolean;
busy: boolean;
expanding: Record<string, boolean>;
+ expandError: Record<string, boolean>;
onSetStrict: (on: boolean) => void;
onClear: () => void;
onExpandVideo: (key: string, aroundSeconds?: number) => void;
@@ -92,6 +94,7 @@ export function PinnedResultsPanel({
<ul className="mt-2 flex flex-col gap-3 border-t border-border pt-2">
{pinned.videos.map((v) => {
const loading = !!expanding[v.key];
+ const failed = !!expandError[v.key];
return (
<li key={v.key} className="flex flex-col gap-1 text-xs">
<div className="flex items-start gap-2">
@@ -119,6 +122,11 @@ export function PinnedResultsPanel({
context
</button>
</div>
+ {failed && (
+ <span className="text-warning/90">
+ Couldn't load this transcript — try again.
+ </span>
+ )}
{v.snippets.length > 0 && (
<ul className="flex flex-col gap-0.5 border-l border-border pl-2 font-mono text-muted-foreground/80">
{v.snippets.map((sn) => (
diff --git a/export/app/ask/useAskChat.ts b/export/app/ask/useAskChat.ts
@@ -100,6 +100,9 @@ export function useAskChat() {
const [strictGrounding, setStrictGroundingState] = useState(true);
// Per-video key → true while its "Load context" fetch is in flight.
const [expanding, setExpanding] = useState<Record<string, boolean>>({});
+ // Per-video key → true when its last "Load context" fetch failed (so the panel
+ // can say so, instead of the spinner just quietly stopping).
+ const [expandError, setExpandError] = useState<Record<string, boolean>>({});
// Set when the conversation outgrew the storage quota and older turns had to be
// dropped from what's saved — surfaced so a reload's missing history isn't a
// silent surprise.
@@ -111,6 +114,9 @@ export function useAskChat() {
// 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);
+ // Per-provider models the user typed this session (survives provider switches
+ // even when "remember" is off).
+ const sessionModels = useRef<Partial<Record<Provider, string>>>({});
// Always-current mirrors so send()/runTurn() never act on a stale snapshot
// (e.g. sending immediately after applying an edited context).
@@ -225,16 +231,23 @@ export function useAskChat() {
}, [messages, contextOverride, pinned, strictGrounding]);
function loadProviderCreds(p: Provider, rememberOn: boolean) {
+ // Prefer a model the user typed for this provider earlier this session, so a
+ // hand-typed model isn't discarded by a round-trip through the dropdown.
+ const session = sessionModels.current[p];
if (rememberOn) {
setApiKey(localStorage.getItem(keyFor(p)) ?? "");
- setModel(localStorage.getItem(modelFor(p)) ?? PROVIDERS[p].defaultModel);
+ setModel(
+ session ?? localStorage.getItem(modelFor(p)) ?? PROVIDERS[p].defaultModel,
+ );
} else {
setApiKey("");
- setModel(PROVIDERS[p].defaultModel);
+ setModel(session ?? PROVIDERS[p].defaultModel);
}
}
function setProvider(p: Provider) {
+ // Stash the current (possibly hand-typed) model before switching away.
+ sessionModels.current[provider] = model;
setProviderState(p);
try {
localStorage.setItem(K_PROVIDER, p);
@@ -404,6 +417,7 @@ export function useAskChat() {
truncated: result.truncated,
error: false,
phase: "done",
+ model: model.trim() || PROVIDERS[provider].defaultModel,
}));
} catch (e) {
const err = e as Error;
@@ -523,6 +537,20 @@ export function useAskChat() {
[busy, apiKey, summariesReady, runTurn],
);
+ // Edit a prior user turn: pull its text back into the composer and drop it (and
+ // every turn after it) so the next send re-asks from that point.
+ const editUserMessage = useCallback(
+ (userIndex: number) => {
+ if (busy) return;
+ const current = messagesRef.current;
+ const msg = current[userIndex];
+ if (!msg || msg.role !== "user") return;
+ setInput(msg.content);
+ setMessages(current.slice(0, userIndex));
+ },
+ [busy],
+ );
+
const stop = () => abortRef.current?.abort();
const reset = () => {
@@ -556,6 +584,12 @@ export function useAskChat() {
? aroundSeconds
: v.snippets[0]?.seconds ?? 0;
setExpanding((e) => ({ ...e, [key]: true }));
+ setExpandError((e) => {
+ if (!e[key]) return e;
+ const next = { ...e };
+ delete next[key];
+ return next;
+ });
try {
const detail = await fetchTranscript(key);
const snips = cuesToSnippets(
@@ -578,7 +612,9 @@ export function useAskChat() {
: prev,
);
} catch {
- /* transcript unavailable — leave the pin untouched */
+ // Transcript unavailable — leave the pin untouched but flag it so the
+ // panel can surface "couldn't load" instead of silently stopping.
+ setExpandError((e) => ({ ...e, [key]: true }));
} finally {
setExpanding((e) => {
const next = { ...e };
@@ -647,6 +683,7 @@ export function useAskChat() {
stop,
reset,
retry,
+ editUserMessage,
storageWarning,
// context editing
contextText,
@@ -660,6 +697,7 @@ export function useAskChat() {
clearPinned,
expandPinnedVideo,
expanding,
+ expandError,
};
}
diff --git a/export/app/lib/askConversation.ts b/export/app/lib/askConversation.ts
@@ -41,6 +41,9 @@ export type UiMessage = {
phase?: AssistantPhase;
truncated?: boolean;
error?: boolean;
+ // Assistant turns: which model produced this answer (shown as a per-message
+ // attribution, since the provider/model can change mid-conversation).
+ model?: string;
};
// Replay completed prior turns for the model as BARE text — user turns are the