Files
codecraft/web/src/components/Sidebar.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

311 lines
13 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { Icon } from "./Icon";
import { StatusLeds } from "./StatusLeds";
import { cycleThemeMode, getThemeMode, onThemeChange } from "../theme";
import { useEffect, useMemo, useState, useSyncExternalStore } from "react";
import { get, patch, post } from "../api";
import { promptDialog, toast } from "../dialogs";
import { fmtRelative, useT, type Key } from "../i18n";
import { navigate, openBeside } from "../router";
import { useStore } from "../store";
import type { Session } from "../types";
function basename(p: string) {
return p.split("/").filter(Boolean).pop() ?? p;
}
function groupKey(ms: number): Key {
const day = 86400000;
const startOfToday = new Date().setHours(0, 0, 0, 0);
if (ms >= startOfToday) return "sidebar.today";
if (ms >= startOfToday - day) return "sidebar.yesterday";
if (ms >= startOfToday - 7 * day) return "sidebar.week";
return "sidebar.older";
}
const GROUP_BY = "codecraft.sidebarGroupBy";
const COLLAPSED = "codecraft.sidebarCollapsed";
const organise = (id: string, body: Partial<Pick<Session, "pinned" | "archived">> & { folder?: string }) => patch(`/sessions/${id}`, body).catch((e) => toast(e.message));
/** One session in the list, with a menu to pin, file or archive it. */
function SessionRow({ s, active, attention, busy, folders }: { s: Session; active: boolean; attention: boolean; busy: boolean; folders: string[] }) {
const t = useT();
const [menu, setMenu] = useState(false);
useEffect(() => {
if (!menu) return;
const close = () => setMenu(false);
window.addEventListener("click", close);
window.addEventListener("keydown", close);
return () => {
window.removeEventListener("click", close);
window.removeEventListener("keydown", close);
};
}, [menu]);
const moveTo = async () => {
const name = await promptDialog({
title: t("sidebar.moveTitle"),
message: folders.length ? t("sidebar.moveHintExisting", { folders: folders.join(", ") }) : t("sidebar.moveHint"),
defaultValue: s.folder ?? "",
placeholder: t("sidebar.folderName"),
confirmLabel: t("sidebar.move"),
});
if (name !== null) void organise(s.id, { folder: name });
};
return (
<div className={`session-row ${menu ? "menu-open" : ""}`}>
<button className={`session-item ${active ? "active" : ""}`} onClick={() => navigate(`/s/${s.id}`)}>
<div className="title">
{attention ? <i className="attn-dot" title={t("sidebar.needsInput")} /> : busy && <i className="busy-ring" title={t("activity.working")} />}
<span>{s.title || t("session.untitled")}</span>
</div>
<div className="sub">
{s.shared && <span title={t("sidebar.shared")}><Icon name="share" className="tiny" /> </span>}
{basename(s.cwd)} · {s.agent} · {fmtRelative(s.updated_at)}
</div>
</button>
<button
className="session-more ghost"
aria-label={t("sidebar.organise")}
aria-haspopup="menu"
aria-expanded={menu}
onClick={(e) => {
e.stopPropagation();
setMenu(!menu);
}}
>
<Icon name="more" />
</button>
{menu && (
<div className="session-menu" role="menu" onClick={(e) => e.stopPropagation()}>
{!s.archived && (
<button role="menuitem" onClick={() => (setMenu(false), organise(s.id, { pinned: !s.pinned }))}>
<Icon name="pin" /> {t(s.pinned ? "sidebar.unpin" : "sidebar.pin")}
</button>
)}
<button role="menuitem" className="hide-mobile" onClick={() => (setMenu(false), openBeside(s.id))}>
<Icon name="external" /> {t("sidebar.openBeside")}
</button>
<button role="menuitem" onClick={() => (setMenu(false), void moveTo())}>
<Icon name="folder" /> {t("sidebar.moveTo")}
</button>
{s.folder && (
<button role="menuitem" onClick={() => (setMenu(false), organise(s.id, { folder: "" }))}>
<Icon name="folder-up" /> {t("sidebar.removeFromFolder")}
</button>
)}
<button role="menuitem" onClick={() => (setMenu(false), organise(s.id, { archived: !s.archived }))}>
<Icon name="archive" /> {t(s.archived ? "sidebar.unarchive" : "sidebar.archiveOne")}
</button>
</div>
)}
</div>
);
}
export function Sidebar({ open, activeIds, section }: { open: boolean; activeIds: string[]; section: string }) {
const t = useT();
const sessions = useStore((s) => s.sessions);
const busy = useStore((s) => s.busy);
const permissions = useStore((s) => s.permissions);
const questions = useStore((s) => s.questions);
const me = useStore((s) => s.me);
const update = useStore((s) => s.update);
// Full-text search over titles and messages.
const [query, setQuery] = useState("");
const [hits, setHits] = useState<{ id: string; title: string; cwd: string; agent: string; updated_at: number; snippet: string }[]>();
useEffect(() => {
const q = query.trim();
if (q.length < 2) {
setHits(undefined);
return;
}
let alive = true;
const timer = setTimeout(() => {
get<typeof hits>(`/sessions/search?q=${encodeURIComponent(q)}`)
.then((h) => alive && setHits(h ?? []))
.catch(() => alive && setHits([]));
}, 250);
return () => {
alive = false;
clearTimeout(timer);
};
}, [query]);
const attention = useMemo(() => {
const ids = new Set<string>();
for (const p of permissions) ids.add(p.root_session_id ?? p.session_id);
for (const q of questions) ids.add(q.root_session_id ?? q.session_id);
return ids;
}, [permissions, questions]);
const [groupBy, setGroupBy] = useState<"time" | "project">(() => (localStorage.getItem(GROUP_BY) === "project" ? "project" : "time"));
const [collapsed, setCollapsed] = useState<Set<string>>(() => new Set(JSON.parse(localStorage.getItem(COLLAPSED) ?? '["archive"]')));
const toggle = (id: string) =>
setCollapsed((c) => {
const n = new Set(c);
if (n.has(id)) n.delete(id);
else n.add(id);
localStorage.setItem(COLLAPSED, JSON.stringify([...n]));
return n;
});
const switchGroupBy = () => {
const next = groupBy === "time" ? "project" : "time";
localStorage.setItem(GROUP_BY, next);
setGroupBy(next);
};
const groups = useMemo(() => {
const out: { id: string; label: string; icon?: string; items: Session[] }[] = [];
const add = (id: string, label: string, s: Session, icon?: string) => {
const g = out.find((x) => x.id === id);
if (g) g.items.push(s);
else out.push({ id, label, icon, items: [s] });
};
const live = sessions.filter((s) => !s.archived);
for (const s of live.filter((s) => s.pinned)) add("pinned", t("sidebar.pinned"), s, "pin");
const rest = live.filter((s) => !s.pinned);
const folders = [...new Set(rest.map((s) => s.folder).filter(Boolean) as string[])].sort((a, b) => a.localeCompare(b));
for (const f of folders) for (const s of rest.filter((s) => s.folder === f)) add(`folder:${f}`, f, s, "folder");
const loose = rest.filter((s) => !s.folder);
if (groupBy === "project") {
const byProject = new Map<string, Session[]>();
for (const s of loose) byProject.set(s.cwd, [...(byProject.get(s.cwd) ?? []), s]);
// Projects with the most recent activity first.
for (const [cwd, items] of [...byProject].sort((a, b) => b[1][0].updated_at - a[1][0].updated_at)) for (const s of items) add(`project:${cwd}`, basename(cwd) || "/", s, "folders");
} else {
for (const s of loose) {
const key = groupKey(s.updated_at);
add(key, t(key), s);
}
}
for (const s of sessions.filter((s) => s.archived)) add("archive", t("sidebar.archive"), s, "archive");
return out;
}, [sessions, groupBy, t]);
const folderNames = useMemo(() => [...new Set(sessions.map((s) => s.folder).filter(Boolean) as string[])].sort(), [sessions]);
return (
<aside className={`sidebar ${open ? "open" : ""}`}>
<div className="sidebar-head">
<div className="logo">
<img src="/icon.svg" alt="" />
CodeCraft
</div>
<StatusLeds />
</div>
<div style={{ padding: "0 12px 8px" }}>
<button className="primary" style={{ width: "100%" }} onClick={() => navigate("/new")}>
+ {t("sidebar.newSession")}
</button>
</div>
<div className="session-search">
<input
type="search"
value={query}
placeholder={t("sidebar.search")}
aria-label={t("sidebar.search")}
onChange={(e) => setQuery(e.target.value)}
onKeyDown={(e) => e.key === "Escape" && setQuery("")}
/>
</div>
<nav className="session-list">
{hits && (
<>
<div className="session-group">{t("sidebar.searchResults", { n: hits.length })}</div>
{hits.length === 0 && <div className="empty">{t("sidebar.noResults")}</div>}
{hits.map((h) => (
<button key={h.id} className={`session-item ${activeIds.includes(h.id) ? "active" : ""}`} onClick={() => navigate(`/s/${h.id}`)}>
<div className="title">
<span>{h.title || t("session.untitled")}</span>
</div>
{h.snippet && <div className="sub snippet">{h.snippet}</div>}
<div className="sub">
{basename(h.cwd)} · {h.agent} · {fmtRelative(h.updated_at)}
</div>
</button>
))}
</>
)}
{!hits && sessions.length === 0 && <div className="empty">{t("sidebar.noSessions")}</div>}
{!hits && sessions.length > 0 && (
<div className="session-view">
<button className="ghost small" onClick={switchGroupBy} title={t("sidebar.groupByHint")}>
<Icon name={groupBy === "time" ? "clock" : "folders"} className="tiny" />
{t(groupBy === "time" ? "sidebar.byTime" : "sidebar.byProject")}
</button>
</div>
)}
{!hits &&
groups.map((g) => (
<div key={g.id}>
<button className="session-group" onClick={() => toggle(g.id)} aria-expanded={!collapsed.has(g.id)}>
<Icon name={collapsed.has(g.id) ? "chevron-right" : "chevron-down"} className="tiny" />
{g.icon && <Icon name={g.icon} className="tiny" />}
<span className="grow">{g.label}</span>
{collapsed.has(g.id) && <span className="count">{g.items.length}</span>}
</button>
{!collapsed.has(g.id) &&
g.items.map((s) => (
<SessionRow key={s.id} s={s} active={activeIds.includes(s.id)} attention={attention.has(s.id)} busy={!!busy[s.id]} folders={folderNames} />
))}
</div>
))}
</nav>
{update && (
<a className="update-chip" href={update.url} target="_blank" rel="noreferrer" title={update.notes}>
<Icon name="sparkle" /> {t("update.available", { version: update.version })}
</a>
)}
<div className="sidebar-user">
<span title={me?.username}>
{me?.display_name || me?.username}
{me?.role === "admin" && <span className="chip" style={{ marginLeft: 6 }}>admin</span>}
</span>
<ThemeModeButton />
<button className="small ghost" onClick={() => post("/auth/logout").then(() => location.reload())}>
<Icon name="logout" className="nav-icon" />
{t("nav.logout")}
</button>
</div>
<div className="sidebar-foot">
<button className={`ghost ${section === "files" ? "active" : ""}`} onClick={() => navigate("/files")}>
<Icon name="folder" className="nav-icon" />
{t("nav.files")}
</button>
<button className={`ghost ${section === "images" ? "active" : ""}`} onClick={() => navigate("/images")}>
<Icon name="image" className="nav-icon" />
{t("nav.images")}
</button>
<button className={`ghost ${section === "terminal" ? "active" : ""}`} onClick={() => navigate("/terminal")}>
<Icon name="terminal" className="nav-icon" />
{t("nav.terminal")}
</button>
<button className={`ghost ${section === "jobs" ? "active" : ""}`} onClick={() => navigate("/jobs")}>
<Icon name="clock" className="nav-icon" />
{t("nav.jobs")}
</button>
<button className={`ghost ${section === "stats" ? "active" : ""}`} onClick={() => navigate("/stats")}>
<Icon name="chart" className="nav-icon" />
{t("nav.stats")}
</button>
<button className={`ghost ${section === "settings" ? "active" : ""}`} onClick={() => navigate("/settings")}>
<Icon name="wrench" className="nav-icon" />
{t("nav.settings")}
</button>
</div>
</aside>
);
}
/** Quick light/dark/auto switcher. */
function ThemeModeButton() {
const t = useT();
const mode = useSyncExternalStore(onThemeChange, getThemeMode);
const icon = <Icon name={mode === "auto" ? "theme-auto" : mode === "light" ? "sun" : "moon"} />;
const label = t(mode === "auto" ? "settings.themeAuto" : mode === "light" ? "settings.themeLight" : "settings.themeDark");
return (
<button className="small ghost theme-toggle" onClick={cycleThemeMode} title={`${t("settings.themeToggle")} – ${label}`} aria-label={t("settings.themeToggle")}>
{icon}
</button>
);
}