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:
+4
-1
@@ -184,6 +184,8 @@ client/
|
||||
│ │ ├── EventDetail.tsx # Inline hook payload viewer (used by ActivityFeed + SessionDetail)
|
||||
│ │ ├── EmptyState.tsx
|
||||
│ │ ├── Sidebar.tsx
|
||||
│ │ ├── ProjectTree.tsx # Sidebar Projects section: workspaces grouped by repo (`GET /api/projects`), untracked worktrees + Adopt, row menu
|
||||
│ │ ├── StatusBar.tsx # Fixed bottom bar: Live/Disconnected, version, running / needs-you counts, today's cost
|
||||
│ │ ├── Layout.tsx
|
||||
│ │ ├── RemoteSources.tsx # Remote Data Sources settings panel (SSH multi-machine collection)
|
||||
│ │ ├── SessionToolBadge.tsx # The CLI that produced a non-Claude session, by its AI Tools label (nothing for Claude)
|
||||
@@ -217,6 +219,7 @@ client/
|
||||
│ │
|
||||
│ ├── hooks/
|
||||
│ │ ├── useWebSocket.ts # Auto-reconnecting WebSocket hook
|
||||
│ │ ├── useLanes.ts # ONE shared lane list (GET /api/lanes + `lane_update` frames, refetch on reconnect) for the sidebar tree, status bar and Workspace page
|
||||
│ │ ├── useNotifications.ts # Browser push notification triggers
|
||||
│ │ └── useTheme.ts # Dark/light/system mode + per-mode named theme: `dark` class, `data-mode`, `data-theme` on <html>, persisted to localStorage
|
||||
│ │
|
||||
@@ -469,7 +472,7 @@ graph TB
|
||||
CcConfig["/cc-config (redirect)"]
|
||||
Lanes["/lanes (redirect)"]
|
||||
Run["/run (redirect)"]
|
||||
Workspace["/workspace"]
|
||||
Workspace["/workspace?lane=<id>"]
|
||||
Settings["/settings (tabs: dashboard | claude-code | ...)"]
|
||||
NF["/* (NotFound)"]
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
/**
|
||||
* @file useLanes — the lane list (GET /api/lanes) shared by every component
|
||||
* that shows workspaces: the sidebar's project tree, the Workspace page and the
|
||||
* status bar's counts. One module-level copy, so N components mean one request;
|
||||
* `lane_update` frames are merged in place (a `removed` or unknown lane
|
||||
* refetches), and a WebSocket reconnect refetches the whole list because the
|
||||
* frames missed while disconnected are never replayed. The copy is dropped
|
||||
* when the last component unmounts, so a fresh mount always reads fresh rows.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useEffect, useSyncExternalStore } from "react";
|
||||
import { api } from "../lib/api";
|
||||
import { eventBus } from "../lib/eventBus";
|
||||
import { keepIfSame } from "../lib/keepIfSame";
|
||||
import type { Lane, WSMessage } from "../lib/types";
|
||||
|
||||
let lanes: Lane[] | null = null;
|
||||
// Bumped when the store is torn down, so a reply to a request made before the
|
||||
// teardown cannot repopulate it.
|
||||
let generation = 0;
|
||||
let inflight: Promise<void> | null = null;
|
||||
const listeners = new Set<() => void>();
|
||||
let detach: (() => void) | null = null;
|
||||
const EMPTY: Lane[] = [];
|
||||
|
||||
function publish(next: Lane[]) {
|
||||
const kept = keepIfSame(next)(lanes);
|
||||
if (kept === lanes) return;
|
||||
lanes = kept;
|
||||
listeners.forEach((l) => l());
|
||||
}
|
||||
|
||||
function load(): Promise<void> {
|
||||
if (!inflight) {
|
||||
const gen = generation;
|
||||
inflight = api.lanes
|
||||
.list()
|
||||
.then((r) => {
|
||||
if (gen === generation) publish(r.lanes);
|
||||
})
|
||||
// Unreachable server: keep what we had.
|
||||
.catch(() => undefined)
|
||||
.finally(() => {
|
||||
if (gen === generation) inflight = null;
|
||||
});
|
||||
}
|
||||
return inflight;
|
||||
}
|
||||
|
||||
function onMessage(msg: WSMessage) {
|
||||
if (msg.type !== "lane_update") return;
|
||||
const payload = msg.data as { lane?: Lane; removed?: number };
|
||||
const lane = payload.lane;
|
||||
if (payload.removed !== undefined || !lane || !lanes) return void load();
|
||||
const i = lanes.findIndex((l) => l.id === lane.id);
|
||||
if (i === -1) return void load();
|
||||
const next = [...lanes];
|
||||
next[i] = lane;
|
||||
publish(next);
|
||||
}
|
||||
|
||||
function subscribe(l: () => void) {
|
||||
listeners.add(l);
|
||||
if (listeners.size === 1) {
|
||||
const offMsg = eventBus.subscribe(onMessage);
|
||||
const offConn = eventBus.onConnection((up) => {
|
||||
if (up) void load();
|
||||
});
|
||||
detach = () => {
|
||||
offMsg();
|
||||
offConn();
|
||||
};
|
||||
}
|
||||
return () => {
|
||||
listeners.delete(l);
|
||||
if (listeners.size === 0) {
|
||||
detach?.();
|
||||
detach = null;
|
||||
lanes = null;
|
||||
inflight = null;
|
||||
generation++;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/** Every lane, in id order; `refresh` re-reads them (after a create/remove). */
|
||||
export function useLanes() {
|
||||
const v = useSyncExternalStore(subscribe, () => lanes);
|
||||
useEffect(() => {
|
||||
if (!lanes) void load();
|
||||
}, []);
|
||||
return { lanes: v ?? EMPTY, loaded: v !== null, refresh: load };
|
||||
}
|
||||
@@ -311,5 +311,32 @@
|
||||
"conflict.reset": "Cancel",
|
||||
"conflict.save": "Save and mark resolved",
|
||||
"changes.takeOursHint": "Keep your version of the whole file",
|
||||
"changes.takeTheirsHint": "Take the incoming version of the whole file"
|
||||
"changes.takeTheirsHint": "Take the incoming version of the whole file",
|
||||
"projects.title": "Projects",
|
||||
"projects.folders": "Folders",
|
||||
"projects.newWorkspace": "New workspace in {{name}}",
|
||||
"projects.refresh": "Refresh projects and worktrees",
|
||||
"projects.hidden_one": "{{count}} hidden",
|
||||
"projects.hidden_other": "{{count}} hidden",
|
||||
"projects.pick": "Pick a workspace in the sidebar",
|
||||
"row.primary": "primary",
|
||||
"row.adopt": "Adopt",
|
||||
"row.actions": "Workspace actions",
|
||||
"row.hint": "Double-click to rename · drag or Alt+↑/↓ to reorder · right-click for actions",
|
||||
"menu.start": "Start",
|
||||
"menu.stop": "Stop",
|
||||
"menu.rename": "Rename",
|
||||
"menu.newHere": "New workspace here…",
|
||||
"menu.copyPath": "Copy path",
|
||||
"menu.reset": "Reset worktree…",
|
||||
"menu.remove": "Delete workspace…",
|
||||
"pane.slot": "slot {{n}}",
|
||||
"pane.close": "Stop this pane's session",
|
||||
"panel.files": "Files",
|
||||
"panel.changes": "Changes",
|
||||
"panel.sessions": "Sessions",
|
||||
"sessions.empty": "No sessions in this workspace yet",
|
||||
"sessions.resume": "Resume",
|
||||
"sessions.open": "Open",
|
||||
"sessions.search": "Filter sessions…"
|
||||
}
|
||||
|
||||
@@ -70,5 +70,10 @@
|
||||
"releaseAvailable": "{{version}} available",
|
||||
"devBuild": "Dev build · {{count}} ahead",
|
||||
"installedNotRunning": "{{version}} installed, not running",
|
||||
"releaseUnchecked": "Could not check for updates"
|
||||
"releaseUnchecked": "Could not check for updates",
|
||||
"statusBar": {
|
||||
"running": "{{count}} running",
|
||||
"needYou": "{{count}} need you",
|
||||
"today": "Today {{cost}}"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -311,5 +311,32 @@
|
||||
"conflict.reset": "Huỷ",
|
||||
"conflict.save": "Lưu và đánh dấu đã xong",
|
||||
"changes.takeOursHint": "Giữ bản của mình cho cả file",
|
||||
"changes.takeTheirsHint": "Lấy bản kia cho cả file"
|
||||
"changes.takeTheirsHint": "Lấy bản kia cho cả file",
|
||||
"projects.title": "Dự án",
|
||||
"projects.folders": "Thư mục",
|
||||
"projects.newWorkspace": "Workspace mới trong {{name}}",
|
||||
"projects.refresh": "Làm mới dự án và worktree",
|
||||
"projects.hidden_one": "{{count}} đang ẩn",
|
||||
"projects.hidden_other": "{{count}} đang ẩn",
|
||||
"projects.pick": "Chọn một workspace ở thanh bên",
|
||||
"row.primary": "chính",
|
||||
"row.adopt": "Nhận",
|
||||
"row.actions": "Thao tác workspace",
|
||||
"row.hint": "Nhấp đúp để đổi tên · kéo hoặc Alt+↑/↓ để sắp xếp · chuột phải để xem thao tác",
|
||||
"menu.start": "Bắt đầu",
|
||||
"menu.stop": "Dừng",
|
||||
"menu.rename": "Đổi tên",
|
||||
"menu.newHere": "Workspace mới tại đây…",
|
||||
"menu.copyPath": "Sao chép đường dẫn",
|
||||
"menu.reset": "Đặt lại worktree…",
|
||||
"menu.remove": "Xóa workspace…",
|
||||
"pane.slot": "ô {{n}}",
|
||||
"pane.close": "Dừng phiên của ô này",
|
||||
"panel.files": "Tệp",
|
||||
"panel.changes": "Thay đổi",
|
||||
"panel.sessions": "Phiên",
|
||||
"sessions.empty": "Chưa có phiên nào trong workspace này",
|
||||
"sessions.resume": "Tiếp tục",
|
||||
"sessions.open": "Mở",
|
||||
"sessions.search": "Lọc phiên…"
|
||||
}
|
||||
|
||||
@@ -70,5 +70,10 @@
|
||||
"releaseAvailable": "Có {{version}}",
|
||||
"devBuild": "Bản dev · trước {{count}} commit",
|
||||
"installedNotRunning": "Đã cài {{version}}, chưa chạy",
|
||||
"releaseUnchecked": "Không kiểm tra được bản mới"
|
||||
"releaseUnchecked": "Không kiểm tra được bản mới",
|
||||
"statusBar": {
|
||||
"running": "{{count}} đang chạy",
|
||||
"needYou": "{{count}} cần bạn",
|
||||
"today": "Hôm nay {{cost}}"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -404,6 +404,7 @@ import type {
|
||||
LaneFileContent,
|
||||
LaneTreeEntry,
|
||||
LaneCounts,
|
||||
Project,
|
||||
LaneGitFacts,
|
||||
LaneRuntime,
|
||||
LaneTool,
|
||||
@@ -2416,6 +2417,14 @@ export const api = {
|
||||
}>("/remote-sources/sync-all", { method: "POST" }),
|
||||
},
|
||||
|
||||
/** Lanes grouped by git repo, with each repo's worktrees (server/lib/projects.js). */
|
||||
projects: {
|
||||
/** GET /api/projects — read on demand by the sidebar tree, never polled. */
|
||||
list: () => request<{ projects: Project[] }>("/projects"),
|
||||
/** POST /api/projects/refresh — drop the cached roots/worktree lists, then list. */
|
||||
refresh: () => request<{ projects: Project[] }>("/projects/refresh", { method: "POST" }),
|
||||
},
|
||||
|
||||
// ──────────────────────────────── Lanes API ────────────────────────────────
|
||||
/** Agent work lanes: durable units of parallel work, one cwd per lane.
|
||||
* Maps to `server/routes/lanes.js`. */
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
/**
|
||||
* @file projectTreePrefs — the sidebar project tree's viewer preferences, in
|
||||
* ONE localStorage key (`agenthub.sidebar.projects`): which projects are
|
||||
* collapsed, whether a repo's untracked worktrees are listed, and which
|
||||
* worktree sources (`claude` = `<repo>/.claude/worktrees/*`, `agenthub` =
|
||||
* LANES_ROOT) are hidden. A `useSyncExternalStore` reader that caches nothing
|
||||
* but the parsed blob for the current raw string, so the sidebar and Settings
|
||||
* can never disagree. Nothing here reaches the server.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useSyncExternalStore } from "react";
|
||||
|
||||
export const PROJECT_TREE_KEY = "agenthub.sidebar.projects";
|
||||
|
||||
export interface ProjectTreePrefs {
|
||||
collapsed: Record<string, boolean>;
|
||||
showUntracked: boolean;
|
||||
hideSources: string[];
|
||||
}
|
||||
|
||||
const DEFAULTS: ProjectTreePrefs = { collapsed: {}, showUntracked: true, hideSources: [] };
|
||||
const listeners = new Set<() => void>();
|
||||
let cache: { raw: string | null; value: ProjectTreePrefs } = { raw: null, value: DEFAULTS };
|
||||
|
||||
function readRaw(): string | null {
|
||||
try {
|
||||
return localStorage.getItem(PROJECT_TREE_KEY);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function readProjectTreePrefs(): ProjectTreePrefs {
|
||||
const raw = readRaw();
|
||||
if (raw === cache.raw) return cache.value;
|
||||
let value = DEFAULTS;
|
||||
try {
|
||||
const parsed = raw ? (JSON.parse(raw) as Partial<ProjectTreePrefs>) : {};
|
||||
value = {
|
||||
collapsed: parsed.collapsed && typeof parsed.collapsed === "object" ? parsed.collapsed : {},
|
||||
showUntracked: parsed.showUntracked !== false,
|
||||
hideSources: Array.isArray(parsed.hideSources) ? parsed.hideSources : [],
|
||||
};
|
||||
} catch {
|
||||
value = DEFAULTS;
|
||||
}
|
||||
cache = { raw, value };
|
||||
return value;
|
||||
}
|
||||
|
||||
export function writeProjectTreePrefs(patch: Partial<ProjectTreePrefs>): void {
|
||||
try {
|
||||
localStorage.setItem(PROJECT_TREE_KEY, JSON.stringify({ ...readProjectTreePrefs(), ...patch }));
|
||||
} catch {
|
||||
/* disabled storage: the tree just forgets between visits */
|
||||
}
|
||||
listeners.forEach((l) => l());
|
||||
}
|
||||
|
||||
function subscribe(l: () => void) {
|
||||
listeners.add(l);
|
||||
return () => listeners.delete(l);
|
||||
}
|
||||
|
||||
export function useProjectTreePrefs(): ProjectTreePrefs {
|
||||
return useSyncExternalStore(subscribe, readProjectTreePrefs);
|
||||
}
|
||||
@@ -2788,6 +2788,27 @@ export type LaneChanges =
|
||||
}
|
||||
| { available: false; range: LaneChangeRange; files: LaneChangeFile[] };
|
||||
|
||||
/** One worktree of a project's repo (`git worktree list`), for the sidebar. */
|
||||
export interface ProjectWorktree {
|
||||
path: string;
|
||||
branch: string | null;
|
||||
/** The repo's main worktree (git lists it first). */
|
||||
main: boolean;
|
||||
/** The lane sitting exactly on this path, or null: an untracked worktree. */
|
||||
workspace_id: number | null;
|
||||
/** `claude` under `<root>/.claude/worktrees/`, `agenthub` under LANES_ROOT. */
|
||||
source?: "claude" | "agenthub";
|
||||
}
|
||||
|
||||
/** Lanes grouped by repo (`GET /api/projects`); `root: null` is "Folders". */
|
||||
export interface Project {
|
||||
root: string | null;
|
||||
name: string;
|
||||
/** Lane ids, in id order. */
|
||||
workspaces: number[];
|
||||
worktrees: ProjectWorktree[];
|
||||
}
|
||||
|
||||
/** Aggregate counts across all lanes. */
|
||||
export interface LaneCounts {
|
||||
total: number;
|
||||
|
||||
+278
-336
@@ -1,9 +1,12 @@
|
||||
/**
|
||||
* @file Workspace.tsx
|
||||
* @description Merged workspace page combining lanes (agent work units) and runs
|
||||
* (CLI spawns: Claude Code or Codex, determined per lane). Displays a horizontal
|
||||
* lane strip at the top (its first tile creates a lane), the selected lane's
|
||||
* pipeline map, and run configuration/console/history below. Runs are tied to lanes: starting a run
|
||||
* (CLI spawns: Claude Code or Codex, determined per lane). The workspace shown
|
||||
* is picked in the sidebar's project tree and named by `?lane=<id>` (mirrored to
|
||||
* `agenthub.workspace.selected`, so a bare /workspace reopens the last one).
|
||||
* Shows the selected lane's one-row header, pipeline map and run console (1, 2
|
||||
* or 4 panes, each split pane with its own header), and the right panel —
|
||||
* Files · Changes · Sessions. Runs are tied to lanes: starting a run
|
||||
* posts to POST /api/lanes/:id/start with tool-appropriate fields. When a cwd
|
||||
* isn't owned by any lane, calls POST /api/lanes/ensure first. Polls GET
|
||||
* /api/sessions?status=active alongside the run list so Claude Code sessions
|
||||
@@ -48,23 +51,23 @@ import {
|
||||
Square,
|
||||
Columns2,
|
||||
Grid2x2,
|
||||
Pencil,
|
||||
Layers,
|
||||
PanelLeftClose,
|
||||
PanelRightClose,
|
||||
Maximize2,
|
||||
FolderTree,
|
||||
GitCompare,
|
||||
History,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import { api } from "../lib/api";
|
||||
import type { CwdSuggestion, RunListResponse } from "../lib/api";
|
||||
import type { Lane, LaneFeature, ProofFeature, Session, WSMessage } from "../lib/types";
|
||||
import type { Lane, LaneFeature, ProofFeature, Session } from "../lib/types";
|
||||
import { eventBus } from "../lib/eventBus";
|
||||
import { keepIfSame } from "../lib/keepIfSame";
|
||||
import {
|
||||
readLaneOrder,
|
||||
writeLaneOrder,
|
||||
sortByStoredOrder,
|
||||
moveLane,
|
||||
} from "../lib/laneOrderStorage";
|
||||
import { useLanes } from "../hooks/useLanes";
|
||||
import { useAiTools } from "../hooks/useAiTools";
|
||||
import { AiToolIcon } from "../components/AiToolIcon";
|
||||
import { LaneConsolePane } from "../components/run/LaneConsolePane";
|
||||
import PipelineMap from "../components/lanes/PipelineMap";
|
||||
import LaneCard, {
|
||||
@@ -73,10 +76,9 @@ import LaneCard, {
|
||||
LaneToolsMenu,
|
||||
LaneRouterRow,
|
||||
} from "../components/lanes/LaneCard";
|
||||
import LaneStripCard from "../components/lanes/LaneStripCard";
|
||||
import { AddLaneModal } from "../components/lanes/AddLaneModal";
|
||||
import { LaneChangesPanel } from "../components/lanes/LaneChangesPanel";
|
||||
import { DestructiveLaneModal } from "../components/lanes/DestructiveLaneModal";
|
||||
import { WorkspaceSessionsTab } from "../components/lanes/WorkspaceSessionsTab";
|
||||
import { readSplitViewState, writeSplitViewState } from "../lib/splitViewStorage";
|
||||
import type { SplitViewState, SplitLayout } from "../lib/splitViewStorage";
|
||||
|
||||
@@ -84,6 +86,17 @@ import type { SplitViewState, SplitLayout } from "../lib/splitViewStorage";
|
||||
const CHANGES_OPEN_KEY = "agenthub.workspace.changesOpen";
|
||||
/** localStorage key for how wide the Changes panel is. */
|
||||
const CHANGES_SIZE_KEY = "agenthub.workspace.changesSize";
|
||||
/** localStorage key for the right panel's activity-bar tab. */
|
||||
const PANEL_TAB_KEY = "agenthub.workspace.panelTab";
|
||||
/** localStorage key for the last workspace shown, restored on a bare /workspace. */
|
||||
const SELECTED_KEY = "agenthub.workspace.selected";
|
||||
|
||||
const PANEL_TABS = [
|
||||
{ id: "files", Icon: FolderTree },
|
||||
{ id: "changes", Icon: GitCompare },
|
||||
{ id: "sessions", Icon: History },
|
||||
] as const;
|
||||
type PanelTab = (typeof PANEL_TABS)[number]["id"];
|
||||
|
||||
/** The three widths the Changes panel cycles through, then closed. Reading a
|
||||
* diff and watching a console want opposite amounts of room, and which one is
|
||||
@@ -155,6 +168,53 @@ function SplitLayoutToggle({
|
||||
);
|
||||
}
|
||||
|
||||
/** A split pane's one-line header: which lane, which slot, the run's model,
|
||||
* and — for slots 1–3 — a button that kills that pane's own tmux session
|
||||
* (`DELETE /api/run/<runId>`). Slot 0 is the lane's run and has no close. */
|
||||
function PaneHeader({
|
||||
lane,
|
||||
slot,
|
||||
runId,
|
||||
model,
|
||||
}: {
|
||||
lane: Lane | null;
|
||||
slot: number;
|
||||
runId: string | null;
|
||||
model: string | null;
|
||||
}) {
|
||||
const { t } = useTranslation("lanes");
|
||||
const tools = useAiTools();
|
||||
return (
|
||||
<div
|
||||
data-testid={`pane-header-${slot}`}
|
||||
className="flex h-6 shrink-0 items-center gap-1.5 rounded border border-border bg-surface-2 px-2 text-[11px] text-fg-muted"
|
||||
>
|
||||
{lane && (
|
||||
<AiToolIcon
|
||||
tool={{ id: lane.tool, label: tools.label(lane.tool) }}
|
||||
className="h-3.5 w-3.5 !p-0"
|
||||
/>
|
||||
)}
|
||||
<span className="min-w-0 truncate font-medium text-fg-secondary">
|
||||
{lane ? lane.title || lane.cwd.split("/").pop() : ""}
|
||||
</span>
|
||||
<span className="shrink-0">{t("pane.slot", { n: slot })}</span>
|
||||
{model && <span className="min-w-0 truncate">{model}</span>}
|
||||
{slot > 0 && runId && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void api.run.kill(runId).catch(() => undefined)}
|
||||
aria-label={t("pane.close")}
|
||||
title={t("pane.close")}
|
||||
className="ml-auto shrink-0 rounded p-0.5 hover:bg-surface-4 hover:text-status-danger"
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ConsoleArea({
|
||||
lanes,
|
||||
selectedLaneId,
|
||||
@@ -170,6 +230,7 @@ function ConsoleArea({
|
||||
onLaneCreated,
|
||||
onLaneIdChange,
|
||||
showToggle = true,
|
||||
resumeRequest,
|
||||
}: {
|
||||
lanes: Lane[];
|
||||
selectedLaneId: number | null;
|
||||
@@ -185,6 +246,8 @@ function ConsoleArea({
|
||||
onLaneCreated: (lane: Lane) => void;
|
||||
onLaneIdChange: (id: number | null) => void;
|
||||
showToggle?: boolean;
|
||||
/** A session to resume in slot 0, from the Sessions tab. */
|
||||
resumeRequest: { sessionId: string; model: string | null; nonce: number } | null;
|
||||
}) {
|
||||
// Every split pane shows the SELECTED lane, each in its own tmux session
|
||||
// (slot = pane index). Tracking what each pane shows lets the others leave
|
||||
@@ -228,6 +291,7 @@ function ConsoleArea({
|
||||
wsConnected={wsConnected}
|
||||
defaultCwd={defaultCwd}
|
||||
onHasActiveRunChange={onHasActiveRunChange}
|
||||
resumeRequest={resumeRequest}
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
@@ -236,23 +300,31 @@ function ConsoleArea({
|
||||
}`}
|
||||
>
|
||||
{Array.from({ length: splitView.layout }, (_, i) => (
|
||||
<LaneConsolePane
|
||||
key={i}
|
||||
lanes={lanes}
|
||||
laneId={selectedLaneId}
|
||||
slot={i}
|
||||
hiddenRunIds={hiddenFor[i]}
|
||||
onHandleChange={paneHandlers[i]}
|
||||
showLaneSelector={false}
|
||||
onLaneIdChange={onLaneIdChange}
|
||||
onLaneCreated={onLaneCreated}
|
||||
binaryStatus={binaryStatus}
|
||||
cwdSuggestions={cwdSuggestions}
|
||||
activeRuns={activeRuns}
|
||||
externalSessions={externalSessions}
|
||||
wsConnected={wsConnected}
|
||||
defaultCwd={defaultCwd}
|
||||
/>
|
||||
<div key={i} className="flex min-h-0 min-w-0 flex-col gap-1">
|
||||
<PaneHeader
|
||||
lane={lanes.find((l) => l.id === selectedLaneId) ?? null}
|
||||
slot={i}
|
||||
runId={paneRunIds[i] ?? null}
|
||||
model={activeRuns?.items.find((r) => r.id === paneRunIds[i])?.model ?? null}
|
||||
/>
|
||||
<LaneConsolePane
|
||||
lanes={lanes}
|
||||
laneId={selectedLaneId}
|
||||
slot={i}
|
||||
hiddenRunIds={hiddenFor[i]}
|
||||
onHandleChange={paneHandlers[i]}
|
||||
showLaneSelector={false}
|
||||
onLaneIdChange={onLaneIdChange}
|
||||
onLaneCreated={onLaneCreated}
|
||||
binaryStatus={binaryStatus}
|
||||
cwdSuggestions={cwdSuggestions}
|
||||
activeRuns={activeRuns}
|
||||
externalSessions={externalSessions}
|
||||
wsConnected={wsConnected}
|
||||
defaultCwd={defaultCwd}
|
||||
resumeRequest={i === 0 ? resumeRequest : null}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
@@ -265,9 +337,49 @@ export function Workspace() {
|
||||
const wsConnected = useSyncExternalStore(eventBus.onConnection, () => eventBus.connected);
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
|
||||
// Lane state
|
||||
const [lanes, setLanes] = useState<Lane[]>([]);
|
||||
const [selectedLaneId, setSelectedLaneId] = useState<number | null>(null);
|
||||
// Lane state: the list is the shared store the sidebar reads too; the
|
||||
// selection is the URL's `?lane=` (the sidebar sets it, so does a
|
||||
// notification click), falling back to the last one shown, then the first.
|
||||
const { lanes, loaded: lanesLoaded, refresh: refreshLanes } = useLanes();
|
||||
const laneParam = searchParams.get("lane");
|
||||
const paramId = laneParam === null ? null : Number(laneParam);
|
||||
const fallbackId = (() => {
|
||||
if (!lanesLoaded) return null;
|
||||
let stored: number | null = null;
|
||||
try {
|
||||
stored = Number(window.localStorage.getItem(SELECTED_KEY));
|
||||
} catch {
|
||||
/* disabled storage */
|
||||
}
|
||||
return lanes.find((l) => l.id === stored)?.id ?? lanes[0]?.id ?? null;
|
||||
})();
|
||||
const selectedLaneId =
|
||||
paramId !== null && lanes.some((l) => l.id === paramId) ? paramId : fallbackId;
|
||||
const setSelectedLaneId = useCallback(
|
||||
(id: number | null) =>
|
||||
setSearchParams(
|
||||
(prev) => {
|
||||
const next = new URLSearchParams(prev);
|
||||
if (id === null) next.delete("lane");
|
||||
else next.set("lane", String(id));
|
||||
return next;
|
||||
},
|
||||
{ replace: true }
|
||||
),
|
||||
[setSearchParams]
|
||||
);
|
||||
// Name the fallback in the URL, so the sidebar highlights it and F5 keeps it.
|
||||
// Only a URL naming NO lane is rewritten: one naming a lane the list has not
|
||||
// caught up with yet (just created) must survive until the refetch lands.
|
||||
useEffect(() => {
|
||||
if (!lanesLoaded || selectedLaneId === null) return;
|
||||
try {
|
||||
window.localStorage.setItem(SELECTED_KEY, String(selectedLaneId));
|
||||
} catch {
|
||||
/* disabled storage */
|
||||
}
|
||||
if (paramId === null) setSelectedLaneId(selectedLaneId);
|
||||
}, [lanesLoaded, selectedLaneId, paramId, setSelectedLaneId]);
|
||||
const [laneActionError, setLaneActionError] = useState<string | null>(null);
|
||||
const [addLaneOpen, setAddLaneOpen] = useState(false);
|
||||
// Fields the Add-lane modal opens with when it was opened from a lane's git
|
||||
@@ -283,10 +395,11 @@ export function Workspace() {
|
||||
>([]);
|
||||
const [viewedFeature, setViewedFeature] = useState<LaneFeature | null>(null);
|
||||
const [proofFeatures, setProofFeatures] = useState<ProofFeature[]>([]);
|
||||
const [removeConfirmOpen, setRemoveConfirmOpen] = useState(false);
|
||||
// Inline rename of the selected lane's title, in the detail header.
|
||||
const [renamingLaneId, setRenamingLaneId] = useState<number | null>(null);
|
||||
const [renameDraft, setRenameDraft] = useState("");
|
||||
const [resumeRequest, setResumeRequest] = useState<{
|
||||
sessionId: string;
|
||||
model: string | null;
|
||||
nonce: number;
|
||||
} | null>(null);
|
||||
|
||||
// Run state kept at page level: shared across every pane, or drives the
|
||||
// lane strip itself rather than any one pane's form.
|
||||
@@ -305,10 +418,21 @@ export function Workspace() {
|
||||
|
||||
// Split view state
|
||||
const [splitView, setSplitView] = useState<SplitViewState>(() => readSplitViewState());
|
||||
// Info block (pickers, LaneCard, PipelineMap, proof gallery) starts shown in
|
||||
// single-pane view and collapsed in split view, but the toggle below can
|
||||
// override that in either mode — the user decides, not the layout.
|
||||
const [infoExpanded, setInfoExpanded] = useState(() => splitView.layout === 1);
|
||||
const [panelTab, setPanelTab] = useState<PanelTab>(() => {
|
||||
try {
|
||||
const saved = window.localStorage.getItem(PANEL_TAB_KEY) as PanelTab | null;
|
||||
return saved && PANEL_TABS.some((x) => x.id === saved) ? saved : "changes";
|
||||
} catch {
|
||||
return "changes";
|
||||
}
|
||||
});
|
||||
useEffect(() => {
|
||||
try {
|
||||
window.localStorage.setItem(PANEL_TAB_KEY, panelTab);
|
||||
} catch {
|
||||
/* the panel just forgets its tab between visits */
|
||||
}
|
||||
}, [panelTab]);
|
||||
// Whether the Changes panel is open. A viewer preference, so it lives in
|
||||
// localStorage and never reaches the server — same rule as the lane strip's
|
||||
// order (client/src/lib/laneOrderStorage.ts).
|
||||
@@ -347,7 +471,6 @@ export function Workspace() {
|
||||
changesSize === CHANGES_SIZES[CHANGES_SIZES.length - 1]
|
||||
? tLanes("changes.collapse")
|
||||
: tLanes("changes.resize", { size: tLanes(`changes.size.${nextChangesSize}`) });
|
||||
const showInfo = infoExpanded;
|
||||
|
||||
const setLayout = useCallback((layout: SplitLayout) => {
|
||||
setSplitView((prev) => {
|
||||
@@ -358,42 +481,6 @@ export function Workspace() {
|
||||
});
|
||||
}, []);
|
||||
|
||||
// Pre-flight: probe binary + active runs + cwd suggestions + lanes on mount
|
||||
const refreshLanes = useCallback(async () => {
|
||||
try {
|
||||
const r = await api.lanes.list();
|
||||
// The `lanes` state IS the display order: the server answers in id order,
|
||||
// the viewer's stored strip order is applied once here, and the in-place
|
||||
// lane_update merge below preserves whatever order this produced.
|
||||
setLanes(keepIfSame(sortByStoredOrder(r.lanes, readLaneOrder())));
|
||||
setSelectedLaneId((cur) =>
|
||||
cur !== null && r.lanes.some((l) => l.id === cur) ? cur : (r.lanes[0]?.id ?? null)
|
||||
);
|
||||
} catch {
|
||||
// Silent fail for lanes load
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Deep link from a notification click (`/workspace?lane=<id>`, see
|
||||
// useNotifications' laneUrlForSession): once that lane is in the loaded
|
||||
// list, select it and drop the param so a later manual lane switch sticks.
|
||||
useEffect(() => {
|
||||
const laneParam = searchParams.get("lane");
|
||||
if (laneParam === null || lanes.length === 0) return;
|
||||
const paramId = Number(laneParam);
|
||||
if (lanes.some((l) => l.id === paramId)) {
|
||||
setSelectedLaneId(paramId);
|
||||
}
|
||||
setSearchParams(
|
||||
(prev) => {
|
||||
const next = new URLSearchParams(prev);
|
||||
next.delete("lane");
|
||||
return next;
|
||||
},
|
||||
{ replace: true }
|
||||
);
|
||||
}, [lanes, searchParams, setSearchParams]);
|
||||
|
||||
// Probe the CLI of every tool a lane uses — fresh on the server, so a CLI
|
||||
// installed since the page loaded clears its missing-binary banner. Claude is
|
||||
// always probed: the console's no-lane state runs it.
|
||||
@@ -417,7 +504,6 @@ export function Workspace() {
|
||||
.pipelines()
|
||||
.then((r) => setPipelineTemplates(r.pipelines))
|
||||
.catch(() => undefined);
|
||||
void refreshLanes();
|
||||
api.run
|
||||
.cwds()
|
||||
.then((r) => {
|
||||
@@ -437,46 +523,7 @@ export function Workspace() {
|
||||
}
|
||||
})
|
||||
.catch(() => undefined);
|
||||
|
||||
// Subscribe to lane updates from the event bus
|
||||
return eventBus.subscribe((msg: WSMessage) => {
|
||||
if (msg.type !== "lane_update") return;
|
||||
const payload = msg.data as { lane?: Lane; removed?: number };
|
||||
if (payload.removed !== undefined) {
|
||||
void refreshLanes();
|
||||
return;
|
||||
}
|
||||
const lane = payload.lane;
|
||||
if (!lane) return;
|
||||
setLanes((cur) => {
|
||||
const i = cur.findIndex((l) => l.id === lane.id);
|
||||
if (i === -1) {
|
||||
void refreshLanes();
|
||||
return cur;
|
||||
}
|
||||
// The server re-broadcasts a lane on hook activity that often changed
|
||||
// nothing this page renders; replacing the array anyway would re-render
|
||||
// the strip and the console on every such event.
|
||||
if (JSON.stringify(cur[i]) === JSON.stringify(lane)) return cur;
|
||||
const next = [...cur];
|
||||
next[i] = lane;
|
||||
return next;
|
||||
});
|
||||
});
|
||||
}, [refreshLanes]);
|
||||
|
||||
// A reconnect (e.g. the dashboard server restarting) resumes the WS but does
|
||||
// not replay missed lane_update diffs, so a lane whose fields changed while
|
||||
// disconnected — status, needs_action — would keep showing its pre-restart
|
||||
// badges forever with no further server-side change to broadcast. Refetch
|
||||
// the full list whenever the socket comes back up.
|
||||
useEffect(
|
||||
() =>
|
||||
eventBus.onConnection((isConnected) => {
|
||||
if (isConnected) void refreshLanes();
|
||||
}),
|
||||
[refreshLanes]
|
||||
);
|
||||
}, []);
|
||||
|
||||
// Clean up stale pane lane IDs when lanes load
|
||||
useEffect(() => {
|
||||
@@ -625,37 +672,6 @@ export function Workspace() {
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Move a lane card within the strip. Purely a viewer preference — it writes
|
||||
* localStorage, never the lane row, so nothing the CLI or another session
|
||||
* reads changes.
|
||||
*/
|
||||
const handleLaneReorder = (from: number, to: number) => {
|
||||
if (from === to || from < 0 || to < 0 || from >= lanes.length || to >= lanes.length) return;
|
||||
const ids = moveLane(
|
||||
lanes.map((l) => l.id),
|
||||
from,
|
||||
to
|
||||
);
|
||||
writeLaneOrder(ids);
|
||||
setLanes(sortByStoredOrder(lanes, ids));
|
||||
};
|
||||
|
||||
/** Commit an inline lane rename. An empty name clears the title, which puts
|
||||
* the cwd back in its place (`lane.title || lane.cwd` everywhere). */
|
||||
const commitRename = async (id: number) => {
|
||||
const title = renameDraft.trim();
|
||||
setRenamingLaneId(null);
|
||||
const lane = lanes.find((l) => l.id === id);
|
||||
if (!lane || title === (lane.title || "")) return;
|
||||
try {
|
||||
await api.lanes.update(id, { title });
|
||||
await refreshLanes();
|
||||
} catch (err) {
|
||||
setLaneActionError(err instanceof Error ? err.message : tLanes("actionErrorUnknown"));
|
||||
}
|
||||
};
|
||||
|
||||
// Mirrors `agenthub lanes pipeline <template> <id>`: same PATCH, same
|
||||
// stage-mismatch warning when the current declared stage matches no node
|
||||
// in the newly chosen template.
|
||||
@@ -685,15 +701,14 @@ export function Workspace() {
|
||||
<div
|
||||
className={
|
||||
viewportLocked
|
||||
? "h-[calc(100vh-2.5rem)] lg:h-[calc(100vh-3rem)] flex flex-col gap-5"
|
||||
? "h-[calc(100vh-4rem)] lg:h-[calc(100vh-4.5rem)] flex flex-col gap-5"
|
||||
: "space-y-5"
|
||||
}
|
||||
>
|
||||
{/* Lane strip + detail stack vertically: the strip is a horizontally
|
||||
scrolling row (holds any number of lanes), the detail/console area
|
||||
below it gets the full width and never competes for it. */}
|
||||
{/* Workspaces are picked in the sidebar's project tree; this page shows
|
||||
the selected one. With none at all, one banner offers to create it. */}
|
||||
<div className="flex min-h-0 flex-1 flex-col gap-4">
|
||||
{lanes.length === 0 ? (
|
||||
{lanesLoaded && lanes.length === 0 && (
|
||||
/* With no lanes the strip was a dashed tile beside a stray sentence,
|
||||
which reads as a half-rendered row rather than a state. One banner
|
||||
instead — and a compact one on purpose: the run setup form below
|
||||
@@ -723,36 +738,6 @@ export function Workspace() {
|
||||
{tLanes("add")}
|
||||
</span>
|
||||
</button>
|
||||
) : (
|
||||
<div
|
||||
data-testid="lane-strip"
|
||||
className="flex shrink-0 flex-row gap-2 overflow-x-auto pb-1"
|
||||
>
|
||||
{/* "Add lane" is a tile in the strip rather than a header button:
|
||||
creating a lane is the same kind of act as picking one, so it
|
||||
reads as the empty slot at the head of the row. Dashed border and
|
||||
no liveness dot keep it from being mistaken for a lane. */}
|
||||
<button
|
||||
type="button"
|
||||
data-testid="lane-tile-add"
|
||||
onClick={() => setAddLaneOpen(true)}
|
||||
title={tLanes("addLane")}
|
||||
className="flex w-56 shrink-0 items-center justify-center gap-1.5 rounded-lg border border-dashed border-border-light p-3 text-[13px] font-medium text-fg-secondary transition-colors hover:border-accent hover:bg-accent/5 hover:text-fg-primary"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
{tLanes("add")}
|
||||
</button>
|
||||
{lanes.map((l, i) => (
|
||||
<LaneStripCard
|
||||
key={l.id}
|
||||
lane={l}
|
||||
index={i}
|
||||
selected={selectedLaneId === l.id}
|
||||
onSelect={() => setSelectedLaneId(l.id)}
|
||||
onReorder={handleLaneReorder}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* The selected lane's pipeline — the thing you actually come to this
|
||||
@@ -792,47 +777,14 @@ export function Workspace() {
|
||||
? tLanes("statusDead")
|
||||
: tLanes(`status.${currentLane.status}`, { defaultValue: currentLane.status })}
|
||||
</span>
|
||||
{/* The lane's name, and the one place it can be set. No "Lane 23 ·"
|
||||
prefix any more: the id is on the strip tile, where the CLI's
|
||||
`--lane` reader looks for it, and repeating it here only ate
|
||||
the width the name needed. Submitting an empty name clears
|
||||
`title`, which puts the cwd back in its place. */}
|
||||
{renamingLaneId === currentLane.id ? (
|
||||
<input
|
||||
data-testid="lane-rename-input"
|
||||
aria-label={tLanes("renameLabel")}
|
||||
autoFocus
|
||||
value={renameDraft}
|
||||
onChange={(e) => setRenameDraft(e.target.value)}
|
||||
// Blur CANCELS, it does not save — same convention as the
|
||||
// rename field in CcConfig. Committing here too would fire
|
||||
// the PATCH twice for one Enter: the commit unmounts the
|
||||
// focused input, and removing a focused element raises a
|
||||
// native blur before the refetch has moved `lanes`, so the
|
||||
// "unchanged title" guard would not catch the second call.
|
||||
onBlur={() => setRenamingLaneId(null)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") void commitRename(currentLane.id);
|
||||
if (e.key === "Escape") setRenamingLaneId(null);
|
||||
}}
|
||||
maxLength={200}
|
||||
className="min-w-0 flex-1 rounded border border-accent bg-surface-1 px-2 py-0.5 text-sm font-semibold text-fg-primary"
|
||||
/>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
data-testid="lane-rename"
|
||||
onClick={() => {
|
||||
setRenameDraft(currentLane.title || "");
|
||||
setRenamingLaneId(currentLane.id);
|
||||
}}
|
||||
title={`${currentLane.cwd}\n${tLanes("renameLabel")}`}
|
||||
className="group flex min-w-0 items-center gap-1 text-sm font-semibold text-fg-primary"
|
||||
>
|
||||
<span className="min-w-0 truncate">{currentLane.title || currentLane.cwd}</span>
|
||||
<Pencil className="h-3 w-3 shrink-0 text-fg-muted opacity-0 transition-opacity group-hover:opacity-100" />
|
||||
</button>
|
||||
)}
|
||||
{/* Renaming lives on the sidebar row (double-click); here the
|
||||
name is only read. */}
|
||||
<span
|
||||
title={currentLane.cwd}
|
||||
className="min-w-0 truncate text-sm font-semibold text-fg-primary"
|
||||
>
|
||||
{currentLane.title || currentLane.cwd}
|
||||
</span>
|
||||
{/* A lane with no pipeline (non-claude, or no template
|
||||
installed): no time-at-stage, no pipeline or feature
|
||||
picker, no map. */}
|
||||
@@ -841,93 +793,68 @@ export function Workspace() {
|
||||
{since(currentLane.stage_seconds)}
|
||||
</span>
|
||||
)}
|
||||
{showInfo && (
|
||||
<>
|
||||
{!noPipeline && (
|
||||
<select
|
||||
data-testid="pipeline-picker"
|
||||
aria-label={tLanes("pipelinePicker.label")}
|
||||
className="rounded border border-border bg-surface-1 px-2 py-0.5 text-xs text-fg-secondary disabled:opacity-60"
|
||||
value={currentLane.pipeline}
|
||||
disabled={!!viewedFeature}
|
||||
title={
|
||||
viewedFeature
|
||||
? tLanes("features.viewingArchived", { slug: viewedFeature.slug })
|
||||
: undefined
|
||||
}
|
||||
onChange={(e) => void handlePipelineChange(currentLane.id, e.target.value)}
|
||||
>
|
||||
{(pipelineTemplates.length
|
||||
? pipelineTemplates
|
||||
: [
|
||||
{
|
||||
id: currentLane.pipeline,
|
||||
name: currentLane.pipeline_name,
|
||||
nodes: [],
|
||||
},
|
||||
]
|
||||
).map((p) => (
|
||||
<option key={p.id} value={p.id}>
|
||||
{p.nodes.length ? `${p.name} (${p.nodes.length})` : p.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
{/* `agy` has no endpoint to point at a router. */}
|
||||
<LaneRouterRow lane={currentLane} />
|
||||
{/* No stage chip and no "auto: <stage>" chip here: the
|
||||
{!noPipeline && (
|
||||
<select
|
||||
data-testid="pipeline-picker"
|
||||
aria-label={tLanes("pipelinePicker.label")}
|
||||
className="rounded border border-border bg-surface-1 px-2 py-0.5 text-xs text-fg-secondary disabled:opacity-60"
|
||||
value={currentLane.pipeline}
|
||||
disabled={!!viewedFeature}
|
||||
title={
|
||||
viewedFeature
|
||||
? tLanes("features.viewingArchived", { slug: viewedFeature.slug })
|
||||
: undefined
|
||||
}
|
||||
onChange={(e) => void handlePipelineChange(currentLane.id, e.target.value)}
|
||||
>
|
||||
{(pipelineTemplates.length
|
||||
? pipelineTemplates
|
||||
: [
|
||||
{
|
||||
id: currentLane.pipeline,
|
||||
name: currentLane.pipeline_name,
|
||||
nodes: [],
|
||||
},
|
||||
]
|
||||
).map((p) => (
|
||||
<option key={p.id} value={p.id}>
|
||||
{p.nodes.length ? `${p.name} (${p.nodes.length})` : p.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
{/* `agy` has no endpoint to point at a router. */}
|
||||
<LaneRouterRow lane={currentLane} />
|
||||
{/* No stage chip and no "auto: <stage>" chip here: the
|
||||
PipelineMap right below this header already draws both
|
||||
— the declared node as the current ring, the detected
|
||||
one as its amber badge — so the chips were a second,
|
||||
smaller copy of the same two facts. */}
|
||||
{!noPipeline && features.length > 0 && (
|
||||
<select
|
||||
data-testid="feature-picker"
|
||||
className="rounded border border-border bg-surface-1 px-2 py-0.5 text-xs"
|
||||
value={viewedFeatureSlug ?? ""}
|
||||
onChange={(e) => setViewedFeatureSlug(e.target.value || null)}
|
||||
>
|
||||
<option value="">{tLanes("features.live")}</option>
|
||||
{features.map((f) => (
|
||||
<option key={f.slug} value={f.slug}>
|
||||
{f.slug}
|
||||
{f.archived_at ? ` (${tLanes("features.archived")})` : ""}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
</>
|
||||
{!noPipeline && features.length > 0 && (
|
||||
<select
|
||||
data-testid="feature-picker"
|
||||
className="rounded border border-border bg-surface-1 px-2 py-0.5 text-xs"
|
||||
value={viewedFeatureSlug ?? ""}
|
||||
onChange={(e) => setViewedFeatureSlug(e.target.value || null)}
|
||||
>
|
||||
<option value="">{tLanes("features.live")}</option>
|
||||
{features.map((f) => (
|
||||
<option key={f.slug} value={f.slug}>
|
||||
{f.slug}
|
||||
{f.archived_at ? ` (${tLanes("features.archived")})` : ""}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
<div className="ml-auto flex items-center gap-2">
|
||||
{/* Per-lane actions (mcp sync, sync-base check)
|
||||
belong beside the lane they act on, not in a page header. */}
|
||||
<LaneToolsMenu lane={currentLane} />
|
||||
<button
|
||||
type="button"
|
||||
data-testid="lane-detail-toggle"
|
||||
aria-expanded={infoExpanded}
|
||||
onClick={() => setInfoExpanded((v) => !v)}
|
||||
className="rounded border border-border-light px-2 py-1 text-xs text-fg-secondary transition-colors hover:text-fg-primary"
|
||||
>
|
||||
{infoExpanded ? tLanes("laneDetail.hide") : tLanes("laneDetail.show")}
|
||||
</button>
|
||||
<SplitLayoutToggle splitView={splitView} setLayout={setLayout} />
|
||||
{showInfo && (
|
||||
<button
|
||||
type="button"
|
||||
data-testid="lane-action-remove"
|
||||
onClick={() => setRemoveConfirmOpen(true)}
|
||||
className="rounded px-2 py-0.5 text-xs text-status-danger transition-colors hover:bg-status-danger/15"
|
||||
>
|
||||
{/* One label for both kinds: removal forgets the lane's row
|
||||
and never deletes a worktree, a branch or a database. */}
|
||||
{tLanes("action.forget")}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{currentLane && showInfo && (
|
||||
{currentLane && (
|
||||
<div className="max-h-[45vh] shrink-0 overflow-y-auto">
|
||||
{/* No margin when the card rendered nothing (see LaneCard). */}
|
||||
<div className="mb-3 has-[>div:empty]:mb-0">
|
||||
@@ -1017,11 +944,10 @@ export function Workspace() {
|
||||
wsConnected={wsConnected}
|
||||
defaultCwd={defaultCwd}
|
||||
onHasActiveRunChange={setPaneHasActiveRun}
|
||||
onLaneCreated={(lane) =>
|
||||
setLanes((prev) => (prev.some((l) => l.id === lane.id) ? prev : [...prev, lane]))
|
||||
}
|
||||
onLaneCreated={() => void refreshLanes()}
|
||||
onLaneIdChange={setSelectedLaneId}
|
||||
showToggle={!currentLane}
|
||||
resumeRequest={resumeRequest}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
@@ -1033,10 +959,26 @@ export function Workspace() {
|
||||
data-testid="lane-changes-card"
|
||||
className={`card flex shrink-0 flex-col p-3 ${CHANGES_WIDTH[changesSize]}`}
|
||||
>
|
||||
<div className="mb-2 flex items-center gap-2">
|
||||
<span className="text-[11px] font-medium uppercase tracking-wide text-fg-secondary">
|
||||
{tLanes("changes.title")}
|
||||
</span>
|
||||
{/* Activity bar: Files · Changes · Sessions, then the resize control. */}
|
||||
<div className="mb-2 flex items-center gap-1" role="toolbar">
|
||||
{PANEL_TABS.map(({ id, Icon }) => (
|
||||
<button
|
||||
key={id}
|
||||
type="button"
|
||||
data-testid={`panel-tab-${id}`}
|
||||
aria-pressed={panelTab === id}
|
||||
onClick={() => setPanelTab(id)}
|
||||
title={tLanes(`panel.${id}`)}
|
||||
aria-label={tLanes(`panel.${id}`)}
|
||||
className={`flex h-7 w-7 items-center justify-center rounded-md transition-colors ${
|
||||
panelTab === id
|
||||
? "bg-accent/15 text-accent"
|
||||
: "text-fg-muted hover:bg-surface-4 hover:text-fg-primary"
|
||||
}`}
|
||||
>
|
||||
<Icon className="h-4 w-4" />
|
||||
</button>
|
||||
))}
|
||||
<button
|
||||
type="button"
|
||||
data-testid="lane-changes-resize"
|
||||
@@ -1067,20 +1009,34 @@ export function Workspace() {
|
||||
})()}
|
||||
</button>
|
||||
</div>
|
||||
<LaneChangesPanel
|
||||
laneId={currentLane.id}
|
||||
full={changesSize === "full"}
|
||||
// A sibling worktree forks from the lane's SOURCE repo when
|
||||
// it has one (this lane is itself a worktree of it); an
|
||||
// adopted lane is its own repo.
|
||||
onNewLane={(branch) => {
|
||||
setAddLanePrefill({
|
||||
sourceRepo: currentLane.source_repo || currentLane.cwd,
|
||||
branch,
|
||||
});
|
||||
setAddLaneOpen(true);
|
||||
}}
|
||||
/>
|
||||
{panelTab === "sessions" ? (
|
||||
<WorkspaceSessionsTab
|
||||
lane={currentLane}
|
||||
onResume={(session) =>
|
||||
setResumeRequest({
|
||||
sessionId: session.id,
|
||||
model: session.model,
|
||||
nonce: Date.now(),
|
||||
})
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<LaneChangesPanel
|
||||
laneId={currentLane.id}
|
||||
tab={panelTab}
|
||||
full={changesSize === "full"}
|
||||
// A sibling worktree forks from the lane's SOURCE repo when
|
||||
// it has one (this lane is itself a worktree of it); an
|
||||
// adopted lane is its own repo.
|
||||
onNewLane={(branch) => {
|
||||
setAddLanePrefill({
|
||||
sourceRepo: currentLane.source_repo || currentLane.cwd,
|
||||
branch,
|
||||
});
|
||||
setAddLaneOpen(true);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</aside>
|
||||
) : (
|
||||
<button
|
||||
@@ -1102,19 +1058,6 @@ export function Workspace() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{currentLane && removeConfirmOpen && (
|
||||
<DestructiveLaneModal
|
||||
lane={currentLane}
|
||||
action="remove"
|
||||
open
|
||||
onClose={() => setRemoveConfirmOpen(false)}
|
||||
onConfirm={(body) => {
|
||||
setRemoveConfirmOpen(false);
|
||||
void handleLaneAction(currentLane.id, "remove", body);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
<AddLaneModal
|
||||
open={addLaneOpen}
|
||||
cwdSuggestions={cwdSuggestions}
|
||||
@@ -1124,9 +1067,8 @@ export function Workspace() {
|
||||
setAddLanePrefill(null);
|
||||
}}
|
||||
onAdded={(lane) => {
|
||||
setLanes((prev) => (prev.some((l) => l.id === lane.id) ? prev : [...prev, lane]));
|
||||
setSelectedLaneId(lane.id);
|
||||
void refreshLanes();
|
||||
setSelectedLaneId(lane.id);
|
||||
}}
|
||||
/>
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { render, act, screen, fireEvent } from "@testing-library/react";
|
||||
import { MemoryRouter } from "react-router-dom";
|
||||
import { MemoryRouter, useSearchParams } from "react-router-dom";
|
||||
import i18n from "i18next";
|
||||
|
||||
// Hoisted: `vi.mock`'s factory is lifted above the module body, so the
|
||||
@@ -138,19 +138,35 @@ describe("Workspace — the console cwd follows the selected lane", () => {
|
||||
expect(cwdInput().value).toBe(LANE_A.cwd);
|
||||
});
|
||||
|
||||
it("swaps the cwd when another lane is selected in the strip", async () => {
|
||||
it("swaps the cwd when another lane is selected in the sidebar", async () => {
|
||||
// Stands in for the sidebar's project tree: picking sets `?lane=`.
|
||||
function Picker() {
|
||||
const [, setParams] = useSearchParams();
|
||||
return (
|
||||
<>
|
||||
{[1, 2].map((id) => (
|
||||
<button
|
||||
key={id}
|
||||
data-testid={`pick-${id}`}
|
||||
onClick={() => setParams({ lane: String(id) })}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
render(
|
||||
<MemoryRouter initialEntries={["/run"]}>
|
||||
<Picker />
|
||||
<Workspace />
|
||||
</MemoryRouter>
|
||||
);
|
||||
await settle();
|
||||
|
||||
fireEvent.click(screen.getByTestId("lane-tile-2"));
|
||||
fireEvent.click(screen.getByTestId("pick-2"));
|
||||
await settle();
|
||||
expect(cwdInput().value).toBe(LANE_B.cwd);
|
||||
|
||||
fireEvent.click(screen.getByTestId("lane-tile-1"));
|
||||
fireEvent.click(screen.getByTestId("pick-1"));
|
||||
await settle();
|
||||
expect(cwdInput().value).toBe(LANE_A.cwd);
|
||||
});
|
||||
|
||||
@@ -1,614 +0,0 @@
|
||||
/**
|
||||
* @file Workspace.laneReorderRename.test.tsx
|
||||
* @description Integration tests for lane strip reordering, persistence to
|
||||
* localStorage, and inline lane rename functionality. Covers the three paths
|
||||
* to reorder (drag, Alt+Arrow, and refetch with stored order), and rename
|
||||
* commit/cancel/error paths.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { render, act, screen, waitFor, fireEvent } from "@testing-library/react";
|
||||
import { MemoryRouter } from "react-router-dom";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
|
||||
const recordedCalls: Array<{ method: string; path: string; payload?: unknown }> = [];
|
||||
|
||||
type LaneFixture = {
|
||||
id: number;
|
||||
cwd: string;
|
||||
title: string;
|
||||
pipeline_name: string;
|
||||
pipeline_nodes: never[];
|
||||
detected_signal: string | null;
|
||||
run_id: string | null;
|
||||
};
|
||||
|
||||
let lanesToReturn: LaneFixture[] = [
|
||||
{
|
||||
id: 1,
|
||||
cwd: "/workspace/a",
|
||||
title: "Lane A",
|
||||
pipeline_name: "default",
|
||||
pipeline_nodes: [],
|
||||
detected_signal: null,
|
||||
run_id: null,
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
cwd: "/workspace/b",
|
||||
title: "Lane B",
|
||||
pipeline_name: "default",
|
||||
pipeline_nodes: [],
|
||||
detected_signal: null,
|
||||
run_id: null,
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
cwd: "/workspace/c",
|
||||
title: "Lane C",
|
||||
pipeline_name: "default",
|
||||
pipeline_nodes: [],
|
||||
detected_signal: null,
|
||||
run_id: null,
|
||||
},
|
||||
];
|
||||
|
||||
vi.mock("../../lib/api", async (importOriginal) => {
|
||||
const actual = await importOriginal<Record<string, unknown>>();
|
||||
const recordCall = (method: string, path: string, payload?: unknown) => {
|
||||
recordedCalls.push({ method, path, payload });
|
||||
};
|
||||
return {
|
||||
...actual,
|
||||
api: {
|
||||
lanes: {
|
||||
list: vi.fn().mockImplementation(async () => {
|
||||
recordCall("GET", "/api/lanes");
|
||||
return {
|
||||
lanes: lanesToReturn,
|
||||
counts: { total: lanesToReturn.length, running: 0, needs_you: 0, dead: 0 },
|
||||
};
|
||||
}),
|
||||
ensure: vi.fn(),
|
||||
action: vi.fn(),
|
||||
git: vi.fn().mockImplementation(async (id: number) => {
|
||||
recordCall("GET", `/api/lanes/${id}/git`);
|
||||
return { available: false };
|
||||
}),
|
||||
runtime: vi.fn().mockImplementation(async (id: number) => {
|
||||
recordCall("GET", `/api/lanes/${id}/runtime`);
|
||||
return { available: false };
|
||||
}),
|
||||
stage: vi.fn(),
|
||||
pipelines: vi.fn().mockImplementation(async () => {
|
||||
recordCall("GET", "/api/lanes/pipelines");
|
||||
return { pipelines: [{ id: "default", name: "default", nodes: [] }] };
|
||||
}),
|
||||
update: vi.fn().mockImplementation(async (id: number, patch: Record<string, unknown>) => {
|
||||
recordCall("PATCH", `/api/lanes/${id}`, patch);
|
||||
const lane = lanesToReturn.find((l) => l.id === id);
|
||||
const updated = { ...lane, ...patch } as LaneFixture;
|
||||
// Update in place for subsequent reads
|
||||
const idx = lanesToReturn.findIndex((l) => l.id === id);
|
||||
if (idx >= 0) lanesToReturn[idx] = updated;
|
||||
return { lane: updated };
|
||||
}),
|
||||
features: {
|
||||
list: vi.fn().mockImplementation(async (id: number) => {
|
||||
recordCall("GET", `/api/lanes/${id}/features`);
|
||||
return { features: [] };
|
||||
}),
|
||||
show: vi.fn(),
|
||||
},
|
||||
proof: {
|
||||
list: vi.fn().mockImplementation(async (id: number) => {
|
||||
recordCall("GET", `/api/lanes/${id}/proof`);
|
||||
return { features: [] };
|
||||
}),
|
||||
imageUrl: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
},
|
||||
},
|
||||
aiRouters: {
|
||||
list: vi.fn().mockImplementation(async () => {
|
||||
recordCall("GET", "/api/ai-routers");
|
||||
return { items: [] };
|
||||
}),
|
||||
},
|
||||
run: {
|
||||
list: vi.fn().mockImplementation(async () => {
|
||||
recordCall("GET", "/api/run/list");
|
||||
return { runs: [], items: [] };
|
||||
}),
|
||||
history: vi.fn().mockResolvedValue({ items: [] }),
|
||||
binary: vi.fn().mockResolvedValue({ found: true, path: "/usr/bin/claude" }),
|
||||
cwds: vi.fn().mockResolvedValue({ items: [] }),
|
||||
files: vi.fn().mockResolvedValue({ items: [] }),
|
||||
start: vi.fn(),
|
||||
get: vi.fn(),
|
||||
send: vi.fn(),
|
||||
kill: vi.fn(),
|
||||
},
|
||||
ccConfig: {
|
||||
commands: vi.fn().mockResolvedValue({ items: [] }),
|
||||
plugins: vi.fn().mockResolvedValue({ plugins: [] }),
|
||||
file: vi.fn().mockResolvedValue({ text: "", content: "" }),
|
||||
},
|
||||
sessions: {
|
||||
list: vi.fn().mockResolvedValue({ sessions: [], total: 0, limit: 50, offset: 0 }),
|
||||
transcript: vi.fn().mockResolvedValue({ messages: [] }),
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("../../lib/eventBus", () => ({
|
||||
eventBus: {
|
||||
subscribe: () => () => {},
|
||||
publish: () => {},
|
||||
onConnection: () => () => {},
|
||||
connected: true,
|
||||
setConnected: () => {},
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("../../components/run/TerminalView", () => ({
|
||||
TerminalView: ({ runId }: { runId: string }) => (
|
||||
<div data-testid="terminal-view" data-run-id={runId} />
|
||||
),
|
||||
}));
|
||||
|
||||
import { Workspace } from "../Workspace";
|
||||
|
||||
class ObserverStub {
|
||||
observe() {}
|
||||
unobserve() {}
|
||||
disconnect() {}
|
||||
takeRecords() {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof globalThis !== "undefined") {
|
||||
globalThis.ResizeObserver =
|
||||
globalThis.ResizeObserver || (ObserverStub as unknown as typeof ResizeObserver);
|
||||
if (typeof Element !== "undefined") {
|
||||
for (const fn of ["scrollIntoView", "scrollBy", "scrollTo"] as const) {
|
||||
if (!(Element.prototype as unknown as Record<string, unknown>)[fn]) {
|
||||
(Element.prototype as unknown as Record<string, unknown>)[fn] = function () {};
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function settle() {
|
||||
await act(async () => {
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
});
|
||||
}
|
||||
|
||||
async function renderWorkspace() {
|
||||
const utils = render(
|
||||
<MemoryRouter initialEntries={["/run"]}>
|
||||
<Workspace />
|
||||
</MemoryRouter>
|
||||
);
|
||||
await settle();
|
||||
await settle();
|
||||
await settle();
|
||||
return utils;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
recordedCalls.length = 0;
|
||||
lanesToReturn = [
|
||||
{
|
||||
id: 1,
|
||||
cwd: "/workspace/a",
|
||||
title: "Lane A",
|
||||
pipeline_name: "default",
|
||||
pipeline_nodes: [],
|
||||
detected_signal: null,
|
||||
run_id: null,
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
cwd: "/workspace/b",
|
||||
title: "Lane B",
|
||||
pipeline_name: "default",
|
||||
pipeline_nodes: [],
|
||||
detected_signal: null,
|
||||
run_id: null,
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
cwd: "/workspace/c",
|
||||
title: "Lane C",
|
||||
pipeline_name: "default",
|
||||
pipeline_nodes: [],
|
||||
detected_signal: null,
|
||||
run_id: null,
|
||||
},
|
||||
];
|
||||
localStorage.clear();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
recordedCalls.length = 0;
|
||||
localStorage.clear();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("Workspace — lane reordering", () => {
|
||||
it("persists lane order to localStorage when dragging a tile", async () => {
|
||||
await renderWorkspace();
|
||||
|
||||
// Verify initial order is A, B, C (strip includes "Add lane" prefix and ids)
|
||||
expect(screen.getByTestId("lane-strip").textContent).toMatch(/Lane A.*Lane B.*Lane C/);
|
||||
|
||||
// Drag tile 3 (Lane C) to position 0
|
||||
const tile1 = screen.getByTestId("lane-tile-1");
|
||||
const tile3 = screen.getByTestId("lane-tile-3");
|
||||
|
||||
fireEvent.dragStart(tile3, { dataTransfer: { setData: vi.fn(), effectAllowed: "" } });
|
||||
fireEvent.dragOver(tile1, { dataTransfer: { dropEffect: "", preventDefault: () => {} } });
|
||||
fireEvent.drop(tile1, {
|
||||
dataTransfer: { getData: vi.fn(() => "2"), preventDefault: () => {} },
|
||||
});
|
||||
|
||||
await settle();
|
||||
|
||||
// Check localStorage has the new order
|
||||
const stored = localStorage.getItem("agenthub.workspace.laneOrder");
|
||||
expect(stored).toBeDefined();
|
||||
const order = JSON.parse(stored!);
|
||||
expect(order).toEqual([3, 1, 2]);
|
||||
});
|
||||
|
||||
it("applies stored order when lanes are fetched", async () => {
|
||||
// Pre-populate localStorage with a custom order
|
||||
localStorage.setItem("agenthub.workspace.laneOrder", JSON.stringify([2, 3, 1]));
|
||||
|
||||
await renderWorkspace();
|
||||
|
||||
// The lane strip should reflect the stored order: B, C, A
|
||||
const strip = screen.getByTestId("lane-strip");
|
||||
const tiles = Array.from(strip.querySelectorAll("[data-testid^='lane-tile-']"));
|
||||
const laneIds = tiles
|
||||
.map((el) => el.getAttribute("data-testid"))
|
||||
.filter((id) => id && id.match(/lane-tile-\d+/))
|
||||
.map((id) => id!.replace("lane-tile-", ""));
|
||||
|
||||
expect(laneIds).toEqual(["2", "3", "1"]);
|
||||
});
|
||||
|
||||
it("appends new lanes to the end, preserving user order", async () => {
|
||||
// Start with a custom order
|
||||
localStorage.setItem("agenthub.workspace.laneOrder", JSON.stringify([2, 1]));
|
||||
|
||||
await renderWorkspace();
|
||||
let strip = screen.getByTestId("lane-strip");
|
||||
let tiles = Array.from(strip.querySelectorAll("[data-testid^='lane-tile-']"));
|
||||
let laneIds = tiles
|
||||
.map((el) => el.getAttribute("data-testid"))
|
||||
.filter((id) => id && id.match(/lane-tile-\d+/))
|
||||
.map((id) => id!.replace("lane-tile-", ""));
|
||||
expect(laneIds).toEqual(["2", "1", "3"]); // 3 is new, appended at end
|
||||
});
|
||||
|
||||
it("ignores stored ids for lanes that no longer exist", async () => {
|
||||
// Store an order that includes a lane ID that will be removed
|
||||
localStorage.setItem("agenthub.workspace.laneOrder", JSON.stringify([2, 999, 1]));
|
||||
|
||||
// Remove lane 2 from the server response
|
||||
lanesToReturn = lanesToReturn.filter((l) => l.id !== 2);
|
||||
|
||||
await renderWorkspace();
|
||||
|
||||
// The strip should show lane 1 and 3 in stored order, skipping 999
|
||||
const strip = screen.getByTestId("lane-strip");
|
||||
const tiles = Array.from(strip.querySelectorAll("[data-testid^='lane-tile-']"));
|
||||
const laneIds = tiles
|
||||
.map((el) => el.getAttribute("data-testid"))
|
||||
.filter((id) => id && id.match(/lane-tile-\d+/))
|
||||
.map((id) => id!.replace("lane-tile-", ""));
|
||||
|
||||
// Expected: skip 999 and 2, show 1, then 3 (the remaining fresh lane)
|
||||
expect(laneIds).toEqual(["1", "3"]);
|
||||
});
|
||||
|
||||
it("reorders with Alt+Arrow keyboard shortcut", async () => {
|
||||
await renderWorkspace();
|
||||
|
||||
// Get tile 2 (Lane B) and move it right to position 2
|
||||
const tile2 = screen.getByTestId("lane-tile-2");
|
||||
fireEvent.keyDown(tile2, { key: "ArrowRight", altKey: true });
|
||||
|
||||
await settle();
|
||||
|
||||
// Check localStorage
|
||||
const stored = localStorage.getItem("agenthub.workspace.laneOrder");
|
||||
const order = JSON.parse(stored!);
|
||||
// Tile 2 was at index 1, moving right puts it at index 2
|
||||
expect(order).toEqual([1, 3, 2]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Workspace — lane rename", () => {
|
||||
it("enters rename mode when the pencil icon is clicked", async () => {
|
||||
const user = userEvent.setup();
|
||||
await renderWorkspace();
|
||||
|
||||
// Click the first lane to select it (so its detail panel shows)
|
||||
const tile1 = screen.getByTestId("lane-tile-1");
|
||||
await user.click(tile1);
|
||||
await settle();
|
||||
|
||||
// Find the rename button (displays the lane title with a pencil icon)
|
||||
const renameButton = screen.getByTestId("lane-rename");
|
||||
await user.click(renameButton);
|
||||
await settle();
|
||||
|
||||
// The input should appear with the current title
|
||||
const input = screen.getByTestId("lane-rename-input") as HTMLInputElement;
|
||||
expect(input).toBeInTheDocument();
|
||||
expect(input.value).toBe("Lane A");
|
||||
});
|
||||
|
||||
it("commits a rename when Enter is pressed", async () => {
|
||||
const user = userEvent.setup();
|
||||
await renderWorkspace();
|
||||
|
||||
// Select lane 1 and open rename
|
||||
const tile1 = screen.getByTestId("lane-tile-1");
|
||||
await user.click(tile1);
|
||||
await settle();
|
||||
|
||||
const renameButton = screen.getByTestId("lane-rename");
|
||||
await user.click(renameButton);
|
||||
await settle();
|
||||
|
||||
const input = screen.getByTestId("lane-rename-input") as HTMLInputElement;
|
||||
await user.clear(input);
|
||||
await user.type(input, "New Lane Name");
|
||||
fireEvent.keyDown(input, { key: "Enter" });
|
||||
await settle();
|
||||
|
||||
// Should have called update with trimmed title
|
||||
await waitFor(() => {
|
||||
const updateCall = recordedCalls.find(
|
||||
(c) => c.path === "/api/lanes/1" && c.method === "PATCH"
|
||||
);
|
||||
expect(updateCall).toBeDefined();
|
||||
expect(updateCall?.payload).toEqual({ title: "New Lane Name" });
|
||||
});
|
||||
|
||||
// Input should be gone, button should be back
|
||||
expect(screen.queryByTestId("lane-rename-input")).toBeNull();
|
||||
expect(screen.getByTestId("lane-rename")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("cancels rename when Escape is pressed", async () => {
|
||||
const user = userEvent.setup();
|
||||
await renderWorkspace();
|
||||
|
||||
const tile1 = screen.getByTestId("lane-tile-1");
|
||||
await user.click(tile1);
|
||||
await settle();
|
||||
|
||||
const renameButton = screen.getByTestId("lane-rename");
|
||||
await user.click(renameButton);
|
||||
await settle();
|
||||
|
||||
const input = screen.getByTestId("lane-rename-input") as HTMLInputElement;
|
||||
await user.type(input, "should not save");
|
||||
fireEvent.keyDown(input, { key: "Escape" });
|
||||
await settle();
|
||||
|
||||
// Input should be gone
|
||||
expect(screen.queryByTestId("lane-rename-input")).toBeNull();
|
||||
// No API call should have been made
|
||||
const updateCalls = recordedCalls.filter(
|
||||
(c) => c.path === "/api/lanes/1" && c.method === "PATCH"
|
||||
);
|
||||
expect(updateCalls).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("exits rename mode when focus leaves (blur/tab away), attempting to commit", async () => {
|
||||
const user = userEvent.setup();
|
||||
await renderWorkspace();
|
||||
|
||||
const tile1 = screen.getByTestId("lane-tile-1");
|
||||
await user.click(tile1);
|
||||
await settle();
|
||||
|
||||
const renameButton = screen.getByTestId("lane-rename");
|
||||
await user.click(renameButton);
|
||||
await settle();
|
||||
|
||||
const input = screen.getByTestId("lane-rename-input") as HTMLInputElement;
|
||||
expect(input).toBeInTheDocument();
|
||||
|
||||
// Move focus away (tab)
|
||||
await user.tab();
|
||||
await settle();
|
||||
|
||||
// Input should be gone (rename mode exited)
|
||||
expect(screen.queryByTestId("lane-rename-input")).toBeNull();
|
||||
// The rename button should be back
|
||||
expect(screen.getByTestId("lane-rename")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("trims whitespace from the renamed title", async () => {
|
||||
const user = userEvent.setup();
|
||||
await renderWorkspace();
|
||||
|
||||
const tile1 = screen.getByTestId("lane-tile-1");
|
||||
await user.click(tile1);
|
||||
await settle();
|
||||
|
||||
const renameButton = screen.getByTestId("lane-rename");
|
||||
await user.click(renameButton);
|
||||
await settle();
|
||||
|
||||
const input = screen.getByTestId("lane-rename-input") as HTMLInputElement;
|
||||
await user.clear(input);
|
||||
await user.type(input, " Trimmed ");
|
||||
fireEvent.keyDown(input, { key: "Enter" });
|
||||
await settle();
|
||||
|
||||
// Should send trimmed title
|
||||
await waitFor(() => {
|
||||
const updateCall = recordedCalls.find(
|
||||
(c) => c.path === "/api/lanes/1" && c.method === "PATCH"
|
||||
);
|
||||
expect(updateCall?.payload).toEqual({ title: "Trimmed" });
|
||||
});
|
||||
});
|
||||
|
||||
it("clears the title (puts cwd in its place) when empty name is submitted", async () => {
|
||||
const user = userEvent.setup();
|
||||
await renderWorkspace();
|
||||
|
||||
const tile1 = screen.getByTestId("lane-tile-1");
|
||||
await user.click(tile1);
|
||||
await settle();
|
||||
|
||||
const renameButton = screen.getByTestId("lane-rename");
|
||||
await user.click(renameButton);
|
||||
await settle();
|
||||
|
||||
const input = screen.getByTestId("lane-rename-input") as HTMLInputElement;
|
||||
await user.clear(input);
|
||||
// Leave it empty (or with just whitespace)
|
||||
await user.type(input, " ");
|
||||
fireEvent.keyDown(input, { key: "Enter" });
|
||||
await settle();
|
||||
|
||||
// Should send empty string
|
||||
await waitFor(() => {
|
||||
const updateCall = recordedCalls.find(
|
||||
(c) => c.path === "/api/lanes/1" && c.method === "PATCH"
|
||||
);
|
||||
expect(updateCall?.payload).toEqual({ title: "" });
|
||||
});
|
||||
});
|
||||
|
||||
it("does not call the API if the title has not changed", async () => {
|
||||
const user = userEvent.setup();
|
||||
await renderWorkspace();
|
||||
|
||||
const tile1 = screen.getByTestId("lane-tile-1");
|
||||
await user.click(tile1);
|
||||
await settle();
|
||||
|
||||
const renameButton = screen.getByTestId("lane-rename");
|
||||
await user.click(renameButton);
|
||||
await settle();
|
||||
|
||||
const input = screen.getByTestId("lane-rename-input") as HTMLInputElement;
|
||||
// Don't change the title, just press Enter
|
||||
fireEvent.keyDown(input, { key: "Enter" });
|
||||
await settle();
|
||||
|
||||
// Should NOT have called update (or at least not with a changed value)
|
||||
const updateCalls = recordedCalls.filter(
|
||||
(c) => c.path === "/api/lanes/1" && c.method === "PATCH"
|
||||
);
|
||||
expect(updateCalls).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("allows the user to retry after an error, starting fresh", async () => {
|
||||
const user = userEvent.setup();
|
||||
await renderWorkspace();
|
||||
|
||||
const tile1 = screen.getByTestId("lane-tile-1");
|
||||
await user.click(tile1);
|
||||
await settle();
|
||||
|
||||
// Enter and exit rename mode twice to verify the state resets
|
||||
const renameButton = screen.getByTestId("lane-rename");
|
||||
await user.click(renameButton);
|
||||
await settle();
|
||||
|
||||
let input = screen.getByTestId("lane-rename-input") as HTMLInputElement;
|
||||
expect(input.value).toBe("Lane A");
|
||||
|
||||
fireEvent.keyDown(input, { key: "Escape" });
|
||||
await settle();
|
||||
|
||||
// Enter again to verify we can re-enter
|
||||
await user.click(screen.getByTestId("lane-rename"));
|
||||
await settle();
|
||||
input = screen.getByTestId("lane-rename-input") as HTMLInputElement;
|
||||
expect(input.value).toBe("Lane A");
|
||||
});
|
||||
|
||||
it("respects max length of 200 characters (enforced by server)", async () => {
|
||||
const user = userEvent.setup();
|
||||
await renderWorkspace();
|
||||
|
||||
const tile1 = screen.getByTestId("lane-tile-1");
|
||||
await user.click(tile1);
|
||||
await settle();
|
||||
|
||||
const renameButton = screen.getByTestId("lane-rename");
|
||||
await user.click(renameButton);
|
||||
await settle();
|
||||
|
||||
const input = screen.getByTestId("lane-rename-input") as HTMLInputElement;
|
||||
// HTML input has maxLength=200, so typing more should be capped
|
||||
expect(input.maxLength).toBe(200);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Workspace — lane order and rename interaction", () => {
|
||||
it("preserves custom order after renaming a lane", async () => {
|
||||
const user = userEvent.setup();
|
||||
// Set custom order
|
||||
localStorage.setItem("agenthub.workspace.laneOrder", JSON.stringify([3, 2, 1]));
|
||||
|
||||
await renderWorkspace();
|
||||
|
||||
// Verify order is C, B, A
|
||||
let strip = screen.getByTestId("lane-strip");
|
||||
let tiles = Array.from(strip.querySelectorAll("[data-testid^='lane-tile-']"));
|
||||
let laneIds = tiles
|
||||
.map((el) => el.getAttribute("data-testid"))
|
||||
.filter((id) => id && id.match(/lane-tile-\d+/))
|
||||
.map((id) => id!.replace("lane-tile-", ""));
|
||||
expect(laneIds).toEqual(["3", "2", "1"]);
|
||||
|
||||
// Click lane 2 (which is now at position 1)
|
||||
const tile2 = screen.getByTestId("lane-tile-2");
|
||||
await user.click(tile2);
|
||||
await settle();
|
||||
|
||||
// Rename it
|
||||
const renameButton = screen.getByTestId("lane-rename");
|
||||
await user.click(renameButton);
|
||||
await settle();
|
||||
|
||||
const input = screen.getByTestId("lane-rename-input") as HTMLInputElement;
|
||||
await user.clear(input);
|
||||
await user.type(input, "Renamed B");
|
||||
fireEvent.keyDown(input, { key: "Enter" });
|
||||
await settle();
|
||||
|
||||
// Wait for the refresh
|
||||
await waitFor(() => {
|
||||
const updateCall = recordedCalls.find(
|
||||
(c) => c.path === "/api/lanes/2" && c.method === "PATCH"
|
||||
);
|
||||
expect(updateCall).toBeDefined();
|
||||
});
|
||||
|
||||
// Order should still be preserved
|
||||
const stored = localStorage.getItem("agenthub.workspace.laneOrder");
|
||||
const order = JSON.parse(stored!);
|
||||
expect(order).toEqual([3, 2, 1]);
|
||||
});
|
||||
});
|
||||
@@ -11,7 +11,7 @@
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, act, screen, waitFor } from "@testing-library/react";
|
||||
import { MemoryRouter } from "react-router-dom";
|
||||
import { MemoryRouter, useSearchParams } from "react-router-dom";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
|
||||
type LaneFixture = {
|
||||
@@ -179,9 +179,24 @@ beforeEach(() => {
|
||||
h.lanes = lanesToReturn as unknown as typeof h.lanes;
|
||||
});
|
||||
|
||||
/** Stands in for the sidebar's project tree: picking a workspace sets `?lane=`. */
|
||||
function Picker() {
|
||||
const [, setParams] = useSearchParams();
|
||||
return (
|
||||
<>
|
||||
{lanesToReturn.map((l) => (
|
||||
<button key={l.id} onClick={() => setParams({ lane: String(l.id) })}>
|
||||
{`pick ${l.title}`}
|
||||
</button>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
async function renderWorkspace() {
|
||||
const utils = render(
|
||||
<MemoryRouter initialEntries={["/run"]}>
|
||||
<Picker />
|
||||
<Workspace />
|
||||
</MemoryRouter>
|
||||
);
|
||||
@@ -191,11 +206,9 @@ async function renderWorkspace() {
|
||||
return utils;
|
||||
}
|
||||
|
||||
/** Click the strip tile for a lane, which is how a lane is selected. */
|
||||
/** Pick a lane the way the sidebar does: by naming it in the URL. */
|
||||
async function selectLane(user: ReturnType<typeof userEvent.setup>, title: string) {
|
||||
const tile = screen.getAllByText(title)[0];
|
||||
if (!tile) throw new Error(`no strip tile for ${title}`);
|
||||
await user.click(tile);
|
||||
await user.click(screen.getByRole("button", { name: `pick ${title}` }));
|
||||
await settle();
|
||||
}
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { render, act, screen, waitFor, fireEvent } from "@testing-library/react";
|
||||
import { MemoryRouter } from "react-router-dom";
|
||||
import { MemoryRouter, useSearchParams } from "react-router-dom";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
|
||||
// Track all API calls made by recording the method and endpoint
|
||||
@@ -284,9 +284,27 @@ async function settle() {
|
||||
});
|
||||
}
|
||||
|
||||
/** Stands in for the sidebar's project tree: picking a workspace sets `?lane=`. */
|
||||
function Picker() {
|
||||
const [params, setParams] = useSearchParams();
|
||||
return (
|
||||
<>
|
||||
<span data-testid="lane-param">{params.get("lane")}</span>
|
||||
{[1, 2].map((id) => (
|
||||
<button
|
||||
key={id}
|
||||
data-testid={`pick-${id}`}
|
||||
onClick={() => setParams({ lane: String(id) })}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
async function renderWorkspace() {
|
||||
const utils = render(
|
||||
<MemoryRouter initialEntries={["/run"]}>
|
||||
<Picker />
|
||||
<Workspace />
|
||||
</MemoryRouter>
|
||||
);
|
||||
@@ -329,11 +347,12 @@ afterEach(() => {
|
||||
});
|
||||
|
||||
describe("Workspace page — lane integration", () => {
|
||||
it("lane strip lists the lanes the API returned", async () => {
|
||||
it("has no lane strip: it opens the first workspace and names it in the URL", async () => {
|
||||
await renderWorkspace();
|
||||
|
||||
expect(screen.getByTestId("lane-tile-1").textContent).toContain("Lane A");
|
||||
expect(screen.getByTestId("lane-tile-2").textContent).toContain("Lane B");
|
||||
expect(screen.queryByTestId("lane-strip")).toBeNull();
|
||||
expect(screen.getByTestId("lane-card-1")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("lane-param").textContent).toBe("1");
|
||||
});
|
||||
|
||||
it("selecting a lane switches the pipeline and run id", async () => {
|
||||
@@ -345,20 +364,16 @@ describe("Workspace page — lane integration", () => {
|
||||
await renderWorkspace();
|
||||
const user = userEvent.setup();
|
||||
|
||||
// Selection is announced on the tile itself, not inferred from styling.
|
||||
const laneATile = screen.getByTestId("lane-tile-1");
|
||||
const laneBTile = screen.getByTestId("lane-tile-2");
|
||||
expect(laneATile.getAttribute("aria-pressed")).toBe("true");
|
||||
expect(laneBTile.getAttribute("aria-pressed")).toBe("false");
|
||||
expect(screen.getByTestId("lane-card-1")).toBeInTheDocument();
|
||||
|
||||
await user.click(laneBTile);
|
||||
// The sidebar names the workspace in the URL; the page follows it.
|
||||
await user.click(screen.getByTestId("pick-2"));
|
||||
await settle();
|
||||
|
||||
expect(laneBTile.getAttribute("aria-pressed")).toBe("true");
|
||||
expect(laneATile.getAttribute("aria-pressed")).toBe("false");
|
||||
// And the detail panel follows: only the selected lane gets a full card.
|
||||
// Only the selected lane gets a full card.
|
||||
expect(screen.getByTestId("lane-card-2")).toBeInTheDocument();
|
||||
expect(screen.queryByTestId("lane-card-1")).toBeNull();
|
||||
expect(screen.queryByTestId("lane-card-1")).toBeNull();
|
||||
});
|
||||
|
||||
it("starting a run requests /api/lanes/<id>/start and not /api/run/start", async () => {
|
||||
@@ -490,27 +505,17 @@ describe("Workspace page — lane integration", () => {
|
||||
});
|
||||
|
||||
describe("Workspace layout", () => {
|
||||
it("opens the add-lane modal from the strip's first tile", async () => {
|
||||
it("opens the add-workspace modal from the empty banner when there is none", async () => {
|
||||
const user = userEvent.setup();
|
||||
lanesToReturn = [];
|
||||
await renderWorkspace();
|
||||
|
||||
// The tile leads the strip, so it is reachable without scrolling past lanes.
|
||||
const strip = screen.getByTestId("lane-strip");
|
||||
expect(strip.firstElementChild).toBe(screen.getByTestId("lane-tile-add"));
|
||||
|
||||
await user.click(screen.getByTestId("lane-tile-add"));
|
||||
await user.click(screen.getByTestId("lane-empty"));
|
||||
expect(
|
||||
await screen.findByText("Create a workspace from a working directory")
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("gives every lane a tile in the carousel", async () => {
|
||||
await renderWorkspace();
|
||||
expect(screen.getByTestId("lane-strip")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("lane-tile-1")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("lane-tile-2")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows the full card only for the selected lane, in the detail panel", async () => {
|
||||
await renderWorkspace();
|
||||
// Lane 1 is selected by default. Its full card — controls, git facts — is
|
||||
@@ -555,7 +560,6 @@ describe("Workspace — the console is its own section", () => {
|
||||
// The console is a window onto a process, not a property of a card. Nesting
|
||||
// it in one made the owning card span the row and left a hole when closed.
|
||||
expect(screen.getByTestId("lane-card-1").contains(body)).toBe(false);
|
||||
expect(screen.getByTestId("lane-strip").contains(body)).toBe(false);
|
||||
});
|
||||
|
||||
it("has no header line of its own - the pipeline panel already names the lane", async () => {
|
||||
|
||||
+28
@@ -1120,6 +1120,34 @@ POST /api/locks/:name/release
|
||||
- **409** `{ "error": { "code": "ENOTHOLDER", "message": "...", "currentHolder": "lane1" } }` — `holder` doesn't match the current owner; a release never trusts its caller, same rule every other destructive path in this project follows.
|
||||
- **404** `{ "error": { "code": "ENOLOCK", "message": "no such lock: build" } }` — nothing by that name is held.
|
||||
|
||||
#### Projects (workspaces grouped by repository)
|
||||
|
||||
```http
|
||||
GET /api/projects
|
||||
POST /api/projects/refresh
|
||||
```
|
||||
|
||||
Every workspace grouped under the git repository it belongs to, for the sidebar's project tree. A project's `root` is the repo's MAIN worktree top level (`git rev-parse --git-common-dir`, realpath); workspaces outside any repo form the `root: null` pseudo-project, listed last. `worktrees` is `git worktree list` for that repo (a worktree whose directory is gone is left out); `workspace_id` is the lane sitting exactly on that path, else `null` — an **untracked worktree** the UI offers to adopt. A lane on a subdirectory of a worktree is in `workspaces` but marks no worktree. `source` is `"claude"` under `<root>/.claude/worktrees/`, `"agenthub"` under `LANES_ROOT`, else absent.
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"projects": [
|
||||
{
|
||||
"root": "/home/u/agenthub",
|
||||
"name": "agenthub",
|
||||
"workspaces": [12, 15],
|
||||
"worktrees": [
|
||||
{ "path": "/home/u/agenthub", "branch": "main", "main": true, "workspace_id": 12 },
|
||||
{ "path": "/home/u/agenthub/.claude/worktrees/wf-1", "branch": "wf-1", "main": false, "workspace_id": null, "source": "claude" }
|
||||
]
|
||||
},
|
||||
{ "root": null, "name": "Folders", "workspaces": [3], "worktrees": [] }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Both git reads are cached in memory — a directory's root for the life of the server, a repo's worktree list for 30 s — and the lane ↔ worktree match is recomputed on every call, so a new or removed workspace needs no invalidation. This route is read on demand (the client re-reads it when the set of workspaces changes), never on the polled `GET /api/lanes`. `POST /api/projects/refresh` drops both caches first and answers the same shape; it is same-origin only (`403 EBADORIGIN`).
|
||||
|
||||
### Sessions
|
||||
|
||||
#### List Sessions
|
||||
|
||||
+24
-13
@@ -389,16 +389,18 @@ A missing source `.env` falls back to `.env.example` with a loud warning, never
|
||||
|
||||
The dashboard web UI merges workspaces and runs into a single **Workspace** page accessible at `/run` (the old `/lanes` route now redirects here). Top to bottom:
|
||||
|
||||
- **No page header.** The page title and the four counters (`lanes`, `running`, `needs you`, `dead`) are gone: the workspace list below already shows every workspace with its own liveness dot and its ⚠ marker, so the counters restated it a row higher and cost height on a screen whose point is the terminal. `GET /api/lanes` still returns `counts` for other callers.
|
||||
- **Workspace list** — the horizontally scrolling row of workspace tiles. Its FIRST tile is **Add workspace** (dashed border, no liveness dot), not a header button: creating a workspace is the same kind of act as picking one, so it reads as the empty slot at the head of the row. A tile shows the workspace's id as a bare `#23` — not "Workspace 23", which cost a third of the row's width and said nothing, though the number itself stays because that is how the CLI addresses a workspace.
|
||||
- **No workspaces yet** — the strip is replaced by a single banner (`lane-empty`): icon, one line of what a workspace is, the CLI equivalent (`agenthub lanes add --cwd $(pwd)`) and an **Add workspace** affordance. The whole banner IS the button — clicking anywhere on it opens the Add-workspace modal, so there is no separate button to hit. The strip itself — the dashed add tile plus the workspace tiles — only renders once at least one workspace exists, because an add tile beside a stray sentence read as a half-drawn row. The banner stays compact on purpose: the run setup form below it is what needs the height.
|
||||
- **Reordering the strip** — drag a tile onto another to move it there, or press **Alt+←/→** with a tile focused (the keyboard path; HTML5 drag-and-drop has no touch support and does not auto-scroll an overflowing strip). The order is a **per-viewer preference**, not a workspace fact: it persists to `localStorage` (`agenthub.workspace.laneOrder`) exactly like the split-view layout beside it, and writes nothing server-side — `GET /api/lanes` and `agenthub lanes` keep answering in id order, and the order does not follow you to another browser. A workspace the stored order has never seen (created since, or created in another tab) lands at the END rather than wherever a stale index would put it; a stored id whose workspace is gone is skipped.
|
||||
- **Renaming a workspace** — the detail header's name is a button: click it (a pencil appears on hover) to edit in place. **Enter** saves via `PATCH /api/lanes/:id` with `{title}`, **Escape** and clicking away both cancel, and saving an empty name clears the title, which puts the workspace's `cwd` back in its place. Renaming is display-only — a managed workspace's branch name was slugified from its title once, at creation.
|
||||
- **Workspace-detail header row** — one row, and every per-workspace control lives on it: liveness + status, the workspace's name (no "Workspace 23 ·" prefix — the id is on the strip tile), time-on-stage, the pipeline picker, the Model (AI Router) select, the feature picker (time-on-stage and both pickers only while a pipeline template is installed; there are no stage chips here — the declared stage and the `auto: <stage>` detection both render in the PipelineMap directly below, so the chips were a second, smaller copy of the same two facts), and on the right the **Tools** menu (mcp sync / sync-base check / create-update readme, hidden when the workspace declares no runtime profile), the info toggle, the split-view toggle and Remove. Tools sits here rather than in Settings because every one of its actions runs against one workspace's `cwd` — in Settings it would need a workspace picker just to say which. **Create/update readme file** (Claude workspaces only — Codex has no slash commands) starts the workspace's terminal run with `/sg-all:sg-md-writing` as its first message, so the workspace opens already writing/refreshing its `CLAUDE.md`/`AGENTS.md` set; it needs the `sg-all` plugin installed in Claude Code, and without it the session just reports an unknown command. It used to be a checkbox in the Add-workspace modal: creating a workspace and writing its docs are separate decisions, and the second is worth re-running long after the first.
|
||||
- **No page header.** The page title and the four counters (`lanes`, `running`, `needs you`, `dead`) are gone: the sidebar's project tree shows every workspace with its own status dot, and the status bar at the bottom of every page counts running and need-you workspaces. `GET /api/lanes` still returns `counts` for other callers.
|
||||
- **The sidebar's project tree picks the workspace** — on every page, between the nav and the footer (`client/src/components/ProjectTree.tsx`). Workspaces are grouped under **Projects**: one per git repository, keyed by the repo's MAIN worktree root, with every workspace outside a repo under **Folders** (last). The grouping comes from `GET /api/projects` (see `docs/API.md`), read when the set of workspaces changes or on the header's Refresh — never on every status frame, so it shells out to git only then, and caches what it read. A row (`WorkspaceRow.tsx`) shows a status dot (green = active, pulsing while running; amber row = needs you; red = dead or error; grey = idle), the title or folder name, a `primary` badge on the repo's own root, the branch (or the path relative to the repo), the tool's icon and the id as `#23` — the number the CLI addresses it by. Clicking a row opens `/workspace?lane=<id>`: the URL is the selection, so a notification link, F5 and the back button all land on the same workspace, and a bare `/workspace` reopens the last one shown (`agenthub.workspace.selected`) or the first. The project's `+` opens the Add-workspace modal prefilled with that repo; the PROJECTS header has Refresh and a plain `+`. In the collapsed rail the tree is a single icon that opens the sidebar.
|
||||
- **Row menu** — right-click a row, or its `⋯` button: Start/Stop (slot 0), Rename, the Model (AI Router) select, Create/update readme file (Claude only), New workspace here…, Copy path, Reset worktree… (managed only) and Delete workspace…, each the call the rest of the UI already makes. Delete is record-only exactly as before (see "Removing a workspace").
|
||||
- **Untracked worktrees** — a repo's worktrees that no workspace sits on (from `git worktree list`) are listed dimmed under the project, with **Adopt** (`POST /api/lanes/ensure {exact: true}` with the default AI tool, then selected). Shown by default; the tree's preferences (`agenthub.sidebar.projects` in `localStorage`: collapsed projects, `showUntracked`, hidden sources `claude` = `<repo>/.claude/worktrees/*` and `agenthub` = `LANES_ROOT`) can hide them, and the project row then says "N hidden" — a click shows them for this visit.
|
||||
- **No workspaces yet** — the page shows a single banner (`lane-empty`): icon, one line of what a workspace is, the CLI equivalent (`agenthub lanes add --cwd $(pwd)`) and an **Add workspace** affordance. The whole banner IS the button — clicking anywhere on it opens the Add-workspace modal. The banner stays compact on purpose: the run setup form below it is what needs the height.
|
||||
- **Reordering** — drag a row onto another within its project, or press **Alt+↑/↓** with a row focused. The order is a **per-viewer preference**, not a workspace fact: it persists to `localStorage` (`agenthub.workspace.laneOrder`) and writes nothing server-side — `GET /api/lanes` and `agenthub lanes` keep answering in id order, and the order does not follow you to another browser. A workspace the stored order has never seen lands at the END of its project; a stored id whose workspace is gone is skipped.
|
||||
- **Renaming a workspace** — double-click its sidebar row (or Rename in the row menu) to edit in place. **Enter** saves via `PATCH /api/lanes/:id` with `{title}`, **Escape** and clicking away both cancel, and saving an empty name clears the title, which puts the folder name back in its place. Renaming is display-only — a managed workspace's branch name was slugified from its title once, at creation.
|
||||
- **Workspace-detail header row** — one row, and every per-workspace control lives on it: liveness + status, the workspace's name (read-only here — renaming is on the sidebar row; no "Workspace 23 ·" prefix — the id is on the row), time-on-stage, the pipeline picker, the Model (AI Router) select, the feature picker (time-on-stage and both pickers only while a pipeline template is installed; there are no stage chips here — the declared stage and the `auto: <stage>` detection both render in the PipelineMap directly below, so the chips were a second, smaller copy of the same two facts), and on the right the **Tools** menu (mcp sync / sync-base check / create-update readme, hidden when the workspace declares no runtime profile) and the split-view toggle. The detail block under it (the workspace card, the PipelineMap, the proof gallery) is always shown — there is no info toggle any more — and Delete lives in the sidebar row menu. Tools sits here rather than in Settings because every one of its actions runs against one workspace's `cwd` — in Settings it would need a workspace picker just to say which. **Create/update readme file** (Claude workspaces only — Codex has no slash commands) starts the workspace's terminal run with `/sg-all:sg-md-writing` as its first message, so the workspace opens already writing/refreshing its `CLAUDE.md`/`AGENTS.md` set; it needs the `sg-all` plugin installed in Claude Code, and without it the session just reports an unknown command. It used to be a checkbox in the Add-workspace modal: creating a workspace and writing its docs are separate decisions, and the second is worth re-running long after the first.
|
||||
- **Detail panel** — the selected workspace's declared stage, its inferred stage when detection leads, a full-width pipeline map, and a legend naming all five node states plus the dashed-amber inferred treatment Hidden while no pipeline template is installed (see "Pipeline stages and the five node states").
|
||||
- **Terminal** — a real interactive terminal (xterm.js) displaying the tmux session's PTY output, with full support for interactive commands, editors, and pagers. A live run keeps its rendered history and scroll position when scrolling. It grabs the keyboard the moment it appears, so typing goes into the pane without clicking it first, and when the pane's process ends — a double Ctrl+C out of `claude`, `exit`, a crash — the console returns to the run setup form on its own, with no "New run" click.
|
||||
- **Clipboard in a pane** — the workspace's tmux session is started with the graphical session's own `DISPLAY`/`WAYLAND_DISPLAY`/`XAUTHORITY`/`XDG_RUNTIME_DIR` (read from `systemctl --user show-environment`, this process's own values winning; `server/lib/desktop-env.js`). The dashboard normally runs as a systemd *user* service, which has none of them, so a pane it started had no clipboard at all and `claude` answered "no image found in clipboard" on every image paste while the same CLI pasted fine in a terminal. Only those four names are forwarded, and only into panes this dashboard spawns — an already-running pane keeps the environment it was started with, so it needs a restart to gain one.
|
||||
- **Split view** — a layout toggle (one pane / two side by side / a 2×2 grid, drawn as icons rather than the words, with the count kept as each button's `aria-label` and tooltip) renders that many terminal panes side by side (`grid-cols-2` for 2, a 2×2 grid for 4). EVERY pane is bound to the workspace list's selection — there is no per-pane workspace picker — and each pane owns its own tmux session on that workspace: pane 0 is the workspace's `agenthub-lane-<id>` (its `run_id`), panes 1–3 are `agenthub-lane-<id>-<slot>` (`POST /api/lanes/:id/start` with `slot`). A pane's **Active runs** leaves out the runs the other panes are showing, so one terminal is never attached twice side by side. Only the layout persists to `localStorage` (`agenthub.workspace.splitView`); a stored per-pane workspace id from an older build is ignored.
|
||||
- **Split view** — a layout toggle (one pane / two side by side / a 2×2 grid, drawn as icons rather than the words, with the count kept as each button's `aria-label` and tooltip) renders that many terminal panes side by side (`grid-cols-2` for 2, a 2×2 grid for 4). EVERY pane is bound to the sidebar's selection — there is no per-pane workspace picker — and each pane owns its own tmux session on that workspace: pane 0 is the workspace's `agenthub-lane-<id>` (its `run_id`), panes 1–3 are `agenthub-lane-<id>-<slot>` (`POST /api/lanes/:id/start` with `slot`). In split view each pane carries a one-line header — tool icon, workspace name, `slot N`, the run's model — and panes 1–3 a close button that kills only that pane's own session (`DELETE /api/run/agenthub-lane-<id>-<slot>`); slot 0 has none. A pane's **Active runs** leaves out the runs the other panes are showing, so one terminal is never attached twice side by side. Only the layout persists to `localStorage` (`agenthub.workspace.splitView`); a stored per-pane workspace id from an older build is ignored.
|
||||
- **Workspace grid** — one card per workspace, 1 column, 2 at `md`, 3 at `xl`. Each card carries the workspace id, liveness dot and status, title, declared stage and time-on-stage, the `auto:` chip when detection leads, the kind and CI tags, the working-copy facts from `GET /api/lanes/:id/git`, and the action row. The needs-you message (`needs_action`) is on the workspace list tile only — the detail card no longer repeats it as a banner.
|
||||
|
||||
Run history is per workspace, queryable via `GET /api/run/history?laneId=<n>`.
|
||||
@@ -615,9 +617,18 @@ which one is "work" changes several times an hour, so the width is a step, not
|
||||
a setting — and one button beats a width button wedged beside a collapse button
|
||||
in the panel's tightest corner. It is icon-only, and the icon it draws is the
|
||||
width the next click LANDS on (even split → wider → closed), with the wording
|
||||
left to its tooltip. It
|
||||
reads the workspace's git surface in five modes (**Files** sits first: it is the
|
||||
only one that answers "show me the code" rather than "show me the change"):
|
||||
left to its tooltip. Beside it, along the panel's top edge, an **activity bar**
|
||||
of three icon buttons picks what the panel shows (`agenthub.workspace.panelTab`):
|
||||
**Files** (the browse mode below, on its own — no git header, no mode
|
||||
switcher), **Changes** (the git header and the other four modes) and
|
||||
**Sessions** — the workspace's sessions (`GET /api/sessions?cwd=<cwd>&tool=<tool>`,
|
||||
newest 50; an exact cwd match, so a session started in a subdirectory is not
|
||||
listed) with tool badge, title, model, relative time and cost, a filter box,
|
||||
**Open** (its detail page) and, on a Claude workspace, **Resume**, which resumes
|
||||
it in the console's slot-0 pane through the same path as a resume from the run
|
||||
history. At Full screen the Files and Changes tabs show the same thing: the
|
||||
explorer sidebar beside the changes. It
|
||||
reads the workspace's git surface in five modes:
|
||||
|
||||
- **Files** — quick-open by path (`/git/ls`) or the repo's tracked file tree
|
||||
(`/git/tree`), which expands folders in place rather than navigating into
|
||||
@@ -1189,9 +1200,9 @@ key back onto its node, so a stage declared by alias keeps its record — and it
|
||||
node has both a canonical record and an alias record, the canonical one wins.
|
||||
|
||||
The same rule governs `agenthub lanes`: the inferred stage is printed only when it
|
||||
leads the declared one (see "Viewing workspaces" below), and `LaneStripCard.tsx`'s
|
||||
`detectionLeads` shows its badge under the identical condition — the terminal
|
||||
and the browser never disagree about which one is the headline. Both locate the
|
||||
leads the declared one (see "Viewing workspaces" below), and the PipelineMap's
|
||||
amber detection badge follows the identical condition — the terminal and the
|
||||
browser never disagree about which one is the headline. Both locate the
|
||||
declaration by the node the server already resolved it onto (the one whose
|
||||
`state` is `current`), never by looking `lane.stage` up as a node id: most of
|
||||
what a skill declares may be an ALIAS, which is not an id, and an id lookup
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
/**
|
||||
* @file Tests for server/lib/projects.js and GET /api/projects: a lane's
|
||||
* project root from a main worktree, a linked worktree, a subdirectory and a
|
||||
* non-repo; grouping lanes per repo with the Folders pseudo-project last; and
|
||||
* matching each worktree to the lane that sits on it.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
const { describe, it, before, after } = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const fs = require("node:fs");
|
||||
const os = require("node:os");
|
||||
const path = require("node:path");
|
||||
const http = require("node:http");
|
||||
const { execFileSync } = require("node:child_process");
|
||||
|
||||
const ROOT = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), "projects-")));
|
||||
process.env.DASHBOARD_DB_PATH = path.join(ROOT, "t.db");
|
||||
process.env.DASHBOARD_REMOTE_SYNC_MS = "0";
|
||||
process.env.DASHBOARD_LIVENESS_PROBE = "0";
|
||||
process.env.GIT_CONFIG_NOSYSTEM = "1";
|
||||
// Inside a git hook (the pre-commit suite run) git exports GIT_DIR,
|
||||
// GIT_INDEX_FILE and friends; the fixture repos below must not inherit them.
|
||||
for (const k of Object.keys(process.env)) {
|
||||
if (k.startsWith("GIT_") && k !== "GIT_CONFIG_NOSYSTEM") delete process.env[k];
|
||||
}
|
||||
|
||||
const { createApp } = require("../index");
|
||||
const lanesLib = require("../lib/lanes");
|
||||
const { projectRootFor, clearProjectCaches } = require("../lib/projects");
|
||||
|
||||
const repo = path.join(ROOT, "zrepo");
|
||||
const linked = path.join(ROOT, "linked");
|
||||
const claudeWt = path.join(repo, ".claude", "worktrees", "wf-1");
|
||||
const plain = path.join(ROOT, "plain");
|
||||
let app;
|
||||
let base;
|
||||
|
||||
function git(cwd, ...args) {
|
||||
execFileSync("git", ["-c", "user.email=t@t", "-c", "user.name=t", ...args], {
|
||||
cwd,
|
||||
stdio: "ignore",
|
||||
});
|
||||
}
|
||||
|
||||
before(async () => {
|
||||
fs.mkdirSync(path.join(repo, "sub"), { recursive: true });
|
||||
fs.mkdirSync(plain);
|
||||
git(repo, "init", "-q", "-b", "main");
|
||||
fs.writeFileSync(path.join(repo, "a.txt"), "a");
|
||||
git(repo, "add", ".");
|
||||
git(repo, "commit", "-q", "-m", "init");
|
||||
git(repo, "worktree", "add", "-q", "-b", "feat", linked);
|
||||
git(repo, "worktree", "add", "-q", "-b", "wf-1", claudeWt);
|
||||
app = http.createServer(createApp());
|
||||
await new Promise((r) => app.listen(0, "127.0.0.1", r));
|
||||
base = `http://127.0.0.1:${app.address().port}`;
|
||||
});
|
||||
after(async () => {
|
||||
await new Promise((r) => app.close(r));
|
||||
});
|
||||
|
||||
describe("projectRootFor", () => {
|
||||
it("resolves a main worktree, a linked one and a subdir to the main root", async () => {
|
||||
assert.equal(await projectRootFor(repo), repo);
|
||||
assert.equal(await projectRootFor(linked), repo);
|
||||
assert.equal(await projectRootFor(path.join(repo, "sub")), repo);
|
||||
});
|
||||
it("answers null outside a repo", async () => {
|
||||
assert.equal(await projectRootFor(plain), null);
|
||||
});
|
||||
});
|
||||
|
||||
describe("GET /api/projects", () => {
|
||||
it("groups lanes per repo, Folders last, and marks untracked worktrees", async () => {
|
||||
const main = lanesLib.createLane({ cwd: repo });
|
||||
const sub = lanesLib.createLane({ cwd: path.join(repo, "sub") });
|
||||
const folder = lanesLib.createLane({ cwd: plain });
|
||||
clearProjectCaches();
|
||||
const res = await fetch(`${base}/api/projects`);
|
||||
assert.equal(res.status, 200);
|
||||
const { projects } = await res.json();
|
||||
assert.deepEqual(
|
||||
projects.map((p) => [p.root, p.name, p.workspaces]),
|
||||
[
|
||||
[repo, "zrepo", [main.id, sub.id]],
|
||||
[null, "Folders", [folder.id]],
|
||||
]
|
||||
);
|
||||
const wt = Object.fromEntries(projects[0].worktrees.map((w) => [w.path, w]));
|
||||
assert.deepEqual(wt[repo], { path: repo, branch: "main", main: true, workspace_id: main.id });
|
||||
// A lane on a SUBDIRECTORY marks no worktree; nobody sits on `linked`.
|
||||
assert.equal(wt[linked].workspace_id, null);
|
||||
assert.equal(wt[linked].source, undefined);
|
||||
assert.equal(wt[claudeWt].source, "claude");
|
||||
});
|
||||
|
||||
it("matches a worktree to a lane adopted on it after the list was cached", async () => {
|
||||
const adopted = lanesLib.createLane({ cwd: linked });
|
||||
const { projects } = await (await fetch(`${base}/api/projects`)).json();
|
||||
const wt = projects[0].worktrees.find((w) => w.path === linked);
|
||||
assert.equal(wt.workspace_id, adopted.id);
|
||||
});
|
||||
|
||||
it("refresh refuses a cross-origin POST", async () => {
|
||||
const res = await fetch(`${base}/api/projects/refresh`, {
|
||||
method: "POST",
|
||||
headers: { Origin: "http://evil.test" },
|
||||
});
|
||||
assert.equal(res.status, 403);
|
||||
});
|
||||
});
|
||||
@@ -88,6 +88,7 @@ const webhooksRouter = require("./routes/webhooks");
|
||||
const remoteSourcesRouter = require("./routes/remote-sources");
|
||||
const metricsRouter = require("./routes/metrics");
|
||||
const lanesRouter = require("./routes/lanes");
|
||||
const projectsRouter = require("./routes/projects");
|
||||
const locksRouter = require("./routes/locks");
|
||||
|
||||
const APP_VERSION = (() => {
|
||||
@@ -128,6 +129,7 @@ function createApp() {
|
||||
app.use("/api/ai-tools", aiToolsRouter);
|
||||
app.use("/api/run", runRouter);
|
||||
app.use("/api/lanes", lanesRouter);
|
||||
app.use("/api/projects", projectsRouter);
|
||||
app.use("/api/locks", locksRouter);
|
||||
app.use("/api/alerts", alertsRouter);
|
||||
app.use("/api/webhooks", webhooksRouter);
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
/**
|
||||
* @file Projects: lanes grouped by the git repository they belong to, for the
|
||||
* sidebar's project → workspace tree. A project is a repo's MAIN worktree root
|
||||
* (the directory holding the common `.git`); a lane outside any repo lands in
|
||||
* the `root: null` pseudo-project ("Folders"). Each project also lists the
|
||||
* repo's worktrees from `git worktree list`, so a worktree no lane covers can
|
||||
* be shown and adopted.
|
||||
*
|
||||
* Both reads shell out to git, so both are cached in memory: a cwd's root for
|
||||
* the life of the process (a directory does not move between repos), a repo's
|
||||
* worktree list for 30 s. `clearProjectCaches` (POST /api/projects/refresh)
|
||||
* drops both. Which lane owns a worktree is recomputed on every call from the
|
||||
* lane rows, so creating or removing a lane needs no invalidation. None of
|
||||
* this runs on the polled `GET /api/lanes`.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
const { git, listWorktrees, LANES_ROOT } = require("./worktree");
|
||||
|
||||
const WORKTREE_TTL_MS = 30_000;
|
||||
const rootCache = new Map();
|
||||
const worktreeCache = new Map();
|
||||
|
||||
function real(p) {
|
||||
try {
|
||||
return fs.realpathSync(p);
|
||||
} catch {
|
||||
return p;
|
||||
}
|
||||
}
|
||||
|
||||
function isUnder(p, dir) {
|
||||
const rel = path.relative(dir, p);
|
||||
return rel !== "" && !rel.startsWith("..") && !path.isAbsolute(rel);
|
||||
}
|
||||
|
||||
/**
|
||||
* The repository a directory belongs to: its main worktree's top level
|
||||
* (realpath), or null when the directory is not inside a git repo.
|
||||
* @param {string} cwd
|
||||
* @returns {Promise<string|null>}
|
||||
*/
|
||||
async function projectRootFor(cwd) {
|
||||
if (rootCache.has(cwd)) return rootCache.get(cwd);
|
||||
let root = null;
|
||||
try {
|
||||
const { stdout } = await git(cwd, ["rev-parse", "--git-common-dir"]);
|
||||
// Relative (".git") from the main worktree itself, absolute from a linked one.
|
||||
const common = path.resolve(cwd, stdout.trim());
|
||||
root = real(path.basename(common) === ".git" ? path.dirname(common) : common);
|
||||
} catch {
|
||||
root = null;
|
||||
}
|
||||
// A lane still being provisioned has no directory yet: ask again next time.
|
||||
if (root || fs.existsSync(cwd)) rootCache.set(cwd, root);
|
||||
return root;
|
||||
}
|
||||
|
||||
async function worktreesFor(root) {
|
||||
const hit = worktreeCache.get(root);
|
||||
if (hit && Date.now() - hit.at < WORKTREE_TTL_MS) return hit.list;
|
||||
let list = [];
|
||||
try {
|
||||
list = await listWorktrees(root);
|
||||
} catch {
|
||||
list = [];
|
||||
}
|
||||
worktreeCache.set(root, { at: Date.now(), list });
|
||||
return list;
|
||||
}
|
||||
|
||||
function clearProjectCaches() {
|
||||
rootCache.clear();
|
||||
worktreeCache.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {{id:number, cwd:string}[]} lanes - lane rows, in id order
|
||||
* @returns {Promise<object[]>} projects by name, the Folders pseudo-project last
|
||||
*/
|
||||
async function listProjects(lanes) {
|
||||
const roots = await Promise.all(lanes.map((l) => projectRootFor(l.cwd)));
|
||||
const byRoot = new Map();
|
||||
lanes.forEach((lane, i) => {
|
||||
const r = roots[i];
|
||||
if (!byRoot.has(r)) byRoot.set(r, []);
|
||||
byRoot.get(r).push(lane);
|
||||
});
|
||||
const lanesRoot = real(LANES_ROOT);
|
||||
const projects = await Promise.all(
|
||||
[...byRoot]
|
||||
.filter(([root]) => root !== null)
|
||||
.map(async ([root, own]) => {
|
||||
const claudeDir = path.join(root, ".claude", "worktrees");
|
||||
const worktrees = (await worktreesFor(root))
|
||||
.map((w, i) => ({ ...w, path: real(w.path), main: i === 0 }))
|
||||
// A worktree whose directory is gone (`prunable`) cannot be adopted.
|
||||
.filter((w) => fs.existsSync(w.path))
|
||||
.map((w) => ({
|
||||
path: w.path,
|
||||
branch: w.branch || null,
|
||||
main: w.main,
|
||||
workspace_id: own.find((l) => l.cwd === w.path)?.id ?? null,
|
||||
...(isUnder(w.path, claudeDir)
|
||||
? { source: "claude" }
|
||||
: isUnder(w.path, lanesRoot)
|
||||
? { source: "agenthub" }
|
||||
: {}),
|
||||
}));
|
||||
return {
|
||||
root,
|
||||
name: path.basename(root).replace(/\.git$/, "") || root,
|
||||
workspaces: own.map((l) => l.id),
|
||||
worktrees,
|
||||
};
|
||||
})
|
||||
);
|
||||
projects.sort((a, b) => a.name.localeCompare(b.name));
|
||||
const folders = byRoot.get(null);
|
||||
if (folders) {
|
||||
projects.push({
|
||||
root: null,
|
||||
name: "Folders",
|
||||
workspaces: folders.map((l) => l.id),
|
||||
worktrees: [],
|
||||
});
|
||||
}
|
||||
return projects;
|
||||
}
|
||||
|
||||
module.exports = { projectRootFor, listProjects, clearProjectCaches };
|
||||
@@ -0,0 +1,25 @@
|
||||
/**
|
||||
* @file Express router for /api/projects: every lane grouped by the git repo
|
||||
* it belongs to, with that repo's worktrees (see server/lib/projects.js). Read
|
||||
* on demand by the sidebar's project tree, never polled; `POST /refresh` drops
|
||||
* the cached roots and worktree lists first.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
const { Router } = require("express");
|
||||
const lanesLib = require("../lib/lanes");
|
||||
const { listProjects, clearProjectCaches } = require("../lib/projects");
|
||||
const { sameOriginGuard } = require("../lib/same-origin");
|
||||
|
||||
const router = Router();
|
||||
|
||||
router.get("/", async (_req, res) => {
|
||||
res.json({ projects: await listProjects(lanesLib.listLanes()) });
|
||||
});
|
||||
|
||||
router.post("/refresh", sameOriginGuard, async (_req, res) => {
|
||||
clearProjectCaches();
|
||||
res.json({ projects: await listProjects(lanesLib.listLanes()) });
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
Reference in New Issue
Block a user