/** * @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}`) 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. The row only STARTS a drag (`LANE_DRAG_TYPE`); the list * (`WorkspaceTree.tsx`) takes the drop anywhere, gaps included. F2 renames too; the * dot's meaning is screen-reader text, and an idle dot is a ring. A `readOnly` * row (another user's workspace, for an analyst) has no menu, rename or `+`. * @author Nguyễn Ngọc Trí Vĩ */ import { useState, type MouseEvent } from "react"; import { useTranslation } from "react-i18next"; import { ChevronDown, ChevronRight, Eye, MoreHorizontal, Plus } from "lucide-react"; import type { Lane } from "../../lib/types"; import { isSubmitEnter } from "../../lib/keys"; /** What a workspace row's drag carries: its index in the list. */ export const LANE_DRAG_TYPE = "application/x-agenthub-lane"; export const isLaneDrag = (e: { dataTransfer: DataTransfer | null }) => !!e.dataTransfer?.types?.includes(LANE_DRAG_TYPE); /** 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"; // A ring, not a fill: an idle surface-4 dot vanished on the light themes. return "border border-fg-muted"; } /** What the dot means (`row.state.*`), for a screen reader. */ export function workspaceDotLabel(lane: Lane): string { if (lane.needs_action) return "needsYou"; if (lane.liveness === "dead" || lane.status === "error") return "error"; if (lane.status === "running") return "running"; if (lane.liveness === "active") return "active"; return "idle"; } export function workspaceName(lane: Lane): string { return lane.title || lane.cwd.split("/").filter(Boolean).pop() || lane.cwd; } export function WorkspaceRow({ lane, index, readOnly, selected, expanded, renaming, onSelect, onToggle, onNewSession, onReorder, onMenu, onRenameStart, onRenameEnd, }: { lane: Lane; /** Position in the list — the unit both reorder paths speak in. */ index: number; /** Set (to its tooltip) for a workspace this user may only read: no menu, * no rename, no new session. */ readOnly?: string; 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; 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 [draft, setDraft] = useState(""); const name = workspaceName(lane); const openMenu = (e: MouseEvent) => { e.preventDefault(); e.stopPropagation(); if (!readOnly) onMenu(e.clientX, e.clientY); }; const startRename = () => { if (readOnly) return; setDraft(lane.title || ""); onRenameStart(); }; 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 (
{ // Firefox starts no drag at all without a setData call. e.dataTransfer.setData(LANE_DRAG_TYPE, String(index)); e.dataTransfer.effectAllowed = "move"; }} onContextMenu={openMenu} className={`group flex items-center gap-1 rounded-md pl-1 pr-1 transition-colors ${ selected ? "bg-accent/10" : lane.needs_action ? "bg-status-warning/10 hover:bg-status-warning/20" : "hover:bg-surface-3" }`} > {renaming ? ( setDraft(e.target.value)} // Blur CANCELS — Enter is the only save, so one edit fires one PATCH. onBlur={() => onRenameEnd(null)} onKeyDown={(e) => { if (isSubmitEnter(e)) onRenameEnd(draft.trim()); if (e.key === "Escape") onRenameEnd(null); }} 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" /> ) : ( )} {readOnly && } {!renaming && !readOnly && ( <> )}
); }