feat(app): GUI backlog — splitter, drag-reorder, fit, persist, modal focus
- LayoutEngine: fix splitter resize (track pointer 1:1 via delta-from-start) and add panel drag-to-reorder using raw pointer events with drop indicators - TerminalView: auto-fit xterm to container via FitAddon + ResizeObserver - App/TopBar: toggleable sidebar; persist sidebar/events collapse in localStorage; bell icon opens the activity log - Wizard: new-workspace modal now grabs focus and handles keyboard - deps: add @xterm/addon-fit Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
+177
-101
@@ -1,10 +1,10 @@
|
||||
import { useRef } from "react";
|
||||
import { Maximize2, Minimize2, RotateCw } from "lucide-react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { Maximize2, Minimize2, RotateCw, GripVertical } from "lucide-react";
|
||||
import { TerminalView } from "./TerminalView";
|
||||
import { StatusRing } from "./StatusRing";
|
||||
import { COLORS, FONT, STATE_COLOR } from "./theme";
|
||||
import type { LayoutNode, SurfaceState, SurfaceView } from "./layoutTypes";
|
||||
import { setRatios, restartSurface, setZoom } from "./socketBridge";
|
||||
import { setRatios, restartSurface, setZoom, moveSurface } from "./socketBridge";
|
||||
|
||||
interface Props {
|
||||
workspaceId: string;
|
||||
@@ -18,6 +18,16 @@ interface Props {
|
||||
zoomed: string | null;
|
||||
}
|
||||
|
||||
type Edge = "left" | "right" | "top" | "bottom";
|
||||
interface DropTarget { id: string; edge: Edge }
|
||||
|
||||
function edgeAt(clientX: number, clientY: number, r: DOMRect): Edge {
|
||||
const px = (clientX - r.left) / r.width;
|
||||
const py = (clientY - r.top) / r.height;
|
||||
const d: Record<Edge, number> = { left: px, right: 1 - px, top: py, bottom: 1 - py };
|
||||
return (Object.keys(d) as Edge[]).reduce((a, b) => (d[b] < d[a] ? b : a), "left");
|
||||
}
|
||||
|
||||
/** Collapse an absolute cwd into a ~/<leaf> style label for the panel header. */
|
||||
function shortPath(cwd: string): string {
|
||||
const leaf = cwd.split("/").filter(Boolean).pop();
|
||||
@@ -25,145 +35,211 @@ function shortPath(cwd: string): string {
|
||||
}
|
||||
|
||||
export function LayoutEngine({ workspaceId, layout, running, states, surfaces, focusedId, onFocus, zoomed }: Props) {
|
||||
// Panel drag-to-reorder. Implemented with raw pointer events rather than the
|
||||
// HTML5 drag API, which is unreliable in the macOS WKWebView Tauri uses.
|
||||
const [drop, setDrop] = useState<DropTarget | null>(null);
|
||||
const dropRef = useRef<DropTarget | null>(null);
|
||||
dropRef.current = drop;
|
||||
|
||||
const startPanelDrag = (srcId: string, e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
const startX = e.clientX, startY = e.clientY;
|
||||
let active = false;
|
||||
const prevUserSelect = document.body.style.userSelect;
|
||||
const move = (ev: MouseEvent) => {
|
||||
if (!active) {
|
||||
if (Math.abs(ev.clientX - startX) + Math.abs(ev.clientY - startY) < 5) return;
|
||||
active = true;
|
||||
document.body.style.userSelect = "none";
|
||||
}
|
||||
const el = (document.elementFromPoint(ev.clientX, ev.clientY) as HTMLElement | null)?.closest("[data-surface-id]") as HTMLElement | null;
|
||||
const tid = el?.getAttribute("data-surface-id");
|
||||
if (!el || !tid || tid === srcId) { setDrop(null); return; }
|
||||
setDrop({ id: tid, edge: edgeAt(ev.clientX, ev.clientY, el.getBoundingClientRect()) });
|
||||
};
|
||||
const up = () => {
|
||||
window.removeEventListener("mousemove", move);
|
||||
window.removeEventListener("mouseup", up);
|
||||
document.body.style.userSelect = prevUserSelect;
|
||||
const d = dropRef.current;
|
||||
setDrop(null);
|
||||
if (active && d && d.id !== srcId) void moveSurface(srcId, d.id, d.edge);
|
||||
};
|
||||
window.addEventListener("mousemove", move);
|
||||
window.addEventListener("mouseup", up);
|
||||
};
|
||||
|
||||
if (!layout) {
|
||||
return <div style={{ color: COLORS.textMuted, padding: 24 }}>Empty workspace — apply a preset to add panels.</div>;
|
||||
}
|
||||
const shared = { workspaceId, running, states, surfaces, focusedId, onFocus, zoomed, drop, onStartPanelDrag: startPanelDrag };
|
||||
if (zoomed) {
|
||||
return (
|
||||
<div style={{ width: "100%", height: "100%", padding: 12, boxSizing: "border-box" }}>
|
||||
<Node workspaceId={workspaceId} node={{ leaf: { surface_id: zoomed } }} path={[]} running={running} states={states} surfaces={surfaces} focusedId={focusedId} onFocus={onFocus} zoomed={zoomed} />
|
||||
<Node node={{ leaf: { surface_id: zoomed } }} path={[]} {...shared} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div style={{ width: "100%", height: "100%", padding: 12, boxSizing: "border-box" }}>
|
||||
<Node workspaceId={workspaceId} node={layout} path={[]} running={running} states={states} surfaces={surfaces} focusedId={focusedId} onFocus={onFocus} zoomed={zoomed} />
|
||||
<Node node={layout} path={[]} {...shared} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Node({ workspaceId, node, path, running, states, surfaces, focusedId, onFocus, zoomed }: {
|
||||
interface NodeProps {
|
||||
workspaceId: string; node: LayoutNode; path: number[];
|
||||
running: Record<string, boolean>; states: Record<string, SurfaceState>;
|
||||
surfaces: Record<string, SurfaceView>; focusedId: string | null; onFocus: (id: string) => void;
|
||||
zoomed: string | null;
|
||||
}) {
|
||||
drop: DropTarget | null;
|
||||
onStartPanelDrag: (srcId: string, e: React.MouseEvent) => void;
|
||||
}
|
||||
|
||||
function Node({ node, path, ...rest }: NodeProps) {
|
||||
if ("leaf" in node) {
|
||||
const id = node.leaf.surface_id;
|
||||
const focused = focusedId === id;
|
||||
const card = (inner: React.ReactNode) => (
|
||||
<div
|
||||
onMouseDown={() => onFocus(id)}
|
||||
style={{
|
||||
display: "flex", flexDirection: "column", width: "100%", height: "100%",
|
||||
background: COLORS.bgPanel, borderRadius: 8, overflow: "hidden",
|
||||
border: focused ? `2px solid ${COLORS.accent}` : `1px solid ${COLORS.borderSubtle}`,
|
||||
boxSizing: "border-box",
|
||||
}}
|
||||
>
|
||||
{inner}
|
||||
</div>
|
||||
);
|
||||
return <Leaf id={node.leaf.surface_id} {...rest} />;
|
||||
}
|
||||
return <SplitView split={node.split} path={path} {...rest} />;
|
||||
}
|
||||
|
||||
if (running[id] === false) {
|
||||
return card(
|
||||
<div style={{ display: "flex", alignItems: "center", justifyContent: "center", height: "100%", width: "100%", color: COLORS.textSecondary, flexDirection: "column", gap: 10 }}>
|
||||
<div style={{ fontFamily: FONT.mono, fontSize: 13 }}>Process exited</div>
|
||||
<div style={{ display: "flex", gap: 8 }}>
|
||||
<button onClick={() => void restartSurface(id)}
|
||||
style={{ display: "flex", alignItems: "center", gap: 6, padding: "6px 14px", background: COLORS.bgElevated, color: COLORS.textPrimary, border: `1px solid ${COLORS.borderStrong}`, borderRadius: 7, fontSize: 12 }}>
|
||||
<RotateCw size={13} /> Restart
|
||||
</button>
|
||||
{zoomed === id && (
|
||||
<button onClick={() => void setZoom(workspaceId, null)}
|
||||
style={{ display: "flex", alignItems: "center", gap: 6, padding: "6px 14px", background: "transparent", color: COLORS.textSecondary, border: `1px solid ${COLORS.borderStrong}`, borderRadius: 7, fontSize: 12 }}>
|
||||
<Minimize2 size={13} /> Exit zoom
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
function Leaf({ id, workspaceId, running, states, surfaces, focusedId, onFocus, zoomed, drop, onStartPanelDrag }: Omit<NodeProps, "node" | "path"> & { id: string }) {
|
||||
const focused = focusedId === id;
|
||||
const dropEdge = drop && drop.id === id ? drop.edge : null;
|
||||
|
||||
const spec = surfaces[id]?.spec;
|
||||
const agent = spec?.agent_label ?? "shell";
|
||||
const state = states[id] ?? "idle";
|
||||
const card = (inner: React.ReactNode) => (
|
||||
<div
|
||||
data-surface-id={id}
|
||||
onMouseDown={() => onFocus(id)}
|
||||
style={{
|
||||
position: "relative", display: "flex", flexDirection: "column", width: "100%", height: "100%",
|
||||
background: COLORS.bgPanel, borderRadius: 8, overflow: "hidden",
|
||||
border: focused ? `2px solid ${COLORS.accent}` : `1px solid ${COLORS.borderSubtle}`,
|
||||
boxSizing: "border-box",
|
||||
}}
|
||||
>
|
||||
{inner}
|
||||
{dropEdge && <DropIndicator edge={dropEdge} />}
|
||||
</div>
|
||||
);
|
||||
|
||||
if (running[id] === false) {
|
||||
return card(
|
||||
<>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 8, height: 30, flex: "0 0 30px", padding: "0 10px", background: COLORS.bgElevated, borderBottom: `1px solid ${COLORS.borderSubtle}` }}>
|
||||
<StatusRing state={state} running={true} />
|
||||
<span style={{ fontFamily: FONT.mono, fontSize: 12, fontWeight: 600, color: COLORS.textPrimary }}>{agent}</span>
|
||||
{spec?.cwd && <span style={{ fontFamily: FONT.mono, fontSize: 11, color: COLORS.textMuted, whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}>{shortPath(spec.cwd)}</span>}
|
||||
<span style={{ flex: 1 }} />
|
||||
<span style={{ display: "flex", alignItems: "center", height: 16, padding: "0 7px", borderRadius: 8, background: "#000", fontFamily: FONT.mono, fontSize: 10, fontWeight: 600, color: STATE_COLOR[state] }}>
|
||||
{state}
|
||||
</span>
|
||||
{zoomed === id
|
||||
? <Minimize2 size={13} color={COLORS.textSecondary} style={{ cursor: "pointer" }} aria-label="Unzoom"
|
||||
onMouseDown={(e) => { e.stopPropagation(); void setZoom(workspaceId, null); }} />
|
||||
: <Maximize2 size={13} color={COLORS.textMuted} style={{ cursor: "pointer" }} aria-label="Zoom"
|
||||
onMouseDown={(e) => { e.stopPropagation(); onFocus(id); void setZoom(workspaceId, id); }} />}
|
||||
<div style={{ display: "flex", alignItems: "center", justifyContent: "center", height: "100%", width: "100%", color: COLORS.textSecondary, flexDirection: "column", gap: 10 }}>
|
||||
<div style={{ fontFamily: FONT.mono, fontSize: 13 }}>Process exited</div>
|
||||
<div style={{ display: "flex", gap: 8 }}>
|
||||
<button onClick={() => void restartSurface(id)}
|
||||
style={{ display: "flex", alignItems: "center", gap: 6, padding: "6px 14px", background: COLORS.bgElevated, color: COLORS.textPrimary, border: `1px solid ${COLORS.borderStrong}`, borderRadius: 7, fontSize: 12 }}>
|
||||
<RotateCw size={13} /> Restart
|
||||
</button>
|
||||
{zoomed === id && (
|
||||
<button onClick={() => void setZoom(workspaceId, null)}
|
||||
style={{ display: "flex", alignItems: "center", gap: 6, padding: "6px 14px", background: "transparent", color: COLORS.textSecondary, border: `1px solid ${COLORS.borderStrong}`, borderRadius: 7, fontSize: 12 }}>
|
||||
<Minimize2 size={13} /> Exit zoom
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div style={{ flex: 1, minHeight: 0 }}>
|
||||
<TerminalView key={id} surfaceId={id} />
|
||||
</div>
|
||||
</>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const { orient, ratios, children } = node.split;
|
||||
const dir = orient === "h" ? "row" : "column";
|
||||
return (
|
||||
<div style={{ display: "flex", flexDirection: dir, width: "100%", height: "100%" }}>
|
||||
{children.map((child, i) => (
|
||||
<Pane key={i} grow={ratios[i] ?? 1} isLast={i === children.length - 1} orient={orient}
|
||||
onResize={(deltaFrac) => {
|
||||
const next = [...ratios];
|
||||
next[i] = Math.max(0.05, next[i] + deltaFrac);
|
||||
next[i + 1] = Math.max(0.05, (next[i + 1] ?? 1) - deltaFrac);
|
||||
void setRatios(workspaceId, path, next);
|
||||
}}>
|
||||
<Node workspaceId={workspaceId} node={child} path={[...path, i]} running={running} states={states} surfaces={surfaces} focusedId={focusedId} onFocus={onFocus} zoomed={zoomed} />
|
||||
</Pane>
|
||||
))}
|
||||
</div>
|
||||
const spec = surfaces[id]?.spec;
|
||||
const agent = spec?.agent_label ?? "shell";
|
||||
const state = states[id] ?? "idle";
|
||||
return card(
|
||||
<>
|
||||
<div
|
||||
onMouseDown={(e) => { onFocus(id); onStartPanelDrag(id, e); }}
|
||||
title="Drag to move this panel"
|
||||
style={{ display: "flex", alignItems: "center", gap: 8, height: 30, flex: "0 0 30px", padding: "0 10px", background: COLORS.bgElevated, borderBottom: `1px solid ${COLORS.borderSubtle}`, cursor: "grab" }}
|
||||
>
|
||||
<GripVertical size={13} color={COLORS.textMuted} />
|
||||
<StatusRing state={state} running={true} />
|
||||
<span style={{ fontFamily: FONT.mono, fontSize: 12, fontWeight: 600, color: COLORS.textPrimary }}>{agent}</span>
|
||||
{spec?.cwd && <span style={{ fontFamily: FONT.mono, fontSize: 11, color: COLORS.textMuted, whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}>{shortPath(spec.cwd)}</span>}
|
||||
<span style={{ flex: 1 }} />
|
||||
<span style={{ display: "flex", alignItems: "center", height: 16, padding: "0 7px", borderRadius: 8, background: "#000", fontFamily: FONT.mono, fontSize: 10, fontWeight: 600, color: STATE_COLOR[state] }}>
|
||||
{state}
|
||||
</span>
|
||||
{zoomed === id
|
||||
? <Minimize2 size={13} color={COLORS.textSecondary} style={{ cursor: "pointer" }} aria-label="Unzoom"
|
||||
onMouseDown={(e) => { e.stopPropagation(); void setZoom(workspaceId, null); }} />
|
||||
: <Maximize2 size={13} color={COLORS.textMuted} style={{ cursor: "pointer" }} aria-label="Zoom"
|
||||
onMouseDown={(e) => { e.stopPropagation(); onFocus(id); void setZoom(workspaceId, id); }} />}
|
||||
</div>
|
||||
<div style={{ flex: 1, minHeight: 0 }}>
|
||||
<TerminalView key={id} surfaceId={id} />
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function Pane({ grow, isLast, orient, onResize, children }: { grow: number; isLast: boolean; orient: "h" | "v"; onResize: (deltaFrac: number) => void; children: React.ReactNode }) {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const startDrag = (e: React.MouseEvent) => {
|
||||
function DropIndicator({ edge }: { edge: Edge }) {
|
||||
const base: React.CSSProperties = { position: "absolute", background: `${COLORS.accent}55`, border: `2px solid ${COLORS.accent}`, pointerEvents: "none", boxSizing: "border-box", zIndex: 5 };
|
||||
const map: Record<Edge, React.CSSProperties> = {
|
||||
left: { ...base, top: 0, bottom: 0, left: 0, width: "50%" },
|
||||
right: { ...base, top: 0, bottom: 0, right: 0, width: "50%" },
|
||||
top: { ...base, left: 0, right: 0, top: 0, height: "50%" },
|
||||
bottom: { ...base, left: 0, right: 0, bottom: 0, height: "50%" },
|
||||
};
|
||||
return <div style={map[edge]} />;
|
||||
}
|
||||
|
||||
function SplitView({ split, path, ...rest }: Omit<NodeProps, "node"> & { split: Extract<LayoutNode, { split: unknown }>["split"] }) {
|
||||
const { orient, ratios, children } = split;
|
||||
const { workspaceId } = rest;
|
||||
const dir = orient === "h" ? "row" : "column";
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const [live, setLive] = useState<number[] | null>(null);
|
||||
// Drop any local override once the authoritative ratios arrive from the daemon.
|
||||
useEffect(() => { setLive(null); }, [ratios.join(",")]);
|
||||
const effective = live ?? ratios;
|
||||
|
||||
const startDrag = (i: number, e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
const parent = ref.current?.parentElement;
|
||||
if (!parent) return;
|
||||
const total = orient === "h" ? parent.clientWidth : parent.clientHeight;
|
||||
let last = orient === "h" ? e.clientX : e.clientY;
|
||||
e.stopPropagation();
|
||||
const container = containerRef.current;
|
||||
if (!container) return;
|
||||
const total = orient === "h" ? container.clientWidth : container.clientHeight;
|
||||
if (total <= 0) return;
|
||||
const start = orient === "h" ? e.clientX : e.clientY;
|
||||
const base = [...ratios];
|
||||
const sum = base.reduce((a, b) => a + b, 0) || 1;
|
||||
const move = (ev: MouseEvent) => {
|
||||
const cur = orient === "h" ? ev.clientX : ev.clientY;
|
||||
const delta = (cur - last) / total;
|
||||
last = cur;
|
||||
onResize(delta);
|
||||
// Accumulated delta from drag start — not an incremental step — so the
|
||||
// panel tracks the pointer 1:1 instead of crawling one echo at a time.
|
||||
const deltaFrac = ((cur - start) / total) * sum;
|
||||
const next = [...base];
|
||||
next[i] = Math.max(0.05, base[i] + deltaFrac);
|
||||
next[i + 1] = Math.max(0.05, (base[i + 1] ?? 1) - deltaFrac);
|
||||
setLive(next);
|
||||
};
|
||||
const up = () => {
|
||||
window.removeEventListener("mousemove", move);
|
||||
window.removeEventListener("mouseup", up);
|
||||
setLive((cur) => { if (cur) void setRatios(workspaceId, path, cur); return cur; });
|
||||
};
|
||||
window.addEventListener("mousemove", move);
|
||||
window.addEventListener("mouseup", up);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div ref={ref} style={{ flexGrow: grow, flexBasis: 0, minWidth: 0, minHeight: 0, overflow: "hidden", position: "relative" }}>
|
||||
{children}
|
||||
</div>
|
||||
{!isLast && (
|
||||
<div onMouseDown={startDrag}
|
||||
style={{
|
||||
flex: "0 0 10px",
|
||||
cursor: orient === "h" ? "col-resize" : "row-resize",
|
||||
background: "transparent",
|
||||
}} />
|
||||
)}
|
||||
</>
|
||||
<div ref={containerRef} style={{ display: "flex", flexDirection: dir, width: "100%", height: "100%" }}>
|
||||
{children.map((child, i) => (
|
||||
<div key={i} style={{ flexGrow: effective[i] ?? 1, flexBasis: 0, minWidth: 0, minHeight: 0, overflow: "visible", position: "relative", display: "flex" }}>
|
||||
<Node node={child} path={[...path, i]} {...rest} />
|
||||
{i < children.length - 1 && (
|
||||
<div onMouseDown={(e) => startDrag(i, e)}
|
||||
style={{
|
||||
position: "absolute", zIndex: 4,
|
||||
...(orient === "h"
|
||||
? { top: 0, bottom: 0, right: -5, width: 10, cursor: "col-resize" }
|
||||
: { left: 0, right: 0, bottom: -5, height: 10, cursor: "row-resize" }),
|
||||
}} />
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user