Replace idle toasts with pane border + titlebar badge

Old behaviour: every pane fired orch.notify("X is idle") after 5s of
silence, stacking up to N toasts that took ages to dismiss.

New behaviour:
- LeafPane tracks its own isIdle state locally and reports up via
  orch.reportLeafIdle(leafId, idle).
- App aggregates into a Set<NodeId> and renders "N idle" in red after
  the "N panes" count in the titlebar (hidden when zero).
- The pane itself gets a red border (.leaf.idle) — but active and
  broadcasting borders still take precedence, so the focus indicator
  isn't masked by idle status.
- The pane's "alive" status text in the toolbar swaps to red "idle"
  while it's quiet (reverts to "alive" the moment output arrives).
- Idle clears immediately on the next byte of output (no 1-second lag)
  AND when the pane unmounts (cleanup effect).

No more flood of toasts.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
megaproxy 2026-05-22 19:54:20 +01:00
parent c93ebddfa5
commit d9ddf52699
5 changed files with 71 additions and 15 deletions

View file

@ -70,6 +70,9 @@
color: #777;
font-size: 11px;
}
.layout-info .idle-info {
color: #d96060;
}
.pane-wrap {
flex: 1 1 auto;

View file

@ -239,6 +239,19 @@ export default function App() {
setNotifications((ns) => ns.filter((n) => n.id !== id));
}, []);
// ---- per-pane idle aggregation (replaces toast spam) --------------------
const [idleLeafIds, setIdleLeafIds] = useState<Set<NodeId>>(() => new Set());
const reportLeafIdle = useCallback((leafId: NodeId, idle: boolean) => {
setIdleLeafIds((prev) => {
if (idle && prev.has(leafId)) return prev;
if (!idle && !prev.has(leafId)) return prev;
const next = new Set(prev);
if (idle) next.add(leafId);
else next.delete(leafId);
return next;
});
}, []);
// ---- header-drag swap ---------------------------------------------------
const [dragSourceId, setDragSourceId] = useState<NodeId | null>(null);
const [dragOverId, setDragOverId] = useState<NodeId | null>(null);
@ -283,6 +296,7 @@ export default function App() {
beginHeaderDrag,
setHeaderDragOver,
endHeaderDrag,
reportLeafIdle,
}),
[
activeLeafId,
@ -301,6 +315,7 @@ export default function App() {
beginHeaderDrag,
setHeaderDragOver,
endHeaderDrag,
reportLeafIdle,
],
);
@ -443,6 +458,12 @@ export default function App() {
<span className="layout-info">
{leafCount(tree)} pane{leafCount(tree) === 1 ? "" : "s"}
{idleLeafIds.size > 0 && (
<span className="idle-info" title="Panes that haven't produced output recently">
{" · "}
{idleLeafIds.size} idle
</span>
)}
</span>
</header>

View file

@ -17,6 +17,20 @@
.leaf.active.broadcasting {
border-color: #ffb840;
}
.leaf.idle {
border-color: #c84040;
}
/* active / broadcasting beats idle visually when you're focused on a
pane (active), the blue tells you "you're here"; idle is implied. */
.leaf.active.idle {
border-color: #5a8cd8;
}
.leaf.broadcasting.idle {
border-color: #e09838;
}
.leaf.active.broadcasting.idle {
border-color: #ffb840;
}
.leaf.drag-source {
opacity: 0.4;
}
@ -154,6 +168,7 @@
}
.pane-status.ok { color: #6c6; }
.pane-status.err { color: #d66; }
.pane-status.idle { color: #d96060; }
.pane-actions {
display: flex;

View file

@ -79,27 +79,33 @@ export default function LeafPane({ leaf }: { leaf: LeafNode }) {
}, [distroOpen]);
// ---- 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.
const lastDataTimeRef = useRef(Date.now());
const notifiedThisIdleRef = useRef(false);
const [isIdle, setIsIdle] = useState(false);
const onDataReceived = useCallback(() => {
lastDataTimeRef.current = Date.now();
notifiedThisIdleRef.current = false;
}, []);
setIsIdle((cur) => {
if (cur) orch.reportLeafIdle(leaf.id, false);
return false;
});
}, [orch.reportLeafIdle, leaf.id]);
useEffect(() => {
const id = window.setInterval(() => {
if (notifiedThisIdleRef.current) return;
const dt = Date.now() - lastDataTimeRef.current;
if (dt >= IDLE_THRESHOLD_MS) {
notifiedThisIdleRef.current = true;
const name = leaf.label ?? leaf.distro ?? "pane";
orch.notify(`${name} is idle`);
}
const nowIdle = dt >= IDLE_THRESHOLD_MS;
setIsIdle((cur) => {
if (cur === nowIdle) return cur;
orch.reportLeafIdle(leaf.id, nowIdle);
return nowIdle;
});
}, 1000);
return () => clearInterval(id);
// Depend on the stable notify function, not the whole orch object.
// orch is recreated every time activeLeafId/distros change; depending
// on it would tear down and rebuild this interval on every click.
}, [leaf.label, leaf.distro, orch.notify]);
}, [leaf.id, orch.reportLeafIdle]);
// 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(
@ -229,7 +235,7 @@ export default function LeafPane({ leaf }: { leaf: LeafNode }) {
return (
<div
className={`leaf${isActive ? " active" : ""}${isBroadcasting ? " broadcasting" : ""}${isDragSource ? " drag-source" : ""}${isDragTarget ? " drag-target" : ""}`}
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}
@ -305,7 +311,13 @@ export default function LeafPane({ leaf }: { leaf: LeafNode }) {
📡
</button>
<span className={`pane-status ${statusOk ? "ok" : "err"}`}>{status}</span>
{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

View file

@ -37,6 +37,11 @@ export interface Orchestration {
beginHeaderDrag: (leafId: NodeId) => void;
setHeaderDragOver: (leafId: NodeId | null) => void;
endHeaderDrag: (commitSwap: boolean) => void;
// Per-leaf idle reporting. LeafPanes call reportLeafIdle when their
// own quiet-state crosses the threshold; App aggregates so the titlebar
// can show an "N idle" count without spamming toast notifications.
reportLeafIdle: (leafId: NodeId, idle: boolean) => void;
}
const OrchestrationContext = createContext<Orchestration | null>(null);