Chat mode, phase 2: local history, answer actions, motion polish
- Conversations in a versioned IndexedDB store (freezmo-chat v1, zod validated with the same schema backup/sync use, capped at 100 x 300). Settled turns persist (question always; answer when produced, partial stops included); ?c=<id> keeps reloads on the conversation via history.replaceState (no router round-trip mid-chat). - History UI: desktop column, phone sheet (spring, backdrop, Escape), auto titles from the first question, inline rename, delete with an Undo toast, diacritic-folding search over titles and message text. Scope and model are remembered per conversation; history is readable offline. - Assistant actions: Turn into flashcards (New deck with the answer as notes, landing on the options step - deck seed gained step) and Save to notes (appends to the single scoped deck, trimmed at 40k). - Chats travel in the backup export (preview counts them) and in the encrypted sync payload; still never the key. - Motion from lib/motion.ts: message fade-up, sheet springs for the model/context/history sheets, 150ms composer height transition, all skipped under reduced motion. - Tests: chat suite 63 -> 89 checks (history, reload, rename/search/ delete/undo, actions, export, two-device sync round-trip, offline, desktop panel). Full set: 12/12 suites, 513 checks. SW -> freezmo-v33. Also: tests/model-compare.mjs benchmark (diagnostic) for deciding the default text model on numbers; defaults unchanged.
This commit is contained in:
parent
756a6257a2
commit
5ca0de920c
19 changed files with 1107 additions and 113 deletions
|
|
@ -7,9 +7,10 @@ import Link from "next/link";
|
|||
|
||||
import { Composer, type PendingAttachment } from "./Composer";
|
||||
import { ContextPicker } from "./ContextPicker";
|
||||
import { ChatHistoryList, HistorySheet } from "./HistoryPanel";
|
||||
import { MessageRow } from "./MessageRow";
|
||||
import { ModelPicker } from "./ModelPicker";
|
||||
import { ChatIcon, ChevronDownIcon, ChevronLeftIcon, PlusIcon, RefreshIcon } from "@/components/icons";
|
||||
import { ChatIcon, ChevronDownIcon, ChevronLeftIcon, HistoryIcon, PlusIcon, RefreshIcon } from "@/components/icons";
|
||||
import { ModelErrorActions } from "@/components/ModelErrorActions";
|
||||
import { ThemeToggle } from "@/components/ThemeToggle";
|
||||
import { Button } from "@/components/ui";
|
||||
|
|
@ -17,24 +18,30 @@ import { ApiError } from "@/lib/client-api";
|
|||
import { extractChatAttachment, streamChat } from "@/lib/chat-client";
|
||||
import { buildChatContext, deckHasNotes } from "@/lib/chat-context";
|
||||
import { takeChatSeed, type ChatSeed } from "@/lib/chat-seed";
|
||||
import { chatHistoryStore, conversationMessages, deleteConversation, ensureChatsHydrated, getConversation, renameConversation, saveConversation } from "@/lib/chat-store";
|
||||
import {
|
||||
chatMessageId,
|
||||
readStoredChatModel,
|
||||
storeChatModel,
|
||||
titleFromMessage,
|
||||
type ChatAttachment,
|
||||
type ChatMessage,
|
||||
type ChatModelChoice,
|
||||
type ChatScope,
|
||||
} from "@/lib/chat";
|
||||
import { setDeckSeed } from "@/lib/deck-seed";
|
||||
import { extractDocument } from "@/lib/documents";
|
||||
import { folderStore } from "@/lib/folders";
|
||||
import { isImageFile, prepareImage } from "@/lib/image";
|
||||
import { useI18n } from "@/lib/i18n";
|
||||
import { springGentle } from "@/lib/motion";
|
||||
import { showToast } from "@/lib/toast";
|
||||
import { deckStore } from "@/lib/storage";
|
||||
import { deckStore, upsertDeck } from "@/lib/storage";
|
||||
import { settingsStore } from "@/lib/settings";
|
||||
import type { ChatConversation } from "@/lib/schemas";
|
||||
import { useHealth } from "@/lib/use-health";
|
||||
import { useOnline } from "@/lib/use-online";
|
||||
import { motion } from "framer-motion";
|
||||
|
||||
type ExtraChunk = { deckId: string; deckTitle: string; section: string; text: string };
|
||||
|
||||
|
|
@ -71,6 +78,16 @@ export function ChatScreen() {
|
|||
const [scopeOpen, setScopeOpen] = useState(false);
|
||||
const [modelOpen, setModelOpen] = useState(false);
|
||||
const [error, setError] = useState<{ message: string; kind?: string } | null>(null);
|
||||
const [conversationId, setConversationId] = useState<string | null>(null);
|
||||
const [title, setTitle] = useState("");
|
||||
const [historyOpen, setHistoryOpen] = useState(false);
|
||||
const [query, setQuery] = useState("");
|
||||
|
||||
const conversations = useSyncExternalStore(
|
||||
chatHistoryStore.subscribe,
|
||||
chatHistoryStore.getSnapshot,
|
||||
chatHistoryStore.getServerSnapshot,
|
||||
);
|
||||
|
||||
const abortRef = useRef<AbortController | null>(null);
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
|
|
@ -89,6 +106,33 @@ export function ChatScreen() {
|
|||
setModel(readStoredChatModel() ?? "");
|
||||
}, []);
|
||||
|
||||
/* History: hydrate once, then open ?c=<id> conversations. */
|
||||
useEffect(() => {
|
||||
void ensureChatsHydrated();
|
||||
}, []);
|
||||
|
||||
const loadedRef = useRef<string | null>(null);
|
||||
const loadConversation = useCallback((found: ChatConversation) => {
|
||||
setConversationId(found.id);
|
||||
setTitle(found.title);
|
||||
setMessages(conversationMessages(found));
|
||||
setScope(found.scope);
|
||||
setSeed(null);
|
||||
setAttachments([]);
|
||||
setDraft("");
|
||||
setError(null);
|
||||
if (found.model) setModel(found.model);
|
||||
loadedRef.current = found.id;
|
||||
}, []);
|
||||
|
||||
const conversationParam = search.get("c");
|
||||
useEffect(() => {
|
||||
if (!conversationParam || loadedRef.current === conversationParam) return;
|
||||
const found = getConversation(conversationParam);
|
||||
if (!found) return; // hydration may still be running; re-runs when it lands
|
||||
loadConversation(found);
|
||||
}, [conversationParam, conversations, loadConversation]);
|
||||
|
||||
/* Keyboard: keep the composer above the on-screen keyboard. */
|
||||
useEffect(() => {
|
||||
const viewport = window.visualViewport;
|
||||
|
|
@ -191,6 +235,33 @@ export function ChatScreen() {
|
|||
return chunks;
|
||||
};
|
||||
|
||||
const persist = useCallback(
|
||||
(nextMessages: ChatMessage[], options: { title?: string; model?: string; scope?: ChatScope } = {}) => {
|
||||
const id = conversationId ?? chatMessageId("chat");
|
||||
if (!conversationId) {
|
||||
setConversationId(id);
|
||||
// Keep the URL in step so a reload continues this conversation.
|
||||
// history.replaceState avoids a router round-trip for the same page.
|
||||
window.history.replaceState(null, "", `/chat?c=${id}`);
|
||||
}
|
||||
const firstUser = nextMessages.find((message) => message.role === "user");
|
||||
const derived = title || (firstUser ? titleFromMessage(firstUser.content) : "");
|
||||
const nextTitle = options.title ?? derived;
|
||||
const existing = getConversation(id);
|
||||
saveConversation({
|
||||
id,
|
||||
title: nextTitle,
|
||||
messages: nextMessages,
|
||||
scope: options.scope ?? scope,
|
||||
model: options.model ?? (activeModel || undefined),
|
||||
createdAt: existing?.createdAt ?? Date.now(),
|
||||
updatedAt: Date.now(),
|
||||
});
|
||||
if (nextTitle && nextTitle !== title) setTitle(nextTitle);
|
||||
},
|
||||
[conversationId, title, scope, activeModel],
|
||||
);
|
||||
|
||||
const run = async (history: ChatMessage[], extraChunks: ExtraChunk[]) => {
|
||||
const lastUser = history[history.length - 1];
|
||||
const context = buildChatContext({
|
||||
|
|
@ -211,6 +282,10 @@ export function ChatScreen() {
|
|||
const controller = new AbortController();
|
||||
abortRef.current = controller;
|
||||
let produced = false;
|
||||
let finalContent = "";
|
||||
let finalThinking = "";
|
||||
let finalModel: string | undefined;
|
||||
let failure: { message: string; kind?: string } | null = null;
|
||||
|
||||
try {
|
||||
await streamChat({
|
||||
|
|
@ -220,6 +295,7 @@ export function ChatScreen() {
|
|||
signal: controller.signal,
|
||||
onToken: (text) => {
|
||||
produced = true;
|
||||
finalContent += text;
|
||||
setMessages((previous) =>
|
||||
previous.map((message) =>
|
||||
message.id === assistantId ? { ...message, content: message.content + text } : message,
|
||||
|
|
@ -227,6 +303,7 @@ export function ChatScreen() {
|
|||
);
|
||||
},
|
||||
onThinking: (text) => {
|
||||
finalThinking += text;
|
||||
setMessages((previous) =>
|
||||
previous.map((message) =>
|
||||
message.id === assistantId ? { ...message, thinking: (message.thinking ?? "") + text } : message,
|
||||
|
|
@ -234,6 +311,7 @@ export function ChatScreen() {
|
|||
);
|
||||
},
|
||||
onDone: (modelName) => {
|
||||
finalModel = modelName;
|
||||
setMessages((previous) =>
|
||||
previous.map((message) => (message.id === assistantId ? { ...message, model: modelName } : message)),
|
||||
);
|
||||
|
|
@ -244,7 +322,8 @@ export function ChatScreen() {
|
|||
if (apiError.kind === "aborted") {
|
||||
// Stopped by the user: keep whatever streamed in.
|
||||
} else {
|
||||
setError({ message: apiError.message, kind: apiError.kind });
|
||||
failure = { message: apiError.message, kind: apiError.kind };
|
||||
setError(failure);
|
||||
if (!produced) {
|
||||
// Nothing arrived: take the placeholder away and give the text back.
|
||||
setMessages(history);
|
||||
|
|
@ -253,7 +332,7 @@ export function ChatScreen() {
|
|||
setMessages((previous) =>
|
||||
previous.map((message) =>
|
||||
message.id === assistantId
|
||||
? { ...message, error: { message: apiError.message, kind: apiError.kind } }
|
||||
? { ...message, error: failure ?? undefined }
|
||||
: message,
|
||||
),
|
||||
);
|
||||
|
|
@ -262,6 +341,21 @@ export function ChatScreen() {
|
|||
} finally {
|
||||
abortRef.current = null;
|
||||
setBusy(false);
|
||||
// Persist the settled turn: the question always, the answer when one
|
||||
// was produced (partial answers from a stop are kept too).
|
||||
const assistant: ChatMessage | null = produced
|
||||
? {
|
||||
id: assistantId,
|
||||
role: "assistant",
|
||||
content: finalContent,
|
||||
thinking: finalThinking || undefined,
|
||||
model: finalModel,
|
||||
sources,
|
||||
at: Date.now(),
|
||||
...(failure ? { error: failure } : {}),
|
||||
}
|
||||
: null;
|
||||
persist(assistant ? [...history, assistant] : history);
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -311,10 +405,70 @@ export function ChatScreen() {
|
|||
|
||||
const newChat = () => {
|
||||
abortRef.current?.abort();
|
||||
loadedRef.current = null;
|
||||
setConversationId(null);
|
||||
setTitle("");
|
||||
setMessages([]);
|
||||
setSeed(null);
|
||||
setAttachments([]);
|
||||
setDraft("");
|
||||
setError(null);
|
||||
setHistoryOpen(false);
|
||||
window.history.replaceState(null, "", "/chat");
|
||||
};
|
||||
|
||||
const openConversation = (id: string) => {
|
||||
abortRef.current?.abort();
|
||||
setHistoryOpen(false);
|
||||
const found = getConversation(id);
|
||||
if (found) loadConversation(found);
|
||||
router.push(`/chat?c=${id}`);
|
||||
};
|
||||
|
||||
const handleDeleteConversation = (conversation: ChatConversation) => {
|
||||
deleteConversation(conversation.id);
|
||||
if (conversation.id === conversationId) {
|
||||
setConversationId(null);
|
||||
setTitle("");
|
||||
setMessages([]);
|
||||
}
|
||||
showToast({
|
||||
message: t("chat.deletedChat"),
|
||||
actionLabel: t("toast.undo"),
|
||||
onAction: () => saveConversation(conversation),
|
||||
});
|
||||
};
|
||||
|
||||
/* The single scoped deck is the target for "Save to notes". */
|
||||
const noteTargetDeck = useMemo(() => {
|
||||
if (scope.kind === "decks" && scope.deckIds.length === 1) {
|
||||
return decks.find((deck) => deck.id === scope.deckIds[0]);
|
||||
}
|
||||
if (seed?.deckId) return decks.find((deck) => deck.id === seed.deckId);
|
||||
return undefined;
|
||||
}, [scope, seed, decks]);
|
||||
|
||||
const saveToNotes = (message: ChatMessage) => {
|
||||
if (!noteTargetDeck) return;
|
||||
const current = noteTargetDeck.sourceText?.trim() ?? "";
|
||||
const addition = `\n\n— ${t("chat.savedFromChat")} —\n${message.content.trim()}`;
|
||||
let next = `${current}${addition}`;
|
||||
let trimmed = false;
|
||||
if (next.length > 40_000) {
|
||||
next = next.slice(-40_000);
|
||||
trimmed = true;
|
||||
}
|
||||
upsertDeck({ ...noteTargetDeck, sourceText: next, updatedAt: Date.now() });
|
||||
showToast({
|
||||
message: trimmed
|
||||
? t("chat.savedToNotesTrimmed", { deck: noteTargetDeck.title })
|
||||
: t("chat.savedToNotes", { deck: noteTargetDeck.title }),
|
||||
});
|
||||
};
|
||||
|
||||
const toFlashcards = (message: ChatMessage) => {
|
||||
setDeckSeed({ text: message.content.slice(0, 40_000), step: 2 });
|
||||
router.push("/create");
|
||||
};
|
||||
|
||||
const handleAttach = async (file: File) => {
|
||||
|
|
@ -346,7 +500,25 @@ export function ChatScreen() {
|
|||
const lastFailedAssistant = [...messages].reverse().find((message) => message.role === "assistant" && message.error)?.id;
|
||||
|
||||
return (
|
||||
<div className="relative flex h-full min-h-0 flex-col" style={{ paddingBottom: "var(--chat-keyboard, 0px)" }}>
|
||||
<div className="flex h-full min-h-0 w-full">
|
||||
{/* Desktop: the conversation list lives in its own column. */}
|
||||
<aside className="hidden w-64 shrink-0 flex-col border-r border-[var(--hairline-soft)] bg-[var(--surface-sunken)] p-2 lg:flex">
|
||||
<h2 className="px-1 pb-1 pt-1 text-[12px] font-semibold uppercase tracking-wide text-[var(--text-subtle)]">
|
||||
{t("chat.history")}
|
||||
</h2>
|
||||
<ChatHistoryList
|
||||
conversations={conversations}
|
||||
currentId={conversationId}
|
||||
query={query}
|
||||
onQueryChange={setQuery}
|
||||
onOpen={openConversation}
|
||||
onRename={renameConversation}
|
||||
onDelete={handleDeleteConversation}
|
||||
onNew={newChat}
|
||||
/>
|
||||
</aside>
|
||||
|
||||
<div className="relative flex h-full min-h-0 min-w-0 flex-1 flex-col" style={{ paddingBottom: "var(--chat-keyboard, 0px)" }}>
|
||||
<header className="glass sticky top-0 z-30 flex h-14 shrink-0 items-center gap-1.5 px-2 sm:px-4">
|
||||
<button
|
||||
type="button"
|
||||
|
|
@ -358,12 +530,21 @@ export function ChatScreen() {
|
|||
<ChevronLeftIcon className="size-5" />
|
||||
</button>
|
||||
<div className="min-w-0 flex-1">
|
||||
<h1 className="truncate text-[15px] font-semibold text-[var(--text)]">{t("chat.title")}</h1>
|
||||
<h1 className="truncate text-[15px] font-semibold text-[var(--text)]">{title || t("chat.title")}</h1>
|
||||
<p className="truncate text-[11.5px] text-[var(--text-subtle)]">
|
||||
{scopeText}
|
||||
{seed ? ` · ${seed.label}` : ""}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setHistoryOpen(true)}
|
||||
aria-label={t("chat.history")}
|
||||
title={t("chat.history")}
|
||||
className="grid size-10 shrink-0 place-items-center rounded-2xl text-[var(--text-muted)] transition hover:bg-[var(--hover)] hover:text-[var(--text)] lg:hidden"
|
||||
>
|
||||
<HistoryIcon className="size-5" />
|
||||
</button>
|
||||
<ThemeToggle className="hidden lg:grid" />
|
||||
<button
|
||||
type="button"
|
||||
|
|
@ -418,17 +599,25 @@ export function ChatScreen() {
|
|||
</div>
|
||||
) : null}
|
||||
{messages.map((message) => (
|
||||
<MessageRow
|
||||
<motion.div
|
||||
key={message.id}
|
||||
message={message}
|
||||
streaming={busy && message.id === lastAssistantId}
|
||||
isLastAssistant={message.id === lastAssistantId}
|
||||
isLastUser={message.id === lastUserId}
|
||||
busy={busy}
|
||||
onRegenerate={regenerate}
|
||||
onEdit={editLast}
|
||||
onRetry={message.id === lastFailedAssistant ? retryLast : undefined}
|
||||
/>
|
||||
initial={reduceMotion ? false : { opacity: 0, y: 8 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={reduceMotion ? { duration: 0 } : springGentle}
|
||||
>
|
||||
<MessageRow
|
||||
message={message}
|
||||
streaming={busy && message.id === lastAssistantId}
|
||||
isLastAssistant={message.id === lastAssistantId}
|
||||
isLastUser={message.id === lastUserId}
|
||||
busy={busy}
|
||||
onRegenerate={regenerate}
|
||||
onEdit={editLast}
|
||||
onRetry={message.id === lastFailedAssistant ? retryLast : undefined}
|
||||
onToFlashcards={() => toFlashcards(message)}
|
||||
onSaveNotes={noteTargetDeck ? () => saveToNotes(message) : undefined}
|
||||
/>
|
||||
</motion.div>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
|
|
@ -495,13 +684,17 @@ export function ChatScreen() {
|
|||
onChange={(next) => {
|
||||
setModel(next);
|
||||
storeChatModel(next);
|
||||
if (messages.length > 0) persist(messages, { model: next });
|
||||
}}
|
||||
/>
|
||||
<ContextPicker
|
||||
open={scopeOpen}
|
||||
onClose={() => setScopeOpen(false)}
|
||||
scope={scope}
|
||||
onChange={setScope}
|
||||
onChange={(next) => {
|
||||
setScope(next);
|
||||
if (messages.length > 0) persist(messages, { scope: next });
|
||||
}}
|
||||
decks={decks}
|
||||
folders={folders}
|
||||
/>
|
||||
|
|
@ -515,6 +708,20 @@ export function ChatScreen() {
|
|||
</Link>
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<HistorySheet
|
||||
open={historyOpen}
|
||||
onClose={() => setHistoryOpen(false)}
|
||||
conversations={conversations}
|
||||
currentId={conversationId}
|
||||
query={query}
|
||||
onQueryChange={setQuery}
|
||||
onOpen={openConversation}
|
||||
onRename={renameConversation}
|
||||
onDelete={handleDeleteConversation}
|
||||
onNew={newChat}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -122,7 +122,7 @@ export function Composer({
|
|||
rows={1}
|
||||
placeholder={t("chat.placeholder")}
|
||||
aria-label={t("chat.placeholder")}
|
||||
className="max-h-[180px] min-h-[44px] min-w-0 flex-1 resize-none rounded-2xl border border-[var(--glass-border)] bg-[var(--surface-strong)] px-3.5 py-2.5 text-[16px] leading-relaxed text-[var(--text)] outline-none transition placeholder:text-[var(--text-subtle)] focus:border-accent-400/60"
|
||||
className="max-h-[180px] min-h-[44px] min-w-0 flex-1 resize-none rounded-2xl border border-[var(--glass-border)] bg-[var(--surface-strong)] px-3.5 py-2.5 text-[16px] leading-relaxed text-[var(--text)] outline-none transition-[height,border-color] duration-150 ease-out placeholder:text-[var(--text-subtle)] focus:border-accent-400/60 motion-reduce:transition-none"
|
||||
/>
|
||||
|
||||
{busy ? (
|
||||
|
|
|
|||
|
|
@ -1,9 +1,11 @@
|
|||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { AnimatePresence, motion, useReducedMotion } from "framer-motion";
|
||||
|
||||
import { CheckCircleIcon, FolderIcon, LayersIcon } from "@/components/icons";
|
||||
import { useI18n } from "@/lib/i18n";
|
||||
import { springSheet } from "@/lib/motion";
|
||||
import { useIsPhone } from "@/lib/use-phone";
|
||||
import { deckHasNotes } from "@/lib/chat-context";
|
||||
import type { ChatScope } from "@/lib/chat";
|
||||
|
|
@ -31,6 +33,7 @@ export function ContextPicker({
|
|||
}) {
|
||||
const { t, plural } = useI18n();
|
||||
const isPhone = useIsPhone();
|
||||
const reduceMotion = useReducedMotion();
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
|
|
@ -41,8 +44,6 @@ export function ContextPicker({
|
|||
return () => window.removeEventListener("keydown", onKey);
|
||||
}, [open, onClose]);
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
const selected = scope.kind === "decks" ? new Set(scope.deckIds) : new Set<string>();
|
||||
|
||||
const choose = (next: ChatScope) => {
|
||||
|
|
@ -171,29 +172,56 @@ export function ContextPicker({
|
|||
|
||||
if (isPhone) {
|
||||
return (
|
||||
<div className="fixed inset-0 z-50" role="dialog" aria-modal="true" aria-label={t("chat.scope.title")}>
|
||||
<button type="button" className="absolute inset-0 bg-black/45" aria-label={t("common.close")} onClick={onClose} />
|
||||
<div className="glass-panel absolute inset-x-0 bottom-0 max-h-[85dvh] overflow-y-auto rounded-b-none px-4 pb-safe pt-2">
|
||||
<div className="sheet-grabber mx-auto" aria-hidden />
|
||||
<h2 className="px-1 pb-3 pt-2 text-[15px] font-semibold text-[var(--text)]">{t("chat.scope.title")}</h2>
|
||||
{body}
|
||||
<p className="px-1 py-3 text-[11.5px] text-[var(--text-subtle)]">{t("chat.scope.note")}</p>
|
||||
</div>
|
||||
</div>
|
||||
<AnimatePresence>
|
||||
{open ? (
|
||||
<div className="fixed inset-0 z-50" role="dialog" aria-modal="true" aria-label={t("chat.scope.title")}>
|
||||
<motion.button
|
||||
type="button"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={reduceMotion ? { duration: 0 } : undefined}
|
||||
className="absolute inset-0 bg-black/45"
|
||||
aria-label={t("common.close")}
|
||||
onClick={onClose}
|
||||
/>
|
||||
<motion.div
|
||||
initial={reduceMotion ? { opacity: 0 } : { y: "100%" }}
|
||||
animate={reduceMotion ? { opacity: 1 } : { y: 0 }}
|
||||
exit={reduceMotion ? { opacity: 0 } : { y: "100%" }}
|
||||
transition={reduceMotion ? { duration: 0 } : springSheet}
|
||||
className="glass-panel absolute inset-x-0 bottom-0 max-h-[85dvh] overflow-y-auto rounded-b-none px-4 pb-safe pt-2"
|
||||
>
|
||||
<div className="sheet-grabber mx-auto" aria-hidden />
|
||||
<h2 className="px-1 pb-3 pt-2 text-[15px] font-semibold text-[var(--text)]">{t("chat.scope.title")}</h2>
|
||||
{body}
|
||||
<p className="px-1 py-3 text-[11.5px] text-[var(--text-subtle)]">{t("chat.scope.note")}</p>
|
||||
</motion.div>
|
||||
</div>
|
||||
) : null}
|
||||
</AnimatePresence>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="glass-panel absolute bottom-full left-0 z-40 mb-2 w-96 p-3"
|
||||
role="dialog"
|
||||
aria-label={t("chat.scope.title")}
|
||||
>
|
||||
<h2 className="px-1 pb-2 text-[13px] font-semibold uppercase tracking-wide text-[var(--text-subtle)]">
|
||||
{t("chat.scope.title")}
|
||||
</h2>
|
||||
{body}
|
||||
<p className="px-1 pt-2 text-[11.5px] text-[var(--text-subtle)]">{t("chat.scope.note")}</p>
|
||||
</div>
|
||||
<AnimatePresence>
|
||||
{open ? (
|
||||
<motion.div
|
||||
initial={reduceMotion ? { opacity: 0 } : { opacity: 0, y: 6, scale: 0.98 }}
|
||||
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||
exit={reduceMotion ? { opacity: 0 } : { opacity: 0, y: 6, scale: 0.98 }}
|
||||
transition={reduceMotion ? { duration: 0 } : { duration: 0.16, ease: "easeOut" }}
|
||||
className="glass-panel absolute bottom-full left-0 z-40 mb-2 w-96 p-3"
|
||||
role="dialog"
|
||||
aria-label={t("chat.scope.title")}
|
||||
>
|
||||
<h2 className="px-1 pb-2 text-[13px] font-semibold uppercase tracking-wide text-[var(--text-subtle)]">
|
||||
{t("chat.scope.title")}
|
||||
</h2>
|
||||
{body}
|
||||
<p className="px-1 pt-2 text-[11.5px] text-[var(--text-subtle)]">{t("chat.scope.note")}</p>
|
||||
</motion.div>
|
||||
) : null}
|
||||
</AnimatePresence>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
205
components/chat/HistoryPanel.tsx
Normal file
205
components/chat/HistoryPanel.tsx
Normal file
|
|
@ -0,0 +1,205 @@
|
|||
"use client";
|
||||
|
||||
import { AnimatePresence, motion, useReducedMotion } from "framer-motion";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import { CheckCircleIcon, PencilIcon, PlusIcon, XIcon } from "@/components/icons";
|
||||
import { Button } from "@/components/ui";
|
||||
import { useI18n } from "@/lib/i18n";
|
||||
import { springSheet } from "@/lib/motion";
|
||||
import { conversationMatches } from "@/lib/chat";
|
||||
import type { ChatConversation } from "@/lib/schemas";
|
||||
import { cn, formatDate } from "@/lib/utils";
|
||||
|
||||
type HistoryProps = {
|
||||
conversations: ChatConversation[];
|
||||
currentId: string | null;
|
||||
query: string;
|
||||
onQueryChange: (value: string) => void;
|
||||
onOpen: (id: string) => void;
|
||||
onRename: (id: string, title: string) => void;
|
||||
onDelete: (conversation: ChatConversation) => void;
|
||||
onNew: () => void;
|
||||
};
|
||||
|
||||
/** The conversation list: a column on desktop, a sheet on phones. */
|
||||
export function ChatHistoryList({
|
||||
conversations,
|
||||
currentId,
|
||||
query,
|
||||
onQueryChange,
|
||||
onOpen,
|
||||
onRename,
|
||||
onDelete,
|
||||
onNew,
|
||||
}: HistoryProps) {
|
||||
const { t } = useI18n();
|
||||
const [renaming, setRenaming] = useState<string | null>(null);
|
||||
const [draft, setDraft] = useState("");
|
||||
|
||||
const visible = conversations.filter((conversation) =>
|
||||
conversationMatches(conversation.title, conversation.messages, query),
|
||||
);
|
||||
|
||||
const startRename = (conversation: ChatConversation) => {
|
||||
setRenaming(conversation.id);
|
||||
setDraft(conversation.title);
|
||||
};
|
||||
|
||||
const commit = () => {
|
||||
if (renaming) onRename(renaming, draft);
|
||||
setRenaming(null);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex min-h-0 flex-1 flex-col gap-2">
|
||||
<div className="flex items-center gap-2 px-1">
|
||||
<input
|
||||
type="search"
|
||||
value={query}
|
||||
onChange={(event) => onQueryChange(event.target.value)}
|
||||
placeholder={t("chat.searchChats")}
|
||||
aria-label={t("chat.searchChats")}
|
||||
className="min-w-0 flex-1 rounded-xl border border-[var(--hairline)] bg-[var(--surface-strong)] px-2.5 py-2 text-[16px] text-[var(--text)] outline-none transition placeholder:text-[var(--text-subtle)] focus:border-accent-400/60 sm:text-[13px]"
|
||||
/>
|
||||
<Button size="sm" variant="secondary" className="touch-target shrink-0" onClick={onNew} aria-label={t("chat.new")}>
|
||||
<PlusIcon className="size-4" />
|
||||
<span className="hidden sm:inline">{t("chat.new")}</span>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{visible.length === 0 ? (
|
||||
<p className="px-2 py-8 text-center text-[12.5px] text-[var(--text-subtle)]">
|
||||
{query ? t("chat.noResults") : t("chat.noHistory")}
|
||||
</p>
|
||||
) : (
|
||||
<ul className="thin-scroll min-h-0 flex-1 space-y-1 overflow-y-auto overscroll-contain px-1 pb-2">
|
||||
{visible.map((conversation) => {
|
||||
const active = conversation.id === currentId;
|
||||
return (
|
||||
<li key={conversation.id}>
|
||||
{renaming === conversation.id ? (
|
||||
<form
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
commit();
|
||||
}}
|
||||
className="flex items-center gap-1 px-1 py-1"
|
||||
>
|
||||
<input
|
||||
autoFocus
|
||||
value={draft}
|
||||
onChange={(event) => setDraft(event.target.value)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Escape") setRenaming(null);
|
||||
}}
|
||||
aria-label={t("common.rename")}
|
||||
className="min-w-0 flex-1 rounded-lg border border-[var(--hairline)] bg-[var(--surface-strong)] px-2 py-1.5 text-[16px] text-[var(--text)] outline-none sm:text-[13px]"
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
aria-label={t("common.save")}
|
||||
className="grid size-9 shrink-0 place-items-center rounded-lg text-emerald-600 transition hover:bg-[var(--hover)] dark:text-emerald-300"
|
||||
>
|
||||
<CheckCircleIcon className="size-4" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setRenaming(null)}
|
||||
aria-label={t("common.cancel")}
|
||||
className="grid size-9 shrink-0 place-items-center rounded-lg text-[var(--text-subtle)] transition hover:bg-[var(--hover)]"
|
||||
>
|
||||
<XIcon className="size-4" />
|
||||
</button>
|
||||
</form>
|
||||
) : (
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onOpen(conversation.id)}
|
||||
aria-current={active ? "true" : undefined}
|
||||
className={cn(
|
||||
"min-w-0 flex-1 rounded-xl px-2.5 py-2 text-left transition",
|
||||
active ? "bg-[var(--nav-active-bg)]" : "hover:bg-[var(--hover)]",
|
||||
)}
|
||||
>
|
||||
<span className="block truncate text-[13px] font-medium text-[var(--text)]">
|
||||
{conversation.title || t("chat.untitled")}
|
||||
</span>
|
||||
<span className="block text-[11px] text-[var(--text-subtle)]">
|
||||
{formatDate(conversation.updatedAt)}
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => startRename(conversation)}
|
||||
aria-label={t("common.rename")}
|
||||
title={t("common.rename")}
|
||||
className="grid size-9 shrink-0 place-items-center rounded-lg text-[var(--text-subtle)] transition hover:bg-[var(--hover)] hover:text-[var(--text)]"
|
||||
>
|
||||
<PencilIcon className="size-3.5" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onDelete(conversation)}
|
||||
aria-label={t("chat.deleteChat")}
|
||||
title={t("chat.deleteChat")}
|
||||
className="grid size-9 shrink-0 place-items-center rounded-lg text-[var(--text-subtle)] transition hover:bg-[var(--hover)] hover:text-rose-500"
|
||||
>
|
||||
<XIcon className="size-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Phone sheet wrapper around the list (drag/backdrop close, spring). */
|
||||
export function HistorySheet({ open, onClose, ...props }: HistoryProps & { open: boolean; onClose: () => void }) {
|
||||
const { t } = useI18n();
|
||||
const reduceMotion = useReducedMotion();
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const onKey = (event: KeyboardEvent) => {
|
||||
if (event.key === "Escape") onClose();
|
||||
};
|
||||
window.addEventListener("keydown", onKey);
|
||||
return () => window.removeEventListener("keydown", onKey);
|
||||
}, [open, onClose]);
|
||||
|
||||
return (
|
||||
<AnimatePresence>
|
||||
{open ? (
|
||||
<div className="fixed inset-0 z-50" role="dialog" aria-modal="true" aria-label={t("chat.history")}>
|
||||
<motion.button
|
||||
type="button"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={reduceMotion ? { duration: 0 } : undefined}
|
||||
className="absolute inset-0 bg-black/45"
|
||||
aria-label={t("common.close")}
|
||||
onClick={onClose}
|
||||
/>
|
||||
<motion.div
|
||||
initial={reduceMotion ? { opacity: 0 } : { y: "100%" }}
|
||||
animate={reduceMotion ? { opacity: 1 } : { y: 0 }}
|
||||
exit={reduceMotion ? { opacity: 0 } : { y: "100%" }}
|
||||
transition={reduceMotion ? { duration: 0 } : springSheet}
|
||||
className="glass-panel absolute inset-x-0 bottom-0 flex max-h-[80dvh] flex-col rounded-b-none px-3 pb-safe pt-2"
|
||||
>
|
||||
<div className="sheet-grabber mx-auto" aria-hidden />
|
||||
<h2 className="px-1 pb-2 pt-2 text-[15px] font-semibold text-[var(--text)]">{t("chat.history")}</h2>
|
||||
<ChatHistoryList {...props} />
|
||||
</motion.div>
|
||||
</div>
|
||||
) : null}
|
||||
</AnimatePresence>
|
||||
);
|
||||
}
|
||||
|
|
@ -7,7 +7,7 @@ import { Markdown } from "./Markdown";
|
|||
import { ThinkingRow } from "./ThinkingRow";
|
||||
import { Button } from "@/components/ui";
|
||||
import { ModelErrorActions } from "@/components/ModelErrorActions";
|
||||
import { PencilIcon, RefreshIcon } from "@/components/icons";
|
||||
import { PencilIcon, PlusIcon, RefreshIcon, SparklesIcon } from "@/components/icons";
|
||||
import { useI18n } from "@/lib/i18n";
|
||||
import type { ChatMessage } from "@/lib/chat";
|
||||
|
||||
|
|
@ -24,6 +24,8 @@ export function MessageRow({
|
|||
onRegenerate,
|
||||
onEdit,
|
||||
onRetry,
|
||||
onToFlashcards,
|
||||
onSaveNotes,
|
||||
}: {
|
||||
message: ChatMessage;
|
||||
streaming?: boolean;
|
||||
|
|
@ -33,6 +35,8 @@ export function MessageRow({
|
|||
onRegenerate?: () => void;
|
||||
onEdit?: () => void;
|
||||
onRetry?: () => void;
|
||||
onToFlashcards?: () => void;
|
||||
onSaveNotes?: () => void;
|
||||
}) {
|
||||
const { t } = useI18n();
|
||||
|
||||
|
|
@ -108,6 +112,26 @@ export function MessageRow({
|
|||
{t("chat.regenerate")}
|
||||
</button>
|
||||
) : null}
|
||||
{onToFlashcards ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onToFlashcards}
|
||||
className="flex items-center gap-1.5 rounded-xl border border-[var(--glass-border)] bg-[var(--surface-strong)] px-2.5 py-2 text-[12px] font-medium text-[var(--text-muted)] transition hover:bg-[var(--hover)] hover:text-[var(--text)]"
|
||||
>
|
||||
<SparklesIcon className="size-3.5" />
|
||||
{t("chat.toFlashcards")}
|
||||
</button>
|
||||
) : null}
|
||||
{onSaveNotes ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onSaveNotes}
|
||||
className="flex items-center gap-1.5 rounded-xl border border-[var(--glass-border)] bg-[var(--surface-strong)] px-2.5 py-2 text-[12px] font-medium text-[var(--text-muted)] transition hover:bg-[var(--hover)] hover:text-[var(--text)]"
|
||||
>
|
||||
<PlusIcon className="size-3.5" />
|
||||
{t("chat.saveToNotes")}
|
||||
</button>
|
||||
) : null}
|
||||
{message.model ? <span className="text-[11px] text-[var(--text-subtle)]">{message.model}</span> : null}
|
||||
</div>
|
||||
) : null}
|
||||
|
|
|
|||
|
|
@ -1,9 +1,11 @@
|
|||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { AnimatePresence, motion, useReducedMotion } from "framer-motion";
|
||||
|
||||
import { CheckCircleIcon } from "@/components/icons";
|
||||
import { useI18n } from "@/lib/i18n";
|
||||
import { springSheet } from "@/lib/motion";
|
||||
import { useIsPhone } from "@/lib/use-phone";
|
||||
import type { ChatModelChoice } from "@/lib/chat";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
|
@ -28,6 +30,7 @@ export function ModelPicker({
|
|||
}) {
|
||||
const { t } = useI18n();
|
||||
const isPhone = useIsPhone();
|
||||
const reduceMotion = useReducedMotion();
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
|
|
@ -38,8 +41,6 @@ export function ModelPicker({
|
|||
return () => window.removeEventListener("keydown", onKey);
|
||||
}, [open, onClose]);
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
const badge = (model: ChatModelChoice) =>
|
||||
model.badge === "vision" ? t("chat.modelVision") : model.badge === "both" ? t("chat.modelBoth") : t("chat.modelText");
|
||||
|
||||
|
|
@ -87,29 +88,56 @@ export function ModelPicker({
|
|||
|
||||
if (isPhone) {
|
||||
return (
|
||||
<div className="fixed inset-0 z-50" role="dialog" aria-modal="true" aria-label={t("chat.modelTitle")}>
|
||||
<button type="button" className="absolute inset-0 bg-black/45" aria-label={t("common.close")} onClick={onClose} />
|
||||
<div className="glass-panel absolute inset-x-0 bottom-0 max-h-[80dvh] rounded-b-none px-4 pb-safe pt-2">
|
||||
<div className="sheet-grabber mx-auto" aria-hidden />
|
||||
<h2 className="px-1 pb-3 pt-2 text-[15px] font-semibold text-[var(--text)]">{t("chat.modelTitle")}</h2>
|
||||
{rows}
|
||||
<p className="px-1 py-3 text-[11.5px] text-[var(--text-subtle)]">{t("chat.modelNote")}</p>
|
||||
</div>
|
||||
</div>
|
||||
<AnimatePresence>
|
||||
{open ? (
|
||||
<div className="fixed inset-0 z-50" role="dialog" aria-modal="true" aria-label={t("chat.modelTitle")}>
|
||||
<motion.button
|
||||
type="button"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={reduceMotion ? { duration: 0 } : undefined}
|
||||
className="absolute inset-0 bg-black/45"
|
||||
aria-label={t("common.close")}
|
||||
onClick={onClose}
|
||||
/>
|
||||
<motion.div
|
||||
initial={reduceMotion ? { opacity: 0 } : { y: "100%" }}
|
||||
animate={reduceMotion ? { opacity: 1 } : { y: 0 }}
|
||||
exit={reduceMotion ? { opacity: 0 } : { y: "100%" }}
|
||||
transition={reduceMotion ? { duration: 0 } : springSheet}
|
||||
className="glass-panel absolute inset-x-0 bottom-0 max-h-[80dvh] rounded-b-none px-4 pb-safe pt-2"
|
||||
>
|
||||
<div className="sheet-grabber mx-auto" aria-hidden />
|
||||
<h2 className="px-1 pb-3 pt-2 text-[15px] font-semibold text-[var(--text)]">{t("chat.modelTitle")}</h2>
|
||||
{rows}
|
||||
<p className="px-1 py-3 text-[11.5px] text-[var(--text-subtle)]">{t("chat.modelNote")}</p>
|
||||
</motion.div>
|
||||
</div>
|
||||
) : null}
|
||||
</AnimatePresence>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="glass-panel absolute bottom-full right-0 z-40 mb-2 w-80 p-3"
|
||||
role="dialog"
|
||||
aria-label={t("chat.modelTitle")}
|
||||
>
|
||||
<h2 className="px-1 pb-2 text-[13px] font-semibold uppercase tracking-wide text-[var(--text-subtle)]">
|
||||
{t("chat.modelTitle")}
|
||||
</h2>
|
||||
{rows}
|
||||
<p className="px-1 pt-2 text-[11.5px] text-[var(--text-subtle)]">{t("chat.modelNote")}</p>
|
||||
</div>
|
||||
<AnimatePresence>
|
||||
{open ? (
|
||||
<motion.div
|
||||
initial={reduceMotion ? { opacity: 0 } : { opacity: 0, y: 6, scale: 0.98 }}
|
||||
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||
exit={reduceMotion ? { opacity: 0 } : { opacity: 0, y: 6, scale: 0.98 }}
|
||||
transition={reduceMotion ? { duration: 0 } : { duration: 0.16, ease: "easeOut" }}
|
||||
className="glass-panel absolute bottom-full right-0 z-40 mb-2 w-80 p-3"
|
||||
role="dialog"
|
||||
aria-label={t("chat.modelTitle")}
|
||||
>
|
||||
<h2 className="px-1 pb-2 text-[13px] font-semibold uppercase tracking-wide text-[var(--text-subtle)]">
|
||||
{t("chat.modelTitle")}
|
||||
</h2>
|
||||
{rows}
|
||||
<p className="px-1 pt-2 text-[11.5px] text-[var(--text-subtle)]">{t("chat.modelNote")}</p>
|
||||
</motion.div>
|
||||
) : null}
|
||||
</AnimatePresence>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue