import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { Rail } from "@/components/Rail"; import { Transcript } from "@/components/Transcript"; import { Composer, type MainView, type SlashCommand } from "@/components/Composer"; import { Hero } from "@/components/Hero"; import { WorkspaceChip } from "@/components/WorkspaceChip"; import { ApprovalCard } from "@/components/ApprovalCard"; import { QuestionCard } from "@/components/QuestionCard"; import { SessionHeader } from "@/components/SessionHeader"; import { SettingsPanel } from "@/components/SettingsPanel"; import { PlanPanel } from "@/components/PlanPanel"; import { BashPanel } from "@/components/BashPanel"; import type { HermitMood } from "@/components/Icons"; import { answerConsent, answerQuestion, attachFile, cancelTurn, clearPending, compactSession, deleteSession, engineStatus, exportSession, getSettings, getTodo, getUsage, isShellTool, listBashRuns, listPending, listProviders, listSessions, listWorkspaceFiles, mockTurnsAllowed, newSession, onEngineEvent, onMenuEvent, openSession, pickFiles, pickWorkspace, pinSession, removePending, renameSession, sendTurn, setSessionFolder, setSettings, useProvider, type AttachmentInfo, type BashRun, type ChatItem, type ConsentRequest, type ProviderInfo, type QuestionRequest, type SessionInfo, type Settings, type UsageInfo, } from "@/lib/bridge"; import frame from "@/dsh/layout/AppFrame.module.css"; import conv from "@/dsh/conversation/ConversationRoot.module.css"; const SIDEBAR_WIDTH = 280; const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; function displaySessionTitle(title?: string, id?: string): string { const t = (title || "").trim(); if (!t || UUID_RE.test(t) || t === id) return "New session"; return t; } export default function App() { const [sessions, setSessions] = useState([]); const [activeId, setActiveId] = useState(null); const [items, setItems] = useState([]); const [draft, setDraft] = useState(""); const [busy, setBusy] = useState(false); const [query, setQuery] = useState(""); const [settings, setSettingsState] = useState({ provider: "deepseek", model: "deepseek-flash", approval: "auto", workspace: ".", plan_mode: false, reasoning_effort: "high", font_size: "m", language: "zh", }); const [consent, setConsent] = useState(null); const [question, setQuestion] = useState(null); const [showSettings, setShowSettings] = useState(false); const [status, setStatus] = useState(""); const [todo, setTodo] = useState(""); const [bashRuns, setBashRuns] = useState([]); const [view, setView] = useState("chat"); const [attachments, setAttachments] = useState([]); const [providers, setProviders] = useState([]); const [usage, setUsage] = useState({ prompt: 0, out: 0, cost: 0, priced: false, pct: 0, }); const [fileSuggestions, setFileSuggestions] = useState([]); const [dragging, setDragging] = useState(false); const [engine, setEngine] = useState("host"); const convColRef = useRef(null); const hero = items.length === 0 && !busy && view === "chat"; useEffect(() => { const el = convColRef.current; if (!el) return; const sync = () => { el.style.setProperty( "--dsh-conversation-column-width", `${el.clientWidth}px`, ); }; sync(); const ro = new ResizeObserver(sync); ro.observe(el); return () => ro.disconnect(); }, []); const agentMood: HermitMood = busy ? consent || question ? "thinking" : "coding" : "idle"; const engineReady = engine === "engine"; const sendBlocked = !engineReady && !mockTurnsAllowed(); const sendBlockedReason = sendBlocked ? engine === "browser" ? "当前在浏览器预览(无 Tauri)。请用 apps/desktop/run-dev.sh 启动真引擎。" : "当前为 host(未链接 libcstcore)。请用 run-dev.sh(自动 --features engine),并在环境或设置中配置 DEEPSEEK_API_KEY。" : engineReady ? undefined : "演示 mock 已开启(hermit.allowMock)— 非真实引擎。"; const refreshSessions = useCallback(async () => { const list = await listSessions(); list.sort( (a, b) => Number(!!b.pinned) - Number(!!a.pinned) || b.updated_at - a.updated_at, ); setSessions(list); }, []); const refreshPending = useCallback(async () => { setAttachments(await listPending()); }, []); const refreshBash = useCallback(async () => { setBashRuns(await listBashRuns()); }, []); const applySettings = useCallback(async (next: Settings) => { const saved = await setSettings(next); setSettingsState(saved); document.documentElement.dataset.font = saved.font_size || "m"; document.documentElement.dataset.lang = saved.language || "zh"; return saved; }, []); const onPickWorkspace = useCallback(async () => { const path = await pickWorkspace(); if (!path) return; await applySettings({ ...settings, workspace: path }); await refreshSessions(); }, [applySettings, refreshSessions, settings]); useEffect(() => { void (async () => { const s = await getSettings(); setSettingsState(s); document.documentElement.dataset.font = s.font_size || "m"; document.documentElement.dataset.lang = s.language || "zh"; const st = await engineStatus(); setEngine(st); setStatus(st === "engine" ? "engine ready" : st); setProviders(await listProviders()); setUsage(await getUsage()); const list = await listSessions(); list.sort( (a, b) => Number(!!b.pinned) - Number(!!a.pinned) || b.updated_at - a.updated_at, ); setSessions(list); await refreshPending(); // Never block first paint on ensure_live/open_session (can freeze GTK). // Start on empty hero; open the latest session after a tick. const forceHero = typeof location !== "undefined" && new URLSearchParams(location.search).get("hero") === "1"; setActiveId(null); setItems([]); if (forceHero) return; const first = list[0]; if (!first) return; window.setTimeout(() => { void (async () => { try { setActiveId(first.id); setItems(await openSession(first.id)); setTodo(await getTodo()); setBashRuns(await listBashRuns()); } catch (e) { console.error("openSession deferred failed", e); setActiveId(null); setItems([]); } })(); }, 0); })(); }, [refreshPending]); useEffect(() => { let un: (() => void) | undefined; void onEngineEvent((ev) => { if (ev.type === "assistant_delta") { setItems((prev) => { const last = prev[prev.length - 1]; if (last && last.kind === "assistant") { return [ ...prev.slice(0, -1), { ...last, text: last.text + ev.text }, ]; } return [ ...prev, { kind: "assistant", id: `a${Date.now()}`, text: ev.text, ts: Date.now(), }, ]; }); } else if (ev.type === "thought_delta") { setItems((prev) => { const last = prev[prev.length - 1]; if (last && last.kind === "thought") { return [ ...prev.slice(0, -1), { ...last, text: last.text + "\n" + ev.text }, ]; } return [ ...prev, { kind: "thought", id: `th${Date.now()}`, text: ev.text, ts: Date.now(), }, ]; }); } else if (ev.type === "tool_start") { setItems((prev) => [ ...prev, { kind: "tool", id: ev.id, name: ev.name, status: "running", detail: ev.detail, ts: Date.now(), }, ]); if (isShellTool(ev.name)) { setBashRuns((prev) => { if (prev.some((b) => b.id === ev.id)) return prev; return [ ...prev, { id: ev.id, command: ev.detail || ev.name, status: "running", started_at: Date.now(), }, ]; }); } } else if (ev.type === "tool_end") { setItems((prev) => prev.map((it) => it.kind === "tool" && it.id === ev.id ? { ...it, status: ev.status, detail: ev.detail || it.detail, output: ev.output ?? it.output, exit_code: ev.exit_code ?? it.exit_code, } : it, ), ); setBashRuns((prev) => prev.map((b) => b.id === ev.id ? { ...b, status: ev.status, output: ev.output ?? b.output, exit_code: ev.exit_code ?? b.exit_code, ended_at: Date.now(), } : b, ), ); } else if (ev.type === "consent") { setConsent(ev.req); } else if (ev.type === "question") { setQuestion(ev.req); } else if (ev.type === "todo") { setTodo(ev.text); } else if (ev.type === "status") { setStatus(ev.message); } else if (ev.type === "turn_end") { setBusy(false); setConsent(null); setQuestion(null); void refreshSessions(); void refreshPending(); void getUsage().then(setUsage); void getTodo().then(setTodo); void refreshBash(); setActiveId((id) => { if (id && ev.ok) { void openSession(id).then(setItems); } return id; }); } else if (ev.type === "error") { setBusy(false); setItems((prev) => [ ...prev, { kind: "assistant", id: `err${Date.now()}`, text: `Error: ${ev.message}`, ts: Date.now(), }, ]); } }).then((u) => { un = u; }); return () => un?.(); }, [refreshBash, refreshPending, refreshSessions]); useEffect(() => { let un: (() => void) | undefined; void onMenuEvent((id) => { if (id === "new_session") void onNew(); else if (id === "open_workspace") void onPickWorkspace(); else if (id === "settings") setShowSettings(true); else if (id === "view_plan") setView((v) => (v === "plan" ? "chat" : "plan")); else if (id === "view_bash") setView((v) => (v === "bash" ? "chat" : "bash")); else if (id === "about") setShowSettings(true); }).then((u) => { un = u; }); return () => un?.(); // eslint-disable-next-line react-hooks/exhaustive-deps }, [onPickWorkspace]); const open = async (id: string) => { setActiveId(id); setItems(await openSession(id)); setTodo(await getTodo()); setBashRuns(await listBashRuns()); setConsent(null); setQuestion(null); setView("chat"); }; const onNew = async () => { const s = await newSession(); await refreshSessions(); setActiveId(s.id); setItems([]); setConsent(null); setQuestion(null); setTodo(""); setBashRuns([]); setView("chat"); }; const onAttach = async () => { const paths = await pickFiles(); for (const p of paths) await attachFile(p); await refreshPending(); }; const onSend = async () => { const text = draft.trim(); if ((!text && attachments.length === 0) || busy || consent || question) return; if (sendBlocked) return; setDraft(""); setBusy(true); setView("chat"); setItems((prev) => [ ...prev, { kind: "user", id: `u${Date.now()}`, text: text || "(attachments)", ts: Date.now(), }, ]); if (!activeId) { const s = await newSession(); setActiveId(s.id); await refreshSessions(); } else { setSessions((prev) => prev.map((s) => s.id === activeId && (s.title === "New session" || s.title === "Welcome") ? { ...s, title: (text || "Attachments").slice(0, 48), updated_at: Date.now(), } : s, ), ); } await sendTurn(text || "Please look at the attached files."); }; const models = useMemo(() => { const base = ["deepseek-flash", "deepseek-chat", "deepseek-reasoner"]; if (!base.includes(settings.model)) return [settings.model, ...base]; return base; }, [settings.model]); const slashCommands: SlashCommand[] = useMemo( () => [ { cmd: "/new", desc: "Start a new session", run: () => void onNew() }, { cmd: "/model", desc: "Cycle model", run: () => { const i = models.indexOf(settings.model); const next = models[(i + 1) % models.length]; void applySettings({ ...settings, model: next }); }, }, { cmd: "/approval", desc: "Cycle approval mode", run: () => { const order: Settings["approval"][] = ["ask", "auto", "yolo"]; const i = order.indexOf(settings.approval); void applySettings({ ...settings, approval: order[(i + 1) % order.length], }); }, }, { cmd: "/plan", desc: "Toggle plan panel", run: () => setView((v) => (v === "plan" ? "chat" : "plan")), }, { cmd: "/bash", desc: "Toggle bash panel", run: () => setView((v) => (v === "bash" ? "chat" : "bash")), }, { cmd: "/compact", desc: "Compact session context", run: async () => { const msg = await compactSession(); const hist = activeId ? await openSession(activeId) : []; setItems([ ...hist, { kind: "assistant", id: `c${Date.now()}`, text: msg, ts: Date.now(), }, ]); setTodo(await getTodo()); }, }, { cmd: "/todo", desc: "Refresh todo checklist", run: async () => { const t = await getTodo(); setTodo(t); setView("plan"); }, }, { cmd: "/provider", desc: "Cycle provider", run: async () => { const list = providers.length > 0 ? providers : await listProviders(); if (list.length === 0) return; const i = list.findIndex((p) => p.name === settings.provider); const next = list[(i + 1) % list.length]; await useProvider(next.name, next.model); setProviders(await listProviders()); setSettingsState(await getSettings()); setStatus(`provider · ${next.name}`); }, }, { cmd: "/clear", desc: "Clear pending attachments", run: async () => { await clearPending(); await refreshPending(); }, }, { cmd: "/rename", desc: "Rename current session", run: () => { if (!activeId) return; const cur = sessions.find((s) => s.id === activeId)?.title ?? ""; const title = window.prompt("Rename session", cur); if (title?.trim()) { void renameSession(activeId, title.trim()).then(refreshSessions); } }, }, { cmd: "/export", desc: "Export transcript as Markdown", run: async () => { const md = await exportSession(); await navigator.clipboard?.writeText(md).catch(() => undefined); setItems((prev) => [ ...prev, { kind: "assistant", id: `e${Date.now()}`, text: "Exported Markdown copied to clipboard.", ts: Date.now(), }, ]); }, }, { cmd: "/settings", desc: "Open settings", run: () => setShowSettings(true), }, ], [ activeId, applySettings, models, providers, refreshPending, refreshSessions, sessions, settings, ], ); const active = sessions.find((s) => s.id === activeId); const sessionTitle = displaySessionTitle(active?.title, active?.id); const wsLabel = settings.workspace === "." || !settings.workspace ? "Choose workspace" : settings.workspace.split(/[/\\]/).filter(Boolean).pop() || settings.workspace; const inputBar = consent ? ( { void answerConsent(consent.id, a); setConsent(null); }} /> ) : question ? ( { void answerQuestion(question.id, a); setQuestion(null); }} onSkip={() => { void answerQuestion(question.id, ""); setQuestion(null); }} /> ) : ( void onSend()} onStop={() => void cancelTurn()} onSettingsPatch={(patch) => { void applySettings({ ...settings, ...patch }); }} onAttach={() => void onAttach()} onRemoveAttach={(id) => { void removePending(id).then(refreshPending); }} onQueryFiles={(q) => { void listWorkspaceFiles(q).then(setFileSuggestions); }} onInsert={() => undefined} onView={setView} /> ); /** DSH ConversationContent composerBar */ const composerBar = (
{hero ? : null} {hero ? (
void onPickWorkspace()} /> {engine}
) : null} {inputBar}
); return (
{ e.preventDefault(); setDragging(true); }} onDragOver={(e) => e.preventDefault()} onDragLeave={() => setDragging(false)} onDrop={(e) => { e.preventDefault(); setDragging(false); const files = [...e.dataTransfer.files]; void (async () => { for (const f of files) { const path = (f as File & { path?: string }).path; if (path) await attachFile(path); } await refreshPending(); })(); }} >
void onNew()} onOpen={(id) => void open(id)} onRename={(id, title) => { void renameSession(id, title).then(refreshSessions); }} onDelete={(id) => { void (async () => { await deleteSession(id); await refreshSessions(); if (activeId === id) { setActiveId(null); setItems([]); } })(); }} onPin={(id) => { void pinSession(id).then(refreshSessions); }} onSetFolder={(id, folder) => { void setSessionFolder(id, folder).then(refreshSessions); }} onSettings={() => setShowSettings(true)} onPickWorkspace={() => void onPickWorkspace()} />
{!hero ? ( ) : null}
{!hero ? (
{view === "plan" ? ( setView("chat")} onTogglePlanMode={() => void applySettings({ ...settings, plan_mode: !settings.plan_mode, }) } /> ) : view === "bash" ? ( setView("chat")} /> ) : ( )}
) : null}
{composerBar}
{/* rightbar fixed closed — 0 track */}
{showSettings ? ( { void applySettings(s); }} onUseProvider={(provider, model, key) => { void useProvider(provider, model, key).then(async () => { setProviders(await listProviders()); setSettingsState(await getSettings()); }); }} onClose={() => setShowSettings(false)} /> ) : null}
); }