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> & { 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 (
{menu && (
e.stopPropagation()}> {!s.archived && ( )} {s.folder && ( )}
)}
); } /** 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 (
{ 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); } }} > {canMove && ( )} {menu && (
e.stopPropagation()}>
)}
); } 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(`/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(); 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>(() => 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(); 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(); useEffect(() => { get("/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 ( ); } /** Quick light/dark/auto switcher. */ function ThemeModeButton() { const t = useT(); const mode = useSyncExternalStore(onThemeChange, getThemeMode); const icon = ; const label = t(mode === "auto" ? "settings.themeAuto" : mode === "light" ? "settings.themeLight" : "settings.themeDark"); return ( ); }