SPB Git forge

spb/polyllm

Public
15commits 1branches 0releases
2.2 MBsize
maindefault branch
14 days agolast push
TypeScript 97.4% SQL 1% JavaScript 0.9% CSS 0.6%
52.7 KB · 1,001 lines tsx
Raw Blame History
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