/** * @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 and handed over by the Workspace page's own * poll (`LIVE_RUNS_EVENT`). Owns its own Add-workspace and * destructive modals so the row menu works from any page. Which rows are * expanded lives in `projectTreePrefs.ts` (`expanded`). * @author Nguyễn Ngọc Trí Vĩ */ import { useCallback, useEffect, useMemo, useRef, useState, type DragEvent } from "react"; import { useTranslation } from "react-i18next"; import { useLocation, useNavigate } from "react-router-dom"; import { 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 { useAiTools } from "../hooks/useAiTools"; import { insertionIndex, moveBefore, moveLane, readLaneOrder, sortByStoredOrder, writeLaneOrder, } from "../lib/laneOrderStorage"; import { useProjectTreePrefs, writeProjectTreePrefs } from "../lib/projectTreePrefs"; import { LANE_DRAG_TYPE, WorkspaceRow, isLaneDrag, workspaceName } from "./lanes/WorkspaceRow"; import { WorkspaceSessionList } from "./lanes/WorkspaceSessionList"; import { WorkspaceRowMenu, type WorkspaceMenuAction } from "./lanes/WorkspaceRowMenu"; import { AddLaneModal } from "./lanes/AddLaneModal"; import { DestructiveLaneModal } from "./lanes/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). */ 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 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); const [cwdSuggestions, setCwdSuggestions] = useState([]); const [orderTick, setOrderTick] = useState(0); // 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). const runKey = lanes.map((l) => `${l.id}:${l.status}:${l.session_id}:${l.run_id}`).join(","); 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) => { setAddPrefill(sourceRepo ? { sourceRepo } : null); 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( () => sortByStoredOrder(lanes, order).filter((l) => !toolFilter || l.tool === toolFilter), [lanes, order, toolFilter] ); 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); }; // 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); const before = dropIndexAt(e.clientY); if (Number.isInteger(from)) reorder((ids) => moveBefore(ids, from, before)); }, }; 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) => (
{dropAt === i && } {visible(lane) && ( { select(lane.id); if (!expanded(lane.id)) setExpanded(lane.id, true); }} onToggle={() => setExpanded(lane.id, !expanded(lane.id))} onNewSession={() => select(lane.id)} onReorder={(from, to) => reorder((ids) => moveLane(ids, from, to))} onMenu={(x, y) => setMenu({ lane, x, y })} onRenameStart={() => setRenamingId(lane.id)} onRenameEnd={(title) => void rename(lane, title)} /> )} {expanded(lane.id) && ( onMatches(lane.id, n) : undefined} hidden={!visible(lane)} /> )}
))} {dropAt === sorted.length && (
)}
{error && (

{error}

)} {menu && ( setMenu(null)} onAction={(a) => onMenuAction(menu.lane, a)} /> )} {destructive && ( setDestructive(null)} onConfirm={(body) => void confirmDestructive(body)} /> )} { setAddOpen(false); setAddPrefill(null); }} onAdded={(lane) => { void refreshLanes(); select(lane.id); }} />
); } /** Where a dragged workspace would land: a line on its parent's top edge. */ function DropLine() { return (
); }