feat(ui): sidebar lists workspaces with their sessions, Claude Desktop style

The sidebar's project tree becomes a flat workspace list (WorkspaceTree):
one row per workspace in the stored order, each expanding to its sessions.
A session that still has a live pane opens /workspace?lane=<id>&run=<runId>,
which only attaches pane 0 to that tmux session; any other session opens
its read-only transcript. Nothing in the sidebar starts or resumes a run.
This commit is contained in:
2026-10-01 22:23:01 +07:00
parent 744e9c6cda
commit 2e051e23b6
16 changed files with 616 additions and 538 deletions
+68 -57
View File
@@ -1,17 +1,18 @@
/**
* @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`).
* @file WorkspaceRow — one workspace (lane) in the sidebar's workspace list, one
* line like a Claude Desktop project: a chevron that expands its sessions, the
* status dot, the name (click opens the workspace, double-click renames,
* `PATCH /api/lanes/:id {title}`), the tool's icon, and on hover `+` (a new
* session there) and `⋯` (the row menu, `WorkspaceRowMenu.tsx`, also on
* right-click). Dragging it, or Alt+↑/↓, reorders the list — a viewer
* preference kept in localStorage (`laneOrderStorage.ts`), never written to
* the lane row.
* @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 { ChevronDown, ChevronRight, MoreHorizontal, Plus } from "lucide-react";
import type { Lane } from "../../lib/types";
import { useAiTools } from "../../hooks/useAiTools";
import { AiToolIcon } from "../AiToolIcon";
@@ -25,34 +26,33 @@ export function workspaceDot(lane: Lane): string {
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 workspaceName(lane: Lane): string {
return lane.title || lane.cwd.split("/").filter(Boolean).pop() || lane.cwd;
}
export function WorkspaceRow({
lane,
projectRoot,
branch,
index,
selected,
expanded,
renaming,
onSelect,
onToggle,
onNewSession,
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. */
/** Position in the list — the unit both reorder paths speak in. */
index: number;
selected: boolean;
expanded: boolean;
renaming: boolean;
onSelect: () => void;
onToggle: () => void;
onNewSession: () => void;
onReorder: (from: number, to: number) => void;
/** Open the row menu at a viewport point. */
onMenu: (x: number, y: number) => void;
@@ -64,12 +64,15 @@ export function WorkspaceRow({
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 name = workspaceName(lane);
const openMenu = (e: MouseEvent) => {
e.preventDefault();
e.stopPropagation();
onMenu(e.clientX, e.clientY);
};
const Chevron = expanded ? ChevronDown : ChevronRight;
const hoverBtn =
"shrink-0 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";
return (
<div
@@ -94,16 +97,26 @@ export function WorkspaceRow({
if (Number.isInteger(from)) onReorder(from, index);
}}
onContextMenu={openMenu}
className={`group relative flex items-center rounded-md border-l-2 transition-colors ${
className={`group flex items-center gap-1 rounded-md pl-1 pr-1 transition-colors ${
dropTarget
? "border-accent bg-accent/20"
? "bg-accent/20"
: selected
? "border-accent bg-accent/10"
? "bg-accent/10"
: lane.needs_action
? "border-transparent bg-status-warning/10 hover:bg-status-warning/20"
: "border-transparent hover:bg-surface-3"
? "bg-status-warning/10 hover:bg-status-warning/20"
: "hover:bg-surface-3"
}`}
>
<button
type="button"
data-testid={`workspace-toggle-${lane.id}`}
aria-expanded={expanded}
aria-label={name}
onClick={onToggle}
className="shrink-0 rounded p-0.5 text-fg-muted hover:text-fg-primary"
>
<Chevron className="h-3.5 w-3.5" />
</button>
{renaming ? (
<input
data-testid={`workspace-rename-${lane.id}`}
@@ -118,11 +131,12 @@ export function WorkspaceRow({
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"
className="my-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"
data-testid={`workspace-open-${lane.id}`}
aria-current={selected ? "true" : undefined}
onClick={onSelect}
onDoubleClick={() => {
@@ -135,42 +149,39 @@ export function WorkspaceRow({
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"
className="flex min-w-0 flex-1 items-center gap-2 py-1.5 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>
<span className={`h-2 w-2 shrink-0 rounded-full ${workspaceDot(lane)}`} />
<span className="truncate text-[13px] text-fg-primary">{name}</span>
<AiToolIcon
tool={{ id: lane.tool, label: tools.label(lane.tool) }}
className="h-3.5 w-3.5 shrink-0 !p-0 opacity-70"
/>
</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>
<>
<button
type="button"
data-testid={`workspace-new-${lane.id}`}
aria-label={t("tree.newSession")}
title={t("tree.newSession")}
onClick={onNewSession}
className={hoverBtn}
>
<Plus className="h-3.5 w-3.5" />
</button>
<button
type="button"
data-testid={`workspace-menu-${lane.id}`}
aria-label={t("row.actions")}
title={t("row.actions")}
onClick={openMenu}
className={hoverBtn}
>
<MoreHorizontal className="h-3.5 w-3.5" />
</button>
</>
)}
</div>
);
@@ -0,0 +1,101 @@
/**
* @file WorkspaceSessionList — the sessions under an expanded workspace in the
* sidebar (`GET /api/sessions?cwd=&tool=`, newest first). A session with a LIVE
* pane (a `GET /api/run` handle carrying its session id) opens that tmux pane
* on the Workspace page (`?lane=<id>&run=<runId>`) — attaching, never starting
* or resuming anything; any other session opens its read-only transcript
* (`/sessions/:id`), where Resume is a separate, explicit action, because a
* resume re-reads the whole transcript into context.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { useNavigate } from "react-router-dom";
import { api, type RunHandle } from "../../lib/api";
import type { Lane, Session } from "../../lib/types";
import { timeAgo } from "../../lib/format";
export const SESSION_LIMIT = 20;
export function WorkspaceSessionList({
lane,
liveRuns,
query,
selectedRunId,
}: {
lane: Lane;
/** Every live dashboard pane, from `GET /api/run`. */
liveRuns: RunHandle[];
/** The sidebar search; filters by session name. */
query: string;
/** The `run` the Workspace page is attached to, to mark its row. */
selectedRunId: string | null;
}) {
const { t } = useTranslation("lanes");
const navigate = useNavigate();
const [sessions, setSessions] = useState<Session[] | null>(null);
// The lane's own session/status move on every hook, which is when a new
// session can appear — re-read then, not on a timer.
useEffect(() => {
let live = true;
api.sessions
.list({ cwd: lane.cwd, tool: lane.tool, limit: SESSION_LIMIT })
.then((r) => live && setSessions(r.sessions))
.catch(() => live && setSessions([]));
return () => {
live = false;
};
}, [lane.cwd, lane.tool, lane.session_id, lane.status]);
const q = query.trim().toLowerCase();
const shown = (sessions ?? []).filter(
(s) => !q || (s.name ?? "").toLowerCase().includes(q) || s.id.startsWith(q)
);
const runFor = (s: Session) =>
liveRuns.find((r) => r.sessionId === s.id && r.status === "running") ?? null;
if (sessions !== null && shown.length === 0) {
return <p className="py-1 pl-8 text-[11px] text-fg-muted">{t("tree.noSessions")}</p>;
}
return (
<ul data-testid={`workspace-sessions-${lane.id}`} className="space-y-px">
{shown.map((s) => {
const run = runFor(s);
const selected = !!run && run.id === selectedRunId;
return (
<li key={s.id}>
<button
type="button"
data-testid={`workspace-session-${s.id}`}
data-live={run ? "true" : undefined}
onClick={() =>
navigate(
run
? `/workspace?lane=${lane.id}&run=${encodeURIComponent(run.id)}`
: `/sessions/${s.id}`
)
}
title={`${s.name || s.id}\n${timeAgo(s.last_activity ?? s.started_at)}${
run ? "" : `\n${t("tree.transcriptHint")}`
}`}
className={`flex w-full items-center gap-2 rounded-md py-1 pl-7 pr-2 text-left text-[12px] ${
selected
? "bg-accent/10 text-fg-primary"
: "text-fg-secondary hover:bg-surface-3 hover:text-fg-primary"
}`}
>
<span
className={`h-1.5 w-1.5 shrink-0 rounded-full ${
run ? "bg-status-success" : "border border-fg-muted"
}`}
/>
<span className="truncate">{s.name || s.id.slice(0, 8)}</span>
</button>
</li>
);
})}
</ul>
);
}