feat(ui): sidebar project tree, status bar and a tabbed workbench for workspaces
The Workspace page's lane strip moves into the sidebar as a project tree: workspaces grouped by git repository (new GET /api/projects, cached in memory, POST /api/projects/refresh), untracked worktrees with Adopt, inline rename, drag/Alt+arrow ordering and a right-click row menu. The selected workspace is the URL's ?lane=, one shared lane list feeds the tree, the new bottom status bar and the page. The Changes panel gains an activity bar (Files / Changes / Sessions, with resume), and split panes get a header.
This commit is contained in:
+278
-336
@@ -1,9 +1,12 @@
|
||||
/**
|
||||
* @file Workspace.tsx
|
||||
* @description Merged workspace page combining lanes (agent work units) and runs
|
||||
* (CLI spawns: Claude Code or Codex, determined per lane). Displays a horizontal
|
||||
* lane strip at the top (its first tile creates a lane), the selected lane's
|
||||
* pipeline map, and run configuration/console/history below. Runs are tied to lanes: starting a run
|
||||
* (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 one-row header, pipeline map and run console (1, 2
|
||||
* or 4 panes, each split pane with its own header), 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. Polls GET
|
||||
* /api/sessions?status=active alongside the run list so Claude Code sessions
|
||||
@@ -48,23 +51,23 @@ import {
|
||||
Square,
|
||||
Columns2,
|
||||
Grid2x2,
|
||||
Pencil,
|
||||
Layers,
|
||||
PanelLeftClose,
|
||||
PanelRightClose,
|
||||
Maximize2,
|
||||
FolderTree,
|
||||
GitCompare,
|
||||
History,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import { api } from "../lib/api";
|
||||
import type { CwdSuggestion, RunListResponse } from "../lib/api";
|
||||
import type { Lane, LaneFeature, ProofFeature, Session, WSMessage } from "../lib/types";
|
||||
import type { Lane, LaneFeature, ProofFeature, Session } from "../lib/types";
|
||||
import { eventBus } from "../lib/eventBus";
|
||||
import { keepIfSame } from "../lib/keepIfSame";
|
||||
import {
|
||||
readLaneOrder,
|
||||
writeLaneOrder,
|
||||
sortByStoredOrder,
|
||||
moveLane,
|
||||
} from "../lib/laneOrderStorage";
|
||||
import { useLanes } from "../hooks/useLanes";
|
||||
import { useAiTools } from "../hooks/useAiTools";
|
||||
import { AiToolIcon } from "../components/AiToolIcon";
|
||||
import { LaneConsolePane } from "../components/run/LaneConsolePane";
|
||||
import PipelineMap from "../components/lanes/PipelineMap";
|
||||
import LaneCard, {
|
||||
@@ -73,10 +76,9 @@ import LaneCard, {
|
||||
LaneToolsMenu,
|
||||
LaneRouterRow,
|
||||
} from "../components/lanes/LaneCard";
|
||||
import LaneStripCard from "../components/lanes/LaneStripCard";
|
||||
import { AddLaneModal } from "../components/lanes/AddLaneModal";
|
||||
import { LaneChangesPanel } from "../components/lanes/LaneChangesPanel";
|
||||
import { DestructiveLaneModal } from "../components/lanes/DestructiveLaneModal";
|
||||
import { WorkspaceSessionsTab } from "../components/lanes/WorkspaceSessionsTab";
|
||||
import { readSplitViewState, writeSplitViewState } from "../lib/splitViewStorage";
|
||||
import type { SplitViewState, SplitLayout } from "../lib/splitViewStorage";
|
||||
|
||||
@@ -84,6 +86,17 @@ import type { SplitViewState, SplitLayout } from "../lib/splitViewStorage";
|
||||
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 right panel's activity-bar tab. */
|
||||
const PANEL_TAB_KEY = "agenthub.workspace.panelTab";
|
||||
/** localStorage key for the last workspace shown, restored on a bare /workspace. */
|
||||
const SELECTED_KEY = "agenthub.workspace.selected";
|
||||
|
||||
const PANEL_TABS = [
|
||||
{ id: "files", Icon: FolderTree },
|
||||
{ id: "changes", Icon: GitCompare },
|
||||
{ id: "sessions", Icon: History },
|
||||
] as const;
|
||||
type PanelTab = (typeof PANEL_TABS)[number]["id"];
|
||||
|
||||
/** The three widths the Changes panel cycles through, then closed. Reading a
|
||||
* diff and watching a console want opposite amounts of room, and which one is
|
||||
@@ -155,6 +168,53 @@ function SplitLayoutToggle({
|
||||
);
|
||||
}
|
||||
|
||||
/** A split pane's one-line header: which lane, which slot, the run's model,
|
||||
* and — for slots 1–3 — a button that kills that pane's own tmux session
|
||||
* (`DELETE /api/run/<runId>`). Slot 0 is the lane's run and has no close. */
|
||||
function PaneHeader({
|
||||
lane,
|
||||
slot,
|
||||
runId,
|
||||
model,
|
||||
}: {
|
||||
lane: Lane | null;
|
||||
slot: number;
|
||||
runId: string | null;
|
||||
model: string | null;
|
||||
}) {
|
||||
const { t } = useTranslation("lanes");
|
||||
const tools = useAiTools();
|
||||
return (
|
||||
<div
|
||||
data-testid={`pane-header-${slot}`}
|
||||
className="flex h-6 shrink-0 items-center gap-1.5 rounded border border-border bg-surface-2 px-2 text-[11px] text-fg-muted"
|
||||
>
|
||||
{lane && (
|
||||
<AiToolIcon
|
||||
tool={{ id: lane.tool, label: tools.label(lane.tool) }}
|
||||
className="h-3.5 w-3.5 !p-0"
|
||||
/>
|
||||
)}
|
||||
<span className="min-w-0 truncate font-medium text-fg-secondary">
|
||||
{lane ? lane.title || lane.cwd.split("/").pop() : ""}
|
||||
</span>
|
||||
<span className="shrink-0">{t("pane.slot", { n: slot })}</span>
|
||||
{model && <span className="min-w-0 truncate">{model}</span>}
|
||||
{slot > 0 && runId && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void api.run.kill(runId).catch(() => undefined)}
|
||||
aria-label={t("pane.close")}
|
||||
title={t("pane.close")}
|
||||
className="ml-auto shrink-0 rounded p-0.5 hover:bg-surface-4 hover:text-status-danger"
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ConsoleArea({
|
||||
lanes,
|
||||
selectedLaneId,
|
||||
@@ -170,6 +230,7 @@ function ConsoleArea({
|
||||
onLaneCreated,
|
||||
onLaneIdChange,
|
||||
showToggle = true,
|
||||
resumeRequest,
|
||||
}: {
|
||||
lanes: Lane[];
|
||||
selectedLaneId: number | null;
|
||||
@@ -185,6 +246,8 @@ function ConsoleArea({
|
||||
onLaneCreated: (lane: Lane) => void;
|
||||
onLaneIdChange: (id: number | null) => void;
|
||||
showToggle?: boolean;
|
||||
/** A session to resume in slot 0, from the Sessions tab. */
|
||||
resumeRequest: { sessionId: string; model: string | null; nonce: number } | null;
|
||||
}) {
|
||||
// Every split pane shows the SELECTED lane, each in its own tmux session
|
||||
// (slot = pane index). Tracking what each pane shows lets the others leave
|
||||
@@ -228,6 +291,7 @@ function ConsoleArea({
|
||||
wsConnected={wsConnected}
|
||||
defaultCwd={defaultCwd}
|
||||
onHasActiveRunChange={onHasActiveRunChange}
|
||||
resumeRequest={resumeRequest}
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
@@ -236,23 +300,31 @@ function ConsoleArea({
|
||||
}`}
|
||||
>
|
||||
{Array.from({ length: splitView.layout }, (_, i) => (
|
||||
<LaneConsolePane
|
||||
key={i}
|
||||
lanes={lanes}
|
||||
laneId={selectedLaneId}
|
||||
slot={i}
|
||||
hiddenRunIds={hiddenFor[i]}
|
||||
onHandleChange={paneHandlers[i]}
|
||||
showLaneSelector={false}
|
||||
onLaneIdChange={onLaneIdChange}
|
||||
onLaneCreated={onLaneCreated}
|
||||
binaryStatus={binaryStatus}
|
||||
cwdSuggestions={cwdSuggestions}
|
||||
activeRuns={activeRuns}
|
||||
externalSessions={externalSessions}
|
||||
wsConnected={wsConnected}
|
||||
defaultCwd={defaultCwd}
|
||||
/>
|
||||
<div key={i} className="flex min-h-0 min-w-0 flex-col gap-1">
|
||||
<PaneHeader
|
||||
lane={lanes.find((l) => l.id === selectedLaneId) ?? null}
|
||||
slot={i}
|
||||
runId={paneRunIds[i] ?? null}
|
||||
model={activeRuns?.items.find((r) => r.id === paneRunIds[i])?.model ?? null}
|
||||
/>
|
||||
<LaneConsolePane
|
||||
lanes={lanes}
|
||||
laneId={selectedLaneId}
|
||||
slot={i}
|
||||
hiddenRunIds={hiddenFor[i]}
|
||||
onHandleChange={paneHandlers[i]}
|
||||
showLaneSelector={false}
|
||||
onLaneIdChange={onLaneIdChange}
|
||||
onLaneCreated={onLaneCreated}
|
||||
binaryStatus={binaryStatus}
|
||||
cwdSuggestions={cwdSuggestions}
|
||||
activeRuns={activeRuns}
|
||||
externalSessions={externalSessions}
|
||||
wsConnected={wsConnected}
|
||||
defaultCwd={defaultCwd}
|
||||
resumeRequest={i === 0 ? resumeRequest : null}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
@@ -265,9 +337,49 @@ export function Workspace() {
|
||||
const wsConnected = useSyncExternalStore(eventBus.onConnection, () => eventBus.connected);
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
|
||||
// Lane state
|
||||
const [lanes, setLanes] = useState<Lane[]>([]);
|
||||
const [selectedLaneId, setSelectedLaneId] = useState<number | null>(null);
|
||||
// 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 } = useLanes();
|
||||
const laneParam = searchParams.get("lane");
|
||||
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);
|
||||
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]);
|
||||
const [laneActionError, setLaneActionError] = useState<string | null>(null);
|
||||
const [addLaneOpen, setAddLaneOpen] = useState(false);
|
||||
// Fields the Add-lane modal opens with when it was opened from a lane's git
|
||||
@@ -283,10 +395,11 @@ export function Workspace() {
|
||||
>([]);
|
||||
const [viewedFeature, setViewedFeature] = useState<LaneFeature | null>(null);
|
||||
const [proofFeatures, setProofFeatures] = useState<ProofFeature[]>([]);
|
||||
const [removeConfirmOpen, setRemoveConfirmOpen] = useState(false);
|
||||
// Inline rename of the selected lane's title, in the detail header.
|
||||
const [renamingLaneId, setRenamingLaneId] = useState<number | null>(null);
|
||||
const [renameDraft, setRenameDraft] = useState("");
|
||||
const [resumeRequest, setResumeRequest] = useState<{
|
||||
sessionId: string;
|
||||
model: string | null;
|
||||
nonce: number;
|
||||
} | null>(null);
|
||||
|
||||
// Run state kept at page level: shared across every pane, or drives the
|
||||
// lane strip itself rather than any one pane's form.
|
||||
@@ -305,10 +418,21 @@ export function Workspace() {
|
||||
|
||||
// Split view state
|
||||
const [splitView, setSplitView] = useState<SplitViewState>(() => readSplitViewState());
|
||||
// Info block (pickers, LaneCard, PipelineMap, proof gallery) starts shown in
|
||||
// single-pane view and collapsed in split view, but the toggle below can
|
||||
// override that in either mode — the user decides, not the layout.
|
||||
const [infoExpanded, setInfoExpanded] = useState(() => splitView.layout === 1);
|
||||
const [panelTab, setPanelTab] = useState<PanelTab>(() => {
|
||||
try {
|
||||
const saved = window.localStorage.getItem(PANEL_TAB_KEY) as PanelTab | null;
|
||||
return saved && PANEL_TABS.some((x) => x.id === saved) ? saved : "changes";
|
||||
} catch {
|
||||
return "changes";
|
||||
}
|
||||
});
|
||||
useEffect(() => {
|
||||
try {
|
||||
window.localStorage.setItem(PANEL_TAB_KEY, panelTab);
|
||||
} catch {
|
||||
/* the panel just forgets its tab between visits */
|
||||
}
|
||||
}, [panelTab]);
|
||||
// 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).
|
||||
@@ -347,7 +471,6 @@ export function Workspace() {
|
||||
changesSize === CHANGES_SIZES[CHANGES_SIZES.length - 1]
|
||||
? tLanes("changes.collapse")
|
||||
: tLanes("changes.resize", { size: tLanes(`changes.size.${nextChangesSize}`) });
|
||||
const showInfo = infoExpanded;
|
||||
|
||||
const setLayout = useCallback((layout: SplitLayout) => {
|
||||
setSplitView((prev) => {
|
||||
@@ -358,42 +481,6 @@ export function Workspace() {
|
||||
});
|
||||
}, []);
|
||||
|
||||
// Pre-flight: probe binary + active runs + cwd suggestions + lanes on mount
|
||||
const refreshLanes = useCallback(async () => {
|
||||
try {
|
||||
const r = await api.lanes.list();
|
||||
// The `lanes` state IS the display order: the server answers in id order,
|
||||
// the viewer's stored strip order is applied once here, and the in-place
|
||||
// lane_update merge below preserves whatever order this produced.
|
||||
setLanes(keepIfSame(sortByStoredOrder(r.lanes, readLaneOrder())));
|
||||
setSelectedLaneId((cur) =>
|
||||
cur !== null && r.lanes.some((l) => l.id === cur) ? cur : (r.lanes[0]?.id ?? null)
|
||||
);
|
||||
} catch {
|
||||
// Silent fail for lanes load
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Deep link from a notification click (`/workspace?lane=<id>`, see
|
||||
// useNotifications' laneUrlForSession): once that lane is in the loaded
|
||||
// list, select it and drop the param so a later manual lane switch sticks.
|
||||
useEffect(() => {
|
||||
const laneParam = searchParams.get("lane");
|
||||
if (laneParam === null || lanes.length === 0) return;
|
||||
const paramId = Number(laneParam);
|
||||
if (lanes.some((l) => l.id === paramId)) {
|
||||
setSelectedLaneId(paramId);
|
||||
}
|
||||
setSearchParams(
|
||||
(prev) => {
|
||||
const next = new URLSearchParams(prev);
|
||||
next.delete("lane");
|
||||
return next;
|
||||
},
|
||||
{ replace: true }
|
||||
);
|
||||
}, [lanes, searchParams, setSearchParams]);
|
||||
|
||||
// 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.
|
||||
@@ -417,7 +504,6 @@ export function Workspace() {
|
||||
.pipelines()
|
||||
.then((r) => setPipelineTemplates(r.pipelines))
|
||||
.catch(() => undefined);
|
||||
void refreshLanes();
|
||||
api.run
|
||||
.cwds()
|
||||
.then((r) => {
|
||||
@@ -437,46 +523,7 @@ export function Workspace() {
|
||||
}
|
||||
})
|
||||
.catch(() => undefined);
|
||||
|
||||
// Subscribe to lane updates from the event bus
|
||||
return eventBus.subscribe((msg: WSMessage) => {
|
||||
if (msg.type !== "lane_update") return;
|
||||
const payload = msg.data as { lane?: Lane; removed?: number };
|
||||
if (payload.removed !== undefined) {
|
||||
void refreshLanes();
|
||||
return;
|
||||
}
|
||||
const lane = payload.lane;
|
||||
if (!lane) return;
|
||||
setLanes((cur) => {
|
||||
const i = cur.findIndex((l) => l.id === lane.id);
|
||||
if (i === -1) {
|
||||
void refreshLanes();
|
||||
return cur;
|
||||
}
|
||||
// The server re-broadcasts a lane on hook activity that often changed
|
||||
// nothing this page renders; replacing the array anyway would re-render
|
||||
// the strip and the console on every such event.
|
||||
if (JSON.stringify(cur[i]) === JSON.stringify(lane)) return cur;
|
||||
const next = [...cur];
|
||||
next[i] = lane;
|
||||
return next;
|
||||
});
|
||||
});
|
||||
}, [refreshLanes]);
|
||||
|
||||
// A reconnect (e.g. the dashboard server restarting) resumes the WS but does
|
||||
// not replay missed lane_update diffs, so a lane whose fields changed while
|
||||
// disconnected — status, needs_action — would keep showing its pre-restart
|
||||
// badges forever with no further server-side change to broadcast. Refetch
|
||||
// the full list whenever the socket comes back up.
|
||||
useEffect(
|
||||
() =>
|
||||
eventBus.onConnection((isConnected) => {
|
||||
if (isConnected) void refreshLanes();
|
||||
}),
|
||||
[refreshLanes]
|
||||
);
|
||||
}, []);
|
||||
|
||||
// Clean up stale pane lane IDs when lanes load
|
||||
useEffect(() => {
|
||||
@@ -625,37 +672,6 @@ export function Workspace() {
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Move a lane card within the strip. Purely a viewer preference — it writes
|
||||
* localStorage, never the lane row, so nothing the CLI or another session
|
||||
* reads changes.
|
||||
*/
|
||||
const handleLaneReorder = (from: number, to: number) => {
|
||||
if (from === to || from < 0 || to < 0 || from >= lanes.length || to >= lanes.length) return;
|
||||
const ids = moveLane(
|
||||
lanes.map((l) => l.id),
|
||||
from,
|
||||
to
|
||||
);
|
||||
writeLaneOrder(ids);
|
||||
setLanes(sortByStoredOrder(lanes, ids));
|
||||
};
|
||||
|
||||
/** Commit an inline lane rename. An empty name clears the title, which puts
|
||||
* the cwd back in its place (`lane.title || lane.cwd` everywhere). */
|
||||
const commitRename = async (id: number) => {
|
||||
const title = renameDraft.trim();
|
||||
setRenamingLaneId(null);
|
||||
const lane = lanes.find((l) => l.id === id);
|
||||
if (!lane || title === (lane.title || "")) return;
|
||||
try {
|
||||
await api.lanes.update(id, { title });
|
||||
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.
|
||||
@@ -685,15 +701,14 @@ export function Workspace() {
|
||||
<div
|
||||
className={
|
||||
viewportLocked
|
||||
? "h-[calc(100vh-2.5rem)] lg:h-[calc(100vh-3rem)] flex flex-col gap-5"
|
||||
? "h-[calc(100vh-4rem)] lg:h-[calc(100vh-4.5rem)] flex flex-col gap-5"
|
||||
: "space-y-5"
|
||||
}
|
||||
>
|
||||
{/* Lane strip + detail stack vertically: the strip is a horizontally
|
||||
scrolling row (holds any number of lanes), the detail/console area
|
||||
below it gets the full width and never competes for it. */}
|
||||
{/* Workspaces are picked in the sidebar's project tree; this page shows
|
||||
the selected one. With none at all, one banner offers to create it. */}
|
||||
<div className="flex min-h-0 flex-1 flex-col gap-4">
|
||||
{lanes.length === 0 ? (
|
||||
{lanesLoaded && lanes.length === 0 && (
|
||||
/* With no lanes the strip was a dashed tile beside a stray sentence,
|
||||
which reads as a half-rendered row rather than a state. One banner
|
||||
instead — and a compact one on purpose: the run setup form below
|
||||
@@ -723,36 +738,6 @@ export function Workspace() {
|
||||
{tLanes("add")}
|
||||
</span>
|
||||
</button>
|
||||
) : (
|
||||
<div
|
||||
data-testid="lane-strip"
|
||||
className="flex shrink-0 flex-row gap-2 overflow-x-auto pb-1"
|
||||
>
|
||||
{/* "Add lane" is a tile in the strip rather than a header button:
|
||||
creating a lane is the same kind of act as picking one, so it
|
||||
reads as the empty slot at the head of the row. Dashed border and
|
||||
no liveness dot keep it from being mistaken for a lane. */}
|
||||
<button
|
||||
type="button"
|
||||
data-testid="lane-tile-add"
|
||||
onClick={() => setAddLaneOpen(true)}
|
||||
title={tLanes("addLane")}
|
||||
className="flex w-56 shrink-0 items-center justify-center gap-1.5 rounded-lg border border-dashed border-border-light p-3 text-[13px] font-medium text-fg-secondary transition-colors hover:border-accent hover:bg-accent/5 hover:text-fg-primary"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
{tLanes("add")}
|
||||
</button>
|
||||
{lanes.map((l, i) => (
|
||||
<LaneStripCard
|
||||
key={l.id}
|
||||
lane={l}
|
||||
index={i}
|
||||
selected={selectedLaneId === l.id}
|
||||
onSelect={() => setSelectedLaneId(l.id)}
|
||||
onReorder={handleLaneReorder}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* The selected lane's pipeline — the thing you actually come to this
|
||||
@@ -792,47 +777,14 @@ export function Workspace() {
|
||||
? tLanes("statusDead")
|
||||
: tLanes(`status.${currentLane.status}`, { defaultValue: currentLane.status })}
|
||||
</span>
|
||||
{/* The lane's name, and the one place it can be set. No "Lane 23 ·"
|
||||
prefix any more: the id is on the strip tile, where the CLI's
|
||||
`--lane` reader looks for it, and repeating it here only ate
|
||||
the width the name needed. Submitting an empty name clears
|
||||
`title`, which puts the cwd back in its place. */}
|
||||
{renamingLaneId === currentLane.id ? (
|
||||
<input
|
||||
data-testid="lane-rename-input"
|
||||
aria-label={tLanes("renameLabel")}
|
||||
autoFocus
|
||||
value={renameDraft}
|
||||
onChange={(e) => setRenameDraft(e.target.value)}
|
||||
// Blur CANCELS, it does not save — same convention as the
|
||||
// rename field in CcConfig. Committing here too would fire
|
||||
// the PATCH twice for one Enter: the commit unmounts the
|
||||
// focused input, and removing a focused element raises a
|
||||
// native blur before the refetch has moved `lanes`, so the
|
||||
// "unchanged title" guard would not catch the second call.
|
||||
onBlur={() => setRenamingLaneId(null)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") void commitRename(currentLane.id);
|
||||
if (e.key === "Escape") setRenamingLaneId(null);
|
||||
}}
|
||||
maxLength={200}
|
||||
className="min-w-0 flex-1 rounded border border-accent bg-surface-1 px-2 py-0.5 text-sm font-semibold text-fg-primary"
|
||||
/>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
data-testid="lane-rename"
|
||||
onClick={() => {
|
||||
setRenameDraft(currentLane.title || "");
|
||||
setRenamingLaneId(currentLane.id);
|
||||
}}
|
||||
title={`${currentLane.cwd}\n${tLanes("renameLabel")}`}
|
||||
className="group flex min-w-0 items-center gap-1 text-sm font-semibold text-fg-primary"
|
||||
>
|
||||
<span className="min-w-0 truncate">{currentLane.title || currentLane.cwd}</span>
|
||||
<Pencil className="h-3 w-3 shrink-0 text-fg-muted opacity-0 transition-opacity group-hover:opacity-100" />
|
||||
</button>
|
||||
)}
|
||||
{/* Renaming lives on the sidebar row (double-click); here the
|
||||
name is only read. */}
|
||||
<span
|
||||
title={currentLane.cwd}
|
||||
className="min-w-0 truncate text-sm font-semibold text-fg-primary"
|
||||
>
|
||||
{currentLane.title || currentLane.cwd}
|
||||
</span>
|
||||
{/* A lane with no pipeline (non-claude, or no template
|
||||
installed): no time-at-stage, no pipeline or feature
|
||||
picker, no map. */}
|
||||
@@ -841,93 +793,68 @@ export function Workspace() {
|
||||
{since(currentLane.stage_seconds)}
|
||||
</span>
|
||||
)}
|
||||
{showInfo && (
|
||||
<>
|
||||
{!noPipeline && (
|
||||
<select
|
||||
data-testid="pipeline-picker"
|
||||
aria-label={tLanes("pipelinePicker.label")}
|
||||
className="rounded border border-border bg-surface-1 px-2 py-0.5 text-xs text-fg-secondary disabled:opacity-60"
|
||||
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>
|
||||
)}
|
||||
{/* `agy` has no endpoint to point at a router. */}
|
||||
<LaneRouterRow lane={currentLane} />
|
||||
{/* No stage chip and no "auto: <stage>" chip here: the
|
||||
{!noPipeline && (
|
||||
<select
|
||||
data-testid="pipeline-picker"
|
||||
aria-label={tLanes("pipelinePicker.label")}
|
||||
className="rounded border border-border bg-surface-1 px-2 py-0.5 text-xs text-fg-secondary disabled:opacity-60"
|
||||
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>
|
||||
)}
|
||||
{/* `agy` has no endpoint to point at a router. */}
|
||||
<LaneRouterRow lane={currentLane} />
|
||||
{/* 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. */}
|
||||
{!noPipeline && features.length > 0 && (
|
||||
<select
|
||||
data-testid="feature-picker"
|
||||
className="rounded border border-border bg-surface-1 px-2 py-0.5 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>
|
||||
)}
|
||||
</>
|
||||
{!noPipeline && features.length > 0 && (
|
||||
<select
|
||||
data-testid="feature-picker"
|
||||
className="rounded border border-border bg-surface-1 px-2 py-0.5 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 className="ml-auto flex items-center gap-2">
|
||||
{/* Per-lane actions (mcp sync, sync-base check)
|
||||
belong beside the lane they act on, not in a page header. */}
|
||||
<LaneToolsMenu lane={currentLane} />
|
||||
<button
|
||||
type="button"
|
||||
data-testid="lane-detail-toggle"
|
||||
aria-expanded={infoExpanded}
|
||||
onClick={() => setInfoExpanded((v) => !v)}
|
||||
className="rounded border border-border-light px-2 py-1 text-xs text-fg-secondary transition-colors hover:text-fg-primary"
|
||||
>
|
||||
{infoExpanded ? tLanes("laneDetail.hide") : tLanes("laneDetail.show")}
|
||||
</button>
|
||||
<SplitLayoutToggle splitView={splitView} setLayout={setLayout} />
|
||||
{showInfo && (
|
||||
<button
|
||||
type="button"
|
||||
data-testid="lane-action-remove"
|
||||
onClick={() => setRemoveConfirmOpen(true)}
|
||||
className="rounded px-2 py-0.5 text-xs text-status-danger transition-colors hover:bg-status-danger/15"
|
||||
>
|
||||
{/* One label for both kinds: removal forgets the lane's row
|
||||
and never deletes a worktree, a branch or a database. */}
|
||||
{tLanes("action.forget")}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{currentLane && showInfo && (
|
||||
{currentLane && (
|
||||
<div className="max-h-[45vh] shrink-0 overflow-y-auto">
|
||||
{/* No margin when the card rendered nothing (see LaneCard). */}
|
||||
<div className="mb-3 has-[>div:empty]:mb-0">
|
||||
@@ -1017,11 +944,10 @@ export function Workspace() {
|
||||
wsConnected={wsConnected}
|
||||
defaultCwd={defaultCwd}
|
||||
onHasActiveRunChange={setPaneHasActiveRun}
|
||||
onLaneCreated={(lane) =>
|
||||
setLanes((prev) => (prev.some((l) => l.id === lane.id) ? prev : [...prev, lane]))
|
||||
}
|
||||
onLaneCreated={() => void refreshLanes()}
|
||||
onLaneIdChange={setSelectedLaneId}
|
||||
showToggle={!currentLane}
|
||||
resumeRequest={resumeRequest}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
@@ -1033,10 +959,26 @@ export function Workspace() {
|
||||
data-testid="lane-changes-card"
|
||||
className={`card flex shrink-0 flex-col p-3 ${CHANGES_WIDTH[changesSize]}`}
|
||||
>
|
||||
<div className="mb-2 flex items-center gap-2">
|
||||
<span className="text-[11px] font-medium uppercase tracking-wide text-fg-secondary">
|
||||
{tLanes("changes.title")}
|
||||
</span>
|
||||
{/* Activity bar: Files · Changes · Sessions, then the resize control. */}
|
||||
<div className="mb-2 flex items-center gap-1" role="toolbar">
|
||||
{PANEL_TABS.map(({ id, Icon }) => (
|
||||
<button
|
||||
key={id}
|
||||
type="button"
|
||||
data-testid={`panel-tab-${id}`}
|
||||
aria-pressed={panelTab === id}
|
||||
onClick={() => setPanelTab(id)}
|
||||
title={tLanes(`panel.${id}`)}
|
||||
aria-label={tLanes(`panel.${id}`)}
|
||||
className={`flex h-7 w-7 items-center justify-center rounded-md transition-colors ${
|
||||
panelTab === id
|
||||
? "bg-accent/15 text-accent"
|
||||
: "text-fg-muted hover:bg-surface-4 hover:text-fg-primary"
|
||||
}`}
|
||||
>
|
||||
<Icon className="h-4 w-4" />
|
||||
</button>
|
||||
))}
|
||||
<button
|
||||
type="button"
|
||||
data-testid="lane-changes-resize"
|
||||
@@ -1067,20 +1009,34 @@ export function Workspace() {
|
||||
})()}
|
||||
</button>
|
||||
</div>
|
||||
<LaneChangesPanel
|
||||
laneId={currentLane.id}
|
||||
full={changesSize === "full"}
|
||||
// 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);
|
||||
}}
|
||||
/>
|
||||
{panelTab === "sessions" ? (
|
||||
<WorkspaceSessionsTab
|
||||
lane={currentLane}
|
||||
onResume={(session) =>
|
||||
setResumeRequest({
|
||||
sessionId: session.id,
|
||||
model: session.model,
|
||||
nonce: Date.now(),
|
||||
})
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<LaneChangesPanel
|
||||
laneId={currentLane.id}
|
||||
tab={panelTab}
|
||||
full={changesSize === "full"}
|
||||
// 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);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</aside>
|
||||
) : (
|
||||
<button
|
||||
@@ -1102,19 +1058,6 @@ export function Workspace() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{currentLane && removeConfirmOpen && (
|
||||
<DestructiveLaneModal
|
||||
lane={currentLane}
|
||||
action="remove"
|
||||
open
|
||||
onClose={() => setRemoveConfirmOpen(false)}
|
||||
onConfirm={(body) => {
|
||||
setRemoveConfirmOpen(false);
|
||||
void handleLaneAction(currentLane.id, "remove", body);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
<AddLaneModal
|
||||
open={addLaneOpen}
|
||||
cwdSuggestions={cwdSuggestions}
|
||||
@@ -1124,9 +1067,8 @@ export function Workspace() {
|
||||
setAddLanePrefill(null);
|
||||
}}
|
||||
onAdded={(lane) => {
|
||||
setLanes((prev) => (prev.some((l) => l.id === lane.id) ? prev : [...prev, lane]));
|
||||
setSelectedLaneId(lane.id);
|
||||
void refreshLanes();
|
||||
setSelectedLaneId(lane.id);
|
||||
}}
|
||||
/>
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { render, act, screen, fireEvent } from "@testing-library/react";
|
||||
import { MemoryRouter } from "react-router-dom";
|
||||
import { MemoryRouter, useSearchParams } from "react-router-dom";
|
||||
import i18n from "i18next";
|
||||
|
||||
// Hoisted: `vi.mock`'s factory is lifted above the module body, so the
|
||||
@@ -138,19 +138,35 @@ describe("Workspace — the console cwd follows the selected lane", () => {
|
||||
expect(cwdInput().value).toBe(LANE_A.cwd);
|
||||
});
|
||||
|
||||
it("swaps the cwd when another lane is selected in the strip", async () => {
|
||||
it("swaps the cwd when another lane is selected in the sidebar", async () => {
|
||||
// Stands in for the sidebar's project tree: picking sets `?lane=`.
|
||||
function Picker() {
|
||||
const [, setParams] = useSearchParams();
|
||||
return (
|
||||
<>
|
||||
{[1, 2].map((id) => (
|
||||
<button
|
||||
key={id}
|
||||
data-testid={`pick-${id}`}
|
||||
onClick={() => setParams({ lane: String(id) })}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
render(
|
||||
<MemoryRouter initialEntries={["/run"]}>
|
||||
<Picker />
|
||||
<Workspace />
|
||||
</MemoryRouter>
|
||||
);
|
||||
await settle();
|
||||
|
||||
fireEvent.click(screen.getByTestId("lane-tile-2"));
|
||||
fireEvent.click(screen.getByTestId("pick-2"));
|
||||
await settle();
|
||||
expect(cwdInput().value).toBe(LANE_B.cwd);
|
||||
|
||||
fireEvent.click(screen.getByTestId("lane-tile-1"));
|
||||
fireEvent.click(screen.getByTestId("pick-1"));
|
||||
await settle();
|
||||
expect(cwdInput().value).toBe(LANE_A.cwd);
|
||||
});
|
||||
|
||||
@@ -1,614 +0,0 @@
|
||||
/**
|
||||
* @file Workspace.laneReorderRename.test.tsx
|
||||
* @description Integration tests for lane strip reordering, persistence to
|
||||
* localStorage, and inline lane rename functionality. Covers the three paths
|
||||
* to reorder (drag, Alt+Arrow, and refetch with stored order), and rename
|
||||
* commit/cancel/error paths.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { render, act, screen, waitFor, fireEvent } from "@testing-library/react";
|
||||
import { MemoryRouter } from "react-router-dom";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
|
||||
const recordedCalls: Array<{ method: string; path: string; payload?: unknown }> = [];
|
||||
|
||||
type LaneFixture = {
|
||||
id: number;
|
||||
cwd: string;
|
||||
title: string;
|
||||
pipeline_name: string;
|
||||
pipeline_nodes: never[];
|
||||
detected_signal: string | null;
|
||||
run_id: string | null;
|
||||
};
|
||||
|
||||
let lanesToReturn: LaneFixture[] = [
|
||||
{
|
||||
id: 1,
|
||||
cwd: "/workspace/a",
|
||||
title: "Lane A",
|
||||
pipeline_name: "default",
|
||||
pipeline_nodes: [],
|
||||
detected_signal: null,
|
||||
run_id: null,
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
cwd: "/workspace/b",
|
||||
title: "Lane B",
|
||||
pipeline_name: "default",
|
||||
pipeline_nodes: [],
|
||||
detected_signal: null,
|
||||
run_id: null,
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
cwd: "/workspace/c",
|
||||
title: "Lane C",
|
||||
pipeline_name: "default",
|
||||
pipeline_nodes: [],
|
||||
detected_signal: null,
|
||||
run_id: null,
|
||||
},
|
||||
];
|
||||
|
||||
vi.mock("../../lib/api", async (importOriginal) => {
|
||||
const actual = await importOriginal<Record<string, unknown>>();
|
||||
const recordCall = (method: string, path: string, payload?: unknown) => {
|
||||
recordedCalls.push({ method, path, payload });
|
||||
};
|
||||
return {
|
||||
...actual,
|
||||
api: {
|
||||
lanes: {
|
||||
list: vi.fn().mockImplementation(async () => {
|
||||
recordCall("GET", "/api/lanes");
|
||||
return {
|
||||
lanes: lanesToReturn,
|
||||
counts: { total: lanesToReturn.length, running: 0, needs_you: 0, dead: 0 },
|
||||
};
|
||||
}),
|
||||
ensure: vi.fn(),
|
||||
action: vi.fn(),
|
||||
git: vi.fn().mockImplementation(async (id: number) => {
|
||||
recordCall("GET", `/api/lanes/${id}/git`);
|
||||
return { available: false };
|
||||
}),
|
||||
runtime: vi.fn().mockImplementation(async (id: number) => {
|
||||
recordCall("GET", `/api/lanes/${id}/runtime`);
|
||||
return { available: false };
|
||||
}),
|
||||
stage: vi.fn(),
|
||||
pipelines: vi.fn().mockImplementation(async () => {
|
||||
recordCall("GET", "/api/lanes/pipelines");
|
||||
return { pipelines: [{ id: "default", name: "default", nodes: [] }] };
|
||||
}),
|
||||
update: vi.fn().mockImplementation(async (id: number, patch: Record<string, unknown>) => {
|
||||
recordCall("PATCH", `/api/lanes/${id}`, patch);
|
||||
const lane = lanesToReturn.find((l) => l.id === id);
|
||||
const updated = { ...lane, ...patch } as LaneFixture;
|
||||
// Update in place for subsequent reads
|
||||
const idx = lanesToReturn.findIndex((l) => l.id === id);
|
||||
if (idx >= 0) lanesToReturn[idx] = updated;
|
||||
return { lane: updated };
|
||||
}),
|
||||
features: {
|
||||
list: vi.fn().mockImplementation(async (id: number) => {
|
||||
recordCall("GET", `/api/lanes/${id}/features`);
|
||||
return { features: [] };
|
||||
}),
|
||||
show: vi.fn(),
|
||||
},
|
||||
proof: {
|
||||
list: vi.fn().mockImplementation(async (id: number) => {
|
||||
recordCall("GET", `/api/lanes/${id}/proof`);
|
||||
return { features: [] };
|
||||
}),
|
||||
imageUrl: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
},
|
||||
},
|
||||
aiRouters: {
|
||||
list: vi.fn().mockImplementation(async () => {
|
||||
recordCall("GET", "/api/ai-routers");
|
||||
return { items: [] };
|
||||
}),
|
||||
},
|
||||
run: {
|
||||
list: vi.fn().mockImplementation(async () => {
|
||||
recordCall("GET", "/api/run/list");
|
||||
return { runs: [], items: [] };
|
||||
}),
|
||||
history: vi.fn().mockResolvedValue({ items: [] }),
|
||||
binary: vi.fn().mockResolvedValue({ found: true, path: "/usr/bin/claude" }),
|
||||
cwds: vi.fn().mockResolvedValue({ items: [] }),
|
||||
files: vi.fn().mockResolvedValue({ items: [] }),
|
||||
start: vi.fn(),
|
||||
get: vi.fn(),
|
||||
send: vi.fn(),
|
||||
kill: vi.fn(),
|
||||
},
|
||||
ccConfig: {
|
||||
commands: vi.fn().mockResolvedValue({ items: [] }),
|
||||
plugins: vi.fn().mockResolvedValue({ plugins: [] }),
|
||||
file: vi.fn().mockResolvedValue({ text: "", content: "" }),
|
||||
},
|
||||
sessions: {
|
||||
list: vi.fn().mockResolvedValue({ sessions: [], total: 0, limit: 50, offset: 0 }),
|
||||
transcript: vi.fn().mockResolvedValue({ messages: [] }),
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("../../lib/eventBus", () => ({
|
||||
eventBus: {
|
||||
subscribe: () => () => {},
|
||||
publish: () => {},
|
||||
onConnection: () => () => {},
|
||||
connected: true,
|
||||
setConnected: () => {},
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("../../components/run/TerminalView", () => ({
|
||||
TerminalView: ({ runId }: { runId: string }) => (
|
||||
<div data-testid="terminal-view" data-run-id={runId} />
|
||||
),
|
||||
}));
|
||||
|
||||
import { Workspace } from "../Workspace";
|
||||
|
||||
class ObserverStub {
|
||||
observe() {}
|
||||
unobserve() {}
|
||||
disconnect() {}
|
||||
takeRecords() {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof globalThis !== "undefined") {
|
||||
globalThis.ResizeObserver =
|
||||
globalThis.ResizeObserver || (ObserverStub as unknown as typeof ResizeObserver);
|
||||
if (typeof Element !== "undefined") {
|
||||
for (const fn of ["scrollIntoView", "scrollBy", "scrollTo"] as const) {
|
||||
if (!(Element.prototype as unknown as Record<string, unknown>)[fn]) {
|
||||
(Element.prototype as unknown as Record<string, unknown>)[fn] = function () {};
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function settle() {
|
||||
await act(async () => {
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
});
|
||||
}
|
||||
|
||||
async function renderWorkspace() {
|
||||
const utils = render(
|
||||
<MemoryRouter initialEntries={["/run"]}>
|
||||
<Workspace />
|
||||
</MemoryRouter>
|
||||
);
|
||||
await settle();
|
||||
await settle();
|
||||
await settle();
|
||||
return utils;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
recordedCalls.length = 0;
|
||||
lanesToReturn = [
|
||||
{
|
||||
id: 1,
|
||||
cwd: "/workspace/a",
|
||||
title: "Lane A",
|
||||
pipeline_name: "default",
|
||||
pipeline_nodes: [],
|
||||
detected_signal: null,
|
||||
run_id: null,
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
cwd: "/workspace/b",
|
||||
title: "Lane B",
|
||||
pipeline_name: "default",
|
||||
pipeline_nodes: [],
|
||||
detected_signal: null,
|
||||
run_id: null,
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
cwd: "/workspace/c",
|
||||
title: "Lane C",
|
||||
pipeline_name: "default",
|
||||
pipeline_nodes: [],
|
||||
detected_signal: null,
|
||||
run_id: null,
|
||||
},
|
||||
];
|
||||
localStorage.clear();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
recordedCalls.length = 0;
|
||||
localStorage.clear();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("Workspace — lane reordering", () => {
|
||||
it("persists lane order to localStorage when dragging a tile", async () => {
|
||||
await renderWorkspace();
|
||||
|
||||
// Verify initial order is A, B, C (strip includes "Add lane" prefix and ids)
|
||||
expect(screen.getByTestId("lane-strip").textContent).toMatch(/Lane A.*Lane B.*Lane C/);
|
||||
|
||||
// Drag tile 3 (Lane C) to position 0
|
||||
const tile1 = screen.getByTestId("lane-tile-1");
|
||||
const tile3 = screen.getByTestId("lane-tile-3");
|
||||
|
||||
fireEvent.dragStart(tile3, { dataTransfer: { setData: vi.fn(), effectAllowed: "" } });
|
||||
fireEvent.dragOver(tile1, { dataTransfer: { dropEffect: "", preventDefault: () => {} } });
|
||||
fireEvent.drop(tile1, {
|
||||
dataTransfer: { getData: vi.fn(() => "2"), preventDefault: () => {} },
|
||||
});
|
||||
|
||||
await settle();
|
||||
|
||||
// Check localStorage has the new order
|
||||
const stored = localStorage.getItem("agenthub.workspace.laneOrder");
|
||||
expect(stored).toBeDefined();
|
||||
const order = JSON.parse(stored!);
|
||||
expect(order).toEqual([3, 1, 2]);
|
||||
});
|
||||
|
||||
it("applies stored order when lanes are fetched", async () => {
|
||||
// Pre-populate localStorage with a custom order
|
||||
localStorage.setItem("agenthub.workspace.laneOrder", JSON.stringify([2, 3, 1]));
|
||||
|
||||
await renderWorkspace();
|
||||
|
||||
// The lane strip should reflect the stored order: B, C, A
|
||||
const strip = screen.getByTestId("lane-strip");
|
||||
const tiles = Array.from(strip.querySelectorAll("[data-testid^='lane-tile-']"));
|
||||
const laneIds = tiles
|
||||
.map((el) => el.getAttribute("data-testid"))
|
||||
.filter((id) => id && id.match(/lane-tile-\d+/))
|
||||
.map((id) => id!.replace("lane-tile-", ""));
|
||||
|
||||
expect(laneIds).toEqual(["2", "3", "1"]);
|
||||
});
|
||||
|
||||
it("appends new lanes to the end, preserving user order", async () => {
|
||||
// Start with a custom order
|
||||
localStorage.setItem("agenthub.workspace.laneOrder", JSON.stringify([2, 1]));
|
||||
|
||||
await renderWorkspace();
|
||||
let strip = screen.getByTestId("lane-strip");
|
||||
let tiles = Array.from(strip.querySelectorAll("[data-testid^='lane-tile-']"));
|
||||
let laneIds = tiles
|
||||
.map((el) => el.getAttribute("data-testid"))
|
||||
.filter((id) => id && id.match(/lane-tile-\d+/))
|
||||
.map((id) => id!.replace("lane-tile-", ""));
|
||||
expect(laneIds).toEqual(["2", "1", "3"]); // 3 is new, appended at end
|
||||
});
|
||||
|
||||
it("ignores stored ids for lanes that no longer exist", async () => {
|
||||
// Store an order that includes a lane ID that will be removed
|
||||
localStorage.setItem("agenthub.workspace.laneOrder", JSON.stringify([2, 999, 1]));
|
||||
|
||||
// Remove lane 2 from the server response
|
||||
lanesToReturn = lanesToReturn.filter((l) => l.id !== 2);
|
||||
|
||||
await renderWorkspace();
|
||||
|
||||
// The strip should show lane 1 and 3 in stored order, skipping 999
|
||||
const strip = screen.getByTestId("lane-strip");
|
||||
const tiles = Array.from(strip.querySelectorAll("[data-testid^='lane-tile-']"));
|
||||
const laneIds = tiles
|
||||
.map((el) => el.getAttribute("data-testid"))
|
||||
.filter((id) => id && id.match(/lane-tile-\d+/))
|
||||
.map((id) => id!.replace("lane-tile-", ""));
|
||||
|
||||
// Expected: skip 999 and 2, show 1, then 3 (the remaining fresh lane)
|
||||
expect(laneIds).toEqual(["1", "3"]);
|
||||
});
|
||||
|
||||
it("reorders with Alt+Arrow keyboard shortcut", async () => {
|
||||
await renderWorkspace();
|
||||
|
||||
// Get tile 2 (Lane B) and move it right to position 2
|
||||
const tile2 = screen.getByTestId("lane-tile-2");
|
||||
fireEvent.keyDown(tile2, { key: "ArrowRight", altKey: true });
|
||||
|
||||
await settle();
|
||||
|
||||
// Check localStorage
|
||||
const stored = localStorage.getItem("agenthub.workspace.laneOrder");
|
||||
const order = JSON.parse(stored!);
|
||||
// Tile 2 was at index 1, moving right puts it at index 2
|
||||
expect(order).toEqual([1, 3, 2]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Workspace — lane rename", () => {
|
||||
it("enters rename mode when the pencil icon is clicked", async () => {
|
||||
const user = userEvent.setup();
|
||||
await renderWorkspace();
|
||||
|
||||
// Click the first lane to select it (so its detail panel shows)
|
||||
const tile1 = screen.getByTestId("lane-tile-1");
|
||||
await user.click(tile1);
|
||||
await settle();
|
||||
|
||||
// Find the rename button (displays the lane title with a pencil icon)
|
||||
const renameButton = screen.getByTestId("lane-rename");
|
||||
await user.click(renameButton);
|
||||
await settle();
|
||||
|
||||
// The input should appear with the current title
|
||||
const input = screen.getByTestId("lane-rename-input") as HTMLInputElement;
|
||||
expect(input).toBeInTheDocument();
|
||||
expect(input.value).toBe("Lane A");
|
||||
});
|
||||
|
||||
it("commits a rename when Enter is pressed", async () => {
|
||||
const user = userEvent.setup();
|
||||
await renderWorkspace();
|
||||
|
||||
// Select lane 1 and open rename
|
||||
const tile1 = screen.getByTestId("lane-tile-1");
|
||||
await user.click(tile1);
|
||||
await settle();
|
||||
|
||||
const renameButton = screen.getByTestId("lane-rename");
|
||||
await user.click(renameButton);
|
||||
await settle();
|
||||
|
||||
const input = screen.getByTestId("lane-rename-input") as HTMLInputElement;
|
||||
await user.clear(input);
|
||||
await user.type(input, "New Lane Name");
|
||||
fireEvent.keyDown(input, { key: "Enter" });
|
||||
await settle();
|
||||
|
||||
// Should have called update with trimmed title
|
||||
await waitFor(() => {
|
||||
const updateCall = recordedCalls.find(
|
||||
(c) => c.path === "/api/lanes/1" && c.method === "PATCH"
|
||||
);
|
||||
expect(updateCall).toBeDefined();
|
||||
expect(updateCall?.payload).toEqual({ title: "New Lane Name" });
|
||||
});
|
||||
|
||||
// Input should be gone, button should be back
|
||||
expect(screen.queryByTestId("lane-rename-input")).toBeNull();
|
||||
expect(screen.getByTestId("lane-rename")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("cancels rename when Escape is pressed", async () => {
|
||||
const user = userEvent.setup();
|
||||
await renderWorkspace();
|
||||
|
||||
const tile1 = screen.getByTestId("lane-tile-1");
|
||||
await user.click(tile1);
|
||||
await settle();
|
||||
|
||||
const renameButton = screen.getByTestId("lane-rename");
|
||||
await user.click(renameButton);
|
||||
await settle();
|
||||
|
||||
const input = screen.getByTestId("lane-rename-input") as HTMLInputElement;
|
||||
await user.type(input, "should not save");
|
||||
fireEvent.keyDown(input, { key: "Escape" });
|
||||
await settle();
|
||||
|
||||
// Input should be gone
|
||||
expect(screen.queryByTestId("lane-rename-input")).toBeNull();
|
||||
// No API call should have been made
|
||||
const updateCalls = recordedCalls.filter(
|
||||
(c) => c.path === "/api/lanes/1" && c.method === "PATCH"
|
||||
);
|
||||
expect(updateCalls).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("exits rename mode when focus leaves (blur/tab away), attempting to commit", async () => {
|
||||
const user = userEvent.setup();
|
||||
await renderWorkspace();
|
||||
|
||||
const tile1 = screen.getByTestId("lane-tile-1");
|
||||
await user.click(tile1);
|
||||
await settle();
|
||||
|
||||
const renameButton = screen.getByTestId("lane-rename");
|
||||
await user.click(renameButton);
|
||||
await settle();
|
||||
|
||||
const input = screen.getByTestId("lane-rename-input") as HTMLInputElement;
|
||||
expect(input).toBeInTheDocument();
|
||||
|
||||
// Move focus away (tab)
|
||||
await user.tab();
|
||||
await settle();
|
||||
|
||||
// Input should be gone (rename mode exited)
|
||||
expect(screen.queryByTestId("lane-rename-input")).toBeNull();
|
||||
// The rename button should be back
|
||||
expect(screen.getByTestId("lane-rename")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("trims whitespace from the renamed title", async () => {
|
||||
const user = userEvent.setup();
|
||||
await renderWorkspace();
|
||||
|
||||
const tile1 = screen.getByTestId("lane-tile-1");
|
||||
await user.click(tile1);
|
||||
await settle();
|
||||
|
||||
const renameButton = screen.getByTestId("lane-rename");
|
||||
await user.click(renameButton);
|
||||
await settle();
|
||||
|
||||
const input = screen.getByTestId("lane-rename-input") as HTMLInputElement;
|
||||
await user.clear(input);
|
||||
await user.type(input, " Trimmed ");
|
||||
fireEvent.keyDown(input, { key: "Enter" });
|
||||
await settle();
|
||||
|
||||
// Should send trimmed title
|
||||
await waitFor(() => {
|
||||
const updateCall = recordedCalls.find(
|
||||
(c) => c.path === "/api/lanes/1" && c.method === "PATCH"
|
||||
);
|
||||
expect(updateCall?.payload).toEqual({ title: "Trimmed" });
|
||||
});
|
||||
});
|
||||
|
||||
it("clears the title (puts cwd in its place) when empty name is submitted", async () => {
|
||||
const user = userEvent.setup();
|
||||
await renderWorkspace();
|
||||
|
||||
const tile1 = screen.getByTestId("lane-tile-1");
|
||||
await user.click(tile1);
|
||||
await settle();
|
||||
|
||||
const renameButton = screen.getByTestId("lane-rename");
|
||||
await user.click(renameButton);
|
||||
await settle();
|
||||
|
||||
const input = screen.getByTestId("lane-rename-input") as HTMLInputElement;
|
||||
await user.clear(input);
|
||||
// Leave it empty (or with just whitespace)
|
||||
await user.type(input, " ");
|
||||
fireEvent.keyDown(input, { key: "Enter" });
|
||||
await settle();
|
||||
|
||||
// Should send empty string
|
||||
await waitFor(() => {
|
||||
const updateCall = recordedCalls.find(
|
||||
(c) => c.path === "/api/lanes/1" && c.method === "PATCH"
|
||||
);
|
||||
expect(updateCall?.payload).toEqual({ title: "" });
|
||||
});
|
||||
});
|
||||
|
||||
it("does not call the API if the title has not changed", async () => {
|
||||
const user = userEvent.setup();
|
||||
await renderWorkspace();
|
||||
|
||||
const tile1 = screen.getByTestId("lane-tile-1");
|
||||
await user.click(tile1);
|
||||
await settle();
|
||||
|
||||
const renameButton = screen.getByTestId("lane-rename");
|
||||
await user.click(renameButton);
|
||||
await settle();
|
||||
|
||||
const input = screen.getByTestId("lane-rename-input") as HTMLInputElement;
|
||||
// Don't change the title, just press Enter
|
||||
fireEvent.keyDown(input, { key: "Enter" });
|
||||
await settle();
|
||||
|
||||
// Should NOT have called update (or at least not with a changed value)
|
||||
const updateCalls = recordedCalls.filter(
|
||||
(c) => c.path === "/api/lanes/1" && c.method === "PATCH"
|
||||
);
|
||||
expect(updateCalls).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("allows the user to retry after an error, starting fresh", async () => {
|
||||
const user = userEvent.setup();
|
||||
await renderWorkspace();
|
||||
|
||||
const tile1 = screen.getByTestId("lane-tile-1");
|
||||
await user.click(tile1);
|
||||
await settle();
|
||||
|
||||
// Enter and exit rename mode twice to verify the state resets
|
||||
const renameButton = screen.getByTestId("lane-rename");
|
||||
await user.click(renameButton);
|
||||
await settle();
|
||||
|
||||
let input = screen.getByTestId("lane-rename-input") as HTMLInputElement;
|
||||
expect(input.value).toBe("Lane A");
|
||||
|
||||
fireEvent.keyDown(input, { key: "Escape" });
|
||||
await settle();
|
||||
|
||||
// Enter again to verify we can re-enter
|
||||
await user.click(screen.getByTestId("lane-rename"));
|
||||
await settle();
|
||||
input = screen.getByTestId("lane-rename-input") as HTMLInputElement;
|
||||
expect(input.value).toBe("Lane A");
|
||||
});
|
||||
|
||||
it("respects max length of 200 characters (enforced by server)", async () => {
|
||||
const user = userEvent.setup();
|
||||
await renderWorkspace();
|
||||
|
||||
const tile1 = screen.getByTestId("lane-tile-1");
|
||||
await user.click(tile1);
|
||||
await settle();
|
||||
|
||||
const renameButton = screen.getByTestId("lane-rename");
|
||||
await user.click(renameButton);
|
||||
await settle();
|
||||
|
||||
const input = screen.getByTestId("lane-rename-input") as HTMLInputElement;
|
||||
// HTML input has maxLength=200, so typing more should be capped
|
||||
expect(input.maxLength).toBe(200);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Workspace — lane order and rename interaction", () => {
|
||||
it("preserves custom order after renaming a lane", async () => {
|
||||
const user = userEvent.setup();
|
||||
// Set custom order
|
||||
localStorage.setItem("agenthub.workspace.laneOrder", JSON.stringify([3, 2, 1]));
|
||||
|
||||
await renderWorkspace();
|
||||
|
||||
// Verify order is C, B, A
|
||||
let strip = screen.getByTestId("lane-strip");
|
||||
let tiles = Array.from(strip.querySelectorAll("[data-testid^='lane-tile-']"));
|
||||
let laneIds = tiles
|
||||
.map((el) => el.getAttribute("data-testid"))
|
||||
.filter((id) => id && id.match(/lane-tile-\d+/))
|
||||
.map((id) => id!.replace("lane-tile-", ""));
|
||||
expect(laneIds).toEqual(["3", "2", "1"]);
|
||||
|
||||
// Click lane 2 (which is now at position 1)
|
||||
const tile2 = screen.getByTestId("lane-tile-2");
|
||||
await user.click(tile2);
|
||||
await settle();
|
||||
|
||||
// Rename it
|
||||
const renameButton = screen.getByTestId("lane-rename");
|
||||
await user.click(renameButton);
|
||||
await settle();
|
||||
|
||||
const input = screen.getByTestId("lane-rename-input") as HTMLInputElement;
|
||||
await user.clear(input);
|
||||
await user.type(input, "Renamed B");
|
||||
fireEvent.keyDown(input, { key: "Enter" });
|
||||
await settle();
|
||||
|
||||
// Wait for the refresh
|
||||
await waitFor(() => {
|
||||
const updateCall = recordedCalls.find(
|
||||
(c) => c.path === "/api/lanes/2" && c.method === "PATCH"
|
||||
);
|
||||
expect(updateCall).toBeDefined();
|
||||
});
|
||||
|
||||
// Order should still be preserved
|
||||
const stored = localStorage.getItem("agenthub.workspace.laneOrder");
|
||||
const order = JSON.parse(stored!);
|
||||
expect(order).toEqual([3, 2, 1]);
|
||||
});
|
||||
});
|
||||
@@ -11,7 +11,7 @@
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, act, screen, waitFor } from "@testing-library/react";
|
||||
import { MemoryRouter } from "react-router-dom";
|
||||
import { MemoryRouter, useSearchParams } from "react-router-dom";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
|
||||
type LaneFixture = {
|
||||
@@ -179,9 +179,24 @@ beforeEach(() => {
|
||||
h.lanes = lanesToReturn as unknown as typeof h.lanes;
|
||||
});
|
||||
|
||||
/** Stands in for the sidebar's project tree: picking a workspace sets `?lane=`. */
|
||||
function Picker() {
|
||||
const [, setParams] = useSearchParams();
|
||||
return (
|
||||
<>
|
||||
{lanesToReturn.map((l) => (
|
||||
<button key={l.id} onClick={() => setParams({ lane: String(l.id) })}>
|
||||
{`pick ${l.title}`}
|
||||
</button>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
async function renderWorkspace() {
|
||||
const utils = render(
|
||||
<MemoryRouter initialEntries={["/run"]}>
|
||||
<Picker />
|
||||
<Workspace />
|
||||
</MemoryRouter>
|
||||
);
|
||||
@@ -191,11 +206,9 @@ async function renderWorkspace() {
|
||||
return utils;
|
||||
}
|
||||
|
||||
/** Click the strip tile for a lane, which is how a lane is selected. */
|
||||
/** Pick a lane the way the sidebar does: by naming it in the URL. */
|
||||
async function selectLane(user: ReturnType<typeof userEvent.setup>, title: string) {
|
||||
const tile = screen.getAllByText(title)[0];
|
||||
if (!tile) throw new Error(`no strip tile for ${title}`);
|
||||
await user.click(tile);
|
||||
await user.click(screen.getByRole("button", { name: `pick ${title}` }));
|
||||
await settle();
|
||||
}
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { render, act, screen, waitFor, fireEvent } from "@testing-library/react";
|
||||
import { MemoryRouter } from "react-router-dom";
|
||||
import { MemoryRouter, useSearchParams } from "react-router-dom";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
|
||||
// Track all API calls made by recording the method and endpoint
|
||||
@@ -284,9 +284,27 @@ async function settle() {
|
||||
});
|
||||
}
|
||||
|
||||
/** Stands in for the sidebar's project tree: picking a workspace sets `?lane=`. */
|
||||
function Picker() {
|
||||
const [params, setParams] = useSearchParams();
|
||||
return (
|
||||
<>
|
||||
<span data-testid="lane-param">{params.get("lane")}</span>
|
||||
{[1, 2].map((id) => (
|
||||
<button
|
||||
key={id}
|
||||
data-testid={`pick-${id}`}
|
||||
onClick={() => setParams({ lane: String(id) })}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
async function renderWorkspace() {
|
||||
const utils = render(
|
||||
<MemoryRouter initialEntries={["/run"]}>
|
||||
<Picker />
|
||||
<Workspace />
|
||||
</MemoryRouter>
|
||||
);
|
||||
@@ -329,11 +347,12 @@ afterEach(() => {
|
||||
});
|
||||
|
||||
describe("Workspace page — lane integration", () => {
|
||||
it("lane strip lists the lanes the API returned", async () => {
|
||||
it("has no lane strip: it opens the first workspace and names it in the URL", async () => {
|
||||
await renderWorkspace();
|
||||
|
||||
expect(screen.getByTestId("lane-tile-1").textContent).toContain("Lane A");
|
||||
expect(screen.getByTestId("lane-tile-2").textContent).toContain("Lane B");
|
||||
expect(screen.queryByTestId("lane-strip")).toBeNull();
|
||||
expect(screen.getByTestId("lane-card-1")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("lane-param").textContent).toBe("1");
|
||||
});
|
||||
|
||||
it("selecting a lane switches the pipeline and run id", async () => {
|
||||
@@ -345,20 +364,16 @@ describe("Workspace page — lane integration", () => {
|
||||
await renderWorkspace();
|
||||
const user = userEvent.setup();
|
||||
|
||||
// Selection is announced on the tile itself, not inferred from styling.
|
||||
const laneATile = screen.getByTestId("lane-tile-1");
|
||||
const laneBTile = screen.getByTestId("lane-tile-2");
|
||||
expect(laneATile.getAttribute("aria-pressed")).toBe("true");
|
||||
expect(laneBTile.getAttribute("aria-pressed")).toBe("false");
|
||||
expect(screen.getByTestId("lane-card-1")).toBeInTheDocument();
|
||||
|
||||
await user.click(laneBTile);
|
||||
// The sidebar names the workspace in the URL; the page follows it.
|
||||
await user.click(screen.getByTestId("pick-2"));
|
||||
await settle();
|
||||
|
||||
expect(laneBTile.getAttribute("aria-pressed")).toBe("true");
|
||||
expect(laneATile.getAttribute("aria-pressed")).toBe("false");
|
||||
// And the detail panel follows: only the selected lane gets a full card.
|
||||
// Only the selected lane gets a full card.
|
||||
expect(screen.getByTestId("lane-card-2")).toBeInTheDocument();
|
||||
expect(screen.queryByTestId("lane-card-1")).toBeNull();
|
||||
expect(screen.queryByTestId("lane-card-1")).toBeNull();
|
||||
});
|
||||
|
||||
it("starting a run requests /api/lanes/<id>/start and not /api/run/start", async () => {
|
||||
@@ -490,27 +505,17 @@ describe("Workspace page — lane integration", () => {
|
||||
});
|
||||
|
||||
describe("Workspace layout", () => {
|
||||
it("opens the add-lane modal from the strip's first tile", async () => {
|
||||
it("opens the add-workspace modal from the empty banner when there is none", async () => {
|
||||
const user = userEvent.setup();
|
||||
lanesToReturn = [];
|
||||
await renderWorkspace();
|
||||
|
||||
// The tile leads the strip, so it is reachable without scrolling past lanes.
|
||||
const strip = screen.getByTestId("lane-strip");
|
||||
expect(strip.firstElementChild).toBe(screen.getByTestId("lane-tile-add"));
|
||||
|
||||
await user.click(screen.getByTestId("lane-tile-add"));
|
||||
await user.click(screen.getByTestId("lane-empty"));
|
||||
expect(
|
||||
await screen.findByText("Create a workspace from a working directory")
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("gives every lane a tile in the carousel", async () => {
|
||||
await renderWorkspace();
|
||||
expect(screen.getByTestId("lane-strip")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("lane-tile-1")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("lane-tile-2")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows the full card only for the selected lane, in the detail panel", async () => {
|
||||
await renderWorkspace();
|
||||
// Lane 1 is selected by default. Its full card — controls, git facts — is
|
||||
@@ -555,7 +560,6 @@ describe("Workspace — the console is its own section", () => {
|
||||
// The console is a window onto a process, not a property of a card. Nesting
|
||||
// it in one made the owning card span the row and left a hole when closed.
|
||||
expect(screen.getByTestId("lane-card-1").contains(body)).toBe(false);
|
||||
expect(screen.getByTestId("lane-strip").contains(body)).toBe(false);
|
||||
});
|
||||
|
||||
it("has no header line of its own - the pipeline panel already names the lane", async () => {
|
||||
|
||||
Reference in New Issue
Block a user