- Personal API tokens (full/read scope, expiry, revoke) and CLI password login - Cargo workspace: codecraft-core (types, providers, permission rules) shared by server and the upcoming terminal client; server re-exports the old paths - CLI server API: /cli/config, /cli/skill, /cli/tool (server MCP tools), /llm/stream model gateway with budgets and usage per user - Theme/language preferences mirrored to the server; personal memory endpoint - Statistics: provider and mode tables stacked; CLI usage labelled - cli/: work in progress (terminal client is being rewritten as a local TUI) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
106 lines
3.8 KiB
TypeScript
106 lines
3.8 KiB
TypeScript
import { lazy, Suspense, useEffect, useState } from "react";
|
|
import { connectEvents, get, setUnauthorizedHandler } from "./api";
|
|
import { useT } from "./i18n";
|
|
import { navigate, useRoute } from "./router";
|
|
import { applyEvent, loadSessions, resync, setState } from "./store";
|
|
import type { Info } from "./types";
|
|
import { Sidebar } from "./components/Sidebar";
|
|
import { Login } from "./components/Login";
|
|
import { ChatView } from "./components/ChatView";
|
|
import { NewSession } from "./components/NewSession";
|
|
import { Settings } from "./components/Settings";
|
|
import { Stats } from "./components/Stats";
|
|
import { Images } from "./components/Images";
|
|
import { Jobs } from "./components/Jobs";
|
|
import { Spinner, useTitleActivity } from "./components/Activity";
|
|
import { DialogHost } from "./dialogs";
|
|
import { startPrefsSync } from "./prefsSync";
|
|
|
|
// The file manager (editor, git) is loaded on demand to keep the chat small.
|
|
const Files = lazy(() => import("./components/Files").then((m) => ({ default: m.Files })));
|
|
const TerminalPage = lazy(() => import("./components/Terminal").then((m) => ({ default: m.TerminalPage })));
|
|
|
|
export function App() {
|
|
const t = useT();
|
|
const route = useRoute();
|
|
const [info, setInfo] = useState<Info>();
|
|
const [error, setError] = useState<string>();
|
|
const [menuOpen, setMenuOpen] = useState(false);
|
|
useTitleActivity();
|
|
|
|
const refresh = async () => {
|
|
try {
|
|
const i = await get<Info>("/info");
|
|
setState(() => ({ me: i.user }));
|
|
if (i.user) {
|
|
await loadSessions();
|
|
void startPrefsSync();
|
|
}
|
|
setInfo(i);
|
|
} catch (e: any) {
|
|
setError(e.message);
|
|
}
|
|
};
|
|
|
|
useEffect(() => {
|
|
setUnauthorizedHandler(() => setInfo((i) => (i ? { ...i, user: null } : i)));
|
|
void refresh();
|
|
}, []);
|
|
|
|
const signedIn = !!info?.user;
|
|
useEffect(() => {
|
|
if (!signedIn) return;
|
|
return connectEvents({
|
|
onEvent: applyEvent,
|
|
onOpen: () => {
|
|
setState(() => ({ connected: true }));
|
|
void resync().catch(() => undefined);
|
|
},
|
|
onClose: () => setState(() => ({ connected: false })),
|
|
});
|
|
}, [signedIn]);
|
|
|
|
// Close the mobile menu on navigation.
|
|
useEffect(() => setMenuOpen(false), [route.join("/")]);
|
|
|
|
if (error) return <div className="empty">{error}</div>;
|
|
if (!info) return <div className="empty">{t("common.loading")}</div>;
|
|
if (!info.user)
|
|
return (
|
|
<>
|
|
<Login info={info} onSuccess={refresh} />
|
|
<DialogHost />
|
|
</>
|
|
);
|
|
|
|
const [section, arg] = route;
|
|
let content;
|
|
if (section === "s" && arg) content = <ChatView key={arg} sessionId={arg} onMenu={() => setMenuOpen(true)} />;
|
|
else if (section === "settings") content = <Settings tab={arg} onMenu={() => setMenuOpen(true)} />;
|
|
else if (section === "stats") content = <Stats onMenu={() => setMenuOpen(true)} />;
|
|
else if (section === "images") content = <Images onMenu={() => setMenuOpen(true)} />;
|
|
else if (section === "jobs") content = <Jobs onMenu={() => setMenuOpen(true)} />;
|
|
else if (section === "terminal")
|
|
content = (
|
|
<Suspense fallback={<div className="empty"><Spinner /></div>}>
|
|
<TerminalPage id={arg} onMenu={() => setMenuOpen(true)} />
|
|
</Suspense>
|
|
);
|
|
else if (section === "files")
|
|
content = (
|
|
<Suspense fallback={<div className="empty"><Spinner /></div>}>
|
|
<Files onMenu={() => setMenuOpen(true)} />
|
|
</Suspense>
|
|
);
|
|
else content = <NewSession onMenu={() => setMenuOpen(true)} onCreated={(id) => navigate(`/s/${id}`)} />;
|
|
|
|
return (
|
|
<div className="app">
|
|
{menuOpen && <div className="backdrop" onClick={() => setMenuOpen(false)} />}
|
|
<Sidebar open={menuOpen} activeId={section === "s" ? arg : undefined} section={section ?? ""} />
|
|
<main className="main">{content}</main>
|
|
<DialogHost />
|
|
</div>
|
|
);
|
|
}
|