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:
2026-06-14 08:01:15 +07:00
parent 6a3875670a
commit 58c75c71ae
7 changed files with 287 additions and 126 deletions
+10
View File
@@ -12,6 +12,7 @@
"@fontsource/inter": "^5.2.8", "@fontsource/inter": "^5.2.8",
"@tauri-apps/api": "^2", "@tauri-apps/api": "^2",
"@tauri-apps/plugin-notification": "^2", "@tauri-apps/plugin-notification": "^2",
"@xterm/addon-fit": "^0.10.0",
"@xterm/addon-search": "^0.16.0", "@xterm/addon-search": "^0.16.0",
"@xterm/addon-webgl": "^0.18.0", "@xterm/addon-webgl": "^0.18.0",
"@xterm/xterm": "^5.5.0", "@xterm/xterm": "^5.5.0",
@@ -1463,6 +1464,15 @@
"vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0"
} }
}, },
"node_modules/@xterm/addon-fit": {
"version": "0.10.0",
"resolved": "https://registry.npmjs.org/@xterm/addon-fit/-/addon-fit-0.10.0.tgz",
"integrity": "sha512-UFYkDm4HUahf2lnEyHvio51TNGiLK66mqP2JoATy7hRZeXaGMRDr00JiSF7m63vR5WKATF605yEggJKsw0JpMQ==",
"license": "MIT",
"peerDependencies": {
"@xterm/xterm": "^5.0.0"
}
},
"node_modules/@xterm/addon-search": { "node_modules/@xterm/addon-search": {
"version": "0.16.0", "version": "0.16.0",
"resolved": "https://registry.npmjs.org/@xterm/addon-search/-/addon-search-0.16.0.tgz", "resolved": "https://registry.npmjs.org/@xterm/addon-search/-/addon-search-0.16.0.tgz",
+1
View File
@@ -13,6 +13,7 @@
"@fontsource/inter": "^5.2.8", "@fontsource/inter": "^5.2.8",
"@tauri-apps/api": "^2", "@tauri-apps/api": "^2",
"@tauri-apps/plugin-notification": "^2", "@tauri-apps/plugin-notification": "^2",
"@xterm/addon-fit": "^0.10.0",
"@xterm/addon-search": "^0.16.0", "@xterm/addon-search": "^0.16.0",
"@xterm/addon-webgl": "^0.18.0", "@xterm/addon-webgl": "^0.18.0",
"@xterm/xterm": "^5.5.0", "@xterm/xterm": "^5.5.0",
+16 -3
View File
@@ -13,6 +13,15 @@ import type { EventRecord, DaemonHealth } from "./socketBridge";
import { leafIds } from "./layoutTypes"; import { leafIds } from "./layoutTypes";
import type { Group, WorkspaceView, SurfaceState } from "./layoutTypes"; import type { Group, WorkspaceView, SurfaceState } from "./layoutTypes";
/** Read a boolean UI flag from localStorage, falling back to `def`. */
function loadFlag(key: string, def: boolean): boolean {
try { const v = localStorage.getItem(key); return v === null ? def : v === "1"; }
catch { return def; }
}
function saveFlag(key: string, value: boolean): void {
try { localStorage.setItem(key, value ? "1" : "0"); } catch { /* ignore */ }
}
export function App() { export function App() {
const [groups, setGroups] = useState<Group[]>([]); const [groups, setGroups] = useState<Group[]>([]);
const [workspaces, setWorkspaces] = useState<WorkspaceView[]>([]); const [workspaces, setWorkspaces] = useState<WorkspaceView[]>([]);
@@ -21,7 +30,8 @@ export function App() {
const [states, setStates] = useState<Record<string, SurfaceState>>({}); const [states, setStates] = useState<Record<string, SurfaceState>>({});
const [events, setEvents] = useState<EventRecord[]>([]); const [events, setEvents] = useState<EventRecord[]>([]);
const [wizard, setWizard] = useState(false); const [wizard, setWizard] = useState(false);
const [eventsOpen, setEventsOpen] = useState(true); const [eventsOpen, setEventsOpen] = useState(() => loadFlag("spacesh.eventsOpen", true));
const [sidebarOpen, setSidebarOpen] = useState(() => loadFlag("spacesh.sidebarOpen", true));
const [health, setHealth] = useState<DaemonHealth | null>(null); const [health, setHealth] = useState<DaemonHealth | null>(null);
const [connected, setConnected] = useState(false); const [connected, setConnected] = useState(false);
const [focusedId, setFocusedId] = useState<string | null>(null); const [focusedId, setFocusedId] = useState<string | null>(null);
@@ -104,6 +114,9 @@ export function App() {
return () => window.removeEventListener("keydown", onKey); return () => window.removeEventListener("keydown", onKey);
}, []); }, []);
useEffect(() => { saveFlag("spacesh.eventsOpen", eventsOpen); }, [eventsOpen]);
useEffect(() => { saveFlag("spacesh.sidebarOpen", sidebarOpen); }, [sidebarOpen]);
const unread = useMemo(() => events.filter((e) => !e.read).length, [events]); const unread = useMemo(() => events.filter((e) => !e.read).length, [events]);
const active = workspaces.find((w) => w.id === activeId) ?? null; const active = workspaces.find((w) => w.id === activeId) ?? null;
const leaves = active ? leafIds(active.layout) : []; const leaves = active ? leafIds(active.layout) : [];
@@ -117,9 +130,9 @@ export function App() {
return ( return (
<div style={{ display: "flex", flexDirection: "column", height: "100vh", background: COLORS.bgApp }}> <div style={{ display: "flex", flexDirection: "column", height: "100vh", background: COLORS.bgApp }}>
<TopBar active={active} eventsOpen={eventsOpen} onToggleEvents={() => setEventsOpen((v) => !v)} unread={unread} /> <TopBar active={active} eventsOpen={eventsOpen} onToggleEvents={() => setEventsOpen((v) => !v)} onShowEvents={() => setEventsOpen(true)} sidebarOpen={sidebarOpen} onToggleSidebar={() => setSidebarOpen((v) => !v)} unread={unread} />
<div style={{ flex: 1, display: "flex", minHeight: 0 }}> <div style={{ flex: 1, display: "flex", minHeight: 0 }}>
<Sidebar groups={groups} workspaces={workspaces} activeId={activeId} onSelect={selectWorkspace} onNew={() => setWizard(true)} health={health} connected={connected} /> {sidebarOpen && <Sidebar groups={groups} workspaces={workspaces} activeId={activeId} onSelect={selectWorkspace} onNew={() => setWizard(true)} health={health} connected={connected} />}
<div style={{ flex: 1, display: "flex", flexDirection: "column", minWidth: 0 }}> <div style={{ flex: 1, display: "flex", flexDirection: "column", minWidth: 0 }}>
{active && ( {active && (
<CenterToolbar selected="" onSelect={(p) => { if (active) void applyPreset(active.id, p, []); }} onOpenSearch={() => setSearchOpen(true)} /> <CenterToolbar selected="" onSelect={(p) => { if (active) void applyPreset(active.id, p, []); }} onOpenSearch={() => setSearchOpen(true)} />
+123 -47
View File
@@ -1,10 +1,10 @@
import { useRef } from "react"; import { useEffect, useRef, useState } from "react";
import { Maximize2, Minimize2, RotateCw } from "lucide-react"; import { Maximize2, Minimize2, RotateCw, GripVertical } from "lucide-react";
import { TerminalView } from "./TerminalView"; import { TerminalView } from "./TerminalView";
import { StatusRing } from "./StatusRing"; import { StatusRing } from "./StatusRing";
import { COLORS, FONT, STATE_COLOR } from "./theme"; import { COLORS, FONT, STATE_COLOR } from "./theme";
import type { LayoutNode, SurfaceState, SurfaceView } from "./layoutTypes"; import type { LayoutNode, SurfaceState, SurfaceView } from "./layoutTypes";
import { setRatios, restartSurface, setZoom } from "./socketBridge"; import { setRatios, restartSurface, setZoom, moveSurface } from "./socketBridge";
interface Props { interface Props {
workspaceId: string; workspaceId: string;
@@ -18,6 +18,16 @@ interface Props {
zoomed: string | null; 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. */ /** Collapse an absolute cwd into a ~/<leaf> style label for the panel header. */
function shortPath(cwd: string): string { function shortPath(cwd: string): string {
const leaf = cwd.split("/").filter(Boolean).pop(); const leaf = cwd.split("/").filter(Boolean).pop();
@@ -25,43 +35,91 @@ function shortPath(cwd: string): string {
} }
export function LayoutEngine({ workspaceId, layout, running, states, surfaces, focusedId, onFocus, zoomed }: Props) { 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) { if (!layout) {
return <div style={{ color: COLORS.textMuted, padding: 24 }}>Empty workspace apply a preset to add panels.</div>; 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) { if (zoomed) {
return ( return (
<div style={{ width: "100%", height: "100%", padding: 12, boxSizing: "border-box" }}> <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> </div>
); );
} }
return ( return (
<div style={{ width: "100%", height: "100%", padding: 12, boxSizing: "border-box" }}> <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> </div>
); );
} }
function Node({ workspaceId, node, path, running, states, surfaces, focusedId, onFocus, zoomed }: { interface NodeProps {
workspaceId: string; node: LayoutNode; path: number[]; workspaceId: string; node: LayoutNode; path: number[];
running: Record<string, boolean>; states: Record<string, SurfaceState>; running: Record<string, boolean>; states: Record<string, SurfaceState>;
surfaces: Record<string, SurfaceView>; focusedId: string | null; onFocus: (id: string) => void; surfaces: Record<string, SurfaceView>; focusedId: string | null; onFocus: (id: string) => void;
zoomed: string | null; zoomed: string | null;
}) { drop: DropTarget | null;
onStartPanelDrag: (srcId: string, e: React.MouseEvent) => void;
}
function Node({ node, path, ...rest }: NodeProps) {
if ("leaf" in node) { if ("leaf" in node) {
const id = node.leaf.surface_id; return <Leaf id={node.leaf.surface_id} {...rest} />;
}
return <SplitView split={node.split} path={path} {...rest} />;
}
function Leaf({ id, workspaceId, running, states, surfaces, focusedId, onFocus, zoomed, drop, onStartPanelDrag }: Omit<NodeProps, "node" | "path"> & { id: string }) {
const focused = focusedId === id; const focused = focusedId === id;
const dropEdge = drop && drop.id === id ? drop.edge : null;
const card = (inner: React.ReactNode) => ( const card = (inner: React.ReactNode) => (
<div <div
data-surface-id={id}
onMouseDown={() => onFocus(id)} onMouseDown={() => onFocus(id)}
style={{ style={{
display: "flex", flexDirection: "column", width: "100%", height: "100%", position: "relative", display: "flex", flexDirection: "column", width: "100%", height: "100%",
background: COLORS.bgPanel, borderRadius: 8, overflow: "hidden", background: COLORS.bgPanel, borderRadius: 8, overflow: "hidden",
border: focused ? `2px solid ${COLORS.accent}` : `1px solid ${COLORS.borderSubtle}`, border: focused ? `2px solid ${COLORS.accent}` : `1px solid ${COLORS.borderSubtle}`,
boxSizing: "border-box", boxSizing: "border-box",
}} }}
> >
{inner} {inner}
{dropEdge && <DropIndicator edge={dropEdge} />}
</div> </div>
); );
@@ -90,7 +148,12 @@ function Node({ workspaceId, node, path, running, states, surfaces, focusedId, o
const state = states[id] ?? "idle"; const state = states[id] ?? "idle";
return card( 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}` }}> <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} /> <StatusRing state={state} running={true} />
<span style={{ fontFamily: FONT.mono, fontSize: 12, fontWeight: 600, color: COLORS.textPrimary }}>{agent}</span> <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>} {spec?.cwd && <span style={{ fontFamily: FONT.mono, fontSize: 11, color: COLORS.textMuted, whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}>{shortPath(spec.cwd)}</span>}
@@ -111,59 +174,72 @@ function Node({ workspaceId, node, path, running, states, surfaces, focusedId, o
); );
} }
const { orient, ratios, children } = node.split; function DropIndicator({ edge }: { edge: Edge }) {
const dir = orient === "h" ? "row" : "column"; const base: React.CSSProperties = { position: "absolute", background: `${COLORS.accent}55`, border: `2px solid ${COLORS.accent}`, pointerEvents: "none", boxSizing: "border-box", zIndex: 5 };
return ( const map: Record<Edge, React.CSSProperties> = {
<div style={{ display: "flex", flexDirection: dir, width: "100%", height: "100%" }}> left: { ...base, top: 0, bottom: 0, left: 0, width: "50%" },
{children.map((child, i) => ( right: { ...base, top: 0, bottom: 0, right: 0, width: "50%" },
<Pane key={i} grow={ratios[i] ?? 1} isLast={i === children.length - 1} orient={orient} top: { ...base, left: 0, right: 0, top: 0, height: "50%" },
onResize={(deltaFrac) => { bottom: { ...base, left: 0, right: 0, bottom: 0, height: "50%" },
const next = [...ratios]; };
next[i] = Math.max(0.05, next[i] + deltaFrac); return <div style={map[edge]} />;
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>
);
} }
function Pane({ grow, isLast, orient, onResize, children }: { grow: number; isLast: boolean; orient: "h" | "v"; onResize: (deltaFrac: number) => void; children: React.ReactNode }) { function SplitView({ split, path, ...rest }: Omit<NodeProps, "node"> & { split: Extract<LayoutNode, { split: unknown }>["split"] }) {
const ref = useRef<HTMLDivElement>(null); const { orient, ratios, children } = split;
const startDrag = (e: React.MouseEvent) => { 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(); e.preventDefault();
const parent = ref.current?.parentElement; e.stopPropagation();
if (!parent) return; const container = containerRef.current;
const total = orient === "h" ? parent.clientWidth : parent.clientHeight; if (!container) return;
let last = orient === "h" ? e.clientX : e.clientY; 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 move = (ev: MouseEvent) => {
const cur = orient === "h" ? ev.clientX : ev.clientY; const cur = orient === "h" ? ev.clientX : ev.clientY;
const delta = (cur - last) / total; // Accumulated delta from drag start — not an incremental step — so the
last = cur; // panel tracks the pointer 1:1 instead of crawling one echo at a time.
onResize(delta); 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 = () => { const up = () => {
window.removeEventListener("mousemove", move); window.removeEventListener("mousemove", move);
window.removeEventListener("mouseup", up); window.removeEventListener("mouseup", up);
setLive((cur) => { if (cur) void setRatios(workspaceId, path, cur); return cur; });
}; };
window.addEventListener("mousemove", move); window.addEventListener("mousemove", move);
window.addEventListener("mouseup", up); window.addEventListener("mouseup", up);
}; };
return ( return (
<> <div ref={containerRef} style={{ display: "flex", flexDirection: dir, width: "100%", height: "100%" }}>
<div ref={ref} style={{ flexGrow: grow, flexBasis: 0, minWidth: 0, minHeight: 0, overflow: "hidden", position: "relative" }}> {children.map((child, i) => (
{children} <div key={i} style={{ flexGrow: effective[i] ?? 1, flexBasis: 0, minWidth: 0, minHeight: 0, overflow: "visible", position: "relative", display: "flex" }}>
</div> <Node node={child} path={[...path, i]} {...rest} />
{!isLast && ( {i < children.length - 1 && (
<div onMouseDown={startDrag} <div onMouseDown={(e) => startDrag(i, e)}
style={{ style={{
flex: "0 0 10px", position: "absolute", zIndex: 4,
cursor: orient === "h" ? "col-resize" : "row-resize", ...(orient === "h"
background: "transparent", ? { top: 0, bottom: 0, right: -5, width: 10, cursor: "col-resize" }
: { left: 0, right: 0, bottom: -5, height: 10, cursor: "row-resize" }),
}} /> }} />
)} )}
</> </div>
))}
</div>
); );
} }
+27 -4
View File
@@ -2,6 +2,7 @@ import { useEffect, useRef } from "react";
import { Terminal } from "@xterm/xterm"; import { Terminal } from "@xterm/xterm";
import { WebglAddon } from "@xterm/addon-webgl"; import { WebglAddon } from "@xterm/addon-webgl";
import { SearchAddon } from "@xterm/addon-search"; import { SearchAddon } from "@xterm/addon-search";
import { FitAddon } from "@xterm/addon-fit";
import { attachSurface, detachSurface, sendInput, resizeSurface } from "./socketBridge"; import { attachSurface, detachSurface, sendInput, resizeSurface } from "./socketBridge";
import { registerSearch, unregisterSearch } from "./searchRegistry"; import { registerSearch, unregisterSearch } from "./searchRegistry";
@@ -25,6 +26,27 @@ export function TerminalView({ surfaceId }: { surfaceId: string }) {
term.loadAddon(search); term.loadAddon(search);
registerSearch(surfaceId, search); registerSearch(surfaceId, search);
const fit = new FitAddon();
term.loadAddon(fit);
// Fit the grid to the container and tell the daemon the new size. Coalesced
// through rAF so a burst of resize callbacks yields one resize per frame.
let rafId = 0;
let lastCols = 0, lastRows = 0;
const doFit = () => {
rafId = 0;
try { fit.fit(); } catch { return; }
if (term.cols !== lastCols || term.rows !== lastRows) {
lastCols = term.cols;
lastRows = term.rows;
void resizeSurface(surfaceId, term.cols, term.rows);
}
};
const scheduleFit = () => { if (!rafId) rafId = requestAnimationFrame(doFit); };
const ro = new ResizeObserver(scheduleFit);
ro.observe(ref.current);
// Input → daemon. // Input → daemon.
const inputDisposable = term.onData((data) => { const inputDisposable = term.onData((data) => {
void sendInput(surfaceId, encoder.encode(data)); void sendInput(surfaceId, encoder.encode(data));
@@ -38,14 +60,15 @@ export function TerminalView({ surfaceId }: { surfaceId: string }) {
}).then((res) => { }).then((res) => {
if (disposed) return; if (disposed) return;
if (res.snapshot) term.write(res.snapshot); if (res.snapshot) term.write(res.snapshot);
if (res.cols && res.rows) { // Fit to the actual container rather than the daemon's stored geometry,
term.resize(res.cols, res.rows); // then push the resulting size back so the PTY reflows to match.
void resizeSurface(surfaceId, res.cols, res.rows); scheduleFit();
}
}); });
return () => { return () => {
disposed = true; disposed = true;
if (rafId) cancelAnimationFrame(rafId);
ro.disconnect();
inputDisposable.dispose(); inputDisposable.dispose();
void detachSurface(surfaceId); void detachSurface(surfaceId);
unregisterSearch(surfaceId); unregisterSearch(surfaceId);
+9 -6
View File
@@ -1,4 +1,4 @@
import { FolderGit2, PanelRight, Search, Bell, Settings, ChevronDown } from "lucide-react"; import { FolderGit2, PanelLeft, PanelRight, Search, Bell, Settings, ChevronDown } from "lucide-react";
import { COLORS, FONT } from "./theme"; import { COLORS, FONT } from "./theme";
import type { WorkspaceView } from "./layoutTypes"; import type { WorkspaceView } from "./layoutTypes";
import { leafIds } from "./layoutTypes"; import { leafIds } from "./layoutTypes";
@@ -29,11 +29,14 @@ function IconBtn({ icon, onClick, active, title }: { icon: React.ReactNode; onCl
} }
export function TopBar({ export function TopBar({
active, eventsOpen, onToggleEvents, unread, active, eventsOpen, onToggleEvents, onShowEvents, sidebarOpen, onToggleSidebar, unread,
}: { }: {
active: WorkspaceView | null; active: WorkspaceView | null;
eventsOpen: boolean; eventsOpen: boolean;
onToggleEvents: () => void; onToggleEvents: () => void;
onShowEvents: () => void;
sidebarOpen: boolean;
onToggleSidebar: () => void;
unread: number; unread: number;
}) { }) {
return ( return (
@@ -44,8 +47,8 @@ export function TopBar({
borderBottom: `1px solid ${COLORS.borderSubtle}`, borderBottom: `1px solid ${COLORS.borderSubtle}`,
}} }}
> >
{/* macOS traffic-light spacer — real lights are drawn by the window chrome. */} {/* Left: sidebar toggle, flush to the left edge. */}
<div style={{ width: 60, flex: "0 0 60px" }} /> <IconBtn icon={<PanelLeft size={15} />} onClick={onToggleSidebar} active={sidebarOpen} title="Toggle Sidebar" />
{/* Workspace breadcrumb */} {/* Workspace breadcrumb */}
<div style={{ display: "flex", alignItems: "center", gap: 8, minWidth: 0 }}> <div style={{ display: "flex", alignItems: "center", gap: 8, minWidth: 0 }}>
@@ -67,10 +70,9 @@ export function TopBar({
{/* Right cluster */} {/* Right cluster */}
<div style={{ display: "flex", alignItems: "center", gap: 6 }}> <div style={{ display: "flex", alignItems: "center", gap: 6 }}>
<IconBtn icon={<PanelRight size={15} />} onClick={onToggleEvents} active={eventsOpen} title="Toggle Event Center" />
<IconBtn icon={<Search size={16} />} title="Search (mock)" /> <IconBtn icon={<Search size={16} />} title="Search (mock)" />
<div style={{ position: "relative", display: "flex" }}> <div style={{ position: "relative", display: "flex" }}>
<IconBtn icon={<Bell size={16} />} title="Notifications (mock)" /> <IconBtn icon={<Bell size={16} />} onClick={onShowEvents} active={eventsOpen} title="Open activity log" />
{unread > 0 && ( {unread > 0 && (
<span style={{ <span style={{
position: "absolute", top: -2, right: -2, minWidth: 14, height: 14, padding: "0 3px", position: "absolute", top: -2, right: -2, minWidth: 14, height: 14, padding: "0 3px",
@@ -82,6 +84,7 @@ export function TopBar({
</span> </span>
)} )}
</div> </div>
<IconBtn icon={<PanelRight size={15} />} onClick={onToggleEvents} active={eventsOpen} title="Toggle Event Center" />
<IconBtn icon={<Settings size={16} />} title="Settings (mock)" /> <IconBtn icon={<Settings size={16} />} title="Settings (mock)" />
<span style={{ width: 1, height: 18, background: COLORS.borderStrong, margin: "0 2px" }} /> <span style={{ width: 1, height: 18, background: COLORS.borderStrong, margin: "0 2px" }} />
<button <button
+40 -5
View File
@@ -1,4 +1,4 @@
import { useState } from "react"; import { useEffect, useRef, useState } from "react";
import { PresetPicker, PRESETS } from "./PresetPicker"; import { PresetPicker, PRESETS } from "./PresetPicker";
import { openWorkspace, applyPreset } from "./socketBridge"; import { openWorkspace, applyPreset } from "./socketBridge";
@@ -6,10 +6,24 @@ export function Wizard({ onDone, onCancel }: { onDone: (workspaceId: string) =>
const [path, setPath] = useState("."); const [path, setPath] = useState(".");
const [preset, setPreset] = useState("2x2"); const [preset, setPreset] = useState("2x2");
const [agents, setAgents] = useState<string[]>([]); const [agents, setAgents] = useState<string[]>([]);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const pathRef = useRef<HTMLInputElement>(null);
const slots = PRESETS.find((p) => p.id === preset)?.slots ?? 1; const slots = PRESETS.find((p) => p.id === preset)?.slots ?? 1;
const agentChoices = ["shell", "claude", "codex", "gemini"]; const agentChoices = ["shell", "claude", "codex", "gemini"];
// Grab focus on open — otherwise keystrokes leak to the xterm panel behind us
// (its helper textarea sits at z-index 1000 and keeps the live focus).
useEffect(() => {
pathRef.current?.focus();
pathRef.current?.select();
}, []);
async function create() { async function create() {
if (busy) return;
setBusy(true);
setError(null);
try {
const ws = await openWorkspace(path); const ws = await openWorkspace(path);
const slotSpecs = Array.from({ length: slots }, (_, i) => { const slotSpecs = Array.from({ length: slots }, (_, i) => {
const a = agents[i] ?? "shell"; const a = agents[i] ?? "shell";
@@ -17,14 +31,32 @@ export function Wizard({ onDone, onCancel }: { onDone: (workspaceId: string) =>
}); });
await applyPreset(ws, preset, slotSpecs); await applyPreset(ws, preset, slotSpecs);
onDone(ws); onDone(ws);
} catch (e) {
setError(e instanceof Error ? e.message : String(e));
setBusy(false);
}
}
// Keep keyboard events inside the modal and add the obvious shortcuts.
function onKeyDown(e: React.KeyboardEvent) {
e.stopPropagation();
if (e.key === "Escape") { e.preventDefault(); onCancel(); }
else if (e.key === "Enter" && (e.target as HTMLElement).tagName !== "SELECT") { e.preventDefault(); void create(); }
} }
return ( return (
<div style={{ position: "fixed", inset: 0, background: "#000A", display: "flex", alignItems: "center", justifyContent: "center" }}> <div
<div style={{ width: 480, background: "#0E1116", border: "1px solid #323C49", borderRadius: 14, padding: 24, color: "#E6EDF3" }}> onMouseDown={onCancel}
style={{ position: "fixed", inset: 0, zIndex: 2000, background: "#000A", display: "flex", alignItems: "center", justifyContent: "center" }}
>
<div
onMouseDown={(e) => e.stopPropagation()}
onKeyDown={onKeyDown}
style={{ width: 480, background: "#0E1116", border: "1px solid #323C49", borderRadius: 14, padding: 24, color: "#E6EDF3" }}
>
<div style={{ fontWeight: 700, fontSize: 16, marginBottom: 16 }}>New workspace</div> <div style={{ fontWeight: 700, fontSize: 16, marginBottom: 16 }}>New workspace</div>
<label style={{ fontSize: 12, color: "#8B97A6" }}>Project folder</label> <label style={{ fontSize: 12, color: "#8B97A6" }}>Project folder</label>
<input value={path} onChange={(e) => setPath(e.target.value)} style={{ width: "100%", margin: "6px 0 16px", padding: 8, background: "#0A0D12", color: "#E6EDF3", border: "1px solid #323C49", borderRadius: 8 }} /> <input ref={pathRef} value={path} onChange={(e) => setPath(e.target.value)} style={{ width: "100%", margin: "6px 0 16px", padding: 8, background: "#0A0D12", color: "#E6EDF3", border: "1px solid #323C49", borderRadius: 8 }} />
<label style={{ fontSize: 12, color: "#8B97A6" }}>Layout</label> <label style={{ fontSize: 12, color: "#8B97A6" }}>Layout</label>
<div style={{ margin: "8px 0 16px" }}><PresetPicker selected={preset} onSelect={setPreset} /></div> <div style={{ margin: "8px 0 16px" }}><PresetPicker selected={preset} onSelect={setPreset} /></div>
<label style={{ fontSize: 12, color: "#8B97A6" }}>Agents</label> <label style={{ fontSize: 12, color: "#8B97A6" }}>Agents</label>
@@ -36,9 +68,12 @@ export function Wizard({ onDone, onCancel }: { onDone: (workspaceId: string) =>
</select> </select>
))} ))}
</div> </div>
{error && <div style={{ margin: "0 0 14px", padding: "8px 10px", background: "#3A1418", border: "1px solid #6B2230", borderRadius: 8, fontSize: 12, color: "#FF9AA6" }}>{error}</div>}
<div style={{ display: "flex", justifyContent: "flex-end", gap: 10 }}> <div style={{ display: "flex", justifyContent: "flex-end", gap: 10 }}>
<button onClick={onCancel} style={{ padding: "8px 16px" }}>Cancel</button> <button onClick={onCancel} style={{ padding: "8px 16px" }}>Cancel</button>
<button onClick={() => void create()} style={{ padding: "8px 16px", background: "#4C8DFF", color: "#0A0D12", border: "none", borderRadius: 8, fontWeight: 700 }}>Create workspace</button> <button onClick={() => void create()} disabled={busy} style={{ padding: "8px 16px", background: "#4C8DFF", color: "#0A0D12", border: "none", borderRadius: 8, fontWeight: 700, opacity: busy ? 0.6 : 1 }}>
{busy ? "Creating…" : "Create workspace"}
</button>
</div> </div>
</div> </div>
</div> </div>