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