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>
This commit is contained in:
@@ -0,0 +1,789 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user