agent-orchestrator/frontend/src/renderer/components/SessionView.tsx

197 lines
8.8 KiB
TypeScript

import { useCallback, useEffect, useRef, useState } from "react";
import type { PanelImperativeHandle, PanelSize } from "react-resizable-panels";
import { CenterPane } from "./CenterPane";
import { SessionInspector } from "./SessionInspector";
import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from "./ui/resizable";
import { useUiStore } from "../stores/ui-store";
import { useShell } from "../lib/shell-context";
import { useWorkspaceQuery } from "../hooks/useWorkspaceQuery";
import { isOrchestratorSession } from "../types/workspace";
import type { TerminalTarget } from "../types/terminal";
const INSPECTOR_MIN_PERCENT = 22;
const INSPECTOR_MAX_PERCENT = 45;
const inspectorSplitStorageKey = "ao.inspector.split";
function initialSplitPercent(): number {
const raw = typeof window === "undefined" ? null : window.localStorage?.getItem(inspectorSplitStorageKey);
const parsed = raw === null ? Number.NaN : Number(raw);
if (!Number.isFinite(parsed)) return 28;
return Math.min(INSPECTOR_MAX_PERCENT, Math.max(INSPECTOR_MIN_PERCENT, parsed));
}
type SessionViewProps = {
sessionId: string;
};
// The session detail screen: terminal + git rail, under the shell-owned
// ShellTopbar. Rendered by both the project-scoped and cross-project session
// routes. TerminalPane owns the terminal lifetime and remounts by terminal
// handle so each session gets a clean xterm/mux binding.
//
// The split is shadcn's resizable (react-resizable-panels v4) with a fully
// collapsible inspector: the panel is `collapsible` and driven to 0% via the
// imperative API from the ui-store (topbar button / ⌘⇧B), animated by the
// flex-grow transition in styles.css. Content keeps a stable min-width inside
// the clipped panel so nothing reflows mid-animation; split width persists.
export function SessionView({ sessionId }: SessionViewProps) {
const workspaceQuery = useWorkspaceQuery();
const workspaces = workspaceQuery.data ?? [];
const { theme } = useUiStore();
const isInspectorOpen = useUiStore((state) => state.isInspectorOpen);
const toggleInspector = useUiStore((state) => state.toggleInspector);
const { daemonStatus } = useShell();
const inspectorRef = useRef<PanelImperativeHandle | null>(null);
const inspectorSeparatorRef = useRef<HTMLDivElement | null>(null);
const [terminalTarget, setTerminalTarget] = useState<TerminalTarget>({ kind: "worker" });
const session = workspaces.flatMap((workspace) => workspace.sessions).find((s) => s.id === sessionId);
const isOrchestrator = session ? isOrchestratorSession(session) : false;
useEffect(() => {
setTerminalTarget({ kind: "worker" });
}, [sessionId]);
// Orchestrator sessions are terminal-only; only worker sessions have the rail.
const hasInspector = !isOrchestrator;
// Computed when the inspector panel mounts and frozen while it stays
// mounted: rrp re-registers the panel (a layout effect keyed on defaultSize,
// among others) whenever this prop's identity changes, and the imperative
// collapse()/expand() below can race that re-registration within the same
// commit — rrp then throws "Panel constraints not found for Panel
// inspector", which unwinds the whole route to the router's CatchBoundary
// (the toggle button looks dead and the session view is torn down).
// Re-derived per panel mount (not once per SessionView mount — navigating
// orchestrator → worker keeps this component mounted while the panel
// remounts) so a freshly mounted panel reflects the store on its own,
// without an imperative fix-up in the mount commit. Afterwards the
// imperative API owns the size, so this must never track live open state.
const inspectorDefaultSizeRef = useRef<string | null>(null);
if (!hasInspector) {
inspectorDefaultSizeRef.current = null;
} else if (inspectorDefaultSizeRef.current === null) {
inspectorDefaultSizeRef.current = isInspectorOpen ? `${initialSplitPercent()}%` : "0%";
}
const inspectorDefaultSize = inspectorDefaultSizeRef.current ?? "0%";
useEffect(() => {
if (!hasInspector) return;
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key.toLowerCase() !== "b" || !event.shiftKey) return;
if (!event.metaKey && !event.ctrlKey) return;
event.preventDefault();
toggleInspector();
};
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [hasInspector, toggleInspector]);
// Drive the collapsible panel from the store so the topbar button, ⌘⇧B, and
// drag-to-collapse all stay in sync. hasInspector must NOT be a dep: when
// the inspector panel mounts into the already-live group (orchestrator →
// worker navigation), rrp only derives the new panel's constraints in the
// next commit, so an expand()/collapse() in the mount commit throws "Panel
// constraints not found for Panel inspector" and unwinds the route. The
// panel mounts in sync via inspectorDefaultSize above; only later toggles
// need the imperative API, by which point registration has settled.
useEffect(() => {
const panel = inspectorRef.current;
if (!panel) return;
if (isInspectorOpen) {
panel.expand();
// expand() restores the "most recent" size, which is 0 when the panel
// mounted collapsed — fall back to the persisted split.
if (panel.getSize().asPercentage === 0) panel.resize(`${initialSplitPercent()}%`);
} else {
panel.collapse();
}
}, [isInspectorOpen]);
// Persist drags and mirror collapse state (dragging past minSize collapses)
// back into the store. Read the store imperatively to avoid a stale closure.
// Gated on an actively dragged separator: rrp v4 derives sizes from the
// observed DOM layout, so the flex-grow transition that animates
// expand()/collapse() (styles.css) fires onResize with transient
// mid-animation sizes too. Writing those back turned the imperative
// collapse into a feedback loop — a mid-collapse size read as "dragged
// back open", re-toggled the store, and the panel bounced back (the
// topbar button looked dead). rrp marks the separator
// data-separator="active" only during a pointer drag — the same hook the
// transition-suppressing CSS keys on, so drag writes are never transition
// frames.
// Also wrapped in useCallback: rrp v4's panel registration useLayoutEffect
// includes onResize in its dep array, so an unstable reference would
// de-register/re-register the inspector panel on every render and race
// with the expand()/collapse() effect above.
const handleInspectorResize = useCallback(
(size: PanelSize) => {
if (inspectorSeparatorRef.current?.getAttribute("data-separator") !== "active") return;
const open = useUiStore.getState().isInspectorOpen;
if (size.asPercentage > 0) {
window.localStorage?.setItem(inspectorSplitStorageKey, String(size.asPercentage));
if (!open) toggleInspector();
} else if (open) {
toggleInspector();
}
},
[toggleInspector],
);
if (!session && !workspaceQuery.isLoading) {
return (
<div className="grid h-full place-items-center bg-background p-6 text-center font-mono text-[12px] text-passive">
Session not found. It may have been cleaned up pick another from the sidebar.
</div>
);
}
return (
<div className="flex h-full min-h-0 flex-col bg-background text-foreground">
<ResizablePanelGroup className="session-split min-h-0 flex-1" id="session-workspace" orientation="horizontal">
{/* react-resizable-panels v4: bare numbers are PIXELS; percentages must
be strings. Numeric sizes here once clamped the inspector to 45px. */}
<ResizablePanel defaultSize="72%" id="terminal" minSize="45%">
<CenterPane
daemonReady={daemonStatus.state === "ready"}
onSelectWorkerTerminal={() => setTerminalTarget({ kind: "worker" })}
session={session}
terminalTarget={terminalTarget}
theme={theme}
/>
</ResizablePanel>
{hasInspector ? (
<>
<ResizableHandle
className="session-inspector__resize-handle focus-visible:ring-0 focus-visible:ring-offset-0"
elementRef={inspectorSeparatorRef}
/>
<ResizablePanel
aria-hidden={!isInspectorOpen}
collapsible
defaultSize={inspectorDefaultSize}
id="inspector"
inert={!isInspectorOpen}
maxSize={`${INSPECTOR_MAX_PERCENT}%`}
minSize={`${INSPECTOR_MIN_PERCENT}%`}
onResize={handleInspectorResize}
panelRef={inspectorRef}
style={{ overflow: "hidden" }}
>
{/* Stable content width while the panel animates (yyork pattern):
the pane clips instead of reflowing the inspector mid-collapse. */}
<div className="h-full min-w-[280px]">
<SessionInspector
onOpenReviewerTerminal={({ handleId, harness }) =>
setTerminalTarget({ kind: "reviewer", handleId, harness })
}
session={session}
/>
</div>
</ResizablePanel>
</>
) : null}
</ResizablePanelGroup>
</div>
);
}