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:
parent
78b8ec8194
commit
0a1f0c2e78
12 changed files with 111 additions and 89 deletions
|
|
@ -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} />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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" },
|
||||
];
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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(() => {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue