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:
2026-10-01 16:33:25 +07:00
parent 5b3884b309
commit ba2c4e1914
39 changed files with 2431 additions and 1439 deletions
@@ -8,7 +8,9 @@
* file — one file, as a diff or as its whole content (issue #35); a
* whole markdown file can also be read rendered (Preview)
* history — recent commits; opening one shows that commit's diff
* browse — quick-open by path, or the repo's tracked file tree
* browse — quick-open by path, or the repo's tracked file tree (the
* Workspace right panel's Files tab; the other four are its
* Changes tab)
*
* and three ranges: `work` (uncommitted, what the agent has touched right
* now), `branch` (`base...HEAD`, what this lane adds on top of its base) and
@@ -417,8 +419,13 @@ export function LaneChangesPanel({
laneId,
onNewLane,
full = false,
tab = "changes",
}: {
laneId: number;
/** The Workspace right panel's activity-bar tab: `files` is the browse mode
* on its own (no git header, no mode switcher), `changes` the other four.
* At full width both show the explorer sidebar beside the changes. */
tab?: "files" | "changes";
/** Open the Add-lane flow for a sibling worktree; see `LaneGitBar`. */
onNewLane?: (branch?: string) => void;
/** The panel has the whole width: the file tree becomes a sidebar and a
@@ -582,16 +589,19 @@ export function LaneChangesPanel({
.catch((err) => setError(err instanceof Error ? err.message : String(err)));
}, [mode, laneId, reread]);
// Re-run when a lane switch empties the cache too: the Files tab stays in
// `browse` across the switch, so the mode alone never changes.
const treeRootLoaded = "" in treeChildren;
useEffect(() => {
const wanted = full || (mode === "browse" && !query.trim());
if (!wanted || "" in treeChildren) return;
if (!wanted || treeRootLoaded) return;
void api.lanes
.gitTree(laneId, "")
.then(forLane(laneId, (r) => setTreeChildren((prev) => ({ ...prev, "": r.entries }))))
.catch((err) => setError(err instanceof Error ? err.message : String(err)));
// `treeChildren` is the cache this fills — reading it as a dep would loop.
// `treeChildren` itself is the cache this fills — a dep on it would loop.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [mode, laneId, query, reread, full]);
}, [mode, laneId, query, reread, full, treeRootLoaded]);
// A folder's children are fetched lazily, the first time it is expanded.
const toggleDir = (path: string) => {
@@ -706,6 +716,11 @@ export function LaneChangesPanel({
returnTo.current = null;
pendingScroll.current = null;
}, [laneId]);
// After the lane reset above: each tab opens on its own mode.
useEffect(() => {
if (!full) setMode(tab === "files" ? "browse" : "overview");
}, [tab, laneId, full]);
const filesTab = tab === "files" && !full;
// Restoring has to wait for the list to be TALL enough again: leaving a file
// re-fetches the mode it came from, so the first render back is an empty
@@ -1032,7 +1047,9 @@ export function LaneChangesPanel({
</aside>
)}
<div className="flex min-h-0 min-w-0 flex-1 flex-col">
<LaneGitHeader laneId={laneId} git={git} onChanged={rereadAll} onNewLane={onNewLane} />
{!filesTab && (
<LaneGitHeader laneId={laneId} git={git} onChanged={rereadAll} onNewLane={onNewLane} />
)}
{/* ONE wrapping row: where you are (mode) then how you are looking at it
(range, layout, context, whole file), refresh last. Each group is
its own inline box, so a narrow panel wraps BETWEEN groups — never
@@ -1048,12 +1065,9 @@ export function LaneChangesPanel({
<ChevronLeft className="h-3 w-3" />
{t("changes.back")}
</button>
) : (
) : filesTab ? null : (
<div className="flex min-w-0 items-center rounded border border-border bg-surface-2 p-0.5">
{(full
? (["overview", "review", "history"] as const)
: (["browse", "overview", "review", "history"] as const)
).map((m) => (
{(["overview", "review", "history"] as const).map((m) => (
<button
key={m}
type="button"
@@ -1,152 +0,0 @@
/**
* @file The compact lane tile used in the Workspace's horizontal lane strip.
* It carries only what you need to pick a lane — which lane, is it alive,
* what stage — because the full card, its controls and its working-copy facts
* live in the detail panel below it. Stage badges ride on the id row rather
* than a line of their own, and progress (bar and percent) is deliberately
* absent: the detail panel's pipeline map below shows the same thing with the
* stage names attached. Keeping the tile small and fixed-width is what lets
* many lanes stay scannable in one scrolling row.
*
* The tile is also the strip's reorder handle: native HTML5 drag-and-drop, no
* dependency. Ceiling: desktop pointers only — there is no touch drag and no
* auto-scroll when the strip overflows, so Alt+Arrow moves the focused tile as
* the keyboard (and touch-free) path.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useState } from "react";
import { useTranslation } from "react-i18next";
import type { Lane } from "../../lib/types";
import { useAiTools } from "../../hooks/useAiTools";
const LIVENESS_DOT: Record<Lane["liveness"], string> = {
active: "bg-status-success",
idle: "bg-surface-4",
dead: "bg-status-danger",
};
/**
* Whether the inferred stage sits ahead of the declared one in node order.
*
* The declaration is located by the node the server resolved it onto (`state
* === "current"`), not by `indexOf(lane.stage)`: a stage declared under an
* ALIAS (`tests`, `booting`, `ship` — most of what the ship-feature skill
* declares) is not a node id, so indexOf answers -1 and a detection sitting
* well BEHIND the declaration would still be badged as the live one.
*
* `>=`, not `>`: an undeclared lane is rendered AT its detected node by the
* server (see `inferredDisplayStage`), so the detection and the "current" node
* are the same one — and that lane is precisely the one whose stage the badge
* is the only readout for. A declared lane can never tie, because
* recordDetection refuses any detection that isn't strictly ahead.
*/
function detectionLeads(lane: Lane): boolean {
if (!lane.detected_stage || !lane.pipeline_nodes.length) return false;
const nodes = lane.pipeline_nodes;
return (
nodes.findIndex((n) => n.id === lane.detected_stage) >=
nodes.findIndex((n) => n.state === "current")
);
}
export default function LaneStripCard({
lane,
index,
selected,
onSelect,
onReorder,
}: {
lane: Lane;
/** Position in the lane array — the unit both reorder paths speak in. */
index: number;
selected: boolean;
onSelect: () => void;
onReorder: (from: number, to: number) => void;
}) {
const { t } = useTranslation(["lanes"]);
const { label: toolLabel } = useAiTools();
const [dropTarget, setDropTarget] = useState(false);
return (
<button
type="button"
data-testid={`lane-tile-${lane.id}`}
data-selected={selected ? "true" : undefined}
data-drop-target={dropTarget ? "true" : undefined}
aria-pressed={selected}
onClick={onSelect}
draggable
onDragStart={(e) => {
// Firefox starts no drag at all without a setData call.
e.dataTransfer.setData("text/plain", String(index));
e.dataTransfer.effectAllowed = "move";
}}
onDragOver={(e) => {
e.preventDefault();
e.dataTransfer.dropEffect = "move";
setDropTarget(true);
}}
onDragLeave={() => setDropTarget(false)}
onDragEnd={() => setDropTarget(false)}
onDrop={(e) => {
e.preventDefault();
setDropTarget(false);
const from = Number(e.dataTransfer.getData("text/plain"));
if (Number.isInteger(from)) onReorder(from, index);
}}
onKeyDown={(e) => {
if (!e.altKey || (e.key !== "ArrowLeft" && e.key !== "ArrowRight")) return;
e.preventDefault();
onReorder(index, index + (e.key === "ArrowLeft" ? -1 : 1));
}}
title={`${lane.cwd}\n${t("reorderHint")}`}
className={`w-56 shrink-0 cursor-grab rounded-lg border p-3 text-left shadow-sm transition-colors active:cursor-grabbing ${
dropTarget
? "border-accent bg-accent/20"
: selected
? "border-accent bg-accent/10"
: "border-border bg-surface-2 hover:border-border-light hover:bg-surface-3"
}`}
>
<div className="mb-1.5 flex items-center gap-1.5 text-[11px]">
<span className={`h-2 w-2 shrink-0 rounded-full ${LIVENESS_DOT[lane.liveness]}`} />
{/* The bare id, not "Lane 23": the word cost a third of the row's
width and said nothing, but the number itself is how the CLI
addresses a lane (`agenthub stage --lane 23`), so it stays. */}
<span className="shrink-0 text-[10px] font-semibold tracking-widest text-fg-muted">
#{lane.id}
</span>
{/* Only claude lanes carry a pipeline, and only while a template is
installed; the others show their CLI's tag in place of the stage chip. */}
{lane.tool !== "claude" || lane.pipeline_nodes.length === 0 ? (
<span className="shrink-0 rounded border border-border bg-surface-2 px-1.5 py-0.5 text-[10px] text-fg-muted">
{toolLabel(lane.tool)}
</span>
) : (
<span className="truncate rounded bg-surface-4 px-1.5 py-0.5 text-fg-secondary">
{lane.stage}
</span>
)}
{lane.tool === "claude" && detectionLeads(lane) && (
<span
data-testid={`lane-tile-auto-${lane.id}`}
title={lane.detected_signal || undefined}
className="shrink-0 rounded border border-dashed border-status-warning/50 px-1 text-status-warning"
>
{lane.detected_stage}
</span>
)}
{lane.needs_action && (
<span className="ml-auto shrink-0 text-status-warning" title={lane.needs_action}>
⚠
</span>
)}
</div>
<div className="truncate text-[13px] font-medium text-fg-primary">
{lane.title || lane.cwd}
</div>
</button>
);
}
@@ -0,0 +1,177 @@
/**
* @file WorkspaceRow — one workspace (lane) in the sidebar's project tree:
* status dot, title (double-click renames, `PATCH /api/lanes/:id {title}`),
* branch or relative path, the tool's icon and the id the CLI addresses it by.
* Selecting it opens the Workspace page on that lane. Dragging it, or Alt+↑/↓,
* reorders it within its project — a viewer preference kept in localStorage
* (`laneOrderStorage.ts`), never written to the lane row. Right-click or the
* `⋯` button opens the row menu (`WorkspaceRowMenu.tsx`).
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useState, type MouseEvent } from "react";
import { useTranslation } from "react-i18next";
import { MoreHorizontal } from "lucide-react";
import type { Lane } from "../../lib/types";
import { useAiTools } from "../../hooks/useAiTools";
import { AiToolIcon } from "../AiToolIcon";
/** The row's dot: the lane's liveness, plus a running pulse and needs-you. */
export function workspaceDot(lane: Lane): string {
if (lane.needs_action) return "bg-status-warning";
if (lane.liveness === "dead" || lane.status === "error") return "bg-status-danger";
if (lane.status === "running") return "bg-status-success animate-pulse-slow";
if (lane.liveness === "active") return "bg-status-success";
return "bg-surface-4";
}
function relativeTo(root: string | null, cwd: string): string {
if (root && cwd.startsWith(root + "/")) return cwd.slice(root.length + 1);
return cwd;
}
export function WorkspaceRow({
lane,
projectRoot,
branch,
index,
selected,
renaming,
onSelect,
onReorder,
onMenu,
onRenameStart,
onRenameEnd,
}: {
lane: Lane;
projectRoot: string | null;
/** The checked-out branch: the lane's own, else its worktree's (an adopted
* lane records none). */
branch: string | null;
/** Position within its project — the unit both reorder paths speak in. */
index: number;
selected: boolean;
renaming: boolean;
onSelect: () => void;
onReorder: (from: number, to: number) => void;
/** Open the row menu at a viewport point. */
onMenu: (x: number, y: number) => void;
onRenameStart: () => void;
/** Called with the new title on Enter, or null when the edit was cancelled. */
onRenameEnd: (title: string | null) => void;
}) {
const { t } = useTranslation("lanes");
const tools = useAiTools();
const [draft, setDraft] = useState("");
const [dropTarget, setDropTarget] = useState(false);
const name = lane.title || lane.cwd.split("/").filter(Boolean).pop() || lane.cwd;
const openMenu = (e: MouseEvent) => {
e.preventDefault();
e.stopPropagation();
onMenu(e.clientX, e.clientY);
};
return (
<div
data-testid={`workspace-row-${lane.id}`}
data-drop-target={dropTarget ? "true" : undefined}
draggable={!renaming}
onDragStart={(e) => {
// Firefox starts no drag at all without a setData call.
e.dataTransfer.setData("text/plain", String(index));
e.dataTransfer.effectAllowed = "move";
}}
onDragOver={(e) => {
e.preventDefault();
setDropTarget(true);
}}
onDragLeave={() => setDropTarget(false)}
onDragEnd={() => setDropTarget(false)}
onDrop={(e) => {
e.preventDefault();
setDropTarget(false);
const from = Number(e.dataTransfer.getData("text/plain"));
if (Number.isInteger(from)) onReorder(from, index);
}}
onContextMenu={openMenu}
className={`group relative flex items-center rounded-md border-l-2 transition-colors ${
dropTarget
? "border-accent bg-accent/20"
: selected
? "border-accent bg-accent/10"
: lane.needs_action
? "border-transparent bg-status-warning/10 hover:bg-status-warning/20"
: "border-transparent hover:bg-surface-3"
}`}
>
{renaming ? (
<input
data-testid={`workspace-rename-${lane.id}`}
aria-label={t("renameLabel")}
autoFocus
value={draft}
maxLength={200}
onChange={(e) => setDraft(e.target.value)}
// Blur CANCELS — Enter is the only save, so one edit fires one PATCH.
onBlur={() => onRenameEnd(null)}
onKeyDown={(e) => {
if (e.key === "Enter") onRenameEnd(draft.trim());
if (e.key === "Escape") onRenameEnd(null);
}}
className="m-1 min-w-0 flex-1 rounded border border-accent bg-surface-1 px-1.5 py-0.5 text-[13px] text-fg-primary"
/>
) : (
<button
type="button"
aria-current={selected ? "true" : undefined}
onClick={onSelect}
onDoubleClick={() => {
setDraft(lane.title || "");
onRenameStart();
}}
onKeyDown={(e) => {
if (!e.altKey || (e.key !== "ArrowUp" && e.key !== "ArrowDown")) return;
e.preventDefault();
onReorder(index, index + (e.key === "ArrowUp" ? -1 : 1));
}}
title={`${lane.cwd}\n${t("row.hint")}`}
className="flex min-w-0 flex-1 items-start gap-2 py-1 pl-2 pr-7 text-left"
>
<span className={`mt-1.5 h-2 w-2 shrink-0 rounded-full ${workspaceDot(lane)}`} />
<span className="min-w-0 flex-1">
<span className="flex items-center gap-1.5">
<span className="truncate text-[13px] font-medium text-fg-primary">{name}</span>
{projectRoot !== null && lane.cwd === projectRoot && (
<span className="shrink-0 rounded border border-border px-1 text-[10px] text-fg-muted">
{t("row.primary")}
</span>
)}
</span>
<span className="flex items-center gap-1 text-[11px] text-fg-muted">
<span className="min-w-0 truncate">
{branch || relativeTo(projectRoot, lane.cwd)}
</span>
<AiToolIcon
tool={{ id: lane.tool, label: tools.label(lane.tool) }}
className="ml-auto h-3.5 w-3.5 !p-0"
/>
<span className="shrink-0 tabular-nums">#{lane.id}</span>
</span>
</span>
</button>
)}
{!renaming && (
<button
type="button"
data-testid={`workspace-menu-${lane.id}`}
aria-label={t("row.actions")}
title={t("row.actions")}
onClick={openMenu}
className="absolute right-1 top-1 rounded p-0.5 text-fg-muted opacity-0 transition-opacity hover:bg-surface-4 hover:text-fg-primary focus:opacity-100 group-hover:opacity-100"
>
<MoreHorizontal className="h-3.5 w-3.5" />
</button>
)}
</div>
);
}
@@ -0,0 +1,156 @@
/**
* @file WorkspaceRowMenu — the sidebar workspace row's context menu, opened at
* the pointer (right-click) or under the row's `⋯` button. Every item is an
* existing call: Start/Stop (`POST /api/lanes/:id/start` slot 0 / `stop`),
* Rename (the row's inline field), the AI Router select (`LaneRouterRow`,
* router-capable tools only), Create/update readme (claude only, the same run
* the lane Tools menu starts), New workspace here (the Add modal prefilled),
* Copy path, and Reset/Remove through `DestructiveLaneModal` — Reset only on a
* managed lane. The server's guards are the real ones; the menu only hides
* what the lane's kind or tool makes meaningless. Closes on Escape, an outside
* click or after an item runs.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { api } from "../../lib/api";
import type { Lane } from "../../lib/types";
import { LaneRouterRow } from "./LaneCard";
export type WorkspaceMenuAction = "rename" | "new-here" | "reset" | "remove" | "started";
export function WorkspaceRowMenu({
lane,
x,
y,
onClose,
onAction,
}: {
lane: Lane;
x: number;
y: number;
onClose: () => void;
/** Items the tree itself carries out (rename field, modals, selection). */
onAction: (action: WorkspaceMenuAction) => void;
}) {
const { t } = useTranslation("lanes");
const ref = useRef<HTMLDivElement>(null);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
const onDown = (e: MouseEvent) => {
if (!ref.current?.contains(e.target as Node)) onClose();
};
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") onClose();
};
document.addEventListener("mousedown", onDown);
document.addEventListener("keydown", onKey);
return () => {
document.removeEventListener("mousedown", onDown);
document.removeEventListener("keydown", onKey);
};
}, [onClose]);
const run = async (fn: () => Promise<unknown>, then?: WorkspaceMenuAction) => {
setError(null);
try {
await fn();
if (then) onAction(then);
onClose();
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
}
};
const live = !!lane.run_id || lane.status === "running";
const item =
"block w-full rounded px-2 py-1 text-left text-xs text-fg-secondary hover:bg-surface-4 hover:text-fg-primary";
return (
<div
ref={ref}
role="menu"
data-testid={`workspace-row-menu-${lane.id}`}
style={{
left: Math.min(x, window.innerWidth - 232),
top: Math.min(y, window.innerHeight - 320),
}}
className="fixed z-50 w-56 rounded-lg border border-border bg-surface-2 p-1 shadow-lg"
>
<button
type="button"
role="menuitem"
className={item}
onClick={() =>
live
? void run(() => api.lanes.action(lane.id, "stop"))
: void run(() => api.lanes.action(lane.id, "start", { slot: 0, prompt: "" }), "started")
}
>
{live ? t("menu.stop") : t("menu.start")}
</button>
<button type="button" role="menuitem" className={item} onClick={() => onAction("rename")}>
{t("menu.rename")}
</button>
<div className="px-2 py-1">
<LaneRouterRow lane={lane} />
</div>
{lane.tool === "claude" && (
<button
type="button"
role="menuitem"
className={item}
onClick={() =>
void run(
() =>
api.run.start({
laneId: lane.id,
cwd: lane.cwd,
initialPrompt: "/sg-all:sg-md-writing",
}),
"started"
)
}
>
{t("actions.writeDocs")}
</button>
)}
<button type="button" role="menuitem" className={item} onClick={() => onAction("new-here")}>
{t("menu.newHere")}
</button>
<button
type="button"
role="menuitem"
className={item}
onClick={() => void run(() => navigator.clipboard.writeText(lane.cwd))}
>
{t("menu.copyPath")}
</button>
<div className="my-1 border-t border-border" />
{lane.kind === "managed" && (
<button
type="button"
role="menuitem"
className={`${item} !text-status-danger`}
onClick={() => onAction("reset")}
>
{t("menu.reset")}
</button>
)}
<button
type="button"
role="menuitem"
className={`${item} !text-status-danger`}
onClick={() => onAction("remove")}
>
{t("menu.remove")}
</button>
{error && (
<p role="alert" className="px-2 py-1 text-[11px] text-status-danger">
{error}
</p>
)}
</div>
);
}
@@ -0,0 +1,101 @@
/**
* @file WorkspaceSessionsTab — the right panel's Sessions tab: the selected
* workspace's sessions (`GET /api/sessions?cwd=<lane cwd>&tool=<lane tool>`,
* newest first, 50), each with its tool badge, title, model, relative time and
* cost, filtered client-side by a search box. **Resume** (claude only — attach
* and resume go through `claude`) hands the session to the console's slot-0
* pane; **Open** goes to its detail page. The cwd match is exact, so a
* session started in a subdirectory of the workspace is not listed here.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { Link } from "react-router-dom";
import { api } from "../../lib/api";
import type { Lane, Session } from "../../lib/types";
import { fmtCost, formatModelName, timeAgo } from "../../lib/format";
import { SessionToolBadge } from "../SessionToolBadge";
export function WorkspaceSessionsTab({
lane,
onResume,
}: {
lane: Lane;
onResume: (session: Session) => void;
}) {
const { t } = useTranslation("lanes");
const [sessions, setSessions] = useState<Session[] | null>(null);
const [query, setQuery] = useState("");
useEffect(() => {
let live = true;
setSessions(null);
api.sessions
.list({ cwd: lane.cwd, tool: lane.tool, limit: 50 })
.then((r) => live && setSessions(r.sessions))
.catch(() => live && setSessions([]));
return () => {
live = false;
};
}, [lane.cwd, lane.tool]);
const q = query.trim().toLowerCase();
const shown = (sessions ?? []).filter(
(s) => !q || (s.name ?? "").toLowerCase().includes(q) || s.id.includes(q)
);
return (
<div data-testid="workspace-sessions" className="flex min-h-0 flex-1 flex-col gap-2">
<input
type="search"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder={t("sessions.search")}
aria-label={t("sessions.search")}
className="rounded border border-border bg-surface-1 px-2 py-1 text-xs text-fg-primary"
/>
{sessions !== null && shown.length === 0 && (
<p className="py-6 text-center text-xs text-fg-muted">{t("sessions.empty")}</p>
)}
<ul className="min-h-0 flex-1 space-y-1 overflow-y-auto">
{shown.map((s) => (
<li
key={s.id}
data-testid={`workspace-session-${s.id}`}
className="rounded-md border border-border bg-surface-2 px-2 py-1.5"
>
<div className="flex items-center gap-1.5">
<SessionToolBadge tool={s.tool} />
<span className="min-w-0 flex-1 truncate text-xs font-medium text-fg-primary">
{s.name || s.id.slice(0, 8)}
</span>
{lane.tool === "claude" && (
<button
type="button"
onClick={() => onResume(s)}
className="shrink-0 rounded border border-border px-1.5 text-[11px] text-fg-secondary hover:bg-surface-4 hover:text-fg-primary"
>
{t("sessions.resume")}
</button>
)}
<Link
to={`/sessions/${s.id}`}
className="shrink-0 rounded border border-border px-1.5 text-[11px] text-fg-secondary hover:bg-surface-4 hover:text-fg-primary"
>
{t("sessions.open")}
</Link>
</div>
<div className="mt-0.5 flex gap-2 text-[11px] text-fg-muted">
<span>{timeAgo(s.last_activity || s.started_at)}</span>
{s.model && <span className="truncate">{formatModelName(s.model)}</span>}
{s.cost !== undefined && (
<span className="ml-auto tabular-nums">{fmtCost(s.cost)}</span>
)}
</div>
</li>
))}
</ul>
</div>
);
}
@@ -13,6 +13,7 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, waitFor, act, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { useState } from "react";
import { LaneChangesPanel } from "../LaneChangesPanel";
import { api } from "../../../lib/api";
import type { LaneChanges } from "../../../lib/types";
@@ -130,6 +131,23 @@ beforeEach(() => {
});
});
/** The Workspace right panel's Files/Changes activity-bar buttons, around the
* panel — the tab is the Workspace's state, passed in as `tab`. */
function Tabbed({ laneId }: { laneId: number }) {
const [tab, setTab] = useState<"files" | "changes">("changes");
return (
<>
<button type="button" onClick={() => setTab("files")}>
Files
</button>
<button type="button" onClick={() => setTab("changes")}>
Changes
</button>
<LaneChangesPanel laneId={laneId} tab={tab} />
</>
);
}
describe("LaneChangesPanel", () => {
it("opens on the overview: a summary request, files listed, no diff bodies", async () => {
render(<LaneChangesPanel laneId={7} />);
@@ -553,7 +571,7 @@ describe("LaneChangesPanel", () => {
truncated: false,
}
);
render(<LaneChangesPanel laneId={7} />);
render(<Tabbed laneId={7} />);
await screen.findByTestId("lane-changes-overview");
await user.click(screen.getByRole("button", { name: "Files" }));
@@ -589,7 +607,7 @@ describe("LaneChangesPanel", () => {
truncated: false,
};
});
render(<LaneChangesPanel laneId={7} />);
render(<Tabbed laneId={7} />);
await screen.findByTestId("lane-changes-overview");
await user.click(screen.getByRole("button", { name: "Files" }));
@@ -617,7 +635,7 @@ describe("LaneChangesPanel", () => {
truncated: false,
});
const user = userEvent.setup();
render(<LaneChangesPanel laneId={7} />);
render(<Tabbed laneId={7} />);
await screen.findByTestId("lane-changes-overview");
await user.click(screen.getByRole("button", { name: "Files" }));
@@ -633,7 +651,7 @@ describe("LaneChangesPanel", () => {
it("quick-opens a tracked file as its whole content, not a diff", async () => {
const user = userEvent.setup();
render(<LaneChangesPanel laneId={7} />);
render(<Tabbed laneId={7} />);
await screen.findByTestId("lane-changes-overview");
await user.click(screen.getByRole("button", { name: "Files" }));
@@ -664,7 +682,7 @@ describe("LaneChangesPanel", () => {
sha: "c".repeat(40),
});
const user = userEvent.setup();
render(<LaneChangesPanel laneId={7} />);
render(<Tabbed laneId={7} />);
await screen.findByTestId("lane-changes-overview");
await user.click(screen.getByRole("button", { name: "Files" }));
await user.type(await screen.findByLabelText("Find a file by path…"), "main");
@@ -697,7 +715,7 @@ describe("LaneChangesPanel", () => {
lines: ["partial"],
});
const user = userEvent.setup();
render(<LaneChangesPanel laneId={7} />);
render(<Tabbed laneId={7} />);
await screen.findByTestId("lane-changes-overview");
await user.click(screen.getByRole("button", { name: "Files" }));
await user.type(await screen.findByLabelText("Find a file by path…"), "main");
@@ -723,7 +741,7 @@ describe("LaneChangesPanel", () => {
Object.assign(new Error("the file changed on disk since it was opened"), { code: "ESTALE" })
);
const user = userEvent.setup();
render(<LaneChangesPanel laneId={7} />);
render(<Tabbed laneId={7} />);
await screen.findByTestId("lane-changes-overview");
await user.click(screen.getByRole("button", { name: "Files" }));
await user.type(await screen.findByLabelText("Find a file by path…"), "main");
@@ -871,17 +889,12 @@ describe("LaneChangesPanel", () => {
expect(screen.queryByTestId("lane-changes-commit")).toBeNull();
});
it("puts Files first in the mode row", async () => {
it("leaves Files out of the mode row: it is the right panel's own tab", async () => {
render(<LaneChangesPanel laneId={7} />);
await screen.findByTestId("lane-changes-overview");
const modes = ["Files", "Overview", "Review", "History"].map((name) =>
screen.getByRole("button", { name })
);
// Source order is the reading order here, so comparing positions pins it.
for (let i = 1; i < modes.length; i += 1) {
expect(
modes[i - 1]!.compareDocumentPosition(modes[i]!) & Node.DOCUMENT_POSITION_FOLLOWING
).toBeTruthy();
expect(screen.queryByRole("button", { name: "Files" })).toBeNull();
for (const name of ["Overview", "Review", "History"]) {
expect(screen.getByRole("button", { name })).toBeInTheDocument();
}
});
@@ -1142,13 +1155,13 @@ describe("LaneChangesPanel", () => {
answer = r as typeof answer;
}) as ReturnType<typeof api.lanes.gitLs>
);
const { rerender } = render(<LaneChangesPanel laneId={7} />);
const { rerender } = render(<Tabbed laneId={7} />);
await screen.findByTestId("lane-changes-overview");
await user.click(screen.getByRole("button", { name: "Files" }));
await user.type(await screen.findByLabelText("Find a file by path…"), "main");
await waitFor(() => expect(api.lanes.gitLs).toHaveBeenCalledWith(7, "main"));
rerender(<LaneChangesPanel laneId={8} />);
rerender(<Tabbed laneId={8} />);
await act(async () => answer({ paths: ["from/lane7.ts"] }));
// Back in Files on lane 8, before its own search lands: nothing of lane 7.
await user.click(screen.getByRole("button", { name: "Files" }));
@@ -1170,7 +1183,7 @@ describe("LaneChangesPanel", () => {
truncated: false,
total: 1,
});
render(<LaneChangesPanel laneId={7} />);
render(<Tabbed laneId={7} />);
await screen.findByTestId("lane-changes-overview");
await user.click(screen.getByRole("button", { name: "Files" }));
const box = await screen.findByLabelText("Find a file by path…");
@@ -1208,7 +1221,7 @@ describe("LaneChangesPanel", () => {
it("opens an unchanged tracked file from Files with no error banner", async () => {
const user = userEvent.setup();
render(<LaneChangesPanel laneId={7} />);
render(<Tabbed laneId={7} />);
await screen.findByTestId("lane-changes-overview");
vi.mocked(api.lanes.changes).mockRejectedValue(
Object.assign(new Error("file has no changes in this range"), {
@@ -1250,7 +1263,7 @@ describe("LaneChangesPanel", () => {
src: [{ name: "main.ts", path: "src/main.ts", dir: false }],
},
});
render(<LaneChangesPanel laneId={7} />);
render(<Tabbed laneId={7} />);
await screen.findByTestId("lane-changes-overview");
await user.click(screen.getByRole("button", { name: "Files" }));
await screen.findByText("src");
@@ -1332,13 +1345,13 @@ describe("LaneChangesPanel", () => {
truncated: false,
});
});
const { rerender } = render(<LaneChangesPanel laneId={7} />);
const { rerender } = render(<Tabbed laneId={7} />);
await screen.findByTestId("lane-changes-overview");
await user.click(screen.getByRole("button", { name: "Files" }));
await screen.findByText("src");
await user.click(screen.getByRole("button", { name: "Expand all folders" }));
rerender(<LaneChangesPanel laneId={8} />);
rerender(<Tabbed laneId={8} />);
await user.click(await screen.findByRole("button", { name: "Files" }));
expect(await screen.findByText("lib8")).toBeInTheDocument();
@@ -1,195 +0,0 @@
/**
* @file Rendering tests for the Workspace lane tile: the amber "detected"
* badge (it may only appear when the inferred stage is genuinely AHEAD of the
* declared one, including when the declaration used a node alias — which is
* most of what the ship-feature skill declares), and the reorder handles.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { describe, it, expect, vi } from "vitest";
import { render, screen, fireEvent } from "@testing-library/react";
import LaneStripCard from "../LaneStripCard";
import type { Lane, LaneNode } from "../../../lib/types";
/** Four nodes of the ship-feature shape: `tests` is an ALIAS of `gates`. */
function nodes(currentId: string): LaneNode[] {
return (["intake", "implementing", "gates", "review"] as const).map((id) => ({
id,
label: id,
icon: "",
gate: false,
state: id === currentId ? "current" : "pending",
}));
}
function lane(overrides: Partial<Lane>): Lane {
return {
id: 1,
title: "lane",
cwd: "/tmp/lane",
branch: null,
kind: "adopted",
tool: "claude",
source_repo: null,
pipeline: "ship-feature",
session_id: null,
run_id: null,
stage: "gates",
stage_since: null,
status: "running",
gate_decision: null,
ci_status: null,
needs_action: null,
links: {},
stages: {},
notes: null,
pipeline_name: "Ship feature",
pipeline_nodes: nodes("gates"),
progress: 50,
stage_seconds: null,
last_event_seconds: null,
liveness: "active",
detected_stage: null,
detected_signal: null,
slot: null,
ports: {},
active_feature_id: null,
...overrides,
} as Lane;
}
describe("LaneStripCard detected badge", () => {
it("badges a detection that is ahead of the declaration", () => {
render(
<LaneStripCard
lane={lane({ detected_stage: "review" })}
index={0}
selected={false}
onSelect={() => {}}
onReorder={() => {}}
/>
);
expect(screen.getByTestId("lane-tile-auto-1")).toHaveTextContent("review");
});
it("stays silent when the declaration was made under an alias and the detection is behind it", () => {
// `tests` is an alias of the `gates` node — not a node id. Locating the
// declaration by id would score it -1 and badge this stale `implementing`
// detection as though the lane had moved backwards.
render(
<LaneStripCard
lane={lane({ stage: "tests", detected_stage: "implementing" })}
index={0}
selected={false}
onSelect={() => {}}
onReorder={() => {}}
/>
);
expect(screen.queryByTestId("lane-tile-auto-1")).toBeNull();
});
it("still badges a detection ahead of an alias declaration", () => {
render(
<LaneStripCard
lane={lane({ stage: "tests", detected_stage: "review" })}
index={0}
selected={false}
onSelect={() => {}}
onReorder={() => {}}
/>
);
expect(screen.getByTestId("lane-tile-auto-1")).toHaveTextContent("review");
});
});
describe("LaneStripCard reorder", () => {
/** jsdom ships no DataTransfer, so the drag events carry a stub store. */
function dataTransfer(initial = "") {
let payload = initial;
return {
setData: (_type: string, v: string) => {
payload = v;
},
getData: () => payload,
effectAllowed: "",
dropEffect: "",
};
}
it("reports the dragged tile's index and the drop target's", () => {
const onReorder = vi.fn();
const dt = dataTransfer();
render(
<>
<LaneStripCard
lane={lane({ id: 1 })}
index={0}
selected={false}
onSelect={() => {}}
onReorder={onReorder}
/>
<LaneStripCard
lane={lane({ id: 2 })}
index={2}
selected={false}
onSelect={() => {}}
onReorder={onReorder}
/>
</>
);
fireEvent.dragStart(screen.getByTestId("lane-tile-1"), { dataTransfer: dt });
fireEvent.drop(screen.getByTestId("lane-tile-2"), { dataTransfer: dt });
expect(onReorder).toHaveBeenCalledWith(0, 2);
});
it("ignores a drop carrying no tile index", () => {
const onReorder = vi.fn();
render(
<LaneStripCard
lane={lane({})}
index={0}
selected={false}
onSelect={() => {}}
onReorder={onReorder}
/>
);
fireEvent.drop(screen.getByTestId("lane-tile-1"), { dataTransfer: dataTransfer("nonsense") });
expect(onReorder).not.toHaveBeenCalled();
});
it("moves the focused tile with Alt+Arrow, the keyboard path for reordering", () => {
const onReorder = vi.fn();
render(
<LaneStripCard
lane={lane({})}
index={2}
selected={false}
onSelect={() => {}}
onReorder={onReorder}
/>
);
const tile = screen.getByTestId("lane-tile-1");
fireEvent.keyDown(tile, { key: "ArrowLeft", altKey: true });
expect(onReorder).toHaveBeenCalledWith(2, 1);
fireEvent.keyDown(tile, { key: "ArrowRight", altKey: true });
expect(onReorder).toHaveBeenCalledWith(2, 3);
// A bare arrow is the strip's own scroll/focus movement, not a reorder.
fireEvent.keyDown(tile, { key: "ArrowLeft" });
expect(onReorder).toHaveBeenCalledTimes(2);
});
it("shows the id without the 'Lane' prefix", () => {
render(
<LaneStripCard
lane={lane({ id: 7, title: "checkout flow" })}
index={0}
selected={false}
onSelect={() => {}}
onReorder={() => {}}
/>
);
const tile = screen.getByTestId("lane-tile-7");
expect(tile).toHaveTextContent("#7");
expect(tile.textContent).not.toMatch(/Lane/i);
});
});
@@ -0,0 +1,90 @@
/**
* @file WorkspaceSessionsTab.test.tsx
* @description Tests for the Workspace right panel's Sessions tab: it lists
* the workspace's own sessions (cwd + tool), filters them client-side, hands a
* claude session to Resume, and offers no Resume on another tool's workspace.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { MemoryRouter } from "react-router-dom";
import type { Lane, Session } from "../../../lib/types";
vi.mock("../../../lib/api", () => ({
api: {
sessions: { list: vi.fn() },
aiTools: { list: vi.fn().mockResolvedValue({ tools: [], default: "claude" }) },
},
}));
import { api } from "../../../lib/api";
import { WorkspaceSessionsTab } from "../WorkspaceSessionsTab";
const session = (id: string, name: string): Session =>
({
id,
name,
status: "completed",
cwd: "/w",
model: "claude-opus-5-5",
started_at: "2026-10-01T00:00:00Z",
ended_at: null,
metadata: null,
cost: 1.5,
tool: "claude",
}) as Session;
const lane = (tool: string) => ({ id: 1, cwd: "/w", tool }) as unknown as Lane;
beforeEach(() => {
vi.mocked(api.sessions.list).mockResolvedValue({
sessions: [session("s-1", "Fix login"), session("s-2", "Add tests")],
} as never);
});
function renderTab(tool: string, onResume = vi.fn()) {
render(
<MemoryRouter>
<WorkspaceSessionsTab lane={lane(tool)} onResume={onResume} />
</MemoryRouter>
);
return onResume;
}
describe("WorkspaceSessionsTab", () => {
it("lists the workspace's sessions by cwd and tool, and filters them", async () => {
const user = userEvent.setup();
renderTab("claude");
expect(await screen.findByText("Fix login")).toBeInTheDocument();
expect(api.sessions.list).toHaveBeenCalledWith({ cwd: "/w", tool: "claude", limit: 50 });
await user.type(screen.getByRole("searchbox"), "tests");
expect(screen.queryByText("Fix login")).toBeNull();
expect(screen.getByText("Add tests")).toBeInTheDocument();
expect(screen.getAllByRole("link", { name: "Open" })[0]).toHaveAttribute(
"href",
"/sessions/s-2"
);
});
it("hands the session to Resume", async () => {
const user = userEvent.setup();
const onResume = renderTab("claude");
await screen.findByText("Fix login");
await user.click(screen.getAllByRole("button", { name: "Resume" })[0]!);
expect(onResume).toHaveBeenCalledWith(expect.objectContaining({ id: "s-1" }));
});
it("offers no Resume on a non-claude workspace", async () => {
renderTab("codex");
await screen.findByText("Fix login");
expect(screen.queryByRole("button", { name: "Resume" })).toBeNull();
});
it("says so when the workspace has no session yet", async () => {
vi.mocked(api.sessions.list).mockResolvedValue({ sessions: [] } as never);
renderTab("claude");
expect(await screen.findByText("No sessions in this workspace yet")).toBeInTheDocument();
});
});