Files
AgentHub/client/src/components/lanes/WorkspaceRow.tsx
T
nntrivi2001 fa156d6a59 feat: v2.6.0 — Chat UI composer (+ menu, project files, activity panel, permission mode, Stop), git on a workspace's extra folders (#70), long paste as a file (#68), S3 server mode (experimental)
Chat UI:
- Each Claude/Codex session keeps its own chat/terminal view; Settings only
  sets how new sessions open.
- Composer + menu: upload, project files and folders (#54, also dragged from
  the Files/Changes tree), context files, recent uploads, skills, plugins.
- Activity side panel (progress, outputs, files read, uploads, memory,
  skills, connectors) with a file preview; edited-file counts per turn.
- Permission-mode chip (Shift+Tab steps, read back from the pane:
  server/lib/pane-mode.js, GET|POST /api/run/:id/permission-mode).
- Stop (Esc) while the main agent works; the status is checked per agent
  (with session_id /api/agents ignores its status filter) and re-read every
  5 s while Stop shows.
- Dropped images shown in the conversation (GET /api/run/drop/:name, the
  requester's own drop dir), full-size viewer.
- A plain-text paste over 5,000 characters is attached as pasted-text.txt,
  so no prompt reaches tmux/argv limits (#68).

Changes panel: every /api/lanes/:id/git* route takes ?source=<lane_sources
id> (sourceFolder; ENOSOURCE otherwise) and a folder picker switches the
whole panel to a workspace's extra folder (#70).

Server mode S3 (one Linux user per account) — experimental, verified with
fake sudo plus a P6 managed-settings spike on a test account:
- member tmux listing runs in the background (cached, 15 s => unknown);
- transcript snapshot copy is async, chunked, temp file + rename;
- Express Layer patch only on Express 4;
- tmux capturePane/sendKey go to the member's own server.

Docs: the multi-user design moved in as docs/plans/server-mode/README.md
(the AgentHub-Server repo is gone); S3-PLAN P6 result; LANES/API/openapi.
2026-10-09 16:35:07 +07:00

200 lines
7.4 KiB
TypeScript

/**
* @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ĩ <vinnt@smartgift.vn>
*/
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 (
<div
data-testid={`workspace-row-${lane.id}`}
data-lane-index={index}
draggable={!renaming}
onDragStart={(e) => {
// 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"
}`}
>
<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}`}
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 (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"
/>
) : (
<button
type="button"
data-testid={`workspace-open-${lane.id}`}
aria-current={selected ? "true" : undefined}
// A double-click's second click renames instead of opening again.
onClick={(e) => e.detail < 2 && onSelect()}
onDoubleClick={startRename}
onKeyDown={(e) => {
if (e.key === "F2") {
e.preventDefault();
startRename();
return;
}
if (!e.altKey || (e.key !== "ArrowUp" && e.key !== "ArrowDown")) return;
e.preventDefault();
onReorder(index, index + (e.key === "ArrowUp" ? -1 : 1));
}}
title={`${lane.cwd}\n${readOnly ?? t("row.hint")}`}
className="flex min-w-0 flex-1 items-center gap-2 py-1.5 text-left"
>
<span aria-hidden className={`h-2 w-2 shrink-0 rounded-full ${workspaceDot(lane)}`} />
<span className="sr-only">{t(`row.state.${workspaceDotLabel(lane)}`)}</span>
<span className="truncate text-[13px] font-medium text-fg-primary">{name}</span>
</button>
)}
{readOnly && <Eye aria-label={readOnly} className="mr-1 h-3 w-3 shrink-0 text-fg-muted" />}
{!renaming && !readOnly && (
<>
<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>
);
}