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:
2026-10-06 11:31:26 +07:00
parent c067abc68f
commit 6c605849d8
104 changed files with 3478 additions and 1396 deletions
+12 -8
View File
@@ -81,6 +81,7 @@ import {
import { api, type ImportResult, type ImportBackupResult } from "../lib/api";
import { eventBus } from "../lib/eventBus";
import type { WSMessage, ImportProgressMessage } from "../lib/types";
import { PathPickButton } from "./PathPickButton";
type Mode = "rescan" | "path" | "upload" | "backup";
@@ -426,14 +427,17 @@ export function ImportHistory() {
{mode === "path" && (
<div className="space-y-3">
<div>
<input
type="text"
value={folderPath}
onChange={(e) => setFolderPath(e.target.value)}
placeholder={t("import.folderPlaceholder")}
className="input w-full text-sm font-mono"
spellCheck={false}
/>
<div className="flex gap-1.5">
<input
type="text"
value={folderPath}
onChange={(e) => setFolderPath(e.target.value)}
placeholder={t("import.folderPlaceholder")}
className="input w-full text-sm font-mono"
spellCheck={false}
/>
<PathPickButton start={folderPath} onPick={setFolderPath} />
</div>
<p className="text-[11px] text-fg-muted mt-1.5">{t("import.folderHelper")}</p>
</div>
<div className="flex justify-end">
+2 -2
View File
@@ -87,9 +87,9 @@ export function PageTabs({
aria-controls={hasPanel && isActive ? panelId : undefined}
tabIndex={isActive ? 0 : -1}
onClick={() => onSelect(id)}
className={`inline-flex items-center gap-1.5 text-xs whitespace-nowrap px-3 py-1.5 rounded-lg border transition-colors flex-shrink-0 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/60 ${
className={`inline-flex items-center gap-1.5 text-xs font-medium whitespace-nowrap px-3 py-1.5 rounded-lg border transition-colors flex-shrink-0 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/60 ${
isActive
? "bg-accent/15 border-accent/30 text-accent font-semibold shadow-[inset_0_-2px_0_rgb(var(--accent))]"
? "bg-accent/15 border-accent/30 text-accent shadow-[inset_0_-2px_0_rgb(var(--accent))]"
: "border-border text-fg-secondary hover:text-fg-primary hover:bg-surface-3"
}`}
>
+86
View File
@@ -0,0 +1,86 @@
/**
* @file PathPickButton.tsx
* @description The one "Browse" button beside every path field: it asks the
* server to open the operating system's own folder/file picker
* (`POST /api/lanes/pick-path`) and fills the field with the absolute path the
* user chose. A browser's picker cannot give a page that path, so the dialog
* opens on the server's desktop — which is only this user's screen when the
* browser runs on the same machine. Anywhere else (a LAN browser, a server
* with no desktop or no zenity/kdialog) a folder falls back to the in-app
* `FolderBrowseModal` (as it does on any other failure), and a file to typing
* the path.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { FolderOpen } from "lucide-react";
import { api } from "../lib/api";
import { toast } from "../lib/toast";
import { FolderBrowseModal } from "./lanes/FolderBrowseModal";
/** Server answers that mean "no native picker for this browser" — not a fault. */
const FALLBACK = new Set(["EREMOTE", "EBADORIGIN", "ENODIALOG"]);
export function PathPickButton({
kind = "dir",
start,
onPick,
showLabel = false,
}: {
kind?: "dir" | "file";
/** The field's current value — the picker opens there. */
start?: string;
onPick: (path: string) => void;
showLabel?: boolean;
}) {
const { t } = useTranslation(["lanes"]);
const [busy, setBusy] = useState(false);
const [fallback, setFallback] = useState(false);
const title = kind === "dir" ? t("browse.title") : t("browse.fileTitle");
const pick = async () => {
setBusy(true);
try {
const r = await api.lanes.pickPath(kind, start?.trim() || undefined);
if (r.path) onPick(r.path);
} catch (err) {
const code = (err as { code?: string }).code;
// A folder always has the in-app browser to fall back on, whatever went
// wrong with the native one (a crashed dialog, one already open).
if (kind === "dir") setFallback(true);
else if (code && FALLBACK.has(code)) toast.info(t("browse.typePath"));
else {
toast.error(err instanceof Error ? err.message : String(err));
}
} finally {
setBusy(false);
}
};
return (
<>
<button
type="button"
onClick={pick}
disabled={busy}
title={title}
aria-label={title}
className="flex shrink-0 items-center gap-1 self-stretch rounded-md border border-border-light px-2 text-xs text-fg-secondary hover:bg-surface-2 disabled:opacity-50"
>
<FolderOpen className="h-3.5 w-3.5" />
{showLabel && t("browse.button")}
</button>
{/* Its clicks stay here: a backdrop click must close only this modal,
not bubble on to a dialog this button sits in. */}
<div className="contents" onClick={(e) => e.stopPropagation()}>
<FolderBrowseModal
open={fallback}
initialPath={start?.trim() || undefined}
onSelect={onPick}
onClose={() => setFallback(false)}
/>
</div>
</>
);
}
+14 -6
View File
@@ -86,6 +86,7 @@ import { toast } from "../lib/toast";
import { isRemoteDataRefreshMessage } from "../lib/remoteDataEvents";
import { useDataScope } from "../lib/dataScope";
import type { ScopeMode } from "../lib/dataScope";
import { PathPickButton } from "./PathPickButton";
/** Data-scope choices in display order (the radio group's arrow-key order). */
const SCOPE_MODES: ScopeMode[] = ["all", "local", "selected"];
@@ -558,12 +559,19 @@ export function RemoteSources() {
<label className="block text-xs text-fg-muted mb-1">
{t("remoteSources.fieldIdentity", "Identity file (optional)")}
</label>
<input
className="input w-full font-mono"
placeholder="~/.ssh/id_ed25519"
value={form.identity_file ?? ""}
onChange={(e) => setForm((f) => ({ ...f, identity_file: e.target.value }))}
/>
<div className="flex gap-1.5">
<input
className="input w-full font-mono"
placeholder="~/.ssh/id_ed25519"
value={form.identity_file ?? ""}
onChange={(e) => setForm((f) => ({ ...f, identity_file: e.target.value }))}
/>
<PathPickButton
kind="file"
start={form.identity_file || "~/.ssh"}
onPick={(p) => setForm((f) => ({ ...f, identity_file: p }))}
/>
</div>
</div>
<div className="sm:col-span-2">
<label className="block text-xs text-fg-muted mb-1">
+82 -27
View File
@@ -2,25 +2,28 @@
* @file StatusBar — the one-line bar along the bottom of the app shell, on
* every page: the live-connection state (click: the connection details modal,
* which the sidebar owns and which also carries the update check), the version
* — with a green dot while a newer release is out —, how many workspaces are running and how
* many need the user (from the shared lane list, no request of its own), and
* today's spend — the `daily_costs` bucket for the local date from
* `GET /api/pricing/cost`, the Dashboard's own cost call — and, on the
* — with a green dot while a newer release is out —, how many SESSIONS are
* running and how many need the user (`GET /api/sessions?status=active` and
* `?awaiting=1` — the Dashboard's Needs-you rule —, re-read on session
* messages), and today's spend — `GET /api/pricing/cost?date=<local date>` — and, on the
* Workspace page, the open workspace's total (`?lane=` on the same call: the
* sessions it owns) and the shown session's (`GET /api/pricing/cost/:id` for
* the URL's `session`, or the focused pane's session from the page's run poll,
* `LIVE_RUNS_EVENT`), all re-read every 30 s while the tab is visible and
* whenever the URL names another workspace or session. No provider quota: reading Claude's quota rotates
* whenever the URL names another workspace or session. Each figure carries its
* token total, and hovering it lists tokens and cost per model. No provider quota: reading Claude's quota rotates
* the stored OAuth token (`cc-usage.js`), and the CLI shows it in the pane.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useEffect, useState } from "react";
import { useCallback, useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { useLocation } from "react-router-dom";
import { Wifi, WifiOff } from "lucide-react";
import { api, type RunHandle } from "../lib/api";
import { useLanes } from "../hooks/useLanes";
import type { CostResult } from "../lib/types";
import { fmt, fmtCostFull, formatModelName } from "../lib/format";
import { useLiveRefresh } from "../hooks/useLiveRefresh";
import { LIVE_RUNS_EVENT, selectedFrom } from "./WorkspaceTree";
const COST_POLL_MS = 30_000;
@@ -30,6 +33,37 @@ function localDate(d = new Date()): string {
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
}
const usd = (n: number) => `$${n.toFixed(2)}`;
/** Tokens and cost per model (the breakdown's speed/geo/tier buckets folded),
* costliest first: the hover text of a cost figure. */
function perModel(r: CostResult): { tokens: number; lines: string } {
const byModel = new Map<string, { tokens: number; cost: number }>();
for (const b of r.breakdown) {
const m = byModel.get(b.model) ?? { tokens: 0, cost: 0 };
m.tokens += b.input_tokens + b.output_tokens + b.cache_read_tokens + b.cache_write_tokens;
m.cost += b.cost;
byModel.set(b.model, m);
}
const rows = [...byModel].sort((a, b) => b[1].cost - a[1].cost);
return {
tokens: rows.reduce((n, [, m]) => n + m.tokens, 0),
lines: rows
.map(([model, m]) => `${formatModelName(model)}: ${fmt(m.tokens)} · ${fmtCostFull(m.cost)}`)
.join("\n"),
};
}
function CostFigure({ testId, label, r }: { testId: string; label: string; r: CostResult }) {
const { t } = useTranslation("nav");
const { tokens, lines } = perModel(r);
return (
<span data-testid={testId} tabIndex={0} title={lines || undefined} className="tabular-nums">
{label} {t("statusBar.tokens", { tokens: fmt(tokens) })}
</span>
);
}
export function StatusBar({
wsConnected,
left,
@@ -44,12 +78,12 @@ export function StatusBar({
updateAvailable?: boolean;
}) {
const { t } = useTranslation("nav");
const { lanes } = useLanes();
const running = lanes.filter((l) => l.status === "running").length;
const needYou = lanes.filter((l) => l.needs_action).length;
const [today, setToday] = useState<number | null>(null);
const [laneCost, setLaneCost] = useState<number | null>(null);
const [sessionCost, setSessionCost] = useState<number | null>(null);
const [active, setActive] = useState(0);
const [needYou, setNeedYou] = useState(0);
const running = Math.max(active - needYou, 0);
const [today, setToday] = useState<CostResult | null>(null);
const [laneCost, setLaneCost] = useState<CostResult | null>(null);
const [sessionCost, setSessionCost] = useState<CostResult | null>(null);
const [runs, setRuns] = useState<RunHandle[]>([]);
const location = useLocation();
const shown = selectedFrom(location.pathname, location.search);
@@ -63,6 +97,22 @@ export function StatusBar({
return () => window.removeEventListener(LIVE_RUNS_EVENT, take);
}, []);
const loadCounts = useCallback(() => {
api.sessions
.list({ status: "active", limit: 1 })
.then((r) => setActive(r.total))
.catch(() => undefined);
api.sessions
.list({ awaiting: true, limit: 1 })
.then((r) => setNeedYou(r.total))
.catch(() => undefined);
}, []);
useEffect(loadCounts, [loadCounts]);
useLiveRefresh(loadCounts, {
intervalMs: 15_000,
match: (msg) => msg.type === "session_updated" || msg.type === "session_created",
});
useEffect(() => {
let live = true;
// Another workspace or session: never show the previous one's figure.
@@ -71,19 +121,19 @@ export function StatusBar({
const tick = () => {
if (document.visibilityState !== "visible") return;
api.pricing
.totalCost()
.then((r) => live && setToday(r.daily_costs.find((d) => d.date === localDate())?.cost ?? 0))
.totalCost({ date: localDate() })
.then((r) => live && setToday(r))
.catch(() => undefined);
if (lane !== null) {
api.pricing
.totalCost({ lane })
.then((r) => live && setLaneCost(r.total_cost))
.then((r) => live && setLaneCost(r))
.catch(() => undefined);
}
if (sessionId) {
api.pricing
.sessionCost(sessionId)
.then((r) => live && setSessionCost(r.total_cost))
.then((r) => live && setSessionCost(r))
.catch(() => undefined);
}
};
@@ -96,7 +146,6 @@ export function StatusBar({
document.removeEventListener("visibilitychange", tick);
};
}, [lane, sessionId]);
const usd = (n: number) => `$${n.toFixed(2)}`;
return (
<footer
@@ -144,19 +193,25 @@ export function StatusBar({
</span>
</span>
{today !== null && (
<span data-testid="status-bar-cost" className="tabular-nums">
{t("statusBar.today", { cost: usd(today) })}
</span>
<CostFigure
testId="status-bar-cost"
label={t("statusBar.today", { cost: usd(today.total_cost) })}
r={today}
/>
)}
{lane !== null && laneCost !== null && (
<span data-testid="status-bar-workspace-cost" className="tabular-nums">
{t("statusBar.workspace", { cost: usd(laneCost) })}
</span>
<CostFigure
testId="status-bar-workspace-cost"
label={t("statusBar.workspace", { cost: usd(laneCost.total_cost) })}
r={laneCost}
/>
)}
{sessionId && sessionCost !== null && (
<span data-testid="status-bar-session-cost" className="tabular-nums">
{t("statusBar.session", { cost: usd(sessionCost) })}
</span>
<CostFigure
testId="status-bar-session-cost"
label={t("statusBar.session", { cost: usd(sessionCost.total_cost) })}
r={sessionCost}
/>
)}
</footer>
);
+75 -32
View File
@@ -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"
/>
);
}
@@ -0,0 +1,69 @@
/**
* @file PathPickButton.test.tsx
* @description Unit tests for the Browse button beside path fields: a chosen
* path fills the field, a Cancel changes nothing, a folder falls back to the
* in-app browser on ANY native-picker failure, and a file falls back to a
* "type the path" hint only when no picker exists for this browser.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { api } from "../../lib/api";
import { toast } from "../../lib/toast";
import { PathPickButton } from "../PathPickButton";
vi.mock("../../lib/api", () => ({
api: { lanes: { pickPath: vi.fn(), browse: vi.fn() } },
}));
vi.mock("../../lib/toast", () => ({
toast: { info: vi.fn(), error: vi.fn(), success: vi.fn() },
}));
const fail = (code: string) => Object.assign(new Error(code), { code });
beforeEach(() => {
vi.mocked(api.lanes.pickPath).mockReset();
vi.mocked(api.lanes.browse)
.mockReset()
.mockResolvedValue({ path: "/tmp", parent: "/", entries: [] });
vi.mocked(toast.info).mockReset();
vi.mocked(toast.error).mockReset();
});
describe("PathPickButton", () => {
it("fills the field with the chosen path and leaves it alone on Cancel", async () => {
const onPick = vi.fn();
vi.mocked(api.lanes.pickPath).mockResolvedValueOnce({ path: "/home/u/proj" });
vi.mocked(api.lanes.pickPath).mockResolvedValueOnce({ cancelled: true });
render(<PathPickButton start=" /home/u " onPick={onPick} />);
const button = screen.getByTitle("Browse for a folder");
await userEvent.click(button);
await waitFor(() => expect(onPick).toHaveBeenCalledWith("/home/u/proj"));
expect(api.lanes.pickPath).toHaveBeenCalledWith("dir", "/home/u");
await userEvent.click(button);
await waitFor(() => expect(api.lanes.pickPath).toHaveBeenCalledTimes(2));
expect(onPick).toHaveBeenCalledTimes(1);
});
it("opens the in-app folder browser on any failure, not only a missing picker", async () => {
vi.mocked(api.lanes.pickPath).mockRejectedValue(fail("EBUSY"));
render(<PathPickButton onPick={vi.fn()} />);
await userEvent.click(screen.getByTitle("Browse for a folder"));
expect(await screen.findByRole("dialog", { name: "Browse for a folder" })).toBeInTheDocument();
expect(toast.error).not.toHaveBeenCalled();
});
it("hints to type a file path when no picker exists, and reports any other failure", async () => {
vi.mocked(api.lanes.pickPath).mockRejectedValueOnce(fail("ENODIALOG"));
vi.mocked(api.lanes.pickPath).mockRejectedValueOnce(fail("EDIALOG"));
render(<PathPickButton kind="file" onPick={vi.fn()} />);
const button = screen.getByRole("button");
await userEvent.click(button);
await waitFor(() => expect(toast.info).toHaveBeenCalledTimes(1));
await userEvent.click(button);
await waitFor(() => expect(toast.error).toHaveBeenCalledWith("EDIALOG"));
expect(screen.queryByRole("dialog")).toBeNull();
});
});
@@ -2,9 +2,9 @@
* @file StatusBar.test.tsx
* @description Tests for the shell's bottom status bar: the live/disconnected
* state with the version (click opens the connection details), the running and
* needs-you counts from the shared lane list, today's spend from the
* pricing cost call's `daily_costs`, and on the Workspace page the open
* workspace's and the shown session's totals.
* needs-you SESSION counts, today's spend (`date=` on the pricing cost call)
* with its tokens and a per-model hover list, and on the Workspace page the
* open workspace's and the shown session's totals.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
@@ -13,6 +13,8 @@ import { act, render, screen } from "@testing-library/react";
import { MemoryRouter } from "react-router-dom";
import userEvent from "@testing-library/user-event";
const M = 1_000_000;
function today() {
const d = new Date();
const pad = (n: number) => String(n).padStart(2, "0");
@@ -21,24 +23,50 @@ function today() {
vi.mock("../../lib/api", () => ({
api: {
lanes: {
list: vi.fn().mockResolvedValue({
lanes: [
{ id: 1, status: "running", needs_action: null },
{ id: 2, status: "running", needs_action: "permission" },
{ id: 3, status: "idle", needs_action: null },
],
counts: {},
}),
sessions: {
// 3 active sessions, 1 of them waiting on the user.
list: vi.fn().mockImplementation(async (p: { awaiting?: boolean }) => ({
sessions: [],
total: p.awaiting ? 1 : 3,
})),
},
pricing: {
totalCost: vi.fn().mockImplementation(async (opts?: { lane?: number }) => ({
total_cost: opts?.lane ? 123.456 : 10,
breakdown: [],
daily_costs: [
{ date: "2000-01-01", cost: 9 },
{ date: today(), cost: 1.234 },
],
totalCost: vi.fn().mockImplementation(async (opts?: { lane?: number; date?: string }) => ({
total_cost: opts?.lane ? 123.456 : opts?.date === today() ? 1.234 : 10,
breakdown: opts?.lane
? []
: [
// Two buckets of one model fold into one line.
{
model: "claude-opus-4-8",
input_tokens: 1 * M,
output_tokens: 0,
cache_read_tokens: 10 * M,
cache_write_tokens: 0,
cost: 1,
matched_rule: null,
},
{
model: "claude-opus-4-8",
speed: "fast",
input_tokens: 0,
output_tokens: 1 * M,
cache_read_tokens: 0,
cache_write_tokens: 0,
cost: 0.2,
matched_rule: null,
},
{
model: "claude-haiku-4-5",
input_tokens: 300_000,
output_tokens: 0,
cache_read_tokens: 0,
cache_write_tokens: 0,
cost: 0.034,
matched_rule: null,
},
],
daily_costs: [],
})),
sessionCost: vi.fn().mockImplementation(async (id: string) => ({
total_cost: id === "s-live" ? 4.5 : 7,
@@ -63,7 +91,13 @@ describe("StatusBar", () => {
at("/", <StatusBar wsConnected left="15rem" onConnectionClick={onConnectionClick} />);
const conn = screen.getByRole("button", { name: /Live/ });
expect(conn).toHaveTextContent(`v${__APP_VERSION__}`);
expect(await screen.findByText("Today $1.23")).toBeInTheDocument();
const cost = await screen.findByText("Today $1.23 (12.3M Tokens)");
expect(api.pricing.totalCost).toHaveBeenCalledWith({ date: today() });
expect(cost).toHaveAttribute(
"title",
"Claude Opus 4.8: 12.0M · $1.20\nClaude Haiku 4.5: 300.0K · $0.03"
);
await screen.findByText("1 need you");
expect(screen.getByTestId("status-bar-counts").textContent).toBe("2 running · 1 need you");
await user.click(conn);
expect(onConnectionClick).toHaveBeenCalled();
@@ -79,7 +113,7 @@ describe("StatusBar", () => {
it("off the Workspace page shows no workspace or session cost", async () => {
at("/sessions", <StatusBar wsConnected left="0" onConnectionClick={() => {}} />);
await screen.findByText("Today $1.23");
await screen.findByText(/^Today \$1\.23/);
expect(screen.queryByTestId("status-bar-workspace-cost")).toBeNull();
expect(screen.queryByTestId("status-bar-session-cost")).toBeNull();
});
@@ -89,8 +123,8 @@ describe("StatusBar", () => {
"/workspace?lane=3&session=s-old",
<StatusBar wsConnected left="0" onConnectionClick={() => {}} />
);
expect(await screen.findByText("Workspace $123.46")).toBeInTheDocument();
expect(await screen.findByText("Session $7.00")).toBeInTheDocument();
expect(await screen.findByText("Workspace $123.46 (0 Tokens)")).toBeInTheDocument();
expect(await screen.findByText(/^Session \$7\.00/)).toBeInTheDocument();
expect(api.pricing.totalCost).toHaveBeenCalledWith({ lane: 3 });
});
@@ -99,7 +133,7 @@ describe("StatusBar", () => {
"/workspace?lane=3&run=agenthub-lane-3-1",
<StatusBar wsConnected left="0" onConnectionClick={() => {}} />
);
await screen.findByText("Workspace $123.46");
await screen.findByText(/^Workspace \$123\.46/);
expect(screen.queryByTestId("status-bar-session-cost")).toBeNull();
act(() => {
window.dispatchEvent(
@@ -108,6 +142,6 @@ describe("StatusBar", () => {
})
);
});
expect(await screen.findByText("Session $4.50")).toBeInTheDocument();
expect(await screen.findByText(/^Session \$4\.50/)).toBeInTheDocument();
});
});
@@ -81,6 +81,7 @@ vi.mock("../../lib/api", async (importOriginal) => {
})),
cwds: vi.fn(async () => ({ items: [] })),
kill: vi.fn(async () => ({ ok: true })),
history: vi.fn(async () => ({ items: [] })),
},
aiTools: {
list: vi.fn(async () => ({ tools: [], default: "claude" })),
@@ -307,7 +308,7 @@ describe("WorkspaceTree", () => {
expect(api.lanes.action).not.toHaveBeenCalled();
});
it("shows a live tab whose session has no row yet as a New session, never a shell", async () => {
it("shows a live tab whose session has no row yet as a New session, and a shell as Terminal", async () => {
vi.mocked(api.run.list).mockImplementation(
async () =>
({
@@ -341,27 +342,95 @@ describe("WorkspaceTree", () => {
await user.click(await screen.findByTestId("workspace-toggle-1"));
const fresh = await screen.findByTestId("workspace-session-new-agenthub-lane-1");
expect(fresh).toHaveTextContent("New session");
expect(screen.queryByTestId("workspace-session-new-agenthub-lane-1-3")).toBeNull();
expect(screen.getByTestId("workspace-session-new-agenthub-lane-1-3")).toHaveTextContent(
"Terminal"
);
expect(screen.queryByTestId("workspace-session-new-agenthub-lane-1-2")).toBeNull();
await user.click(fresh);
expect(screen.getByTestId("where").textContent).toBe("/workspace?lane=1&run=agenthub-lane-1");
});
it("lists a workspace's closed terminals, and a click opens a new shell tab", async () => {
vi.mocked(api.run.history).mockImplementation(
async () =>
({
items: [
{
id: "agenthub-lane-1-2",
tool: "shell",
status: "running",
started_at: "2026-10-06T10:00:00Z",
ended_at: null,
isLive: true,
},
{
id: "agenthub-lane-1-3@a",
tool: "shell",
status: "killed",
started_at: "2026-10-06T09:00:00Z",
ended_at: "2026-10-06T09:05:00Z",
isLive: false,
},
{
id: "agenthub-lane-1-1@b",
tool: "shell",
status: "exited",
started_at: "2026-10-06T08:00:00Z",
ended_at: "2026-10-06T08:05:00Z",
isLive: false,
},
{
id: "agenthub-lane-1-1@c",
tool: "shell",
status: "exited",
started_at: "2026-10-06T07:00:00Z",
ended_at: "2026-10-06T07:05:00Z",
isLive: false,
},
{
id: "agenthub-lane-1-1@d",
tool: "shell",
status: "exited",
started_at: "2026-10-06T06:00:00Z",
ended_at: "2026-10-06T06:05:00Z",
isLive: false,
},
],
}) as never
);
vi.mocked(api.lanes.action).mockImplementation(
async () => ({ ok: true, run_id: "agenthub-lane-1-3" }) as never
);
const user = userEvent.setup();
renderTree();
await user.click(await screen.findByTestId("workspace-toggle-1"));
const closed = await screen.findByTestId("workspace-terminal-closed-agenthub-lane-1-3@a");
expect(closed).toHaveTextContent("Terminal");
expect(api.run.history).toHaveBeenCalledWith(
expect.objectContaining({ laneId: 1, tool: "shell" })
);
// The live one is not history, and only the newest three are listed.
expect(screen.queryByTestId("workspace-terminal-closed-agenthub-lane-1-2")).toBeNull();
expect(screen.getAllByTestId(/^workspace-terminal-closed-/)).toHaveLength(3);
await user.click(closed);
expect(api.lanes.action).toHaveBeenCalledWith(1, "start", { shell: true, slot: "auto" });
expect(screen.getByTestId("where").textContent).toBe("/workspace?lane=1&run=agenthub-lane-1-3");
});
it("selects by URL, and a click selects and expands", async () => {
const user = userEvent.setup();
renderTree("/workspace?lane=2");
expect(await screen.findByTestId("workspace-open-2")).toHaveAttribute("aria-current", "true");
// Lane 1 has a live tab: its row opens the tab, not the home.
// Lane 1 has a live tab: its row still opens the home.
await waitFor(() => expect(api.run.list).toHaveBeenCalled());
await user.click(screen.getByTestId("workspace-open-1"));
expect(screen.getByTestId("where").textContent).toBe("/workspace?lane=1&run=agenthub-lane-1-2");
expect(screen.getByTestId("where").textContent).toBe("/workspace?lane=1");
expect(await screen.findByTestId("workspace-sessions-1")).toBeInTheDocument();
// Lane 2 has none: its home.
await user.click(screen.getByTestId("workspace-open-2"));
expect(screen.getByTestId("where").textContent).toBe("/workspace?lane=2");
});
it("opens a workspace on the pane it last focused, and follows the page's run list", async () => {
it("opens a workspace's home even while it has live tabs", async () => {
vi.mocked(api.run.list).mockImplementation(async () => ({ items: [] }) as never);
localStorage.setItem(
"agenthub.workspace.paneLayout",
@@ -393,7 +462,7 @@ describe("WorkspaceTree", () => {
);
});
await user.click(screen.getByTestId("workspace-open-1"));
expect(screen.getByTestId("where").textContent).toBe("/workspace?lane=1&run=agenthub-lane-1-2");
expect(screen.getByTestId("where").textContent).toBe("/workspace?lane=1");
});
it("reorders with Alt+ArrowUp and keeps the order in localStorage", async () => {
@@ -21,6 +21,7 @@ import { api, type CwdSuggestion } from "../../lib/api";
import { confirmDiscard } from "../../lib/confirm";
import { useDialog } from "../../hooks/useDialog";
import { useUnsavedGuard } from "../../hooks/useUnsavedGuard";
import { PathPickButton } from "../PathPickButton";
interface ProjectMemoryModalProps {
/** i18n namespace of the calling page — "ccConfig" or "codexConfig". */
@@ -133,15 +134,18 @@ export function ProjectMemoryModal({
>
{t("projectMemory.dirLabel")}
</label>
<input
id={`${listId}-dir`}
list={listId}
value={dir}
onChange={(e) => setDir(e.target.value)}
placeholder="/home/you/projects/my-app"
spellCheck={false}
className="w-full bg-surface-2 border border-border rounded-md px-3 py-1.5 text-sm font-mono text-fg-primary placeholder:text-fg-muted focus:outline-none focus:border-accent/50"
/>
<div className="flex gap-1.5">
<input
id={`${listId}-dir`}
list={listId}
value={dir}
onChange={(e) => setDir(e.target.value)}
placeholder="/home/you/projects/my-app"
spellCheck={false}
className="w-full bg-surface-2 border border-border rounded-md px-3 py-1.5 text-sm font-mono text-fg-primary placeholder:text-fg-muted focus:outline-none focus:border-accent/50"
/>
<PathPickButton start={dir} onPick={setDir} />
</div>
<datalist id={listId}>
{suggestions.map((s) => (
<option key={s.path} value={s.path}>
+352 -325
View File
@@ -8,21 +8,21 @@
* manually-typed branch name. The user also picks which CLI drives the lane
* (Claude Code or Codex) — this choice is immutable after creation. Either
* mode's path field can be filled by typing, by the CwdAutocomplete
* suggestions, or by browsing (`FolderBrowseModal`) — a native folder picker
* cannot hand a web page an absolute filesystem path, so browsing is
* server-backed instead. The worktree lane returned is `status: "provisioning"`:
* suggestions, or by Browse (`PathPickButton`: the OS's own folder picker
* opened by the server, the in-app `FolderBrowseModal` where it cannot be).
* The worktree lane returned is `status: "provisioning"`:
* the route answers before the actual `git worktree add` runs, so this
* component polls `GET /api/lanes/:id` until that finishes before running the
* auto-setup calls against a cwd that must actually exist on disk first.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useCallback, useEffect, useRef, useState } from "react";
import { useCallback, useEffect, useId, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { ChevronDown, FolderOpen } from "lucide-react";
import { ConfirmModal } from "../ConfirmModal";
import { ChevronDown, X } from "lucide-react";
import { useDialog } from "../../hooks/useDialog";
import { CwdAutocomplete } from "../run/RunSetup";
import { FolderBrowseModal } from "./FolderBrowseModal";
import { PathPickButton } from "../PathPickButton";
import { api } from "../../lib/api";
import type { CwdSuggestion } from "../../lib/api";
import type { Lane, LaneWorktree } from "../../lib/types";
@@ -124,7 +124,6 @@ export function AddLaneModal({
const noTools = aiTools.loaded && aiTools.defaultId === null;
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const [browseOpen, setBrowseOpen] = useState(false);
const [setupResult, setSetupResult] = useState<{
profile: "scaffolded" | "skipped" | "failed";
mcp: "ok" | "failed";
@@ -306,23 +305,18 @@ export function AddLaneModal({
}
};
// ConfirmModal re-focuses its Cancel button in an effect keyed on `onCancel`'s
// identity. Every keystroke in the fields below re-renders this component; an
// inline `() => {...}` handed to `onCancel` would get a new identity each
// time, re-running that effect and yanking focus off the field being typed
// into after the very first character. useCallback keeps the identity stable
// across renders so only mount/unmount (and a real onClose change) refocuses.
// Escape, the backdrop, the X and Cancel all land here. A folder browser
// open on top answers its own Escape and backdrop (PathPickButton).
const handleCancel = useCallback(() => {
// ConfirmModal's own Escape/backdrop/X handling calls this directly.
// While the folder browser is open on top of it, that dismissal should
// close only the browser, not both modals at once.
if (browseOpen) {
setBrowseOpen(false);
return;
}
reset();
onClose();
}, [onClose, browseOpen]);
}, [onClose]);
// Cancel takes focus on open, the safer default.
const titleId = useId();
const panelRef = useRef<HTMLDivElement>(null);
const cancelRef = useRef<HTMLButtonElement>(null);
useDialog(open, panelRef, handleCancel, cancelRef);
// A summary on screen means the lane already exists and a step failed, so
// the confirm button has no second creation to run: it closes instead. That
@@ -341,336 +335,369 @@ export function AddLaneModal({
);
const freeBranches = (branches || []).filter((b) => !busyBranches.has(b));
if (!open) return null;
// Its own shell, not ConfirmModal: that one lays its body beside the title in
// the X button's row, which left this form narrower than its own footer.
return (
<ConfirmModal
open={open}
title={t("addLane")}
confirmLabel={done ? t("addLaneSetupClose") : t("add")}
cancelLabel={t("destructive.cancel")}
destructive={false}
busy={busy}
disabled={disabled}
onConfirm={done ? handleCancel : submit}
onCancel={handleCancel}
<div
className="fixed inset-0 z-[60] flex items-center justify-center bg-black/60 p-4"
onClick={handleCancel}
role="presentation"
>
<div className="space-y-3">
<div className="flex items-center rounded-md border border-border bg-surface-2 p-0.5">
<Seg
active={mode === "repo"}
label={t("mode.repo")}
title={t("mode.repoHint")}
onClick={() => setMode("repo")}
/>
<Seg
active={mode === "worktree"}
label={t("mode.worktree")}
title={t("mode.worktreeHint")}
onClick={() => setMode("worktree")}
/>
<div
ref={panelRef}
className="relative flex max-h-[90vh] w-full max-w-lg flex-col rounded-xl border border-border bg-surface-1 shadow-xl shadow-black/40"
onClick={(e) => e.stopPropagation()}
role="dialog"
aria-modal="true"
aria-labelledby={titleId}
>
<div className="flex items-center gap-3 border-b border-border px-5 py-3.5">
<h3 id={titleId} className="min-w-0 flex-1 text-sm font-semibold text-fg-primary">
{t("addLane")}
</h3>
<button
type="button"
onClick={handleCancel}
className="btn-icon -mr-1"
aria-label={t("destructive.cancel")}
>
<X className="h-4 w-4" aria-hidden />
</button>
</div>
<div>
<label className="mb-1 block text-xs text-fg-secondary">{t("addLaneToolHint")}</label>
{noTools ? (
<p className="rounded-md border border-status-warning/40 bg-status-warning/10 px-2 py-1.5 text-xs text-status-warning">
{t("addLaneNoTools")}{" "}
<a href="/settings/agents" className="underline">
{t("addLaneNoToolsLink")}
</a>
</p>
) : (
<div>
<button
type="button"
aria-expanded={toolListOpen}
disabled={toolChoices.length < 2}
onClick={() => setToolListOpen((o) => !o)}
className="flex w-full items-center gap-2 rounded-md border border-border bg-surface-2 px-2.5 py-1.5 text-left text-sm text-fg-primary hover:bg-surface-3 disabled:cursor-default disabled:hover:bg-surface-2"
>
<AiToolIcon tool={{ id: tool, label: aiTools.label(tool) }} className="h-5 w-5" />
<span className="min-w-0 flex-1 truncate">{aiTools.label(tool)}</span>
{tool === defaultTool && (
<span className="rounded-full bg-accent/15 px-2 py-0.5 text-[10px] font-medium text-accent">
{t("addLaneToolDefaultBadge")}
</span>
)}
{toolChoices.length > 1 && (
<ChevronDown
aria-hidden
className={`h-4 w-4 text-fg-muted transition-transform ${toolListOpen ? "rotate-180" : ""}`}
/>
)}
</button>
{toolListOpen && (
<div
role="group"
aria-label={t("addLaneToolHint")}
className="mt-1.5 grid grid-cols-2 gap-1.5 sm:grid-cols-3"
>
{toolChoices.map((x) => (
<button
key={x.id}
type="button"
aria-pressed={x.id === tool}
onClick={() => pickTool(x.id)}
className={`flex items-center gap-2 rounded-md border px-2 py-1.5 text-left text-xs transition-colors ${
x.id === tool
? "border-accent bg-accent/10 text-accent"
: "border-border bg-surface-2 text-fg-secondary hover:bg-surface-3 hover:text-fg-primary"
}`}
>
<AiToolIcon tool={x} className="h-5 w-5" />
<span className="min-w-0 flex-1 truncate">{x.label}</span>
</button>
))}
</div>
)}
</div>
)}
</div>
<div>
<label className="mb-1 block text-xs text-fg-secondary" htmlFor="add-lane-repo">
{mode === "repo" ? t("addLaneRepoLabelAdopt") : t("addLaneRepoLabel")}
</label>
<div className="flex gap-1.5">
<div className="min-w-0 flex-1">
<CwdAutocomplete
inputId="add-lane-repo"
value={sourceRepo}
onChange={setSourceRepo}
suggestions={cwdSuggestions}
/>
</div>
<button
type="button"
onClick={() => setBrowseOpen(true)}
title={t("browse.title")}
className="flex items-center gap-1 rounded-md border border-border-light px-2 text-xs text-fg-secondary hover:bg-surface-2"
>
<FolderOpen className="h-3.5 w-3.5" />
{t("browse.button")}
</button>
<div className="min-h-0 flex-1 space-y-4 overflow-y-auto px-5 py-4">
<div className="flex items-center rounded-md border border-border bg-surface-2 p-0.5">
<Seg
active={mode === "repo"}
label={t("mode.repo")}
title={t("mode.repoHint")}
onClick={() => setMode("repo")}
/>
<Seg
active={mode === "worktree"}
label={t("mode.worktree")}
title={t("mode.worktreeHint")}
onClick={() => setMode("worktree")}
/>
</div>
<p className="mt-1 text-[10px] text-fg-muted">
{mode === "repo" ? t("addLaneRepoHintAdopt") : t("addLaneRepoHint")}
</p>
</div>
{/* What this repo already has checked out. Without it the form asked
<div>
<label className="mb-1 block text-xs text-fg-secondary">{t("addLaneToolHint")}</label>
{noTools ? (
<p className="rounded-md border border-status-warning/40 bg-status-warning/10 px-2 py-1.5 text-xs text-status-warning">
{t("addLaneNoTools")}{" "}
<a href="/settings/agents" className="underline">
{t("addLaneNoToolsLink")}
</a>
</p>
) : (
<div>
<button
type="button"
aria-expanded={toolListOpen}
disabled={toolChoices.length < 2}
onClick={() => setToolListOpen((o) => !o)}
className="flex w-full items-center gap-2 rounded-md border border-border bg-surface-2 px-2.5 py-1.5 text-left text-sm text-fg-primary hover:bg-surface-3 disabled:cursor-default disabled:hover:bg-surface-2"
>
<AiToolIcon tool={{ id: tool, label: aiTools.label(tool) }} className="h-5 w-5" />
<span className="min-w-0 flex-1 truncate">{aiTools.label(tool)}</span>
{tool === defaultTool && (
<span className="rounded-full bg-accent/15 px-2 py-0.5 text-[10px] font-medium text-accent">
{t("addLaneToolDefaultBadge")}
</span>
)}
{toolChoices.length > 1 && (
<ChevronDown
aria-hidden
className={`h-4 w-4 text-fg-muted transition-transform ${toolListOpen ? "rotate-180" : ""}`}
/>
)}
</button>
{toolListOpen && (
<div
role="group"
aria-label={t("addLaneToolHint")}
className="mt-1.5 grid grid-cols-2 gap-1.5 sm:grid-cols-3"
>
{toolChoices.map((x) => (
<button
key={x.id}
type="button"
aria-pressed={x.id === tool}
onClick={() => pickTool(x.id)}
className={`flex items-center gap-2 rounded-md border px-2 py-1.5 text-left text-xs transition-colors ${
x.id === tool
? "border-accent bg-accent/10 text-accent"
: "border-border bg-surface-2 text-fg-secondary hover:bg-surface-3 hover:text-fg-primary"
}`}
>
<AiToolIcon tool={x} className="h-5 w-5" />
<span className="min-w-0 flex-1 truncate">{x.label}</span>
</button>
))}
</div>
)}
</div>
)}
</div>
<div>
<label className="mb-1 block text-xs text-fg-secondary" htmlFor="add-lane-repo">
{mode === "repo" ? t("addLaneRepoLabelAdopt") : t("addLaneRepoLabel")}
</label>
<div className="flex gap-1.5">
<div className="min-w-0 flex-1">
<CwdAutocomplete
inputId="add-lane-repo"
value={sourceRepo}
onChange={setSourceRepo}
suggestions={cwdSuggestions}
/>
</div>
<PathPickButton start={sourceRepo} onPick={setSourceRepo} showLabel />
</div>
<p className="mt-1 text-[10px] text-fg-muted">
{mode === "repo" ? t("addLaneRepoHintAdopt") : t("addLaneRepoHint")}
</p>
</div>
{/* What this repo already has checked out. Without it the form asked
for a branch in the dark: the one fact that decides whether a
branch can be used at all is which worktree is holding it. */}
{mode === "worktree" && worktrees.length > 0 && (
<div
data-testid="add-lane-worktrees"
className="rounded-md border border-border-light bg-surface-0 p-2"
>
<p className="mb-1 text-[10px] font-medium text-fg-secondary">
{t("addLaneWorktreesTitle", { count: worktrees.length })}
</p>
<ul className="space-y-0.5">
{worktrees.slice(0, 8).map((w) => (
<li key={w.path} className="flex items-baseline gap-1.5 text-[10px]">
<span className="shrink-0 font-mono text-fg-primary">
⑂ {w.branch || t("addLaneWorktreeDetached")}
</span>
<span className="truncate text-fg-muted" title={w.path}>
{w.path}
</span>
</li>
))}
{worktrees.length > 8 && (
<li className="text-[10px] text-fg-muted">
{t("addLaneWorktreesMore", { count: worktrees.length - 8 })}
</li>
)}
</ul>
{mode === "worktree" && worktrees.length > 0 && (
<div
data-testid="add-lane-worktrees"
className="rounded-md border border-border-light bg-surface-0 p-2"
>
<p className="mb-1 text-[10px] font-medium text-fg-secondary">
{t("addLaneWorktreesTitle", { count: worktrees.length })}
</p>
<ul className="space-y-0.5">
{worktrees.slice(0, 8).map((w) => (
<li key={w.path} className="flex items-baseline gap-1.5 text-[10px]">
<span className="shrink-0 font-mono text-fg-primary">
⑂ {w.branch || t("addLaneWorktreeDetached")}
</span>
<span className="truncate text-fg-muted" title={w.path}>
{w.path}
</span>
</li>
))}
{worktrees.length > 8 && (
<li className="text-[10px] text-fg-muted">
{t("addLaneWorktreesMore", { count: worktrees.length - 8 })}
</li>
)}
</ul>
</div>
)}
<div>
<label className="mb-1 block text-xs text-fg-secondary" htmlFor="add-lane-title">
{t("addLaneTitleLabel")}
</label>
<input
id="add-lane-title"
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder={t("addLaneTitlePlaceholder")}
className="w-full rounded-md border border-border-light bg-surface-0 px-3 py-1.5 text-xs text-fg-primary placeholder:text-fg-muted focus:border-accent focus:outline-none"
/>
</div>
)}
<div>
<label className="mb-1 block text-xs text-fg-secondary" htmlFor="add-lane-title">
{t("addLaneTitleLabel")}
</label>
<input
id="add-lane-title"
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder={t("addLaneTitlePlaceholder")}
className="w-full rounded-md border border-border-light bg-surface-0 px-3 py-1.5 text-xs text-fg-primary placeholder:text-fg-muted focus:border-accent focus:outline-none"
/>
</div>
{mode === "worktree" && branchesError && !branches && (
<p className="text-[10px] text-status-warning">{branchesError}</p>
)}
{mode === "worktree" && branchesError && !branches && (
<p className="text-[10px] text-status-warning">{branchesError}</p>
)}
{mode === "worktree" && branches && (
<div data-testid="add-lane-branch-section" className="space-y-2">
<div className="flex items-center rounded-md border border-border bg-surface-2 p-0.5">
<Seg
active={branchMode === "new"}
label={t("addLaneBranchModeNew")}
onClick={() => {
setBranchMode("new");
setBranch("");
}}
/>
<Seg
active={branchMode === "existing"}
label={t("addLaneBranchModeExisting")}
onClick={() => {
setBranchMode("existing");
// Land on something usable rather than on an empty select
// that looks like a choice was already made.
setBranch((current) =>
freeBranches.includes(current) ? current : freeBranches[0] || ""
);
}}
/>
{/* The third answer to "which branch": none. A small or personal
{mode === "worktree" && branches && (
<div data-testid="add-lane-branch-section" className="space-y-2">
<div className="flex items-center rounded-md border border-border bg-surface-2 p-0.5">
<Seg
active={branchMode === "new"}
label={t("addLaneBranchModeNew")}
onClick={() => {
setBranchMode("new");
setBranch("");
}}
/>
<Seg
active={branchMode === "existing"}
label={t("addLaneBranchModeExisting")}
onClick={() => {
setBranchMode("existing");
// Land on something usable rather than on an empty select
// that looks like a choice was already made.
setBranch((current) =>
freeBranches.includes(current) ? current : freeBranches[0] || ""
);
}}
/>
{/* The third answer to "which branch": none. A small or personal
project usually wants the lane to BE the repo, working on
whatever is checked out (`main`) — which is exactly Repo
mode, so this jumps there rather than inventing a second way
to do it. git cannot check `main` out in a second worktree
while the repo itself holds it, so a worktree lane could not
offer this even if it wanted to. */}
<Seg
active={false}
label={t("addLaneBranchModeCurrent")}
title={t("addLaneBranchModeCurrentHint")}
onClick={() => setMode("repo")}
/>
</div>
<Seg
active={false}
label={t("addLaneBranchModeCurrent")}
title={t("addLaneBranchModeCurrentHint")}
onClick={() => setMode("repo")}
/>
</div>
{branchMode === "new" ? (
<>
<div>
<label className="mb-1 block text-xs text-fg-secondary" htmlFor="add-lane-base">
{t("addLaneBaseLabel")}
</label>
{branches.length === 0 ? (
// No commit, nothing to fork: the server refuses this with
// EEMPTYREPO, so the one way forward is offered instead.
<div className="space-y-1">
<p className="text-[10px] text-fg-muted">{t("addLaneNoBranches")}</p>
<button
type="button"
onClick={() => setMode("repo")}
className="rounded-md border border-border-light px-2 py-0.5 text-xs text-fg-secondary hover:bg-surface-2"
{branchMode === "new" ? (
<>
<div>
<label className="mb-1 block text-xs text-fg-secondary" htmlFor="add-lane-base">
{t("addLaneBaseLabel")}
</label>
{branches.length === 0 ? (
// No commit, nothing to fork: the server refuses this with
// EEMPTYREPO, so the one way forward is offered instead.
<div className="space-y-1">
<p className="text-[10px] text-fg-muted">{t("addLaneNoBranches")}</p>
<button
type="button"
onClick={() => setMode("repo")}
className="rounded-md border border-border-light px-2 py-0.5 text-xs text-fg-secondary hover:bg-surface-2"
>
{t("addLaneNoBranchesUseRepo")}
</button>
</div>
) : (
<select
id="add-lane-base"
value={base}
onChange={(e) => setBase(e.target.value)}
className="select w-full !py-1.5 !text-xs"
>
{t("addLaneNoBranchesUseRepo")}
</button>
</div>
{branches.map((b) => (
<option key={b} value={b}>
{b}
</option>
))}
</select>
)}
</div>
<div>
<label
className="mb-1 block text-xs text-fg-secondary"
htmlFor="add-lane-branch"
>
{t("addLaneBranchLabel")}
</label>
<input
id="add-lane-branch"
value={branch}
onChange={(e) => setBranch(e.target.value)}
// Neutral on purpose: an empty field lets the SERVER name
// the branch, under a LANE_BRANCH_PREFIX this form cannot
// see, so any `feat/…` preview here could be wrong.
placeholder={t("addLaneBranchPlaceholder")}
className="w-full rounded-md border border-border-light bg-surface-0 px-3 py-1.5 font-mono text-xs text-fg-primary placeholder:text-fg-muted focus:border-accent focus:outline-none"
/>
<p className="mt-1 text-[10px] text-fg-muted">{t("addLaneBranchHintAuto")}</p>
</div>
</>
) : (
<div>
<label
className="mb-1 block text-xs text-fg-secondary"
htmlFor="add-lane-existing"
>
{t("addLaneBranchExistingLabel")}
</label>
{freeBranches.length === 0 ? (
<p className="text-[10px] text-status-warning">{t("addLaneNoFreeBranches")}</p>
) : (
<select
id="add-lane-base"
value={base}
onChange={(e) => setBase(e.target.value)}
className="select w-full !py-1.5 !text-xs"
id="add-lane-existing"
value={branch}
onChange={(e) => setBranch(e.target.value)}
className="select w-full !py-1.5 font-mono !text-xs"
>
{branches.map((b) => (
<option key={b} value={b}>
{b}
</option>
))}
{branches.map((b) => {
const busyAt = busyBranches.get(b);
return (
// Disabled rather than hidden: "why is my branch not
// in the list" is a worse question than seeing it
// greyed out with the worktree that holds it.
<option key={b} value={b} disabled={!!busyAt}>
{busyAt ? t("addLaneBranchBusy", { branch: b, path: busyAt }) : b}
</option>
);
})}
</select>
)}
<p className="mt-1 text-[10px] text-fg-muted">{t("addLaneBranchExistingHint")}</p>
</div>
<div>
<label className="mb-1 block text-xs text-fg-secondary" htmlFor="add-lane-branch">
{t("addLaneBranchLabel")}
</label>
<input
id="add-lane-branch"
value={branch}
onChange={(e) => setBranch(e.target.value)}
// Neutral on purpose: an empty field lets the SERVER name
// the branch, under a LANE_BRANCH_PREFIX this form cannot
// see, so any `feat/…` preview here could be wrong.
placeholder={t("addLaneBranchPlaceholder")}
className="w-full rounded-md border border-border-light bg-surface-0 px-3 py-1.5 font-mono text-xs text-fg-primary placeholder:text-fg-muted focus:border-accent focus:outline-none"
/>
<p className="mt-1 text-[10px] text-fg-muted">{t("addLaneBranchHintAuto")}</p>
</div>
</>
) : (
<div>
<label className="mb-1 block text-xs text-fg-secondary" htmlFor="add-lane-existing">
{t("addLaneBranchExistingLabel")}
</label>
{freeBranches.length === 0 ? (
<p className="text-[10px] text-status-warning">{t("addLaneNoFreeBranches")}</p>
) : (
<select
id="add-lane-existing"
value={branch}
onChange={(e) => setBranch(e.target.value)}
className="select w-full !py-1.5 font-mono !text-xs"
>
{branches.map((b) => {
const busyAt = busyBranches.get(b);
return (
// Disabled rather than hidden: "why is my branch not
// in the list" is a worse question than seeing it
// greyed out with the worktree that holds it.
<option key={b} value={b} disabled={!!busyAt}>
{busyAt ? t("addLaneBranchBusy", { branch: b, path: busyAt }) : b}
</option>
);
})}
</select>
)}
<p className="mt-1 text-[10px] text-fg-muted">{t("addLaneBranchExistingHint")}</p>
</div>
)}
</div>
)}
)}
</div>
)}
{setupResult && (
<div className="rounded-md border border-border-light bg-surface-0 p-2 space-y-1">
<p className="text-[10px] font-medium text-fg-secondary">{t("addLaneSetupTitle")}</p>
{(
[
{
label: t("addLaneSetupProfile"),
ok: setupResult.profile !== "failed",
skipped: setupResult.profile === "skipped",
},
{ label: t("addLaneSetupMcp"), ok: setupResult.mcp === "ok" },
] as const
).map((row) => (
<p key={row.label} className="flex items-center gap-1.5 text-[11px] text-fg-primary">
<span
className={
"skipped" in row && row.skipped
? "text-fg-muted"
: row.ok
? "text-status-success"
: "text-status-danger"
}
{setupResult && (
<div className="rounded-md border border-border-light bg-surface-0 p-2 space-y-1">
<p className="text-[10px] font-medium text-fg-secondary">{t("addLaneSetupTitle")}</p>
{(
[
{
label: t("addLaneSetupProfile"),
ok: setupResult.profile !== "failed",
skipped: setupResult.profile === "skipped",
},
{ label: t("addLaneSetupMcp"), ok: setupResult.mcp === "ok" },
] as const
).map((row) => (
<p
key={row.label}
className="flex items-center gap-1.5 text-[11px] text-fg-primary"
>
{"skipped" in row && row.skipped ? "–" : row.ok ? "✓" : "✗"}
</span>
{row.label}
</p>
))}
</div>
)}
<span
className={
"skipped" in row && row.skipped
? "text-fg-muted"
: row.ok
? "text-status-success"
: "text-status-danger"
}
>
{"skipped" in row && row.skipped ? "–" : row.ok ? "✓" : "✗"}
</span>
{row.label}
</p>
))}
</div>
)}
{error && (
<p role="alert" className="text-xs text-status-danger">
{error}
</p>
)}
{error && (
<p role="alert" className="text-xs text-status-danger">
{error}
</p>
)}
</div>
<div className="flex items-center justify-end gap-2 border-t border-border px-5 py-3">
<button
ref={cancelRef}
type="button"
onClick={handleCancel}
className="btn-ghost border border-border text-xs"
>
{t("destructive.cancel")}
</button>
<button
type="button"
onClick={done ? handleCancel : submit}
disabled={busy || disabled}
className="btn-primary text-xs disabled:opacity-50"
>
{done ? t("addLaneSetupClose") : t("add")}
</button>
</div>
</div>
<FolderBrowseModal
open={browseOpen}
initialPath={sourceRepo.trim() || undefined}
onSelect={setSourceRepo}
onClose={() => setBrowseOpen(false)}
/>
</ConfirmModal>
</div>
);
}
@@ -6,7 +6,8 @@
* actions; click one to open it
* review — every file's full diff, stacked, for reading a whole change-set
* file — one file, as a diff or as its whole content (issue #35); a
* whole markdown file can also be read rendered (Preview)
* whole markdown file can also be read rendered (Preview), and an
* image file is shown as the picture (`GET …/git/raw`)
* history — recent commits; opening one shows that commit's diff
* browse — quick-open by path, or the repo's tracked file tree (the
* Workspace right panel's Files tab; the other four are its
@@ -131,6 +132,33 @@ function Delta({
);
}
/** What the viewer shows as a picture (`readImageAt`'s list on the server). */
const IMAGE_FILE = /\.(png|jpe?g|gif|webp|avif|bmp|ico|svg)$/i;
/** An image file, shown as itself; a broken one (deleted, too big) says so. */
function ImageView({ src, path }: { src: string; path: string }) {
const { t } = useTranslation("lanes");
const [failed, setFailed] = useState(false);
useEffect(() => setFailed(false), [src]);
return (
<div
data-testid="lane-file-image"
className="flex min-h-0 flex-1 items-center justify-center overflow-auto rounded border border-border-light bg-surface-0 p-4"
>
{failed ? (
<p className="text-[11px] text-fg-muted">{t("changes.imageFailed")}</p>
) : (
<img
src={src}
alt={path}
onError={() => setFailed(true)}
className="max-h-full max-w-full object-contain"
/>
)}
</div>
);
}
/** Why a whole-file read came back without an edit stamp (`sha`): the i18n
* key naming it, so the reader is never left with a button that does nothing. */
function noEditReason(text: { binary?: boolean; truncated: boolean }) {
@@ -482,7 +510,7 @@ export function LaneChangesPanel({
(text: string) => {
const runId = pasteToLane(laneId, text);
if (runId) {
const slot = Number(/-([1-3])$/.exec(runId)?.[1] ?? 0);
const slot = Number(/-([1-9])$/.exec(runId)?.[1] ?? 0);
toast.success(t("changes.commentPasted", { tab: slot + 1 }));
}
return runId !== null;
@@ -495,6 +523,8 @@ export function LaneChangesPanel({
const [range, setRange] = useState<LaneChangeRange>("work");
const [mode, setMode] = useState<Mode>("overview");
const [openFile, setOpenFile] = useState<string | null>(null);
// An image opens as a picture, never as text or a binary diff.
const openImage = openFile && IMAGE_FILE.test(openFile) ? openFile : null;
const [sha, setSha] = useState<string | null>(null);
const [context, setContext] = useState(CONTEXT_STEPS[0]);
const [layout, setLayout] = useState<DiffLayout>("unified");
@@ -1683,7 +1713,7 @@ export function LaneChangesPanel({
}}
/>
) : mode === "file" ? (
current || (whole && text) ? (
current || (whole && text) || openImage ? (
<div className="flex min-h-0 flex-1 flex-col">
<div className="mb-1 flex items-baseline gap-1.5">
<FileText className="h-3 w-3 shrink-0 text-fg-muted" />
@@ -1714,6 +1744,7 @@ export function LaneChangesPanel({
only the diff, which has no whole text to click, keeps
an Edit button. */}
{range !== "commit" &&
!openImage &&
(draft === null && whole && text && !text.sha ? (
<span
data-testid="lane-file-noedit"
@@ -1766,7 +1797,16 @@ export function LaneChangesPanel({
</>
))}
</div>
{whole && draft !== null ? (
{openImage ? (
<ImageView
path={openImage}
src={api.lanes.gitRawUrl(
target,
openImage,
range === "commit" && sha ? sha : "work"
)}
/>
) : whole && draft !== null ? (
<textarea
ref={editorRef}
data-testid="lane-file-editor"
+105 -53
View File
@@ -1,7 +1,9 @@
/**
* @file The top of the Changes panel: the lane's working-copy line (branch,
* head, subject, and the uncommitted counts that open a per-file hover panel)
* and the git action bar under it (`LaneGitBar`).
* head, subject, and the uncommitted counts) and the git action bar under it
* (`LaneGitBar`); plus the same counts drawn vertical on the CLOSED Changes
* rail (`LaneGitRailCounts`), where hovering them opens the per-file panel —
* the open panel lists those files itself, so its header chip opens nothing.
*
* It used to sit in the lane card, a column away from the diff it describes —
* a pull there changed files the Changes panel only noticed on its next poll.
@@ -101,6 +103,7 @@ function LaneGitFilesPanel({
files,
hidden,
anchor,
side = "below",
onPointerEnter,
onPointerLeave,
}: {
@@ -108,6 +111,8 @@ function LaneGitFilesPanel({
hidden: number;
/** The counts button this panel hangs off. */
anchor: HTMLElement | null;
/** `left` beside a chip on the right-edge rail, where below would cover it. */
side?: "below" | "left";
onPointerEnter: () => void;
onPointerLeave: () => void;
}) {
@@ -122,13 +127,20 @@ function LaneGitFilesPanel({
const a = anchor.getBoundingClientRect();
const p = panelRef.current.getBoundingClientRect();
const gap = 4;
if (side === "left") {
setPos({
top: Math.max(8, Math.min(a.top, window.innerHeight - p.height - 8)),
left: Math.max(8, a.left - p.width - gap),
});
return;
}
const top =
a.bottom + gap + p.height > window.innerHeight ? a.top - p.height - gap : a.bottom + gap;
setPos({
top: Math.max(8, top),
left: Math.max(8, Math.min(a.left, document.documentElement.clientWidth - p.width - 8)),
});
}, [anchor, files]);
}, [anchor, files, side]);
// Totals are over the LISTED files only — the cap means the rest are unknown,
// and a total that silently omits them would be a wrong number rather than a
// partial one. `hidden` says how many are missing.
@@ -204,6 +216,95 @@ function LaneGitFilesPanel({
);
}
/**
* The uncommitted counts as a chip. With `details` it opens the per-file panel
* on hover, focus or click — the closed Changes rail, `vertical`, where nothing
* else lists the files. In the open panel the list below already does, so the
* header shows the chip alone.
*/
export function LaneGitCounts({
git,
vertical = false,
details = true,
}: {
git: Extract<LaneGitFacts, { available: true }>;
vertical?: boolean;
details?: boolean;
}) {
const { t } = useTranslation(["lanes"]);
const [filesOpen, setFilesOpen] = useState(false);
const [anchor, setAnchor] = useState<HTMLButtonElement | null>(null);
// Closing is DELAYED: the panel is portalled away from the trigger, so the
// pointer leaves the trigger before it can land on the panel it is heading
// for. Any enter — trigger or panel — cancels the pending close.
const closeTimer = useRef<number | undefined>(undefined);
const openFiles = useCallback(() => {
window.clearTimeout(closeTimer.current);
setFilesOpen(true);
}, []);
const closeFiles = useCallback(() => {
window.clearTimeout(closeTimer.current);
closeTimer.current = window.setTimeout(() => setFilesOpen(false), 150);
}, []);
useEffect(() => () => window.clearTimeout(closeTimer.current), []);
if (git.dirty === 0 && git.untracked === 0) return null;
const label = t("git.uncommitted", { dirty: git.dirty, untracked: git.untracked });
const chip = `shrink-0 rounded-full border border-status-warning/40 bg-status-warning/10 text-[10.5px] text-status-warning ${
vertical ? "px-px py-2 [writing-mode:vertical-rl]" : "px-2 py-px"
}`;
if (!details) {
return (
<span data-testid="lane-git-counts" className={chip}>
{label}
</span>
);
}
return (
<>
<button
type="button"
ref={setAnchor}
data-testid="lane-git-counts"
aria-expanded={filesOpen}
onMouseEnter={openFiles}
onMouseLeave={closeFiles}
// Focus and click open it too: hover is not a gesture a
// keyboard or a touchscreen has.
onFocus={openFiles}
onBlur={closeFiles}
// Click only OPENS: hover (or a tap's focus) has already opened it,
// and a toggle here shut it again on the same gesture. Leaving,
// blurring or Escape closes it.
onClick={openFiles}
onKeyDown={(e) => {
if (e.key === "Escape") setFilesOpen(false);
}}
className={`cursor-help ${chip}`}
>
{label}
</button>
{filesOpen && git.files?.length > 0 && (
<LaneGitFilesPanel
files={git.files}
hidden={git.dirty + git.untracked - git.files.length}
anchor={anchor}
side={vertical ? "left" : "below"}
onPointerEnter={openFiles}
onPointerLeave={closeFiles}
/>
)}
</>
);
}
/** The counts on the closed Changes rail: its own facts read, polled only
* while the rail is shown (the open panel owns the read otherwise). */
export function LaneGitRailCounts({ laneId, session }: { laneId: number; session?: string }) {
const [git] = useLaneGitFacts(laneId, session);
return git?.available ? <LaneGitCounts git={git} vertical /> : null;
}
export function LaneGitHeader({
laneId,
session,
@@ -220,23 +321,6 @@ export function LaneGitHeader({
/** See `LaneGitBar`'s prop of the same name. */
onNewLane?: (branch?: string) => void;
}) {
const { t } = useTranslation(["lanes"]);
const [filesOpen, setFilesOpen] = useState(false);
const [countsAnchor, setCountsAnchor] = useState<HTMLButtonElement | null>(null);
// Closing is DELAYED: the panel is portalled away from the trigger, so the
// pointer leaves the trigger before it can land on the panel it is heading
// for. Any enter — trigger or panel — cancels the pending close.
const filesCloseTimer = useRef<number | undefined>(undefined);
const openFiles = useCallback(() => {
window.clearTimeout(filesCloseTimer.current);
setFilesOpen(true);
}, []);
const closeFiles = useCallback(() => {
window.clearTimeout(filesCloseTimer.current);
filesCloseTimer.current = window.setTimeout(() => setFilesOpen(false), 150);
}, []);
useEffect(() => () => window.clearTimeout(filesCloseTimer.current), []);
if (!git?.available) return null;
const lead = (
<>
@@ -255,30 +339,7 @@ export function LaneGitHeader({
<span className="shrink-0 text-fg-muted">{git.head}</span>
<span className="min-w-0 truncate text-fg-muted">{git.subject}</span>
</div>
{(git.dirty > 0 || git.untracked > 0) && (
<button
type="button"
ref={setCountsAnchor}
data-testid="lane-git-counts"
aria-expanded={filesOpen}
onMouseEnter={openFiles}
onMouseLeave={closeFiles}
// Focus and click open it too: hover is not a gesture a
// keyboard or a touchscreen has.
onFocus={openFiles}
onBlur={closeFiles}
// Click only OPENS: hover (or a tap's focus) has already opened it,
// and a toggle here shut it again on the same gesture. Leaving,
// blurring or Escape closes it.
onClick={openFiles}
onKeyDown={(e) => {
if (e.key === "Escape") setFilesOpen(false);
}}
className="shrink-0 cursor-help rounded-full border border-status-warning/40 bg-status-warning/10 px-2 py-px text-[10.5px] text-status-warning"
>
{t("git.uncommitted", { dirty: git.dirty, untracked: git.untracked })}
</button>
)}
<LaneGitCounts git={git} details={false} />
</>
);
return (
@@ -291,15 +352,6 @@ export function LaneGitHeader({
onNewLane={onNewLane}
lead={lead}
/>
{filesOpen && git.files?.length > 0 && (
<LaneGitFilesPanel
files={git.files}
hidden={git.dirty + git.untracked - git.files.length}
anchor={countsAnchor}
onPointerEnter={openFiles}
onPointerLeave={closeFiles}
/>
)}
</div>
);
}
+10 -11
View File
@@ -1,7 +1,7 @@
/**
* @file WorkspaceHome — what `/workspace?lane=<id>` opens on, shaped like a
* Claude Desktop project page: the name, a prompt box that starts a NEW
* session in the first free pane slot (0–3, `POST /api/lanes/:id/start`) and
* session in the first free slot (0–9, `POST /api/lanes/:id/start`) and
* then opens it (`&run=<runId>`) — the one place a session starts WITH a
* prompt (the session view's tab bar `+` only starts a bare CLI or a shell,
* `ConsoleTabs.tsx`). Inside
@@ -82,6 +82,7 @@ export {
startInFreeTabDetailed,
} from "./startSession";
import { startInFreeTab } from "./startSession";
import { PathPickButton } from "../PathPickButton";
function fmtBytes(n: number | null): string {
if (!n) return "";
@@ -110,9 +111,8 @@ function HomeBody({
onRenamed,
}: {
lane: Lane;
/** Row-menu items the page carries out (its modals); rename is done here.
* `started` comes with the run it opened. */
onMenuAction: (action: WorkspaceMenuAction, runId?: string) => void;
/** Row-menu items the page carries out (its modals); rename is done here. */
onMenuAction: (action: WorkspaceMenuAction) => void;
/** Called after a rename, so the page re-reads the lane list. */
onRenamed?: () => void;
}) {
@@ -957,6 +957,7 @@ function HomeBody({
onChange={(e) => setFolderPath(e.target.value)}
className="min-w-0 flex-1 rounded border border-border bg-surface-1 px-2 py-1 font-mono text-[11px] text-fg-primary"
/>
<PathPickButton start={folderPath || lane.cwd} onPick={setFolderPath} />
<button type="submit" disabled={!folderPath.trim()} className="btn-ghost text-xs">
{t("home.add")}
</button>
@@ -1016,10 +1017,10 @@ function HomeBody({
<div
role="tablist"
aria-label={t("home.memory")}
// shrink-0: a horizontal scroller's min-height is 0, so in this
// height-capped column a long file squeezed the row and clipped
// the tabs. The panel below is the one part that scrolls.
className="mb-3 flex shrink-0 gap-1.5 overflow-x-auto border-b border-border/60 pb-2 no-scrollbar"
// Wraps, so every file is reachable without a horizontal wheel (a
// hidden-scrollbar row cut the rest off); past ~3 rows it scrolls
// with a visible bar. shrink-0: a long file must not squeeze it.
className="mb-3 flex max-h-28 shrink-0 flex-wrap gap-1.5 overflow-y-auto border-b border-border/60 pb-2"
>
{memory.map((f, i) => (
<button
@@ -1124,9 +1125,7 @@ function HomeBody({
x={menuAt.x}
y={menuAt.y}
onClose={() => setMenuAt(null)}
onAction={(a, runId) =>
a === "rename" ? setTitle(lane.title || "") : onMenuAction(a, runId)
}
onAction={(a) => (a === "rename" ? setTitle(lane.title || "") : onMenuAction(a))}
/>
)}
</div>
+9 -28
View File
@@ -6,8 +6,8 @@
* 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. Only a workspace row's own drag (`LANE_DRAG_TYPE`) is taken —
* a file, a tab or a text dragged over is not a reorder. F2 renames too; the
* 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.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
@@ -20,7 +20,7 @@ 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";
const isLaneDrag = (e: { dataTransfer: DataTransfer | null }) =>
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. */
@@ -78,7 +78,6 @@ export function WorkspaceRow({
}) {
const { t } = useTranslation("lanes");
const [draft, setDraft] = useState("");
const [dropTarget, setDropTarget] = useState(false);
const name = workspaceName(lane);
const openMenu = (e: MouseEvent) => {
e.preventDefault();
@@ -92,38 +91,20 @@ export function WorkspaceRow({
return (
<div
data-testid={`workspace-row-${lane.id}`}
data-drop-target={dropTarget ? "true" : undefined}
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";
}}
onDragOver={(e) => {
if (!isLaneDrag(e)) return;
e.preventDefault();
setDropTarget(true);
}}
onDragLeave={() => setDropTarget(false)}
onDragEnd={() => setDropTarget(false)}
onDrop={(e) => {
setDropTarget(false);
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);
if (Number.isInteger(from)) onReorder(from, index);
}}
onContextMenu={openMenu}
className={`group flex items-center gap-1 rounded-md pl-1 pr-1 transition-colors ${
dropTarget
? "bg-accent/20"
: selected
? "bg-accent/10"
: lane.needs_action
? "bg-status-warning/10 hover:bg-status-warning/20"
: "hover:bg-surface-3"
selected
? "bg-accent/10"
: lane.needs_action
? "bg-status-warning/10 hover:bg-status-warning/20"
: "hover:bg-surface-3"
}`}
>
<button
@@ -1,29 +1,20 @@
/**
* @file WorkspaceRowMenu — the sidebar workspace row's context menu, opened at
* the pointer (right-click) or under the row's `⋯` button. Every item is an
* existing call: Start (`POST /api/lanes/:id/start`, the free tab the server
* picks — then the session opens, `onAction("started", runId)`) / Stop
* (`stop`, every tab of the workspace — counted from `GET /api/run` when the
* menu opens, and confirmed: "Stop N sessions?"),
* Rename (the row's inline field), New workspace here (the Add modal prefilled),
* Copy path, and Reset/Remove through `DestructiveLaneModal` — Reset only on a
* existing call: Rename (the row's inline field), New workspace here (the
* Add modal prefilled), Copy path, and Reset/Remove through `DestructiveLaneModal` — Reset only on a
* managed lane. The server's guards are the real ones; the menu only hides
* what the lane's kind or tool makes meaningless. The shell (position,
* Escape / outside-click close) is `ContextMenu`.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { api } from "../../lib/api";
import type { Lane } from "../../lib/types";
import { ContextMenu, MenuItem, MenuSeparator } from "../ContextMenu";
import { confirmDialog } from "../../lib/confirm";
import { copyText } from "../../lib/clipboard";
import { toast } from "../../lib/toast";
import { startInFreeTab } from "./startSession";
export type WorkspaceMenuAction = "rename" | "new-here" | "reset" | "remove" | "started";
export type WorkspaceMenuAction = "rename" | "new-here" | "reset" | "remove";
export function WorkspaceRowMenu({
lane,
@@ -36,62 +27,12 @@ export function WorkspaceRowMenu({
x: number;
y: number;
onClose: () => void;
/** Items the tree itself carries out (rename field, modals, selection);
* `started` comes with the run it opened. */
onAction: (action: WorkspaceMenuAction, runId?: string) => void;
/** Items the tree itself carries out (rename field, modals). */
onAction: (action: WorkspaceMenuAction) => void;
}) {
const { t } = useTranslation("lanes");
// Every live tab of the workspace — slot 0 alone undercounts a split.
const [liveCount, setLiveCount] = useState<number | null>(null);
useEffect(() => {
let alive = true;
api.run
.list()
.then(
(r) =>
alive &&
setLiveCount(
(r.items ?? []).filter((x) => x.laneId === lane.id && x.status === "running").length
)
)
.catch(() => undefined);
return () => {
alive = false;
};
}, [lane.id]);
const live = liveCount === null ? !!lane.run_id || lane.status === "running" : liveCount > 0;
const stop = async () => {
onClose();
const n = liveCount ?? 1;
if (
!(await confirmDialog({
title: t("menu.stopTitle", { count: n }),
message: t("menu.stopBody"),
confirmLabel: t("menu.stop"),
destructive: true,
}))
)
return;
try {
await api.lanes.action(lane.id, "stop");
} catch (err) {
toast.error(err);
}
};
const start = async () => {
onClose();
try {
onAction("started", await startInFreeTab(lane.id, { prompt: "" }));
} catch (err) {
toast.error(err);
}
};
return (
<ContextMenu x={x} y={y} onClose={onClose} testId={`workspace-row-menu-${lane.id}`}>
<MenuItem onSelect={() => void (live ? stop() : start())}>
{live ? t("menu.stop") : t("menu.start")}
</MenuItem>
<MenuItem onSelect={() => onAction("rename")}>{t("menu.rename")}</MenuItem>
<MenuItem onSelect={() => onAction("new-here")}>{t("menu.newHere")}</MenuItem>
<MenuItem
@@ -11,7 +11,10 @@
* carrying its session id) opens that tmux pane on the Workspace page
* (`?lane=<id>&run=<runId>`, its tab in the session view; a live tab whose
* session has no row yet — a bare tab before its first prompt — is listed as
* **New session** from the run itself; Ctrl/Cmd+click opens
* **New session** from the run itself, and a live Terminal tab (`tool: "shell"`,
* which never has a session) as **Terminal** — and under the sessions the last
* `CLOSED_TERMINALS` closed ones (`GET /api/run/history?tool=shell`), whose
* click opens a NEW shell tab (a shell cannot be resumed); Ctrl/Cmd+click opens
* it in a split beside the focused pane) — attaching, never starting or
* resuming anything;
* any other session opens in its workspace too (`?lane=<id>&session=<id>`,
@@ -28,10 +31,12 @@
import { useEffect, useState, type ReactNode } from "react";
import { confirmDialog } from "../../lib/confirm";
import { isSubmitEnter } from "../../lib/keys";
import { Archive, GitBranch } from "lucide-react";
import { Archive, GitBranch, SquareTerminal } from "lucide-react";
import { useTranslation } from "react-i18next";
import { Link, useNavigate } from "react-router-dom";
import { api, type RunHandle } from "../../lib/api";
import { api, type RunHandle, type RunHistoryItem } from "../../lib/api";
import { toast } from "../../lib/toast";
import { startInFreeTab } from "./startSession";
import { isSessionAwaitingInput, type Lane, type Session } from "../../lib/types";
import { timeAgo } from "../../lib/format";
import { ConfirmModal } from "../ConfirmModal";
@@ -41,6 +46,8 @@ import { useContextMenu } from "../ContextMenu";
import { SessionMenu } from "./SessionMenu";
export const SESSION_LIMIT = 6;
/** Closed Terminal tabs listed under a workspace's sessions. */
export const CLOSED_TERMINALS = 3;
/**
* The live pane holding a session: the run the hooks bound it to
@@ -253,12 +260,43 @@ export function WorkspaceSessionList({
const laneRuns = liveRuns.filter(
(r) => r.laneId === lane.id && r.status === "running" && r.tool !== "shell"
);
const liveShells = liveRuns.filter(
(r) => r.laneId === lane.id && r.status === "running" && r.tool === "shell"
);
// The lane's own session/status/needs-you move on every hook, which is when
// a session can appear or change state, and a tab opening or getting its
// session is too — re-read then, not on a timer.
const refreshKey = `${lane.session_id}:${lane.status}:${lane.needs_action}:${version}:${laneRuns
.map((r) => `${r.id}=${r.sessionId ?? ""}`)
.join(",")}`;
// A Terminal tab closing is when one joins the history.
const shellKey = liveShells.map((r) => r.id).join(",");
const [closedShells, setClosedShells] = useState<RunHistoryItem[]>([]);
useEffect(() => {
if (hidden) return;
let live = true;
api.run
// Live ones come back too (isLive): ask for enough to fill the list anyway.
.history({ laneId: lane.id, tool: "shell", limit: CLOSED_TERMINALS + 10 })
.then(
(r) => live && setClosedShells(r.items.filter((x) => !x.isLive).slice(0, CLOSED_TERMINALS))
)
.catch(() => undefined);
return () => {
live = false;
};
}, [lane.id, shellKey, hidden]);
const reopenShell = async (split: boolean) => {
try {
const runId = await startInFreeTab(lane.id, { shell: true });
navigate(
`/workspace?lane=${lane.id}&run=${encodeURIComponent(runId)}`,
split ? { state: { split: true } } : undefined
);
} catch (err) {
toast.error(err);
}
};
const own = useSessionPage({ lane: lane.id, archived: false, limit }, refreshKey);
const sessions = own?.sessions ?? (own === null ? null : []);
const more = own ? Math.max(0, own.total - own.sessions.length) : 0;
@@ -301,11 +339,15 @@ export function WorkspaceSessionList({
const fresh =
sessions === null || q
? []
: laneRuns.filter(
(r) =>
!r.sessionId ||
(!sessions.some((s) => s.id === r.sessionId) && !hasRow.has(r.sessionId))
);
: [
...laneRuns.filter(
(r) =>
!r.sessionId ||
(!sessions.some((s) => s.id === r.sessionId) && !hasRow.has(r.sessionId))
),
// Live Terminal tabs: no session row ever, so listed from the run.
...liveShells,
];
const count = sessions === null ? null : shown.length;
useEffect(() => {
if (count !== null) onMatches?.(count);
@@ -380,7 +422,9 @@ export function WorkspaceSessionList({
{error}
</p>
);
if (sessions !== null && shown.length === 0 && fresh.length === 0) {
// Not during a search, like the live ones: they have no name to match.
const closed = q ? [] : closedShells;
if (sessions !== null && shown.length === 0 && fresh.length === 0 && closed.length === 0) {
return (
<>
<p className="py-1 pl-8 text-[11px] text-fg-muted">{t("tree.noSessions")}</p>
@@ -405,7 +449,7 @@ export function WorkspaceSessionList({
)
}
onContextMenu={(e) => ctx.open(e, r)}
title={`${t("tree.newSession")}\n${t("tree.splitHint")}`}
title={`${r.tool === "shell" ? t("tabs.terminal") : t("tree.newSession")}\n${t("tree.splitHint")}`}
className={`flex w-full items-center gap-2 rounded-md py-1 pl-7 pr-2 text-left text-[12px] ${
r.id === selectedRunId
? "bg-accent/10 text-fg-primary"
@@ -414,11 +458,20 @@ export function WorkspaceSessionList({
>
<span aria-hidden className="h-1.5 w-1.5 shrink-0 rounded-full bg-status-success" />
<span className="sr-only">{t("tree.state.live")}</span>
<span className="truncate italic">{t("tree.newSession")}</span>
<AiToolIcon
tool={{ id: r.tool, label: tools.label(r.tool) }}
className="ml-auto h-3.5 w-3.5 shrink-0 !p-0 opacity-70"
/>
{r.tool === "shell" ? (
<>
<span className="truncate">{t("tabs.terminal")}</span>
<SquareTerminal aria-hidden className="ml-auto h-3.5 w-3.5 shrink-0 opacity-70" />
</>
) : (
<>
<span className="truncate italic">{t("tree.newSession")}</span>
<AiToolIcon
tool={{ id: r.tool, label: tools.label(r.tool) }}
className="ml-auto h-3.5 w-3.5 shrink-0 !p-0 opacity-70"
/>
</>
)}
</button>
</li>
))}
@@ -521,6 +574,28 @@ export function WorkspaceSessionList({
</li>
);
})}
{closed.map((r) => (
<li key={r.id}>
<button
type="button"
data-testid={`workspace-terminal-closed-${r.id}`}
onClick={(e) => void reopenShell(e.ctrlKey || e.metaKey)}
title={t("tree.closedTerminal")}
className="flex w-full items-center gap-2 rounded-md py-1 pl-7 pr-2 text-left text-[12px] text-fg-muted hover:bg-surface-3 hover:text-fg-primary"
>
<span
aria-hidden
className="h-1.5 w-1.5 shrink-0 rounded-full border border-fg-muted"
/>
<span className="sr-only">{t("tree.state.idle")}</span>
<span className="truncate">{t("tabs.terminal")}</span>
<span className="ml-auto shrink-0 text-[11px]">
{timeAgo(r.ended_at ?? r.started_at)}
</span>
<SquareTerminal aria-hidden className="h-3.5 w-3.5 shrink-0 opacity-70" />
</button>
</li>
))}
</ul>
{more > 0 && !q && (
<button
@@ -26,6 +26,7 @@ vi.mock("../../../lib/api", () => ({
worktree: vi.fn(),
ensure: vi.fn(),
browse: vi.fn(),
pickPath: vi.fn(),
get: vi.fn(),
profileInit: vi.fn(),
mcpSync: vi.fn(),
@@ -147,6 +148,7 @@ beforeEach(() => {
vi.mocked(api.lanes.worktree).mockReset();
vi.mocked(api.lanes.ensure).mockReset();
vi.mocked(api.lanes.browse).mockReset();
vi.mocked(api.lanes.pickPath).mockReset();
// Provisioning finishes instantly by default - tests that care about the
// provisioning-in-progress race override this per-test.
vi.mocked(api.lanes.get)
@@ -549,7 +551,45 @@ describe("AddLaneModal — repo mode (adopt)", () => {
});
describe("AddLaneModal — folder browse", () => {
const noNativePicker = () =>
vi
.mocked(api.lanes.pickPath)
.mockRejectedValue(Object.assign(new Error("no picker"), { code: "ENODIALOG" }));
it("fills the path field from the OS's own picker", async () => {
vi.mocked(api.lanes.pickPath).mockResolvedValue({ path: "/Users/tester/app" });
renderModal();
const user = userEvent.setup();
await user.click(screen.getByTitle("Browse for a folder"));
await waitFor(() =>
expect(document.getElementById("add-lane-repo")).toHaveValue("/Users/tester/app")
);
expect(api.lanes.browse).not.toHaveBeenCalled();
});
it("falls back to the in-app browser when no native picker is available", async () => {
noNativePicker();
vi.mocked(api.lanes.browse).mockResolvedValue({
path: "/Users/tester",
parent: null,
entries: [],
});
const onClose = vi.fn();
renderModal({ onClose });
const user = userEvent.setup();
await user.click(screen.getByTitle("Browse for a folder"));
const browser = await screen.findByRole("dialog", { name: "Browse for a folder" });
// Its backdrop closes only the browser, never the modal behind it.
await user.click(browser.parentElement!);
expect(screen.queryByRole("dialog", { name: "Browse for a folder" })).toBeNull();
expect(onClose).not.toHaveBeenCalled();
});
it("opens the browser, lists subdirectories, and selecting one fills the path field", async () => {
noNativePicker();
vi.mocked(api.lanes.browse).mockResolvedValue({
path: "/Users/tester",
parent: "/Users",
@@ -566,6 +606,7 @@ describe("AddLaneModal — folder browse", () => {
});
it("Escape closes only the folder browser, not the whole modal", async () => {
noNativePicker();
vi.mocked(api.lanes.browse).mockResolvedValue({
path: "/Users/tester",
parent: null,
@@ -34,6 +34,8 @@ vi.mock("../../../lib/api", () => ({
gitAction: vi.fn(),
gitLog: vi.fn(),
gitFile: vi.fn(),
gitRawUrl: (id: number, path: string, rev: string) =>
`/api/lanes/${id}/git/raw?path=${encodeURIComponent(path)}&rev=${rev}`,
gitSaveFile: vi.fn(),
gitLs: vi.fn(),
gitTree: vi.fn(),
@@ -380,6 +382,32 @@ describe("LaneChangesPanel", () => {
expect(await screen.findByTestId("lane-file-view")).toHaveTextContent("# Hello");
});
it("shows an image file as the picture, with no edit control", async () => {
vi.mocked(api.lanes.gitTree).mockResolvedValue({
available: true,
path: "",
entries: [{ name: "logo.png", path: "logo.png", dir: false, status: "" }],
truncated: false,
});
vi.mocked(api.lanes.gitFile).mockResolvedValue({
available: true,
path: "logo.png",
rev: "work",
binary: true,
truncated: false,
lines: [],
});
const user = userEvent.setup();
render(<LaneChangesPanel laneId={7} full />);
const sidebar = await screen.findByTestId("lane-changes-sidebar");
await user.click(await within(sidebar).findByText("logo.png"));
const img = within(await screen.findByTestId("lane-file-image")).getByRole("img");
expect(img).toHaveAttribute("src", "/api/lanes/7/git/raw?path=logo.png&rev=work");
expect(screen.queryByTestId("lane-file-view")).toBeNull();
expect(screen.queryByTestId("lane-file-noedit")).toBeNull();
expect(screen.queryByTestId("lane-file-edit")).toBeNull();
});
it("at full width shows the tree as a sidebar and opens a file for reading, editing on a click", async () => {
vi.mocked(api.lanes.gitTree).mockResolvedValue({
available: true,
@@ -1,7 +1,8 @@
/**
* @file LaneGitHeader.test.tsx
* @description Pins the Changes panel's working-copy line: the live branch,
* head, subject and uncommitted counts, the per-file hover panel behind the
* head, subject and uncommitted counts (no hover there — the panel lists the
* files below), the per-file hover panel behind the closed rail's vertical
* counts, that a lane switch drops the previous lane's facts at once, and that
* an unreadable repo renders nothing rather than an error.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
@@ -9,7 +10,7 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, waitFor, fireEvent } from "@testing-library/react";
import { LaneGitHeader, useLaneGitFacts } from "../LaneGitHeader";
import { LaneGitHeader, LaneGitRailCounts, useLaneGitFacts } from "../LaneGitHeader";
import { api } from "../../../lib/api";
vi.mock("../../../lib/api", () => ({
@@ -70,9 +71,9 @@ describe("LaneGitHeader", () => {
expect(screen.queryByText(/feat\/rename-metric/)).toBeNull();
});
it("names the files behind the counts on hover, with each one's line delta", async () => {
it("names the files behind the rail's counts on hover, with each one's line delta", async () => {
vi.mocked(api.lanes.git).mockResolvedValueOnce(facts);
render(<Harness laneId={1} />);
render(<LaneGitRailCounts laneId={1} />);
const counts = await screen.findByTestId("lane-git-counts");
// Hidden until asked for: the row is a one-line summary until you hover.
@@ -98,7 +99,7 @@ describe("LaneGitHeader", () => {
it("stays open while the pointer is inside the panel", async () => {
vi.mocked(api.lanes.git).mockResolvedValueOnce(facts);
render(<Harness laneId={1} />);
render(<LaneGitRailCounts laneId={1} />);
const counts = await screen.findByTestId("lane-git-counts");
fireEvent.mouseEnter(counts);
@@ -115,12 +116,27 @@ describe("LaneGitHeader", () => {
it("says how many files the server-side cap left out", async () => {
// 40 uncommitted files, 3 listed: the counts are exact, the listing is not.
vi.mocked(api.lanes.git).mockResolvedValueOnce({ ...facts, dirty: 39, untracked: 1 });
render(<Harness laneId={1} />);
render(<LaneGitRailCounts laneId={1} />);
fireEvent.mouseEnter(await screen.findByTestId("lane-git-counts"));
expect(screen.getByTestId("lane-git-files").textContent).toContain("37 more");
});
it("shows the counts in the open panel's header without the hover list", async () => {
vi.mocked(api.lanes.git).mockResolvedValueOnce(facts);
render(<Harness laneId={1} />);
const counts = await screen.findByTestId("lane-git-counts");
fireEvent.mouseEnter(counts);
fireEvent.focus(counts);
expect(screen.queryByTestId("lane-git-files")).toBeNull();
});
it("draws the rail's counts vertical", async () => {
vi.mocked(api.lanes.git).mockResolvedValueOnce(facts);
render(<LaneGitRailCounts laneId={1} />);
expect((await screen.findByTestId("lane-git-counts")).className).toContain("vertical-rl");
});
it("omits the uncommitted line when the tree is clean", async () => {
vi.mocked(api.lanes.git).mockResolvedValueOnce({ ...facts, dirty: 0, untracked: 0 });
render(<Harness laneId={1} />);
@@ -221,7 +221,7 @@ describe("SessionResumeButton", () => {
expect(screen.getByTestId("where").textContent).toBe("/workspace?lane=3&run=agenthub-lane-3-2");
});
it("says so when the server finds all four panes busy", async () => {
it("says so when the server finds all ten sessions in use", async () => {
vi.mocked(api.lanes.action).mockRejectedValueOnce(
Object.assign(new Error("every tab of this workspace is busy"), { code: "ERUNLIVE" })
);
@@ -229,7 +229,7 @@ describe("SessionResumeButton", () => {
renderButton({ id: "s-1", cwd: "/r", tool: "claude" });
await user.click(await screen.findByRole("button", { name: "Resume" }));
await user.click(screen.getByRole("button", { name: "Resume session" }));
expect(await screen.findByRole("alert")).toHaveTextContent("All four panes");
expect(await screen.findByRole("alert")).toHaveTextContent("All 10 sessions");
});
it("opens the pane the server names when the session is already live there", async () => {
@@ -193,8 +193,9 @@ describe("freeSlot / paneRunId", () => {
it("picks the first slot no live run of this lane holds", () => {
expect(freeSlot(1, [])).toBe(0);
expect(freeSlot(1, runs)).toBe(1);
const all = [0, 1, 2, 3].map((slot) => ({ laneId: 1, slot, status: "running" }) as RunHandle);
const all = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9].map((slot) => ({ laneId: 1, slot, status: "running" }) as RunHandle);
expect(freeSlot(1, all)).toBeNull();
expect(freeSlot(1, all.slice(0, 4))).toBe(4);
expect(freeSlot(2, all)).toBe(0);
expect(paneRunId(4, 0)).toBe("agenthub-lane-4");
expect(paneRunId(4, 2)).toBe("agenthub-lane-4-2");
@@ -318,7 +319,7 @@ describe("WorkspaceHome", () => {
});
});
it("says so when the server finds all four panes busy", async () => {
it("says so when the server finds all ten sessions in use", async () => {
vi.mocked(api.lanes.action).mockRejectedValueOnce(
Object.assign(new Error("every tab of this workspace is busy"), { code: "ERUNLIVE" })
);
@@ -327,7 +328,7 @@ describe("WorkspaceHome", () => {
await screen.findByTestId("workspace-recent-s-old");
await user.type(screen.getByLabelText("How can I help you today?"), "go");
await user.click(screen.getByTestId("workspace-home-start"));
expect(await screen.findByRole("alert")).toHaveTextContent("All four panes");
expect(await screen.findByRole("alert")).toHaveTextContent("All 10 sessions");
});
it("starts nothing on an empty prompt, and only once on a double Enter", async () => {
+7 -5
View File
@@ -1,14 +1,15 @@
/**
* @file startSession.ts — starting a session in a workspace's first free tab
* (`POST /api/lanes/:id/start` with `slot: "auto"`) and the small pane-slot
* helpers around it. Kept out of `WorkspaceHome.tsx` so the sidebar's row menu
* can start a session without pulling the whole home (and react-markdown) into
* helpers around it. Kept out of `WorkspaceHome.tsx` so the sidebar's session
* list can use them without pulling the whole home (and react-markdown) into
* the main bundle.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import i18n from "../../i18n";
import { api, type RunHandle } from "../../lib/api";
import { MAX_SLOTS } from "../../lib/paneLayout";
/** The tmux session a pane slot of a lane owns (server/lib/pty-run.js). */
export function paneRunId(laneId: number, slot: number): string {
@@ -31,7 +32,7 @@ export async function startInFreeTab(
/** The workspace a pane belongs to, from its tmux name `agenthub-lane-<id>[-slot]`. */
export function laneOfRun(runId: string): number | null {
const m = /^agenthub-lane-(\d+)(?:-[1-3])?$/.exec(runId);
const m = /^agenthub-lane-(\d+)(?:-[1-9])?$/.exec(runId);
return m ? Number(m[1]) : null;
}
@@ -54,10 +55,11 @@ export async function startInFreeTabDetailed(
}
}
/** The first pane slot with no live run, or null when all four are busy. */
/** The first pane slot with no live run, or null when all ten are busy. */
export function freeSlot(laneId: number, runs: RunHandle[]): number | null {
const busy = new Set(
runs.filter((r) => r.laneId === laneId && r.status === "running").map((r) => r.slot ?? 0)
);
return [0, 1, 2, 3].find((s) => !busy.has(s)) ?? null;
for (let s = 0; s < MAX_SLOTS; s++) if (!busy.has(s)) return s;
return null;
}
+248 -94
View File
@@ -1,31 +1,38 @@
/**
* @file ConsoleTabs.tsx
* @description The Workspace session view's tab bar: one tab per live tmux
* slot of the workspace (0–3, `GET /api/run`), the active one being the URL's
* `?run=`. A tab is named by its session (`sessions.name`, double-click or
* Rename to change it — `PATCH /api/sessions/:id`), else the prompt it started
* on, else its tool and number, and carries a state dot (waiting for you /
* error / running, with screen-reader text). A tab's X stops its session
* (`DELETE /api/run/:id`) — after a confirm for an AI tab, since it may be
* mid-turn; a shell closes at once. Arrow keys, Home and End move between tabs
* (one tab stop, APG). Errors are toasts. The `+` menu opens a new tab in the
* free slot the server picks (`slot: "auto"`) — a plain shell
* (`POST /api/lanes/:id/start {shell: true}`) or an installed, enabled AI
* tool started bare on its default model, no prompt (a prompt, router or
* worktree is still the workspace home's prompt box) — plus a link to
* Settings → Agents. Four tabs is the server's limit (`MAX_PANE_SLOT`). The
* bar is the console's only header: Home (the workspace's home) at its left
* end, `actions` (the single pane's split menus) at its right end. A click shows a tab in the focused pane, Ctrl/Cmd+click
* (on a tab or a `+` item) opens it in a split to the right, and a tab drags
* onto the panes (`PaneGrid.tsx`); a tab another pane shows is marked. A
* right-click on a tab opens the same actions as a menu (`ContextMenu`).
* @description The Workspace session view's tab bar. Every tab is a GROUP of
* one to four split panes (`PaneGroups`, `lib/paneLayout.ts`), each pane a
* live tmux slot of the workspace (0–9, `GET /api/run`); the active tab is the
* one on screen. A one-pane tab is named by its session (`sessions.name`,
* double-click or Rename to change it — `PATCH /api/sessions/:id`), else the
* prompt it started on, else its tool and number; a combo tab shows every
* pane's icon and name. A tab carries a state dot (waiting for you / error /
* running — a combo shows the most urgent, with screen-reader text). A tab's
* X stops every session in it (`DELETE /api/run/:id`) — after a confirm when
* one is an AI tool, since it may be mid-turn; a shell closes at once. Arrow
* keys, Home and End move between tabs (one tab stop, APG). Errors are
* toasts. The `+` menu opens a new tab in the free slot the server picks
* (`slot: "auto"`) — a plain shell (`POST /api/lanes/:id/start {shell: true}`)
* or an installed, enabled AI tool started bare on its default model, no
* prompt (a prompt, router or worktree is still the workspace home's prompt
* box) — plus a link to Settings → Agents. Ten sessions is the server's limit
* (`MAX_PANE_SLOT`). The bar is the console's only header: Home (the
* workspace's home) at its left end, `actions` (the single pane's split menus)
* at its right end. A click shows a tab; Ctrl/Cmd+click on a one-pane tab (or
* a `+` item) brings it into the tab on screen, split to the right, and a
* one-pane tab drags onto the panes (`PaneGrid.tsx`) to join that tab. Any
* tab drags along the bar to reorder it, and a pane header dragged onto the
* bar takes that pane out into a tab of its own there. A right-click on a tab
* opens the same actions as a menu (`ContextMenu`).
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import {
useEffect,
useMemo,
useRef,
useState,
type DragEvent,
type KeyboardEvent as ReactKeyboardEvent,
type ReactNode,
} from "react";
@@ -42,7 +49,15 @@ import { isSubmitEnter } from "../../lib/keys";
import { useAiTools } from "../../hooks/useAiTools";
import { AiToolIcon } from "../AiToolIcon";
import { freeSlot, startInFreeTab } from "../lanes/startSession";
import { RunIcon, startPaneDrag } from "./PaneGrid";
import { PANE_DRAG_TYPE, RunIcon, startPaneDrag, type GroupsUpdate } from "./PaneGrid";
import { insertionIndex } from "../../lib/laneOrderStorage";
import {
type PaneGroups,
detachSlot,
groupOf,
leaves,
reorderGroups,
} from "../../lib/paneLayout";
import { ContextMenu, MenuItem, MenuSeparator, useContextMenu } from "../ContextMenu";
type AiToolsState = ReturnType<typeof useAiTools>;
@@ -113,11 +128,27 @@ export function newTabBody(id: string, lane: Lane, aiTools: AiToolsState) {
};
}
/** Drag data of a whole tab (its index), which only reorders the bar — a
* one-pane tab drags as its pane instead (`PANE_DRAG_TYPE`). */
const TAB_DRAG_TYPE = "application/x-agenthub-tab";
/** One tab: a group's index, slots and the runs the list knows of them. */
interface Tab {
index: number;
key: string;
slots: number[];
runs: RunHandle[];
/** The group's focused pane's run. */
focus: RunHandle;
}
const URGENCY: RunState[] = ["awaiting", "error", "running"];
export function ConsoleTabs({
lane,
tabs,
activeRunId,
shownRunIds = [],
tabs: runs,
groups,
onGroupsChange,
onOpen,
onClosed,
onHome,
@@ -128,15 +159,16 @@ export function ConsoleTabs({
lane: Lane;
/** The workspace's live runs, in slot order. */
tabs: RunHandle[];
/** The tabs: groups of panes, the active one on screen. */
groups: PaneGroups;
/** Reorders the tabs or takes a pane out into a tab of its own. */
onGroupsChange: GroupsUpdate;
/** The sessions the tabs hold, by id: their names and states. */
sessions?: Map<string, Session>;
/** A tab's session was renamed: re-read the sessions. */
onRenamed?: () => void;
/** The focused pane's run. */
activeRunId: string | null;
/** Runs the other panes show. */
shownRunIds?: string[];
/** `split`: Ctrl/Cmd+click — open beside the focused pane. */
/** Shows the tab holding `runId`; `split` (Ctrl/Cmd+click) brings it into
* the tab on screen instead. */
onOpen: (runId: string, split?: boolean) => void;
/** A tab's session was stopped. */
onClosed: (runId: string) => void;
@@ -154,7 +186,78 @@ export function ConsoleTabs({
const [draft, setDraft] = useState("");
const tabRefs = useRef(new Map<string, HTMLButtonElement>());
const menuRef = useRef<HTMLDivElement>(null);
const tabCtx = useContextMenu<RunHandle>();
const tabCtx = useContextMenu<Tab>();
// A group whose runs the list has not caught up with yet is not drawn.
const tabs = useMemo<Tab[]>(
() =>
groups.groups.flatMap((l, index) => {
const slots = leaves(l.root);
const own = slots
.map((s) => runs.find((r) => (r.slot ?? 0) === s))
.filter((r): r is RunHandle => !!r);
if (!own.length) return [];
const focus = own.find((r) => (r.slot ?? 0) === l.focus) ?? (own[0] as RunHandle);
return [{ index, key: slots.join("-"), slots, runs: own, focus }];
}),
[groups, runs]
);
// A tab or a pane header dragged along the bar: `dropAt` is the insertion
// point among the drawn tabs, from the pointer against their middles.
const listRef = useRef<HTMLDivElement>(null);
const [dropAt, setDropAt] = useState<number | null>(null);
const isBarDrag = (e: DragEvent) => {
const types = e.dataTransfer?.types;
return !!types && (types.includes(PANE_DRAG_TYPE) || types.includes(TAB_DRAG_TYPE));
};
const dropIndexAt = (x: number) => {
const els = [...(listRef.current?.querySelectorAll<HTMLElement>("[data-tab-index]") ?? [])];
return insertionIndex(
els.map((el) => {
const b = el.getBoundingClientRect();
return b.left + b.width / 2;
}),
x
);
};
const dragHandlers = {
onDragOver: (e: DragEvent) => {
if (!isBarDrag(e)) return;
e.preventDefault();
e.dataTransfer.dropEffect = "move";
const at = dropIndexAt(e.clientX);
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 (!isBarDrag(e)) return;
e.preventDefault();
// The drawn position, as an index into the groups.
const drawn = dropIndexAt(e.clientX);
const at = tabs[drawn]?.index ?? groups.groups.length;
const tab = e.dataTransfer.getData(TAB_DRAG_TYPE);
if (tab !== "") {
onGroupsChange((g) => reorderGroups(g, Number(tab), at));
return;
}
const raw = e.dataTransfer.getData(PANE_DRAG_TYPE);
if (raw === "") return;
const slot = Number(raw);
onGroupsChange((g) => {
const from = groupOf(g, slot);
if (from < 0) return g;
// A pane of a combo leaves it; a one-pane tab just moves.
return leaves(g.groups[from]?.root ?? null).length > 1
? detachSlot(g, slot, at)
: reorderGroups(g, from, at);
});
},
};
useEffect(() => {
if (!menuOpen) return;
@@ -170,11 +273,17 @@ export function ConsoleTabs({
};
}, [menuOpen]);
const slot = freeSlot(lane.id, tabs);
const slot = freeSlot(lane.id, runs);
const sessionOf = (r: RunHandle) => (r.sessionId ? sessions?.get(r.sessionId) : undefined);
const toolLabel = (r: RunHandle) =>
r.tool === "shell" ? t("tabs.terminal") : aiTools.label(r.tool);
const label = (r: RunHandle) => runName(r, sessionOf(r), toolLabel(r), t("tabs.terminal"));
const tabLabel = (tab: Tab) => tab.runs.map(label).join(" | ");
// A combo's dot is its most urgent AI pane's; a shell has none.
const tabState = (tab: Tab): RunState | null => {
const states = tab.runs.filter((r) => r.tool !== "shell").map((r) => runState(sessionOf(r)));
return URGENCY.find((x) => states.includes(x)) ?? null;
};
const toolIds = newTabTools(lane, aiTools);
const open = async (body: Record<string, unknown>, split = false) => {
@@ -187,23 +296,29 @@ export function ConsoleTabs({
}
};
// An AI tab may be mid-turn: stopping it asks first. A shell holds no agent.
const close = async (r: RunHandle) => {
// An AI session may be mid-turn: stopping a tab holding one asks first. A
// shell holds no agent.
const close = async (tab: Tab) => {
if (
r.tool !== "shell" &&
tab.runs.some((r) => r.tool !== "shell") &&
!(await confirmDialog({
title: t("tabs.stopTitle", { name: label(r) }),
title:
tab.runs.length > 1
? t("tabs.stopGroupTitle", { count: tab.runs.length })
: t("tabs.stopTitle", { name: label(tab.focus) }),
message: t("tabs.stopBody"),
confirmLabel: t("tabs.stopConfirm"),
destructive: true,
}))
)
return;
try {
await api.run.kill(r.id);
onClosed(r.id);
} catch (err) {
toast.error(err);
for (const r of tab.runs) {
try {
await api.run.kill(r.id);
onClosed(r.id);
} catch (err) {
toast.error(err);
}
}
};
@@ -243,22 +358,28 @@ export function ConsoleTabs({
: null;
if (to === null || !tabs[to]) return;
e.preventDefault();
tabRefs.current.get(tabs[to].id)?.focus();
tabRefs.current.get(tabs[to].key)?.focus();
};
const tabStop = tabs.some((r) => r.id === activeRunId) ? activeRunId : tabs[0]?.id;
const tabStop = tabs.find((x) => x.index === groups.active)?.key ?? tabs[0]?.key;
// A tab-menu item: close the menu, then act on the tab it was opened on.
const pickTab = (fn: (r: RunHandle) => unknown) => () => {
const r = tabCtx.menu?.item;
const pickTab = (fn: (tab: Tab) => unknown) => () => {
const tab = tabCtx.menu?.item;
tabCtx.close();
if (r) fn(r);
if (tab) fn(tab);
};
const ctxTab = tabCtx.menu?.item;
const ctxRun = ctxTab?.runs.length === 1 ? ctxTab.focus : null;
const item =
"flex w-full items-center gap-2.5 rounded-md px-2.5 py-1.5 text-left text-sm text-fg-secondary hover:bg-surface-4 hover:text-fg-primary";
return (
<div className="flex h-10 shrink-0 items-stretch border-b border-border bg-surface-1">
// The whole bar takes a tab's drop: past the last tab is the end.
<div
className="flex h-10 shrink-0 items-stretch border-b border-border bg-surface-1"
{...dragHandlers}
>
{onHome && (
<button
type="button"
@@ -271,15 +392,21 @@ export function ConsoleTabs({
<House className="h-4 w-4" />
</button>
)}
<div role="tablist" aria-label={t("tabs.label")} className="flex min-w-0 items-stretch">
{tabs.map((r, i) => {
const active = r.id === activeRunId;
const shown = !active && shownRunIds.includes(r.id);
if (renaming === r.id) {
<div
ref={listRef}
role="tablist"
aria-label={t("tabs.label")}
className="flex min-w-0 items-stretch overflow-x-auto no-scrollbar"
>
{tabs.map((tab, i) => {
const active = tab.index === groups.active;
const single = tab.runs.length === 1 ? tab.focus : null;
const state = tabState(tab);
if (single && renaming === single.id) {
return (
<div key={r.id} className="flex items-center px-2">
<div key={tab.key} className="flex items-center px-2">
<input
data-testid={`console-tab-rename-${r.slot ?? 0}`}
data-testid={`console-tab-rename-${single.slot ?? 0}`}
aria-label={t("tree.renameSession")}
autoFocus
value={draft}
@@ -291,7 +418,7 @@ export function ConsoleTabs({
if (e.key === "Escape") setRenaming(null);
if (!isSubmitEnter(e)) return;
e.preventDefault();
void saveRename(r);
void saveRename(single);
}}
className="w-40 rounded border border-accent bg-surface-1 px-1.5 py-0.5 text-sm text-fg-primary"
/>
@@ -300,58 +427,79 @@ export function ConsoleTabs({
}
return (
<div
key={r.id}
key={tab.key}
data-tab-index={i}
data-slots={tab.key}
draggable
onDragStart={(e) => startPaneDrag(e, r.slot ?? 0)}
onContextMenu={(e) => tabCtx.open(e, r)}
data-shown={shown ? "true" : undefined}
className={`group relative flex min-w-0 max-w-[15rem] items-center rounded-t-md ${
onDragStart={(e) => {
if (single) startPaneDrag(e, single.slot ?? 0);
else {
e.dataTransfer?.setData(TAB_DRAG_TYPE, String(tab.index));
if (e.dataTransfer) e.dataTransfer.effectAllowed = "move";
}
}}
onContextMenu={(e) => tabCtx.open(e, tab)}
className={`group relative flex min-w-0 shrink-0 items-center rounded-t-md ${
single ? "max-w-[15rem]" : "max-w-[22rem]"
} ${
active
? "bg-surface-0 text-fg-primary"
: shown
? "text-fg-secondary hover:bg-surface-4"
: "text-fg-muted hover:bg-surface-4 hover:text-fg-secondary"
: "text-fg-muted hover:bg-surface-4 hover:text-fg-secondary"
}`}
>
{(active || shown) && (
{(dropAt === i || (dropAt === tabs.length && i === tabs.length - 1)) && (
<span
className={`absolute inset-x-2 -bottom-px h-0.5 rounded-full ${
active ? "bg-accent" : "bg-fg-muted/50"
aria-hidden
data-testid="console-tab-drop-line"
className={`pointer-events-none absolute inset-y-1.5 z-10 w-0.5 rounded-full bg-accent-solid ${
dropAt === i ? "-left-px" : "-right-px"
}`}
/>
)}
{active && (
<span className="absolute inset-x-2 -bottom-px h-0.5 rounded-full bg-accent" />
)}
<button
type="button"
role="tab"
aria-selected={active}
tabIndex={r.id === tabStop ? 0 : -1}
tabIndex={tab.key === tabStop ? 0 : -1}
ref={(el) => {
if (el) tabRefs.current.set(r.id, el);
else tabRefs.current.delete(r.id);
if (el) tabRefs.current.set(tab.key, el);
else tabRefs.current.delete(tab.key);
}}
onKeyDown={(e) => onTabKey(e, i)}
data-testid={`console-tab-${r.slot ?? 0}`}
onClick={(e) => onOpen(r.id, e.ctrlKey || e.metaKey)}
onDoubleClick={() => startRename(r)}
title={[
toolLabel(r),
r.model,
r.promptPreview,
t("tabs.splitHint"),
r.sessionId ? t("tabs.renameHint") : null,
]
.filter(Boolean)
.join("\n")}
data-testid={`console-tab-${tab.key}`}
// Ctrl/Cmd+click brings a one-pane tab into the tab on screen.
onClick={(e) => onOpen(tab.focus.id, !!single && (e.ctrlKey || e.metaKey))}
onDoubleClick={() => single && startRename(single)}
title={
single
? [
toolLabel(single),
single.model,
single.promptPreview,
t("tabs.splitHint"),
single.sessionId ? t("tabs.renameHint") : null,
]
.filter(Boolean)
.join("\n")
: tab.runs.map((r) => `${toolLabel(r)} — ${label(r)}`).join("\n")
}
className="flex h-full min-w-0 items-center gap-2 pl-3 pr-1.5 text-sm"
>
<RunIcon run={r} label={toolLabel(r)} />
<span className="truncate">{label(r)}</span>
{r.tool !== "shell" && <RunDot state={runState(sessionOf(r))} />}
<span className="flex shrink-0 items-center gap-1">
{tab.runs.map((r) => (
<RunIcon key={r.id} run={r} label={toolLabel(r)} />
))}
</span>
<span className="truncate">{tabLabel(tab)}</span>
{state && <RunDot state={state} />}
</button>
<button
type="button"
data-testid={`console-tab-close-${r.slot ?? 0}`}
onClick={() => void close(r)}
data-testid={`console-tab-close-${tab.key}`}
onClick={() => void close(tab)}
aria-label={t("tabs.close")}
title={t("tabs.close")}
className={`mr-2 shrink-0 rounded p-0.5 hover:bg-surface-4 hover:text-status-danger ${
@@ -421,30 +569,36 @@ export function ConsoleTabs({
)}
</div>
{actions && <div className="ml-auto flex items-center pr-2">{actions}</div>}
{tabCtx.menu && (
{tabCtx.menu && ctxTab && (
<ContextMenu
x={tabCtx.menu.x}
y={tabCtx.menu.y}
onClose={tabCtx.close}
testId={`console-tab-menu-${tabCtx.menu.item.slot ?? 0}`}
testId={`console-tab-menu-${ctxTab.key}`}
>
<MenuItem onSelect={pickTab((r) => onOpen(r.id))}>{t("sessionMenu.open")}</MenuItem>
<MenuItem onSelect={pickTab((r) => onOpen(r.id, true))}>
{t("sessionMenu.openSplit")}
<MenuItem onSelect={pickTab((tab) => onOpen(tab.focus.id))}>
{t("sessionMenu.open")}
</MenuItem>
{tabCtx.menu.item.sessionId && tabCtx.menu.item.tool !== "shell" && (
{ctxRun && (
<MenuItem onSelect={pickTab((tab) => onOpen(tab.focus.id, true))}>
{t("sessionMenu.openSplit")}
</MenuItem>
)}
{ctxRun?.sessionId && ctxRun.tool !== "shell" && (
<>
<MenuItem onSelect={pickTab(startRename)}>{t("menu.rename")}</MenuItem>
<MenuItem onSelect={pickTab((r) => navigate(`/sessions/${r.sessionId}`))}>
<MenuItem onSelect={pickTab((tab) => startRename(tab.focus))}>
{t("menu.rename")}
</MenuItem>
<MenuItem onSelect={pickTab((tab) => navigate(`/sessions/${tab.focus.sessionId}`))}>
{t("sessionMenu.details")}
</MenuItem>
<MenuItem onSelect={pickTab((r) => void copyText(r.sessionId ?? ""))}>
<MenuItem onSelect={pickTab((tab) => void copyText(tab.focus.sessionId ?? ""))}>
{t("sessionMenu.copyId")}
</MenuItem>
</>
)}
<MenuSeparator />
<MenuItem danger onSelect={pickTab((r) => void close(r))}>
<MenuItem danger onSelect={pickTab((tab) => void close(tab))}>
{t("tabs.close")}
</MenuItem>
</ContextMenu>
@@ -5,7 +5,7 @@
* whose button goes back to the workspace home, where a session starts with a
* prompt (a pane has no run form; the tab bar's `+`, `ConsoleTabs.tsx`, starts
* a bare CLI or a shell). No header of its own: the tab bar is the console's
* header. The Workspace page keeps one per tab (slot 0–3) mounted, the
* header. The Workspace page keeps one per live session (slot 0–9) mounted, the
* inactive ones hidden with CSS.
*
* The pane is bound to the lane it shows: switching `laneId` swaps it to the
@@ -97,7 +97,7 @@ export function LaneConsolePane({
// arrives — the request must land after that. Only this lane's own tabs.
useEffect(() => {
if (!attachRequest || laneId === null) return;
if (!new RegExp(`^agenthub-lane-${laneId}(-[1-3])?$`).test(attachRequest.runId)) return;
if (!new RegExp(`^agenthub-lane-${laneId}(-[1-9])?$`).test(attachRequest.runId)) return;
let live = true;
autoAttachedForLane.current = laneId;
api.run
+19 -14
View File
@@ -8,8 +8,10 @@
* remount it and drop what it drew. With more than one pane each gets a slim
* header: drag it to move the pane (drop on another pane's edge to split
* there, on its middle to swap), pick another live session for it, split it
* right or down, or HIDE the pane (its session keeps running as a tab — so
* its icon is not the tab's stop X). A header names the pane's session and
* right or down, or take the pane OUT of this tab (its session keeps running
* in a tab of its own — so its icon is not the tab's stop X). The grid shows
* one tab's panes; `onChange` applies through `updateActive`, so a session
* dropped or swapped in from another tab leaves that tab. A header names the pane's session and
* shows its state dot, as its tab does.
* Tabs from `ConsoleTabs` drag in the same way (`startPaneDrag`). A
* right-click on a header offers the swap, a split with a new terminal and
@@ -33,8 +35,9 @@ import {
import type { RunHandle } from "../../lib/api";
import { ContextMenu, MenuItem, MenuSeparator, useContextMenu } from "../ContextMenu";
import {
EMPTY_LAYOUT,
EMPTY_GROUPS,
type Edge,
type PaneGroups,
type PaneLayout,
type Rect,
type SplitDir,
@@ -42,9 +45,9 @@ import {
closeInLayout,
layoutRects,
leaves,
loadLayout,
loadGroups,
moveInLayout,
saveLayout,
saveGroups,
setRatio,
swapInLayout,
} from "../../lib/paneLayout";
@@ -72,13 +75,15 @@ export function RunIcon({ run, label }: { run: RunHandle; label: string }) {
}
export type LayoutUpdate = (fn: (l: PaneLayout) => PaneLayout) => void;
export type GroupsUpdate = (fn: (g: PaneGroups) => PaneGroups) => void;
/** A workspace's pane layout, loaded and saved per workspace. A layout update
* that lands after a workspace switch never writes into the new one. */
export function usePaneLayout(laneId: number | null): [PaneLayout, LayoutUpdate] {
/** A workspace's tabs (groups of split panes), loaded and saved per
* workspace. An update that lands after a workspace switch never writes into
* the new one. */
export function usePaneGroups(laneId: number | null): [PaneGroups, GroupsUpdate] {
const load = (id: number | null) => ({
laneId: id,
layout: id === null ? EMPTY_LAYOUT : loadLayout(id),
groups: id === null ? EMPTY_GROUPS : loadGroups(id),
});
const [state, setState] = useState(() => load(laneId));
let current = state;
@@ -87,18 +92,18 @@ export function usePaneLayout(laneId: number | null): [PaneLayout, LayoutUpdate]
setState(current);
}
useEffect(() => {
if (state.laneId !== null) saveLayout(state.laneId, state.layout);
if (state.laneId !== null) saveGroups(state.laneId, state.groups);
}, [state]);
const update = useCallback<LayoutUpdate>(
const update = useCallback<GroupsUpdate>(
(fn) =>
setState((s) => {
if (s.laneId !== laneId) return s;
const layout = fn(s.layout);
return layout === s.layout ? s : { ...s, layout };
const groups = fn(s.groups);
return groups === s.groups ? s : { ...s, groups };
}),
[laneId]
);
return [current.layout, update];
return [current.groups, update];
}
type Zone = Edge | "center";
@@ -414,7 +414,7 @@ export function AiToolsSettings() {
>
<pre
data-testid="ai-tool-confirm-command"
className="whitespace-pre-wrap rounded-md border border-border bg-surface-1 p-2 font-mono text-[11px] text-fg-primary"
className="mt-3 whitespace-pre-wrap rounded-md border border-border bg-surface-1 p-2 font-mono text-[11px] text-fg-primary"
>
{confirmLines.join("\n")}
</pre>
@@ -17,6 +17,7 @@ import { AlertTriangle, FolderOpen } from "lucide-react";
import { confirmDialog } from "../../lib/confirm";
import { isSubmitEnter } from "../../lib/keys";
import { SettingsSection } from "./SettingsRow";
import { PathPickButton } from "../PathPickButton";
export interface HomeDirValue {
value: string;
@@ -122,6 +123,13 @@ export function HomeDirSetting({
className="input flex-1 font-mono"
placeholder={placeholder}
/>
<PathPickButton
start={input}
onPick={(p) => {
setInput(p);
setError(null);
}}
/>
<button
type="button"
onClick={handleSave}
@@ -710,8 +710,13 @@ export function PricingSettings({ vendor, allowReset = false, id = "pricing", he
onContextMenu={(e) => ctx.open(e, rule)}
className="hover:bg-surface-4 transition-colors group"
>
<td className="whitespace-nowrap px-3 py-3 text-sm font-mono text-fg-secondary">
{rule.model_pattern}
{/* The trailing wildcard is noise in a list of prefixes; the
full pattern stays in the tooltip, the editor and Copy. */}
<td
title={rule.model_pattern}
className="whitespace-nowrap px-3 py-3 text-sm font-mono text-fg-secondary"
>
{rule.model_pattern.replace(/%$/, "")}
</td>
<td className="whitespace-nowrap px-3 py-3 text-sm text-fg-secondary">
{rule.display_name}
@@ -78,7 +78,7 @@ beforeEach(() => {
describe("ClaudeAgentHubSettings", () => {
it("renders pricing, hook status and CLAUDE_HOME in their own sections", async () => {
const { container } = render(<ClaudeAgentHubSettings />);
expect(await screen.findByText("claude-opus-5-5%")).toBeInTheDocument();
expect(await screen.findByText("claude-opus-5-5")).toBeInTheDocument();
for (const id of ["pricing", "hooks", "claude-home"]) {
expect(container.querySelector(`section#${id}`)).not.toBeNull();
}
@@ -96,7 +96,7 @@ describe("ClaudeAgentHubSettings", () => {
await user.type(input, "/srv/claude");
const home = container.querySelector("section#claude-home") as HTMLElement;
const off = confirmYes();
await user.click(within(home).getByRole("button"));
await user.click(within(home).getByRole("button", { name: "Save" }));
await waitFor(() => expect(api.settings.claudeHome.set).toHaveBeenCalledWith("/srv/claude"));
off();
@@ -115,7 +115,7 @@ describe("ClaudeAgentHubSettings", () => {
const home = container.querySelector("section#claude-home") as HTMLElement;
const reads = vi.mocked(api.settings.info).mock.calls.length;
const off = confirmYes();
await user.click(within(home).getByRole("button"));
await user.click(within(home).getByRole("button", { name: "Save" }));
await waitFor(() => expect(onHomeChanged).toHaveBeenCalledTimes(1));
off();
expect(vi.mocked(api.settings.info).mock.calls.length).toBeGreaterThan(reads);
@@ -67,11 +67,12 @@ describe("PricingSettings", () => {
it("shows the vendor's rows, and the rest behind Show all models", async () => {
const user = userEvent.setup();
render(<PricingSettings vendor="openai" />);
expect(await screen.findByText("gpt-5.6-sol%")).toBeInTheDocument();
expect(screen.queryByText("claude-opus-5-5%")).toBeNull();
// The trailing wildcard is hidden in the list, kept in the tooltip.
expect(await screen.findByText("gpt-5.6-sol")).toHaveAttribute("title", "gpt-5.6-sol%");
expect(screen.queryByText("claude-opus-5-5")).toBeNull();
expect(screen.queryByRole("button", { name: /Reset Defaults/i })).toBeNull();
await user.click(screen.getByRole("checkbox", { name: /Show all models \(1 more\)/ }));
expect(screen.getByText("claude-opus-5-5%")).toBeInTheDocument();
expect(screen.getByText("claude-opus-5-5")).toBeInTheDocument();
});
it("marks a row the automatic sync keeps up to date", async () => {
@@ -82,7 +83,7 @@ describe("PricingSettings", () => {
],
});
render(<PricingSettings vendor="openai" />);
await screen.findByText("gpt-6-sol%");
await screen.findByText("gpt-6-sol");
expect(screen.getAllByText("Auto")).toHaveLength(1);
});
@@ -95,7 +96,7 @@ describe("PricingSettings", () => {
it("turns Show all on when a row outside the filter is added", async () => {
const user = userEvent.setup();
render(<PricingSettings vendor="openai" />);
await screen.findByText("gpt-5.6-sol%");
await screen.findByText("gpt-5.6-sol");
await user.click(screen.getByRole("button", { name: /Add Model/i }));
const [pattern, name] = screen.getAllByRole("textbox");
await user.type(pattern!, "deepseek-v4%");
@@ -104,7 +105,7 @@ describe("PricingSettings", () => {
pricing: [...ROWS, rule("deepseek-v4%", "DeepSeek V4")],
});
await user.click(screen.getByTitle(/save/i));
expect(await screen.findByText("deepseek-v4%")).toBeInTheDocument();
expect(await screen.findByText("deepseek-v4")).toBeInTheDocument();
expect(screen.getByRole("checkbox", { name: /Show all models/ })).toBeChecked();
});
@@ -112,8 +113,8 @@ describe("PricingSettings", () => {
const user = userEvent.setup();
vi.mocked(api.settings.resetPricing).mockReset().mockResolvedValue({ ok: true, pricing: ROWS });
render(<PricingSettings vendor="claude" allowReset />);
expect(await screen.findByText("claude-opus-5-5%")).toBeInTheDocument();
expect(screen.queryByText("gpt-5.6-sol%")).toBeNull();
expect(await screen.findByText("claude-opus-5-5")).toBeInTheDocument();
expect(screen.queryByText("gpt-5.6-sol")).toBeNull();
await user.click(screen.getByRole("button", { name: /Reset Defaults/i }));
expect(api.settings.resetPricing).not.toHaveBeenCalled();
await user.click(screen.getByRole("button", { name: /confirm/i }));
@@ -128,7 +129,7 @@ describe("PricingSettings", () => {
const confirm = answerConfirms(true);
const user = userEvent.setup();
render(<PricingSettings vendor="openai" />);
const row = (await screen.findByText("gpt-5.6-sol%")).closest("tr")!;
const row = (await screen.findByText("gpt-5.6-sol")).closest("tr")!;
await user.pointer({ keys: "[MouseRight]", target: row });
await user.click(screen.getByRole("menuitem", { name: "Delete" }));
@@ -147,7 +148,7 @@ describe("PricingSettings", () => {
const confirm = answerConfirms(false);
const user = userEvent.setup();
render(<PricingSettings vendor="openai" />);
await screen.findByText("gpt-5.6-sol%");
await screen.findByText("gpt-5.6-sol");
await user.click(screen.getByTitle("Delete"));
await vi.waitFor(() => expect(confirm.asked).toHaveLength(1));
expect(confirm.asked[0]!.message).toMatch(/sync/i);
@@ -167,14 +168,14 @@ describe("PricingSettings", () => {
<PricingSettings vendor="openai" id="b" />
</>
);
await screen.findByText("gpt-5.6-sol%");
await screen.findByText("claude-opus-5-5%");
await screen.findByText("gpt-5.6-sol");
await screen.findByText("claude-opus-5-5");
vi.mocked(api.pricing.list).mockResolvedValue({
pricing: [...ROWS, rule("claude-fable-5%", "Claude Fable 5")],
});
// The second table (openai) deletes its row; the first must reload too.
await user.click(screen.getAllByTitle("Delete")[1]!);
expect(await screen.findByText("claude-fable-5%")).toBeInTheDocument();
expect(await screen.findByText("claude-fable-5")).toBeInTheDocument();
confirm.off();
});
});
@@ -94,7 +94,7 @@ export function LanePipelinePanel({
t("lanes.emptyLane")
) : (
<>
{t("lanes.empty")} <code>agenthub lanes add --cwd $(pwd)</code>
{t("lanes.empty")} <code>agenthub workspace add --cwd $(pwd)</code>
</>
)}
</p>
@@ -151,7 +151,7 @@ describe("LanePipelinePanel", () => {
statsMock.mockResolvedValue({ pipelines: [], lanes: [] });
render(<LanePipelinePanel />);
expect(await screen.findByText(/agenthub lanes add/)).toBeInTheDocument();
expect(await screen.findByText(/agenthub workspace add/)).toBeInTheDocument();
});
it("refetches narrowed to the lane picked, and falls back to a cwd for an untitled one", async () => {
@@ -181,7 +181,7 @@ describe("LanePipelinePanel", () => {
// the whole subtree, so a handle grabbed before it is already detached.
await waitFor(() => expect(screen.queryByTestId("pipeline-stat-plan")).not.toBeInTheDocument());
expect(screen.getByTestId("pipeline-stats-lane-picker")).toBeInTheDocument();
expect(screen.queryByText(/agenthub lanes add/)).not.toBeInTheDocument();
expect(screen.queryByText(/agenthub workspace add/)).not.toBeInTheDocument();
});
it("surfaces a failed fetch", async () => {
+10 -10
View File
@@ -45,8 +45,10 @@
"addLaneWorktreesTitle": "Worktrees of this repo ({{count}})",
"browse.button": "Browse",
"browse.empty": "This folder is empty.",
"browse.fileTitle": "Browse for a file",
"browse.select": "Select this folder",
"browse.title": "Browse for a folder",
"browse.typePath": "No native picker is available here — type the path instead.",
"changes.back": "All files",
"changes.bin": "bin",
"changes.binary": "Binary file — not shown.",
@@ -241,6 +243,7 @@
"changes.nextHunk": "Next change",
"changes.hunkOf": "{{n}} of {{total}}",
"changes.noEditBinary": "Binary file — not editable here",
"changes.imageFailed": "Could not show this image (deleted, or larger than 20 MB).",
"changes.noEditTooBig": "Too large to edit here (over 1 MB)",
"changes.noEdit": "Not editable here",
"gitBar.loginTitle": "Git needs a login for {{host}}",
@@ -291,8 +294,6 @@
"changes.takeTheirsHint": "Take the incoming version of the whole file",
"row.actions": "Workspace actions",
"row.hint": "Double-click to rename · drag or Alt+↑/↓ to reorder · right-click for actions",
"menu.start": "Start",
"menu.stop": "Stop",
"menu.rename": "Rename",
"menu.newHere": "New workspace here…",
"menu.copyPath": "Copy path",
@@ -321,6 +322,7 @@
"tree.allTools": "All tools",
"tree.newSession": "New session",
"tree.noSessions": "No sessions yet",
"tree.closedTerminal": "Closed terminal — click opens a new one in this workspace's folder (Ctrl/Cmd+click: in a split)",
"tree.transcriptHint": "No live pane — opens its conversation; a message you send resumes it",
"tree.splitHint": "Live pane — Ctrl/Cmd+click opens it in a split beside the focused pane",
"tree.renameSession": "Rename session",
@@ -349,7 +351,7 @@
"home.localHint": "Work in the workspace folder itself",
"home.worktree": "New worktree",
"home.worktreeHint": "Run this session in its own git worktree — <repo>/.claude/worktrees/, a branch of its own, made and cleaned up by Claude Code",
"home.allBusy": "All four panes of this workspace are busy — stop one before starting another session.",
"home.allBusy": "All 10 sessions of this workspace are in use — stop one before starting another.",
"home.recents": "Recents",
"home.noRecents": "No sessions yet — start one above.",
"home.instructions": "Instructions",
@@ -380,7 +382,7 @@
"tabs.newTerminal": "New terminal",
"tabs.terminal": "Terminal",
"tabs.close": "Close tab (stops its session)",
"tabs.full": "All 4 tabs are in use — close one first",
"tabs.full": "All 10 sessions are in use — stop one first",
"tabs.agentSettings": "Agent settings…",
"tabs.splitHint": "Ctrl/Cmd+click: open in a split · drag onto a pane to place it",
"panes.drag": "Drag to move this pane — onto another pane's edge to split there, onto its middle to swap",
@@ -388,9 +390,9 @@
"panes.empty": "No session",
"panes.split.right": "Split right",
"panes.split.bottom": "Split down",
"panes.full": "4 panes is the most a workspace can show",
"panes.full": "A tab holds at most 4 panes",
"panes.newTerminal": "New terminal",
"panes.close": "Hide pane (its session keeps running as a tab)",
"panes.close": "Move to its own tab (the session keeps running)",
"panes.resize": "Resize panes",
"tabs.home": "Workspace home",
"sessionMenu.open": "Open",
@@ -401,7 +403,7 @@
"panes.show": "Show {{name}} here",
"panes.splitNew.right": "Split right with a new terminal",
"panes.splitNew.bottom": "Split down with a new terminal",
"panes.closeShort": "Hide pane",
"panes.closeShort": "Move to own tab",
"changes.copyPath": "Copy relative path",
"changes.viewCommit": "View commit",
"changes.copySha": "Copy commit SHA",
@@ -414,6 +416,7 @@
"tabs.state.running": "Running",
"tabs.renameHint": "Double-click to rename",
"tabs.stopTitle": "Stop “{{name}}”?",
"tabs.stopGroupTitle": "Stop the {{count}} sessions in this tab?",
"tabs.stopBody": "Closing the tab stops its session, even mid-turn. It can be resumed later from Recents.",
"tabs.stopConfirm": "Stop session",
"changes.loadMore": "Load more commits",
@@ -434,9 +437,6 @@
"row.state.running": "running",
"row.state.active": "active",
"row.state.idle": "idle",
"menu.stopTitle_one": "Stop the running session?",
"menu.stopTitle_other": "Stop {{count}} running sessions?",
"menu.stopBody": "Every tab of this workspace stops, even mid-turn. Its sessions can be resumed later from Recents.",
"gitBar.done.fetch": "Fetched",
"gitBar.done.pull": "Pulled",
"gitBar.done.push": "Pushed",
+2 -1
View File
@@ -66,7 +66,8 @@
"needYou": "{{count}} need you",
"today": "Today {{cost}}",
"workspace": "Workspace {{cost}}",
"session": "Session {{cost}}"
"session": "Session {{cost}}",
"tokens": "({{tokens}} Tokens)"
},
"version": "Version",
"openMenu": "Open menu",
+10 -10
View File
@@ -45,8 +45,10 @@
"addLaneWorktreesTitle": "Worktree của repo này ({{count}})",
"browse.button": "Duyệt",
"browse.empty": "Thư mục này trống.",
"browse.fileTitle": "Duyệt chọn tệp",
"browse.select": "Chọn thư mục này",
"browse.title": "Duyệt chọn thư mục",
"browse.typePath": "Không mở được hộp thoại chọn của hệ điều hành ở đây — hãy gõ đường dẫn.",
"changes.back": "Tất cả file",
"changes.bin": "bin",
"changes.binary": "File nhị phân — không hiển thị.",
@@ -241,6 +243,7 @@
"changes.nextHunk": "Thay đổi sau",
"changes.hunkOf": "{{n}} / {{total}}",
"changes.noEditBinary": "File nhị phân — không sửa được ở đây",
"changes.imageFailed": "Không hiển thị được ảnh này (đã bị xoá, hoặc lớn hơn 20 MB).",
"changes.noEditTooBig": "File quá lớn để sửa ở đây (trên 1 MB)",
"changes.noEdit": "Không sửa được ở đây",
"gitBar.loginTitle": "Git cần đăng nhập {{host}}",
@@ -291,8 +294,6 @@
"changes.takeTheirsHint": "Lấy bản kia cho cả file",
"row.actions": "Thao tác workspace",
"row.hint": "Nhấp đúp để đổi tên · kéo hoặc Alt+↑/↓ để sắp xếp · chuột phải để xem thao tác",
"menu.start": "Bắt đầu",
"menu.stop": "Dừng",
"menu.rename": "Đổi tên",
"menu.newHere": "Workspace mới tại đây…",
"menu.copyPath": "Sao chép đường dẫn",
@@ -321,6 +322,7 @@
"tree.allTools": "Mọi tool",
"tree.newSession": "Session mới",
"tree.noSessions": "Chưa có session",
"tree.closedTerminal": "Terminal đã đóng — bấm để mở terminal mới ở thư mục của workspace (Ctrl/Cmd+click: mở ở split)",
"tree.transcriptHint": "Không còn pane — mở hội thoại; gửi tin nhắn sẽ tiếp tục (resume) session",
"tree.splitHint": "Pane đang chạy — Ctrl/Cmd+click để mở ở split cạnh pane đang focus",
"tree.renameSession": "Đổi tên session",
@@ -349,7 +351,7 @@
"home.localHint": "Làm việc ngay trong thư mục của workspace",
"home.worktree": "Worktree mới",
"home.worktreeHint": "Chạy session này trong worktree git riêng — <repo>/.claude/worktrees/, branch riêng, do Claude Code tạo và dọn",
"home.allBusy": "Cả bốn pane của workspace này đang bận — hãy dừng một pane trước khi mở session mới.",
"home.allBusy": "Workspace này đã dùng hết 10 session — hãy dừng một session trước khi mở session mới.",
"home.recents": "Gần đây",
"home.noRecents": "Chưa có session nào — bắt đầu ở ô phía trên.",
"home.instructions": "Hướng dẫn",
@@ -380,7 +382,7 @@
"tabs.newTerminal": "Terminal mới",
"tabs.terminal": "Terminal",
"tabs.close": "Đóng tab (dừng phiên của nó)",
"tabs.full": "Đã dùng hết 4 tab — hãy đóng bớt một tab",
"tabs.full": "Đã dùng hết 10 session — hãy dừng bớt một session",
"tabs.agentSettings": "Cài đặt agent…",
"tabs.splitHint": "Ctrl/Cmd+click: mở ở split · kéo thả vào một pane để đặt vị trí",
"panes.drag": "Kéo để di chuyển pane — thả vào cạnh pane khác để tách ở đó, vào giữa để đổi chỗ",
@@ -388,9 +390,9 @@
"panes.empty": "Không có session",
"panes.split.right": "Tách sang phải",
"panes.split.bottom": "Tách xuống dưới",
"panes.full": "Một workspace hiện tối đa 4 pane",
"panes.full": "Một tab chứa tối đa 4 pane",
"panes.newTerminal": "Terminal mới",
"panes.close": "Ẩn pane (session vẫn chạy dưới dạng tab)",
"panes.close": "Tách ra tab riêng (session vẫn chạy)",
"panes.resize": "Đổi kích thước pane",
"tabs.home": "Trang chủ workspace",
"sessionMenu.open": "Mở",
@@ -401,7 +403,7 @@
"panes.show": "Hiện {{name}} ở đây",
"panes.splitNew.right": "Chia sang phải với terminal mới",
"panes.splitNew.bottom": "Chia xuống dưới với terminal mới",
"panes.closeShort": "Ẩn pane",
"panes.closeShort": "Tách ra tab riêng",
"changes.copyPath": "Sao chép đường dẫn tương đối",
"changes.viewCommit": "Xem commit",
"changes.copySha": "Sao chép SHA của commit",
@@ -414,6 +416,7 @@
"tabs.state.running": "Đang chạy",
"tabs.renameHint": "Nhấp đúp để đổi tên",
"tabs.stopTitle": "Dừng “{{name}}”?",
"tabs.stopGroupTitle": "Dừng {{count}} session trong tab này?",
"tabs.stopBody": "Đóng tab sẽ dừng phiên của nó, kể cả khi đang chạy dở. Bạn có thể tiếp tục (resume) sau từ mục Gần đây.",
"tabs.stopConfirm": "Dừng phiên",
"changes.loadMore": "Tải thêm commit",
@@ -434,9 +437,6 @@
"row.state.running": "đang chạy",
"row.state.active": "đang hoạt động",
"row.state.idle": "nghỉ",
"menu.stopTitle_one": "Dừng phiên đang chạy?",
"menu.stopTitle_other": "Dừng {{count}} phiên đang chạy?",
"menu.stopBody": "Mọi tab của workspace này sẽ dừng, kể cả khi đang chạy dở. Có thể tiếp tục các phiên sau từ mục Gần đây.",
"gitBar.done.fetch": "Đã fetch",
"gitBar.done.pull": "Đã pull",
"gitBar.done.push": "Đã push",
+2 -1
View File
@@ -66,7 +66,8 @@
"needYou": "{{count}} cần bạn",
"today": "Hôm nay {{cost}}",
"workspace": "Workspace {{cost}}",
"session": "Session {{cost}}"
"session": "Session {{cost}}",
"tokens": "({{tokens}} Tokens)"
},
"version": "Phiên bản",
"openMenu": "Mở menu",
@@ -2,12 +2,20 @@
* @file laneOrderStorage.test.ts
* @description Tests for the Workspace lane strip's stored card order: the
* storage round-trip, and the two rules that keep a stale stored order from
* losing a lane — unknown ids land at the end, vanished ids are skipped.
* losing a lane — unknown ids land at the end, vanished ids are skipped — plus
* the drop math: a drop lands between items by the pointer's position.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { describe, it, expect, beforeEach } from "vitest";
import { readLaneOrder, writeLaneOrder, sortByStoredOrder, moveLane } from "../laneOrderStorage";
import {
readLaneOrder,
writeLaneOrder,
sortByStoredOrder,
moveLane,
insertionIndex,
moveBefore,
} from "../laneOrderStorage";
const lanes = (...ids: number[]) => ids.map((id) => ({ id }));
@@ -72,4 +80,21 @@ describe("moveLane", () => {
expect(moveLane([1, 2, 3], 0, 3)).toEqual([1, 2, 3]);
expect(moveLane([1, 2, 3], -1, 0)).toEqual([1, 2, 3]);
});
it("places a drop before the first item whose middle is past the pointer", () => {
const mids = [10, 30, 50];
expect(insertionIndex(mids, 0)).toBe(0);
expect(insertionIndex(mids, 20)).toBe(1); // a gap between two items
expect(insertionIndex(mids, 49)).toBe(2);
expect(insertionIndex(mids, 90)).toBe(3); // past the last: the end
});
it("moves an item to just before an insertion point", () => {
expect(moveBefore([1, 2, 3, 4], 0, 2)).toEqual([2, 1, 3, 4]);
expect(moveBefore([1, 2, 3, 4], 0, 4)).toEqual([2, 3, 4, 1]);
expect(moveBefore([1, 2, 3, 4], 3, 0)).toEqual([4, 1, 2, 3]);
// Before itself or right after itself: no move.
expect(moveBefore([1, 2, 3], 1, 1)).toEqual([1, 2, 3]);
expect(moveBefore([1, 2, 3], 1, 2)).toEqual([1, 2, 3]);
});
});
+124 -11
View File
@@ -3,26 +3,36 @@
* @description Tests for the session view's split-pane tree: opening a run
* (replace the focused pane, or split it), closing a pane and handing focus to
* the pane that takes its place, swapping/moving panes, resizing a split,
* pruning panes whose run is gone, the rectangles the grid draws, and the
* pruning panes whose run is gone, the rectangles the grid draws, the TABS
* that group up to four panes each (a session in exactly one), and the
* per-workspace storage that must survive bad or stale data.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { describe, it, expect, beforeEach } from "vitest";
import {
type PaneGroups,
type PaneLayout,
type PaneNode,
EMPTY_GROUPS,
MAX_PANES,
closeInLayout,
detachSlot,
layoutRects,
leaves,
loadLayout,
loadGroups,
moveInLayout,
openInGroups,
openInLayout,
pane,
parseGroups,
parseLayout,
pruneGroups,
pruneLayout,
saveLayout,
removeSlot,
reorderGroups,
saveGroups,
setRatio,
updateActive,
split,
swapInLayout,
} from "../paneLayout";
@@ -182,9 +192,112 @@ describe("pruneLayout", () => {
});
});
describe("pane groups (tabs of up to four panes)", () => {
const single = (slot: number): PaneLayout => ({ root: pane(slot), focus: slot });
const g2: PaneGroups = { groups: [single(0), single(1)], active: 0 };
it("opens a new session as a tab of its own after the active one", () => {
const out = openInGroups(g2, 5, false);
expect(out.groups.map((g) => leaves(g.root))).toEqual([[0], [5], [1]]);
expect(out.active).toBe(1);
});
it("activates the tab already holding a session, focusing it", () => {
const g: PaneGroups = { groups: [single(0), { root: split("row", pane(1), pane(2)), focus: 1 }], active: 0 };
const out = openInGroups(g, 2, false);
expect(out.active).toBe(1);
expect(out.groups[1]!.focus).toBe(2);
});
it("a split open joins the active tab, and the tab it left goes", () => {
const out = openInGroups(g2, 1, true);
expect(out.groups).toHaveLength(1);
expect(leaves(out.groups[0]!.root)).toEqual([0, 1]);
expect(out.groups[0]!.focus).toBe(1);
});
it("a split open on a full tab opens its own tab instead", () => {
let g: PaneGroups = { groups: [single(0)], active: 0 };
for (const s of [1, 2, 3]) g = openInGroups(g, s, true);
expect(leaves(g.groups[0]!.root)).toHaveLength(MAX_PANES);
const out = openInGroups(g, 4, true);
expect(out.groups.map((x) => leaves(x.root))).toEqual([[0, 1, 2, 3], [4]]);
expect(out.active).toBe(1);
});
it("a pane leaving the active tab becomes a tab of its own next to it", () => {
const g: PaneGroups = {
groups: [{ root: split("row", pane(0), pane(1)), focus: 1 }, single(2)],
active: 0,
};
const out = updateActive(g, (l) => closeInLayout(l, 1));
expect(out.groups.map((x) => leaves(x.root))).toEqual([[0], [1], [2]]);
expect(out.active).toBe(0);
});
it("a pane moved in from another tab leaves that tab", () => {
const g: PaneGroups = { groups: [single(0), single(1), single(2)], active: 0 };
const out = updateActive(g, (l) => moveInLayout(l, 2, 0, "bottom"));
expect(out.groups.map((x) => leaves(x.root))).toEqual([[0, 2], [1]]);
expect(out.active).toBe(0);
});
it("an ended session leaves its tab; an emptied active tab hands over to a neighbour", () => {
const g: PaneGroups = { groups: [single(0), single(1), single(2)], active: 2 };
expect(removeSlot(g, 2)).toEqual({ groups: [single(0), single(1)], active: 1 });
expect(removeSlot({ ...g, active: 0 }, 0)).toEqual({ groups: [single(1), single(2)], active: 0 });
expect(removeSlot(g, 7)).toBe(g);
});
it("prunes dead panes and gives every live session without a tab one", () => {
const g: PaneGroups = { groups: [{ root: split("row", pane(0), pane(1)), focus: 1 }, single(2)], active: 1 };
const out = pruneGroups(g, new Set([0, 4]), new Set());
expect(out.groups.map((x) => leaves(x.root))).toEqual([[0], [4]]);
expect(out.active).toBe(0);
expect(pruneGroups(g, new Set([0, 1, 2]), new Set())).toBe(g);
});
it("detaches a pane into a tab of its own at a given place, and reorders tabs", () => {
const g: PaneGroups = { groups: [{ root: split("row", pane(0), pane(1)), focus: 0 }, single(2)], active: 1 };
const out = detachSlot(g, 1, 0);
expect(out.groups.map((x) => leaves(x.root))).toEqual([[1], [0], [2]]);
expect(out.active).toBe(0);
const moved = reorderGroups(out, 2, 0);
expect(moved.groups.map((x) => leaves(x.root))).toEqual([[2], [1], [0]]);
expect(moved.active).toBe(1);
});
});
describe("parseLayout / storage", () => {
beforeEach(() => window.localStorage.clear());
it("stores tabs per workspace and reads a pre-tab single layout as one tab", () => {
const g: PaneGroups = { groups: [{ root: split("row", pane(0), pane(9)), focus: 9 }, { root: pane(4), focus: 4 }], active: 1 };
saveGroups(1, g);
expect(loadGroups(1)).toEqual(g);
window.localStorage.setItem(
"agenthub.workspace.paneLayout",
JSON.stringify({ 2: { root: split("row", pane(0), pane(1)), focus: 1 } })
);
expect(loadGroups(2)).toEqual({ groups: [{ root: split("row", pane(0), pane(1)), focus: 1 }], active: 0 });
});
it("drops a slot past nine and a session stored in two tabs", () => {
expect(parseGroups({ groups: [{ root: pane(10), focus: 10 }], active: 0 })).toEqual(EMPTY_GROUPS);
const out = parseGroups({ groups: [{ root: pane(1), focus: 1 }, { root: pane(1), focus: 1 }], active: 1 });
expect(out).toEqual({ groups: [{ root: pane(1), focus: 1 }], active: 0 });
});
it("keeps the stored active tab selected when a tab before it is dropped", () => {
const b = { root: pane(2), focus: 2 };
const c = { root: pane(3), focus: 3 };
expect(parseGroups({ groups: [{ root: pane(10), focus: 10 }, b, c], active: 1 })).toEqual({
groups: [b, c],
active: 0,
});
expect(parseGroups({ groups: [b, c], active: 7 }).active).toBe(1);
});
it("accepts a valid stored layout", () => {
const l: PaneLayout = { root: split("col", pane(1), pane(3), 0.3), focus: 3 };
expect(parseLayout(JSON.parse(JSON.stringify(l)))).toEqual(l);
@@ -192,7 +305,7 @@ describe("parseLayout / storage", () => {
it("rejects junk, out-of-range slots, duplicates and oversized trees", () => {
expect(parseLayout(null)).toEqual({ root: null, focus: null });
expect(parseLayout({ root: { type: "pane", slot: 7 }, focus: 7 }).root).toBeNull();
expect(parseLayout({ root: { type: "pane", slot: 10 }, focus: 10 }).root).toBeNull();
expect(parseLayout({ root: split("row", pane(1), pane(1)), focus: 1 }).root).toBeNull();
const five = split(
"row",
@@ -208,16 +321,16 @@ describe("parseLayout / storage", () => {
expect(out.focus).toBe(0);
});
it("keeps one layout per workspace", () => {
saveLayout(1, { root: split("row", pane(0), pane(1)), focus: 1 });
saveLayout(2, one);
expect(loadLayout(1).focus).toBe(1);
expect(loadLayout(2)).toEqual(one);
expect(loadLayout(3)).toEqual({ root: null, focus: null });
it("keeps one set of tabs per workspace", () => {
saveGroups(1, { groups: [{ root: split("row", pane(0), pane(1)), focus: 1 }], active: 0 });
saveGroups(2, { groups: [one], active: 0 });
expect(loadGroups(1).groups[0]?.focus).toBe(1);
expect(loadGroups(2)).toEqual({ groups: [one], active: 0 });
expect(loadGroups(3)).toEqual(EMPTY_GROUPS);
});
it("survives unreadable storage", () => {
window.localStorage.setItem("agenthub.workspace.paneLayout", "{not json");
expect(loadLayout(1)).toEqual({ root: null, focus: null });
expect(loadGroups(1)).toEqual(EMPTY_GROUPS);
});
});
+39 -1
View File
@@ -1380,13 +1380,15 @@ export const api = {
*
* @returns {@link CostResult} — the aggregate cost breakdown across sessions.
*/
totalCost: (opts: { lane?: number } = {}) => {
totalCost: (opts: { lane?: number; date?: string } = {}) => {
// Scope the aggregate to the active data-scope, exactly like the sessions /
// stats / analytics endpoints — otherwise switching the Data scope selector
// left the Dashboard "total cost" showing the un-narrowed global total.
// `lane`: only the sessions that workspace owns.
const qs = new URLSearchParams({ tz_offset: String(new Date().getTimezoneOffset()) });
if (opts.lane !== undefined) qs.set("lane", String(opts.lane));
// `date` (local YYYY-MM-DD): only that day's usage, breakdown included.
if (opts.date) qs.set("date", opts.date);
applyScope(qs);
return request<CostResult>(`/pricing/cost?${qs.toString()}`);
},
@@ -2176,6 +2178,14 @@ export const api = {
run: {
/** GET /api/run - lanes with a live tmux-backed run, computed fresh from tmux state. */
list: () => request<RunListResponse>("/run"),
/** GET /api/run/history - persisted runs, newest first; `laneId` narrows to
* one workspace and `tool` (with it) to one tool's — `shell` = Terminal tabs. */
history: (opts: { laneId: number; tool?: string; limit?: number }) => {
const qs = new URLSearchParams({ laneId: String(opts.laneId) });
if (opts.tool) qs.set("tool", opts.tool);
if (opts.limit) qs.set("limit", String(opts.limit));
return request<{ items: RunHistoryItem[] }>(`/run/history?${qs.toString()}`);
},
/** GET /api/run/binary - whether the lane tool's CLI was found on PATH (fresh look). */
binary: (tool: LaneTool = "claude") =>
request<{ found: boolean; path: string | null }>(`/run/binary?tool=${tool}`),
@@ -2607,6 +2617,20 @@ export const api = {
parent: string | null;
entries: { name: string; path: string; isGitRepo: boolean }[];
}>(`/lanes/browse${path ? `?path=${encodeURIComponent(path)}` : ""}`),
/**
* POST /api/lanes/pick-path — open the OS's own folder/file picker on the
* server's desktop and wait for the user. Throws `.code` `EREMOTE` (not a
* browser on the server's machine), `EBADORIGIN` or `ENODIALOG` (no desktop
* / no picker program) when the caller should fall back to {@link browse}.
* @param kind `dir` or `file`.
* @param start Folder (or file) to open the picker at; home otherwise.
* @returns `{ path }`, or `{ cancelled: true }` when the user cancelled.
*/
pickPath: (kind: "dir" | "file", start?: string) =>
request<{ path?: string; cancelled?: boolean }>("/lanes/pick-path", {
method: "POST",
body: JSON.stringify({ kind, start }),
}),
/**
* GET /api/lanes/:id — fetch one lane.
* @param id The lane id.
@@ -2666,6 +2690,9 @@ export const api = {
*/
gitFile: (id: GitTarget, path: string, rev = "work") =>
request<LaneFileContent>(gitUrl(id, "/file", new URLSearchParams({ path, rev }))),
/** URL of GET /api/lanes/:id/git/raw — an image's bytes, for an `<img>`. */
gitRawUrl: (id: GitTarget, path: string, rev = "work") =>
`${BASE}${gitUrl(id, "/raw", new URLSearchParams({ path, rev }))}`,
/**
* PUT /api/lanes/:id/git/file — save the editor's text over the working
* file. `baseSha` is the `sha` it was opened at; a file that changed on
@@ -4011,6 +4038,17 @@ export interface RunListResponse {
items: RunHandle[];
}
/** One row of GET /api/run/history (`dashboard_runs`; only the fields read). */
export interface RunHistoryItem {
id: string;
tool: string;
status: string;
started_at: string;
ended_at: string | null;
/** The run's tmux pane is still up. */
isLive: boolean;
}
/** One suggested working directory for the Run page's cwd picker. */
export interface CwdSuggestion {
/** "dashboard" = this server's own cwd; "home" = user's home dir; "recent"
+21 -2
View File
@@ -1,7 +1,9 @@
/**
* @file laneOrderStorage.ts
* @description Persists the Workspace lane strip's user-chosen card order to
* localStorage, the same read/validate/fallback convention as useTheme.ts.
* @description Persists the sidebar's user-chosen workspace order to
* localStorage, the same read/validate/fallback convention as useTheme.ts, and
* holds the drag-to-reorder math the sidebar and the console tab bar share
* (the tab bar's own order lives with its tabs, `lib/paneLayout.ts`).
*
* The order lives in the browser rather than the lanes table on purpose: it is
* a per-viewer preference, and nothing
@@ -66,3 +68,20 @@ export function moveLane(ids: number[], from: number, to: number): number[] {
next.splice(to, 0, ...next.splice(from, 1));
return next;
}
/** Where a drop lands: before the first item whose middle (`mids`, on the
* drag axis) lies past the pointer, else at the end. Gaps between items —
* a workspace's session list, the space between two tabs — count too. */
export function insertionIndex(mids: number[], pos: number): number {
const i = mids.findIndex((m) => pos < m);
return i === -1 ? mids.length : i;
}
/** Moves the item at `from` to just before insertion point `before`. */
export function moveBefore<T>(ids: T[], from: number, before: number): T[] {
if (from < 0 || from >= ids.length || before < 0 || before > ids.length) return ids;
if (before === from || before === from + 1) return ids;
const next = [...ids];
next.splice(before > from ? before - 1 : before, 0, ...next.splice(from, 1));
return next;
}
+163 -10
View File
@@ -1,20 +1,29 @@
/**
* @file paneLayout.ts
* @description The session view's split panes, VS Code style: a binary tree
* whose leaves are pane SLOTS (0–3, one tmux session each — see
* whose leaves are pane SLOTS (0–9, one tmux session each — see
* `server/lib/pty-run.js`) and whose inner nodes split their box in a row
* (side by side) or a column (stacked) at a ratio. Pure functions only: the
* page keeps the layout in state, and `layoutRects` turns it into the
* rectangles `PaneGrid` draws every pane at — each pane is an absolutely
* placed sibling, so reshaping the tree never re-parents (and so never
* remounts) a live terminal. A slot appears at most once: one terminal is
* never attached twice. The layout is a viewer preference, kept per workspace
* in `localStorage` (`agenthub.workspace.paneLayout`), never on the server.
* never attached twice.
*
* The session view's TABS are groups of such trees (`PaneGroups`): each tab
* holds one to `MAX_PANES` panes, every live session sits in exactly one tab,
* and a session on its own is a one-pane tab — splitting or dropping one tab
* onto another's pane merges them into one tab, closing a pane of a tab puts
* its session back in a tab of its own. The tabs are a viewer preference,
* kept per workspace in `localStorage` (`agenthub.workspace.paneLayout`; a
* stored pre-tab single layout reads as one tab), never on the server.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
/** As many panes as a workspace has tmux slots. */
/** Panes in one tab. */
export const MAX_PANES = 4;
/** Sessions a workspace may hold: slots 0–9 (`MAX_PANE_SLOT` + 1 on the server). */
export const MAX_SLOTS = 10;
const MIN_RATIO = 0.1;
const STORAGE_KEY = "agenthub.workspace.paneLayout";
@@ -202,7 +211,7 @@ function parseNode(raw: unknown): PaneNode | null {
if (!raw || typeof raw !== "object") return null;
const n = raw as Record<string, unknown>;
if (n.type === "pane") {
return Number.isInteger(n.slot) && (n.slot as number) >= 0 && (n.slot as number) < MAX_PANES
return Number.isInteger(n.slot) && (n.slot as number) >= 0 && (n.slot as number) < MAX_SLOTS
? pane(n.slot as number)
: null;
}
@@ -225,6 +234,150 @@ export function parseLayout(raw: unknown): PaneLayout {
return { root, focus: focusOf({ root, focus: typeof focus === "number" ? focus : null }) };
}
// ── Tabs: groups of up to MAX_PANES panes ─────────────────────────────
/** The tabs, each a split tree, and the one on screen. */
export interface PaneGroups {
groups: PaneLayout[];
active: number;
}
export const EMPTY_GROUPS: PaneGroups = { groups: [], active: 0 };
const solo = (slot: number): PaneLayout => ({ root: pane(slot), focus: slot });
/** The tab on screen (an empty layout when there is none). */
export const activeLayout = (g: PaneGroups): PaneLayout => g.groups[g.active] ?? EMPTY;
/** The tab holding `slot`, or -1. */
export const groupOf = (g: PaneGroups, slot: number): number =>
g.groups.findIndex((l) => leaves(l.root).includes(slot));
/** Drops emptied tabs. The active tab stays active; when it went, the tab
* before it takes over (the first one when it was first). */
function compact(groups: PaneLayout[], active: number): PaneGroups {
const kept = groups.filter((l) => l.root);
if (!kept.length) return EMPTY_GROUPS;
if (groups[active]?.root) return { groups: kept, active: kept.indexOf(groups[active]) };
const before = groups.slice(0, active).filter((l) => l.root).length;
return { groups: kept, active: Math.max(0, Math.min(before - 1, kept.length - 1)) };
}
/**
* Changes the tab on screen with a per-tree operation (`moveInLayout`,
* `swapInLayout`, `closeInLayout`, `setRatio`…). A session the change brings
* in leaves the tab it was in; one it takes out gets a tab of its own next to
* this one — closing a pane never hides a running session.
*/
export function updateActive(g: PaneGroups, fn: (l: PaneLayout) => PaneLayout): PaneGroups {
const base = g.groups.length ? g : { groups: [EMPTY], active: 0 };
const cur = base.groups[base.active] ?? EMPTY;
const next = fn(cur);
if (next === cur) return g;
const before = leaves(cur.root);
const after = leaves(next.root);
const added = after.filter((s) => !before.includes(s));
const solos = before.filter((s) => !after.includes(s)).map(solo);
const groups = base.groups.map((l, i) =>
i === base.active ? next : added.reduce((acc, s) => closeInLayout(acc, s), l)
);
// An emptied tab is replaced by what left it, in its place.
if (next.root) groups.splice(base.active + 1, 0, ...solos);
else groups.splice(base.active, 1, ...solos);
return compact(groups, base.active);
}
/**
* Shows `slot`. Its own tab is activated (and the slot focused there); a
* session in no tab opens a new one after the active tab. `split` (Ctrl/Cmd+
* click) brings it into the active tab beside the focused pane instead —
* unless that tab is already full, when it falls back to the plain open.
*/
export function openInGroups(g: PaneGroups, slot: number, split: boolean): PaneGroups {
const cur = activeLayout(g);
if (split && cur.root && (leaves(cur.root).includes(slot) || leaves(cur.root).length < MAX_PANES))
return updateActive(g, (l) => openInLayout(l, slot, "split"));
const at = groupOf(g, slot);
if (at >= 0) {
const groups = [...g.groups];
groups[at] = { ...(groups[at] as PaneLayout), focus: slot };
return { groups, active: at };
}
const i = g.groups.length ? g.active + 1 : 0;
const groups = [...g.groups];
groups.splice(i, 0, solo(slot));
return { groups, active: i };
}
/** A session ended: its pane goes, and its tab when that was the last one. */
export function removeSlot(g: PaneGroups, slot: number): PaneGroups {
const at = groupOf(g, slot);
if (at < 0) return g;
const groups = [...g.groups];
groups[at] = closeInLayout(groups[at] as PaneLayout, slot);
return compact(groups, g.active);
}
/** Takes `slot` out of its tab into a tab of its own, inserted before the tab
* now at `at` (`groups.length` = last), and shows it. */
export function detachSlot(g: PaneGroups, slot: number, at: number): PaneGroups {
const from = groupOf(g, slot);
if (from < 0) return g;
const groups = g.groups.map((l, i) => (i === from ? closeInLayout(l, slot) : l));
groups.splice(at, 0, solo(slot));
return compact(groups, at);
}
/** Moves the tab at `from` before the one at `at`; the active tab stays active. */
export function reorderGroups(g: PaneGroups, from: number, at: number): PaneGroups {
if (from < 0 || from === at || from + 1 === at) return g;
const groups = [...g.groups];
const [moved] = groups.splice(from, 1);
groups.splice(at > from ? at - 1 : at, 0, moved as PaneLayout);
return { groups, active: groups.indexOf(g.groups[g.active] as PaneLayout) };
}
/**
* Drops every pane whose session is neither `live` nor `keep` (see
* `pruneLayout`) and the tabs that leaves empty, and gives every live session
* no tab holds one of its own. Same object back when nothing changed.
*/
export function pruneGroups(g: PaneGroups, live: Set<number>, keep: Set<number>): PaneGroups {
let changed = false;
const groups = g.groups.map((l) => {
const n = pruneLayout(l, live, keep);
if (n !== l) changed = true;
return n;
});
const tabbed = new Set(groups.flatMap((l) => leaves(l.root)));
const missing = [...live].filter((s) => !tabbed.has(s)).sort((a, b) => a - b);
if (!changed && !missing.length) return g;
return compact([...groups, ...missing.map(solo)], g.active);
}
/** Stored tabs, checked: a malformed tab, or one showing a session an
* earlier tab already holds, is dropped. A pre-tab single layout is one tab. */
export function parseGroups(raw: unknown): PaneGroups {
if (!raw || typeof raw !== "object") return EMPTY_GROUPS;
const r = raw as { groups?: unknown; active?: unknown };
const list = Array.isArray(r.groups) ? r.groups : "root" in r ? [raw] : [];
const rawActive = Number.isInteger(r.active) ? (r.active as number) : 0;
const seen = new Set<number>();
const groups: PaneLayout[] = [];
// `active` indexes the STORED list; count only the tabs kept before it, so
// a dropped tab ahead of it does not shift the selection onto its neighbour.
let active = 0;
list.forEach((item, i) => {
const l = parseLayout(item);
const shown = leaves(l.root);
if (!l.root || shown.some((s) => seen.has(s))) return;
shown.forEach((s) => seen.add(s));
if (i < rawActive) active++;
groups.push(l);
});
if (!groups.length) return EMPTY_GROUPS;
return { groups, active: Math.min(active, groups.length - 1) };
}
function readAll(): Record<string, unknown> {
try {
const all = JSON.parse(window.localStorage.getItem(STORAGE_KEY) ?? "{}");
@@ -234,17 +387,17 @@ function readAll(): Record<string, unknown> {
}
}
export function loadLayout(laneId: number): PaneLayout {
return parseLayout(readAll()[String(laneId)]);
export function loadGroups(laneId: number): PaneGroups {
return parseGroups(readAll()[String(laneId)]);
}
export function saveLayout(laneId: number, layout: PaneLayout): void {
export function saveGroups(laneId: number, g: PaneGroups): void {
const all = readAll();
if (layout.root) all[String(laneId)] = layout;
if (g.groups.length) all[String(laneId)] = g;
else delete all[String(laneId)];
try {
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(all));
} catch {
/* disabled storage: the layout lives for this page only */
/* disabled storage: the tabs live for this page only */
}
}
+8 -6
View File
@@ -2870,6 +2870,14 @@ export function Dashboard() {
</div>
</div>
<div className="flex items-center gap-3">
{/* Monitor's Refresh sits LEFT of the tabs: on their right it pushed
Analytics/Monitor/Health aside every time Monitor opened.
Analytics and Health carry their own Refresh; one per view. */}
{monitorOn && (
<button onClick={load} className="btn-ghost flex-shrink-0">
<RefreshCw className="w-4 h-4" /> {t("common:refresh")}
</button>
)}
{/* Tabs */}
<div
role="tablist"
@@ -2892,12 +2900,6 @@ export function Dashboard() {
</button>
))}
</div>
{/* Analytics and Health carry their own Refresh; one per view. */}
{monitorOn && (
<button onClick={load} className="btn-ghost flex-shrink-0">
<RefreshCw className="w-4 h-4" /> {t("common:refresh")}
</button>
)}
</div>
</div>
+87 -68
View File
@@ -5,7 +5,8 @@
* is picked in the sidebar's project tree and named by `?lane=<id>` (mirrored to
* `agenthub.workspace.selected`, so a bare /workspace reopens the last one).
* Shows the selected lane's pipeline map (when a template is installed) and run console (a
* tab per live tmux slot, 0–3, with a `+` menu — `ConsoleTabs.tsx`), and the
* tab per group of up to four split panes over the live tmux slots 0–9, with a
* `+` menu — `ConsoleTabs.tsx`), and the
* right panel —
* Files · Changes · Sessions. Runs are tied to lanes: starting a run
* posts to POST /api/lanes/:id/start with tool-appropriate fields. When a cwd
@@ -84,18 +85,23 @@ import {
type LayoutUpdate,
PaneGrid,
SplitMenus,
usePaneLayout,
usePaneGroups,
} from "../components/run/PaneGrid";
import {
type Edge,
type PaneGroups,
type PaneLayout,
MAX_PANES,
activeLayout,
closeInLayout,
groupOf,
leaves,
moveInLayout,
openInLayout,
openInGroups,
pane,
pruneLayout,
pruneGroups,
removeSlot,
updateActive,
} from "../lib/paneLayout";
import { useAiTools } from "../hooks/useAiTools";
import { freeSlot, startInFreeTab } from "../components/lanes/startSession";
@@ -105,6 +111,7 @@ import PipelineMap from "../components/lanes/PipelineMap";
import LaneCard, { since } from "../components/lanes/LaneCard";
import { AddLaneModal } from "../components/lanes/AddLaneModal";
import { LaneChangesPanel } from "../components/lanes/LaneChangesPanel";
import { LaneGitRailCounts } from "../components/lanes/LaneGitHeader";
import { WorkspaceHome } from "../components/lanes/WorkspaceHome";
import { SessionReplay } from "../components/lanes/SessionReplay";
import { DestructiveLaneModal } from "../components/lanes/DestructiveLaneModal";
@@ -161,20 +168,20 @@ const CHANGES_MIN_PX = 288;
/** The pane slot a run id names (`agenthub-lane-<id>[-<slot>]`). */
function slotOf(runId: string | null | undefined): number {
const m = runId?.match(/^agenthub-lane-\d+(?:-([1-3]))?$/);
const m = runId?.match(/^agenthub-lane-\d+(?:-([1-9]))?$/);
return m ? Number(m[1] || 0) : 0;
}
/** The session view's console: a tab bar over the workspace's split panes
* (`PaneGrid`; layout model in `lib/paneLayout.ts`), one `LaneConsolePane`
* per slot 0–3, each its own tmux session on the selected lane. The focused
* pane is the URL's `?run=`: a plain open (a tab, a sidebar row) shows its run
* in the focused pane — or focuses the pane already showing it — and a split
* open (Ctrl/Cmd+click) opens it to the right of the focused one. Every tab
* with a live run stays MOUNTED, hidden with CSS while no pane shows it, so
* neither switching tabs nor reshaping the panes ever drops a terminal. A
* pane whose run ends closes; the last one leaves for another tab, else the
* home. */
/** The session view's console: a tab bar whose every tab is a group of up to
* four split panes (`PaneGrid`; model in `lib/paneLayout.ts`), one
* `LaneConsolePane` per slot 0–9, each its own tmux session on the selected
* lane. The focused pane of the tab on screen is the URL's `?run=`: a plain
* open (a tab, a sidebar row) shows the tab holding that run — a session in
* no tab gets one of its own — and a split open (Ctrl/Cmd+click) brings it
* into the tab on screen, to the right of the focused pane. Every live run
* stays MOUNTED, hidden with CSS while its tab is not shown, so neither
* switching tabs nor reshaping the panes ever drops a terminal. A pane whose
* run ends closes; the last tab gone leaves for the home. */
function ConsoleArea({
lanes,
selectedLaneId,
@@ -216,11 +223,12 @@ function ConsoleArea({
.sort((a, b) => (a.slot ?? 0) - (b.slot ?? 0)),
[activeRuns, selectedLaneId]
);
const [layout, setLayout] = usePaneLayout(selectedLaneId);
// The latest layout, updated the moment a change is made: two changes in
// one commit (an open request and a prune) must build on each other.
const layoutRef = useRef(layout);
layoutRef.current = layout;
const [groups, setGroups] = usePaneGroups(selectedLaneId);
// The latest tabs, updated the moment a change is made: two changes in one
// commit (an open request and a prune) must build on each other.
const groupsRef = useRef(groups);
groupsRef.current = groups;
const layout = activeLayout(groups);
// Slots opened here whose run the polled list has not caught up with yet,
// each with the list it was opened against: pruning spares one until a
// NEWER list still lacks it (a run that died before it was ever listed).
@@ -263,16 +271,16 @@ function ConsoleArea({
// is no effect watching the focus — an open request and that effect would
// keep overriding each other. Session view only: the home and a replay
// keep their URL.
const followFocus = (next: PaneLayout) => {
const followFocus = (next: PaneGroups) => {
if (!attachRequest) return;
const run = runOf(next.focus);
const run = runOf(activeLayout(next).focus);
if (run && run.id !== attachRequest.runId) onOpenRun(run.id, { focusOnly: true });
};
const change: LayoutUpdate = (fn) => {
const next = fn(layoutRef.current);
if (next === layoutRef.current) return;
layoutRef.current = next;
setLayout(() => next);
const changeGroups = (fn: (g: PaneGroups) => PaneGroups) => {
const next = fn(groupsRef.current);
if (next === groupsRef.current) return;
groupsRef.current = next;
setGroups(() => next);
followFocus(next);
};
@@ -286,19 +294,23 @@ function ConsoleArea({
// lane arrives on a cold load; only this lane's own tabs.
useEffect(() => {
if (!attachRequest || selectedLaneId === null) return;
if (!new RegExp(`^agenthub-lane-${selectedLaneId}(-[1-3])?$`).test(attachRequest.runId)) return;
if (!new RegExp(`^agenthub-lane-${selectedLaneId}(-[1-9])?$`).test(attachRequest.runId)) return;
const slot = slotOf(attachRequest.runId);
// The URL catching up with a focus change: by now that pane may be closed
// or swapped, and re-opening it would undo what the user just did. Back/
// Forward onto such an entry puts the real focus back in the URL instead.
if (attachRequest.focusOnly) {
if (leaves(layoutRef.current.root).includes(slot))
change((l) => (l.focus === slot ? l : { ...l, focus: slot }));
else followFocus(layoutRef.current);
const g = groupsRef.current;
const at = groupOf(g, slot);
if (at >= 0)
changeGroups(() =>
at === g.active && g.groups[at]?.focus === slot ? g : openInGroups(g, slot, false)
);
else followFocus(g);
return;
}
if (!tabs.some((r) => (r.slot ?? 0) === slot)) pending.current.set(slot, activeRuns);
change((l) => openInLayout(l, slot, attachRequest.split ? "split" : "replace"));
changeGroups((g) => openInGroups(g, slot, attachRequest.split === true));
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [attachRequest?.nonce, selectedLaneId]);
@@ -318,12 +330,14 @@ function ConsoleArea({
}
const first = listed.current !== selectedLaneId;
listed.current = selectedLaneId;
const tabbed = groupsRef.current.groups.flatMap((l) => leaves(l.root));
const keep = first
? new Set(pending.current.keys())
: new Set(leaves(layoutRef.current.root).filter((s) => !expired.has(s)));
const before = layoutRef.current;
change((l) => pruneLayout(l, live, keep));
if (layoutRef.current === before && before.focus !== null && arrived.includes(before.focus))
: new Set(tabbed.filter((s) => !expired.has(s)));
const before = groupsRef.current;
changeGroups((g) => pruneGroups(g, live, keep));
const focus = activeLayout(before).focus;
if (groupsRef.current === before && focus !== null && arrived.includes(focus))
followFocus(before);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [activeRuns, tabs, selectedLaneId]);
@@ -335,9 +349,9 @@ function ConsoleArea({
onRunsChanged();
const dead = runOf(slot);
if (dead) exited.current.add(runKey(dead));
if (!leaves(layoutRef.current.root).includes(slot)) return;
change((l) => closeInLayout(l, slot));
if (layoutRef.current.root || !attachRequest) return;
if (groupOf(groupsRef.current, slot) < 0) return;
changeGroups((g) => removeSlot(g, slot));
if (groupsRef.current.groups.length || !attachRequest) return;
const other = tabs.find((r) => (r.slot ?? 0) !== slot && !exited.current.has(runKey(r)));
if (other) onOpenRun(other.id);
else onNewSession?.();
@@ -358,7 +372,7 @@ function ConsoleArea({
onRunsChanged();
if (laneRef.current !== laneId) return;
pending.current.set(slot, activeRuns);
change((l) => moveInLayout(l.root ? l : view, slot, target, edge));
change((l) => moveInLayout(l, slot, target, edge));
} catch (err) {
toast.error(err);
}
@@ -369,12 +383,17 @@ function ConsoleArea({
// all): one pane, so the console always has its single render site.
const fallback = requested[0] ?? 0;
const view: PaneLayout = layout.root ? layout : { root: pane(fallback), focus: fallback };
const viewGroups: PaneGroups = groups.groups.length ? groups : { groups: [view], active: 0 };
// A change to the tab on screen (`PaneGrid`, the split menus). Tabs never
// saved are the one-pane `view`: change that.
const change: LayoutUpdate = (fn) =>
changeGroups((g) => updateActive(g.groups.length ? g : viewGroups, fn));
const shown = leaves(view.root);
const mounted = [...new Set([...shown, ...tabs.map((r) => r.slot ?? 0)])].sort();
const mounted = [...new Set([...shown, ...tabs.map((r) => r.slot ?? 0)])].sort((a, b) => a - b);
const focusSlot = view.focus;
// Alt+1–4 focus a pane, Alt+\ splits the focused one right with a new
// terminal, Alt+W hides it. Session view only, and never from inside a
// terminal, Alt+W takes it out into a tab of its own. Session view only, and never from inside a
// terminal or a field, whose keys are their own (Ctrl+M lets Tab out of a
// terminal first). Read through a ref: one listener, always current.
const onKeys = useRef<(e: KeyboardEvent) => void>(() => undefined);
@@ -414,8 +433,8 @@ function ConsoleArea({
<ConsoleTabs
lane={lane}
tabs={tabs}
activeRunId={runOf(focusSlot)?.id ?? attachRequest?.runId ?? null}
shownRunIds={shown.map((s) => runOf(s)?.id).filter((id): id is string => !!id)}
groups={viewGroups}
onGroupsChange={changeGroups}
onOpen={(runId, split) => {
onRunsChanged();
// A tab click is not a new history entry: Back leaves the
@@ -435,8 +454,7 @@ function ConsoleArea({
shown={shown}
runs={tabs}
label={label}
// A layout never saved is the one-pane `view`: split that.
onChange={(fn) => change((l) => fn(l.root ? l : view))}
onChange={change}
canStart={free !== null}
tools={splitTools}
toolLabel={aiTools.label}
@@ -963,7 +981,7 @@ export function Workspace() {
<p className="mt-0.5 text-xs text-fg-muted">
{tLanes("empty.body")}{" "}
<code className="rounded bg-surface-2 px-1.5 py-0.5 text-[11px] text-fg-secondary">
agenthub lanes add --cwd $(pwd)
agenthub workspace add --cwd $(pwd)
</code>
</p>
</div>
@@ -992,17 +1010,12 @@ export function Workspace() {
<WorkspaceHome
lane={currentLane}
onRenamed={() => void refreshLanes()}
onMenuAction={(action, runId) => {
onMenuAction={(action) => {
if (action === "new-here") {
setAddLanePrefill({ sourceRepo: currentLane.source_repo || currentLane.cwd });
setAddLaneOpen(true);
} else if (action === "reset" || action === "remove") {
setDestructive({ lane: currentLane, action });
} else if (action === "started") {
setSearchParams({
lane: String(currentLane.id),
run: runId ?? `agenthub-lane-${currentLane.id}`,
});
}
}}
/>
@@ -1323,21 +1336,27 @@ export function Workspace() {
</div>
</aside>
) : (
<button
type="button"
data-testid="lane-changes-expand"
onClick={() => setChangesOpen(true)}
aria-expanded={false}
aria-controls="lane-changes-panel"
aria-label={tLanes("changes.expand")}
title={tLanes("changes.expand")}
className="flex w-9 shrink-0 flex-col items-center gap-2 border-l border-border bg-surface-1 py-3 text-fg-muted transition-colors hover:text-fg-primary"
>
<PanelLeftClose className="h-4 w-4" />
<span className="text-[10px] uppercase tracking-wide [writing-mode:vertical-rl]">
{tLanes("changes.title")}
</span>
</button>
// The rail also carries the uncommitted counts, vertical, with
// the per-file list on hover — a sibling of the expand button,
// since a button may not hold another.
<div className="flex w-9 shrink-0 flex-col items-center gap-3 border-l border-border bg-surface-1 py-3">
<button
type="button"
data-testid="lane-changes-expand"
onClick={() => setChangesOpen(true)}
aria-expanded={false}
aria-controls="lane-changes-panel"
aria-label={tLanes("changes.expand")}
title={tLanes("changes.expand")}
className="flex flex-col items-center gap-2 text-fg-muted transition-colors hover:text-fg-primary"
>
<PanelLeftClose className="h-4 w-4" />
<span className="text-[10px] uppercase tracking-wide [writing-mode:vertical-rl]">
{tLanes("changes.title")}
</span>
</button>
{panelAt && <LaneGitRailCounts laneId={panelAt.laneId} session={panelAt.session} />}
</div>
))}
</div>
</div>
+46 -27
View File
@@ -7,7 +7,7 @@
*/
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { render, act, screen, waitFor, within } from "@testing-library/react";
import { render, act, cleanup, screen, waitFor, within } from "@testing-library/react";
import { MemoryRouter, Route, Routes, useSearchParams } from "react-router-dom";
import userEvent from "@testing-library/user-event";
@@ -56,6 +56,8 @@ let nextRunId = 1;
vi.mock("../../components/lanes/WorkspaceHome", () => ({
WorkspaceHome: () => <div data-testid="workspace-home" />,
}));
// Hoisted with the mock: a workspace holds ten sessions (MAX_PANE_SLOT 9).
const SLOTS = vi.hoisted(() => [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]);
vi.mock("../../components/lanes/startSession", () => ({
paneRunId: (id: number, slot: number) =>
slot ? `agenthub-lane-${id}-${slot}` : `agenthub-lane-${id}`,
@@ -63,7 +65,7 @@ vi.mock("../../components/lanes/startSession", () => ({
const busy = new Set(
runs.filter((r) => r.laneId === laneId && r.status === "running").map((r) => r.slot ?? 0)
);
return [0, 1, 2, 3].find((s) => !busy.has(s)) ?? null;
return SLOTS.find((s) => !busy.has(s)) ?? null;
},
// The server picks the slot (`slot: "auto"`); stand in for it with the first
// one the mocked run list leaves free.
@@ -75,7 +77,7 @@ vi.mock("../../components/lanes/startSession", () => ({
.filter((r) => r.laneId === laneId && r.status === "running")
.map((r) => r.slot ?? 0)
);
const slot = [0, 1, 2, 3].find((x) => !busy.has(x)) ?? 0;
const slot = SLOTS.find((x) => !busy.has(x)) ?? 0;
await api.lanes.action(laneId, "start", { ...body, slot: "auto" });
return slot ? `agenthub-lane-${laneId}-${slot}` : `agenthub-lane-${laneId}`;
},
@@ -621,9 +623,13 @@ describe("console tabs", () => {
expect(api.lanes.action).toHaveBeenCalledWith(1, "start", { slot: "auto", shell: true });
});
it("disables + once all four slots are taken", async () => {
it("keeps + enabled past four sessions and disables it once all ten slots are taken", async () => {
withRuns([live(0), live(1), live(2), live(3)]);
await open("/workspace?lane=1&run=agenthub-lane-1");
expect(screen.getByTestId("console-tab-new")).toBeEnabled();
cleanup();
withRuns(SLOTS.map((s) => live(s)));
await open("/workspace?lane=1&run=agenthub-lane-1");
expect(screen.getByTestId("console-tab-new")).toBeDisabled();
});
@@ -685,7 +691,7 @@ describe("split panes", () => {
const stored = () =>
JSON.parse(window.localStorage.getItem("agenthub.workspace.paneLayout") ?? "{}")["1"];
it("Ctrl+click on a tab opens it in a split beside the focused pane, and remembers it", async () => {
it("Ctrl+click on a tab brings it into the tab on screen as a split, one combo tab", async () => {
withRuns([live(0), live(1)]);
await open("/workspace?lane=1&run=agenthub-lane-1");
expect(shownPanes()).toEqual(["pane-0"]);
@@ -696,29 +702,41 @@ describe("split panes", () => {
expect(screen.getByTestId("run-param").textContent).toBe("agenthub-lane-1-1");
expect(screen.getByTestId("pane-1")).toHaveAttribute("data-focused", "true");
expect(screen.getByTestId("pane-header-0")).toBeInTheDocument();
// The other pane's tab is marked as shown; the focused one is the active tab.
expect(screen.getByTestId("console-tab-1")).toHaveAttribute("aria-selected", "true");
expect(screen.getByTestId("console-tab-0").parentElement).toHaveAttribute("data-shown", "true");
expect(stored().root).toMatchObject({ type: "split", dir: "row" });
// The two tabs became ONE tab holding both panes.
expect(screen.queryByTestId("console-tab-1")).toBeNull();
expect(screen.getByTestId("console-tab-0-1")).toHaveAttribute("aria-selected", "true");
expect(stored().groups).toHaveLength(1);
expect(stored().groups[0].root).toMatchObject({ type: "split", dir: "row" });
});
it("a plain click focuses a tab another pane shows, and replaces the focused pane otherwise", async () => {
it("a plain click shows another tab, and a combo tab comes back as it was", async () => {
withRuns([live(0), live(1), live(2)]);
await open("/workspace?lane=1&run=agenthub-lane-1");
await ctrlClick(screen.getByTestId("console-tab-1"));
await userEvent.setup().click(screen.getByTestId("console-tab-0"));
await settle();
expect(shownPanes()).toEqual(["pane-0", "pane-1"]);
expect(screen.getByTestId("run-param").textContent).toBe("agenthub-lane-1");
await userEvent.setup().click(screen.getByTestId("console-tab-2"));
await settle();
expect(shownPanes()).toEqual(["pane-1", "pane-2"]);
expect(screen.getByTestId("pane-2")).toHaveAttribute("data-focused", "true");
expect(shownPanes()).toEqual(["pane-2"]);
expect(screen.getByTestId("run-param").textContent).toBe("agenthub-lane-1-2");
await userEvent.setup().click(screen.getByTestId("console-tab-0-1"));
await settle();
expect(shownPanes()).toEqual(["pane-0", "pane-1"]);
expect(screen.getByTestId("run-param").textContent).toBe("agenthub-lane-1-1");
});
it("closing a pane keeps its session as a tab; closing a tab closes its pane", async () => {
it("a tab holds at most four panes; a fifth split opens a tab of its own", async () => {
withRuns([live(0), live(1), live(2), live(3), live(4)]);
await open("/workspace?lane=1&run=agenthub-lane-1");
for (const s of [1, 2, 3]) await ctrlClick(screen.getByTestId(`console-tab-${s}`));
expect(shownPanes()).toEqual(["pane-0", "pane-1", "pane-2", "pane-3"]);
await ctrlClick(screen.getByTestId("console-tab-4"));
expect(shownPanes()).toEqual(["pane-4"]);
expect(screen.getByTestId("console-tab-0-1-2-3")).toBeInTheDocument();
expect(screen.getByTestId("console-tab-4")).toHaveAttribute("aria-selected", "true");
});
it("closing a pane takes it out into a tab of its own; closing a tab stops every session in it", async () => {
withRuns([live(0), live(1), live(2)]);
await open("/workspace?lane=1&run=agenthub-lane-1");
await ctrlClick(screen.getByTestId("console-tab-1"));
@@ -731,12 +749,13 @@ describe("split panes", () => {
expect(api.run.kill).not.toHaveBeenCalled();
expect(screen.getByTestId("console-tab-1")).toBeInTheDocument();
withRuns([live(0), live(1)]);
await userEvent.setup().click(screen.getByTestId("console-tab-close-2"));
withRuns([live(1)]);
await userEvent.setup().click(screen.getByTestId("console-tab-close-0-2"));
await settle();
expect(api.run.kill).toHaveBeenCalledWith("agenthub-lane-1");
expect(api.run.kill).toHaveBeenCalledWith("agenthub-lane-1-2");
expect(shownPanes()).toEqual(["pane-0"]);
expect(screen.getByTestId("run-param").textContent).toBe("agenthub-lane-1");
expect(shownPanes()).toEqual(["pane-1"]);
expect(screen.getByTestId("run-param").textContent).toBe("agenthub-lane-1-1");
});
it("drops a remembered pane whose session is gone", async () => {
@@ -812,7 +831,7 @@ describe("split panes", () => {
expect(shownPanes()).toEqual(["pane-0", "pane-1", "pane-3"]);
await right(screen.getByTestId("pane-header-1"));
await user.click(within(screen.getByTestId("pane-menu-1")).getByText("Hide pane"));
await user.click(within(screen.getByTestId("pane-menu-1")).getByText("Move to own tab"));
await settle();
expect(shownPanes()).toEqual(["pane-0", "pane-3"]);
expect(api.run.kill).not.toHaveBeenCalled();
@@ -896,7 +915,7 @@ describe("split panes", () => {
await act(async () => screen.getByTestId("exit-agenthub-lane-1-1").click());
await settle();
expect(screen.getByTestId("run-param").textContent).toBe("");
expect(stored().root).toEqual({ type: "pane", slot: 0 });
expect(stored().groups[0].root).toEqual({ type: "pane", slot: 0 });
// The last one too: still the home.
await act(async () => screen.getByTestId("exit-agenthub-lane-1").click());
await settle();
@@ -908,7 +927,7 @@ describe("split panes", () => {
await open("/workspace?lane=1&run=agenthub-lane-1");
await ctrlClick(screen.getByTestId("console-tab-1"));
withRuns([]);
await userEvent.setup().click(screen.getByTestId("console-tab-0"));
await userEvent.setup().click(screen.getByTestId("console-tab-0-1"));
await settle();
expect(shownPanes()).toEqual(["pane-0", "pane-1"]);
});
@@ -937,8 +956,8 @@ describe("split panes", () => {
expect(term("agenthub-lane-1")).toHaveAttribute("data-autofocus", "false");
});
it("offers no new terminal in a split once every slot is busy", async () => {
withRuns([live(0), live(1), live(2), live(3)]);
it("offers no new terminal in a split once all ten slots are busy", async () => {
withRuns(SLOTS.map((s) => live(s)));
await open("/workspace?lane=1&run=agenthub-lane-1");
await ctrlClick(screen.getByTestId("console-tab-1"));
await userEvent.setup().click(screen.getByTestId("pane-split-right-1"));
@@ -2618,6 +2618,32 @@ exports[`screen snapshots > Claude Config 1`] = `
type="text"
value="/home/test/.claude"
/>
<button
aria-label="Browse for a folder"
class="flex shrink-0 items-center gap-1 self-stretch rounded-md border border-border-light px-2 text-xs text-fg-secondary hover:bg-surface-2 disabled:opacity-50"
title="Browse for a folder"
type="button"
>
<svg
class="lucide lucide-folder-open h-3.5 w-3.5"
fill="none"
height="24"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
viewBox="0 0 24 24"
width="24"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="m6 14 1.5-2.9A2 2 0 0 1 9.24 10H20a2 2 0 0 1 1.94 2.5l-1.54 6a2 2 0 0 1-1.95 1.5H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h3.9a2 2 0 0 1 1.69.9l.81 1.2a2 2 0 0 0 1.67.9H18a2 2 0 0 1 2 2v2"
/>
</svg>
</button>
<div
class="contents"
/>
<button
class="btn-primary px-4 py-2 text-sm disabled:opacity-50 disabled:cursor-not-allowed"
disabled=""
@@ -5452,7 +5478,7 @@ exports[`screen snapshots > Run 1`] = `
<code
class="rounded bg-surface-2 px-1.5 py-0.5 text-[11px] text-fg-secondary"
>
agenthub lanes add --cwd $(pwd)
agenthub workspace add --cwd $(pwd)
</code>
</p>
</div>
@@ -6955,7 +6981,7 @@ exports[`screen snapshots > Settings — about 1`] = `
<p
class="text-[10px] text-status-warning/90 mt-1"
>
UI build v2.0.1 (rebuild client to match)
UI build v2.1.0 (rebuild client to match)
</p>
</div>
<div
@@ -9935,7 +9961,7 @@ exports[`screen snapshots > Workflows 1`] = `
No workspace has recorded a stage yet. Create one with
<code>
agenthub lanes add --cwd $(pwd)
agenthub workspace add --cwd $(pwd)
</code>
</p>
</div>