/** * @file WorkspaceTree — the sidebar's Workspaces section, shaped like Claude * Desktop's project list: every workspace (lane) on one line in the stored drag * order (`laneOrderStorage.ts`), each expanding to ITS sessions * (`WorkspaceSessionList.tsx`) — no repo grouping, no worktree rows. Picking a * workspace — or `+` on its row — opens its home (`/workspace?lane=`); a * live tab is opened from its session row. Live status comes from the shared * lane list (`useLanes`), live panes from `GET /api/run`, re-read whenever a * lane's status or session moves (at most every 3 s) and handed over by the * Workspace page's own poll (`LIVE_RUNS_EVENT`). Owns its own Add-workspace and * destructive modals (lazy-loaded on first open) so the row menu works from any page. Which rows are * expanded lives in `projectTreePrefs.ts` (`expanded`). Server mode, admin or * analyst: EVERY workspace, grouped under its owner (their avatar — * `owner_avatar_at`, `UserAvatar` — and `owner_display_name`, else `owner_name`; yours * first, no owner last, groups foldable — `collapsedOwners`); one this user * may not change (`canEditLane`) is read only — no menu, no new session, and a * session opens its analysis page (`/sessions/`), never a pane. * The user's own GROUPS (`lib/workspaceGroups.ts`, kept per account on the * server like the other preferences) arrange the list under foldable headers — * each group in its order, then "Ungrouped" — inside each owner group when * there are owner groups. A workspace joins a group from its menu, by a drop * on the group's header, by a drag that lands it below a row of that * group, or by Alt+↑/↓ past the group's edge; a header is dragged onto * another to reorder the groups, and its menu renames, moves, folds every * group, adds a new workspace straight into it or deletes it (its * workspaces stay, ungrouped). A folded header still shows the worst state * of its workspaces (needs you, error, running) and marks the selected one. * @author Nguyễn Ngọc Trí Vĩ */ import { lazy, Suspense, useCallback, useEffect, useMemo, useRef, useState, type DragEvent, } from "react"; import { useTranslation } from "react-i18next"; import { useLocation, useNavigate } from "react-router-dom"; import { ChevronDown, ChevronRight, FolderPlus, MoreHorizontal, Plus, Search } from "lucide-react"; import { api, type CwdSuggestion, type RunHandle } from "../lib/api"; import type { Lane } from "../lib/types"; import { useLanes } from "../hooks/useLanes"; import { useAuth, usePermissions } from "./AuthGate"; import { useAiTools } from "../hooks/useAiTools"; import { useThrottledValue } from "../hooks/useThrottledValue"; import { insertionIndex, moveBefore, moveLane, readLaneOrder, sortByStoredOrder, writeLaneOrder, } from "../lib/laneOrderStorage"; import { useProjectTreePrefs, writeProjectTreePrefs } from "../lib/projectTreePrefs"; import { arrangeByGroup, assignGroup, createGroup, deleteGroup, groupOf, moveGroup, moveGroupBefore, pruneWorkspaceGroups, renameGroup, setAllFolded, setGroupFolded, useWorkspaceGroups, type WorkspaceGroup, } from "../lib/workspaceGroups"; import { confirmDialog } from "../lib/confirm"; import { ContextMenu, MenuItem, MenuSeparator } from "./ContextMenu"; import { LANE_DRAG_TYPE, WorkspaceRow, isLaneDrag, workspaceDot, workspaceDotLabel, workspaceName, } from "./lanes/WorkspaceRow"; import { WorkspaceSessionList } from "./lanes/WorkspaceSessionList"; import { WorkspaceRowMenu, type WorkspaceMenuAction } from "./lanes/WorkspaceRowMenu"; import { UserAvatar } from "./users/UserAvatar"; // The sidebar is in the entry chunk; its two modals (and the run form the add // modal pulls in) load when first opened. const AddLaneModal = lazy(() => import("./lanes/AddLaneModal").then((m) => ({ default: m.AddLaneModal })) ); const DestructiveLaneModal = lazy(() => import("./lanes/DestructiveLaneModal").then((m) => ({ default: m.DestructiveLaneModal })) ); /** The workspace (and attached run) the Workspace page shows, from its URL. */ /** The workspace, pane run and replayed session the URL names (Workspace page only). */ /** An owner as the tree shows it: their display name, else their username. */ const ownerShown = (l: Lane): string => l.owner_display_name || l.owner_name || ""; export function selectedFrom(pathname: string, search: string) { if (!pathname.startsWith("/workspace")) return { lane: null, run: null, session: null }; const p = new URLSearchParams(search); const v = p.get("lane"); return { lane: v === null ? null : Number(v), run: p.get("run"), session: p.get("session") }; } /** The Workspace page's polled run list, handed to the sidebar as it lands. */ export const LIVE_RUNS_EVENT = "agenthub:live-runs"; export function WorkspaceTree() { const { t } = useTranslation("lanes"); const navigate = useNavigate(); const location = useLocation(); const selected = selectedFrom(location.pathname, location.search); const { lanes, refresh: refreshLanes } = useLanes(); const { state: auth } = useAuth(); const { seesAll, canEditLane } = usePermissions(); const meId = auth.mode === "server" ? (auth.user?.id ?? null) : null; // Server mode staff see every user's workspaces, so the list says whose. const grouped = auth.mode === "server" && seesAll; const prefs = useProjectTreePrefs(); const [liveRuns, setLiveRuns] = useState([]); const [query, setQuery] = useState(""); const [searching, setSearching] = useState(false); const [error, setError] = useState(null); const [menu, setMenu] = useState<{ lane: Lane; x: number; y: number } | null>(null); const [renamingId, setRenamingId] = useState(null); const [destructive, setDestructive] = useState<{ lane: Lane; action: "reset" | "remove" } | null>( null ); const [addOpen, setAddOpen] = useState(false); const [addPrefill, setAddPrefill] = useState<{ sourceRepo?: string } | null>(null); // "New workspace in this group": the group the added workspace joins. const [addToGroup, setAddToGroup] = useState(null); const [cwdSuggestions, setCwdSuggestions] = useState([]); const [orderTick, setOrderTick] = useState(0); const groups = useWorkspaceGroups(); const hasGroups = groups.groups.length > 0; const [renamingGroup, setRenamingGroup] = useState(null); const [groupMenu, setGroupMenu] = useState<{ group: WorkspaceGroup; x: number; y: number; } | null>(null); // A removed workspace leaves the stored groups (they hold ids only). const laneIdsKey = lanes.map((l) => l.id).join(","); useEffect(() => { if (laneIdsKey) pruneWorkspaceGroups(laneIdsKey.split(",").map(Number)); }, [laneIdsKey]); // A slot-0 pane starts, ends or learns its session id exactly when a lane's // status or session moves, so that is when the live-pane list is re-read; // the Workspace page also hands over every run list it polls (a tab in // slots 1–3 moves no lane column). Throttled: with two sessions in one // workspace these columns flip on every hook, and each flip was a GET. const runKey = useThrottledValue( lanes.map((l) => `${l.id}:${l.status}:${l.session_id}:${l.run_id}`).join(","), 3000 ); useEffect(() => { let live = true; api.run .list() .then((r) => live && setLiveRuns(r.items)) .catch(() => undefined); return () => { live = false; }; }, [runKey]); useEffect(() => { const take = (e: Event) => setLiveRuns((e as CustomEvent).detail); window.addEventListener(LIVE_RUNS_EVENT, take); return () => window.removeEventListener(LIVE_RUNS_EVENT, take); }, []); const openAdd = (sourceRepo?: string, groupId: string | null = null) => { setAddPrefill(sourceRepo ? { sourceRepo } : null); setAddToGroup(groupId); setAddOpen(true); api.run .cwds() .then((r) => setCwdSuggestions(r.items)) .catch(() => undefined); }; const select = useCallback((id: number) => navigate(`/workspace?lane=${id}`), [navigate]); const expanded = (id: number) => !!prefs.expanded[id] || !!query.trim(); const setExpanded = (id: number, open: boolean) => writeProjectTreePrefs({ expanded: { ...prefs.expanded, [id]: open } }); const order = useMemo(() => readLaneOrder(), [orderTick]); // eslint-disable-line react-hooks/exhaustive-deps // The tool filter, offered once workspaces of two tools exist; a stored // tool no workspace has any more filters nothing. const aiTools = useAiTools(); const tools = useMemo(() => [...new Set(lanes.map((l) => l.tool))], [lanes]); const toolFilter = tools.length > 1 && prefs.tool && tools.includes(prefs.tool) ? prefs.tool : null; const sorted = useMemo(() => { const list = arrangeByGroup( sortByStoredOrder(lanes, order).filter((l) => !toolFilter || l.tool === toolFilter), groups ); if (!grouped) return list; // Stable: each owner's workspaces keep the stored order inside the group. const rank = (l: Lane) => (l.owner_id == null ? 2 : l.owner_id === meId ? 0 : 1); return [...list].sort( (a, b) => rank(a) - rank(b) || ownerShown(a).localeCompare(ownerShown(b)) ); }, [lanes, order, toolFilter, grouped, meId, groups]); const q = query.trim().toLowerCase(); const nameHit = (l: Lane) => workspaceName(l).toLowerCase().includes(q) || l.cwd.toLowerCase().includes(q); // A search also finds a workspace by a session's name: every workspace's // list stays mounted (hidden) to load and count its matches. const [hits, setHits] = useState>({}); const onMatches = useCallback( (id: number, n: number) => setHits((h) => (h[id] === n ? h : { ...h, [id]: n })), [] ); // A cleared search unmounts the collapsed lists; their old counts must not // surface workspaces in the next search before its fetch answers. useEffect(() => { if (!q) setHits({}); }, [q]); const visible = (l: Lane) => !q || nameHit(l) || (hits[l.id] ?? 0) > 0; const reorder = (move: (ids: number[]) => number[]) => { const ids = sorted.map((l) => l.id); const moved = move(ids); if (moved === ids) return; // Under a tool filter the shown ids go back into the slots they held in // the full order, so the hidden workspaces keep their places. const shownIds = new Set(ids); let k = 0; const full = sortByStoredOrder(lanes, order).map((l) => shownIds.has(l.id) ? (moved[k++] ?? l.id) : l.id ); writeLaneOrder(full); setOrderTick((n) => n + 1); }; // Alt+↑/↓. Past the edge of its group the workspace stays where it is on // screen and joins the next group instead (the arrangement would otherwise // put it straight back): last of the group above, first of the one below — // opened if folded, so the focused row stays shown. The shown order is // written as it stands, so the store agrees. Never across an owner's section. const nudge = (from: number, to: number) => { const lane = sorted[from]; const next = sorted[to]; if (lane && next && hasGroups && ownerKey(lane) === ownerKey(next)) { const target = groupOf(groups, next.id); if ((target?.id ?? null) !== (groupOf(groups, lane.id)?.id ?? null)) { assignGroup(lane.id, target?.id ?? null); if (target?.folded) setGroupFolded(target.id, false); reorder((ids) => [...ids]); return; } } reorder((ids) => moveLane(ids, from, to)); }; // A row only starts a drag; the whole section takes the drop, so a gap — a // workspace's session list, the space above or below a row — lands too. // `dropAt` is the insertion point in `sorted`, drawn as a line. const listRef = useRef(null); const [dropAt, setDropAt] = useState(null); const dropIndexAt = (y: number) => { const rows = [...(listRef.current?.querySelectorAll("[data-lane-index]") ?? [])]; const mids = rows.map((r) => { const b = r.getBoundingClientRect(); return b.top + b.height / 2; }); const row = rows[insertionIndex(mids, y)]; return row ? Number(row.dataset.laneIndex) : sorted.length; }; const dragHandlers = { onDragOver: (e: DragEvent) => { if (!isLaneDrag(e)) return; e.preventDefault(); e.dataTransfer.dropEffect = "move"; const at = dropIndexAt(e.clientY); setDropAt((d) => (d === at ? d : at)); }, onDragLeave: (e: DragEvent) => { if (!e.currentTarget.contains(e.relatedTarget as Node | null)) setDropAt(null); }, onDragEnd: () => setDropAt(null), onDrop: (e: DragEvent) => { setDropAt(null); if (!isLaneDrag(e)) return; e.preventDefault(); const raw = e.dataTransfer.getData(LANE_DRAG_TYPE); // Number("") is 0: an empty payload must not move the first row. const from = raw === "" ? NaN : Number(raw); if (!Number.isInteger(from)) return; const lane = sorted[from]; // Dropped on a group's header: into that group, at its top. const header = (e.target as HTMLElement).closest("[data-group-drop]"); if (header && lane) { const gid = header.dataset.groupDrop || null; assignGroup(lane.id, gid); const first = sorted.findIndex( (l) => (groups.of[l.id] ?? null) === gid && l.id !== lane.id ); if (first >= 0) reorder((ids) => moveBefore(ids, from, first)); return; } const before = dropIndexAt(e.clientY); // Elsewhere it joins the group of the row it now sits under (the line // drawn above a header closes the section before it). if (lane && hasGroups) { const rest = sorted.filter((l) => l.id !== lane.id); const at = sorted.slice(0, before).filter((l) => l.id !== lane.id).length; const neighbour = rest[at - 1] ?? rest[0]; if (neighbour) assignGroup(lane.id, groups.of[neighbour.id] ?? null); } reorder((ids) => moveBefore(ids, from, before)); }, }; // A group no workspace is in yet still shows (to be renamed, or dropped on), // just above the ungrouped ones; a search hides it. const emptyGroups = q ? [] : groups.groups.filter((g) => !sorted.some((l) => groups.of[l.id] === g.id)); const firstUngrouped = hasGroups ? sorted.findIndex((l) => !groupOf(groups, l.id)) : -1; const groupHeader = (g: WorkspaceGroup | null, members: Lane[], first: boolean) => ( l.id === selected.lane)} onGroupDrop={(dragged) => moveGroupBefore(dragged, g?.id ?? null)} folded={!!g?.folded && !q} renaming={!!g && renamingGroup === g.id} first={first} onToggle={() => g && setGroupFolded(g.id, !g.folded)} onMenu={(x, y) => g && setGroupMenu({ group: g, x, y })} onRenameEnd={(name) => { setRenamingGroup(null); if (g && name !== null) renameGroup(g.id, name); }} /> ); const newGroup = (laneIds: number[] = []) => { const id = createGroup(t("tree.groupDefaultName"), laneIds); setRenamingGroup(id); }; const removeGroup = async (g: WorkspaceGroup) => { const ok = await confirmDialog({ title: t("tree.groupDelete"), message: t("tree.groupDeleteConfirm", { name: g.name }), confirmLabel: t("tree.groupDelete"), }); if (!ok) return; deleteGroup(g.id); }; const rename = async (lane: Lane, title: string | null) => { setRenamingId(null); if (title === null || title === (lane.title || "")) return; try { await api.lanes.update(lane.id, { title }); await refreshLanes(); } catch (err) { setError(err instanceof Error ? err.message : String(err)); } }; const onMenuAction = (lane: Lane, action: WorkspaceMenuAction) => { if (action === "rename") setRenamingId(lane.id); else if (action === "new-here") openAdd(lane.source_repo || lane.cwd); else if (action === "reset" || action === "remove") setDestructive({ lane, action }); }; const confirmDestructive = async (body?: { expect?: unknown; force?: boolean }) => { if (!destructive) return; const { lane, action } = destructive; setDestructive(null); try { await api.lanes.action(lane.id, action, { confirm: true, expect: body?.expect, ...(body?.force === true ? { force: true } : {}), }); await refreshLanes(); if (action === "remove" && selected.lane === lane.id) navigate("/workspace"); } catch (err) { setError(err instanceof Error ? err.message : String(err)); } }; // ≥24px targets (WCAG 2.5.8); each carries an aria-label. const headBtn = "btn-icon"; return (
{t("tree.title")} {tools.length > 1 && ( )}
{searching && ( setQuery(e.target.value)} onKeyDown={(e) => { if (e.key === "Escape") { setQuery(""); setSearching(false); } }} placeholder={t("tree.search")} aria-label={t("tree.search")} className="mx-2 mb-1 w-[calc(100%-1rem)] rounded border border-border bg-surface-1 px-2 py-1 text-xs text-fg-primary" /> )}
{sorted.map((lane, i) => { const owner = ownerKey(lane); // A search opens every group, like it opens every workspace. const folded = grouped && !q && !!prefs.collapsedOwners[owner]; const readOnly = !canEditLane(lane); const ownerLabel = lane.owner_id == null ? t("tree.noOwner") : lane.owner_id === meId ? t("tree.ownerYou", { name: ownerShown(lane) || `#${lane.owner_id}` }) : ownerShown(lane) || `#${lane.owner_id}`; const groupStart = grouped && (i === 0 || ownerKey(sorted[i - 1] ?? lane) !== owner); const groupShown = sorted.some((l) => ownerKey(l) === owner && visible(l)); // The user's own group, inside the owner's. const ug = groupOf(groups, lane.id); const ugKey = ug?.id ?? ""; const sameSection = (l: Lane) => ownerKey(l) === owner && (groupOf(groups, l.id)?.id ?? "") === ugKey; const prev = sorted[i - 1]; const sectionStart = hasGroups && (groupStart || !prev || !sameSection(prev)); const sectionShown = sorted.some((l) => sameSection(l) && visible(l)); const sectionFolded = !!ug?.folded && !q; const hiddenRow = folded || sectionFolded; return (
{groupStart && groupShown && ( )} {i === firstUngrouped && emptyGroups.map((g) => groupHeader(g, [], false))} {sectionStart && sectionShown && !folded && groupHeader(ug, sorted.filter(sameSection), i === 0 || !!groupStart)} {dropAt === i && } {visible(lane) && !hiddenRow && ( { // A read-only workspace has no home to open: it only unfolds. if (readOnly) return setExpanded(lane.id, !expanded(lane.id)); select(lane.id); if (!expanded(lane.id)) setExpanded(lane.id, true); }} onToggle={() => setExpanded(lane.id, !expanded(lane.id))} onNewSession={() => select(lane.id)} onReorder={nudge} onMenu={(x, y) => setMenu({ lane, x, y })} onRenameStart={() => setRenamingId(lane.id)} onRenameEnd={(title) => void rename(lane, title)} /> )} {expanded(lane.id) && !hiddenRow && ( onMatches(lane.id, n) : undefined} hidden={!visible(lane)} /> )}
); })} {firstUngrouped === -1 && emptyGroups.map((g, k) => groupHeader(g, [], k === 0 && sorted.length === 0))} {dropAt === sorted.length && (
)}
{error && (

{error}

)} {menu && ( setMenu(null)} onAction={(a) => onMenuAction(menu.lane, a)} groups={groups.groups} groupId={groups.of[menu.lane.id] ?? null} onGroup={(target) => { const id = menu.lane.id; setMenu(null); if (target === "new") newGroup([id]); else assignGroup(id, target); }} /> )} {groupMenu && ( setGroupMenu(null)} testId={`workspace-group-menu-${groupMenu.group.id}`} > { setRenamingGroup(groupMenu.group.id); setGroupMenu(null); }} > {t("tree.groupRename")} { moveGroup(groupMenu.group.id, -1); setGroupMenu(null); }} > {t("tree.groupMoveUp")} { moveGroup(groupMenu.group.id, 1); setGroupMenu(null); }} > {t("tree.groupMoveDown")} { const g = groupMenu.group; setGroupMenu(null); openAdd(undefined, g.id); }} > {t("tree.groupAddWorkspace")} { setGroupMenu(null); setAllFolded(true); }} > {t("tree.groupFoldAll")} { setGroupMenu(null); setAllFolded(false); }} > {t("tree.groupUnfoldAll")} { const g = groupMenu.group; setGroupMenu(null); void removeGroup(g); }} > {t("tree.groupDelete")} )} {destructive && ( setDestructive(null)} onConfirm={(body) => void confirmDestructive(body)} /> )} {addOpen && ( { setAddOpen(false); setAddPrefill(null); setAddToGroup(null); }} onAdded={(lane) => { if (addToGroup) assignGroup(lane.id, addToGroup); void refreshLanes(); select(lane.id); }} /> )}
); } /** What a group header's drag carries: the group's id. */ const GROUP_DRAG_TYPE = "application/x-agenthub-workspace-group"; const isGroupDrag = (e: { dataTransfer: DataTransfer | null }) => !!e.dataTransfer?.types?.includes(GROUP_DRAG_TYPE); // needsYou outranks an error, an error a running agent; idle/active say nothing. const URGENCY: Record = { needsYou: 3, error: 2, running: 1 }; /** The most urgent of `lanes` by its dot, or null when all are at rest. */ function worstOf(lanes: Lane[]): Lane | null { let best: Lane | null = null; let top = 0; for (const l of lanes) { const u = URGENCY[workspaceDotLabel(l)] ?? 0; if (u > top) [best, top] = [l, u]; } return best; } /** A workspace's owner group: its user id, or "none". */ const ownerKey = (l: Lane) => (l.owner_id == null ? "none" : String(l.owner_id)); /** * A section's header: a user group (foldable, menu, inline rename) or, with * `group` null, "Ungrouped". Both take a dropped workspace (`data-group-drop`). */ function GroupHeader({ group, count, worst, holdsSelected, folded, renaming, first, onToggle, onMenu, onRenameEnd, onGroupDrop, }: { group: WorkspaceGroup | null; count: number; /** The most urgent workspace inside, drawn as its dot while folded. */ worst: Lane | null; /** The open workspace is in this group (marked while folded). */ holdsSelected: boolean; folded: boolean; renaming: boolean; first: boolean; onToggle: () => void; onMenu: (x: number, y: number) => void; /** The typed name, or null when the edit was cancelled. */ onRenameEnd: (name: string | null) => void; /** A group header (by id) dropped here: it goes before this group, or last on "Ungrouped". */ onGroupDrop: (draggedId: string) => void; }) { const { t } = useTranslation("lanes"); const [draft, setDraft] = useState(group?.name ?? ""); const [dropOver, setDropOver] = useState(false); useEffect(() => { if (renaming) setDraft(group?.name ?? ""); }, [renaming, group?.name]); const label = group ? group.name : t("tree.ungrouped"); return (
{ if (!group) return; e.preventDefault(); onMenu(e.clientX, e.clientY); }} // A group (never "Ungrouped") is dragged by its header to reorder. draggable={!!group && !renaming} onDragStart={(e) => { if (!group) return; e.dataTransfer.setData(GROUP_DRAG_TYPE, group.id); e.dataTransfer.effectAllowed = "move"; }} onDragOver={(e) => { if (!isGroupDrag(e)) return; e.preventDefault(); e.dataTransfer.dropEffect = "move"; setDropOver(true); }} onDragLeave={() => setDropOver(false)} onDrop={(e) => { setDropOver(false); if (!isGroupDrag(e)) return; e.preventDefault(); const id = e.dataTransfer.getData(GROUP_DRAG_TYPE); if (id) onGroupDrop(id); }} className={`group/gh flex items-center gap-1 rounded px-1 py-0.5 text-[11px] font-semibold text-fg-muted ${first ? "" : "mt-1.5"} ${dropOver ? "shadow-[inset_0_2px_0_rgb(var(--accent))]" : ""}`} > {renaming && group ? ( setDraft(e.target.value)} onFocus={(e) => e.currentTarget.select()} onBlur={() => onRenameEnd(draft)} onKeyDown={(e) => { if (e.key === "Enter") onRenameEnd(draft); else if (e.key === "Escape") onRenameEnd(null); }} className="min-w-0 flex-1 rounded border border-border bg-surface-1 px-1 py-0.5 text-[11px] font-normal text-fg-primary" /> ) : ( )} {group && !renaming && ( )}
); } /** Where a dragged workspace would land: a line on its parent's top edge. */ function DropLine() { return (
); }