fix(chat): address review feedback from Gemini, Codex, and internal review

1. ArtifactPanel textarea: use local state with onChange/onBlur instead
   of empty onChange handler that made the editor unwritable

2. Artifact emission: move store.addArtifact from render body into a
   dedicated ArtifactEmitter component using useEffect

3. searchText for new threads: write searchText during generateTitle
   so post-migration threads are searchable by content

4. PromptLibrarySheet: wire onInsertPrompt with clipboard fallback and
   toast notification; handle prompt() cancel (null) as abort

5. download.ts: defer URL.revokeObjectURL with setTimeout to avoid
   failed downloads on Firefox

6. DB migration: replace serial for-of await loop with Promise.all to
   prevent transaction timeout on large databases; skip empty text

7. handleDeleteFolder: use Dexie transaction with .modify() for atomic
   unfile-then-delete; use undefined instead of "" for unfiled folderId

8. Keyboard shortcuts: remove Cmd+Shift+F from help dialog since the
   handler is not wired

Also:
- Remove unused aui hook, SearchIcon import, and prompt-library imports
- Fix stale artifactPanelOpen closure in command palette toggle
- Fix JSONL export: remove non-standard feedback field from message
  objects; use application/x-ndjson MIME type
- Fix nested button in ArtifactTab: use div with role="tab"
- Use static db import instead of redundant dynamic import in adapter
- Log memory injection errors instead of silent catch
- Add cancelled flag to feedback useEffect to prevent stale setState
- Use functional setState in handleFeedback to avoid stale closure
- Add .catch() on feedback DB write
This commit is contained in:
Daniel Han 2026-03-25 10:47:17 +00:00
commit 0a1f0c2e78
12 changed files with 111 additions and 89 deletions

View file

@ -12,7 +12,7 @@ import { code } from "@streamdown/code";
import { createMathPlugin } from "@streamdown/math";
import { mermaid } from "@streamdown/mermaid";
import { DownloadIcon, Maximize2Icon, Minimize2Icon } from "lucide-react";
import { useEffect, useMemo, useRef, useState } from "react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Block, type BlockProps, Streamdown } from "streamdown";
import "katex/dist/katex.min.css";
import { AudioPlayer } from "./audio-player";
@ -337,6 +337,23 @@ function CodeBlockActions({
);
}
/** Emits an artifact to the store via useEffect (avoids side-effects in render). */
function ArtifactEmitter({ language, source }: { language: string | null; source: string }) {
useEffect(() => {
const artifactId = `artifact-${hashCode(source)}`;
const store = useArtifactStore.getState();
if (store.artifacts.some((a) => a.id === artifactId)) return;
store.addArtifact({
id: artifactId,
title: language ? `${language} snippet` : "Code snippet",
language,
content: source,
createdAt: Date.now(),
});
}, [language, source]);
return null;
}
function StreamdownBlock(props: BlockProps) {
const hasMermaidFence = props.content.includes("```mermaid");
const mermaidSource = getMermaidSource(props.content);
@ -389,34 +406,23 @@ function StreamdownBlock(props: BlockProps) {
const isArtifactWorthy =
!props.isIncomplete &&
(lineCount >= 20 || svgSource !== null || htmlSource !== null);
if (isArtifactWorthy) {
const artifactId = `artifact-${hashCode(codeFence.source)}`;
const store = useArtifactStore.getState();
if (!store.artifacts.some((a) => a.id === artifactId)) {
store.addArtifact({
id: artifactId,
title: codeFence.language
? `${codeFence.language} snippet`
: "Code snippet",
language: codeFence.language,
content: codeFence.source,
createdAt: Date.now(),
});
}
}
return (
<>
<div className="relative isolate">
<Block {...props} />
<CodeBlockActions
disabled={props.isIncomplete}
language={codeFence.language}
source={codeFence.source}
/>
</div>
{svgSource && <SvgPreview source={svgSource} />}
{htmlSource && <HtmlPreview source={htmlSource} />}
{isArtifactWorthy && (
<ArtifactEmitter language={codeFence.language} source={codeFence.source} />
)}
<div className="relative isolate">
<Block {...props} />
<CodeBlockActions
disabled={props.isIncomplete}
language={codeFence.language}
source={codeFence.source}
/>
</div>
{svgSource && <SvgPreview source={svgSource} />}
{htmlSource && <HtmlPreview source={htmlSource} />}
</>
);
}

View file

@ -619,7 +619,6 @@ const CopyButton: FC = () => {
};
const FeedbackButtons: FC = () => {
const aui = useAui();
const messageId = useAuiState(({ message }) => message.id);
const [feedback, setFeedback] = useState<"thumbs_up" | "thumbs_down" | null>(
null,
@ -628,22 +627,27 @@ const FeedbackButtons: FC = () => {
// Load existing feedback from DB
useEffect(() => {
if (!messageId) return;
setFeedback(null);
let cancelled = false;
void db.messages.get(messageId).then((msg) => {
if (msg?.feedback) setFeedback(msg.feedback);
if (!cancelled && msg?.feedback) setFeedback(msg.feedback);
});
return () => { cancelled = true; };
}, [messageId]);
const handleFeedback = useCallback(
(value: "thumbs_up" | "thumbs_down") => {
const next = feedback === value ? null : value;
setFeedback(next);
if (messageId) {
void db.messages.update(messageId, {
feedback: next ?? undefined,
});
}
setFeedback((prev) => {
const next = prev === value ? null : value;
if (messageId) {
void db.messages
.update(messageId, { feedback: next ?? undefined })
.catch((err) => console.error("Failed to save feedback:", err));
}
return next;
});
},
[feedback, messageId],
[messageId],
);
return (

View file

@ -446,8 +446,7 @@ export function createOpenAIStreamAdapter(): ChatModelAdapter {
let systemContent =
typeof params.systemPrompt === "string" ? params.systemPrompt.trim() : "";
try {
const { db: chatDb } = await import("../db");
const allMemories = await chatDb.memory.toArray();
const allMemories = await db.memory.toArray();
const enabledMemories = allMemories.filter(
(m: { enabled: boolean }) => m.enabled,
);
@ -461,8 +460,8 @@ export function createOpenAIStreamAdapter(): ChatModelAdapter {
? `${memoryBlock}\n\n${systemContent}`
: memoryBlock;
}
} catch {
// Memory table may not exist yet during migration
} catch (err) {
console.warn("Memory injection skipped:", err);
}
if (systemContent) {
outboundMessages.unshift({

View file

@ -27,6 +27,7 @@ import {
SheetTitle,
} from "@/components/ui/sheet";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { copyToClipboard } from "@/lib/copy-to-clipboard";
import { cn } from "@/lib/utils";
import {
ColumnInsertIcon,
@ -41,7 +42,6 @@ import {
KeyboardIcon,
PanelRightIcon,
PencilIcon,
SearchIcon,
SettingsIcon,
} from "lucide-react";
import {
@ -775,6 +775,10 @@ export function ChatPage(): ReactElement {
<PromptLibrarySheet
open={promptLibraryOpen}
onOpenChange={setPromptLibraryOpen}
onInsertPrompt={(content) => {
copyToClipboard(content);
toast.success("Prompt copied to clipboard");
}}
/>
<CommandDialog open={commandPaletteOpen} onOpenChange={setCommandPaletteOpen}>
<Command>
@ -805,7 +809,7 @@ export function ChatPage(): ReactElement {
<BookOpenIcon className="mr-2 size-4" />
Prompt Library
</CommandItem>
<CommandItem onSelect={() => { useArtifactStore.getState().setPanelOpen(!artifactPanelOpen); setCommandPaletteOpen(false); }}>
<CommandItem onSelect={() => { const s = useArtifactStore.getState(); s.setPanelOpen(!s.panelOpen); setCommandPaletteOpen(false); }}>
<PanelRightIcon className="mr-2 size-4" />
Toggle Artifacts Panel
</CommandItem>

View file

@ -12,7 +12,7 @@ import {
DownloadIcon,
XIcon,
} from "lucide-react";
import { type FC, useRef, useState } from "react";
import { type FC, useEffect, useRef, useState } from "react";
import {
type Artifact,
useArtifactStore,
@ -28,10 +28,12 @@ const ArtifactTab: FC<{ artifact: Artifact; isActive: boolean }> = ({
const remove = useArtifactStore((s) => s.removeArtifact);
return (
<button
type="button"
<div
role="tab"
tabIndex={0}
onClick={() => setActive(artifact.id)}
className={`group flex items-center gap-1.5 rounded-t-md border-b-2 px-3 py-1.5 text-xs font-medium transition-colors ${
onKeyDown={(e) => e.key === "Enter" && setActive(artifact.id)}
className={`group flex cursor-pointer items-center gap-1.5 rounded-t-md border-b-2 px-3 py-1.5 text-xs font-medium transition-colors ${
isActive
? "border-primary bg-background text-foreground"
: "border-transparent text-muted-foreground hover:text-foreground"
@ -48,7 +50,7 @@ const ArtifactTab: FC<{ artifact: Artifact; isActive: boolean }> = ({
>
<XIcon className="size-3" />
</button>
</button>
</div>
);
};
@ -61,13 +63,18 @@ export const ArtifactPanel: FC = () => {
const updateContent = useArtifactStore((s) => s.updateArtifactContent);
const [copied, setCopied] = useState(false);
const [localValue, setLocalValue] = useState("");
const resetRef = useRef<ReturnType<typeof setTimeout>>();
const textareaRef = useRef<HTMLTextAreaElement>(null);
if (!panelOpen || artifacts.length === 0) return null;
const active = artifacts.find((a) => a.id === activeId) ?? artifacts[0];
if (!active) return null;
// Sync local editor value when active artifact changes
useEffect(() => {
if (active) setLocalValue(active.content);
}, [active?.id, active?.content]);
if (!panelOpen || artifacts.length === 0 || !active) return null;
const handleCopy = () => {
if (copyToClipboard(active.content)) {
@ -152,14 +159,11 @@ export const ArtifactPanel: FC = () => {
<div className="flex-1 overflow-auto">
<textarea
ref={textareaRef}
value={active.content}
onChange={(e) => {
// Direct editing creates a new version on blur
}}
onBlur={(e) => {
const val = e.target.value;
if (val !== active.content) {
updateContent(active.id, val);
value={localValue}
onChange={(e) => setLocalValue(e.target.value)}
onBlur={() => {
if (localValue !== active.content) {
updateContent(active.id, localValue);
}
}}
className="h-full w-full resize-none bg-transparent p-4 font-mono text-xs leading-relaxed outline-none"

View file

@ -18,7 +18,6 @@ const shortcuts = [
{ keys: `${mod}+Shift+N`, description: "New chat" },
{ keys: `${mod}+Shift+C`, description: "Toggle compare mode" },
{ keys: `${mod}+Shift+S`, description: "Toggle settings" },
{ keys: `${mod}+Shift+F`, description: "Search conversations" },
{ keys: "Escape", description: "Close dialogs / Cancel" },
{ keys: "?", description: "Show this help" },
];

View file

@ -11,11 +11,10 @@ import {
SheetHeader,
SheetTitle,
} from "@/components/ui/sheet";
import { PlusIcon, Trash2Icon, PencilIcon, CheckIcon, XIcon, CopyIcon } from "lucide-react";
import { Trash2Icon, PencilIcon, CopyIcon } from "lucide-react";
import { type FC, useCallback, useState } from "react";
import { db, useLiveQuery } from "../db";
import type { PromptRecord } from "../types";
import { copyToClipboard } from "@/lib/copy-to-clipboard";
const VAR_RE = /\{\{(\w+)\}\}/g;
@ -92,7 +91,8 @@ export const PromptLibrarySheet: FC<{
let result = p.content;
if (p.variables.length > 0) {
for (const v of p.variables) {
const value = prompt(`Value for {{${v}}}:`) ?? "";
const value = window.prompt(`Value for {{${v}}}:`);
if (value === null) return; // user cancelled
result = result.replaceAll(`{{${v}}}`, value);
}
}

View file

@ -56,27 +56,30 @@ db.version(4)
.upgrade(async (tx) => {
// Backfill searchText from first user message in each thread
const threads = await tx.table("threads").toArray();
for (const thread of threads) {
const msgs = await tx
.table("messages")
.where("threadId")
.equals(thread.id)
.toArray();
const firstUser = msgs
.sort((a: MessageRecord, b: MessageRecord) => a.createdAt - b.createdAt)
.find((m: MessageRecord) => m.role === "user");
if (firstUser) {
await Promise.all(
threads.map(async (thread) => {
const msgs = await tx
.table("messages")
.where("threadId")
.equals(thread.id)
.toArray();
const firstUser = msgs
.sort((a: MessageRecord, b: MessageRecord) => a.createdAt - b.createdAt)
.find((m: MessageRecord) => m.role === "user");
if (!firstUser) return;
const textParts = Array.isArray(firstUser.content)
? firstUser.content
.filter((p: { type: string }) => p.type === "text")
.map((p: { text: string }) => p.text)
.join(" ")
: "";
await tx
.table("threads")
.update(thread.id, { searchText: textParts.slice(0, 500) });
}
}
if (textParts.trim()) {
await tx
.table("threads")
.update(thread.id, { searchText: textParts.slice(0, 500) });
}
}),
);
});
export { db };

View file

@ -74,18 +74,17 @@ export async function exportAsJSONL(threadId: string): Promise<void> {
if (!data) return;
const { thread, messages } = data;
// OpenAI chat format -- each line is a conversation
// OpenAI chat format -- standard SFT structure, no extra fields
const chatMessages = messages.map((m) => ({
role: m.role === "user" ? "user" : "assistant",
content: extractText(m.content),
...(m.feedback ? { feedback: m.feedback } : {}),
}));
const line = JSON.stringify({ messages: chatMessages });
downloadTextFile(
`${sanitizeFilename(thread.title)}.jsonl`,
line + "\n",
"application/jsonl",
"application/x-ndjson",
);
}

View file

@ -481,6 +481,11 @@ function createDexieAdapter(
const firstUser = messages.find((m) => m.role === "user");
const userText = extractTextParts(firstUser) || defaultTitle;
// Backfill searchText for new threads (once, on first title generation)
if (!thread.searchText && userText !== defaultTitle) {
await db.threads.update(remoteId, { searchText: userText.slice(0, 500) });
}
if (!autoTitle) {
const title = fallbackTitleFromUserText(userText);
await persistTitle(title);

View file

@ -200,12 +200,13 @@ export function ThreadSidebar({
}
async function handleMoveToFolder(item: SidebarItem, folderId: string | undefined) {
const newFolderId = folderId || undefined;
if (item.type === "single") {
await db.threads.update(item.id, { folderId: folderId ?? "" });
await db.threads.update(item.id, { folderId: newFolderId });
} else {
const paired = await db.threads.where("pairId").equals(item.id).toArray();
for (const t of paired) {
await db.threads.update(t.id, { folderId: folderId ?? "" });
await db.threads.update(t.id, { folderId: newFolderId });
}
}
}
@ -233,12 +234,10 @@ export function ThreadSidebar({
}, []);
const handleDeleteFolder = useCallback(async (folderId: string) => {
// Unfile threads in the folder, then delete the folder
const threads = await db.threads.where("folderId").equals(folderId).toArray();
for (const t of threads) {
await db.threads.update(t.id, { folderId: "" });
}
await db.folders.delete(folderId);
await db.transaction("rw", db.threads, db.folders, async () => {
await db.threads.where("folderId").equals(folderId).modify({ folderId: undefined });
await db.folders.delete(folderId);
});
}, []);
const toggleSearch = useCallback(() => {

View file

@ -14,5 +14,5 @@ export function downloadTextFile(
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
setTimeout(() => URL.revokeObjectURL(url), 100);
}