feat(ui): sidebar project tree, status bar and a tabbed workbench for workspaces

The Workspace page's lane strip moves into the sidebar as a project tree:
workspaces grouped by git repository (new GET /api/projects, cached in
memory, POST /api/projects/refresh), untracked worktrees with Adopt, inline
rename, drag/Alt+arrow ordering and a right-click row menu. The selected
workspace is the URL's ?lane=, one shared lane list feeds the tree, the new
bottom status bar and the page. The Changes panel gains an activity bar
(Files / Changes / Sessions, with resume), and split panes get a header.
This commit is contained in:
2026-10-01 16:33:25 +07:00
parent 5b3884b309
commit ba2c4e1914
39 changed files with 2431 additions and 1439 deletions
@@ -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>
);
}