This repository has been archived on 2026-10-08. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
maestro/web/src/components/layout/MultiView.tsx
T
Miguel PalhasandClaude Opus 4.6 563a35d2ec
ci / web (push) Failing after 6s
ci / rust (push) Failing after 48s
ci / flake (push) Has been cancelled
fix(maestro): restore sidebar fill, pane title, and focus dimming after React port
The React port replaced CSS class names with Tailwind utilities, breaking
three visual contracts:

- SessionCard used inline bg-* classes instead of .is-active/.is-open/
  .is-previewing that the styles.css cascade targets.
- PaneTitle used className "at-title" instead of "attach-title", so font,
  overflow, and child layout rules never applied.
- MultiView's Pane never applied .is-focused or .is-preview, so every
  pane matched :not(.is-focused) and was dimmed to 0.45 opacity.

Closes #256

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-09-10 21:51:53 +01:00

270 lines
9.8 KiB
TypeScript

// React port of multiview.ts's tab + split tree (issue #249) plus its preview
// mode (issue #251, docs/PREVIEW.md). Sidebar drag-to-dock and the mobile
// single-pane collapse stay with whichever issue wires this into the shell
// (#253) — this renders the tree itself: tabs, nested splits, pane chrome.
import type { ReactNode } from "react";
import { forwardRef, useEffect, useImperativeHandle, useRef } from "react";
import { useStore } from "zustand";
import { activeTab, type Dir, type Node } from "../../layout.js";
import { usePreview } from "../../hooks/usePreview.js";
import type { LayoutStore, LayoutStoreApi } from "../../stores/layoutStore.js";
import type { Session, Uuid } from "../../types.js";
import { PaneContainer, type PaneContainerHandle } from "./PaneContainer.js";
import { PaneTitle } from "./PaneTitle.js";
import { TabBar } from "./TabBar.js";
/** Reaching the focused pane from outside the tree — what the shell's
* leader chords need (focus.toggle, the literal-byte escape hatches). */
export interface MultiViewHandle {
/** Hand the keyboard to the focused pane's terminal (or composer). */
focusPane(): void;
/** Type into the focused pane. A structured pane has no PTY and ignores it. */
sendInput(data: string): void;
}
export interface MultiViewProps {
store: LayoutStoreApi;
/** Live sessions, keyed by id — drives pane titles and (once known) boot
* reconciliation. Passing `undefined` defers reconciliation. */
sessions?: ReadonlyMap<Uuid, Session>;
onDisconnect?: (sessionId: Uuid) => void;
emptyState?: ReactNode;
}
export const MultiView = forwardRef<MultiViewHandle, MultiViewProps>(function MultiView(
{ store, sessions, onDisconnect, emptyState },
ref,
) {
const layout = useStore(store, (s: LayoutStore) => s.layout);
const tab = activeTab(layout);
// Live pane handles, keyed by leaf. Read at call time (not render time) so
// the chords always reach whatever is focused right now.
const panes = useRef(new Map<string, PaneContainerHandle>()).current;
const focusedLeafRef = useRef<string | null>(null);
focusedLeafRef.current = tab.focusedLeafId;
useImperativeHandle(
ref,
() => ({
focusPane: () => panes.get(focusedLeafRef.current ?? "")?.focusTerminal(),
sendInput: (data: string) => panes.get(focusedLeafRef.current ?? "")?.sendInput(data),
}),
[panes],
);
const { previewedSessionId, commit: commitPreview } = usePreview({ store });
const isPreviewing = previewedSessionId !== null;
const reconciledRef = useRef(false);
useEffect(() => {
if (reconciledRef.current || !sessions) return;
reconciledRef.current = true;
store.getState().reconcile(new Set(sessions.keys()));
}, [sessions, store]);
return (
<div className="mv flex h-full min-h-0 flex-col">
<TabBar store={store} />
<div className="mv-canvas relative min-h-0 flex-1">
{tab.root ? (
<SplitNode
node={tab.root}
tab={tab}
panes={panes}
store={store}
sessions={sessions}
onDisconnect={onDisconnect}
previewedSessionId={previewedSessionId}
commitPreview={commitPreview}
/>
) : (
(emptyState ?? (
<div className="mv-empty flex h-full items-center justify-center p-sp-4 text-fs-13 text-faint">
No session open — pick one from the sidebar, or spawn a new one.
</div>
))
)}
{isPreviewing && (
<>
<div className="mv-preview-ring pointer-events-none absolute inset-0 rounded-r-2 border border-dashed border-preview-ring" />
<div className="mv-preview-chip pointer-events-none absolute right-sp-2 top-sp-2 z-sticky rounded-r-pill bg-preview px-sp-2 py-[2px] text-fs-11 text-preview-ink">
preview
</div>
</>
)}
</div>
</div>
);
});
interface TreeProps {
tab: { id: string; focusedLeafId: string | null };
/** Pane handles by leaf id — each `Pane` registers and unregisters its own. */
panes: Map<string, PaneContainerHandle>;
store: LayoutStoreApi;
sessions?: ReadonlyMap<Uuid, Session>;
onDisconnect?: (sessionId: Uuid) => void;
previewedSessionId: Uuid | null;
commitPreview: (sessionId: Uuid) => void;
}
function SplitNode({ node, ...rest }: TreeProps & { node: Node }) {
if (node.kind === "leaf") {
return <Pane leaf={node} isFocused={rest.tab.focusedLeafId === node.id} {...rest} />;
}
return <SplitContainer node={node} {...rest} />;
}
function SplitContainer({ node, ...rest }: TreeProps & { node: Extract<Node, { kind: "split" }> }) {
const slotA = useRef<HTMLDivElement | null>(null);
const slotB = useRef<HTMLDivElement | null>(null);
const containerRef = useRef<HTMLDivElement | null>(null);
const onDividerPointerDown = (e: React.PointerEvent<HTMLDivElement>) => {
e.preventDefault();
const divider = e.currentTarget;
const container = containerRef.current;
if (!container) return;
divider.setPointerCapture(e.pointerId);
const rect = container.getBoundingClientRect();
const move = (ev: PointerEvent) => {
const frac =
node.dir === "row" ? (ev.clientX - rect.left) / rect.width : (ev.clientY - rect.top) / rect.height;
node.ratio = Math.max(0.1, Math.min(0.9, frac));
if (slotA.current) slotA.current.style.flexGrow = String(node.ratio);
if (slotB.current) slotB.current.style.flexGrow = String(1 - node.ratio);
};
const up = (ev: PointerEvent) => {
try {
divider.releasePointerCapture(ev.pointerId);
} catch {
// already released
}
divider.removeEventListener("pointermove", move);
divider.removeEventListener("pointerup", up);
rest.store.getState().commitLayout();
};
divider.addEventListener("pointermove", move);
divider.addEventListener("pointerup", up);
};
return (
<div
ref={containerRef}
className={`mv-split flex h-full min-h-0 w-full ${node.dir === "row" ? "flex-row" : "flex-col"}`}
>
<div ref={slotA} className="mv-slot min-h-0 min-w-0" style={{ flex: `${node.ratio} 1 0` }}>
<SplitNode node={node.a} {...rest} />
</div>
<div
className={
"mv-divider flex-none bg-border-strong hover:bg-accent " +
(node.dir === "row" ? "w-[3px] cursor-col-resize" : "h-[3px] cursor-row-resize")
}
onPointerDown={onDividerPointerDown}
/>
<div ref={slotB} className="mv-slot min-h-0 min-w-0" style={{ flex: `${1 - node.ratio} 1 0` }}>
<SplitNode node={node.b} {...rest} />
</div>
</div>
);
}
function Pane({
leaf,
isFocused,
panes,
store,
sessions,
onDisconnect,
previewedSessionId,
commitPreview,
}: TreeProps & { leaf: Extract<Node, { kind: "leaf" }>; isFocused: boolean }) {
const session = leaf.sessionId ? sessions?.get(leaf.sessionId) : undefined;
const isPreview = leaf.sessionId !== null && leaf.sessionId === previewedSessionId;
const splitFocused = (dir: Dir) => store.getState().splitFocused(leaf.id, dir);
return (
<div
className={
"mv-pane flex h-full min-h-0 min-w-0 flex-col" +
(isFocused ? " is-focused" : "") +
(isPreview ? " is-preview" : "")
}
data-leaf-id={leaf.id}
data-session-id={leaf.sessionId ?? undefined}
onPointerDownCapture={() => {
// Clicking into a previewed pane is the operator choosing it
// (docs/PREVIEW.md).
if (isPreview) commitPreview(leaf.sessionId!);
store.getState().focusLeaf(leaf.id);
}}
>
<div
className="mv-pane-header attach-header flex items-center justify-between gap-sp-2 border-b border-border-strong px-sp-2 py-sp-1 text-fs-12"
>
{leaf.sessionId ? (
<PaneTitle sessionId={leaf.sessionId} session={session} />
) : (
<span className="text-faint">Empty pane</span>
)}
<div className="mv-pane-tools flex items-center gap-sp-1">
{leaf.sessionId && onDisconnect && (
<button
type="button"
className="mv-tool rounded-r-1 px-sp-1 text-faint hover:bg-surface-3 hover:text-ink"
title="Disconnect"
onClick={() => onDisconnect(leaf.sessionId!)}
>
⏻
</button>
)}
<button
type="button"
className="mv-tool rounded-r-1 px-sp-1 text-faint hover:bg-surface-3 hover:text-ink"
title="Split right"
onClick={() => splitFocused("row")}
>
▥
</button>
<button
type="button"
className="mv-tool rounded-r-1 px-sp-1 text-faint hover:bg-surface-3 hover:text-ink"
title="Split down"
onClick={() => splitFocused("col")}
>
▤
</button>
<button
type="button"
className="mv-tool rounded-r-1 px-sp-1 text-faint hover:bg-surface-3 hover:text-ink"
title="Close pane"
onClick={() => store.getState().closePane(leaf.id)}
>
✕
</button>
</div>
</div>
<div className="mv-pane-host min-h-0 flex-1">
{leaf.sessionId ? (
<PaneContainer
ref={(handle) => {
if (handle) panes.set(leaf.id, handle);
else panes.delete(leaf.id);
}}
sessionId={leaf.sessionId}
session={session}
focus={isFocused && previewedSessionId === null}
onClosed={() => store.getState().closeSession(leaf.sessionId!)}
/>
) : (
<div className="mv-pane-empty flex h-full items-center justify-center p-sp-4 text-fs-13 text-faint">
Empty pane — pick a session from the sidebar, or spawn one.
</div>
)}
</div>
</div>
);
}