Files
CST-agent/archive/desktop-tauri/src/App.tsx
T
WHCandCursor 5538ed3412
ci / tcc-check (push) Successful in 1m8s
ci / linux (aarch64, 0) (push) Failing after 1h0m3s
ci / linux (x86, 1) (push) Failing after 1h0m3s
ci / linux (x86_64, 1) (push) Failing after 1h0m2s
ci / windows (push) Failing after 50s
refactor: archive GUI clients; keep minimal TUI with hooks/API
Move Qt/Tauri/full Web SPA under archive/, ship hermit as CLI/TUI plus
libcstcore and cst-webui/1 HTTP+SSE (turn lifecycle hooks, capabilities).

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-30 22:49:23 +08:00

790 lines
23 KiB
TypeScript

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<SessionInfo[]>([]);
const [activeId, setActiveId] = useState<string | null>(null);
const [items, setItems] = useState<ChatItem[]>([]);
const [draft, setDraft] = useState("");
const [busy, setBusy] = useState(false);
const [query, setQuery] = useState("");
const [settings, setSettingsState] = useState<Settings>({
provider: "deepseek",
model: "deepseek-flash",
approval: "auto",
workspace: ".",
plan_mode: false,
reasoning_effort: "high",
font_size: "m",
language: "zh",
});
const [consent, setConsent] = useState<ConsentRequest | null>(null);
const [question, setQuestion] = useState<QuestionRequest | null>(null);
const [showSettings, setShowSettings] = useState(false);
const [status, setStatus] = useState("");
const [todo, setTodo] = useState("");
const [bashRuns, setBashRuns] = useState<BashRun[]>([]);
const [view, setView] = useState<MainView>("chat");
const [attachments, setAttachments] = useState<AttachmentInfo[]>([]);
const [providers, setProviders] = useState<ProviderInfo[]>([]);
const [usage, setUsage] = useState<UsageInfo>({
prompt: 0,
out: 0,
cost: 0,
priced: false,
pct: 0,
});
const [fileSuggestions, setFileSuggestions] = useState<string[]>([]);
const [dragging, setDragging] = useState(false);
const [engine, setEngine] = useState("host");
const convColRef = useRef<HTMLDivElement>(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 ? (
<ApprovalCard
req={consent}
onAnswer={(a) => {
void answerConsent(consent.id, a);
setConsent(null);
}}
/>
) : question ? (
<QuestionCard
req={question}
onAnswer={(a) => {
void answerQuestion(question.id, a);
setQuestion(null);
}}
onSkip={() => {
void answerQuestion(question.id, "");
setQuestion(null);
}}
/>
) : (
<Composer
value={draft}
busy={busy}
settings={settings}
models={models}
attachments={attachments}
usage={usage}
slashCommands={slashCommands}
fileSuggestions={fileSuggestions}
view={view}
hero={hero}
dragging={dragging}
sendBlocked={sendBlocked}
sendBlockedReason={sendBlockedReason}
onChange={setDraft}
onSend={() => 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 = (
<div
className={`${conv.composerStack}${hero ? ` ${conv.composerHero}` : ""}`}
>
{hero ? <Hero mood={agentMood} /> : null}
{hero ? (
<div className={conv.heroWorkspaceRow}>
<WorkspaceChip
workspace={settings.workspace}
onPick={() => void onPickWorkspace()}
/>
<span className="hermit-engine-chip" data-mode={engine}>
{engine}
</span>
</div>
) : null}
{inputBar}
</div>
);
return (
<div
className={`${frame.frame} app-frame-fill`}
style={{
gridTemplateColumns: `${SIDEBAR_WIDTH}px minmax(0, 1fr) 0px`,
}}
data-rightbar-collapsed=""
data-font={settings.font_size || "m"}
onDragEnter={(e) => {
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();
})();
}}
>
<div className={frame.sidebarCol}>
<Rail
sessions={sessions}
activeId={activeId}
workspace={settings.workspace}
query={query}
engine={engine}
onQuery={setQuery}
onNew={() => 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()}
/>
</div>
<div className={frame.centerCol}>
<div className={frame.mainPanel}>
<div
ref={convColRef}
className={`${conv.root} hermit-conversation-body`}
data-phase={hero ? "hero" : "active"}
>
{!hero ? (
<SessionHeader
workspaceLabel={wsLabel}
folder={active?.folder}
title={sessionTitle}
mood={agentMood}
status={status && status !== engine ? status : undefined}
engine={engine}
/>
) : null}
<div className={conv.body}>
<div className={conv.scrollBody} data-conversation-scroll="">
{!hero ? (
<div className={conv.viewArea}>
{view === "plan" ? (
<PlanPanel
text={todo}
planMode={!!settings.plan_mode}
onClose={() => setView("chat")}
onTogglePlanMode={() =>
void applySettings({
...settings,
plan_mode: !settings.plan_mode,
})
}
/>
) : view === "bash" ? (
<BashPanel
runs={bashRuns}
onClose={() => setView("chat")}
/>
) : (
<Transcript items={items} busy={busy} />
)}
</div>
) : null}
<div className={conv.composerSeat} data-composer-seat="">
{composerBar}
</div>
</div>
</div>
</div>
</div>
</div>
{/* rightbar fixed closed — 0 track */}
<div className={frame.rightbarCol} aria-hidden />
{showSettings ? (
<SettingsPanel
settings={settings}
providers={providers}
engine={engine}
onSave={(s) => {
void applySettings(s);
}}
onUseProvider={(provider, model, key) => {
void useProvider(provider, model, key).then(async () => {
setProviders(await listProviders());
setSettingsState(await getSettings());
});
}}
onClose={() => setShowSettings(false)}
/>
) : null}
</div>
);
}