Files
codecraft/web/src/App.tsx
T
Richard ZinkandClaude Opus 5.5 d152ad836d Split view, personal modes, budget warnings, server move script
- Up to three chats side by side (#/s/a+b+c), "Open beside" in the sidebar menu
- File manager tabs of a chat stored per user on the server (/sessions/{id}/tabs)
- Personal modes for every user (Settings → My modes, /account/modes)
- Budget meters (total, provider, images, opencode Go windows) with push warnings
  for admins at the warning share and 100 %, shown in the status overlay
- scripts/move-server.sh: move to another Docker host with bind mounts (Unraid);
  scripts/smoke-test.sh

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-09 12:06:20 +01:00

125 lines
4.6 KiB
TypeScript

import { lazy, Suspense, useEffect, useState } from "react";
import { connectEvents, get, setUnauthorizedHandler } from "./api";
import { useT } from "./i18n";
import { navigate, useRoute, closePane, MAX_PANES } 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";
import { WhatsNew } from "./components/WhatsNew";
import { ShareTarget } from "./components/ShareTarget";
import { Palette } from "./components/Palette";
import { pendingShare } from "./share";
// 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);
const [shared, setShared] = useState(pendingShare);
useTitleActivity();
const refresh = async () => {
try {
const i = await get<Info>("/info");
setState(() => ({ me: i.user, update: i.update ?? null }));
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;
const chatIds = section === "s" && arg ? arg.split("+").filter(Boolean).slice(0, MAX_PANES) : [];
if (chatIds.length === 1) content = <ChatView key={chatIds[0]} sessionId={chatIds[0]} onMenu={() => setMenuOpen(true)} />;
else if (chatIds.length > 1)
content = (
<div className="split-view">
{chatIds.map((id) => (
<div key={id} className="split-pane">
<ChatView sessionId={id} onMenu={() => setMenuOpen(true)} onClosePane={() => closePane(id)} />
</div>
))}
</div>
);
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} activeIds={chatIds} section={section ?? ""} />
<main className="main">{content}</main>
<WhatsNew version={info.version} />
<Palette />
{shared && <ShareTarget shared={shared} onClose={() => setShared(null)} />}
<DialogHost />
</div>
);
}