tiletopia/src/lib/layout/LeafPane.tsx
megaproxy f51033a142 Idle filter: suppress when watched process (claude) is running in distro
Probes wsl.exe -d <distro> -- pgrep -x claude before flagging a WSL pane
idle, with a 3s per-distro cache on the Rust side. If claude is running
anywhere in the distro, all panes in that distro stay out of the idle set
(per-pane granularity is out of scope — PIDs aren't observable from
Windows). PowerShell + SSH panes skip the probe and keep the legacy
always-notify behaviour.
2026-05-26 17:33:10 +01:00

585 lines
20 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.

import {
useState,
useEffect,
useRef,
useCallback,
type KeyboardEvent,
type MouseEvent,
type PointerEvent as ReactPointerEvent,
} from "react";
import { type LeafNode, resolveFontSize, type LeafShellSpec } from "./tree";
import { useOrchestration } from "./orchestration";
import XtermPane from "../../components/XtermPane";
import { isWatchProcessRunning, type SpawnSpec } from "../../ipc";
import "./LeafPane.css";
const IDLE_THRESHOLD_MS = 5000;
export default function LeafPane({ leaf }: { leaf: LeafNode }) {
const orch = useOrchestration();
const isActive = orch.activeLeafId === leaf.id;
const isBroadcasting = !!leaf.broadcast;
// ---- status (from XtermPane) -------------------------------------------
const [status, setStatus] = useState("starting…");
const [statusOk, setStatusOk] = useState(true);
// ---- label editing -----------------------------------------------------
const [editingLabel, setEditingLabel] = useState(false);
const [labelDraft, setLabelDraft] = useState("");
const labelInputRef = useRef<HTMLInputElement | null>(null);
const startEditLabel = useCallback(
(e: MouseEvent) => {
e.stopPropagation();
setLabelDraft(leaf.label ?? "");
setEditingLabel(true);
// Focus on next tick so input is mounted
queueMicrotask(() => labelInputRef.current?.select());
},
[leaf.label],
);
const commitLabel = useCallback(() => {
if (!editingLabel) return;
orch.setLabel(leaf.id, labelDraft);
setEditingLabel(false);
}, [editingLabel, orch.setLabel, leaf.id, labelDraft]);
const cancelLabel = useCallback(() => setEditingLabel(false), []);
const onLabelKey = useCallback(
(e: KeyboardEvent<HTMLInputElement>) => {
if (e.key === "Enter") {
e.preventDefault();
commitLabel();
} else if (e.key === "Escape") {
e.preventDefault();
cancelLabel();
}
},
[commitLabel, cancelLabel],
);
// ---- shell-picker popover ----------------------------------------------
// Hierarchical menu: WSL distros, then Windows (PowerShell), then SSH
// hosts + a "Manage hosts…" entry. Picking any item swaps the leaf id
// (forces respawn).
const [shellMenuOpen, setShellMenuOpen] = useState(false);
const toggleShellMenu = useCallback((e: MouseEvent) => {
e.stopPropagation();
setShellMenuOpen((v) => !v);
}, []);
const pickShell = useCallback(
(spec: LeafShellSpec) => {
setShellMenuOpen(false);
// Only respawn if the spec is actually different from what's running.
if (spec.shellKind === "wsl" && leaf.shellKind === "wsl" && spec.distro === leaf.distro) {
return;
}
if (spec.shellKind === "powershell" && leaf.shellKind === "powershell") {
return;
}
if (
spec.shellKind === "ssh" &&
leaf.shellKind === "ssh" &&
spec.sshHostId === leaf.sshHostId
) {
return;
}
orch.setShell(leaf.id, spec);
},
[orch.setShell, leaf.id, leaf.shellKind, leaf.distro, leaf.sshHostId],
);
const onManageHosts = useCallback(
(e: MouseEvent) => {
e.stopPropagation();
setShellMenuOpen(false);
orch.openHostManager();
},
[orch.openHostManager],
);
// Dismiss popover on outside click
useEffect(() => {
if (!shellMenuOpen) return;
const onDocClick = () => setShellMenuOpen(false);
window.addEventListener("click", onDocClick);
return () => window.removeEventListener("click", onDocClick);
}, [shellMenuOpen]);
// Label shown on the dropdown chip — tells the user what's currently
// running without expanding the menu.
const chipLabel =
leaf.shellKind === "powershell"
? "PowerShell"
: leaf.shellKind === "ssh"
? `ssh: ${orch.hosts.find((h) => h.id === leaf.sshHostId)?.label ?? "(missing host)"}`
: (leaf.distro ?? "(default)");
// ---- idle detection ----------------------------------------------------
// Local boolean for the red border + status text on this pane; reported
// up to App via orch.reportLeafIdle for the titlebar's "N idle" badge.
//
// Filter: for WSL panes, before flagging idle we probe the backend to
// see if any "watched" process (currently just `claude`) is running in
// the distro. If it is, the silence is "claude thinking / user reading",
// not "nothing happening" — stay quiet. Probe is per-distro (not per-
// pane: the inside-WSL PID isn't observable from Windows), so multiple
// panes in the same distro will all suppress if claude is running in
// any of them. Agreed trade-off; over-suppression beats the previous
// always-notify behaviour.
//
// PowerShell + SSH skip the probe and fall through to legacy behaviour
// (PS has no portable `ps`; SSH processes live on the remote box).
const lastDataTimeRef = useRef(Date.now());
const [isIdle, setIsIdle] = useState(false);
const isWslPane = leaf.shellKind === "wsl";
// Captures the distro name into the interval callback. Empty string when
// the leaf doesn't have one yet — the probe treats that as fail-safe true.
const wslDistro = isWslPane ? (leaf.distro ?? "") : "";
const onDataReceived = useCallback(() => {
lastDataTimeRef.current = Date.now();
setIsIdle((cur) => {
if (cur) orch.reportLeafIdle(leaf.id, false);
return false;
});
}, [orch.reportLeafIdle, leaf.id]);
useEffect(() => {
// Guard against late-resolving probes after unmount or another tick
// already shipping a fresher answer.
let cancelled = false;
let inFlight = false;
const tick = () => {
const dt = Date.now() - lastDataTimeRef.current;
const nowIdle = dt >= IDLE_THRESHOLD_MS;
// Transitioning out of idle is unconditional — fresh output beats
// any probe answer.
if (!nowIdle) {
setIsIdle((cur) => {
if (!cur) return cur;
orch.reportLeafIdle(leaf.id, false);
return false;
});
return;
}
// Transitioning into idle. Non-WSL panes: report immediately (legacy
// behaviour). WSL panes: gate on the probe; suppress if a watched
// process is running in the distro.
if (!isWslPane) {
setIsIdle((cur) => {
if (cur) return cur;
orch.reportLeafIdle(leaf.id, true);
return true;
});
return;
}
// WSL path. Don't stack probes — one in flight per pane at a time.
if (inFlight) return;
inFlight = true;
void isWatchProcessRunning(wslDistro)
.then((suppress) => {
if (cancelled) return;
// If output arrived while the probe was in flight, the next tick
// (or onDataReceived) will reconcile; don't flip-flop here.
if (Date.now() - lastDataTimeRef.current < IDLE_THRESHOLD_MS) return;
if (suppress) {
// claude (or another watched proc) is running — treat silence
// as expected and stay out of the idle set.
setIsIdle((cur) => {
if (!cur) return cur;
orch.reportLeafIdle(leaf.id, false);
return false;
});
} else {
setIsIdle((cur) => {
if (cur) return cur;
orch.reportLeafIdle(leaf.id, true);
return true;
});
}
})
.catch((e) => {
// Probe IPC errored — fail-safe to suppression (matches the Rust
// side's own fail-safe).
if (cancelled) return;
// eslint-disable-next-line no-console
console.debug("idle probe failed", e);
})
.finally(() => {
inFlight = false;
});
};
const id = window.setInterval(tick, 1000);
return () => {
cancelled = true;
clearInterval(id);
};
}, [leaf.id, orch.reportLeafIdle, isWslPane, wslDistro]);
// Clear from the app-level idle set when this pane unmounts.
useEffect(() => {
return () => orch.reportLeafIdle(leaf.id, false);
}, [leaf.id, orch.reportLeafIdle]);
// ---- broadcast ---------------------------------------------------------
const onTerminalInput = useCallback(
(b64: string) => {
if (isBroadcasting) orch.broadcastFrom(leaf.id, b64);
},
[isBroadcasting, orch.broadcastFrom, leaf.id],
);
// ---- focus / active highlighting ---------------------------------------
const [focusTrigger, setFocusTrigger] = useState(0);
// When this leaf becomes active, bump focusTrigger so XtermPane refocuses.
useEffect(() => {
if (isActive) setFocusTrigger((n) => n + 1);
}, [isActive]);
const onPaneClick = useCallback(() => {
orch.setActive(leaf.id);
}, [orch.setActive, leaf.id]);
const onPaneSpawned = useCallback(
(paneId: number) => {
orch.registerPaneId(leaf.id, paneId);
},
[orch.registerPaneId, leaf.id],
);
// Unregister on TRUE unmount only — depending on `orch` here would
// delete the paneId from App's lookup on every activeLeafId change,
// which broke broadcast routing (peers found, but their paneIds
// had been silently removed from the map).
useEffect(() => {
return () => orch.registerPaneId(leaf.id, null);
}, [orch.registerPaneId, leaf.id]);
const onXtermFocus = useCallback(
() => orch.setActive(leaf.id),
[orch.setActive, leaf.id],
);
const onStatus = useCallback((msg: string, ok: boolean) => {
setStatus(msg);
setStatusOk(ok);
}, []);
// ---- header-drag swap ---------------------------------------------------
// Drag the toolbar onto another pane's toolbar/body to swap their tree
// positions. Uses a movement threshold so accidental tiny moves while
// clicking a label etc don't initiate a drag.
const DRAG_THRESHOLD_PX = 5;
const dragStartRef = useRef<{ x: number; y: number; armed: boolean; dragging: boolean } | null>(
null,
);
const isDragSource = orch.dragSourceId === leaf.id;
const isDragTarget =
orch.dragOverId === leaf.id && orch.dragSourceId !== leaf.id;
const onToolbarPointerDown = useCallback(
(e: ReactPointerEvent<HTMLDivElement>) => {
const target = e.target as HTMLElement;
// Skip if the click landed on an interactive child.
if (target.closest("button, input, .distro-menu")) return;
(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);
dragStartRef.current = {
x: e.clientX,
y: e.clientY,
armed: true,
dragging: false,
};
// Make this pane active (since clicking the toolbar should focus it).
orch.setActive(leaf.id);
},
[orch.setActive, leaf.id],
);
const onToolbarPointerMove = useCallback(
(e: ReactPointerEvent<HTMLDivElement>) => {
const st = dragStartRef.current;
if (!st || !st.armed) return;
const dx = e.clientX - st.x;
const dy = e.clientY - st.y;
if (!st.dragging) {
if (Math.hypot(dx, dy) < DRAG_THRESHOLD_PX) return;
st.dragging = true;
orch.beginHeaderDrag(leaf.id);
document.body.style.cursor = "grabbing";
}
// Find the leaf under the cursor.
const el = document.elementFromPoint(e.clientX, e.clientY);
const tEl = el?.closest("[data-leaf-id]");
const targetId = tEl?.getAttribute("data-leaf-id") ?? null;
orch.setHeaderDragOver(targetId);
},
[orch.beginHeaderDrag, orch.setHeaderDragOver, leaf.id],
);
const onToolbarPointerUp = useCallback(
(e: ReactPointerEvent<HTMLDivElement>) => {
const st = dragStartRef.current;
if (!st) return;
(e.currentTarget as HTMLElement).releasePointerCapture(e.pointerId);
const wasDragging = st.dragging;
dragStartRef.current = null;
if (wasDragging) {
document.body.style.cursor = "";
orch.endHeaderDrag(true);
}
},
[orch.endHeaderDrag],
);
const onToolbarPointerCancel = useCallback(
(e: ReactPointerEvent<HTMLDivElement>) => {
const st = dragStartRef.current;
if (!st) return;
(e.currentTarget as HTMLElement).releasePointerCapture(e.pointerId);
const wasDragging = st.dragging;
dragStartRef.current = null;
if (wasDragging) {
document.body.style.cursor = "";
orch.endHeaderDrag(false);
}
},
[orch.endHeaderDrag],
);
const labelText = leaf.label ?? "(unnamed)";
// Resolve the SpawnSpec from the leaf + host table. If shellKind=ssh but
// the referenced host was deleted, we surface an error in the toolbar
// status instead of spawning an unrelated shell.
const spec: SpawnSpec | null = (() => {
if (leaf.shellKind === "wsl") {
return { kind: "wsl", distro: leaf.distro, cwd: leaf.cwd };
}
if (leaf.shellKind === "powershell") {
return { kind: "powershell" };
}
const host = orch.hosts.find((h) => h.id === leaf.sshHostId);
if (!host) return null;
return {
kind: "ssh",
host: host.hostname,
user: host.user,
port: host.port,
identityFile: host.identityFile,
jumpHost: host.jumpHost,
extraArgs: host.extraArgs,
hostId: host.id,
};
})();
return (
<div
className={`leaf${isActive ? " active" : ""}${isBroadcasting ? " broadcasting" : ""}${isIdle ? " idle" : ""}${isDragSource ? " drag-source" : ""}${isDragTarget ? " drag-target" : ""}`}
role="group"
aria-label={`Terminal pane: ${leaf.label ?? leaf.distro ?? "unnamed"}`}
data-leaf-id={leaf.id}
onPointerDown={onPaneClick}
>
<div
className="pane-toolbar"
onPointerDown={onToolbarPointerDown}
onPointerMove={onToolbarPointerMove}
onPointerUp={onToolbarPointerUp}
onPointerCancel={onToolbarPointerCancel}
>
{editingLabel ? (
<input
ref={labelInputRef}
className="label-input"
value={labelDraft}
onChange={(e) => setLabelDraft(e.target.value)}
onKeyDown={onLabelKey}
onBlur={commitLabel}
placeholder="(label)"
/>
) : (
<button
className="pane-label"
onClick={startEditLabel}
title="Click to rename pane"
>
{labelText}
</button>
)}
<span className="distro-wrap">
<button
className="distro-chip"
onClick={toggleShellMenu}
title="Change shell (respawns the pane)"
>
{chipLabel}
</button>
{shellMenuOpen && (
<div
className="distro-menu shell-menu"
role="menu"
onClick={(e) => e.stopPropagation()}
>
{orch.distros.length > 0 && (
<>
<div className="shell-menu-header">WSL</div>
{orch.distros.map((d) => {
const active = leaf.shellKind === "wsl" && d === leaf.distro;
return (
<button
key={`wsl-${d}`}
className={`distro-menu-item${active ? " active" : ""}`}
onClick={() => pickShell({ shellKind: "wsl", distro: d })}
>
{d}
</button>
);
})}
</>
)}
<div className="shell-menu-header">Windows</div>
<button
className={`distro-menu-item${leaf.shellKind === "powershell" ? " active" : ""}`}
onClick={() => pickShell({ shellKind: "powershell" })}
>
PowerShell
</button>
<div className="shell-menu-header">SSH</div>
{orch.hosts.length === 0 ? (
<div className="shell-menu-empty">(no saved hosts)</div>
) : (
orch.hosts.map((h) => {
const active =
leaf.shellKind === "ssh" && h.id === leaf.sshHostId;
return (
<button
key={`ssh-${h.id}`}
className={`distro-menu-item${active ? " active" : ""}`}
onClick={() =>
pickShell({ shellKind: "ssh", sshHostId: h.id })
}
title={
h.user
? `${h.user}@${h.hostname}${h.port ? ":" + h.port : ""}`
: `${h.hostname}${h.port ? ":" + h.port : ""}`
}
>
{h.label || h.hostname}
</button>
);
})
)}
<button
className="distro-menu-item shell-menu-manage"
onClick={onManageHosts}
>
Manage hosts
</button>
</div>
)}
</span>
<button
className={`bcast-chip${isBroadcasting ? " on" : ""}`}
onClick={(e) => {
e.stopPropagation();
orch.toggleBroadcast(leaf.id);
}}
title={
isBroadcasting
? "Broadcasting (click or Ctrl+Shift+B to leave group)"
: "Click or Ctrl+Shift+B to broadcast input to other broadcast panes"
}
aria-pressed={isBroadcasting ? "true" : "false"}
>
📡
</button>
<button
className={`bcast-chip mcp-chip${leaf.mcpAllow ? " on" : ""}`}
onClick={(e) => {
e.stopPropagation();
orch.toggleMcpAllow(leaf.id);
}}
title={
leaf.mcpAllow
? "MCP can see this pane — click to revoke"
: "MCP cannot see this pane — click to allow (only matters when the MCP server is on)"
}
aria-pressed={leaf.mcpAllow ? "true" : "false"}
>
🤖
</button>
{isIdle && statusOk ? (
<span className="pane-status idle" title={`No output for ${IDLE_THRESHOLD_MS / 1000}s+`}>
idle
</span>
) : (
<span className={`pane-status ${statusOk ? "ok" : "err"}`}>{status}</span>
)}
<span className="pane-actions">
<button
className="pane-btn"
title="Split right (Ctrl+Shift+E)"
onClick={(e) => {
e.stopPropagation();
orch.split(leaf.id, "h");
}}
aria-label="Split right"
>
</button>
<button
className="pane-btn"
title="Split down (Ctrl+Shift+O)"
onClick={(e) => {
e.stopPropagation();
orch.split(leaf.id, "v");
}}
aria-label="Split down"
>
</button>
<button
className="pane-btn close"
title="Close pane (Ctrl+Shift+W)"
onClick={(e) => {
e.stopPropagation();
orch.close(leaf.id);
}}
aria-label="Close pane"
>
×
</button>
</span>
</div>
<div className="xterm-wrap">
{spec ? (
<XtermPane
spec={spec}
onStatus={onStatus}
onSpawn={onPaneSpawned}
onInput={onTerminalInput}
onDataReceived={onDataReceived}
onFocus={onXtermFocus}
focusTrigger={focusTrigger}
fontSize={resolveFontSize(leaf.fontSizeOffset)}
/>
) : (
<div className="leaf-missing-host">
<p>SSH host not found</p>
<p className="hint">
Open the shell menu and pick another host, or add this host back
via Manage hosts.
</p>
</div>
)}
</div>
</div>
);
}