c3f83d3922
The Workspace keeps panes of other workspaces and tabs mounted (display:none), and every one of them kept following the live stream as if shown. - useOnScreen (IntersectionObserver): ConversationView, the outline and the 700 ms menu scan skip while their pane is hidden and catch up once on show. - A new_event refetches the tail at most every 1.5 s (leading + trailing). - MessageList: WorkGroup/MessageRow/EditedFiles compare only their own blocks and results, so a tail append no longer re-renders every row. - A terminal under the chat is moved off the viewport with a transform, so xterm pauses drawing without a resize. - PaneChat and TerminalView are memoized; LaneConsolePane passes a stable onExit. - RunningStep's clock and the outline poll skip unchanged or hidden updates. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
231 lines
9.5 KiB
TypeScript
231 lines
9.5 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.
|
||
* A Claude or Codex pane also has a Chat view (`PaneChat`): the session's
|
||
* conversation laid OVER the terminal, which stays mounted and attached under
|
||
* it. Chat or terminal is each SESSION's own (`useSessionChat`, the toggle
|
||
* left of the pane's split buttons); the Chat UI setting (Settings →
|
||
* Appearance, `lib/chatUi.ts`) is only what a session shows first.
|
||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||
*/
|
||
|
||
import { useCallback, 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";
|
||
import { PaneChat } from "./PaneChat";
|
||
import { useSessionChat } from "../../lib/chatUi";
|
||
|
||
const CHAT_TOOLS = new Set(["claude", "codex"]);
|
||
|
||
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 pane spans the grid's full width or height: its Chat view's right
|
||
* panel starts open (collapsed in a quarter-sized pane). */
|
||
panelOpen?: 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,
|
||
panelOpen = false,
|
||
}: 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";
|
||
// A run learns its session id after it starts (Codex): the page's poll has it.
|
||
const chatSession = handle
|
||
? (activeRuns?.items.find((r) => r.id === handle.id)?.sessionId ?? handle.sessionId)
|
||
: null;
|
||
const chat = useSessionChat(chatSession);
|
||
const chatable = !!handle && !!chatSession && CHAT_TOOLS.has(handle.tool);
|
||
// Stable, so the memoized terminal is not re-rendered for a new closure.
|
||
const onRunExitRef = useRef(onRunExit);
|
||
onRunExitRef.current = onRunExit;
|
||
const onTerminalExit = useCallback(() => {
|
||
setHandle(null);
|
||
onRunExitRef.current?.();
|
||
}, []);
|
||
|
||
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="relative flex-1 min-h-0 flex flex-col">
|
||
{/* Over the terminal, never instead of it: unmounting would drop the attach. */}
|
||
{chatable && chat && (
|
||
<div className="absolute inset-0 z-20 flex flex-col bg-surface-0">
|
||
<PaneChat
|
||
run={handle}
|
||
sessionId={chatSession}
|
||
cwd={handle.cwd || currentLane?.cwd || ""}
|
||
laneId={laneId}
|
||
laneCwd={currentLane?.cwd || ""}
|
||
panelOpen={panelOpen}
|
||
/>
|
||
</div>
|
||
)}
|
||
<TerminalView
|
||
runId={handle.id}
|
||
wsBaseUrl={window.location.origin.replace(/^http/, "ws")}
|
||
autoFocus={autoFocus && !(chatable && chat)}
|
||
covered={!!(chatable && chat)}
|
||
// 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={onTerminalExit}
|
||
/>
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|