Files
AgentHub/client/src/components/run/LaneConsolePane.tsx
T
nntrivi2001 c3f83d3922 perf(chat): hidden panes stop polling, rows stop re-rendering, a covered terminal stops drawing
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>
2026-10-14 11:26:57 +07:00

231 lines
9.5 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.
* 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>
);
}