Files
AgentHub/client/src/components/run/LaneConsolePane.tsx
T
nntrivi2001 6c605849d8 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.
2026-10-06 11:31:26 +07:00

194 lines
7.7 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* @file LaneConsolePane.tsx
* @description One console tab's body: a TerminalView on the tab's tmux
* session (`agenthub-lane-<id>[-<slot>]`), or — with no run — an empty pane
* 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 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
* new lane's own live session for its slot, or an empty pane — never the
* previous lane's terminal. `attachRequest` (a run picked in the sidebar or a
* tab) attaches to that tmux session only; nothing is started or resumed.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { AlertCircle, Plus } from "lucide-react";
import { api } from "../../lib/api";
import type { RunHandle, RunListResponse } from "../../lib/api";
import type { Lane } from "../../lib/types";
import { useAiTools } from "../../hooks/useAiTools";
import { TerminalView } from "./TerminalView";
export interface LaneConsolePaneProps {
lanes: Lane[];
laneId: number | null;
binaryStatus: Record<string, { found: boolean; path: string | null }> | null;
activeRuns: RunListResponse | null;
onHasActiveRunChange?: (active: boolean) => void;
/** Which tab this is: slot 0 is the lane's own session, 1–3
* `agenthub-lane-<id>-<slot>`. */
slot?: number;
/** A live run to show in this pane (a session picked in the sidebar or a
* tab). It attaches to that tmux session only. */
attachRequest?: { runId: string; nonce: string } | null;
/** Opens the workspace home, where a new session is started. */
onNewSession?: () => void;
/** The pane's run exited (the tab bar opens another tab, or the home). */
onRunExit?: () => void;
/** The terminal takes the keyboard when it appears (default). */
autoFocus?: boolean;
}
/** The tmux session a lane's tab owns (see server/lib/pty-run.js). */
function paneRunId(laneId: number, slot: number): string {
return slot ? `agenthub-lane-${laneId}-${slot}` : `agenthub-lane-${laneId}`;
}
export function LaneConsolePane({
lanes,
laneId,
binaryStatus,
activeRuns,
onHasActiveRunChange,
slot = 0,
attachRequest,
onNewSession,
onRunExit,
autoFocus,
}: LaneConsolePaneProps) {
const { t } = useTranslation("run");
const [handle, setHandle] = useState<RunHandle | null>(null);
const [error, setError] = useState<string | null>(null);
const currentLane = laneId !== null ? lanes.find((l) => l.id === laneId) : null;
const aiTools = useAiTools();
// The tool of the run on screen — a session's own, which need not be the
// workspace's; the workspace's with no run.
const paneTool = handle?.tool ?? currentLane?.tool ?? "claude";
useEffect(() => {
onHasActiveRunChange?.(handle !== null);
}, [handle, onHasActiveRunChange]);
// `activeRuns` is re-fetched every few seconds by the page, so reading it
// through a ref keeps the lane-switch effect below off its identity.
const latest = useRef(activeRuns);
latest.current = activeRuns;
// A pane's terminal belongs to the lane it shows, so switching lanes swaps
// it — to the new lane's own live session for this slot, if it has one.
const autoAttachedForLane = useRef<number | null>(null);
useEffect(() => {
setError(null);
const own = laneId === null ? null : paneRunId(laneId, slot);
setHandle(latest.current?.items.find((r) => r.id === own && r.status === "running") ?? null);
autoAttachedForLane.current = null;
}, [laneId, slot]);
// Runs after the lane-switch effect above, so a sidebar pick that changes
// lane AND run at once lands on the picked run, not the lane's own. Keyed on
// `laneId` too: on a cold load the pane mounts before the lane list does,
// and the switch effect then resets the pane to its own slot once `laneId`
// 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-9])?$`).test(attachRequest.runId)) return;
let live = true;
autoAttachedForLane.current = laneId;
api.run
.get(attachRequest.runId)
.then((h) => {
if (!live) return;
setHandle(h);
// A later attach that worked clears an earlier one's failure.
setError(null);
})
.catch(
(err: unknown) =>
live &&
setError(
t("errors.attachFailed", { message: err instanceof Error ? err.message : "unknown" })
)
);
return () => {
live = false;
};
// A new request (nonce), or the lane it names arriving, attaches; a switch
// to another lane drops `?run=` (Workspace), so it never replays one.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [attachRequest?.nonce, laneId]);
// The switch effect only sees whatever `activeRuns` the page had loaded at
// that instant — null right after a remount. Re-check once the poll lands,
// once per lane.
useEffect(() => {
if (handle || laneId === null) return;
if (autoAttachedForLane.current === laneId) return;
const own = paneRunId(laneId, slot);
const running = activeRuns?.items.find((r) => r.id === own && r.status === "running");
if (running) {
autoAttachedForLane.current = laneId;
setHandle(running);
}
}, [activeRuns, laneId, slot, handle]);
return (
<div data-testid="console-body" className="flex min-h-0 flex-1 flex-col gap-3">
{/* Not probed yet (a tool the page has not probed, or a shell) is not "missing". */}
{binaryStatus?.[paneTool] && !binaryStatus[paneTool].found && (
<div className="mx-3 mt-2 rounded-lg border border-status-danger/40 bg-status-danger/10 px-4 py-3 text-sm text-status-danger flex items-center gap-2">
<AlertCircle className="w-4 h-4 flex-shrink-0" />
<span>
{t("binary.missing", {
cli: aiTools.byId(paneTool)?.command ?? paneTool,
tool: aiTools.label(paneTool),
})}
</span>
</div>
)}
{error && (
<div className="mx-3 mt-2 rounded-lg border border-status-danger/40 bg-status-danger/10 px-4 py-3 text-sm text-status-danger flex items-center gap-2">
<AlertCircle className="w-4 h-4 flex-shrink-0" />
<span className="flex-1 break-all">{error}</span>
</div>
)}
{!handle ? (
<div
data-testid="pane-no-run"
className="m-3 flex flex-1 flex-col items-center justify-center gap-3 rounded-xl border border-dashed border-border p-6 text-center text-sm text-fg-muted"
>
<p>{t("emptyPane")}</p>
{onNewSession && (
<button type="button" onClick={onNewSession} className="btn-primary text-xs">
<Plus className="h-3.5 w-3.5" />
{t("actions.newSession")}
</button>
)}
</div>
) : (
<div className="flex-1 min-h-0 flex flex-col">
<TerminalView
runId={handle.id}
wsBaseUrl={window.location.origin.replace(/^http/, "ws")}
autoFocus={autoFocus}
// The pane died (double Ctrl+C out of `claude`, `exit`, a crash) —
// the tab bar moves on (another tab, or the home's prompt box)
// rather than leaving a dead terminal the user has to click out of.
onExit={() => {
setHandle(null);
onRunExit?.();
}}
/>
</div>
)}
</div>
);
}