tiletopia/src/lib/layout/LeafPane.tsx
megaproxy 6772b8db37 Idle filter: pivot per-distro → per-pane via TILETOPIA_PANE_ID env marker
Per-distro suppression (shipped earlier today) broke tiletopia's primary
use case — multiple claude panes per distro means as soon as one runs
claude, ALL Ubuntu panes go silent. Tested live: user couldn't reproduce
idle on any pane because PID 46848 (their main session) tripped the gate.

New mechanism, per-pane via env-var marker:

1. pty.rs tags every WSL spawn with TILETOPIA_PANE_ID=<id> as a Windows
   env var, plus WSLENV=...TILETOPIA_PANE_ID/u (appended to any pre-
   existing WSLENV) so the var forwards into the distro. Pane id is now
   reserved BEFORE build_command so the tag is available at spawn time.
2. probe.rs rewritten — is_watch_process_running(distro, pane_id) runs
   a bash one-liner that pgreps for each watched name, then for each PID
   checks /proc/<pid>/environ for the matching TILETOPIA_PANE_ID line.
   Env inheritance does the work: shell inherits from wsl.exe, claude
   inherits from shell. Cache keyed by (distro, pane_id).
3. Fail-safe INVERTED: probe failure now returns false (don't suppress)
   instead of true (suppress). A transient error should never silence
   the idle indicator permanently. Frontend catch updated to match.
4. LeafPane tracks PaneId in paneIdRef set by onPaneSpawned; idle ticks
   before spawn-completion pass 0, which won't match any real marker so
   the pane idles normally.

Existing panes won't have the marker until respawned — they'll always
show idle (since probe never matches). User opens fresh panes once after
deploying this. Documented in memory.md follow-ups.

pnpm check clean. Rust validation: cargo test --lib on Windows.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 17:58:51 +01:00

596 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 THIS
// pane specifically — per-pane, not per-distro. Per-pane is essential for
// tiletopia's primary use case (multiple claude sessions across panes in
// the same distro). The backend matches by reading `TILETOPIA_PANE_ID`
// out of each candidate process's `/proc/<pid>/environ` (the env var is
// injected at spawn time; see src-tauri/src/pty.rs WSLENV setup).
//
// PowerShell + SSH skip the probe and fall through to legacy behaviour
// (PS has no portable `ps`; SSH processes live on a 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 returns "not running" for
// empty input so the pane goes idle normally.
const wslDistro = isWslPane ? (leaf.distro ?? "") : "";
// Backend pane id (PaneId, the u64 used inside Rust). Set by the
// XtermPane onSpawn callback; null until the spawn round-trip completes.
// Idle ticks before that point pass 0 — won't match any real pane's
// TILETOPIA_PANE_ID env, so the probe returns false (no suppression).
const paneIdRef = useRef<number | null>(null);
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;
const paneIdForProbe = paneIdRef.current ?? 0;
void isWatchProcessRunning(wslDistro, paneIdForProbe)
.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 in THIS pane —
// treat the silence as expected; 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 — don't flip idle either way; next tick retries.
// The Rust side now also fails-safe to "not running" so the pane
// will flag idle eventually if the probe stays broken.
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) => {
paneIdRef.current = paneId;
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 () => {
paneIdRef.current = null;
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>
);
}