Bubbles: ten -> eighteen (interleaved, 18px to 96px, 10-23s runs), with a tighter specular hotspot, a reflected-light glint low on the sphere, a 1px glass edge and a brighter inner rim on both the bubbles and the drifting orbs. Bodies stay translucent with capped opacity, so wallpaper text stays readable; bubble-perf shows no added frame cost. Chat: the conversation scrolls up to the glass header, and a line clipped exactly at that edge read as a rendering bug over the busy wallpaper - the scroller now carries a 14px top mask in Aero, shorter than the column's 16px top padding so a fresh conversation shows no dimming. Contrast 0 findings, axe clean, chat 101/101, mobile 123/123 (bubble count pinned to 18), regression 44/44, i18n 13/13, native 33/33. Version 0.3.7, SW -> freezmo-v66.
749 lines
29 KiB
TypeScript
749 lines
29 KiB
TypeScript
"use client";
|
|
|
|
import { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore } from "react";
|
|
import { useRouter, useSearchParams } from "next/navigation";
|
|
import { useReducedMotion } from "framer-motion";
|
|
import Link from "next/link";
|
|
|
|
import { Composer, type PendingAttachment } from "./Composer";
|
|
import { ContextPicker } from "./ContextPicker";
|
|
import { HistorySheet } from "./HistoryPanel";
|
|
import { MessageRow } from "./MessageRow";
|
|
import { ModelPicker } from "./ModelPicker";
|
|
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";
|
|
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 { chatActive } from "@/lib/chat-active";
|
|
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, 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 };
|
|
|
|
/**
|
|
* Chat mode: an LLM-style conversation that knows the user's notes. The
|
|
* retrieval runs here in the browser (BM25 over the scoped decks), and only
|
|
* the top chunks plus the recent turns travel with each request.
|
|
*/
|
|
export function ChatScreen() {
|
|
const { t, plural } = useI18n();
|
|
const online = useOnline();
|
|
const reduceMotion = useReducedMotion();
|
|
const router = useRouter();
|
|
const search = useSearchParams();
|
|
|
|
const decks = useSyncExternalStore(deckStore.subscribe, deckStore.getSnapshot, deckStore.getServerSnapshot);
|
|
const folders = useSyncExternalStore(folderStore.subscribe, folderStore.getSnapshot, folderStore.getServerSnapshot);
|
|
const settings = useSyncExternalStore(settingsStore.subscribe, settingsStore.getSnapshot, settingsStore.getServerSnapshot);
|
|
const { health } = useHealth();
|
|
|
|
const deckParam = search.get("deck");
|
|
|
|
const [scope, setScope] = useState<ChatScope>(() =>
|
|
deckParam ? { kind: "decks", deckIds: [deckParam] } : { kind: "all" },
|
|
);
|
|
const [seed, setSeed] = useState<ChatSeed | null>(null);
|
|
const [messages, setMessages] = useState<ChatMessage[]>([]);
|
|
const [draft, setDraft] = useState("");
|
|
const [attachments, setAttachments] = useState<PendingAttachment[]>([]);
|
|
const [attachBusy, setAttachBusy] = useState(false);
|
|
const [model, setModel] = useState("");
|
|
const [busy, setBusy] = useState(false);
|
|
const [pinned, setPinned] = useState(true);
|
|
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);
|
|
const pinnedRef = useRef(true);
|
|
const extrasRef = useRef<Record<string, ExtraChunk[]>>({});
|
|
|
|
/* Hand-offs from a deck menu / results screen. */
|
|
useEffect(() => {
|
|
const taken = takeChatSeed();
|
|
if (!taken) return;
|
|
setSeed(taken);
|
|
if (taken.deckId) setScope({ kind: "decks", deckIds: [taken.deckId] });
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
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;
|
|
chatActive.set(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;
|
|
if (!viewport) return;
|
|
const apply = () => {
|
|
const inset = Math.max(0, window.innerHeight - viewport.height - viewport.offsetTop);
|
|
document.documentElement.style.setProperty("--chat-keyboard", `${Math.round(inset)}px`);
|
|
};
|
|
apply();
|
|
viewport.addEventListener("resize", apply);
|
|
viewport.addEventListener("scroll", apply);
|
|
return () => {
|
|
viewport.removeEventListener("resize", apply);
|
|
viewport.removeEventListener("scroll", apply);
|
|
document.documentElement.style.removeProperty("--chat-keyboard");
|
|
};
|
|
}, []);
|
|
|
|
const chatModels = useMemo<ChatModelChoice[]>(() => {
|
|
const fromHealth = health?.chatModels ?? [];
|
|
const list =
|
|
fromHealth.length > 0
|
|
? fromHealth
|
|
: [
|
|
{ name: health?.text?.configured ?? "", badge: "text" as const, available: health?.text?.available ?? null },
|
|
{ name: health?.vision?.configured ?? "", badge: "vision" as const, available: health?.vision?.available ?? null },
|
|
];
|
|
return list.filter((entry) => entry.name);
|
|
}, [health]);
|
|
|
|
const activeModel = useMemo(() => {
|
|
if (model && chatModels.some((entry) => entry.name === model)) return model;
|
|
return chatModels[0]?.name ?? model;
|
|
}, [model, chatModels]);
|
|
|
|
const scopeText = useMemo(() => {
|
|
if (scope.kind === "all") return t("chat.scope.all");
|
|
if (scope.kind === "none") return t("chat.scope.none");
|
|
return plural(scope.deckIds.length, "chat.scope.decks");
|
|
}, [scope, t, plural]);
|
|
|
|
const scopedDecks = useMemo(() => {
|
|
if (scope.kind === "none") return [];
|
|
if (scope.kind === "all") return decks;
|
|
return decks.filter((deck) => scope.deckIds.includes(deck.id));
|
|
}, [decks, scope]);
|
|
|
|
const missingNotes = useMemo(
|
|
() => (settings.chatNotes ? scopedDecks.some((deck) => !deckHasNotes(deck)) : false),
|
|
[scopedDecks, settings.chatNotes],
|
|
);
|
|
|
|
const suggestions = useMemo(() => {
|
|
const chosen = scope.kind === "decks" ? scopedDecks : [];
|
|
if (chosen.length === 1) {
|
|
return [
|
|
t("chat.suggest.explain", { deck: chosen[0].title }),
|
|
t("chat.suggest.quiz", { deck: chosen[0].title }),
|
|
t("chat.suggest.summarize"),
|
|
];
|
|
}
|
|
if (scope.kind === "none") return [t("chat.suggest.general"), t("chat.suggest.explainTopic")];
|
|
return [t("chat.suggest.summarize"), t("chat.suggest.quizAll"), t("chat.suggest.studyFirst")];
|
|
}, [scope, scopedDecks, t]);
|
|
|
|
const scrollToBottom = useCallback(
|
|
(smooth = false) => {
|
|
const element = scrollRef.current;
|
|
if (!element) return;
|
|
element.scrollTo({ top: element.scrollHeight, behavior: smooth && !reduceMotion ? "smooth" : "auto" });
|
|
},
|
|
[reduceMotion],
|
|
);
|
|
|
|
useEffect(() => {
|
|
if (pinnedRef.current) scrollToBottom(false);
|
|
}, [messages, scrollToBottom]);
|
|
|
|
const onScroll = () => {
|
|
const element = scrollRef.current;
|
|
if (!element) return;
|
|
const distance = element.scrollHeight - element.scrollTop - element.clientHeight;
|
|
const next = distance < 80;
|
|
pinnedRef.current = next;
|
|
setPinned(next);
|
|
};
|
|
|
|
const extraFor = (text: string, pending: PendingAttachment[]): ExtraChunk[] => {
|
|
const chunks: ExtraChunk[] = [];
|
|
if (seed) chunks.push({ deckId: seed.deckId ?? "seed", deckTitle: seed.label, section: seed.label, text: seed.text });
|
|
if (pending.length > 0) {
|
|
chunks.push({
|
|
deckId: "attachment",
|
|
deckTitle: t("chat.attachmentLabel"),
|
|
section: t("chat.attachmentLabel"),
|
|
text: pending.map((entry) => `${entry.attachment.name}\n${entry.text}`).join("\n\n").slice(0, 24_000),
|
|
});
|
|
}
|
|
void text;
|
|
return chunks;
|
|
};
|
|
|
|
const persist = useCallback(
|
|
(nextMessages: ChatMessage[], options: { title?: string; model?: string; scope?: ChatScope } = {}) => {
|
|
const id = conversationId ?? chatMessageId("chat");
|
|
if (!conversationId) {
|
|
setConversationId(id);
|
|
chatActive.set(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({
|
|
decks,
|
|
scope,
|
|
question: lastUser.content,
|
|
history: history.slice(0, -1),
|
|
useNotes: settings.chatNotes,
|
|
extraChunks,
|
|
});
|
|
const sources = context.chunks.map(({ deckId, deckTitle, section }) => ({ deckId, deckTitle, section }));
|
|
const assistantId = chatMessageId("a");
|
|
|
|
setMessages([...history, { id: assistantId, role: "assistant", content: "", sources, at: Date.now() }]);
|
|
setBusy(true);
|
|
pinnedRef.current = true;
|
|
setPinned(true);
|
|
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({
|
|
model: activeModel || undefined,
|
|
messages: history.map((message) => ({ role: message.role, content: message.content })),
|
|
context: context.chunks.map(({ deckId, deckTitle, section, text }) => ({ deckId, deckTitle, section, text })),
|
|
signal: controller.signal,
|
|
onToken: (text) => {
|
|
produced = true;
|
|
finalContent += text;
|
|
setMessages((previous) =>
|
|
previous.map((message) =>
|
|
message.id === assistantId ? { ...message, content: message.content + text } : message,
|
|
),
|
|
);
|
|
},
|
|
onThinking: (text) => {
|
|
finalThinking += text;
|
|
setMessages((previous) =>
|
|
previous.map((message) =>
|
|
message.id === assistantId ? { ...message, thinking: (message.thinking ?? "") + text } : message,
|
|
),
|
|
);
|
|
},
|
|
onDone: (modelName) => {
|
|
finalModel = modelName;
|
|
setMessages((previous) =>
|
|
previous.map((message) => (message.id === assistantId ? { ...message, model: modelName } : message)),
|
|
);
|
|
},
|
|
});
|
|
} catch (error) {
|
|
const apiError = error instanceof ApiError ? error : new ApiError(String(error), "unknown");
|
|
if (apiError.kind === "aborted") {
|
|
// Stopped by the user: keep whatever streamed in.
|
|
} else {
|
|
failure = { message: apiError.message, kind: apiError.kind };
|
|
setError(failure);
|
|
if (!produced) {
|
|
// Nothing arrived: take the placeholder away and give the text back.
|
|
setMessages(history);
|
|
setDraft(lastUser.content);
|
|
} else {
|
|
setMessages((previous) =>
|
|
previous.map((message) =>
|
|
message.id === assistantId
|
|
? { ...message, error: failure ?? undefined }
|
|
: message,
|
|
),
|
|
);
|
|
}
|
|
}
|
|
} 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);
|
|
}
|
|
};
|
|
|
|
const send = (text?: string) => {
|
|
const content = (text ?? draft).trim();
|
|
if (!content || busy || !online) return;
|
|
const pending = attachments;
|
|
const userMessage: ChatMessage = {
|
|
id: chatMessageId("u"),
|
|
role: "user",
|
|
content,
|
|
attachments: pending.length > 0 ? pending.map((entry) => entry.attachment) : undefined,
|
|
at: Date.now(),
|
|
};
|
|
const extras = extraFor(content, pending);
|
|
extrasRef.current[userMessage.id] = extras;
|
|
const history = [...messages, userMessage];
|
|
setError(null);
|
|
setMessages(history);
|
|
setDraft("");
|
|
setAttachments([]);
|
|
void run(history, extras);
|
|
};
|
|
|
|
const retryLast = () => {
|
|
const lastUserIndex = messages.map((message) => message.role).lastIndexOf("user");
|
|
if (lastUserIndex < 0) return;
|
|
const history = messages.slice(0, lastUserIndex + 1);
|
|
setMessages(history);
|
|
void run(history, extrasRef.current[history[lastUserIndex].id] ?? []);
|
|
};
|
|
|
|
const regenerate = () => {
|
|
const lastUserIndex = messages.map((message) => message.role).lastIndexOf("user");
|
|
if (lastUserIndex < 0) return;
|
|
const history = messages.slice(0, lastUserIndex + 1);
|
|
setMessages(history);
|
|
void run(history, extrasRef.current[history[lastUserIndex].id] ?? []);
|
|
};
|
|
|
|
const editLast = () => {
|
|
const lastUserIndex = messages.map((message) => message.role).lastIndexOf("user");
|
|
if (lastUserIndex < 0) return;
|
|
setDraft(messages[lastUserIndex].content);
|
|
setMessages(messages.slice(0, lastUserIndex));
|
|
};
|
|
|
|
const resetView = useCallback(() => {
|
|
abortRef.current?.abort();
|
|
loadedRef.current = null;
|
|
setConversationId(null);
|
|
setTitle("");
|
|
setMessages([]);
|
|
setSeed(null);
|
|
setAttachments([]);
|
|
setDraft("");
|
|
setError(null);
|
|
chatActive.set(null);
|
|
window.history.replaceState(null, "", "/chat");
|
|
}, []);
|
|
|
|
const newChat = () => {
|
|
resetView();
|
|
setHistoryOpen(false);
|
|
};
|
|
|
|
/* The sidebar can start a new chat (URL -> /chat) while this screen is open. */
|
|
useEffect(() => {
|
|
if (conversationParam || !loadedRef.current) return;
|
|
resetView();
|
|
}, [conversationParam, resetView]);
|
|
|
|
/* ...and delete the conversation that is currently open. */
|
|
useEffect(() => {
|
|
if (!conversationId || !loadedRef.current) return;
|
|
if (getConversation(conversationId)) return;
|
|
resetView();
|
|
}, [conversations, conversationId, resetView]);
|
|
|
|
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) => {
|
|
setAttachBusy(true);
|
|
try {
|
|
if (isImageFile(file)) {
|
|
const prepared = await prepareImage(file);
|
|
const text = await extractChatAttachment({ images: [prepared.dataUrl] });
|
|
const attachment: ChatAttachment = { name: file.name || t("chat.attachPhoto"), kind: "photo", chars: text.length };
|
|
setAttachments((previous) => [...previous, { attachment, text }]);
|
|
} else {
|
|
const document = await extractDocument(file);
|
|
if (!document.text.trim()) {
|
|
showToast({ message: t("chat.attachNoText") });
|
|
} else {
|
|
const attachment: ChatAttachment = { name: document.name, kind: "document", chars: document.text.length };
|
|
setAttachments((previous) => [...previous, { attachment, text: document.text }]);
|
|
}
|
|
}
|
|
} catch (error) {
|
|
showToast({ message: error instanceof ApiError ? error.message : t("chat.attachFailed") });
|
|
} finally {
|
|
setAttachBusy(false);
|
|
}
|
|
};
|
|
|
|
const lastAssistantId = [...messages].reverse().find((message) => message.role === "assistant")?.id;
|
|
const lastUserId = [...messages].reverse().find((message) => message.role === "user")?.id;
|
|
const lastFailedAssistant = [...messages].reverse().find((message) => message.role === "assistant" && message.error)?.id;
|
|
|
|
return (
|
|
<div className="flex h-full min-h-0 w-full">
|
|
{/* Desktop's conversation list lives in the main sidebar (on /chat). */}
|
|
<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-2 px-2 sm:px-4">
|
|
<button
|
|
type="button"
|
|
onClick={() => (window.history.length > 1 ? router.back() : router.push("/"))}
|
|
aria-label={t("chat.back")}
|
|
title={t("chat.back")}
|
|
className="grid size-11 shrink-0 place-items-center rounded-2xl text-[var(--text-muted)] transition hover:bg-[var(--hover)] hover:text-[var(--text)] lg:hidden"
|
|
>
|
|
<ChevronLeftIcon className="size-5" />
|
|
</button>
|
|
<div className="min-w-0 flex-1">
|
|
<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>
|
|
{/* Actions share one size, radius, border, background and hit area. */}
|
|
<div className="flex shrink-0 items-center gap-1.5">
|
|
<button
|
|
type="button"
|
|
onClick={() => setHistoryOpen(true)}
|
|
aria-label={t("chat.history")}
|
|
title={t("chat.history")}
|
|
className="grid size-11 place-items-center rounded-2xl border border-[var(--glass-border)] bg-[var(--surface-strong)] text-[var(--text-muted)] transition hover:bg-[var(--hover)] hover:text-[var(--text)] lg:hidden"
|
|
>
|
|
<HistoryIcon className="size-5" />
|
|
</button>
|
|
<ThemeToggle
|
|
plain
|
|
className="grid size-11 place-items-center rounded-2xl border border-[var(--glass-border)] bg-[var(--surface-strong)]"
|
|
/>
|
|
<button
|
|
type="button"
|
|
onClick={newChat}
|
|
aria-label={t("chat.new")}
|
|
title={t("chat.new")}
|
|
className="grid size-11 place-items-center rounded-2xl border border-[var(--glass-border)] bg-[var(--surface-strong)] text-[var(--text-muted)] transition hover:bg-[var(--hover)] hover:text-[var(--text)]"
|
|
>
|
|
<PlusIcon className="size-5" />
|
|
</button>
|
|
</div>
|
|
</header>
|
|
|
|
<div className="relative min-h-0 flex-1">
|
|
<div ref={scrollRef} onScroll={onScroll} className="chat-scroll h-full overflow-y-auto overscroll-contain">
|
|
<div className="mx-auto w-full max-w-3xl space-y-4 px-4 py-4">
|
|
{messages.length === 0 ? (
|
|
<div className="space-y-5 py-6">
|
|
<div className="text-center">
|
|
<span className="mx-auto mb-4 grid size-12 place-items-center rounded-2xl border border-[var(--hairline)] bg-[var(--surface-strong)] text-accent-600 dark:text-accent-200">
|
|
<ChatIcon className="size-6" />
|
|
</span>
|
|
<h2 className="text-[20px] font-semibold tracking-[-0.01em] text-[var(--text)]">{t("chat.emptyTitle")}</h2>
|
|
<p className="mx-auto mt-1.5 max-w-sm text-[13.5px] leading-relaxed text-[var(--text-subtle)]">
|
|
{t("chat.emptyHint")}
|
|
</p>
|
|
</div>
|
|
{missingNotes ? (
|
|
<p className="rounded-2xl border border-amber-300/50 bg-amber-50/70 px-3.5 py-3 text-[12.5px] leading-relaxed text-amber-800 dark:border-amber-400/25 dark:bg-amber-400/10 dark:text-amber-200">
|
|
{t("chat.oldDecksNote")}
|
|
</p>
|
|
) : null}
|
|
<div className="flex flex-wrap justify-center gap-2">
|
|
{suggestions.map((suggestion) => (
|
|
<button
|
|
key={suggestion}
|
|
type="button"
|
|
disabled={!online || busy}
|
|
onClick={() => send(suggestion)}
|
|
className="max-w-full rounded-2xl border border-[var(--glass-border)] bg-[var(--surface-strong)] px-3.5 py-2.5 text-left text-[13px] text-[var(--text-muted)] transition hover:bg-[var(--hover)] hover:text-[var(--text)] disabled:opacity-55"
|
|
>
|
|
{suggestion}
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
) : (
|
|
<>
|
|
{seed ? (
|
|
<div className="flex items-center gap-2 rounded-2xl border border-[var(--hairline-soft)] bg-[var(--surface-sunken)] px-3 py-2 text-[12px] text-[var(--text-subtle)]">
|
|
<span className="truncate">
|
|
{t("chat.fromLabel")}: {seed.label}
|
|
</span>
|
|
</div>
|
|
) : null}
|
|
{messages.map((message) => (
|
|
<motion.div
|
|
key={message.id}
|
|
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>
|
|
))}
|
|
</>
|
|
)}
|
|
<div className="h-2" aria-hidden />
|
|
</div>
|
|
</div>
|
|
|
|
{!pinned && messages.length > 0 ? (
|
|
<button
|
|
type="button"
|
|
onClick={() => {
|
|
pinnedRef.current = true;
|
|
setPinned(true);
|
|
scrollToBottom(true);
|
|
}}
|
|
aria-label={t("chat.jumpToBottom")}
|
|
title={t("chat.jumpToBottom")}
|
|
className="glass-panel absolute bottom-4 left-1/2 grid size-10 -translate-x-1/2 place-items-center rounded-full text-[var(--text-muted)] shadow-lg"
|
|
>
|
|
<ChevronDownIcon className="size-5" />
|
|
</button>
|
|
) : null}
|
|
</div>
|
|
|
|
{error ? (
|
|
<div
|
|
role="alert"
|
|
className="mx-3 mb-2 shrink-0 rounded-2xl border border-rose-300/60 bg-rose-50/80 p-3 dark:border-rose-400/30 dark:bg-rose-500/10"
|
|
>
|
|
<p className="text-[13px] leading-relaxed text-rose-800 dark:text-rose-200">{error.message}</p>
|
|
<div className="mt-2 flex flex-wrap items-center gap-2">
|
|
{messages.some((message) => message.role === "user") ? (
|
|
<Button size="sm" variant="secondary" onClick={retryLast}>
|
|
<RefreshIcon className="size-4" /> {t("chat.retry")}
|
|
</Button>
|
|
) : null}
|
|
<ModelErrorActions kind={error.kind} />
|
|
</div>
|
|
</div>
|
|
) : null}
|
|
|
|
<div className="relative shrink-0">
|
|
<Composer
|
|
value={draft}
|
|
onChange={setDraft}
|
|
onSend={() => send()}
|
|
onStop={() => abortRef.current?.abort()}
|
|
busy={busy}
|
|
online={online}
|
|
scopeLabel={scopeText}
|
|
modelLabel={activeModel || t("chat.modelTitle")}
|
|
scopeOpen={scopeOpen}
|
|
modelOpen={modelOpen}
|
|
// The chips toggle their own popover (and open one at a time), so a
|
|
// second tap closes instead of forcing a pick.
|
|
onOpenScope={() => {
|
|
setScopeOpen((open) => !open);
|
|
setModelOpen(false);
|
|
}}
|
|
onOpenModel={() => {
|
|
setModelOpen((open) => !open);
|
|
setScopeOpen(false);
|
|
}}
|
|
onAttachFile={(file) => void handleAttach(file)}
|
|
attachBusy={attachBusy}
|
|
attachments={attachments}
|
|
onRemoveAttachment={(index) => setAttachments((previous) => previous.filter((_, i) => i !== index))}
|
|
/>
|
|
<ModelPicker
|
|
open={modelOpen}
|
|
onClose={() => setModelOpen(false)}
|
|
models={chatModels}
|
|
value={activeModel}
|
|
onChange={(next) => {
|
|
setModel(next);
|
|
storeChatModel(next);
|
|
if (messages.length > 0) persist(messages, { model: next });
|
|
}}
|
|
/>
|
|
<ContextPicker
|
|
open={scopeOpen}
|
|
onClose={() => setScopeOpen(false)}
|
|
scope={scope}
|
|
onChange={(next) => {
|
|
setScope(next);
|
|
if (messages.length > 0) persist(messages, { scope: next });
|
|
}}
|
|
decks={decks}
|
|
folders={folders}
|
|
/>
|
|
</div>
|
|
|
|
{settings.chatNotes === false && scope.kind !== "none" ? (
|
|
<p className="border-t border-[var(--hairline-soft)] bg-[var(--surface-sunken)] px-4 py-2 text-[11.5px] text-[var(--text-subtle)]">
|
|
{t("chat.notesOffNote")}{" "}
|
|
<Link href="/" className="underline decoration-dotted">
|
|
{t("chat.notesOffLink")}
|
|
</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>
|
|
);
|
|
}
|