6c605849d8
- /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.
194 lines
7.7 KiB
TypeScript
194 lines
7.7 KiB
TypeScript
/**
|
||
* @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>
|
||
);
|
||
}
|