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:
@@ -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>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user