feat: workspace aliases, tab groups of split panes, native path picker (v2.1.0)
- /api/workspaces and `agenthub workspace(s)` / `--workspace` as aliases of /api/lanes and `agenthub lanes` / `--lane`; old names print one stderr note. REPL `help lanes|workspace|workspaces` resolves through the aliases. - A workspace holds up to ten sessions (slots 0-9); every console tab is a group of 1-4 split panes (PaneGroups, agenthub.workspace.paneLayout); parseGroups keeps the stored active tab when an earlier one is dropped. - Browse button beside path fields: POST /api/lanes/pick-path opens the OS picker on the server's desktop — loopback only and no forwarding header (EREMOTE), a display failure is ENODIALOG not a Cancel, the dialog is killed when the request closes, a folder field falls back to the in-app browser on any failure. - Changes rail counts chip, image viewer (/git/raw), status bar session counts and per-day cost (`date=` on /api/pricing/cost). - /agenthub-open and /agenthub-doctor run from the INSTALLED build (supersededBy), doctor offers fixes behind two confirmations; plugin-open treats a locked bootstrap as non-fatal. - New-terminal shell waits for the first attach (issue #55). - repo-onboarding / debug-live-issue skills removed. - OpenAPI: pick-path, lane/date on /pricing/cost, tool on /run/history, /api/workspaces mount.
This commit is contained in:
@@ -3,9 +3,8 @@
|
||||
* Desktop's project list: every workspace (lane) on one line in the stored drag
|
||||
* order (`laneOrderStorage.ts`), each expanding to ITS sessions
|
||||
* (`WorkspaceSessionList.tsx`) — no repo grouping, no worktree rows. Picking a
|
||||
* workspace opens its tabs (`/workspace?lane=<id>&run=<runId>`, the pane it
|
||||
* last focused) when it has live ones, else its home (`?lane=<id>`, also what
|
||||
* `+` on a row opens for a new session). Live status comes from the shared
|
||||
* workspace — or `+` on its row — opens its home (`/workspace?lane=<id>`); a
|
||||
* live tab is opened from its session row. Live status comes from the shared
|
||||
* lane list (`useLanes`), live panes from `GET /api/run`, re-read whenever a
|
||||
* lane's status or session moves and handed over by the Workspace page's own
|
||||
* poll (`LIVE_RUNS_EVENT`). Owns its own Add-workspace and
|
||||
@@ -14,23 +13,24 @@
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState, type DragEvent } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useLocation, useNavigate } from "react-router-dom";
|
||||
import { Plus, Search } from "lucide-react";
|
||||
import { api, type CwdSuggestion, type RunHandle } from "../lib/api";
|
||||
import type { Lane } from "../lib/types";
|
||||
import { loadLayout } from "../lib/paneLayout";
|
||||
import { useLanes } from "../hooks/useLanes";
|
||||
import { useAiTools } from "../hooks/useAiTools";
|
||||
import {
|
||||
insertionIndex,
|
||||
moveBefore,
|
||||
moveLane,
|
||||
readLaneOrder,
|
||||
sortByStoredOrder,
|
||||
writeLaneOrder,
|
||||
} from "../lib/laneOrderStorage";
|
||||
import { useProjectTreePrefs, writeProjectTreePrefs } from "../lib/projectTreePrefs";
|
||||
import { WorkspaceRow, workspaceName } from "./lanes/WorkspaceRow";
|
||||
import { LANE_DRAG_TYPE, WorkspaceRow, isLaneDrag, workspaceName } from "./lanes/WorkspaceRow";
|
||||
import { WorkspaceSessionList } from "./lanes/WorkspaceSessionList";
|
||||
import { WorkspaceRowMenu, type WorkspaceMenuAction } from "./lanes/WorkspaceRowMenu";
|
||||
import { AddLaneModal } from "./lanes/AddLaneModal";
|
||||
@@ -100,16 +100,6 @@ export function WorkspaceTree() {
|
||||
};
|
||||
|
||||
const select = useCallback((id: number) => navigate(`/workspace?lane=${id}`), [navigate]);
|
||||
// A row click: a workspace with live tabs opens on them — on the pane it
|
||||
// last focused — and one with none on its home (the tab bar's Home button
|
||||
// reaches the home of the other kind).
|
||||
const open = (id: number) => {
|
||||
const runs = liveRuns.filter((r) => r.laneId === id && r.status === "running");
|
||||
const focus = loadLayout(id).focus;
|
||||
const run = runs.find((r) => (r.slot ?? 0) === focus) ?? runs[0];
|
||||
if (run) navigate(`/workspace?lane=${id}&run=${encodeURIComponent(run.id)}`);
|
||||
else select(id);
|
||||
};
|
||||
const expanded = (id: number) => !!prefs.expanded[id] || !!query.trim();
|
||||
const setExpanded = (id: number, open: boolean) =>
|
||||
writeProjectTreePrefs({ expanded: { ...prefs.expanded, [id]: open } });
|
||||
@@ -142,9 +132,9 @@ export function WorkspaceTree() {
|
||||
}, [q]);
|
||||
const visible = (l: Lane) => !q || nameHit(l) || (hits[l.id] ?? 0) > 0;
|
||||
|
||||
const reorder = (from: number, to: number) => {
|
||||
const reorder = (move: (ids: number[]) => number[]) => {
|
||||
const ids = sorted.map((l) => l.id);
|
||||
const moved = moveLane(ids, from, to);
|
||||
const moved = move(ids);
|
||||
if (moved === ids) return;
|
||||
// Under a tool filter the shown ids go back into the slots they held in
|
||||
// the full order, so the hidden workspaces keep their places.
|
||||
@@ -157,6 +147,44 @@ export function WorkspaceTree() {
|
||||
setOrderTick((n) => n + 1);
|
||||
};
|
||||
|
||||
// A row only starts a drag; the whole section takes the drop, so a gap — a
|
||||
// workspace's session list, the space above or below a row — lands too.
|
||||
// `dropAt` is the insertion point in `sorted`, drawn as a line.
|
||||
const listRef = useRef<HTMLDivElement>(null);
|
||||
const [dropAt, setDropAt] = useState<number | null>(null);
|
||||
const dropIndexAt = (y: number) => {
|
||||
const rows = [...(listRef.current?.querySelectorAll<HTMLElement>("[data-lane-index]") ?? [])];
|
||||
const mids = rows.map((r) => {
|
||||
const b = r.getBoundingClientRect();
|
||||
return b.top + b.height / 2;
|
||||
});
|
||||
const row = rows[insertionIndex(mids, y)];
|
||||
return row ? Number(row.dataset.laneIndex) : sorted.length;
|
||||
};
|
||||
const dragHandlers = {
|
||||
onDragOver: (e: DragEvent) => {
|
||||
if (!isLaneDrag(e)) return;
|
||||
e.preventDefault();
|
||||
e.dataTransfer.dropEffect = "move";
|
||||
const at = dropIndexAt(e.clientY);
|
||||
setDropAt((d) => (d === at ? d : at));
|
||||
},
|
||||
onDragLeave: (e: DragEvent) => {
|
||||
if (!e.currentTarget.contains(e.relatedTarget as Node | null)) setDropAt(null);
|
||||
},
|
||||
onDragEnd: () => setDropAt(null),
|
||||
onDrop: (e: DragEvent) => {
|
||||
setDropAt(null);
|
||||
if (!isLaneDrag(e)) return;
|
||||
e.preventDefault();
|
||||
const raw = e.dataTransfer.getData(LANE_DRAG_TYPE);
|
||||
// Number("") is 0: an empty payload must not move the first row.
|
||||
const from = raw === "" ? NaN : Number(raw);
|
||||
const before = dropIndexAt(e.clientY);
|
||||
if (Number.isInteger(from)) reorder((ids) => moveBefore(ids, from, before));
|
||||
},
|
||||
};
|
||||
|
||||
const rename = async (lane: Lane, title: string | null) => {
|
||||
setRenamingId(null);
|
||||
if (title === null || title === (lane.title || "")) return;
|
||||
@@ -168,17 +196,10 @@ export function WorkspaceTree() {
|
||||
}
|
||||
};
|
||||
|
||||
const onMenuAction = (lane: Lane, action: WorkspaceMenuAction, runId?: string) => {
|
||||
const onMenuAction = (lane: Lane, action: WorkspaceMenuAction) => {
|
||||
if (action === "rename") setRenamingId(lane.id);
|
||||
else if (action === "new-here") openAdd(lane.source_repo || lane.cwd);
|
||||
else if (action === "reset" || action === "remove") setDestructive({ lane, action });
|
||||
// Start opens the session it started, as the home's menu does.
|
||||
else if (action === "started")
|
||||
navigate(
|
||||
runId
|
||||
? `/workspace?lane=${lane.id}&run=${encodeURIComponent(runId)}`
|
||||
: `/workspace?lane=${lane.id}`
|
||||
);
|
||||
};
|
||||
|
||||
const confirmDestructive = async (body?: { expect?: unknown; force?: boolean }) => {
|
||||
@@ -201,7 +222,12 @@ export function WorkspaceTree() {
|
||||
// ≥24px targets (WCAG 2.5.8); each carries an aria-label.
|
||||
const headBtn = "btn-icon";
|
||||
return (
|
||||
<section data-testid="workspace-tree" className="mt-3 border-t border-border pt-3">
|
||||
<section
|
||||
data-testid="workspace-tree"
|
||||
// The bottom padding is a drop strip below the last workspace.
|
||||
className="mt-3 border-t border-border pb-6 pt-3"
|
||||
{...dragHandlers}
|
||||
>
|
||||
<div className="mb-1 flex items-center gap-1 px-2">
|
||||
<span className="flex-1 text-[11px] font-semibold uppercase tracking-wide text-fg-muted">
|
||||
{t("tree.title")}
|
||||
@@ -264,9 +290,10 @@ export function WorkspaceTree() {
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="space-y-px px-1">
|
||||
<div ref={listRef} className="space-y-px px-1">
|
||||
{sorted.map((lane, i) => (
|
||||
<div key={lane.id}>
|
||||
<div key={lane.id} className="relative">
|
||||
{dropAt === i && <DropLine />}
|
||||
{visible(lane) && (
|
||||
<WorkspaceRow
|
||||
lane={lane}
|
||||
@@ -275,12 +302,12 @@ export function WorkspaceTree() {
|
||||
expanded={expanded(lane.id)}
|
||||
renaming={renamingId === lane.id}
|
||||
onSelect={() => {
|
||||
open(lane.id);
|
||||
select(lane.id);
|
||||
if (!expanded(lane.id)) setExpanded(lane.id, true);
|
||||
}}
|
||||
onToggle={() => setExpanded(lane.id, !expanded(lane.id))}
|
||||
onNewSession={() => select(lane.id)}
|
||||
onReorder={reorder}
|
||||
onReorder={(from, to) => reorder((ids) => moveLane(ids, from, to))}
|
||||
onMenu={(x, y) => setMenu({ lane, x, y })}
|
||||
onRenameStart={() => setRenamingId(lane.id)}
|
||||
onRenameEnd={(title) => void rename(lane, title)}
|
||||
@@ -300,6 +327,11 @@ export function WorkspaceTree() {
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
{dropAt === sorted.length && (
|
||||
<div className="relative">
|
||||
<DropLine />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
@@ -314,7 +346,7 @@ export function WorkspaceTree() {
|
||||
x={menu.x}
|
||||
y={menu.y}
|
||||
onClose={() => setMenu(null)}
|
||||
onAction={(a, runId) => onMenuAction(menu.lane, a, runId)}
|
||||
onAction={(a) => onMenuAction(menu.lane, a)}
|
||||
/>
|
||||
)}
|
||||
{destructive && (
|
||||
@@ -342,3 +374,14 @@ export function WorkspaceTree() {
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
/** Where a dragged workspace would land: a line on its parent's top edge. */
|
||||
function DropLine() {
|
||||
return (
|
||||
<div
|
||||
aria-hidden
|
||||
data-testid="workspace-drop-line"
|
||||
className="pointer-events-none absolute inset-x-1 -top-px z-10 h-0.5 rounded-full bg-accent-solid"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user