22de5009ff
- Artifacts (/artifacts): claude.ai pages sessions publish (captured from the Artifact tool's hook), pinned workspace files, skills and plugins, each kept as a snapshot; shared ones appear in a Hub and a skill or plugin installs into the viewer's own account (server/lib/artifacts.js, /api/artifacts, docs/plans/artifacts/). - Workspace extra folders: clone one or more related repos beside the workspace and add them in one step (POST /api/lanes/:id/sources/clone). - A pane that ended by itself while tmux kept running is no longer offered for resume (tmux.serverStartedAt in reconcileOrphans). - Changes panel: a folder that is not a git repo opens on Files, and its files open from there. - Plus the parallel sessions' work in this tree: File Explorer button in the panel header, user delete, RSA SSH keys, chat/terminal and pet fixes. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1812 lines
80 KiB
TypeScript
1812 lines
80 KiB
TypeScript
/**
|
||
* @file Workspace.tsx
|
||
* @description Merged workspace page combining lanes (agent work units) and runs
|
||
* (CLI spawns: Claude Code or Codex, determined per lane). The workspace shown
|
||
* is picked in the sidebar's project tree and named by `?lane=<id>` (mirrored to
|
||
* `agenthub.workspace.selected`, so a bare /workspace reopens the last one).
|
||
* Shows the selected lane's pipeline map (when a template is installed) and run console (a
|
||
* tab per group of up to four split panes over the live tmux slots 0–9, with a
|
||
* `+` menu — `ConsoleTabs.tsx`), and the
|
||
* right panel —
|
||
* Files · Changes · Sessions. Runs are tied to lanes: starting a run
|
||
* posts to POST /api/lanes/:id/start with tool-appropriate fields. When a cwd
|
||
* isn't owned by any lane, calls POST /api/lanes/ensure first.
|
||
* Tabs and pane headers name each live session (`runName`) and show its state;
|
||
* a tab click replaces the history entry (Back leaves the session view, not
|
||
* the last tab). Keyboard, outside a terminal: Alt+1–4 focus a pane, Alt+\\
|
||
* splits the focused one right with a new terminal, Alt+W hides it. The
|
||
* Changes panel has Full screen (Restore at full screen) and Collapse
|
||
* buttons, and resizes by dragging (or arrow keys on) its splitter; a file in
|
||
* a chat's "Edited N files" card opens in it (`OPEN_IN_CHANGES_EVENT`); an unsaved edit in it is asked about before the panel
|
||
* closes, switches tab, or moves to another workspace or session (it stays on
|
||
* the old one until saved or discarded). Its bottom fold, Workspace context,
|
||
* is the home's context column compact (`WorkspaceContext`). Errors are toasts.
|
||
* Settings opened on a workspace (`?settings=<section>`, `lib/settingsLink.ts`)
|
||
* is a tab of its own at the end of the tab bar, kept until its X: the
|
||
* Settings pages (`SettingsHub`) shown instead of the panes, which stay
|
||
* mounted behind them.
|
||
*
|
||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||
*/
|
||
/* =============================================================================
|
||
* MODULE_GUIDE — extended in-file reference (comments only; safe to read, never executed)
|
||
* =============================================================================
|
||
* ## Design constraints
|
||
* - Local-first: no telemetry leaves the machine unless the user configures webhooks.
|
||
* - Fail-safe hooks path on the server must never block Claude Code; UI mirrors that
|
||
* philosophy by degrading gracefully (empty states, stale badges, reconnect loops).
|
||
* - Destructive flows stay behind explicit confirmation modals and server-side gates.
|
||
* - Internationalization: user-visible strings belong in i18n JSON, not literals here.
|
||
* - The console never writes a lane's stage: no POST /api/lanes/:id/stage calls.
|
||
*
|
||
* ## Internal dependencies
|
||
* - `../lib/api` — REST client including lanes and run endpoints.
|
||
* - `../lib/types` — Lane, Lane Counts, run handles, etc.
|
||
* - `../components/lanes/` — LaneCard, PipelineMap, DestructiveLaneModal.
|
||
* - `../components/run/` — ConsoleTabs, LaneConsolePane.
|
||
*
|
||
* ## Public surface
|
||
* - `Workspace` — merged page; see TSDoc on the symbol for behavior.
|
||
*
|
||
* ============================================================================= */
|
||
/* -----------------------------------------------------------------------------
|
||
* EXPORT CATALOG
|
||
* **Workspace**
|
||
* The main exported page component. Merges lanes and runs into one workspace.
|
||
*
|
||
* ----------------------------------------------------------------------------- */
|
||
|
||
import { runWorktreeSession, type RunWorktree } from "../lib/runWorktree";
|
||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||
import { useLocation, useNavigate, useSearchParams } from "react-router-dom";
|
||
import { useTranslation } from "react-i18next";
|
||
import {
|
||
PanelLeftClose,
|
||
PanelRightClose,
|
||
Maximize2,
|
||
FolderOpen,
|
||
Minimize2,
|
||
ChartColumn,
|
||
ChevronDown,
|
||
ChevronRight,
|
||
} from "lucide-react";
|
||
import { api } from "../lib/api";
|
||
import { usePermissions } from "../components/AuthGate";
|
||
import type {
|
||
CwdSuggestion,
|
||
LaneSource,
|
||
LaneSourcesView,
|
||
RunHandle,
|
||
RunListResponse,
|
||
} from "../lib/api";
|
||
import type { Lane, LaneFeature, ProofFeature, Session } from "../lib/types";
|
||
import { toast } from "../lib/toast";
|
||
import { ContextMenu, MenuItem, useContextMenu } from "../components/ContextMenu";
|
||
import { confirmDialog, confirmDiscard } from "../lib/confirm";
|
||
import { keepIfSame } from "../lib/keepIfSame";
|
||
import { useWorkLanes } from "../hooks/useLanes";
|
||
import { LaneConsolePane } from "../components/run/LaneConsolePane";
|
||
import {
|
||
ConsoleTabs,
|
||
newTabBody,
|
||
newTabTools,
|
||
runName,
|
||
runState,
|
||
} from "../components/run/ConsoleTabs";
|
||
import {
|
||
Divider,
|
||
type LayoutUpdate,
|
||
PaneGrid,
|
||
SplitMenus,
|
||
usePaneGroups,
|
||
} from "../components/run/PaneGrid";
|
||
import {
|
||
type Edge,
|
||
type GroupsScope,
|
||
type PaneGroups,
|
||
type PaneLayout,
|
||
MAX_PANES,
|
||
MAX_SLOTS,
|
||
activeLayout,
|
||
closeInLayout,
|
||
groupOf,
|
||
layoutRects,
|
||
leaves,
|
||
moveInLayout,
|
||
openInGroups,
|
||
pane,
|
||
paneKey,
|
||
pruneGroups,
|
||
removeSlot,
|
||
slotOfKey,
|
||
updateActive,
|
||
} from "../lib/paneLayout";
|
||
import { readAllTabs } from "../lib/workspaceTabs";
|
||
import { settingsHref } from "../lib/settingsLink";
|
||
import { SettingsHub } from "./SettingsHub";
|
||
import { useAiTools } from "../hooks/useAiTools";
|
||
import { freeSlot, laneOfRun, startInFreeTab } from "../components/lanes/startSession";
|
||
import { sessionWorktreeName } from "../components/lanes/WorkspaceSessionList";
|
||
import { LIVE_RUNS_EVENT } from "../components/WorkspaceTree";
|
||
import PipelineMap from "../components/lanes/PipelineMap";
|
||
import LaneCard, { since } from "../components/lanes/LaneCard";
|
||
import { AddLaneModal } from "../components/lanes/AddLaneModal";
|
||
import { LaneChangesPanel } from "../components/lanes/LaneChangesPanel";
|
||
import { FileExplorer } from "../components/lanes/FileExplorer";
|
||
import { WorkspaceContext } from "../components/lanes/WorkspaceContext";
|
||
import { OPEN_IN_CHANGES_EVENT, type OpenInChanges } from "../lib/openInChanges";
|
||
import { LaneGitRailCounts } from "../components/lanes/LaneGitHeader";
|
||
import { WorkspaceHome } from "../components/lanes/WorkspaceHome";
|
||
import { SessionReplay } from "../components/lanes/SessionReplay";
|
||
import { DestructiveLaneModal } from "../components/lanes/DestructiveLaneModal";
|
||
|
||
/** localStorage key for the Changes panel's open/closed state. */
|
||
const CHANGES_OPEN_KEY = "agenthub.workspace.changesOpen";
|
||
/** localStorage key for how wide the Changes panel is. */
|
||
const CHANGES_SIZE_KEY = "agenthub.workspace.changesSize";
|
||
/** localStorage key for the last width the user dragged the panel to — what
|
||
* it reopens at after the step control closes it. */
|
||
const CHANGES_DRAGGED_KEY = "agenthub.workspace.changesDragged";
|
||
/** localStorage key for whether the Changes panel's Workspace context fold is open. */
|
||
const CONTEXT_OPEN_KEY = "agenthub.workspace.contextOpen";
|
||
/** localStorage key for the last workspace shown, restored on a bare /workspace. */
|
||
const SELECTED_KEY = "agenthub.workspace.selected";
|
||
/** Workspaces left most recently whose live panes stay mounted (hidden), so
|
||
* switching back to one shows its terminals as they were, with no re-attach. */
|
||
const KEEP_LANES = 4;
|
||
|
||
/** The Changes panel's two preset widths. Its header has two controls, like a
|
||
* window's: Full screen (Restore once at full screen, back to the last
|
||
* dragged width) and Collapse (to the rail). */
|
||
const CHANGES_SIZES = ["compact", "full"] as const;
|
||
type ChangesPreset = (typeof CHANGES_SIZES)[number];
|
||
/** A preset, or a width (panel fraction 0.15–0.85) the splitter was dragged to. */
|
||
type ChangesSize = ChangesPreset | number;
|
||
const CHANGES_WIDTH: Record<ChangesPreset, string> = {
|
||
compact: "w-[25%]",
|
||
// The console beside it is HIDDEN, not unmounted: a live run's terminal
|
||
// and a half-typed prompt must survive a round trip through full width.
|
||
full: "w-full",
|
||
};
|
||
/** The panel's share of the row, for the splitter. */
|
||
const changesRatio = (s: ChangesSize) => (typeof s === "number" ? s : s === "compact" ? 0.25 : 1);
|
||
const clampChanges = (r: number) => Math.min(0.85, Math.max(0.15, r));
|
||
/** The width the user last dragged the panel to, else `compact`. */
|
||
function lastDraggedChanges(): ChangesSize {
|
||
try {
|
||
const n = Number(window.localStorage.getItem(CHANGES_DRAGGED_KEY) || NaN);
|
||
return Number.isFinite(n) ? clampChanges(n) : "compact";
|
||
} catch {
|
||
return "compact";
|
||
}
|
||
}
|
||
/** The panel's own minimum (`min-w-[18rem]`), in px. */
|
||
const CHANGES_MIN_PX = 288;
|
||
|
||
// ── Page ──────────────────────────────────────────────────────────────
|
||
|
||
/** The pane slot a run id names (`agenthub-lane-<id>[-<slot>]`). */
|
||
function slotOf(runId: string | null | undefined): number {
|
||
const m = runId?.match(/^agenthub-lane-\d+(?:-([1-9]))?$/);
|
||
return m ? Number(m[1] || 0) : 0;
|
||
}
|
||
|
||
/** The session view's console: a tab bar whose every tab is a group of up to
|
||
* four split panes (`PaneGrid`; model in `lib/paneLayout.ts`), one
|
||
* `LaneConsolePane` per slot 0–9, each its own tmux session on the selected
|
||
* lane. The focused pane of the tab on screen is the URL's `?run=`: a plain
|
||
* open (a tab, a sidebar row) shows the tab holding that run — a session in
|
||
* no tab gets one of its own — and a split open (Ctrl/Cmd+click) brings it
|
||
* into the tab on screen, at the 2×2 grid's next place. Every live run
|
||
* stays MOUNTED, hidden with CSS while its tab is not shown, so neither
|
||
* switching tabs nor reshaping the panes ever drops a terminal. A pane whose
|
||
* run ends closes; the last tab gone leaves for the home. With `allTabs`
|
||
* (Settings → Appearance) the tabs hold EVERY workspace's live runs, stored
|
||
* once (`all`): a leaf is a `paneKey` (lane × 10 + slot), each pane is bound
|
||
* to its own run's lane, and focusing one selects its workspace (the URL's
|
||
* `?lane=` follows `?run=`). Without it, the live panes of the last
|
||
* `KEEP_LANES` workspaces left stay mounted too — hidden siblings in the same
|
||
* grid, each keyed by its `paneKey` — so a workspace switch neither disposes
|
||
* nor re-attaches a terminal. */
|
||
function ConsoleArea({
|
||
lanes,
|
||
selectedLaneId,
|
||
binaryStatus,
|
||
activeRuns,
|
||
onHasActiveRunChange,
|
||
attachRequest,
|
||
onNewSession,
|
||
onOpenRun,
|
||
onRunsChanged,
|
||
allTabs = false,
|
||
settings = null,
|
||
}: {
|
||
lanes: Lane[];
|
||
selectedLaneId: number | null;
|
||
binaryStatus: Record<string, { found: boolean; path: string | null }> | null;
|
||
activeRuns: RunListResponse | null;
|
||
onHasActiveRunChange: (val: boolean) => void;
|
||
/** The opened run (`?run=`): its pane is the focused one. `split` opens it
|
||
* in a split (the grid's next place) instead; `focusOnly` (the URL following a
|
||
* focus change made here) never opens anything. */
|
||
attachRequest: { runId: string; nonce: string; split?: boolean; focusOnly?: boolean } | null;
|
||
/** Back to the workspace home, where a new session is started. */
|
||
onNewSession?: () => void;
|
||
/** Opens a run (`?run=`); `split` splits, `focusOnly` only follows the focus,
|
||
* `replace` adds no history entry (a tab click). */
|
||
onOpenRun: (
|
||
runId: string,
|
||
opts?: { split?: boolean; focusOnly?: boolean; replace?: boolean }
|
||
) => void;
|
||
/** A tab was opened or closed: re-read the run list now. */
|
||
onRunsChanged: () => void;
|
||
/** Tabs from every workspace, not only the selected one's. */
|
||
allTabs?: boolean;
|
||
/** The Settings tab while it is open: its section, and whether it is the one
|
||
* on screen (the panes are then hidden, never unmounted). */
|
||
settings?: {
|
||
section: string;
|
||
active: boolean;
|
||
onOpen: () => void;
|
||
onClose: () => void;
|
||
} | null;
|
||
}) {
|
||
const { t } = useTranslation("lanes");
|
||
const aiTools = useAiTools();
|
||
const lane = lanes.find((l) => l.id === selectedLaneId) ?? null;
|
||
// Every workspace's runs, each `slot` rewritten to its `paneKey`, so the tab
|
||
// bar and the grid — which key everything by slot — need no other change.
|
||
const tabs = useMemo(
|
||
() =>
|
||
(activeRuns?.items ?? [])
|
||
.filter((r) =>
|
||
allTabs
|
||
? r.laneId !== null && r.status === "running"
|
||
: r.laneId === selectedLaneId && r.status === "running"
|
||
)
|
||
.map((r) => (allTabs ? { ...r, slot: paneKey(r.laneId as number, r.slot ?? 0) } : r))
|
||
.sort((a, b) => (a.slot ?? 0) - (b.slot ?? 0)),
|
||
[activeRuns, selectedLaneId, allTabs]
|
||
);
|
||
// A run id's leaf: its slot, or with `allTabs` its `paneKey`.
|
||
const keyOf = (runId: string) => {
|
||
const l = laneOfRun(runId);
|
||
return allTabs && l !== null ? paneKey(l, slotOf(runId)) : slotOf(runId);
|
||
};
|
||
const scope: GroupsScope | null = allTabs ? "all" : selectedLaneId;
|
||
const [groups, setGroups] = usePaneGroups(scope);
|
||
// The latest tabs, updated the moment a change is made: two changes in one
|
||
// commit (an open request and a prune) must build on each other.
|
||
const groupsRef = useRef(groups);
|
||
groupsRef.current = groups;
|
||
const layout = activeLayout(groups);
|
||
// Slots opened here whose run the polled list has not caught up with yet,
|
||
// each with the list it was opened against: pruning spares one until a
|
||
// NEWER list still lacks it (a run that died before it was ever listed).
|
||
const pending = useRef(new Map<number, RunListResponse | null>());
|
||
// The workspace whose first run list was read. That first read prunes, so a
|
||
// remembered layout loses its dead panes on arrival; after it a shown pane
|
||
// closes on its terminal's exit, not on the polled list — one bad read (the
|
||
// server answers an empty list on any tmux error) must not wipe a split.
|
||
const listed = useRef<GroupsScope | null>(null);
|
||
// Runs seen ending here (`id@startedAt`): a list not yet refreshed must not
|
||
// send the last pane to one of them.
|
||
const exited = useRef(new Set<string>());
|
||
const laneRef = useRef(selectedLaneId);
|
||
laneRef.current = selectedLaneId;
|
||
// The sessions the tabs hold — their names and states — re-read when a tab
|
||
// opens or learns its session, when the lane's status moves, or on a rename.
|
||
const [tabSessions, setTabSessions] = useState<Map<string, Session>>(new Map());
|
||
const [sessionsVersion, setSessionsVersion] = useState(0);
|
||
const tabSessionIds = tabs.map((r) => r.sessionId ?? "").join(",");
|
||
// The workspaces the tabs belong to: the selected one, or with `allTabs`
|
||
// every one holding a tab.
|
||
const tabLaneIds = allTabs
|
||
? [...new Set(tabs.map((r) => r.laneId as number))].join(",")
|
||
: String(selectedLaneId ?? "");
|
||
useEffect(() => {
|
||
if (!tabLaneIds || !tabSessionIds.replace(/,/g, "")) return;
|
||
let live = true;
|
||
Promise.all(
|
||
tabLaneIds.split(",").map((id) => api.sessions.list({ lane: Number(id), limit: 50 }))
|
||
)
|
||
.then(
|
||
(rs) => live && setTabSessions(new Map(rs.flatMap((r) => r.sessions.map((x) => [x.id, x]))))
|
||
)
|
||
.catch(() => undefined);
|
||
return () => {
|
||
live = false;
|
||
};
|
||
}, [tabLaneIds, tabSessionIds, lane?.status, lane?.needs_action, sessionsVersion]);
|
||
const laneName = (r: RunHandle) => {
|
||
const l = lanes.find((x) => x.id === r.laneId);
|
||
return l ? l.title || l.cwd.split("/").filter(Boolean).pop() || l.cwd : "";
|
||
};
|
||
const sessionOf = (r: RunHandle) => (r.sessionId ? tabSessions.get(r.sessionId) : undefined);
|
||
const label = (r: RunHandle) => {
|
||
const name = runName(r, sessionOf(r), aiTools.label(r.tool), t("tabs.terminal"));
|
||
return allTabs ? `${laneName(r)} · ${name}` : name;
|
||
};
|
||
const stateOf = (r: RunHandle) => (r.tool === "shell" ? null : runState(sessionOf(r)));
|
||
const runOf = (slot: number | null) => tabs.find((r) => (r.slot ?? 0) === slot);
|
||
const runKey = (r: RunHandle) => `${r.id}@${r.startedAt}`;
|
||
|
||
// The URL's `?run=` names the focused pane, so the right panel and the
|
||
// sidebar read it. A change made HERE that moves the focus pushes it; there
|
||
// is no effect watching the focus — an open request and that effect would
|
||
// keep overriding each other. Session view only: the home and a replay
|
||
// keep their URL.
|
||
const followFocus = (next: PaneGroups) => {
|
||
if (!attachRequest) return;
|
||
const run = runOf(activeLayout(next).focus);
|
||
if (run && run.id !== attachRequest.runId) onOpenRun(run.id, { focusOnly: true });
|
||
};
|
||
const changeGroups = (fn: (g: PaneGroups) => PaneGroups) => {
|
||
const next = fn(groupsRef.current);
|
||
if (next === groupsRef.current) return;
|
||
groupsRef.current = next;
|
||
setGroups(() => next);
|
||
followFocus(next);
|
||
};
|
||
|
||
// Another workspace's tabs: nothing pending or exited carries over.
|
||
useEffect(() => {
|
||
pending.current.clear();
|
||
exited.current.clear();
|
||
}, [scope]);
|
||
|
||
// An open request (a new nonce): focus, replace or split. Re-run when the
|
||
// lane arrives on a cold load; only this lane's own tabs.
|
||
useEffect(() => {
|
||
if (!attachRequest || selectedLaneId === null) return;
|
||
const own = allTabs ? "\\d+" : String(selectedLaneId);
|
||
if (!new RegExp(`^agenthub-lane-${own}(-[1-9])?$`).test(attachRequest.runId)) return;
|
||
const slot = keyOf(attachRequest.runId);
|
||
// The URL catching up with a focus change: by now that pane may be closed
|
||
// or swapped, and re-opening it would undo what the user just did. Back/
|
||
// Forward onto such an entry puts the real focus back in the URL instead.
|
||
if (attachRequest.focusOnly) {
|
||
const g = groupsRef.current;
|
||
const at = groupOf(g, slot);
|
||
if (at >= 0)
|
||
changeGroups(() =>
|
||
at === g.active && g.groups[at]?.focus === slot ? g : openInGroups(g, slot, false)
|
||
);
|
||
else followFocus(g);
|
||
return;
|
||
}
|
||
if (!tabs.some((r) => (r.slot ?? 0) === slot)) pending.current.set(slot, activeRuns);
|
||
changeGroups((g) => openInGroups(g, slot, attachRequest.split === true));
|
||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||
}, [attachRequest?.nonce, selectedLaneId]);
|
||
|
||
// The run list caught up: on a workspace's first read every pane whose run
|
||
// is gone closes; after that only a pane opened here whose run never got
|
||
// listed (a newer list still lacks it) does. A pane opened here whose run
|
||
// just got listed takes the URL if it holds the focus.
|
||
useEffect(() => {
|
||
if (!activeRuns || selectedLaneId === null) return;
|
||
const live = new Set(tabs.map((r) => r.slot ?? 0));
|
||
const arrived: number[] = [];
|
||
const expired = new Set<number>();
|
||
for (const [s, list] of pending.current) {
|
||
if (live.has(s)) arrived.push(s);
|
||
else if (list !== activeRuns) expired.add(s);
|
||
if (live.has(s) || list !== activeRuns) pending.current.delete(s);
|
||
}
|
||
const first = listed.current !== scope;
|
||
listed.current = scope;
|
||
const tabbed = groupsRef.current.groups.flatMap((l) => leaves(l.root));
|
||
const keep = first
|
||
? new Set(pending.current.keys())
|
||
: new Set(tabbed.filter((s) => !expired.has(s)));
|
||
const before = groupsRef.current;
|
||
changeGroups((g) => pruneGroups(g, live, keep));
|
||
const focus = activeLayout(before).focus;
|
||
if (groupsRef.current === before && focus !== null && arrived.includes(focus))
|
||
followFocus(before);
|
||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||
}, [activeRuns, tabs, selectedLaneId]);
|
||
|
||
// A pane's run ended (it exited, or its tab was closed): its pane closes,
|
||
// and in the session view the last one leaves for another tab, else the
|
||
// home — the home and a replay only lose the pane, never their URL.
|
||
const ended = (slot: number) => {
|
||
onRunsChanged();
|
||
const dead = runOf(slot);
|
||
if (dead) exited.current.add(runKey(dead));
|
||
if (groupOf(groupsRef.current, slot) < 0) return;
|
||
changeGroups((g) => removeSlot(g, slot));
|
||
if (groupsRef.current.groups.length || !attachRequest) return;
|
||
const other = tabs.find((r) => (r.slot ?? 0) !== slot && !exited.current.has(runKey(r)));
|
||
if (other) onOpenRun(other.id);
|
||
else onNewSession?.();
|
||
};
|
||
|
||
// A pane header's X: stops that one session, as a one-pane tab's X does —
|
||
// an AI session may be mid-turn, so it asks first; a shell holds no agent.
|
||
const stopPane = async (slot: number) => {
|
||
const run = runOf(slot);
|
||
if (!run) return;
|
||
if (
|
||
run.tool !== "shell" &&
|
||
!(await confirmDialog({
|
||
title: t("tabs.stopTitle", { name: label(run) }),
|
||
message: t("tabs.stopBody"),
|
||
confirmLabel: t("tabs.stopConfirm"),
|
||
destructive: true,
|
||
}))
|
||
)
|
||
return;
|
||
try {
|
||
await api.run.kill(run.id);
|
||
ended(slot);
|
||
} catch (err) {
|
||
toast.error(err);
|
||
}
|
||
};
|
||
|
||
// A split menu's "New terminal" or AI tool (bare, as the tab bar's `+`
|
||
// starts it) in the first free slot, placed on that side of the pane —
|
||
// unless the workspace changed while it started.
|
||
const free = lane ? freeSlot(lane.id, tabs) : null;
|
||
const splitTools = lane ? newTabTools(lane, aiTools) : [];
|
||
const newSplit = async (target: number, edge: Edge, tool?: string) => {
|
||
// With `allTabs`, the split starts in the target pane's own workspace.
|
||
const into = allTabs ? lanes.find((l) => l.id === Math.floor(target / MAX_SLOTS)) : lane;
|
||
if (!into || freeSlot(into.id, tabs) === null) return;
|
||
const laneId = into.id;
|
||
try {
|
||
const body = tool ? newTabBody(tool, into, aiTools) : { shell: true };
|
||
// The server picks the slot: the list `free` came from may be stale.
|
||
const slot = keyOf(await startInFreeTab(laneId, body));
|
||
onRunsChanged();
|
||
if (!allTabs && laneRef.current !== laneId) return;
|
||
pending.current.set(slot, activeRuns);
|
||
change((l) => moveInLayout(l, slot, target, edge));
|
||
} catch (err) {
|
||
toast.error(err);
|
||
}
|
||
};
|
||
|
||
const requested = attachRequest ? [keyOf(attachRequest.runId)] : [];
|
||
// No layout yet (a first visit, a request not yet applied, or no run at
|
||
// all): one pane, so the console always has its single render site.
|
||
const fallback = requested[0] ?? (allTabs ? paneKey(selectedLaneId ?? 0, 0) : 0);
|
||
const view: PaneLayout = layout.root ? layout : { root: pane(fallback), focus: fallback };
|
||
const viewGroups: PaneGroups = groups.groups.length ? groups : { groups: [view], active: 0 };
|
||
// A change to the tab on screen (`PaneGrid`, the split menus). Tabs never
|
||
// saved are the one-pane `view`: change that.
|
||
const change: LayoutUpdate = (fn) =>
|
||
changeGroups((g) => updateActive(g.groups.length ? g : viewGroups, fn));
|
||
const shown = leaves(view.root);
|
||
// A pane spanning the grid's full width or height has room for its chat's
|
||
// right panel to start open; a quarter-sized one starts it collapsed.
|
||
const spanning = new Set(
|
||
layoutRects(view.root)
|
||
.panes.filter((r) => r.w === 1 || r.h === 1)
|
||
.map((r) => r.slot)
|
||
);
|
||
const mounted = [...new Set([...shown, ...tabs.map((r) => r.slot ?? 0)])].sort((a, b) => a - b);
|
||
const focusSlot = view.focus;
|
||
|
||
// The workspaces just left, newest first — updated DURING the render that
|
||
// switches, because the switch's own commit would otherwise unmount the
|
||
// panes before an effect could keep them. Idempotent on a repeat render.
|
||
const leftLanes = useRef<number[]>([]);
|
||
const lastLane = useRef(selectedLaneId);
|
||
if (lastLane.current !== selectedLaneId) {
|
||
const prev = lastLane.current;
|
||
if (prev !== null)
|
||
leftLanes.current = [prev, ...leftLanes.current.filter((id) => id !== prev)].slice(
|
||
0,
|
||
KEEP_LANES
|
||
);
|
||
lastLane.current = selectedLaneId;
|
||
}
|
||
const kept = allTabs
|
||
? []
|
||
: (activeRuns?.items ?? [])
|
||
.filter(
|
||
(r) =>
|
||
r.laneId !== null &&
|
||
r.laneId !== selectedLaneId &&
|
||
r.status === "running" &&
|
||
leftLanes.current.includes(r.laneId)
|
||
)
|
||
.map((r) => {
|
||
const id = r.laneId as number;
|
||
const slot = r.slot ?? 0;
|
||
return {
|
||
key: paneKey(id, slot),
|
||
node: (
|
||
<LaneConsolePane
|
||
lanes={lanes}
|
||
laneId={id}
|
||
slot={slot}
|
||
binaryStatus={binaryStatus}
|
||
activeRuns={activeRuns}
|
||
onNewSession={onNewSession}
|
||
onRunExit={onRunsChanged}
|
||
autoFocus={false}
|
||
attachRequest={null}
|
||
/>
|
||
),
|
||
};
|
||
});
|
||
|
||
// Alt+1–4 focus a pane, Alt+\ splits the focused one right with a new
|
||
// terminal, Alt+W takes it out into a tab of its own. Session view only, and never from inside a
|
||
// terminal or a field, whose keys are their own (Ctrl+M lets Tab out of a
|
||
// terminal first). Read through a ref: one listener, always current.
|
||
const onKeys = useRef<(e: KeyboardEvent) => void>(() => undefined);
|
||
const areaRef = useRef<HTMLDivElement>(null);
|
||
onKeys.current = (e) => {
|
||
if (!attachRequest || settings?.active || !e.altKey || e.ctrlKey || e.metaKey || e.shiftKey)
|
||
return;
|
||
// Not under a dialog, and not while the console is hidden (the Changes
|
||
// panel at full width, a replay, the home) — CSS-hidden, so by class.
|
||
if (document.querySelector('[role="dialog"]')) return;
|
||
if (!areaRef.current || areaRef.current.closest(".hidden")) return;
|
||
const el = e.target as HTMLElement | null;
|
||
if (el?.closest?.(".xterm")) return;
|
||
if (el && (el.isContentEditable || /^(INPUT|TEXTAREA|SELECT)$/.test(el.tagName))) return;
|
||
const digit = /^Digit([1-4])$/.exec(e.code);
|
||
if (digit) {
|
||
const s = shown[Number(digit[1]) - 1];
|
||
if (s === undefined) return;
|
||
e.preventDefault();
|
||
change((l) => (l.root && l.focus !== s ? { ...l, focus: s } : l));
|
||
} else if (e.code === "Backslash" && focusSlot !== null) {
|
||
e.preventDefault();
|
||
if (free !== null && shown.length < MAX_PANES) void newSplit(focusSlot, "right");
|
||
} else if (e.code === "KeyW" && focusSlot !== null && shown.length > 1) {
|
||
e.preventDefault();
|
||
change((l) => closeInLayout(l, focusSlot));
|
||
}
|
||
};
|
||
useEffect(() => {
|
||
const on = (e: KeyboardEvent) => onKeys.current(e);
|
||
document.addEventListener("keydown", on);
|
||
return () => document.removeEventListener("keydown", on);
|
||
}, []);
|
||
|
||
return (
|
||
<div ref={areaRef} className="flex min-h-0 flex-1 flex-col">
|
||
{lane && (
|
||
<ConsoleTabs
|
||
lane={lane}
|
||
tabs={tabs}
|
||
groups={viewGroups}
|
||
onGroupsChange={changeGroups}
|
||
onOpen={(runId, split) => {
|
||
onRunsChanged();
|
||
// A tab click is not a new history entry: Back leaves the
|
||
// session view instead of walking back through every tab.
|
||
onOpenRun(runId, { split, replace: true });
|
||
}}
|
||
sessions={tabSessions}
|
||
workspaceOf={allTabs ? laneName : undefined}
|
||
onRenamed={() => setSessionsVersion((v) => v + 1)}
|
||
onClosed={(runId) => ended(keyOf(runId))}
|
||
onHome={onNewSession}
|
||
settings={settings}
|
||
// While one pane is shown, its split menus (a split pane has its own).
|
||
actions={
|
||
!settings?.active && shown.length === 1 && shown[0] !== undefined ? (
|
||
<div className="flex items-center gap-1">
|
||
<SplitMenus
|
||
slot={shown[0]}
|
||
shown={shown}
|
||
runs={tabs}
|
||
label={label}
|
||
onChange={change}
|
||
canStart={free !== null}
|
||
tools={splitTools}
|
||
toolLabel={aiTools.label}
|
||
onNewSplit={(target, edge, tool) => void newSplit(target, edge, tool)}
|
||
/>
|
||
</div>
|
||
) : undefined
|
||
}
|
||
/>
|
||
)}
|
||
{settings && (
|
||
<div
|
||
data-testid="workspace-settings"
|
||
className={settings.active ? "min-h-0 flex-1 overflow-y-auto p-5 lg:p-6" : "hidden"}
|
||
>
|
||
<SettingsHub section={settings.section} />
|
||
</div>
|
||
)}
|
||
<div className={settings?.active ? "hidden" : "contents"}>
|
||
<PaneGrid
|
||
layout={view}
|
||
mounted={mounted}
|
||
runs={tabs}
|
||
label={label}
|
||
onChange={change}
|
||
canStart={free !== null}
|
||
tools={splitTools}
|
||
toolLabel={aiTools.label}
|
||
onNewSplit={(target, edge, tool) => void newSplit(target, edge, tool)}
|
||
stateOf={stateOf}
|
||
onStop={(slot) => void stopPane(slot)}
|
||
// Keyed by workspace too, so a kept pane coming back is the same one.
|
||
keyOf={allTabs ? undefined : (slot) => paneKey(selectedLaneId ?? 0, slot)}
|
||
kept={kept}
|
||
renderPane={(slot) => (
|
||
<LaneConsolePane
|
||
lanes={lanes}
|
||
// With `allTabs` a pane is bound to its own run's workspace.
|
||
laneId={allTabs ? Math.floor(slot / MAX_SLOTS) : selectedLaneId}
|
||
slot={slotOfKey(slot)}
|
||
binaryStatus={binaryStatus}
|
||
activeRuns={activeRuns}
|
||
onHasActiveRunChange={slot === focusSlot ? onHasActiveRunChange : undefined}
|
||
onNewSession={onNewSession}
|
||
onRunExit={() => ended(slot)}
|
||
// Only the focused pane takes the keyboard when it appears.
|
||
autoFocus={slot === focusSlot}
|
||
panelOpen={spanning.has(slot)}
|
||
attachRequest={
|
||
attachRequest && keyOf(attachRequest.runId) === slot ? attachRequest : null
|
||
}
|
||
/>
|
||
)}
|
||
/>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
export function Workspace() {
|
||
const { t: tLanes } = useTranslation("lanes");
|
||
const { local } = usePermissions();
|
||
const [searchParams, setSearchParams] = useSearchParams();
|
||
|
||
// Lane state: the list is the shared store the sidebar reads too; the
|
||
// selection is the URL's `?lane=` (the sidebar sets it, so does a
|
||
// notification click), falling back to the last one shown, then the first.
|
||
const { lanes, loaded: lanesLoaded, refresh: refreshLanes } = useWorkLanes();
|
||
const laneParam = searchParams.get("lane");
|
||
// `?run=` — a live session picked in the sidebar; the location key makes a
|
||
// second pick of the same session a new request.
|
||
const location = useLocation();
|
||
const runParam = searchParams.get("run");
|
||
// `?session=` — an ENDED session picked in the sidebar or Recents: its
|
||
// conversation, with a prompt box whose message resumes it (SessionReplay).
|
||
const sessionParam = runParam ? null : searchParams.get("session");
|
||
// `?settings=<section>` — the Settings tab is on screen ("" = the section it
|
||
// was last on). It stays open (`settingsTab`, its last section) until its X.
|
||
const settingsParam = searchParams.get("settings");
|
||
const [settingsTab, setSettingsTab] = useState<string | null>(null);
|
||
const settingsSection = settingsParam || settingsTab || "appearance";
|
||
useEffect(() => {
|
||
if (settingsParam !== null) setSettingsTab(settingsSection);
|
||
}, [settingsParam, settingsSection]);
|
||
const navigate = useNavigate();
|
||
// A Ctrl/Cmd+click (tab or sidebar) opens the run in a split;
|
||
// it rides on the navigation's state, never in the URL.
|
||
const navState = location.state as { split?: unknown; focusOnly?: unknown } | null;
|
||
const splitOpen = navState?.split === true;
|
||
const focusOnly = navState?.focusOnly === true;
|
||
const attachRequest = useMemo(
|
||
() => (runParam ? { runId: runParam, nonce: location.key, split: splitOpen, focusOnly } : null),
|
||
[runParam, location.key, splitOpen, focusOnly]
|
||
);
|
||
const paramId = laneParam === null ? null : Number(laneParam);
|
||
const fallbackId = (() => {
|
||
if (!lanesLoaded) return null;
|
||
let stored: number | null = null;
|
||
try {
|
||
stored = Number(window.localStorage.getItem(SELECTED_KEY));
|
||
} catch {
|
||
/* disabled storage */
|
||
}
|
||
return lanes.find((l) => l.id === stored)?.id ?? lanes[0]?.id ?? null;
|
||
})();
|
||
const selectedLaneId =
|
||
paramId !== null && lanes.some((l) => l.id === paramId) ? paramId : fallbackId;
|
||
const setSelectedLaneId = useCallback(
|
||
(id: number | null) =>
|
||
setSearchParams(
|
||
(prev) => {
|
||
const next = new URLSearchParams(prev);
|
||
// A run belongs to its lane: another lane opens on its home.
|
||
if (next.get("lane") !== String(id)) {
|
||
next.delete("run");
|
||
next.delete("session");
|
||
}
|
||
if (id === null) next.delete("lane");
|
||
else next.set("lane", String(id));
|
||
return next;
|
||
},
|
||
{ replace: true }
|
||
),
|
||
[setSearchParams]
|
||
);
|
||
// Name the fallback in the URL, so the sidebar highlights it and F5 keeps it.
|
||
// Only a URL naming NO lane is rewritten: one naming a lane the list has not
|
||
// caught up with yet (just created) must survive until the refetch lands.
|
||
useEffect(() => {
|
||
if (!lanesLoaded || selectedLaneId === null) return;
|
||
try {
|
||
window.localStorage.setItem(SELECTED_KEY, String(selectedLaneId));
|
||
} catch {
|
||
/* disabled storage */
|
||
}
|
||
if (paramId === null) setSelectedLaneId(selectedLaneId);
|
||
}, [lanesLoaded, selectedLaneId, paramId, setSelectedLaneId]);
|
||
// A lane action's failure, as a toast (it stays until dismissed).
|
||
const setLaneActionError = (message: string | null) => {
|
||
if (message) toast.error(tLanes("actionError", { message }));
|
||
};
|
||
const [destructive, setDestructive] = useState<{
|
||
lane: Lane;
|
||
action: "reset" | "remove";
|
||
} | null>(null);
|
||
const [addLaneOpen, setAddLaneOpen] = useState(false);
|
||
// Fields the Add-lane modal opens with when it was opened from a lane's git
|
||
// bar ("new lane here"): one provisioning path for every lane, prefilled.
|
||
const [addLanePrefill, setAddLanePrefill] = useState<{
|
||
sourceRepo?: string;
|
||
branch?: string;
|
||
} | null>(null);
|
||
const [viewedFeatureSlug, setViewedFeatureSlug] = useState<string | null>(null);
|
||
const [features, setFeatures] = useState<LaneFeature[]>([]);
|
||
const [pipelineTemplates, setPipelineTemplates] = useState<
|
||
{ id: string; name: string; nodes: { id: string }[] }[]
|
||
>([]);
|
||
const [viewedFeature, setViewedFeature] = useState<LaneFeature | null>(null);
|
||
const [proofFeatures, setProofFeatures] = useState<ProofFeature[]>([]);
|
||
|
||
// Run state kept at page level: shared across every pane, or drives the
|
||
// lane strip itself rather than any one pane's form.
|
||
const [activeRuns, setActiveRuns] = useState<RunListResponse | null>(null);
|
||
const [binaryStatus, setBinaryStatus] = useState<Record<
|
||
string,
|
||
{ found: boolean; path: string | null }
|
||
> | null>(null);
|
||
const [cwdSuggestions, setCwdSuggestions] = useState<CwdSuggestion[]>([]);
|
||
const [paneHasActiveRun, setPaneHasActiveRun] = useState(false);
|
||
// Settings → Appearance: tabs from every workspace. Read on mount — the
|
||
// switch lives on another page.
|
||
const [allTabs] = useState(readAllTabs);
|
||
|
||
// Whether the Changes panel is open. A viewer preference, so it lives in
|
||
// localStorage and never reaches the server — same rule as the lane strip's
|
||
// order (client/src/lib/laneOrderStorage.ts).
|
||
// The File Explorer dialog, opened from the Changes panel header.
|
||
const [explorerOpen, setExplorerOpen] = useState(false);
|
||
// Bumped when the File Explorer changed files: the Files tree reads again.
|
||
const [filesChanged, setFilesChanged] = useState(0);
|
||
const [changesOpen, setChangesOpen] = useState(() => {
|
||
try {
|
||
return window.localStorage.getItem(CHANGES_OPEN_KEY) === "1";
|
||
} catch {
|
||
return false;
|
||
}
|
||
});
|
||
useEffect(() => {
|
||
try {
|
||
window.localStorage.setItem(CHANGES_OPEN_KEY, changesOpen ? "1" : "0");
|
||
} catch {
|
||
/* private mode / disabled storage: the panel just forgets between visits */
|
||
}
|
||
}, [changesOpen]);
|
||
// The Changes panel's bottom fold (instructions, files, folders): folded by
|
||
// default so the git sections keep the height.
|
||
const [contextOpen, setContextOpen] = useState(() => {
|
||
try {
|
||
return window.localStorage.getItem(CONTEXT_OPEN_KEY) === "1";
|
||
} catch {
|
||
return false;
|
||
}
|
||
});
|
||
useEffect(() => {
|
||
try {
|
||
window.localStorage.setItem(CONTEXT_OPEN_KEY, contextOpen ? "1" : "0");
|
||
} catch {
|
||
/* disabled storage: the fold just forgets between visits */
|
||
}
|
||
}, [contextOpen]);
|
||
// A preset name, or the fraction a splitter drag left (stored as a number).
|
||
const [changesSize, setChangesSize] = useState<ChangesSize>(() => {
|
||
try {
|
||
const saved = window.localStorage.getItem(CHANGES_SIZE_KEY);
|
||
if (saved && (CHANGES_SIZES as readonly string[]).includes(saved))
|
||
return saved as ChangesPreset;
|
||
const n = Number(saved);
|
||
return saved && Number.isFinite(n) ? clampChanges(n) : "compact";
|
||
} catch {
|
||
return "compact";
|
||
}
|
||
});
|
||
useEffect(() => {
|
||
try {
|
||
window.localStorage.setItem(CHANGES_SIZE_KEY, String(changesSize));
|
||
if (typeof changesSize === "number")
|
||
window.localStorage.setItem(CHANGES_DRAGGED_KEY, String(changesSize));
|
||
} catch {
|
||
/* same as above: a forgotten width, not an error */
|
||
}
|
||
}, [changesSize]);
|
||
const changesFull = changesSize === "full";
|
||
const changesSizeLabel = changesFull ? tLanes("changes.restore") : tLanes("changes.size.full");
|
||
const changesRowRef = useRef<HTMLDivElement>(null);
|
||
// The row's width: the panel never gets narrower than its 18rem minimum,
|
||
// so the splitter is drawn — and dragged — no further than that edge.
|
||
const [rowWidth, setRowWidth] = useState(0);
|
||
useEffect(() => {
|
||
const el = changesRowRef.current;
|
||
if (!el || typeof ResizeObserver === "undefined") return;
|
||
const ro = new ResizeObserver(() => setRowWidth(el.clientWidth));
|
||
ro.observe(el);
|
||
setRowWidth(el.clientWidth);
|
||
return () => ro.disconnect();
|
||
}, []);
|
||
const minChanges = rowWidth ? Math.min(0.85, CHANGES_MIN_PX / rowWidth) : 0.15;
|
||
|
||
// The session the opened run holds, when that run works in a worktree of
|
||
// its own (`claude --worktree`): the Changes panel shows that tree. A
|
||
// replayed session (`?session=`) shows its own worktree the same way.
|
||
const openedRun = runParam ? activeRuns?.items.find((r) => r.id === runParam) : undefined;
|
||
// Its worktree is checked once (`GET …/git?session=`): an ended session's
|
||
// worktree Claude already removed answers ENOWORKTREE, and the panel then
|
||
// shows the workspace's own folder instead of an error.
|
||
const [replayed, setReplayed] = useState<{ id: string; worktree: boolean } | null>(null);
|
||
const replayLane = lanes.find((l) => l.id === selectedLaneId);
|
||
useEffect(() => {
|
||
setReplayed(null);
|
||
if (!sessionParam || !replayLane) return;
|
||
let live = true;
|
||
void (async () => {
|
||
try {
|
||
const { session } = await api.sessions.get(sessionParam, { events: false });
|
||
if (!sessionWorktreeName(replayLane, session)) {
|
||
if (live) setReplayed({ id: sessionParam, worktree: false });
|
||
return;
|
||
}
|
||
const ok = await api.lanes
|
||
.git({ lane: replayLane.id, session: sessionParam })
|
||
.then(() => true)
|
||
.catch((err: { code?: string }) => err?.code !== "ENOWORKTREE");
|
||
if (live) setReplayed({ id: sessionParam, worktree: ok });
|
||
} catch {
|
||
/* no row: the workspace's folder */
|
||
}
|
||
})();
|
||
return () => {
|
||
live = false;
|
||
};
|
||
// eslint-disable-next-line react-hooks/exhaustive-deps -- the lane by id
|
||
}, [sessionParam, replayLane?.id]);
|
||
const runWorktree = useRef<RunWorktree | null>(null);
|
||
runWorktree.current = runWorktreeSession(runWorktree.current, runParam, openedRun);
|
||
const gitSession = runWorktree.current
|
||
? runWorktree.current.session
|
||
: sessionParam && replayed?.id === sessionParam && replayed.worktree
|
||
? sessionParam
|
||
: undefined;
|
||
|
||
// An unsaved whole-file edit in a Changes panel section, by section key. While
|
||
// one is open the panel stays on the tree it was opened in — moving it
|
||
// (another workspace, session or worktree) would remount it and drop the
|
||
// edit — until the user says to discard it, or saves.
|
||
const [dirtyBy, setDirtyBy] = useState<Record<string, boolean>>({});
|
||
const changesDirty = Object.values(dirtyBy).some(Boolean);
|
||
const dirtyFor = useCallback(
|
||
(key: string) => (dirty: boolean) => setDirtyBy((d) => ({ ...d, [key]: dirty })),
|
||
[]
|
||
);
|
||
const wantPanel =
|
||
selectedLaneId === null ? null : { laneId: selectedLaneId, session: gitSession };
|
||
const keyOf = (p: typeof wantPanel) => (p ? `${p.laneId}:${p.session ?? ""}` : "");
|
||
const wantPanelKey = keyOf(wantPanel);
|
||
const [panelAt, setPanelAt] = useState(wantPanel);
|
||
const panelKey = keyOf(panelAt);
|
||
|
||
// The workspace's extra folders: each is its own repo, stacked under the
|
||
// workspace folder's in the Changes panel (`?source=`). Read for the
|
||
// workspace the panel shows, and again from the home's Folder list whenever
|
||
// it adds or removes one.
|
||
const [gitFolders, setGitFolders] = useState<{ laneId: number; items: LaneSource[] } | null>(
|
||
null
|
||
);
|
||
const takeGitFolders = useCallback(
|
||
(laneId: number, view: LaneSourcesView) =>
|
||
setGitFolders({
|
||
laneId,
|
||
items: view.sources.filter((s) => s.kind === "folder" && !s.missing),
|
||
}),
|
||
[]
|
||
);
|
||
const panelLaneId = panelAt?.laneId ?? null;
|
||
useEffect(() => {
|
||
if (panelLaneId === null) return;
|
||
api.lanes.sources(panelLaneId).then(
|
||
(v) => takeGitFolders(panelLaneId, v),
|
||
() => setGitFolders({ laneId: panelLaneId, items: [] })
|
||
);
|
||
}, [panelLaneId, takeGitFolders]);
|
||
const folders = gitFolders?.laneId === panelLaneId ? gitFolders.items : [];
|
||
// Sections folded shut ("" = the workspace folder, else the folder's id).
|
||
const [foldedSources, setFoldedSources] = useState<Set<string>>(new Set());
|
||
// Right-click on a section: close extra folders — which REMOVES them from the
|
||
// workspace (the folder stays on disk). The workspace folder never closes.
|
||
const sourceCtx = useContextMenu<string>();
|
||
// Bumped after a close here or an edit in either context list (the home's,
|
||
// the Changes panel's fold), so every one on screen re-reads.
|
||
const [sourcesVersion, setSourcesVersion] = useState(0);
|
||
const sourcesChanged = (laneId: number, view: LaneSourcesView) => {
|
||
takeGitFolders(laneId, view);
|
||
setSourcesVersion((n) => n + 1);
|
||
};
|
||
const closeSources = async (laneId: number, keys: string[]) => {
|
||
if (keys.some((k) => dirtyBy[k]) && !(await confirmDiscard())) return;
|
||
try {
|
||
let view: LaneSourcesView | null = null;
|
||
for (const k of keys) view = await api.lanes.removeSource(laneId, Number(k));
|
||
if (view) takeGitFolders(laneId, view);
|
||
} catch (err) {
|
||
toast.error(err instanceof Error ? err.message : String(err));
|
||
api.lanes.sources(laneId).then(
|
||
(v) => takeGitFolders(laneId, v),
|
||
() => undefined
|
||
);
|
||
}
|
||
setSourcesVersion((n) => n + 1);
|
||
};
|
||
const asking = useRef(false);
|
||
useEffect(() => {
|
||
if (wantPanelKey === panelKey) return;
|
||
if (!changesDirty) {
|
||
setPanelAt(wantPanel);
|
||
return;
|
||
}
|
||
if (asking.current) return;
|
||
asking.current = true;
|
||
void confirmDiscard().then((ok) => {
|
||
asking.current = false;
|
||
if (!ok) return;
|
||
setDirtyBy({});
|
||
setPanelAt(wantPanel);
|
||
});
|
||
// eslint-disable-next-line react-hooks/exhaustive-deps -- keyed on the keys
|
||
}, [wantPanelKey, panelKey, changesDirty]);
|
||
// A chat's "Edited N files" card asks for a file: open the panel on it, if
|
||
// the panel shows that workspace.
|
||
const [changesRequest, setChangesRequest] = useState<{ path: string; nonce: number } | null>(
|
||
null
|
||
);
|
||
const panelLaneRef = useRef<number | null>(null);
|
||
panelLaneRef.current = panelAt?.laneId ?? null;
|
||
useEffect(() => {
|
||
const onOpen = (e: Event) => {
|
||
const { laneId, path } = (e as CustomEvent<OpenInChanges>).detail;
|
||
if (laneId !== panelLaneRef.current) return;
|
||
// Its path is the workspace folder's: unfold that section.
|
||
setFoldedSources((prev) => {
|
||
const next = new Set(prev);
|
||
next.delete("");
|
||
return next;
|
||
});
|
||
setChangesOpen(true);
|
||
setChangesRequest((r) => ({ path, nonce: (r?.nonce ?? 0) + 1 }));
|
||
};
|
||
window.addEventListener(OPEN_IN_CHANGES_EVENT, onOpen);
|
||
return () => window.removeEventListener(OPEN_IN_CHANGES_EVENT, onOpen);
|
||
}, []);
|
||
// Asked once: a panel reopened or moved to another tree must not replay it.
|
||
useEffect(() => {
|
||
if (!changesOpen) setChangesRequest(null);
|
||
}, [changesOpen]);
|
||
useEffect(() => setChangesRequest(null), [panelKey]);
|
||
/** Runs `fn` once an unsaved edit (if any) may be dropped. */
|
||
const afterDrop = (fn: () => void) => {
|
||
if (!changesDirty) return fn();
|
||
void confirmDiscard().then((ok) => ok && fn());
|
||
};
|
||
|
||
// Probe the CLI of every tool a lane uses — fresh on the server, so a CLI
|
||
// installed since the page loaded clears its missing-binary banner. Claude is
|
||
// always probed: the console's no-lane state runs it.
|
||
const laneToolsKey = Array.from(new Set(["claude", ...lanes.map((l) => l.tool || "claude")]))
|
||
.sort()
|
||
.join(",");
|
||
useEffect(() => {
|
||
const tools = laneToolsKey.split(",");
|
||
Promise.all(tools.map((tool) => api.run.binary(tool)))
|
||
.then((found) =>
|
||
setBinaryStatus(Object.fromEntries(tools.map((tool, i) => [tool, found[i]!])))
|
||
)
|
||
// Fetch failure (server unreachable, proxy misrouted, etc.) isn't proof
|
||
// a CLI is missing from PATH — leave the probe unresolved rather than
|
||
// showing a misleading "binary missing" banner for an unrelated fault.
|
||
.catch(() => undefined);
|
||
}, [laneToolsKey]);
|
||
|
||
useEffect(() => {
|
||
api.lanes
|
||
.pipelines()
|
||
.then((r) => setPipelineTemplates(r.pipelines))
|
||
.catch(() => undefined);
|
||
api.run
|
||
.cwds()
|
||
.then((r) => setCwdSuggestions(r.items))
|
||
.catch(() => undefined);
|
||
}, []);
|
||
|
||
// Goes through `keepIfSame`: this fires every 5 s below, and a
|
||
// fresh-but-identical payload would otherwise re-render the lane strip, the
|
||
// lane card and the whole console area on every tick for nothing.
|
||
const refreshList = useCallback(() => {
|
||
api.run
|
||
.list()
|
||
.then((r) => setActiveRuns(keepIfSame(r)))
|
||
.catch(() => undefined);
|
||
}, []);
|
||
|
||
// The sidebar's live panes follow this page's poll: a tab in slots 1–3
|
||
// moves no lane column, so the sidebar would not re-read on its own.
|
||
useEffect(() => {
|
||
if (activeRuns)
|
||
window.dispatchEvent(new CustomEvent(LIVE_RUNS_EVENT, { detail: activeRuns.items }));
|
||
}, [activeRuns]);
|
||
|
||
// Background poll so the run list and history reflect external changes
|
||
// (server-boot reconciliation, sibling tabs, direct DB edits) even when
|
||
// no WS event fires. Lighter than typical WS gaps; aggressive enough that
|
||
// status flips appear within seconds without needing a manual refresh.
|
||
useEffect(() => {
|
||
refreshList();
|
||
const tick = setInterval(() => {
|
||
// Hidden tab: skip; the visibility handler below re-reads on show.
|
||
if (document.visibilityState === "visible") refreshList();
|
||
}, 5000);
|
||
return () => clearInterval(tick);
|
||
}, [refreshList]);
|
||
|
||
// Refresh whenever the tab regains focus / visibility - typical when the
|
||
// user comes back from running `claude` in a terminal and wants to see the
|
||
// current state of every run without waiting for the next poll.
|
||
useEffect(() => {
|
||
const onFocus = () => refreshList();
|
||
const onVis = () => {
|
||
if (document.visibilityState === "visible") refreshList();
|
||
};
|
||
window.addEventListener("focus", onFocus);
|
||
document.addEventListener("visibilitychange", onVis);
|
||
return () => {
|
||
window.removeEventListener("focus", onFocus);
|
||
document.removeEventListener("visibilitychange", onVis);
|
||
};
|
||
}, [refreshList]);
|
||
|
||
const currentLane = selectedLaneId !== null ? lanes.find((l) => l.id === selectedLaneId) : null;
|
||
// Only claude lanes carry a pipeline (codex and antigravity emit no hooks),
|
||
// and only while a template is installed — none ships, so it is dormant.
|
||
const noPipeline =
|
||
!!currentLane && (currentLane.tool !== "claude" || currentLane.pipeline_nodes.length === 0);
|
||
|
||
// Feature list follows the selected lane, resets the viewer on lane switch.
|
||
useEffect(() => {
|
||
setViewedFeatureSlug(null);
|
||
setViewedFeature(null);
|
||
if (currentLane === null || currentLane === undefined) {
|
||
setFeatures([]);
|
||
return;
|
||
}
|
||
api.lanes.features
|
||
.list(currentLane.id)
|
||
.then((data) => setFeatures(data.features))
|
||
.catch(() => setFeatures([]));
|
||
}, [currentLane?.id]);
|
||
|
||
// Fetch the archived snapshot when the picker selects one — read-only, never
|
||
// touches the live lane.
|
||
useEffect(() => {
|
||
if (!currentLane || !viewedFeatureSlug) {
|
||
setViewedFeature(null);
|
||
return;
|
||
}
|
||
let cancelled = false;
|
||
api.lanes.features
|
||
.show(currentLane.id, viewedFeatureSlug)
|
||
.then((data) => {
|
||
if (!cancelled) setViewedFeature(data.feature);
|
||
})
|
||
.catch(() => {
|
||
if (!cancelled) setViewedFeature(null);
|
||
});
|
||
return () => {
|
||
cancelled = true;
|
||
};
|
||
}, [currentLane?.id, viewedFeatureSlug]);
|
||
|
||
// Proof gallery follows the selected lane.
|
||
useEffect(() => {
|
||
if (!currentLane) {
|
||
setProofFeatures([]);
|
||
return;
|
||
}
|
||
api.lanes.proof
|
||
.list(currentLane.id)
|
||
.then((data) => setProofFeatures(data.features))
|
||
.catch(() => setProofFeatures([]));
|
||
}, [currentLane?.id, viewedFeatureSlug]);
|
||
|
||
// Resolve the active feature slug: either the user-selected one, or the lane's active_feature_id
|
||
const activeFeatureSlug =
|
||
viewedFeatureSlug ??
|
||
(currentLane?.active_feature_id
|
||
? features.find((f) => f.id === currentLane?.active_feature_id)?.slug
|
||
: null);
|
||
const proofFeature = activeFeatureSlug
|
||
? proofFeatures.find((f) => f.slug === activeFeatureSlug)
|
||
: null;
|
||
|
||
// Viewport locked only when a live run is showing (TerminalView needs locked
|
||
// viewport for chat scrolling). The config-card screen needs normal page flow
|
||
// so the form is fully reachable on short windows. A full-width Changes panel
|
||
// hides that form anyway, and without a locked height it shrinks to its
|
||
// content — the editor inside it collapsed to a few lines (no live run).
|
||
// A workspace opens on its HOME (WorkspaceHome); `?run=` opens a session —
|
||
// the console, panes and Changes panel, which the home only hides (CSS), so
|
||
// a live terminal never unmounts behind it.
|
||
const settingsOn = !!currentLane && settingsParam !== null;
|
||
const home = !!currentLane && !runParam && !sessionParam && !settingsOn;
|
||
const replaying = !!currentLane && !!sessionParam && !settingsOn;
|
||
// The session the Analyst button opens (`/sessions/:id`): the replayed one,
|
||
// or the one the opened pane holds.
|
||
const analystSessionId = sessionParam ?? openedRun?.sessionId ?? null;
|
||
// The session view (console or replay) fills the page edge to edge, like an
|
||
// editor: no page padding, no cards, dividers between its regions.
|
||
const sessionView = !!currentLane && !home;
|
||
const viewportLocked =
|
||
sessionView || (!home && (paneHasActiveRun || (!!currentLane && changesOpen && changesFull)));
|
||
|
||
const handleLaneAction = async (id: number, action: string, body?: Record<string, unknown>) => {
|
||
setLaneActionError(null);
|
||
try {
|
||
if (action === "reset" || action === "remove" || action === "purge") {
|
||
await api.lanes.action(id, action, {
|
||
confirm: true,
|
||
expect: body?.expect,
|
||
...(body?.force === true ? { force: true } : {}),
|
||
});
|
||
} else {
|
||
await api.lanes.action(id, action, body);
|
||
}
|
||
if (action === "remove") await refreshLanes();
|
||
} catch (err) {
|
||
setLaneActionError(err instanceof Error ? err.message : tLanes("actionErrorUnknown"));
|
||
}
|
||
};
|
||
|
||
// Mirrors `agenthub lanes pipeline <template> <id>`: same PATCH, same
|
||
// stage-mismatch warning when the current declared stage matches no node
|
||
// in the newly chosen template.
|
||
const handlePipelineChange = async (id: number, pipeline: string) => {
|
||
setLaneActionError(null);
|
||
try {
|
||
const { lane } = await api.lanes.update(id, { pipeline });
|
||
await refreshLanes();
|
||
// `stage` defaults to the DB sentinel "idle" until a driving session ever
|
||
// calls `agenthub stage` — that's the normal state for most lanes and never
|
||
// matches a real pipeline node, so it isn't a mismatch worth surfacing.
|
||
if (lane.stage !== "idle" && !lane.pipeline_nodes.some((n) => n.state === "current")) {
|
||
setLaneActionError(
|
||
tLanes("pipelinePicker.stageMismatch", {
|
||
stage: lane.stage,
|
||
pipeline: lane.pipeline,
|
||
nodes: lane.pipeline_nodes.map((n) => n.id).join(", "),
|
||
})
|
||
);
|
||
}
|
||
} catch (err) {
|
||
setLaneActionError(err instanceof Error ? err.message : tLanes("actionErrorUnknown"));
|
||
}
|
||
};
|
||
|
||
return (
|
||
<div
|
||
className={
|
||
sessionView
|
||
? // Cancels Layout's page padding; the 1.5rem left is the status bar's,
|
||
// plus the drawer's 2.75rem menu bar below `md` (issue #61).
|
||
"-m-5 flex h-[calc(100vh-1.5rem)] flex-col max-md:h-[calc(100vh-4.25rem)] lg:-m-6"
|
||
: viewportLocked
|
||
? "h-[calc(100vh-4rem)] max-md:h-[calc(100vh-6.75rem)] lg:h-[calc(100vh-4.5rem)] flex flex-col gap-5"
|
||
: "space-y-5"
|
||
}
|
||
>
|
||
{/* Workspaces are picked — and added, with the + — in the sidebar's
|
||
project tree; this page shows the selected one. */}
|
||
<div className="flex min-h-0 flex-1 flex-col gap-4">
|
||
{/* The selected lane's pipeline — the thing you actually come to this
|
||
page to read, beside the list rather than stacked under it.
|
||
The section itself is ALWAYS rendered, even with no lane selected:
|
||
`ConsoleArea` lives inside it, and a conditional wrapper would give
|
||
the console two render sites, so every flip of `currentLane` (lanes
|
||
arriving on mount, a lane removed, a refetch re-picking the
|
||
selection) unmounted the terminal and wiped a half-typed prompt.
|
||
Same rule as RunConsole's own collapse — hide with CSS, never by
|
||
unmounting. */}
|
||
{/* Detail and the Changes panel share one row: the panel is a reading
|
||
surface for the same lane the console is driving, so it sits beside
|
||
it rather than under it. It collapses to a rail instead of
|
||
unmounting nothing — there is no live subscription inside it, so
|
||
unmounting is safe and keeps a closed panel from polling. */}
|
||
{home && currentLane && (
|
||
<WorkspaceHome
|
||
lane={currentLane}
|
||
onRenamed={() => void refreshLanes()}
|
||
onSourcesChange={(v) => sourcesChanged(currentLane.id, v)}
|
||
sourcesVersion={sourcesVersion}
|
||
onMenuAction={(action) => {
|
||
if (action === "new-here") {
|
||
setAddLanePrefill({ sourceRepo: currentLane.source_repo || currentLane.cwd });
|
||
setAddLaneOpen(true);
|
||
} else if (action === "reset" || action === "remove") {
|
||
setDestructive({ lane: currentLane, action });
|
||
}
|
||
}}
|
||
/>
|
||
)}
|
||
<div ref={changesRowRef} className={`relative flex min-h-0 flex-1${home ? " hidden" : ""}`}>
|
||
<section
|
||
data-testid="lane-detail"
|
||
className={`flex min-h-0 min-w-0 flex-1 flex-col${
|
||
currentLane && changesOpen && changesFull ? " hidden" : ""
|
||
}`}
|
||
>
|
||
{/* No header row: the console's tab bar is the header. Only a
|
||
workspace with a pipeline template (dormant: none ships) keeps
|
||
its time-at-stage, pipeline and feature pickers here. */}
|
||
{currentLane && !noPipeline && (
|
||
<div className="mb-3 flex flex-wrap items-center gap-2">
|
||
<span className="shrink-0 tabular-nums text-xs text-fg-muted">
|
||
{since(currentLane.stage_seconds)}
|
||
</span>
|
||
<select
|
||
data-testid="pipeline-picker"
|
||
aria-label={tLanes("pipelinePicker.label")}
|
||
className="select !w-auto !rounded !py-0.5 !pl-2 !pr-6 !text-xs text-fg-secondary"
|
||
value={currentLane.pipeline}
|
||
disabled={!!viewedFeature}
|
||
title={
|
||
viewedFeature
|
||
? tLanes("features.viewingArchived", { slug: viewedFeature.slug })
|
||
: undefined
|
||
}
|
||
onChange={(e) => void handlePipelineChange(currentLane.id, e.target.value)}
|
||
>
|
||
{(pipelineTemplates.length
|
||
? pipelineTemplates
|
||
: [
|
||
{
|
||
id: currentLane.pipeline,
|
||
name: currentLane.pipeline_name,
|
||
nodes: [],
|
||
},
|
||
]
|
||
).map((p) => (
|
||
<option key={p.id} value={p.id}>
|
||
{p.nodes.length ? `${p.name} (${p.nodes.length})` : p.name}
|
||
</option>
|
||
))}
|
||
</select>
|
||
{/* No stage chip and no "auto: <stage>" chip here: the
|
||
PipelineMap right below this header already draws both
|
||
— the declared node as the current ring, the detected
|
||
one as its amber badge — so the chips were a second,
|
||
smaller copy of the same two facts. */}
|
||
{features.length > 0 && (
|
||
<select
|
||
data-testid="feature-picker"
|
||
className="select !w-auto !rounded !py-0.5 !pl-2 !pr-6 !text-xs"
|
||
value={viewedFeatureSlug ?? ""}
|
||
onChange={(e) => setViewedFeatureSlug(e.target.value || null)}
|
||
>
|
||
<option value="">{tLanes("features.live")}</option>
|
||
{features.map((f) => (
|
||
<option key={f.slug} value={f.slug}>
|
||
{f.slug}
|
||
{f.archived_at ? ` (${tLanes("features.archived")})` : ""}
|
||
</option>
|
||
))}
|
||
</select>
|
||
)}
|
||
</div>
|
||
)}
|
||
{currentLane && (
|
||
<div className="max-h-[45vh] shrink-0 overflow-y-auto px-3">
|
||
{/* No margin when the card rendered nothing (see LaneCard). */}
|
||
<div className="mb-3 has-[>div:empty]:mb-0">
|
||
<LaneCard
|
||
lane={currentLane}
|
||
onAction={(a, b) => handleLaneAction(currentLane.id, a, b)}
|
||
childWorktrees={lanes.filter(
|
||
(l) => l.source_repo === currentLane.cwd && l.id !== currentLane.id
|
||
)}
|
||
onSelectLane={setSelectedLaneId}
|
||
/>
|
||
</div>
|
||
{!noPipeline && (
|
||
<div className="mb-3">
|
||
<PipelineMap
|
||
nodes={
|
||
viewedFeature ? viewedFeature.pipeline_nodes : currentLane.pipeline_nodes
|
||
}
|
||
detectedSignal={viewedFeature ? undefined : currentLane.detected_signal}
|
||
/>
|
||
{viewedFeature && (
|
||
<p data-testid="feature-viewer-banner" className="mb-2 text-xs text-fg-muted">
|
||
{tLanes("features.viewingArchived", { slug: viewedFeature.slug })}
|
||
</p>
|
||
)}
|
||
</div>
|
||
)}
|
||
{proofFeature &&
|
||
(Object.keys(proofFeature.groups).length > 0 || proofFeature.ticket_report) && (
|
||
<div data-testid="proof-gallery" className="mt-2">
|
||
{proofFeature.ticket_report && (
|
||
<a
|
||
href={`/api/lanes/${currentLane.id}/proof/${proofFeature.ticket_report}`}
|
||
target="_blank"
|
||
rel="noreferrer"
|
||
className="text-xs text-fg-muted"
|
||
>
|
||
{tLanes("proof.ticketReport")}
|
||
</a>
|
||
)}
|
||
{Object.entries(proofFeature.groups).map(([group, images]) => (
|
||
<div key={group} className="mt-1">
|
||
<span className="text-xs text-fg-muted">
|
||
{group} · {images.length}
|
||
</span>
|
||
<div className="flex flex-wrap gap-1">
|
||
{images.slice(0, 8).map((img) => (
|
||
<img
|
||
key={img}
|
||
loading="lazy"
|
||
className="h-16 w-16 rounded object-cover"
|
||
src={api.lanes.proof.imageUrl(
|
||
currentLane.id,
|
||
proofFeature.slug,
|
||
group,
|
||
img
|
||
)}
|
||
alt={img}
|
||
/>
|
||
))}
|
||
{images.length > 8 && (
|
||
<span className="text-xs text-fg-muted">+{images.length - 8}</span>
|
||
)}
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
<div className="flex min-h-0 flex-1 flex-col">
|
||
{replaying && (
|
||
<div className="flex min-h-0 flex-1 flex-col px-4 pb-3 pt-3">
|
||
<SessionReplay
|
||
lane={currentLane}
|
||
sessionId={sessionParam}
|
||
liveRuns={activeRuns?.items ?? []}
|
||
onResumed={(runId, laneId) =>
|
||
setSearchParams({ lane: String(laneId ?? currentLane.id), run: runId })
|
||
}
|
||
/>
|
||
</div>
|
||
)}
|
||
{/* A replay only HIDES the console: it has one render site and a
|
||
live terminal must never unmount. */}
|
||
<div className={replaying ? "hidden" : "contents"}>
|
||
<ConsoleArea
|
||
lanes={lanes}
|
||
selectedLaneId={selectedLaneId}
|
||
binaryStatus={binaryStatus}
|
||
activeRuns={activeRuns}
|
||
onHasActiveRunChange={setPaneHasActiveRun}
|
||
attachRequest={attachRequest}
|
||
onNewSession={
|
||
currentLane
|
||
? () => setSearchParams({ lane: String(currentLane.id) })
|
||
: undefined
|
||
}
|
||
allTabs={allTabs}
|
||
onOpenRun={(runId, opts) =>
|
||
currentLane &&
|
||
setSearchParams(
|
||
// A run names its own workspace (another one's with `allTabs`).
|
||
{ lane: String(laneOfRun(runId) ?? currentLane.id), run: runId },
|
||
{
|
||
// Following the focus, or a tab click, is not a new
|
||
// history entry.
|
||
replace: opts?.focusOnly || opts?.replace,
|
||
state: opts?.split
|
||
? { split: true }
|
||
: opts?.focusOnly
|
||
? { focusOnly: true }
|
||
: undefined,
|
||
}
|
||
)
|
||
}
|
||
onRunsChanged={refreshList}
|
||
settings={
|
||
currentLane && (settingsOn || settingsTab !== null)
|
||
? {
|
||
section: settingsSection,
|
||
active: settingsOn,
|
||
onOpen: () =>
|
||
navigate(settingsHref(settingsTab ?? "", location), {
|
||
replace: true,
|
||
}),
|
||
onClose: () => {
|
||
setSettingsTab(null);
|
||
if (!settingsOn) return;
|
||
setSearchParams(
|
||
(prev) => {
|
||
const next = new URLSearchParams(prev);
|
||
next.delete("settings");
|
||
next.delete("tab");
|
||
return next;
|
||
},
|
||
{ replace: true }
|
||
);
|
||
},
|
||
}
|
||
: null
|
||
}
|
||
/>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
|
||
{currentLane && changesOpen && !changesFull && (
|
||
// Drag (or arrow keys) to any width; the button keeps its presets.
|
||
<Divider
|
||
gridRef={changesRowRef}
|
||
dir="row"
|
||
at={1 - Math.max(changesRatio(changesSize), minChanges)}
|
||
box={{ x: 0, y: 0, w: 1, h: 1 }}
|
||
label={tLanes("changes.splitter")}
|
||
onRatio={(r) => setChangesSize(clampChanges(Math.max(1 - r, minChanges)))}
|
||
/>
|
||
)}
|
||
{currentLane &&
|
||
(changesOpen ? (
|
||
<aside
|
||
id="lane-changes-panel"
|
||
data-testid="lane-changes-card"
|
||
aria-label={tLanes("changes.title")}
|
||
style={
|
||
typeof changesSize === "number" ? { width: `${changesSize * 100}%` } : undefined
|
||
}
|
||
className={`flex shrink-0 flex-col bg-surface-1 ${
|
||
typeof changesSize === "number" ? "" : CHANGES_WIDTH[changesSize]
|
||
}${changesFull ? "" : " min-w-[18rem] border-l border-border"}`}
|
||
>
|
||
{/* Header: the title, Analyst, then Full screen and Collapse — the
|
||
same height as the console's tab bar, so the two read as
|
||
one header strip. Files and Changes are ONE panel now; its
|
||
own mode switcher moves between them. */}
|
||
<div
|
||
className="flex h-10 shrink-0 items-center gap-1 border-b border-border px-2"
|
||
role="toolbar"
|
||
>
|
||
<span className="px-1 text-xs font-medium text-fg-secondary">
|
||
{tLanes("panel.changes")}
|
||
</span>
|
||
{/* The opened session's analysis page — a page of its own. */}
|
||
<button
|
||
type="button"
|
||
data-testid="panel-analyst"
|
||
disabled={!analystSessionId}
|
||
onClick={() =>
|
||
analystSessionId &&
|
||
navigate(`/sessions/${encodeURIComponent(analystSessionId)}`)
|
||
}
|
||
title={tLanes("panel.analyst")}
|
||
aria-label={tLanes("panel.analyst")}
|
||
className="flex h-7 w-7 items-center justify-center rounded-md text-fg-muted transition-colors hover:bg-surface-4 hover:text-fg-primary disabled:opacity-40 disabled:hover:bg-transparent"
|
||
>
|
||
<ChartColumn className="h-4 w-4" />
|
||
</button>
|
||
{/* The File Explorer, beside the window controls — on this
|
||
workspace (the session's own worktree when it has one).
|
||
Not in local mode: the desktop's file manager is there. */}
|
||
{local ? (
|
||
<span className="ml-auto" />
|
||
) : (
|
||
<>
|
||
<button
|
||
type="button"
|
||
data-testid="panel-file-explorer"
|
||
disabled={!panelAt}
|
||
onClick={() => setExplorerOpen(true)}
|
||
aria-label={tLanes("changes.openExplorer")}
|
||
title={tLanes("changes.openExplorer")}
|
||
className="ml-auto flex h-6 w-6 items-center justify-center rounded-md text-fg-muted transition-colors hover:bg-surface-4 hover:text-fg-primary disabled:opacity-40"
|
||
>
|
||
<FolderOpen className="h-4 w-4" />
|
||
</button>
|
||
{panelAt && (
|
||
<FileExplorer
|
||
open={explorerOpen}
|
||
laneId={panelAt.laneId}
|
||
session={panelAt.session}
|
||
onChanged={() => setFilesChanged((n) => n + 1)}
|
||
onClose={() => setExplorerOpen(false)}
|
||
/>
|
||
)}
|
||
</>
|
||
)}
|
||
{/* A window's two controls: Full screen (Restore at full
|
||
screen, to the last dragged width) and Collapse. */}
|
||
<button
|
||
type="button"
|
||
data-testid="lane-changes-resize"
|
||
onClick={() => setChangesSize(changesFull ? lastDraggedChanges() : "full")}
|
||
aria-label={changesSizeLabel}
|
||
title={changesSizeLabel}
|
||
className="flex h-6 w-6 items-center justify-center rounded-md text-fg-muted transition-colors hover:bg-surface-4 hover:text-fg-primary"
|
||
>
|
||
{changesFull ? (
|
||
<Minimize2 className="h-4 w-4" />
|
||
) : (
|
||
<Maximize2 className="h-4 w-4" />
|
||
)}
|
||
</button>
|
||
<button
|
||
type="button"
|
||
data-testid="lane-changes-collapse"
|
||
// Closing drops an unsaved edit, so that asks first; it
|
||
// reopens from the rail at the last dragged width.
|
||
onClick={() =>
|
||
afterDrop(() => {
|
||
setChangesOpen(false);
|
||
setChangesSize(lastDraggedChanges());
|
||
})
|
||
}
|
||
// The same region the rail button names, so a screen
|
||
// reader reads one panel that is open or closed.
|
||
aria-expanded
|
||
aria-controls="lane-changes-panel"
|
||
aria-label={tLanes("changes.collapse")}
|
||
title={tLanes("changes.collapse")}
|
||
className="flex h-6 w-6 items-center justify-center rounded-md text-fg-muted transition-colors hover:bg-surface-4 hover:text-fg-primary"
|
||
>
|
||
<PanelRightClose className="h-4 w-4" />
|
||
</button>
|
||
</div>
|
||
<div className="flex min-h-0 flex-1 flex-col px-3 pt-2">
|
||
{panelAt &&
|
||
[
|
||
{ key: "", source: undefined as number | undefined },
|
||
...folders.map((f) => ({ key: String(f.id), source: f.id })),
|
||
].map(({ key, source }, i) => {
|
||
const panel = (
|
||
<LaneChangesPanel
|
||
// A new tree is a new panel: no read for the previous
|
||
// one can land in it. `panelAt` holds still while an
|
||
// edit is unsaved (see above).
|
||
key={`${panelKey}:${key}`}
|
||
laneId={panelAt.laneId}
|
||
session={panelAt.session}
|
||
source={source}
|
||
full={changesFull}
|
||
wide={changesRatio(changesSize) >= 0.45}
|
||
onDirtyChange={dirtyFor(key)}
|
||
openRequest={source ? null : changesRequest}
|
||
filesChanged={source ? 0 : filesChanged}
|
||
// A sibling worktree forks from the lane's SOURCE repo
|
||
// when it has one (this lane is itself a worktree of
|
||
// it); an adopted lane is its own repo.
|
||
onNewLane={(branch) => {
|
||
setAddLanePrefill({
|
||
sourceRepo: currentLane.source_repo || currentLane.cwd,
|
||
branch,
|
||
});
|
||
setAddLaneOpen(true);
|
||
}}
|
||
/>
|
||
);
|
||
// No extra folders: the one panel, as it always was.
|
||
if (!folders.length) return panel;
|
||
// Else every repo is a section, stacked like VS Code's
|
||
// Source Control; the open ones share the height. A
|
||
// folded one stays mounted (CSS), so an unsaved edit in
|
||
// it survives.
|
||
const folded = foldedSources.has(key);
|
||
const name = source
|
||
? folders[i - 1]!.name
|
||
: currentLane.cwd.split(/[\\/]/).filter(Boolean).pop() || currentLane.cwd;
|
||
return (
|
||
<section
|
||
key={key}
|
||
data-testid="panel-git-source"
|
||
// A row dragged from an extra folder lies outside
|
||
// the pane's folder: `dragProjectPath` makes it absolute.
|
||
data-drag-root={source ? folders[i - 1]!.path : undefined}
|
||
className={`flex flex-col border-b border-border-light ${
|
||
folded ? "shrink-0" : "min-h-0 flex-1"
|
||
}`}
|
||
>
|
||
<button
|
||
type="button"
|
||
aria-expanded={!folded}
|
||
onContextMenu={(e) => sourceCtx.open(e, key)}
|
||
onClick={() =>
|
||
setFoldedSources((prev) => {
|
||
const next = new Set(prev);
|
||
if (!next.delete(key)) next.add(key);
|
||
return next;
|
||
})
|
||
}
|
||
className="flex shrink-0 items-center gap-1 py-1 text-left text-xs font-medium text-fg-secondary hover:text-fg-primary"
|
||
>
|
||
{folded ? (
|
||
<ChevronRight className="h-3.5 w-3.5 shrink-0" />
|
||
) : (
|
||
<ChevronDown className="h-3.5 w-3.5 shrink-0" />
|
||
)}
|
||
<span className="truncate">{name}</span>
|
||
</button>
|
||
<div className={folded ? "hidden" : "flex min-h-0 flex-1 flex-col"}>
|
||
{panel}
|
||
</div>
|
||
</section>
|
||
);
|
||
})}
|
||
{panelAt && sourceCtx.menu && (
|
||
<ContextMenu
|
||
x={sourceCtx.menu.x}
|
||
y={sourceCtx.menu.y}
|
||
onClose={sourceCtx.close}
|
||
testId="panel-git-source-menu"
|
||
>
|
||
{sourceCtx.menu.item !== "" && (
|
||
<MenuItem
|
||
testId="panel-git-source-close"
|
||
onSelect={() => {
|
||
const key = sourceCtx.menu!.item;
|
||
sourceCtx.close();
|
||
void closeSources(panelAt.laneId, [key]);
|
||
}}
|
||
>
|
||
{tLanes("changes.closeRepo")}
|
||
</MenuItem>
|
||
)}
|
||
<MenuItem
|
||
testId="panel-git-source-close-others"
|
||
disabled={!folders.some((f) => String(f.id) !== sourceCtx.menu!.item)}
|
||
onSelect={() => {
|
||
const key = sourceCtx.menu!.item;
|
||
sourceCtx.close();
|
||
void closeSources(
|
||
panelAt.laneId,
|
||
folders.map((f) => String(f.id)).filter((k) => k !== key)
|
||
);
|
||
}}
|
||
>
|
||
{tLanes("changes.closeOtherRepos")}
|
||
</MenuItem>
|
||
</ContextMenu>
|
||
)}
|
||
{/* The workspace's context — the home's right column, compact. */}
|
||
<section
|
||
data-testid="panel-workspace-context"
|
||
className={`-mx-3 flex shrink-0 flex-col border-t border-border px-3 ${
|
||
contextOpen ? "max-h-[45%]" : ""
|
||
}`}
|
||
>
|
||
<button
|
||
type="button"
|
||
aria-expanded={contextOpen}
|
||
onClick={() => setContextOpen((v) => !v)}
|
||
className="flex shrink-0 items-center gap-1 py-1.5 text-left text-xs font-medium text-fg-secondary hover:text-fg-primary"
|
||
>
|
||
{contextOpen ? (
|
||
<ChevronDown className="h-3.5 w-3.5 shrink-0" />
|
||
) : (
|
||
<ChevronRight className="h-3.5 w-3.5 shrink-0" />
|
||
)}
|
||
{tLanes("home.contextPanel")}
|
||
</button>
|
||
{contextOpen && (
|
||
<div className="min-h-0 overflow-y-auto pb-2">
|
||
<WorkspaceContext
|
||
lane={currentLane}
|
||
compact
|
||
onSourcesChange={(v) => sourcesChanged(currentLane.id, v)}
|
||
sourcesVersion={sourcesVersion}
|
||
/>
|
||
</div>
|
||
)}
|
||
</section>
|
||
</div>
|
||
</aside>
|
||
) : (
|
||
// The rail also carries the uncommitted counts, vertical, with
|
||
// the per-file list on hover — a sibling of the expand button,
|
||
// since a button may not hold another.
|
||
<div className="flex w-9 shrink-0 flex-col items-center gap-3 border-l border-border bg-surface-1 py-3">
|
||
<button
|
||
type="button"
|
||
data-testid="lane-changes-expand"
|
||
onClick={() => setChangesOpen(true)}
|
||
aria-expanded={false}
|
||
aria-controls="lane-changes-panel"
|
||
aria-label={tLanes("changes.expand")}
|
||
title={tLanes("changes.expand")}
|
||
className="flex flex-col items-center gap-2 text-fg-muted transition-colors hover:text-fg-primary"
|
||
>
|
||
<PanelLeftClose className="h-4 w-4" />
|
||
<span className="text-[10px] uppercase tracking-wide [writing-mode:vertical-rl]">
|
||
{tLanes("changes.title")}
|
||
</span>
|
||
</button>
|
||
{panelAt &&
|
||
[undefined, ...folders.map((f) => f.id)].map((source) => (
|
||
<LaneGitRailCounts
|
||
key={source ?? ""}
|
||
laneId={panelAt.laneId}
|
||
session={panelAt.session}
|
||
source={source}
|
||
onShowAll={() => setChangesOpen(true)}
|
||
/>
|
||
))}
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
|
||
<AddLaneModal
|
||
open={addLaneOpen}
|
||
cwdSuggestions={cwdSuggestions}
|
||
prefill={addLanePrefill}
|
||
onClose={() => {
|
||
setAddLaneOpen(false);
|
||
setAddLanePrefill(null);
|
||
}}
|
||
onAdded={(lane) => {
|
||
void refreshLanes();
|
||
setSelectedLaneId(lane.id);
|
||
}}
|
||
/>
|
||
|
||
{destructive && (
|
||
<DestructiveLaneModal
|
||
lane={destructive.lane}
|
||
action={destructive.action}
|
||
open
|
||
onClose={() => setDestructive(null)}
|
||
onConfirm={async (body) => {
|
||
const { lane, action } = destructive;
|
||
setDestructive(null);
|
||
setLaneActionError(null);
|
||
try {
|
||
await api.lanes.action(lane.id, action, {
|
||
confirm: true,
|
||
expect: body.expect,
|
||
...(body.force === true ? { force: true } : {}),
|
||
});
|
||
await refreshLanes();
|
||
if (action === "remove") setSearchParams({});
|
||
} catch (err) {
|
||
setLaneActionError(err instanceof Error ? err.message : String(err));
|
||
}
|
||
}}
|
||
/>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|