- 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>
311 lines
13 KiB
TypeScript
311 lines
13 KiB
TypeScript
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>
|
||
);
|
||
}
|