TypeScript 97.4%
SQL 1%
JavaScript 0.9%
CSS 0.6%
1"use client";2import * as React from "react";3import { useRouter, useSearchParams } from "next/navigation";4import { AlertTriangle, ArrowDown, Braces, Columns3, Download, EyeOff, Globe, Menu, MoreHorizontal, Pin, Settings2, Share2, Swords, Terminal, Trash2, Wrench } from "lucide-react";5import Link from "next/link";6import { useApp, invalidateConversations, AUTO_MODEL_KEY } from "@/components/app/store";7import { providerName } from "@/lib/client/providers";8import { api, streamEvents, ClientApiError } from "@/lib/client/api";9import type { ChatAdoptResponse, ChatMetaExtras, ChatStreamEvent, ConversationDetail, PublicConversation, PublicMessage, PolyModel, ModelPreset, PromptPreset } from "@/lib/client/types";10import { analyzePrompt, routeModels, explainRoute, type RouteResult, type RouteCandidate, type RouterMode } from "@/lib/client/router";11import { estimateContext, estimateCost, estimateTextTokens, estimateAttachmentTokens, COST_CONFIRM_THRESHOLD_USD, type CostEstimate } from "@/lib/client/tokens";12import { useIsMobile, useLocalStorage } from "@/lib/client/hooks";13import { deprecationNotice, suggestReplacement, largerContextModel } from "@/lib/chat/deprecation";14import { MessageItem, type LiveState, type MessageActions } from "./message";15import { Composer, type PendingAttachment, type ComposerAction, type ComposerHandle } from "./composer";16import { ModelSelector } from "./model-selector";17import { ModelConfig, countActiveSettings, type ChatSettings } from "./model-config";18import { ChatEmptyState } from "./empty-state";19import { ContextIndicator } from "./context-indicator";20import { RouterCard } from "./router-card";21import { CostConfirm } from "./cost-confirm";22import { CompareInline } from "./compare-inline";23import { ModelPickerLauncher, type ModelPickerLauncherHandle } from "./model-launcher";24import { StructuredOutputSheet, SystemPromptSheet, ToolsSheet } from "./composer-sheets";25import { ConfirmDialog } from "@/components/common/confirm-dialog";26import { openShareSheet } from "@/components/share/share-sheet";27import { ExportMenu } from "@/components/share/export-menu";28import { exportConversation } from "@/components/share/export";29import { usePromptInsert, consumePendingPromptInsert, decodeVars, type PromptInsertDetail } from "@/components/prompts/insert";30import { consumePendingAttachments } from "@/components/library/use-in-chat";31import { ActionSheet, type ActionSheetItem } from "@/components/ui/sheet";32import { Button } from "@/components/ui/button";33import { Badge } from "@/components/ui/badge";34import { Tooltip } from "@/components/ui/tooltip";35import { toast } from "@/components/ui/toast";36import { cn, formatUsd } from "@/lib/utils";3738interface Props {39 conversationId?: string;40 initial?: ConversationDetail;41}4243interface Pending {44 text: string;45 attachments: PendingAttachment[];46}4748interface ProjectLite {49 id: string;50 name: string;51 instructions: string | null;52 preferredModelKeys: string[];53}5455type MetaEvent = Extract<ChatStreamEvent, { type: "meta" }> & ChatMetaExtras;5657const LS_ROUTER_ALWAYS = "polyllm:router-always";58const LS_ROUTER_MODE = "polyllm:router-mode";59const SUMMARY_PROMPT = "Summarize this conversation so far for hand-off to a fresh session: goals, key facts, decisions, constraints, open questions and the exact state of any code or data. Be complete but compact, in Markdown, without preamble.";6061const emptyLive = (): LiveState => ({ text: "", reasoning: "", tools: [], serverTools: [], citations: [], startedAt: Date.now() });6263function mkMessage(base: Partial<PublicMessage> & Pick<PublicMessage, "id" | "role" | "content">): PublicMessage {64 return { conversationId: "", parts: [], modelKey: null, provider: null, status: "complete", finishReason: null, error: null, usage: null, settings: null, latencyMs: null, ttftMs: null, costUsd: null, parentMessageId: null, version: 1, active: true, createdAt: new Date().toISOString(), ...base } as PublicMessage;65}6667function attachmentsOf(m: PublicMessage) {68 return (m.parts as { type: string; kind?: string; sizeBytes?: number; width?: number | null; height?: number | null }[]).filter((p) => p.type === "attachment").map((p) => ({ kind: p.kind ?? "file", sizeBytes: p.sizeBytes ?? 0, width: p.width, height: p.height }));69}7071export function ChatView({ conversationId, initial }: Props) {72 const router = useRouter();73 const search = useSearchParams();74 const isMobile = useIsMobile();75 const { modelsByKey, models, selectedModelKey, setSelectedModelKey, preferences, setSidebarOpen, connectedProviders, favorites, activeProjectId } = useApp();7677 const [conversation, setConversation] = React.useState<PublicConversation | null>(initial?.conversation ?? null);78 const [messages, setMessages] = React.useState<PublicMessage[]>(() => (initial?.messages ?? []).filter((m) => (m as unknown as { active?: boolean }).active !== false));79 const [modelKey, setModelKey] = React.useState<string | null>(initial?.conversation.modelKey ?? null);80 const [settings, setSettings] = React.useState<ChatSettings>(() => ((initial?.conversation.settings as ChatSettings) ?? {}));81 const [systemPrompt, setSystemPrompt] = React.useState<string>(initial?.conversation.systemPrompt ?? preferences.defaultSystemPrompt ?? "");82 const [attachments, setAttachments] = React.useState<PendingAttachment[]>([]);83 const [live, setLive] = React.useState<LiveState | null>(null);84 const [busy, setBusy] = React.useState(false);85 const [draft, setDraft] = React.useState("");86 const [ephemeral, setEphemeral] = React.useState(() => !conversationId && search.get("temporary") === "1");87 const [turnMeta, setTurnMeta] = React.useState<Record<string, { requestId?: string; errorAt?: number }>>({});88 const [project, setProject] = React.useState<ProjectLite | null>(null);89 const [routerState, setRouterState] = React.useState<{ result: RouteResult; pending: Pending } | null>(null);90 const [costState, setCostState] = React.useState<{ pending: Pending; modelKey: string; estimate: CostEstimate; alternatives: RouteCandidate[] } | null>(null);91 const [compare, setCompare] = React.useState<{ prompt: string; editable: boolean; initialModels: string[] } | null>(null);92 const [pick, setPick] = React.useState<{ onPick: (key: string) => void } | null>(null);93 const [sysOpen, setSysOpen] = React.useState(false);94 const [structOpen, setStructOpen] = React.useState(false);95 const [toolsOpen, setToolsOpen] = React.useState(false);96 const [moreOpen, setMoreOpen] = React.useState(false);97 const [deleteOpen, setDeleteOpen] = React.useState(false);98 const [summarizing, setSummarizing] = React.useState(false);99 const [alwaysAuto, setAlwaysAuto] = useLocalStorage<boolean>(LS_ROUTER_ALWAYS, false);100 const [routerMode, setRouterMode] = useLocalStorage<RouterMode>(LS_ROUTER_MODE, "balanced");101 const [now] = React.useState(() => Date.now());102103 const abortRef = React.useRef<AbortController | null>(null);104 const scrollRef = React.useRef<HTMLDivElement>(null);105 const composerRef = React.useRef<ComposerHandle>(null);106 const pickerRef = React.useRef<ModelPickerLauncherHandle>(null);107 const messagesRef = React.useRef<PublicMessage[]>(messages);108 const userPickedModel = React.useRef(false);109 const [atBottom, setAtBottom] = React.useState(true);110 const presetApplied = React.useRef(false);111112 React.useEffect(() => {113 messagesRef.current = messages;114 }, [messages]);115116 // ?temporary=1 (also when navigating from a normal new chat via the command palette)117 React.useEffect(() => {118 if (!conversationId && search.get("temporary") === "1") {119 // eslint-disable-next-line react-hooks/set-state-in-effect120 setEphemeral(true);121 }122 }, [conversationId, search]);123124 // Model for new chats follows the global selection; existing conversations keep theirs.125 React.useEffect(() => {126 if (!conversationId && !modelKey && selectedModelKey) {127 // eslint-disable-next-line react-hooks/set-state-in-effect128 setModelKey(selectedModelKey);129 }130 }, [conversationId, modelKey, selectedModelKey]);131132 // Project instructions + preferred model for new chats in the active project (degrades silently on 404).133 React.useEffect(() => {134 if (conversationId || !activeProjectId) return;135 let cancelled = false;136 api<{ project: ProjectLite }>(`/api/projects/${activeProjectId}`)137 .then((res) => {138 if (cancelled || !res?.project) return;139 setProject({ id: res.project.id, name: res.project.name, instructions: res.project.instructions ?? null, preferredModelKeys: res.project.preferredModelKeys ?? [] });140 const pref = res.project.preferredModelKeys?.[0];141 if (pref && modelsByKey.has(pref) && !userPickedModel.current && messagesRef.current.length === 0) setModelKey(pref);142 })143 .catch(() => {144 /* Projects API not available yet or project removed: no instructions */145 });146 return () => {147 cancelled = true;148 };149 }, [conversationId, activeProjectId, modelsByKey]);150 const activeProject = !conversationId && activeProjectId && project?.id === activeProjectId ? project : null;151152 // Apply ?preset= / ?prompt= / ?promptId= / ?model= for new chats.153 React.useEffect(() => {154 if (conversationId || presetApplied.current) return;155 const presetId = search.get("preset");156 const promptId = search.get("prompt");157 const libraryPromptId = search.get("promptId");158 const m = search.get("model");159 if (!presetId && !promptId && !m && !libraryPromptId) return;160 presetApplied.current = true;161 (async () => {162 if (m && (modelsByKey.has(m) || m === AUTO_MODEL_KEY)) {163 userPickedModel.current = true;164 setModelKey(m);165 }166 if (presetId || promptId) {167 const res = await api<{ modelPresets: ModelPreset[]; promptPresets: PromptPreset[] }>("/api/presets");168 if (presetId) {169 const p = res.modelPresets.find((x) => x.id === presetId);170 if (p) {171 userPickedModel.current = true;172 setModelKey(p.modelKey);173 const tools = (p.tools as { builtin?: string[] })?.builtin;174 setSettings({ ...(p.parameters as ChatSettings), ...(tools?.length ? { tools } : {}) });175 if (p.systemPrompt) setSystemPrompt(p.systemPrompt);176 toast.info(`Preset “${p.name}” applied`);177 }178 }179 if (promptId) {180 const p = res.promptPresets.find((x) => x.id === promptId);181 if (p) {182 setSystemPrompt(p.systemPrompt);183 if (p.defaultModelKey && modelsByKey.has(p.defaultModelKey)) setModelKey(p.defaultModelKey);184 setSettings((s) => ({ ...s, ...(p.parameters as ChatSettings) }));185 toast.info(`Prompt “${p.name}” applied`);186 }187 }188 }189 if (libraryPromptId) {190 // Prompt library (Projects workstream). 404 → ignore.191 const res = await api<{ prompt?: { content?: string; body?: string; text?: string } }>(`/api/prompts/${libraryPromptId}`).catch(() => null);192 const content = res?.prompt?.content ?? res?.prompt?.body ?? res?.prompt?.text;193 if (content) setDraft(content);194 }195 })().catch(() => {});196 }, [conversationId, search, modelsByKey]);197198199 const isAuto = modelKey === AUTO_MODEL_KEY;200 const model: PolyModel | undefined = modelKey && !isAuto ? modelsByKey.get(modelKey) : undefined;201 const modelUsable = Boolean(model && connectedProviders.has(model.provider));202 const usableModels = React.useMemo(() => models.filter((m) => connectedProviders.has(m.provider) && m.status !== "deprecated"), [models, connectedProviders]);203 const fullSystemPrompt = React.useMemo(() => [activeProject?.instructions?.trim(), systemPrompt.trim()].filter(Boolean).join("\n\n") || null, [activeProject, systemPrompt]);204 const notice = React.useMemo(() => deprecationNotice(model, now), [model, now]);205 const replacement = React.useMemo(() => (model && notice ? suggestReplacement(model, models, connectedProviders) : null), [model, notice, models, connectedProviders]);206207 // --- context + cost estimate ----------------------------------------------------------------------208 const expectedOut = settings.maxTokens && settings.maxTokens < 4000 ? settings.maxTokens : 600;209 const estimate = React.useMemo(210 () =>211 estimateContext({212 historyText: messages.filter((m) => m.status !== "streaming").map((m) => m.content),213 historyAttachments: messages.flatMap(attachmentsOf),214 draft,215 attachments,216 systemPrompt: fullSystemPrompt,217 model,218 expectedOutput: expectedOut,219 }),220 [messages, draft, attachments, fullSystemPrompt, model, expectedOut],221 );222 const cost = React.useMemo(() => estimateCost(model, estimate.total, expectedOut), [model, estimate.total, expectedOut]);223 const largerModel = React.useMemo(() => (estimate.level !== "ok" ? largerContextModel(model, models, connectedProviders) : null), [estimate.level, model, models, connectedProviders]);224225 // --- scrolling --------------------------------------------------------------------------------------226 const scrollToBottom = React.useCallback((smooth = false) => {227 const el = scrollRef.current;228 if (!el) return;229 el.scrollTo({ top: el.scrollHeight, behavior: smooth ? "smooth" : "auto" });230 }, []);231 React.useEffect(() => {232 if (atBottom) scrollToBottom();233 }, [messages, live, atBottom, scrollToBottom, compare]);234 React.useEffect(() => {235 const hash = window.location.hash.slice(1);236 if (hash) document.getElementById(hash)?.scrollIntoView({ block: "center" });237 else scrollToBottom();238 }, [conversationId, scrollToBottom]);239 const onScroll = () => {240 const el = scrollRef.current;241 if (!el) return;242 setAtBottom(el.scrollHeight - el.scrollTop - el.clientHeight < 80);243 };244245 // --- model picker launcher (retry with…, switch model, choose another) ------------------------------246 React.useEffect(() => {247 if (pick) requestAnimationFrame(() => pickerRef.current?.open());248 }, [pick]);249250 const persistConversationMeta = React.useCallback(251 async (patch: Record<string, unknown>) => {252 if (!conversation) return;253 try {254 const res = await api<{ conversation: PublicConversation }>(`/api/conversations/${conversation.id}`, { method: "PATCH", json: patch });255 setConversation(res.conversation);256 invalidateConversations();257 } catch {258 /* non-fatal */259 }260 },261 [conversation],262 );263264 const changeModel = React.useCallback(265 (key: string, opts: { keepGlobal?: boolean } = {}) => {266 userPickedModel.current = true;267 setModelKey(key);268 if (!opts.keepGlobal) setSelectedModelKey(key);269 if (conversation && key !== AUTO_MODEL_KEY) void persistConversationMeta({ modelKey: key });270 },271 [conversation, persistConversationMeta, setSelectedModelKey],272 );273274 // --- cross-area integrations (prompt library, file library, onboarding, command palette) ------------275 const applyPromptInsert = React.useCallback(276 (d: PromptInsertDetail) => {277 if (d.kind === "system") {278 setSystemPrompt(d.systemPrompt ?? d.text ?? "");279 toast.info(`System prompt “${d.name}” applied`);280 } else if (d.text) {281 composerRef.current?.insert(d.text);282 }283 if (d.kind === "structured" && d.schema) setSettings((s) => ({ ...s, responseFormat: { type: "json_schema", schema: d.schema!, schemaName: d.name.replace(/[^a-zA-Z0-9_-]/g, "_").slice(0, 64) } }));284 if (d.defaultModelKey && modelsByKey.has(d.defaultModelKey) && connectedProviders.has(modelsByKey.get(d.defaultModelKey)!.provider)) changeModel(d.defaultModelKey);285 },286 [modelsByKey, connectedProviders, changeModel],287 );288 usePromptInsert(applyPromptInsert);289 const integrationsApplied = React.useRef(false);290 React.useEffect(() => {291 if (integrationsApplied.current) return;292 integrationsApplied.current = true;293 // Onboarding / palette: prefill the draft.294 const q = search.get("q");295 // eslint-disable-next-line react-hooks/set-state-in-effect -- one-shot URL hand-off296 if (q && !conversationId) setDraft(q);297 if (conversationId) return;298 // Prompt library hand-off (sessionStorage fast path, URL fallback for reloads/shared links).299 const pending = consumePendingPromptInsert();300 const promptInsert = search.get("promptInsert");301 if (pending) applyPromptInsert(pending);302 else if (promptInsert) {303 api<{ prompt: { id: string; name: string }; kind: PromptInsertDetail["kind"]; text: string; systemPrompt: string | null; schema: Record<string, unknown> | null; defaultModelKey: string | null }>(`/api/prompts/${promptInsert}/use`, { method: "POST", json: { variables: decodeVars(search.get("vars")) } })304 .then((res) => applyPromptInsert({ promptId: res.prompt.id, name: res.prompt.name, kind: res.kind, text: res.text, systemPrompt: res.systemPrompt, schema: res.schema, defaultModelKey: res.defaultModelKey, variables: {}, at: Date.now() }))305 .catch(() => toast.warning("Prompt not found", "It may have been deleted from your library."));306 }307 // Library files → composer chips.308 if (search.get("attachments")) {309 const list = consumePendingAttachments();310 if (list?.length) setAttachments((prev) => [...prev, ...list.filter((a) => !prev.some((p) => p.id === a.id))]);311 else toast.warning("Attachments expired", "Pick the files again from the library.");312 }313 }, [search, conversationId, applyPromptInsert]);314 React.useEffect(() => {315 const onAttach = () => composerRef.current?.openFilePicker("document");316 const onSwitch = (e: Event) => {317 const key = (e as CustomEvent<{ modelKey?: string }>).detail?.modelKey;318 if (key) changeModel(key);319 };320 window.addEventListener("polyllm:open-attach", onAttach);321 window.addEventListener("polyllm:switch-model", onSwitch);322 return () => {323 window.removeEventListener("polyllm:open-attach", onAttach);324 window.removeEventListener("polyllm:switch-model", onSwitch);325 };326 }, [changeModel]);327328 const updateSettings = (s: ChatSettings) => {329 setSettings(s);330 if (conversation) void persistConversationMeta({ settings: s });331 };332 const updateSystemPrompt = (v: string) => {333 setSystemPrompt(v);334 if (conversation) void persistConversationMeta({ systemPrompt: v || null });335 };336337 // --- streaming turn ---------------------------------------------------------------------------------338 const run = React.useCallback(339 async (body: Record<string, unknown>, opts: { optimisticUser?: PublicMessage | null; replaceAssistantId?: string; truncateAfterIndex?: number; modelKey?: string; historyUntil?: number } = {}) => {340 const key = opts.modelKey ?? modelKey;341 if (!key || key === AUTO_MODEL_KEY) return toast.warning("Pick a model first");342 if (busy) return;343 const keyModel = modelsByKey.get(key);344 setBusy(true);345 const controller = new AbortController();346 abortRef.current = controller;347 const liveState = emptyLive();348 setLive(liveState);349 let assistantId: string | null = null;350 let convId = ephemeral ? null : conversation?.id ?? null;351 let flushTimer: ReturnType<typeof setTimeout> | null = null;352 const flush = () => {353 flushTimer = null;354 setLive({ ...liveState, tools: [...liveState.tools], serverTools: [...liveState.serverTools], citations: [...liveState.citations] });355 };356 const schedule = () => {357 if (!flushTimer) flushTimer = setTimeout(flush, 40);358 };359360 // Temporary chats have no server history: replay prior turns.361 const snapshot = messagesRef.current;362 const historyEnd = opts.historyUntil ?? opts.truncateAfterIndex ?? snapshot.length;363 const history = ephemeral ? snapshot.slice(0, historyEnd).filter((m) => (m.role === "user" || m.role === "assistant") && m.content && m.status !== "streaming" && !m.id.startsWith("tmp_")).map((m) => ({ role: m.role as "user" | "assistant", content: m.content })) : undefined;364365 // optimistic UI366 setMessages((prev) => {367 let next = opts.truncateAfterIndex !== undefined ? prev.slice(0, opts.truncateAfterIndex) : [...prev];368 if (opts.replaceAssistantId) next = next.filter((m) => m.id !== opts.replaceAssistantId);369 if (opts.optimisticUser) next.push(opts.optimisticUser);370 next.push(mkMessage({ id: "pending-assistant", conversationId: convId ?? "", role: "assistant", content: "", modelKey: key, provider: keyModel?.provider ?? null, status: "streaming" }));371 return next;372 });373374 try {375 await streamEvents(376 "/api/chat",377 {378 ...body,379 modelKey: key,380 conversationId: convId ?? undefined,381 systemPrompt: fullSystemPrompt,382 settings: Object.keys(settings).length ? settings : undefined,383 projectId: !convId && !ephemeral && activeProjectId ? activeProjectId : undefined,384 ephemeral: ephemeral || undefined,385 history,386 },387 (raw) => {388 const ev = raw as ChatStreamEvent & ChatMetaExtras;389 switch (ev.type) {390 case "meta": {391 const meta = ev as MetaEvent;392 assistantId = meta.assistantMessageId;393 if (!meta.ephemeral) convId = meta.conversationId;394 setMessages((prev) =>395 prev.map((m) => {396 if (m.id === "pending-assistant") return { ...m, id: meta.assistantMessageId, conversationId: meta.conversationId };397 if (opts.optimisticUser && m.id === opts.optimisticUser.id && meta.userMessage) return meta.userMessage;398 return m;399 }),400 );401 if (meta.requestId) setTurnMeta((t) => ({ ...t, [meta.assistantMessageId]: { ...t[meta.assistantMessageId], requestId: meta.requestId } }));402 if (meta.isNewConversation && !meta.ephemeral) {403 window.history.replaceState(null, "", `/app/chat/${meta.conversationId}`);404 setConversation({ id: meta.conversationId, title: "New chat", folderId: null, pinned: false, archived: false, modelKey: key, provider: keyModel?.provider ?? null, systemPrompt: fullSystemPrompt, settings, messageCount: 0, totalCostUsd: 0, totalInputTokens: 0, totalOutputTokens: 0, parentConversationId: null, lastMessageAt: null, createdAt: new Date().toISOString(), updatedAt: new Date().toISOString() } as PublicConversation);405 }406 break;407 }408 case "text-delta":409 if (!liveState.firstTokenAt) liveState.firstTokenAt = Date.now();410 liveState.text += ev.text;411 schedule();412 break;413 case "reasoning-delta":414 if (!liveState.firstTokenAt) liveState.firstTokenAt = Date.now();415 liveState.reasoning += ev.text;416 schedule();417 break;418 case "tool-start":419 liveState.tools.push({ id: ev.id, name: ev.name, args: "" });420 schedule();421 break;422 case "tool-delta": {423 const t = liveState.tools.find((x) => x.id === ev.id);424 if (t) t.args += ev.argumentsDelta;425 schedule();426 break;427 }428 case "tool-end": {429 const t = liveState.tools.find((x) => x.id === ev.id);430 if (t) t.args = ev.argumentsText ?? JSON.stringify(ev.arguments);431 else liveState.tools.push({ id: ev.id, name: ev.name, args: ev.argumentsText ?? JSON.stringify(ev.arguments) });432 schedule();433 break;434 }435 case "tool-result": {436 const t = liveState.tools.find((x) => x.id === ev.id);437 if (t) {438 t.result = ev.result;439 t.isError = ev.isError;440 t.durationMs = ev.durationMs;441 }442 if (liveState.text && !liveState.text.endsWith("\n\n")) liveState.text += "\n\n";443 schedule();444 break;445 }446 case "server-tool":447 liveState.serverTools.push({ name: ev.name, status: ev.status });448 schedule();449 break;450 case "citation":451 liveState.citations.push(ev.citation);452 schedule();453 break;454 case "refusal":455 break;456 case "error": {457 const id = assistantId ?? "pending-assistant";458 setTurnMeta((t) => ({ ...t, [id]: { ...t[id], errorAt: Date.now() } }));459 break;460 }461 case "done": {462 if (flushTimer) clearTimeout(flushTimer);463 setMessages((prev) => prev.map((m) => (m.id === (assistantId ?? "pending-assistant") ? ev.message : m)));464 if (ev.error) setTurnMeta((t) => ({ ...t, [ev.message.id]: { ...t[ev.message.id], errorAt: t[ev.message.id]?.errorAt ?? Date.now() } }));465 if (!ephemeral) {466 if (ev.title) setConversation((c) => (c ? { ...c, title: ev.title! } : c));467 invalidateConversations();468 setConversation((c) => (c ? { ...c, totalCostUsd: (c.totalCostUsd ?? 0) + (ev.costUsd ?? 0), messageCount: c.messageCount + 1 } : c));469 }470 break;471 }472 }473 },474 controller.signal,475 );476 } catch (e) {477 if ((e as Error).name === "AbortError") {478 // Stopped by the user: the server persists the partial message; reload it (not for temporary chats).479 if (convId) {480 const res = await api<ConversationDetail>(`/api/conversations/${convId}`).catch(() => null);481 if (res) setMessages(res.messages.filter((m) => (m as unknown as { active?: boolean }).active !== false));482 } else {483 setMessages((prev) => prev.map((m) => (m.id === (assistantId ?? "pending-assistant") ? { ...m, content: liveState.text, status: "stopped" as const, finishReason: "cancelled" } : m)));484 }485 } else {486 const err = e as ClientApiError;487 toast.error(err.message, err.code === "NO_PROVIDER_KEY" ? "Add the key in Settings → Providers." : undefined);488 setMessages((prev) => prev.filter((m) => m.id !== "pending-assistant" && m.id !== assistantId).filter((m) => !(opts.optimisticUser && m.id === opts.optimisticUser.id && m.id.startsWith("tmp_"))));489 if (err.code === "NO_PROVIDER_KEY") router.push("/app/settings/providers");490 }491 } finally {492 if (flushTimer) clearTimeout(flushTimer);493 setLive(null);494 setBusy(false);495 abortRef.current = null;496 }497 },498 [busy, conversation, modelKey, modelsByKey, settings, fullSystemPrompt, router, ephemeral, activeProjectId],499 );500501 // --- send pipeline: Smart Router → cost confirm → run ----------------------------------------------502 const restorePending = (p: Pending) => {503 setDraft(p.text);504 setAttachments(p.attachments);505 };506507 const doSend = (key: string, pending: Pending) => {508 if (key !== modelKey) changeModel(key, { keepGlobal: isAuto });509 const km = modelsByKey.get(key);510 const optimistic = mkMessage({511 id: `tmp_${Date.now()}`,512 conversationId: conversation?.id ?? "",513 role: "user",514 content: pending.text,515 parts: [...(pending.text ? [{ type: "text" as const, text: pending.text }] : []), ...pending.attachments.map((a) => ({ type: "attachment" as const, attachmentId: a.id, kind: a.kind, name: a.name, mimeType: a.mimeType, sizeBytes: a.sizeBytes, width: a.width, height: a.height }))],516 });517 setAttachments([]);518 void km;519 void run({ action: "send", message: { text: pending.text, attachmentIds: pending.attachments.map((a) => a.id) } }, { optimisticUser: optimistic, modelKey: key });520 };521522 const routeFor = (text: string, atts: PendingAttachment[], mode: RouterMode = routerMode): RouteResult => {523 const analysis = analyzePrompt(text, atts, { historyTokens: estimate.history, systemPrompt: fullSystemPrompt, webSearch: settings.webSearch, responseFormat: Boolean(settings.responseFormat) });524 return routeModels(usableModels, analysis, mode, { favorites, budgetCapUsd: COST_CONFIRM_THRESHOLD_USD });525 };526527 const proceed = (key: string, pending: Pending) => {528 const km = modelsByKey.get(key);529 const inputTokens = estimate.history + estimateTextTokens(pending.text) + pending.attachments.reduce((n, a) => n + estimateAttachmentTokens(a), 0) + estimateTextTokens(fullSystemPrompt ?? "");530 const est = estimateCost(km, inputTokens, expectedOut);531 if (est.usd !== null && est.usd > COST_CONFIRM_THRESHOLD_USD) {532 const cheap = routeFor(pending.text, pending.attachments, "cheapest");533 const alternatives = [cheap.recommended, ...cheap.alternatives].filter((c): c is RouteCandidate => Boolean(c) && c!.model.key !== key && c!.estimatedUsd !== null && (c!.estimatedUsd as number) < (est.usd as number)).slice(0, 3);534 setCostState({ pending, modelKey: key, estimate: est, alternatives });535 return;536 }537 doSend(key, pending);538 };539540 const send = (text: string) => {541 if (!text && attachments.length === 0) return;542 const pending: Pending = { text, attachments: [...attachments] };543 if (isAuto) {544 if (!usableModels.length) return toast.warning("No provider connected", "Add a key in Settings → Providers.");545 const result = routeFor(text, pending.attachments);546 if (!result.recommended) {547 restorePending(pending);548 return toast.warning("No compatible model", "None of your connected models can take this prompt (vision, files or context).");549 }550 if (alwaysAuto && !result.needsConfirmation) {551 toast.info(`Auto-routed to ${result.recommended.model.displayName}`, explainRoute(result.recommended));552 proceed(result.recommended.model.key, pending);553 } else {554 setRouterState({ result, pending });555 }556 return;557 }558 if (!modelKey) return toast.warning("Pick a model first");559 if (!modelUsable) {560 restorePending(pending);561 return toast.warning("This model's provider isn't connected", "Add a key in Settings → Providers.");562 }563 proceed(modelKey, pending);564 };565566 const stop = () => abortRef.current?.abort();567568 // --- message actions ---------------------------------------------------------------------------------569 const lastUserBefore = (idx: number) => {570 for (let i = idx - 1; i >= 0; i--) if (messages[i].role === "user") return i;571 return -1;572 };573574 const regenerate = (m: PublicMessage, key?: string) => {575 const idx = messages.findIndex((x) => x.id === m.id);576 if (key && key !== modelKey) changeModel(key, { keepGlobal: isAuto });577 if (ephemeral) {578 const ui = lastUserBefore(idx);579 if (ui < 0) return;580 const u = messages[ui];581 void run({ action: "send", message: { text: u.content, attachmentIds: [] } }, { truncateAfterIndex: idx, historyUntil: ui, modelKey: key });582 return;583 }584 void run({ action: "regenerate", targetMessageId: m.id }, { truncateAfterIndex: idx, modelKey: key });585 };586 const regenerateWith = (m: PublicMessage) => setPick({ onPick: (key) => regenerate(m, key) });587 const retry = (m: PublicMessage) => {588 const idx = messages.findIndex((x) => x.id === m.id);589 if (ephemeral) return regenerate(m);590 void run({ action: "retry", targetMessageId: m.id }, { truncateAfterIndex: idx });591 };592 const edit = (m: PublicMessage, text: string) => {593 const idx = messages.findIndex((x) => x.id === m.id);594 const optimistic = mkMessage({ ...m, id: `tmp_${Date.now()}`, content: text, parts: [{ type: "text", text }, ...m.parts.filter((p) => p.type === "attachment")] });595 if (ephemeral) {596 void run({ action: "send", message: { text, attachmentIds: [] } }, { optimisticUser: optimistic, truncateAfterIndex: idx, historyUntil: idx });597 return;598 }599 void run({ action: "edit", targetMessageId: m.id, message: { text } }, { optimisticUser: optimistic, truncateAfterIndex: idx });600 };601 const cont = (m: PublicMessage) => {602 const idx = messages.findIndex((x) => x.id === m.id);603 void run({ action: "continue", targetMessageId: m.id }, { truncateAfterIndex: idx });604 };605 const branch = async (m: PublicMessage) => {606 if (!conversation) return;607 const res = await api<{ conversation: PublicConversation }>(`/api/conversations/${conversation.id}/actions`, { method: "POST", json: { action: "branch", messageId: m.id } });608 invalidateConversations();609 toast.success("Branch created");610 router.push(`/app/chat/${res.conversation.id}`);611 };612 const del = async (m: PublicMessage) => {613 if (ephemeral) {614 setMessages((prev) => prev.filter((x) => x.id !== m.id));615 return;616 }617 if (!conversation) return;618 await api(`/api/conversations/${conversation.id}/actions`, { method: "POST", json: { action: "delete-message", messageId: m.id } });619 setMessages((prev) => prev.filter((x) => x.id !== m.id));620 };621 const quote = (m: PublicMessage) => {622 const q = m.content623 .trim()624 .split("\n")625 .map((l) => `> ${l}`)626 .join("\n");627 composerRef.current?.insert(`${q}\n\n`);628 };629 const saveAsPrompt = async (m: PublicMessage) => {630 const name = m.content.replace(/\s+/g, " ").trim().slice(0, 60) || "Saved prompt";631 try {632 await api("/api/prompts", { method: "POST", json: { name, content: m.content, source: "chat" } });633 toast.success("Saved to your prompt library");634 } catch (e) {635 const err = e as ClientApiError;636 if (err.status === 404 || err.status === 405) toast.info("Prompt library not available yet");637 else toast.error("Could not save the prompt", err.message);638 }639 };640 const exportMessage = async (m: PublicMessage) => {641 const who = m.role === "user" ? "User" : modelsByKey.get(m.modelKey ?? "")?.displayName ?? m.modelKey ?? "Assistant";642 const md = `### ${who} · ${new Date(m.createdAt).toLocaleString()}\n\n${m.content}\n`;643 await navigator.clipboard.writeText(md).catch(() => {});644 toast.success("Message copied as Markdown");645 };646 const compareFrom = (m: PublicMessage) => {647 const idx = messages.findIndex((x) => x.id === m.id);648 const ui = lastUserBefore(idx);649 const prompt = ui >= 0 ? messages[ui].content : "";650 if (!prompt.trim()) return toast.warning("Nothing to compare", "This response has no user prompt before it.");651 const base = m.modelKey && modelsByKey.has(m.modelKey) ? [m.modelKey] : model ? [model.key] : [];652 setCompare({ prompt, editable: false, initialModels: base });653 setAtBottom(true);654 };655 const openCompareFromComposer = () => {656 setCompare({ prompt: draft, editable: true, initialModels: model ? [model.key] : [] });657 setAtBottom(true);658 };659 const onAdopted = (res: ChatAdoptResponse, key: string) => {660 setCompare(null);661 if (res.isNewConversation) {662 invalidateConversations();663 setSelectedModelKey(key);664 router.push(`/app/chat/${res.conversation.id}`);665 return;666 }667 setMessages((prev) => [...prev, res.message]);668 setConversation(res.conversation);669 changeModel(key, { keepGlobal: isAuto });670 invalidateConversations();671 toast.success(`Continuing with ${modelsByKey.get(key)?.displayName ?? key}`);672 };673674 const share = () => {675 if (!conversation) return;676 openShareSheet({ conversationId: conversation.id, title: conversation.title, messages });677 };678 const deleteConversation = async () => {679 if (!conversation) return;680 await api(`/api/conversations/${conversation.id}`, { method: "DELETE" });681 invalidateConversations();682 router.push("/app/chat");683 };684685 const toggleTemporary = () => {686 if (conversation || messages.length) {687 router.push("/app/chat?temporary=1");688 return;689 }690 const next = !ephemeral;691 setEphemeral(next);692 window.history.replaceState(null, "", next ? "/app/chat?temporary=1" : "/app/chat");693 };694695 // Summarize the context with the current model, then start a new chat seeded with the summary.696 const summarize = async () => {697 if (!model || !modelKey || summarizing || busy) return;698 setSummarizing(true);699 try {700 const history = messages.filter((m) => (m.role === "user" || m.role === "assistant") && m.content && m.status !== "streaming").map((m) => ({ role: m.role as "user" | "assistant", content: m.content }));701 let summary = "";702 let failure: string | null = null;703 await streamEvents("/api/chat", { modelKey, ephemeral: true, history, message: { text: SUMMARY_PROMPT }, systemPrompt: null }, (raw) => {704 const ev = raw as ChatStreamEvent;705 if (ev.type === "text-delta") summary += ev.text;706 if (ev.type === "error") failure = ev.error.message;707 });708 if (!summary.trim()) throw new Error(failure ?? "The model returned an empty summary");709 const sys = [systemPrompt.trim(), `Context carried over from a previous conversation (summarized):\n\n${summary.trim()}`].filter(Boolean).join("\n\n");710 const res = await api<{ conversation: PublicConversation }>("/api/conversations", { method: "POST", json: { title: `${conversation?.title ?? "Chat"} (continued)`, modelKey, systemPrompt: sys, settings } });711 invalidateConversations();712 toast.success("Context summarized", "A new chat was started with the summary as system prompt.");713 router.push(`/app/chat/${res.conversation.id}`);714 } catch (e) {715 toast.error("Could not summarize the context", (e as Error).message);716 } finally {717 setSummarizing(false);718 }719 };720721 const actions = React.useMemo<MessageActions>(722 () => ({723 onEdit: edit,724 onRegenerate: regenerate,725 onRegenerateWith: regenerateWith,726 onRetry: retry,727 onContinue: ephemeral ? undefined : cont,728 onBranch: conversation && !ephemeral ? branch : undefined,729 onDelete: conversation || ephemeral ? del : undefined,730 onCompare: usableModels.length > 1 ? compareFrom : undefined,731 onQuote: quote,732 onSaveAsPrompt: saveAsPrompt,733 onExport: exportMessage,734 onSwitchModel: (key) => changeModel(key),735 }),736 // eslint-disable-next-line react-hooks/exhaustive-deps737 [conversation, messages, ephemeral, modelKey, usableModels.length],738 );739740 const lastAssistantIdx = messages.map((m) => m.role).lastIndexOf("assistant");741 const composerDisabled = isAuto ? usableModels.length === 0 : !model || (!modelUsable && models.length > 0);742 const activeSettings = countActiveSettings(settings);743744 // --- `+` menu extras ---------------------------------------------------------------------------------745 const extraActions: (ComposerAction | "separator")[] = [746 ...(model?.capabilities.tools ? [{ key: "tools", label: "Tools", icon: <Wrench />, hint: settings.tools?.length ? `${settings.tools.length} on` : undefined, selected: Boolean(settings.tools?.length), onSelect: () => setToolsOpen(true) }] : []),747 ...(model?.capabilities.webSearch ? [{ key: "web", label: "Web search", icon: <Globe />, selected: Boolean(settings.webSearch), onSelect: () => updateSettings(settings.webSearch ? (({ webSearch: _w, ...rest }) => rest)(settings) : { ...settings, webSearch: true }) }] : []),748 ...(model?.capabilities.structuredOutput ? [{ key: "json", label: "Structured output", icon: <Braces />, hint: settings.responseFormat?.type === "json_schema" ? "schema" : settings.responseFormat?.type === "json" ? "JSON" : undefined, selected: Boolean(settings.responseFormat), onSelect: () => setStructOpen(true) }] : []),749 { key: "system", label: "System prompt", icon: <Terminal />, selected: Boolean(systemPrompt.trim()), onSelect: () => setSysOpen(true) },750 "separator",751 { key: "temporary", label: "Temporary chat", icon: <EyeOff />, hint: conversation || messages.length ? "New" : undefined, selected: ephemeral, onSelect: toggleTemporary },752 ];753754 const moreItems: (ActionSheetItem | "separator")[] = [755 { key: "config", label: "Model settings", icon: <Settings2 />, hint: activeSettings ? `${activeSettings} set` : undefined, onSelect: () => document.getElementById("chat-model-config")?.click() },756 { key: "compare", label: "Compare with…", icon: <Columns3 />, onSelect: openCompareFromComposer, disabled: usableModels.length < 2 },757 ...(conversation && !ephemeral758 ? ([759 { key: "pin", label: conversation.pinned ? "Unpin" : "Pin", icon: <Pin />, onSelect: () => persistConversationMeta({ pinned: !conversation.pinned }) },760 { key: "share", label: "Share…", icon: <Share2 />, onSelect: share },761 { key: "export-md", label: "Export Markdown", icon: <Download />, onSelect: () => void exportConversation(conversation.id, "markdown") },762 { key: "export-pdf", label: "Export PDF (print)", icon: <Download />, onSelect: () => void exportConversation(conversation.id, "pdf") },763 ] as ActionSheetItem[])764 : []),765 "separator",766 { key: "temporary", label: "New temporary chat", icon: <EyeOff />, onSelect: () => router.push("/app/chat?temporary=1") },767 { key: "arena", label: "Open Arena", icon: <Swords />, onSelect: () => router.push("/app/arena") },768 ...(conversation && !ephemeral ? (["separator", { key: "delete", label: "Delete conversation", icon: <Trash2 />, destructive: true, onSelect: () => setDeleteOpen(true) }] as (ActionSheetItem | "separator")[]) : []),769 ];770771 const title = ephemeral ? "Temporary chat" : conversation?.title ?? (activeProject ? activeProject.name : "New chat");772773 return (774 <div className="flex h-full min-h-0 flex-col">775 {/* Header — 48 px */}776 <header className="flex h-12 shrink-0 items-center gap-1.5 border-b border-border px-2 sm:px-4">777 <Button variant="ghost" size="icon-sm" className="tap md:hidden" onClick={() => setSidebarOpen(true)} aria-label="Open sidebar">778 <Menu />779 </Button>780 <div className="flex min-w-0 flex-1 items-center gap-2">781 {ephemeral ? (782 <Badge variant="warning" className="gap-1 max-w-full">783 <EyeOff /> <span className="truncate">Temporary chat — not stored in history</span>784 </Badge>785 ) : (786 <span className="truncate text-[13.5px] font-medium text-fg">{title}</span>787 )}788 {activeProject && !ephemeral && conversation ? <Badge variant="outline" className="hidden sm:inline-flex">{activeProject.name}</Badge> : null}789 </div>790 <div className="ml-auto flex items-center gap-0.5">791 {conversation && !ephemeral && preferences.showCosts && (conversation.totalCostUsd ?? 0) > 0 ? (792 <Tooltip content="Estimated conversation cost">793 <span className="hidden rounded-md bg-bg-muted px-2 py-1 font-mono text-[11px] tabular-nums text-fg-muted sm:inline">≈ {formatUsd(conversation.totalCostUsd, { precise: conversation.totalCostUsd < 0.01 })}</span>794 </Tooltip>795 ) : null}796 {/* Phone: everything under "more" */}797 <Button variant="ghost" size="icon-sm" className="tap md:hidden" onClick={() => setMoreOpen(true)} aria-label="More actions">798 <MoreHorizontal />799 </Button>800 {/* Desktop actions */}801 <div className="hidden items-center gap-0.5 md:flex">802 {usableModels.length > 1 ? (803 <Tooltip content="Compare with…">804 <Button variant="ghost" size="icon-sm" onClick={openCompareFromComposer} aria-label="Compare models inline" disabled={busy}>805 <Columns3 />806 </Button>807 </Tooltip>808 ) : null}809 {conversation && !ephemeral ? (810 <>811 <Tooltip content={conversation.pinned ? "Unpin" : "Pin"}>812 <Button variant="ghost" size="icon-sm" onClick={() => persistConversationMeta({ pinned: !conversation.pinned })} aria-label="Pin conversation">813 <Pin className={cn(conversation.pinned && "fill-current text-accent")} />814 </Button>815 </Tooltip>816 <Tooltip content="Share">817 <Button variant="ghost" size="icon-sm" onClick={share} aria-label="Share conversation">818 <Share2 />819 </Button>820 </Tooltip>821 <ExportMenu conversationId={conversation.id} title={conversation.title} />822 <Tooltip content="Delete conversation">823 <Button variant="ghost" size="icon-sm" aria-label="Delete conversation" onClick={() => setDeleteOpen(true)}>824 <Trash2 />825 </Button>826 </Tooltip>827 </>828 ) : (829 <>830 <Tooltip content={ephemeral ? "Temporary chat (not stored)" : "New temporary chat"}>831 <Button variant="ghost" size="icon-sm" aria-label="Temporary chat" onClick={toggleTemporary} className={cn(ephemeral && "text-warning")}>832 <EyeOff />833 </Button>834 </Tooltip>835 <Tooltip content="Compare models in the Arena">836 <Button asChild variant="ghost" size="icon-sm" aria-label="Open Arena">837 <Link href="/app/arena">838 <Swords />839 </Link>840 </Button>841 </Tooltip>842 </>843 )}844 </div>845 </div>846 </header>847848 {/* Messages */}849 <div ref={scrollRef} onScroll={onScroll} className="relative min-h-0 flex-1 overflow-y-auto scrollbar-thin">850 {messages.length === 0 && !compare ? (851 <ChatEmptyState model={model} temporary={ephemeral} projectName={activeProject?.name} onPick={(t) => { setDraft(t); composerRef.current?.focus(); }} onAnalyzeFile={() => composerRef.current?.openFilePicker()} onCompare={usableModels.length > 1 ? openCompareFromComposer : undefined} />852 ) : (853 <div className="mx-auto flex w-full max-w-3xl flex-col gap-6 px-0 py-6 sm:px-4">854 {messages.map((m, i) => (855 <MessageItem key={m.id} message={m} model={m.modelKey ? modelsByKey.get(m.modelKey) : undefined} replacement={m.modelKey === modelKey ? replacement : null} live={m.status === "streaming" ? live : null} isLast={i === lastAssistantIdx} wrapCode={preferences.codeWrap} showReasoning={preferences.showReasoning} showCosts={preferences.showCosts} actions={actions} busy={busy} requestId={turnMeta[m.id]?.requestId} errorAt={turnMeta[m.id]?.errorAt} />856 ))}857 {compare ? (858 <div className="px-3 sm:px-0">859 <CompareInline prompt={compare.prompt} onPromptChange={compare.editable ? (p) => setCompare((c) => (c ? { ...c, prompt: p } : c)) : undefined} systemPrompt={fullSystemPrompt} settings={settings} initialModelKeys={compare.initialModels} conversationId={ephemeral ? null : conversation?.id ?? null} projectId={activeProjectId} onClose={() => setCompare(null)} onAdopted={onAdopted} />860 </div>861 ) : null}862 </div>863 )}864 {!atBottom && (messages.length > 0 || compare) ? (865 <button onClick={() => scrollToBottom(true)} className="tap sticky bottom-3 left-1/2 -translate-x-1/2 rounded-full border border-border bg-bg-elevated p-2 shadow-md hover:bg-bg-subtle" aria-label="Scroll to bottom">866 <ArrowDown className="size-4" />867 </button>868 ) : null}869 </div>870871 {/* Composer */}872 <div className="shrink-0 px-2 pb-[max(8px,var(--sab))] pt-1.5 sm:px-4 sm:pb-3">873 <div className="mx-auto w-full max-w-3xl">874 {notice && replacement && !ephemeral ? (875 <div className="mb-1.5 flex flex-wrap items-center gap-2 rounded-xl bg-warning-soft px-3 py-2 text-[12.5px] text-warning">876 <AlertTriangle className="size-3.5 shrink-0" />877 <span className="min-w-0 flex-1">878 {model?.displayName} is {notice.kind === "deprecated" ? "deprecated" : `retiring on ${notice.shutdownDate}`}.879 </span>880 <Button size="xs" variant="outline" className="bg-bg-elevated" onClick={() => changeModel(replacement.key)}>881 Switch to {replacement.displayName}882 </Button>883 </div>884 ) : null}885 <Composer886 ref={composerRef}887 model={model}888 busy={busy}889 disabled={composerDisabled}890 enterToSend={preferences.enterToSend}891 attachments={attachments}892 onAttachmentsChange={setAttachments}893 onSend={send}894 onStop={stop}895 autoFocus896 value={draft}897 onValueChange={setDraft}898 extraActions={extraActions}899 allowAttachWithoutModel={isAuto && usableModels.length > 0}900 projectId={activeProjectId}901 placeholder={isAuto ? "Ask anything — Auto picks the model…" : undefined}902 topSlot={903 <div className="mb-1.5 flex items-center gap-1.5">904 <ModelSelector value={modelKey} onChange={(k) => changeModel(k)} size="sm" className="max-w-[46vw] rounded-full sm:max-w-[280px]" buttonLabel={isAuto ? "Auto" : undefined} />905 <ModelConfig906 model={model}907 settings={settings}908 onChange={updateSettings}909 systemPrompt={systemPrompt}910 onSystemPromptChange={updateSystemPrompt}911 trigger={912 <Button id="chat-model-config" variant="ghost" size="icon-sm" className="tap relative rounded-full" aria-label="Model settings" disabled={!model}>913 <Settings2 />914 {activeSettings > 0 ? <span className="absolute -right-0.5 -top-0.5 flex size-3.5 items-center justify-center rounded-full bg-accent text-[9px] font-semibold text-accent-fg">{activeSettings}</span> : null}915 </Button>916 }917 />918 <div className="ml-auto min-w-0">919 <ContextIndicator estimate={estimate} cost={cost} model={model} showCost={preferences.showCosts} onNewChat={() => router.push("/app/chat")} onSummarize={model && messages.length > 1 && !ephemeral ? summarize : undefined} summarizing={summarizing} largerModel={largerModel} onSwitchLarger={(k) => changeModel(k)} />920 </div>921 </div>922 }923 bottomSlot={924 <p className="mt-1.5 hidden text-center text-[11px] text-fg-subtle sm:block">925 {ephemeral ? "Temporary chat — messages are not saved; usage is still counted." : model ? <>Responses come straight from {providerName(model.provider)} using your key · costs are estimates</> : isAuto ? "Smart Router recommends a model before each request — nothing is sent without your confirmation." : "Choose a model to start"}926 </p>927 }928 />929 </div>930 </div>931932 {/* Sheets & dialogs */}933 {routerState ? (934 <RouterCard935 open936 onOpenChange={(o) => {937 if (!o) {938 restorePending(routerState.pending);939 setRouterState(null);940 }941 }}942 result={routerState.result}943 mode={routerMode}944 onModeChange={(m) => {945 setRouterMode(m);946 setRouterState((s) => (s ? { ...s, result: routeFor(s.pending.text, s.pending.attachments, m) } : s));947 }}948 alwaysAuto={alwaysAuto}949 onAlwaysAutoChange={setAlwaysAuto}950 onUse={(key) => {951 const p = routerState.pending;952 setRouterState(null);953 proceed(key, p);954 }}955 />956 ) : null}957 <CostConfirm958 open={Boolean(costState)}959 onOpenChange={(o) => {960 if (!o && costState) {961 restorePending(costState.pending);962 setCostState(null);963 }964 }}965 model={costState ? modelsByKey.get(costState.modelKey) : undefined}966 estimate={costState?.estimate ?? null}967 alternatives={costState?.alternatives ?? []}968 onSend={() => {969 if (!costState) return;970 const s = costState;971 setCostState(null);972 doSend(s.modelKey, s.pending);973 }}974 onSwitch={(key) => {975 if (!costState) return;976 const s = costState;977 setCostState(null);978 doSend(key, s.pending);979 }}980 />981 {pick ? (982 <ModelPickerLauncher983 ref={pickerRef}984 hiddenTrigger985 value={modelKey}986 onChange={(key) => {987 const p = pick;988 setPick(null);989 p.onPick(key);990 }}991 />992 ) : null}993 <SystemPromptSheet open={sysOpen} onOpenChange={setSysOpen} value={systemPrompt} onChange={updateSystemPrompt} projectInstructions={activeProject?.instructions} />994 <StructuredOutputSheet open={structOpen} onOpenChange={setStructOpen} settings={settings} onChange={updateSettings} />995 <ToolsSheet open={toolsOpen} onOpenChange={setToolsOpen} settings={settings} onChange={updateSettings} supported={Boolean(model?.capabilities.tools)} />996 {isMobile ? <ActionSheet open={moreOpen} onOpenChange={setMoreOpen} items={moreItems} title={title} /> : null}997 <ConfirmDialog open={deleteOpen} onOpenChange={setDeleteOpen} title="Delete this conversation?" description="Messages and attachments are removed permanently. Usage records are kept for your cost history." confirmLabel="Delete" destructive onConfirm={deleteConversation} />998 </div>999 );1000}1001