Files
codecraft/web/src/components/Sidebar.tsx
T
Richard ZinkandClaude Sonnet 5.5 ce0d8afe69 Chat bubbles, icons for chats, more vivid and distinct colour tags
- Bubbles: own messages right-aligned in their own colour, agent answers left.
- Chat icons (22, validated on the server), shown in list, top bar, project page.
- Colour tags: eight fixed hues (new: pink) with theme-matched lightness and the
  highest chroma sRGB allows; stronger background tint.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-14 23:31:52 +01:00

509 lines
21 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 { ColorSwatches, IconPicker } from "./ColorSwatches";
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, put } 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; color?: string; icon?: 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" : ""}`} data-color={s.color} 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 className={s.color ? "color-badge" : undefined}>
{s.icon && <Icon name={s.icon} className="tiny chat-icon" />}
{s.title || t("session.untitled")}
</span>
</div>
{s.summary && <div className="summary" title={s.summary}>{s.summary}</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>
)}
<ColorSwatches value={s.color} onChange={(color) => (setMenu(false), organise(s.id, { color }))} />
<IconPicker value={s.icon} color={s.color} onChange={(icon) => (setMenu(false), organise(s.id, { icon }))} />
<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>
);
}
/** Order and pinning of folders and projects in the list (per user, all devices). */
interface Layout {
order: string[];
pinned: string[];
}
/** Folders and projects can be moved and pinned; time groups and the archive cannot. */
const movable = (id: string) => id.startsWith("folder:") || id.startsWith("project:");
const DRAG_TYPE = "application/x-codecraft-group";
/** Header of a group: collapses it; folders and projects also have a menu and can be dragged. */
function GroupHeader({
g,
collapsed,
pinned,
onToggle,
onPin,
onMove,
onDrop,
}: {
g: { id: string; label: string; icon?: string; items: Session[] };
collapsed: boolean;
pinned: boolean;
onToggle: () => void;
onPin?: () => void;
onMove?: (dir: -1 | 1) => void;
onDrop?: (dragged: string, after: boolean) => void;
}) {
const t = useT();
const [menu, setMenu] = useState(false);
const [over, setOver] = useState<"before" | "after">();
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 canMove = !!onMove;
return (
<div
className={`session-row group-row ${menu ? "menu-open" : ""} ${over ? `drop-${over}` : ""}`}
draggable={canMove}
onDragStart={(e) => {
if (!canMove) return;
e.dataTransfer.setData(DRAG_TYPE, g.id);
e.dataTransfer.effectAllowed = "move";
}}
onDragOver={(e) => {
if (!onDrop || !e.dataTransfer.types.includes(DRAG_TYPE)) return;
e.preventDefault();
const r = e.currentTarget.getBoundingClientRect();
setOver(e.clientY > r.top + r.height / 2 ? "after" : "before");
}}
onDragLeave={() => setOver(undefined)}
onDrop={(e) => {
const id = e.dataTransfer.getData(DRAG_TYPE);
const after = over === "after";
setOver(undefined);
if (id && id !== g.id && onDrop) {
e.preventDefault();
onDrop(id, after);
}
}}
>
<button className="session-group" onClick={onToggle} aria-expanded={!collapsed}>
<Icon name={collapsed ? "chevron-right" : "chevron-down"} className="tiny" />
{g.icon && <Icon name={g.icon} className="tiny" />}
<span className="grow">{g.label}</span>
{pinned && <Icon name="pin" className="tiny group-pin" />}
{collapsed && <span className="count">{g.items.length}</span>}
</button>
{canMove && (
<button
className="session-more ghost"
aria-label={t("sidebar.groupMenu")}
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()}>
{g.id.startsWith("project:") && (
<button role="menuitem" onClick={() => (setMenu(false), navigate(`/p/${encodeURIComponent(g.id.slice("project:".length))}`))}>
<Icon name="clock" /> {t("project.open")}
</button>
)}
<button role="menuitem" onClick={() => (setMenu(false), onPin?.())}>
<Icon name="pin" /> {t(pinned ? "sidebar.groupUnpin" : "sidebar.groupPin")}
</button>
<button role="menuitem" onClick={() => (setMenu(false), onMove?.(-1))}>
<Icon name="arrow-up" /> {t("sidebar.groupUp")}
</button>
<button role="menuitem" onClick={() => (setMenu(false), onMove?.(1))}>
<Icon name="arrow-down" /> {t("sidebar.groupDown")}
</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[])];
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]);
// Order of the projects: see `ordered` below (not by activity – a new message
// moves the chat to the top of its project, not the project).
for (const [cwd, items] of byProject) 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]);
// Folders and projects: pinned first, then in the user's order. Groups not ordered yet
// (new folder or project) start at the top and are then kept where they are.
const [layout, setLayout] = useState<Layout>();
useEffect(() => {
get<Layout>("/account/sidebar")
.then((l) => setLayout({ order: l.order ?? [], pinned: l.pinned ?? [] }))
.catch(() => setLayout({ order: [], pinned: [] }));
}, []);
const ordered = useMemo(() => {
const l = layout ?? { order: [], pinned: [] };
const head = groups.filter((g) => g.id === "pinned");
const tail = groups.filter((g) => g.id !== "pinned" && !movable(g.id));
const rank = (id: string) => l.order.indexOf(id);
const mid = groups
.filter((g) => movable(g.id))
.sort((a, b) => {
const pa = l.pinned.includes(a.id);
const pb = l.pinned.includes(b.id);
if (pa !== pb) return pa ? -1 : 1;
const ra = rank(a.id);
const rb = rank(b.id);
if (ra < 0 && rb < 0) return b.items[0].updated_at - a.items[0].updated_at;
if (ra < 0 || rb < 0) return ra < 0 ? -1 : 1;
return ra - rb;
});
return { head, mid, tail };
}, [groups, layout]);
const saveLayout = (next: Layout) => {
// Forget groups that no longer exist (no chat in that folder or project).
const known = new Set(sessions.flatMap((s) => [`project:${s.cwd}`, ...(s.folder ? [`folder:${s.folder}`] : [])]));
const clean = { order: next.order.filter((id) => known.has(id)), pinned: next.pinned.filter((id) => known.has(id)) };
setLayout(clean);
void put("/account/sidebar", clean).catch((e) => toast(e.message));
};
// Freeze the position of groups that appeared since the last visit.
useEffect(() => {
if (!layout) return;
const fresh = ordered.mid.map((g) => g.id).filter((id) => !layout.order.includes(id));
if (fresh.length) saveLayout({ ...layout, order: [...fresh, ...layout.order] });
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [ordered, layout]);
/** Order of the shown groups with `id` moved: by a step, or next to another group. */
const reorder = (ids: string[]) => {
if (!layout) return;
const shown = new Set(ids);
saveLayout({ ...layout, order: [...ids, ...layout.order.filter((id) => !shown.has(id))] });
};
const moveGroup = (id: string, dir: -1 | 1) => {
if (!layout) return;
const ids = ordered.mid.map((g) => g.id);
const i = ids.indexOf(id);
const j = i + dir;
// Only within the pinned or the unpinned part.
if (j < 0 || j >= ids.length || layout.pinned.includes(ids[j]) !== layout.pinned.includes(id)) return;
[ids[i], ids[j]] = [ids[j], ids[i]];
reorder(ids);
};
const dropGroup = (dragged: string, target: string, after: boolean) => {
if (!layout || !movable(dragged)) return;
const ids = ordered.mid.map((g) => g.id).filter((id) => id !== dragged);
const at = ids.indexOf(target);
if (at < 0) return;
ids.splice(after ? at + 1 : at, 0, dragged);
// Dropped among pinned groups: pinned too (and the other way round).
const pinTarget = layout.pinned.includes(target);
const pinned = pinTarget ? [...new Set([...layout.pinned, dragged])] : layout.pinned.filter((p) => p !== dragged);
const shown = new Set(ids);
saveLayout({ pinned, order: [...ids, ...layout.order.filter((id) => !shown.has(id))] });
};
const pinGroup = (id: string) => {
if (!layout) return;
const pinned = layout.pinned.includes(id) ? layout.pinned.filter((p) => p !== id) : [...layout.pinned, id];
saveLayout({ ...layout, pinned });
};
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 &&
[...ordered.head, ...ordered.mid, ...ordered.tail].map((g) => (
<div key={g.id}>
<GroupHeader
g={g}
collapsed={collapsed.has(g.id)}
pinned={!!layout?.pinned.includes(g.id)}
onToggle={() => toggle(g.id)}
onPin={movable(g.id) ? () => pinGroup(g.id) : undefined}
onMove={movable(g.id) ? (dir) => moveGroup(g.id, dir) : undefined}
onDrop={movable(g.id) ? (dragged, after) => dropGroup(dragged, g.id, after) : undefined}
/>
{!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>
);
}