Files
AgentHub/client/src/components/WorkspaceTree.tsx
T
nntrivi2001 fa156d6a59 feat: v2.6.0 — Chat UI composer (+ menu, project files, activity panel, permission mode, Stop), git on a workspace's extra folders (#70), long paste as a file (#68), S3 server mode (experimental)
Chat UI:
- Each Claude/Codex session keeps its own chat/terminal view; Settings only
  sets how new sessions open.
- Composer + menu: upload, project files and folders (#54, also dragged from
  the Files/Changes tree), context files, recent uploads, skills, plugins.
- Activity side panel (progress, outputs, files read, uploads, memory,
  skills, connectors) with a file preview; edited-file counts per turn.
- Permission-mode chip (Shift+Tab steps, read back from the pane:
  server/lib/pane-mode.js, GET|POST /api/run/:id/permission-mode).
- Stop (Esc) while the main agent works; the status is checked per agent
  (with session_id /api/agents ignores its status filter) and re-read every
  5 s while Stop shows.
- Dropped images shown in the conversation (GET /api/run/drop/:name, the
  requester's own drop dir), full-size viewer.
- A plain-text paste over 5,000 characters is attached as pasted-text.txt,
  so no prompt reaches tmux/argv limits (#68).

Changes panel: every /api/lanes/:id/git* route takes ?source=<lane_sources
id> (sourceFolder; ENOSOURCE otherwise) and a folder picker switches the
whole panel to a workspace's extra folder (#70).

Server mode S3 (one Linux user per account) — experimental, verified with
fake sudo plus a P6 managed-settings spike on a test account:
- member tmux listing runs in the background (cached, 15 s => unknown);
- transcript snapshot copy is async, chunked, temp file + rename;
- Express Layer patch only on Express 4;
- tmux capturePane/sendKey go to the member's own server.

Docs: the multi-user design moved in as docs/plans/server-mode/README.md
(the AgentHub-Server repo is gone); S3-PLAN P6 result; LANES/API/openapi.
2026-10-09 16:35:07 +07:00

481 lines
19 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 (`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.
* @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, 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 { LANE_DRAG_TYPE, WorkspaceRow, isLaneDrag, workspaceName } from "./lanes/WorkspaceRow";
import { WorkspaceSessionList } from "./lanes/WorkspaceSessionList";
import { WorkspaceRowMenu, type WorkspaceMenuAction } from "./lanes/WorkspaceRowMenu";
// 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). */
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);
const [cwdSuggestions, setCwdSuggestions] = useState<CwdSuggestion[]>([]);
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). 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) => {
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(() => {
const list = sortByStoredOrder(lanes, order).filter(
(l) => !toolFilter || l.tool === toolFilter
);
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) || (a.owner_name ?? "").localeCompare(b.owner_name ?? "")
);
}, [lanes, order, toolFilter, grouped, meId]);
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);
};
// 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);
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 (
<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-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: lane.owner_name ?? `#${lane.owner_id}` })
: (lane.owner_name ?? `#${lane.owner_id}`);
const groupStart = grouped && (i === 0 || ownerKey(sorted[i - 1] ?? lane) !== owner);
const groupShown = sorted.some((l) => ownerKey(l) === owner && visible(l));
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" />
)}
<span
aria-hidden
className="flex h-4 w-4 shrink-0 items-center justify-center rounded-full bg-surface-4 text-[9px] font-semibold uppercase text-fg-secondary"
>
{lane.owner_name?.[0] ?? "?"}
</span>
<span className="truncate">{ownerLabel}</span>
<span className="ml-auto shrink-0 tabular-nums">
{sorted.filter((l) => ownerKey(l) === owner).length}
</span>
</button>
)}
{dropAt === i && <DropLine />}
{visible(lane) && !folded && (
<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={(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) && !folded && (
<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>
);
})}
{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)}
/>
)}
<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);
}}
onAdded={(lane) => {
void refreshLanes();
select(lane.id);
}}
/>
)}
</Suspense>
</section>
);
}
/** A workspace's owner group: its user id, or "none". */
const ownerKey = (l: Lane) => (l.owner_id == null ? "none" : String(l.owner_id));
/** 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"
/>
);
}