Files
AgentHub/client/src/components/WorkspaceTree.tsx
T
nntrivi2001 c8bc4a95a9 chore: v2.8.23 — workspace groups, inner repos in Changes (opt-in), sub-agent tokens counted
Workspace groups: named, foldable, ordered sidebar sections kept per
account (agenthub.workspace.groups); a workspace joins one from its menu,
a drop on the header, a drag under a row, or Alt+Up/Down past a group's
edge; headers drag to reorder; a folded header shows its most urgent
state; New workspace in this group; fold/unfold all.

Inner repos: GET /api/lanes/:id/repos + ?repo= on git/fs routes; the
Changes panel shows a parent folder's repos only when Settings -> Git ->
Changes panel turns it on (agenthub.changes.innerRepos, off by default).

Cost: the hook writer adds a session's sub-agent transcripts to its
tokens and hours (server/lib/subagent-usage.js) — a sub-agent on the
session's own model was never counted live (~8% of a week here); an
imported session is re-raised on every pass, so a claude -p run first
seen with an empty transcript no longer stays at $0 (~10%). A one-time
raise-only catch-up (user_version 5) re-reads ~/.claude/projects.

Also: omp install script may redirect to its exact install.redirect URL
(#85, repo-links test exempts it), native paste in the terminal.
2026-10-23 16:41:45 +07:00

893 lines
34 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.
/**
* @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=<id>`); 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/<id>`), 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ĩ <vinnt@smartgift.vn>
*/
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<RunHandle[]>([]);
const [query, setQuery] = useState("");
const [searching, setSearching] = useState(false);
const [error, setError] = useState<string | null>(null);
const [menu, setMenu] = useState<{ lane: Lane; x: number; y: number } | null>(null);
const [renamingId, setRenamingId] = useState<number | null>(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<string | null>(null);
const [cwdSuggestions, setCwdSuggestions] = useState<CwdSuggestion[]>([]);
const [orderTick, setOrderTick] = useState(0);
const groups = useWorkspaceGroups();
const hasGroups = groups.groups.length > 0;
const [renamingGroup, setRenamingGroup] = useState<string | null>(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<RunHandle[]>).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<Record<number, number>>({});
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<HTMLDivElement>(null);
const [dropAt, setDropAt] = useState<number | null>(null);
const dropIndexAt = (y: number) => {
const rows = [...(listRef.current?.querySelectorAll<HTMLElement>("[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<HTMLElement>("[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) => (
<GroupHeader
key={g?.id ?? "none"}
group={g}
count={members.length}
// What a folded group hides: the most urgent of its workspaces, and
// whether the open one is among them.
worst={worstOf(members)}
holdsSelected={members.some((l) => 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 (
<section
data-testid="workspace-tree"
// The bottom padding is a drop strip below the last workspace.
className="mt-3 border-t border-border pb-6 pt-3"
{...dragHandlers}
>
<div className="mb-1 flex items-center gap-1 px-2">
<span className="flex-1 text-[11px] font-semibold uppercase tracking-wide text-fg-muted">
{t("tree.title")}
</span>
{tools.length > 1 && (
<select
value={toolFilter ?? ""}
onChange={(e) => writeProjectTreePrefs({ tool: e.target.value || null })}
aria-label={t("tree.toolFilter")}
title={t("tree.toolFilter")}
className="select max-w-[6.5rem] !rounded !py-0.5 !pl-1 !pr-5 !text-[11px] text-fg-secondary"
>
<option value="">{t("tree.allTools")}</option>
{tools.map((id) => (
<option key={id} value={id}>
{aiTools.label(id)}
</option>
))}
</select>
)}
<button
type="button"
onClick={() => {
setSearching((v) => !v);
setQuery("");
}}
aria-label={t("tree.search")}
aria-pressed={searching}
title={t("tree.search")}
className={headBtn}
>
<Search className="h-3.5 w-3.5" />
</button>
<button
type="button"
data-testid="workspace-tree-new-group"
onClick={() => newGroup()}
aria-label={t("tree.newGroup")}
title={t("tree.newGroup")}
className={headBtn}
>
<FolderPlus className="h-3.5 w-3.5" />
</button>
<button
type="button"
data-testid="workspace-tree-add"
onClick={() => openAdd()}
aria-label={t("addLane")}
title={t("addLane")}
className={headBtn}
>
<Plus className="h-3.5 w-3.5" />
</button>
</div>
{searching && (
<input
type="search"
autoFocus
value={query}
onChange={(e) => 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"
/>
)}
<div ref={listRef} className="space-y-px px-1">
{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 (
<div key={lane.id} className="relative">
{groupStart && groupShown && (
<button
type="button"
data-testid={`workspace-owner-${owner}`}
aria-expanded={!folded}
title={t("tree.ownerToggle", { name: ownerLabel })}
onClick={() =>
writeProjectTreePrefs({
collapsedOwners: { ...prefs.collapsedOwners, [owner]: !folded },
})
}
className={`flex w-full items-center gap-1 rounded px-1 py-1 text-left text-[11px] font-medium text-fg-muted hover:text-fg-primary ${i === 0 ? "" : "mt-2"}`}
>
{folded ? (
<ChevronRight className="h-3 w-3 shrink-0" />
) : (
<ChevronDown className="h-3 w-3 shrink-0" />
)}
<UserAvatar
name={ownerShown(lane) || "?"}
userId={lane.owner_id ?? undefined}
avatarAt={lane.owner_avatar_at}
letters={1}
className="h-4 w-4 text-[9px]"
initialsClassName="bg-surface-4 text-fg-secondary"
/>
<span className="truncate">{ownerLabel}</span>
<span className="ml-auto shrink-0 tabular-nums">
{sorted.filter((l) => ownerKey(l) === owner).length}
</span>
</button>
)}
{i === firstUngrouped && emptyGroups.map((g) => groupHeader(g, [], false))}
{sectionStart &&
sectionShown &&
!folded &&
groupHeader(ug, sorted.filter(sameSection), i === 0 || !!groupStart)}
{dropAt === i && <DropLine />}
{visible(lane) && !hiddenRow && (
<WorkspaceRow
lane={lane}
index={i}
readOnly={readOnly ? t("tree.readOnly", { name: ownerLabel }) : undefined}
selected={selected.lane === lane.id && !selected.run && !selected.session}
expanded={expanded(lane.id)}
renaming={renamingId === lane.id}
onSelect={() => {
// 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 && (
<WorkspaceSessionList
lane={lane}
liveRuns={liveRuns}
readOnly={readOnly}
// A workspace found by its name shows all of its sessions.
query={q && nameHit(lane) ? "" : query}
selectedRunId={selected.lane === lane.id ? selected.run : null}
selectedSessionId={selected.lane === lane.id ? selected.session : null}
onMatches={q ? (n) => onMatches(lane.id, n) : undefined}
hidden={!visible(lane)}
/>
)}
</div>
);
})}
{firstUngrouped === -1 &&
emptyGroups.map((g, k) => groupHeader(g, [], k === 0 && sorted.length === 0))}
{dropAt === sorted.length && (
<div className="relative">
<DropLine />
</div>
)}
</div>
{error && (
<p role="alert" className="mx-2 mt-2 text-[11px] text-status-danger">
{error}
</p>
)}
{menu && (
<WorkspaceRowMenu
lane={menu.lane}
x={menu.x}
y={menu.y}
onClose={() => 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 && (
<ContextMenu
x={groupMenu.x}
y={groupMenu.y}
onClose={() => setGroupMenu(null)}
testId={`workspace-group-menu-${groupMenu.group.id}`}
>
<MenuItem
onSelect={() => {
setRenamingGroup(groupMenu.group.id);
setGroupMenu(null);
}}
>
{t("tree.groupRename")}
</MenuItem>
<MenuItem
disabled={groups.groups[0]?.id === groupMenu.group.id}
onSelect={() => {
moveGroup(groupMenu.group.id, -1);
setGroupMenu(null);
}}
>
{t("tree.groupMoveUp")}
</MenuItem>
<MenuItem
disabled={groups.groups[groups.groups.length - 1]?.id === groupMenu.group.id}
onSelect={() => {
moveGroup(groupMenu.group.id, 1);
setGroupMenu(null);
}}
>
{t("tree.groupMoveDown")}
</MenuItem>
<MenuSeparator />
<MenuItem
testId="workspace-group-add"
onSelect={() => {
const g = groupMenu.group;
setGroupMenu(null);
openAdd(undefined, g.id);
}}
>
{t("tree.groupAddWorkspace")}
</MenuItem>
<MenuItem
testId="workspace-group-fold-all"
onSelect={() => {
setGroupMenu(null);
setAllFolded(true);
}}
>
{t("tree.groupFoldAll")}
</MenuItem>
<MenuItem
testId="workspace-group-unfold-all"
onSelect={() => {
setGroupMenu(null);
setAllFolded(false);
}}
>
{t("tree.groupUnfoldAll")}
</MenuItem>
<MenuSeparator />
<MenuItem
danger
testId="workspace-group-delete"
onSelect={() => {
const g = groupMenu.group;
setGroupMenu(null);
void removeGroup(g);
}}
>
{t("tree.groupDelete")}
</MenuItem>
</ContextMenu>
)}
<Suspense fallback={null}>
{destructive && (
<DestructiveLaneModal
lane={destructive.lane}
action={destructive.action}
open
onClose={() => setDestructive(null)}
onConfirm={(body) => void confirmDestructive(body)}
/>
)}
{addOpen && (
<AddLaneModal
open
cwdSuggestions={cwdSuggestions}
prefill={addPrefill}
onClose={() => {
setAddOpen(false);
setAddPrefill(null);
setAddToGroup(null);
}}
onAdded={(lane) => {
if (addToGroup) assignGroup(lane.id, addToGroup);
void refreshLanes();
select(lane.id);
}}
/>
)}
</Suspense>
</section>
);
}
/** 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<string, number> = { 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 (
<div
data-group-drop={group?.id ?? ""}
data-testid={`workspace-group-${group?.id ?? "none"}`}
onContextMenu={(e) => {
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 ? (
<input
autoFocus
value={draft}
maxLength={60}
aria-label={t("tree.groupNameLabel")}
onChange={(e) => 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"
/>
) : (
<button
type="button"
aria-expanded={group ? !folded : undefined}
disabled={!group}
title={group ? t("tree.groupToggle", { name: label }) : undefined}
onClick={onToggle}
className="flex min-w-0 flex-1 items-center gap-1 text-left hover:text-fg-primary disabled:hover:text-fg-muted"
>
{group ? (
folded ? (
<ChevronRight className="h-3 w-3 shrink-0" />
) : (
<ChevronDown className="h-3 w-3 shrink-0" />
)
) : (
<span className="w-3 shrink-0" />
)}
<span
className={`truncate uppercase tracking-wide ${folded && holdsSelected ? "text-accent" : ""}`}
>
{label}
</span>
<span className="ml-1 shrink-0 font-normal tabular-nums">{count}</span>
{folded && worst && (
<span
data-testid={`workspace-group-state-${group?.id ?? "none"}`}
className="ml-1 flex shrink-0 items-center"
>
<span aria-hidden className={`h-1.5 w-1.5 rounded-full ${workspaceDot(worst)}`} />
<span className="sr-only">{t(`row.state.${workspaceDotLabel(worst)}`)}</span>
</span>
)}
</button>
)}
{group && !renaming && (
<button
type="button"
aria-label={t("tree.groupMenu")}
title={t("tree.groupMenu")}
onClick={(e) => {
const r = e.currentTarget.getBoundingClientRect();
onMenu(r.left, r.bottom);
}}
className="rounded p-0.5 opacity-0 hover:bg-surface-4 hover:text-fg-primary focus:opacity-100 group-hover/gh:opacity-100"
>
<MoreHorizontal className="h-3.5 w-3.5" />
</button>
)}
</div>
);
}
/** Where a dragged workspace would land: a line on its parent's top edge. */
function DropLine() {
return (
<div
aria-hidden
data-testid="workspace-drop-line"
className="pointer-events-none absolute inset-x-1 -top-px z-10 h-0.5 rounded-full bg-accent-solid"
/>
);
}