c8bc4a95a9
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.
893 lines
34 KiB
TypeScript
893 lines
34 KiB
TypeScript
/**
|
||
* @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"
|
||
/>
|
||
);
|
||
}
|