- Files in chat: preview with type/size badges and file name; attachment chips - Menu and settings tabs with icons - Heatmap: distinct colour for empty hours, hourly labels - Context chip shows used/total tokens (e.g. 221k/240k) plus percent - LSP client: diagnostics appended to edit/write results and a diagnostics tool (typescript-language-server, pyright, rust-analyzer, gopls, clangd; sandboxed) - Compaction summarizes a plain transcript and retries once on empty output - files/stat endpoint Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
132 lines
5.3 KiB
TypeScript
132 lines
5.3 KiB
TypeScript
import { cycleThemeMode, getThemeMode, onThemeChange } from "../theme";
|
||
import { useMemo, useSyncExternalStore } from "react";
|
||
import { post } from "../api";
|
||
import { fmtRelative, useT, type Key } from "../i18n";
|
||
import { navigate } 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";
|
||
}
|
||
|
||
export function Sidebar({ open, activeId, section }: { open: boolean; activeId?: string; section: string }) {
|
||
const t = useT();
|
||
const sessions = useStore((s) => s.sessions);
|
||
const busy = useStore((s) => s.busy);
|
||
const connected = useStore((s) => s.connected);
|
||
const permissions = useStore((s) => s.permissions);
|
||
const questions = useStore((s) => s.questions);
|
||
const me = useStore((s) => s.me);
|
||
|
||
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 groups = useMemo(() => {
|
||
const out: { key: Key; items: Session[] }[] = [];
|
||
for (const s of sessions) {
|
||
const key = groupKey(s.updated_at);
|
||
const g = out.find((x) => x.key === key);
|
||
if (g) g.items.push(s);
|
||
else out.push({ key, items: [s] });
|
||
}
|
||
return out;
|
||
}, [sessions]);
|
||
|
||
return (
|
||
<aside className={`sidebar ${open ? "open" : ""}`}>
|
||
<div className="sidebar-head">
|
||
<div className="logo">
|
||
<img src="/icon.svg" alt="" />
|
||
CodeCraft
|
||
</div>
|
||
<span className={`conn-dot ${connected ? "on" : ""}`} title={connected ? t("sidebar.connected") : t("sidebar.disconnected")} />
|
||
</div>
|
||
<div style={{ padding: "0 12px 8px" }}>
|
||
<button className="primary" style={{ width: "100%" }} onClick={() => navigate("/new")}>
|
||
+ {t("sidebar.newSession")}
|
||
</button>
|
||
</div>
|
||
<nav className="session-list">
|
||
{sessions.length === 0 && <div className="empty">{t("sidebar.noSessions")}</div>}
|
||
{groups.map((g) => (
|
||
<div key={g.key}>
|
||
<div className="session-group">{t(g.key)}</div>
|
||
{g.items.map((s) => (
|
||
<button
|
||
key={s.id}
|
||
className={`session-item ${s.id === activeId ? "active" : ""}`}
|
||
onClick={() => navigate(`/s/${s.id}`)}
|
||
>
|
||
<div className="title">
|
||
{attention.has(s.id) ? <i className="attn-dot" title={t("sidebar.needsInput")} /> : busy[s.id] && <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")}>⇄ </span>}
|
||
{basename(s.cwd)} · {s.agent} · {fmtRelative(s.updated_at)}
|
||
</div>
|
||
</button>
|
||
))}
|
||
</div>
|
||
))}
|
||
</nav>
|
||
<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())}>
|
||
<span className="nav-icon" aria-hidden>🚪</span>
|
||
{t("nav.logout")}
|
||
</button>
|
||
</div>
|
||
<div className="sidebar-foot">
|
||
<button className={`ghost ${section === "files" ? "active" : ""}`} onClick={() => navigate("/files")}>
|
||
<span className="nav-icon" aria-hidden>📁</span>
|
||
{t("nav.files")}
|
||
</button>
|
||
<button className={`ghost ${section === "images" ? "active" : ""}`} onClick={() => navigate("/images")}>
|
||
<span className="nav-icon" aria-hidden>🖼️</span>
|
||
{t("nav.images")}
|
||
</button>
|
||
<button className={`ghost ${section === "stats" ? "active" : ""}`} onClick={() => navigate("/stats")}>
|
||
<span className="nav-icon" aria-hidden>📊</span>
|
||
{t("nav.stats")}
|
||
</button>
|
||
<button className={`ghost ${section === "settings" ? "active" : ""}`} onClick={() => navigate("/settings")}>
|
||
<span className="nav-icon" aria-hidden>🔧</span>
|
||
{t("nav.settings")}
|
||
</button>
|
||
</div>
|
||
</aside>
|
||
);
|
||
}
|
||
|
||
/** Quick light/dark/auto switcher. */
|
||
function ThemeModeButton() {
|
||
const t = useT();
|
||
const mode = useSyncExternalStore(onThemeChange, getThemeMode);
|
||
const icon = mode === "auto" ? "◐" : mode === "light" ? "☀" : "☾";
|
||
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>
|
||
);
|
||
}
|