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
+21 -5
View File
@@ -1,7 +1,9 @@
/**
* @file Layout.tsx
* @description Application shell that frames every authenticated route: persistent
* sidebar, main content column, update notifier, and the Sagi assistant overlay.
* sidebar, main content column, the bottom status bar, update notifier, and the
* Sagi assistant overlay. The connection details modal's open state lives here
* because both the sidebar's rail button and the status bar open it.
* The layout is the single parent route in {@link App} — child pages render inside
* React Router's `<Outlet />` so navigation never remounts chrome.
*
@@ -68,6 +70,7 @@ import { useTranslation } from "react-i18next";
import { Sidebar, SIDEBAR_STORAGE_KEY, loadCollapsed } from "./Sidebar";
import { UpdateNotifier } from "./UpdateNotifier";
import { Sagi } from "./Sagi/Sagi";
import { StatusBar } from "./StatusBar";
/** Props for {@link Layout}. */
interface LayoutProps {
@@ -82,6 +85,8 @@ interface LayoutProps {
export function Layout({ wsConnected }: LayoutProps) {
const { t } = useTranslation("nav");
const [collapsed, setCollapsed] = useState(loadCollapsed);
const [connectionOpen, setConnectionOpen] = useState(false);
const sidebarWidth = collapsed ? "4.25rem" : "15rem";
const toggle = useCallback(() => {
setCollapsed((prev) => {
@@ -100,14 +105,20 @@ export function Layout({ wsConnected }: LayoutProps) {
</a>
<UpdateNotifier />
<Sagi />
<Sidebar wsConnected={wsConnected} collapsed={collapsed} onToggle={toggle} />
<Sidebar
wsConnected={wsConnected}
collapsed={collapsed}
onToggle={toggle}
connectionOpen={connectionOpen}
onConnectionOpenChange={setConnectionOpen}
/>
<main
id="main-content"
tabIndex={-1}
className="min-h-screen min-w-0 transition-[margin-left,width] duration-200 outline-none"
className="min-h-screen min-w-0 pb-6 transition-[margin-left,width] duration-200 outline-none"
style={{
marginLeft: collapsed ? "4.25rem" : "15rem",
width: collapsed ? "calc(100% - 4.25rem)" : "calc(100% - 15rem)",
marginLeft: sidebarWidth,
width: `calc(100% - ${sidebarWidth})`,
}}
>
{/* overflow-x-clip (not -hidden) clips horizontal overflow without
@@ -117,6 +128,11 @@ export function Layout({ wsConnected }: LayoutProps) {
<Outlet />
</div>
</main>
<StatusBar
wsConnected={wsConnected}
left={sidebarWidth}
onConnectionClick={() => setConnectionOpen(true)}
/>
</div>
);
}
+340
View File
@@ -0,0 +1,340 @@
/**
* @file ProjectTree — the sidebar's Projects section: every workspace (lane)
* grouped under the git repo it belongs to (`GET /api/projects`), lanes outside
* any repo under "Folders", plus each repo's worktrees no lane covers, dimmed,
* with an **Adopt** button (`POST /api/lanes/ensure {exact: true}`). Rendered on
* every page; picking a row opens `/workspace?lane=<id>`, the one place a
* workspace is selected. Live status comes from the shared lane list
* (`useLanes`); the project grouping is re-read only when the SET of lanes
* changes, or on the header's Refresh (`POST /api/projects/refresh`), never on
* every status frame. Owns its own Add-workspace and destructive modals so the
* row menu works from any page. Collapse/visibility state lives in
* `projectTreePrefs.ts`; row order in `laneOrderStorage.ts`.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useCallback, useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { useLocation, useNavigate } from "react-router-dom";
import { ChevronDown, ChevronRight, Folder, FolderGit2, Plus, RefreshCw } from "lucide-react";
import { api, type CwdSuggestion } from "../lib/api";
import type { Lane, Project } from "../lib/types";
import { useLanes } from "../hooks/useLanes";
import { useAiTools } from "../hooks/useAiTools";
import {
moveLane,
readLaneOrder,
sortByStoredOrder,
writeLaneOrder,
} from "../lib/laneOrderStorage";
import { useProjectTreePrefs, writeProjectTreePrefs } from "../lib/projectTreePrefs";
import { WorkspaceRow } from "./lanes/WorkspaceRow";
import { WorkspaceRowMenu, type WorkspaceMenuAction } from "./lanes/WorkspaceRowMenu";
import { AddLaneModal } from "./lanes/AddLaneModal";
import { DestructiveLaneModal } from "./lanes/DestructiveLaneModal";
/** The workspace the Workspace page shows, read from its URL. */
function selectedFrom(pathname: string, search: string): number | null {
if (!pathname.startsWith("/workspace")) return null;
const v = new URLSearchParams(search).get("lane");
return v === null ? null : Number(v);
}
export function ProjectTree() {
const { t } = useTranslation("lanes");
const navigate = useNavigate();
const location = useLocation();
const selected = selectedFrom(location.pathname, location.search);
const { lanes, refresh: refreshLanes } = useLanes();
const aiTools = useAiTools();
const prefs = useProjectTreePrefs();
const [projects, setProjects] = useState<Project[] | null>(null);
const [refreshing, setRefreshing] = useState(false);
const [error, setError] = useState<string | null>(null);
const [menu, setMenu] = useState<{ lane: Lane; x: number; y: number } | null>(null);
const [renamingId, setRenamingId] = useState<number | null>(null);
const [destructive, setDestructive] = useState<{ lane: Lane; action: "reset" | "remove" } | null>(
null
);
const [addOpen, setAddOpen] = useState(false);
const [addPrefill, setAddPrefill] = useState<{ sourceRepo?: string } | null>(null);
const [cwdSuggestions, setCwdSuggestions] = useState<CwdSuggestion[]>([]);
const [orderTick, setOrderTick] = useState(0);
// Untracked rows the user asked to see for this visit despite the setting.
const [revealed, setRevealed] = useState<Set<string>>(new Set());
const laneKey = lanes.map((l) => l.id).join(",");
useEffect(() => {
let live = true;
api.projects
.list()
.then((r) => live && setProjects(r.projects))
.catch(() => undefined);
return () => {
live = false;
};
}, [laneKey]);
const refresh = async () => {
setRefreshing(true);
try {
setProjects((await api.projects.refresh()).projects);
await refreshLanes();
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
} finally {
setRefreshing(false);
}
};
const openAdd = (sourceRepo?: string) => {
setAddPrefill(sourceRepo ? { sourceRepo } : null);
setAddOpen(true);
api.run
.cwds()
.then((r) => setCwdSuggestions(r.items))
.catch(() => undefined);
};
const select = useCallback((id: number) => navigate(`/workspace?lane=${id}`), [navigate]);
const byId = useMemo(() => new Map(lanes.map((l) => [l.id, l])), [lanes]);
const order = useMemo(() => readLaneOrder(), [orderTick]); // eslint-disable-line react-hooks/exhaustive-deps
const reorder = (ids: number[], from: number, to: number) => {
const moved = moveLane(ids, from, to);
if (moved === ids) return;
const rest = sortByStoredOrder(
lanes.filter((l) => !moved.includes(l.id)),
order
).map((l) => l.id);
writeLaneOrder([...moved, ...rest]);
setOrderTick((n) => n + 1);
};
const rename = async (lane: Lane, title: string | null) => {
setRenamingId(null);
if (title === null || title === (lane.title || "")) return;
try {
await api.lanes.update(lane.id, { title });
await refreshLanes();
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
}
};
const adopt = async (path: string) => {
setError(null);
try {
const { lane } = await api.lanes.ensure({
cwd: path,
tool: aiTools.defaultId ?? "claude",
exact: true,
});
await refreshLanes();
select(lane.id);
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
}
};
const onMenuAction = (lane: Lane, action: WorkspaceMenuAction) => {
if (action === "rename") setRenamingId(lane.id);
else if (action === "new-here") openAdd(lane.source_repo || lane.cwd);
else if (action === "reset" || action === "remove") setDestructive({ lane, action });
else if (action === "started") select(lane.id);
};
const confirmDestructive = async (body?: { expect?: unknown; force?: boolean }) => {
if (!destructive) return;
const { lane, action } = destructive;
setDestructive(null);
try {
await api.lanes.action(lane.id, action, {
confirm: true,
expect: body?.expect,
...(body?.force === true ? { force: true } : {}),
});
await refreshLanes();
if (action === "remove" && selected === lane.id) navigate("/workspace");
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
}
};
return (
<section data-testid="project-tree" className="mt-3 border-t border-border pt-3">
<div className="mb-1 flex items-center gap-1 px-2">
<span className="flex-1 text-[11px] font-semibold uppercase tracking-wide text-fg-muted">
{t("projects.title")}
</span>
<button
type="button"
onClick={() => void refresh()}
aria-label={t("projects.refresh")}
title={t("projects.refresh")}
className="rounded p-1 text-fg-muted hover:bg-surface-3 hover:text-fg-primary"
>
<RefreshCw className={`h-3.5 w-3.5 ${refreshing ? "animate-spin" : ""}`} />
</button>
<button
type="button"
data-testid="project-tree-add"
onClick={() => openAdd()}
aria-label={t("addLane")}
title={t("addLane")}
className="rounded p-1 text-fg-muted hover:bg-surface-3 hover:text-fg-primary"
>
<Plus className="h-3.5 w-3.5" />
</button>
</div>
{(projects ?? []).map((p) => {
const key = p.root ?? "";
const own = sortByStoredOrder(
p.workspaces.map((id) => byId.get(id)).filter((l): l is Lane => !!l),
order
);
const untracked = p.worktrees.filter((w) => w.workspace_id === null);
const visible = untracked.filter(
(w) =>
revealed.has(key) ||
(prefs.showUntracked && !(w.source && prefs.hideSources.includes(w.source)))
);
const hidden = untracked.length - visible.length;
const collapsed = !!prefs.collapsed[key];
const name = p.root === null ? t("projects.folders") : p.name;
const ids = own.map((l) => l.id);
return (
<div key={key} data-testid={`project-${p.root === null ? "folders" : p.name}`}>
<div className="group flex items-center gap-1 rounded-md px-1 py-1 hover:bg-surface-3">
<button
type="button"
aria-expanded={!collapsed}
onClick={() =>
writeProjectTreePrefs({ collapsed: { ...prefs.collapsed, [key]: !collapsed } })
}
title={p.root ?? undefined}
className="flex min-w-0 flex-1 items-center gap-1 text-left text-[13px] font-medium text-fg-secondary"
>
{collapsed ? (
<ChevronRight className="h-3.5 w-3.5 shrink-0" />
) : (
<ChevronDown className="h-3.5 w-3.5 shrink-0" />
)}
{p.root === null ? (
<Folder className="h-3.5 w-3.5 shrink-0 text-fg-muted" />
) : (
<FolderGit2 className="h-3.5 w-3.5 shrink-0 text-fg-muted" />
)}
<span className="truncate">{name}</span>
</button>
{hidden > 0 && (
<button
type="button"
onClick={() => setRevealed((s) => new Set(s).add(key))}
className="shrink-0 text-[10px] text-fg-muted hover:text-fg-primary"
>
{t("projects.hidden", { count: hidden })}
</button>
)}
{p.root !== null && (
<button
type="button"
onClick={() => openAdd(p.root ?? undefined)}
aria-label={t("projects.newWorkspace", { name })}
title={t("projects.newWorkspace", { name })}
className="shrink-0 rounded p-0.5 text-fg-muted opacity-0 hover:bg-surface-4 hover:text-fg-primary focus:opacity-100 group-hover:opacity-100"
>
<Plus className="h-3.5 w-3.5" />
</button>
)}
</div>
{!collapsed && (
<div className="ml-3 space-y-0.5 border-l border-border pl-1">
{own.map((lane, i) => (
<WorkspaceRow
key={lane.id}
lane={lane}
projectRoot={p.root}
branch={
lane.branch ||
p.worktrees.find((w) => w.workspace_id === lane.id)?.branch ||
null
}
index={i}
selected={selected === lane.id}
renaming={renamingId === lane.id}
onSelect={() => select(lane.id)}
onReorder={(from, to) => reorder(ids, from, to)}
onMenu={(x, y) => setMenu({ lane, x, y })}
onRenameStart={() => setRenamingId(lane.id)}
onRenameEnd={(title) => void rename(lane, title)}
/>
))}
{visible.map((w) => (
<div
key={w.path}
data-testid="untracked-worktree"
title={w.path}
className="group flex items-center gap-2 rounded-md py-1 pl-2 pr-1 text-fg-muted"
>
<span className="h-2 w-2 shrink-0 rounded-full border border-fg-muted" />
<span className="min-w-0 flex-1 truncate text-[12px]">
{w.branch || w.path.split("/").pop()}
</span>
<button
type="button"
onClick={() => void adopt(w.path)}
className="shrink-0 rounded border border-border px-1.5 text-[10px] hover:bg-surface-4 hover:text-fg-primary"
>
{t("row.adopt")}
</button>
</div>
))}
</div>
)}
</div>
);
})}
{error && (
<p role="alert" className="mx-2 mt-2 text-[11px] text-status-danger">
{error}
</p>
)}
{menu && (
<WorkspaceRowMenu
lane={menu.lane}
x={menu.x}
y={menu.y}
onClose={() => setMenu(null)}
onAction={(a) => onMenuAction(menu.lane, a)}
/>
)}
{destructive && (
<DestructiveLaneModal
lane={destructive.lane}
action={destructive.action}
open
onClose={() => setDestructive(null)}
onConfirm={(body) => void confirmDestructive(body)}
/>
)}
<AddLaneModal
open={addOpen}
cwdSuggestions={cwdSuggestions}
prefill={addPrefill}
onClose={() => {
setAddOpen(false);
setAddPrefill(null);
}}
onAdded={(lane) => {
void refreshLanes();
select(lane.id);
}}
/>
</section>
);
}
+62 -35
View File
@@ -73,11 +73,13 @@ import {
Sun,
Monitor,
Moon,
FolderTree,
} from "lucide-react";
import type { LucideIcon } from "lucide-react";
import { api } from "../lib/api";
import { eventBus } from "../lib/eventBus";
import { useTheme } from "../hooks/useTheme";
import { ProjectTree } from "./ProjectTree";
import type { UpdateStatusPayload, WSMessage } from "../lib/types";
function isUpdatePayload(x: unknown): x is UpdateStatusPayload {
@@ -174,9 +176,18 @@ interface SidebarProps {
wsConnected: boolean;
collapsed: boolean;
onToggle: () => void;
/** The connection details modal, opened from here or from the status bar. */
connectionOpen: boolean;
onConnectionOpenChange: (open: boolean) => void;
}
export function Sidebar({ wsConnected, collapsed, onToggle }: SidebarProps) {
export function Sidebar({
wsConnected,
collapsed,
onToggle,
connectionOpen,
onConnectionOpenChange,
}: SidebarProps) {
const { t, i18n } = useTranslation();
const { theme, mode, setMode, toggleTheme } = useTheme();
// Track whether nav items are clipped by overflow so we can render
@@ -187,7 +198,6 @@ export function Sidebar({ wsConnected, collapsed, onToggle }: SidebarProps) {
const [updateStatus, setUpdateStatus] = useState<UpdateStatusPayload | null>(null);
const [checking, setChecking] = useState(false);
const [checkError, setCheckError] = useState(false);
const [statusModalOpen, setStatusModalOpen] = useState(false);
const [connectedSince, setConnectedSince] = useState<number | null>(
wsConnected ? Date.now() : null
);
@@ -464,6 +474,21 @@ export function Sidebar({ wsConnected, collapsed, onToggle }: SidebarProps) {
</NavLink>
);
})}
{/* The project → workspace tree scrolls with the nav. In the rail it
is one icon that opens the sidebar instead. */}
{collapsed ? (
<button
type="button"
onClick={onToggle}
title={t("lanes:projects.title")}
aria-label={t("lanes:projects.title")}
className="flex w-full items-center justify-center rounded-lg border border-transparent px-2 py-2.5 text-fg-secondary transition-colors hover:bg-surface-3 hover:text-fg-primary"
>
<FolderTree className="w-4 h-4" />
</button>
) : (
<ProjectTree />
)}
</nav>
{!collapsed && navOverflow.up && (
<button
@@ -620,41 +645,43 @@ export function Sidebar({ wsConnected, collapsed, onToggle }: SidebarProps) {
<div
className={`px-3 pt-3 pb-4 border-t border-border space-y-2.5 flex-shrink-0 ${collapsed ? "px-2" : ""}`}
>
<button
type="button"
onClick={() => setStatusModalOpen(true)}
aria-label={t("nav:connectionDetails")}
title={t("nav:connectionDetails")}
className={`rounded-lg border border-border bg-surface-2 hover:bg-surface-3 transition-colors text-left cursor-pointer ${
collapsed
? "w-8 h-8 mx-auto flex items-center justify-center p-0"
: "block w-full px-2.5 py-2"
}`}
>
<div
className={`flex items-center text-xs ${collapsed ? "justify-center" : "justify-between gap-2"}`}
{collapsed && (
<button
type="button"
onClick={() => onConnectionOpenChange(true)}
aria-label={t("nav:connectionDetails")}
title={t("nav:connectionDetails")}
className={`rounded-lg border border-border bg-surface-2 hover:bg-surface-3 transition-colors text-left cursor-pointer ${
collapsed
? "w-8 h-8 mx-auto flex items-center justify-center p-0"
: "block w-full px-2.5 py-2"
}`}
>
<span
className={`inline-flex items-center gap-2 ${
wsConnected ? "text-status-success" : "text-fg-muted"
}`}
<div
className={`flex items-center text-xs ${collapsed ? "justify-center" : "justify-between gap-2"}`}
>
{wsConnected ? (
<Wifi className="w-3.5 h-3.5 flex-shrink-0" />
) : (
<WifiOff className="w-3.5 h-3.5 flex-shrink-0" />
)}
<span
className={`inline-flex items-center gap-2 ${
wsConnected ? "text-status-success" : "text-fg-muted"
}`}
>
{wsConnected ? (
<Wifi className="w-3.5 h-3.5 flex-shrink-0" />
) : (
<WifiOff className="w-3.5 h-3.5 flex-shrink-0" />
)}
{!collapsed && (
<span className="font-medium">
{wsConnected ? t("nav:live") : t("nav:disconnected")}
</span>
)}
</span>
{!collapsed && (
<span className="font-medium">
{wsConnected ? t("nav:live") : t("nav:disconnected")}
</span>
<span className="text-[11px] font-medium text-fg-muted">{`v${__APP_VERSION__}`}</span>
)}
</span>
{!collapsed && (
<span className="text-[11px] font-medium text-fg-muted">{`v${__APP_VERSION__}`}</span>
)}
</div>
</button>
</div>
</button>
)}
{collapsed ? (
<button
type="button"
@@ -704,8 +731,8 @@ export function Sidebar({ wsConnected, collapsed, onToggle }: SidebarProps) {
</div>
<ConnectionStatusModal
open={statusModalOpen}
onClose={() => setStatusModalOpen(false)}
open={connectionOpen}
onClose={() => onConnectionOpenChange(false)}
wsConnected={wsConnected}
connectedSince={connectedSince}
eventCountRef={eventCountRef}
+91
View File
@@ -0,0 +1,91 @@
/**
* @file StatusBar — the one-line bar along the bottom of the app shell, on
* every page: the live-connection state (click: the connection details modal,
* which the sidebar owns), the version, how many workspaces are running and how
* many need the user (from the shared lane list, no request of its own), and
* today's spend — the `daily_costs` bucket for the local date from
* `GET /api/pricing/cost`, the Dashboard's own cost call, re-read every 30 s
* while the tab is visible. No provider quota: reading Claude's quota rotates
* the stored OAuth token (`cc-usage.js`), and the CLI shows it in the pane.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { Wifi, WifiOff } from "lucide-react";
import { api } from "../lib/api";
import { useLanes } from "../hooks/useLanes";
const COST_POLL_MS = 30_000;
function localDate(d = new Date()): string {
const pad = (n: number) => String(n).padStart(2, "0");
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
}
export function StatusBar({
wsConnected,
left,
onConnectionClick,
}: {
wsConnected: boolean;
/** Sidebar width, so the bar starts where the main column does. */
left: string;
onConnectionClick: () => void;
}) {
const { t } = useTranslation("nav");
const { lanes } = useLanes();
const running = lanes.filter((l) => l.status === "running").length;
const needYou = lanes.filter((l) => l.needs_action).length;
const [today, setToday] = useState<number | null>(null);
useEffect(() => {
const tick = () => {
if (document.visibilityState !== "visible") return;
api.pricing
.totalCost()
.then((r) => setToday(r.daily_costs.find((d) => d.date === localDate())?.cost ?? 0))
.catch(() => undefined);
};
tick();
const id = setInterval(tick, COST_POLL_MS);
document.addEventListener("visibilitychange", tick);
return () => {
clearInterval(id);
document.removeEventListener("visibilitychange", tick);
};
}, []);
return (
<footer
data-testid="status-bar"
style={{ left }}
className="fixed bottom-0 right-0 z-20 flex h-6 items-center gap-4 border-t border-border bg-surface-1 px-3 text-xs text-fg-muted transition-[left] duration-200"
>
<button
type="button"
onClick={onConnectionClick}
title={t("connectionDetails")}
className={`flex items-center gap-1.5 hover:text-fg-primary ${
wsConnected ? "text-status-success" : ""
}`}
>
{wsConnected ? <Wifi className="h-3 w-3" /> : <WifiOff className="h-3 w-3" />}
{wsConnected ? t("live") : t("disconnected")}
<span className="text-fg-muted">{`v${__APP_VERSION__}`}</span>
</button>
<span data-testid="status-bar-counts">
{t("statusBar.running", { count: running })}
{" · "}
<span className={needYou ? "text-status-warning" : ""}>
{t("statusBar.needYou", { count: needYou })}
</span>
</span>
{today !== null && (
<span data-testid="status-bar-cost" className="tabular-nums">
{t("statusBar.today", { cost: `$${today.toFixed(2)}` })}
</span>
)}
</footer>
);
}
@@ -0,0 +1,240 @@
/**
* @file ProjectTree.test.tsx
* @description Tests for the sidebar's project → workspace tree: grouping by
* repo with Folders last, the primary badge, untracked worktrees with Adopt and
* the hidden count, collapse persistence, Alt+arrow reordering kept in
* localStorage, double-click rename, the row menu's items by kind and tool, and
* the status dot (`workspaceDot`).
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, waitFor, within, fireEvent } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { MemoryRouter, Route, Routes, useLocation } from "react-router-dom";
import type { Lane, Project } from "../../lib/types";
function lane(id: number, cwd: string, extra: Partial<Lane> = {}): Lane {
return {
id,
title: "",
cwd,
branch: null,
kind: "adopted",
tool: "claude",
router_profile: null,
source_repo: null,
pipeline: "default",
session_id: null,
run_id: null,
stage: "idle",
stage_since: null,
status: "idle",
needs_action: null,
liveness: "idle",
pipeline_nodes: [],
...extra,
} as unknown as Lane;
}
let lanes: Lane[] = [];
let projects: Project[] = [];
vi.mock("../../lib/api", async (importOriginal) => {
const actual = await importOriginal<Record<string, unknown>>();
return {
...actual,
api: {
lanes: {
list: vi.fn(async () => ({ lanes, counts: {} })),
update: vi.fn(async () => ({ lane: lanes[0] })),
ensure: vi.fn(async () => ({ lane: lane(9, "/r/wt"), created: true })),
action: vi.fn(async () => ({ ok: true })),
routerList: vi.fn(async () => ({ routers: [] })),
},
projects: {
list: vi.fn(async () => ({ projects })),
refresh: vi.fn(async () => ({ projects })),
},
run: { cwds: vi.fn(async () => ({ items: [] })) },
aiTools: {
list: vi.fn(async () => ({ tools: [], default: "claude" })),
iconUrl: () => "",
},
aiRouters: { list: vi.fn(async () => ({ routers: [] })) },
},
};
});
import { api } from "../../lib/api";
import { ProjectTree } from "../ProjectTree";
import { workspaceDot } from "../lanes/WorkspaceRow";
function Where() {
const loc = useLocation();
return <span data-testid="where">{loc.pathname + loc.search}</span>;
}
function renderTree(at = "/") {
return render(
<MemoryRouter initialEntries={[at]}>
<Routes>
<Route
path="*"
element={
<>
<ProjectTree />
<Where />
</>
}
/>
</Routes>
</MemoryRouter>
);
}
beforeEach(() => {
localStorage.clear();
vi.clearAllMocks();
lanes = [
lane(1, "/r", { branch: "main" }),
lane(2, "/r/sub", { title: "Sub" }),
lane(3, "/tmp/plain", { kind: "managed", tool: "codex" }),
];
projects = [
{
root: "/r",
name: "r",
workspaces: [1, 2],
worktrees: [
{ path: "/r", branch: "main", main: true, workspace_id: 1 },
{
path: "/r/.claude/worktrees/x",
branch: "wf-x",
main: false,
workspace_id: null,
source: "claude",
},
{ path: "/elsewhere", branch: "feat", main: false, workspace_id: null },
],
},
{ root: null, name: "Folders", workspaces: [3], worktrees: [] },
];
});
describe("ProjectTree", () => {
it("groups workspaces under their repo, Folders last, with the primary badge", async () => {
renderTree();
const repo = await screen.findByTestId("project-r");
expect(within(repo).getByTestId("workspace-row-1")).toBeInTheDocument();
expect(within(repo).getByTestId("workspace-row-2")).toBeInTheDocument();
expect(within(repo).getByText("primary")).toBeInTheDocument();
const folders = screen.getByTestId("project-folders");
expect(within(folders).getByTestId("workspace-row-3")).toBeInTheDocument();
expect(repo.compareDocumentPosition(folders) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
});
it("lists untracked worktrees with Adopt, and adopting selects the new workspace", async () => {
const user = userEvent.setup();
renderTree();
await screen.findByTestId("project-r");
expect(screen.getAllByTestId("untracked-worktree")).toHaveLength(2);
await user.click(screen.getAllByRole("button", { name: "Adopt" })[1]!);
expect(api.lanes.ensure).toHaveBeenCalledWith({
cwd: "/elsewhere",
tool: "claude",
exact: true,
});
await waitFor(() => expect(screen.getByTestId("where").textContent).toBe("/workspace?lane=9"));
});
it("hides a hidden source and says how many, and reveals them on click", async () => {
const user = userEvent.setup();
localStorage.setItem(
"agenthub.sidebar.projects",
JSON.stringify({ hideSources: ["claude"], showUntracked: true, collapsed: {} })
);
renderTree();
await screen.findByTestId("project-r");
expect(screen.getAllByTestId("untracked-worktree")).toHaveLength(1);
await user.click(screen.getByText("1 hidden"));
expect(screen.getAllByTestId("untracked-worktree")).toHaveLength(2);
});
it("remembers a collapsed project", async () => {
const user = userEvent.setup();
renderTree();
const repo = await screen.findByTestId("project-r");
await user.click(within(repo).getByRole("button", { name: "r" }));
expect(screen.queryByTestId("workspace-row-1")).toBeNull();
expect(JSON.parse(localStorage.getItem("agenthub.sidebar.projects")!).collapsed).toEqual({
"/r": true,
});
});
it("selects a workspace by URL and marks the current one", async () => {
const user = userEvent.setup();
renderTree("/workspace?lane=2");
const row = await screen.findByTestId("workspace-row-2");
expect(within(row).getAllByRole("button")[0]).toHaveAttribute("aria-current", "true");
await user.click(within(screen.getByTestId("workspace-row-1")).getAllByRole("button")[0]!);
expect(screen.getByTestId("where").textContent).toBe("/workspace?lane=1");
});
it("reorders within a project with Alt+ArrowUp and keeps the order in localStorage", async () => {
renderTree();
const row2 = await screen.findByTestId("workspace-row-2");
fireEvent.keyDown(within(row2).getAllByRole("button")[0]!, { key: "ArrowUp", altKey: true });
const order = JSON.parse(localStorage.getItem("agenthub.workspace.laneOrder")!);
expect(order.slice(0, 2)).toEqual([2, 1]);
const ids = within(screen.getByTestId("project-r"))
.getAllByTestId(/^workspace-row-/)
.map((el) => el.dataset.testid);
expect(ids).toEqual(["workspace-row-2", "workspace-row-1"]);
});
it("renames on double-click + Enter, and Escape cancels without a call", async () => {
const user = userEvent.setup();
renderTree();
const row = await screen.findByTestId("workspace-row-2");
await user.dblClick(within(row).getAllByRole("button")[0]!);
const input = screen.getByTestId("workspace-rename-2");
await user.clear(input);
await user.type(input, " Renamed {Enter}");
expect(api.lanes.update).toHaveBeenCalledWith(2, { title: "Renamed" });
vi.mocked(api.lanes.update).mockClear();
await user.dblClick(within(screen.getByTestId("workspace-row-2")).getAllByRole("button")[0]!);
await user.type(screen.getByTestId("workspace-rename-2"), "x{Escape}");
expect(api.lanes.update).not.toHaveBeenCalled();
});
it("offers Reset only on a managed workspace and the readme run only on claude", async () => {
const user = userEvent.setup();
renderTree();
await user.click(await screen.findByTestId("workspace-menu-1"));
let menu = screen.getByTestId("workspace-row-menu-1");
expect(within(menu).queryByText("Reset worktree…")).toBeNull();
expect(within(menu).getByText("Create/update readme file")).toBeInTheDocument();
expect(within(menu).getByText("Delete workspace…")).toBeInTheDocument();
await user.keyboard("{Escape}");
await user.click(screen.getByTestId("workspace-menu-3"));
menu = screen.getByTestId("workspace-row-menu-3");
expect(within(menu).getByText("Reset worktree…")).toBeInTheDocument();
expect(within(menu).queryByText("Create/update readme file")).toBeNull();
});
});
describe("workspaceDot", () => {
it("maps needs-you, dead/error, running, active and idle", () => {
expect(workspaceDot(lane(1, "/a", { needs_action: "x", status: "running" }))).toBe(
"bg-status-warning"
);
expect(workspaceDot(lane(1, "/a", { liveness: "dead" }))).toBe("bg-status-danger");
expect(workspaceDot(lane(1, "/a", { status: "error" }))).toBe("bg-status-danger");
expect(workspaceDot(lane(1, "/a", { status: "running" }))).toContain("animate-pulse-slow");
expect(workspaceDot(lane(1, "/a", { liveness: "active" }))).toBe("bg-status-success");
expect(workspaceDot(lane(1, "/a"))).toBe("bg-surface-4");
});
});
@@ -14,7 +14,13 @@ import { eventBus } from "../../lib/eventBus";
function renderSidebar(wsConnected: boolean, collapsed = false) {
return render(
<MemoryRouter>
<Sidebar wsConnected={wsConnected} collapsed={collapsed} onToggle={() => {}} />
<Sidebar
wsConnected={wsConnected}
collapsed={collapsed}
onToggle={() => {}}
connectionOpen={false}
onConnectionOpenChange={() => {}}
/>
</MemoryRouter>
);
}
@@ -45,22 +51,24 @@ describe("Sidebar", () => {
expect(screen.queryByText("Activity Feed")).toBeNull();
});
it('should show "Live" when WebSocket is connected', () => {
// The live/version line moved to the status bar (StatusBar.test.tsx); the
// rail keeps an icon-only button for the connection details.
it("keeps the connection button in the rail only", () => {
renderSidebar(true);
expect(screen.getByText("Live")).toBeInTheDocument();
expect(screen.queryByText("Live")).toBeNull();
expect(screen.queryByRole("button", { name: "Connection details" })).toBeNull();
});
it('should show "Disconnected" when WebSocket is not connected', () => {
renderSidebar(false);
expect(screen.getByText("Disconnected")).toBeInTheDocument();
it("offers the connection details from the collapsed rail", () => {
renderSidebar(false, true);
expect(screen.getByRole("button", { name: "Connection details" })).toBeInTheDocument();
});
it("should show version number", () => {
// `__APP_VERSION__` is injected by Vite from the repo-root package.json
// (see vite.config.ts) and replaced at transform time in tests too, so this
// stays correct as the project version changes.
it("renders the project tree when expanded and its icon in the rail", () => {
renderSidebar(true);
expect(screen.getByText(`v${__APP_VERSION__}`)).toBeInTheDocument();
expect(screen.getByTestId("project-tree")).toBeInTheDocument();
renderSidebar(true, true);
expect(screen.getAllByRole("button", { name: "Projects" }).length).toBeGreaterThan(0);
});
it("should have correct navigation hrefs", () => {
@@ -13,7 +13,13 @@ import { Sidebar } from "../Sidebar";
function renderSidebar(collapsed = false) {
return render(
<MemoryRouter>
<Sidebar wsConnected collapsed={collapsed} onToggle={() => {}} />
<Sidebar
wsConnected
collapsed={collapsed}
onToggle={() => {}}
connectionOpen={false}
onConnectionOpenChange={() => {}}
/>
</MemoryRouter>
);
}
@@ -0,0 +1,64 @@
/**
* @file StatusBar.test.tsx
* @description Tests for the shell's bottom status bar: the live/disconnected
* state with the version (click opens the connection details), the running and
* needs-you counts from the shared lane list, and today's spend from the
* pricing cost call's `daily_costs`.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { describe, it, expect, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
function today() {
const d = new Date();
const pad = (n: number) => String(n).padStart(2, "0");
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
}
vi.mock("../../lib/api", () => ({
api: {
lanes: {
list: vi.fn().mockResolvedValue({
lanes: [
{ id: 1, status: "running", needs_action: null },
{ id: 2, status: "running", needs_action: "permission" },
{ id: 3, status: "idle", needs_action: null },
],
counts: {},
}),
},
pricing: {
totalCost: vi.fn().mockImplementation(async () => ({
total_cost: 10,
breakdown: [],
daily_costs: [
{ date: "2000-01-01", cost: 9 },
{ date: today(), cost: 1.234 },
],
})),
},
},
}));
import { StatusBar } from "../StatusBar";
describe("StatusBar", () => {
it("shows the connection with the version, the counts and today's spend", async () => {
const user = userEvent.setup();
const onConnectionClick = vi.fn();
render(<StatusBar wsConnected left="15rem" onConnectionClick={onConnectionClick} />);
expect(screen.getByText("Live")).toBeInTheDocument();
expect(screen.getByText(`v${__APP_VERSION__}`)).toBeInTheDocument();
expect(await screen.findByText("Today $1.23")).toBeInTheDocument();
expect(screen.getByTestId("status-bar-counts").textContent).toBe("2 running · 1 need you");
await user.click(screen.getByText("Live"));
expect(onConnectionClick).toHaveBeenCalled();
});
it("says Disconnected when the socket is down", () => {
render(<StatusBar wsConnected={false} left="0" onConnectionClick={() => {}} />);
expect(screen.getByText("Disconnected")).toBeInTheDocument();
});
});
@@ -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();
});
});
@@ -86,6 +86,10 @@ export interface LaneConsolePaneProps {
hiddenRunIds?: string[];
/** Reports the run this pane shows (null on the setup form). */
onHandleChange?: (runId: string | null) => void;
/** A session to resume in this pane, from the Workspace's Sessions tab. A new
* `nonce` is a new request; it goes through the same path as a resume from
* the run history, live-run fallback included. */
resumeRequest?: { sessionId: string; model: string | null; nonce: number } | null;
}
/** The tmux session a lane's split pane owns (see server/lib/pty-run.js). */
@@ -109,6 +113,7 @@ export function LaneConsolePane({
slot = 0,
hiddenRunIds,
onHandleChange,
resumeRequest,
}: LaneConsolePaneProps) {
const { t } = useTranslation("run");
const { t: tLanes } = useTranslation("lanes");
@@ -455,6 +460,23 @@ export function LaneConsolePane({
[busy, refreshList, t, lanes, slot, onLaneCreated, onLaneIdChange, attachToRun]
);
const resumeRef = useRef(onResumeFromHistory);
resumeRef.current = onResumeFromHistory;
useEffect(() => {
const lane = latest.current.lanes.find((l) => l.id === laneId);
if (!resumeRequest || !lane) return;
void resumeRef.current({
session_id: resumeRequest.sessionId,
cwd: lane.cwd,
tool: lane.tool,
model: resumeRequest.model,
permission_mode: null,
effort: null,
} as DashboardRunHistoryItem);
// Only a new request (nonce) resumes; a lane switch never replays one.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [resumeRequest?.nonce]);
const onViewFromHistory = useCallback(
(item: DashboardRunHistoryItem) => {
if (item.session_id) void onResumeFromHistory(item);
@@ -483,4 +483,47 @@ describe("LaneConsolePane", () => {
expect(screen.queryByText("shown")).toBeNull();
});
});
it("resumes a session handed over by the Sessions tab, once per request", async () => {
(api.lanes.action as ReturnType<typeof vi.fn>).mockResolvedValue({ run_id: "agenthub-lane-1" });
(api.run.get as ReturnType<typeof vi.fn>).mockResolvedValue({
id: "agenthub-lane-1",
laneId: 1,
status: "running",
cwd: "/workspace/a",
model: null,
permissionMode: null,
effort: null,
resumeSessionId: "s-1",
sessionId: null,
startedAt: null,
promptPreview: null,
});
const request = { sessionId: "s-1", model: "claude-opus-5-5", nonce: 1 };
const { rerender } = render(
<MemoryRouter>
<LaneConsolePane {...baseProps()} resumeRequest={request} />
</MemoryRouter>
);
await waitFor(() =>
expect(api.lanes.action).toHaveBeenCalledWith(
1,
"start",
expect.objectContaining({
slot: 0,
prompt: "",
resumeSessionId: "s-1",
model: "claude-opus-5-5",
})
)
);
// A re-render with the same request is not a second resume.
rerender(
<MemoryRouter>
<LaneConsolePane {...baseProps()} resumeRequest={{ ...request }} />
</MemoryRouter>
);
await screen.findByTestId("terminal-view");
expect(api.lanes.action).toHaveBeenCalledTimes(1);
});
});