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>
790 lines
23 KiB
TypeScript
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>
|
|
);
|
|
}
|