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.
This commit is contained in:
parent
5b970f8b48
commit
f51033a142
7 changed files with 352 additions and 11 deletions
41
memory.md
41
memory.md
|
|
@ -29,7 +29,8 @@ Durable memory for this project. Read at session start, update before session en
|
|||
- [x] ~~**M4 — orchestration.** Broadcast input, idle notifications, Ctrl+K palette.~~ Done 2026-05-22.
|
||||
- [x] ~~**Auto-save debouncing.**~~ 500ms timer in `App.svelte` `$effect`.
|
||||
- [x] ~~**HMR distro picker reset.**~~ No longer an issue — per-pane distro selection.
|
||||
- [ ] **Idle detection: filter by "claude is foreground."** Currently every pane notifies after 5s silence, which fires too eagerly when the user is reading a `claude` response. Want to detect that `claude` (or any user-specified process) is actually running in the pane's shell before notifying. Needs a Rust-side probe over WSL: `wsl.exe -d <distro> ps --ppid <shell_pid> -o comm=`. Defer to a future polish pass.
|
||||
- [x] ~~**Idle detection: filter by "claude is foreground."** Currently every pane notifies after 5s silence, which fires too eagerly when the user is reading a `claude` response. Want to detect that `claude` (or any user-specified process) is actually running in the pane's shell before notifying.~~ Done 2026-05-26 — per-distro probe via `wsl.exe -d <distro> -- pgrep -x claude`, cached 3s on the Rust side. WSL panes only; PS + SSH fall back to legacy always-notify. Watched list hardcoded to `["claude"]` — `[[user-watch-list]]` follow-up below.
|
||||
- [ ] **`[[user-watch-list]]` — user-configurable idle-suppress process list.** v1 hardcodes `DEFAULT_WATCH_PROCESSES = ["claude"]` in `src-tauri/src/probe.rs`. Move to a workspace-config field (or dedicated `watch.json`) so users can add `cargo`, `npm test`, `pytest`, etc. without a recompile. Two design notes: (1) the values are passed straight to `pgrep -x`, so user-supplied strings must be validated (no shell metachars / leading `-`) before reaching `probe_one`; (2) the cache key is currently just the distro name — if the watched-list becomes per-pane / per-workspace, key the cache by `(distro, sorted_watch_list)` to prevent stale answers.
|
||||
- [ ] **Native OS notifications.** Right now toasts only show while the app is focused. `tauri-plugin-notification` would push to Windows Action Center; useful for "claude finished" when the app is minimized. Worth adding if/when the user actually backgrounds the app while waiting for sessions.
|
||||
- [ ] **Configurable idle threshold.** Hardcoded 5000ms in `LeafPane.svelte`. Should move into a settings panel; M5 territory.
|
||||
- [x] ~~**Logic tests for `tree.ts`.**~~ Vitest, 43 cases, runs via `pnpm test`. Done 2026-05-22.
|
||||
|
|
@ -52,6 +53,44 @@ Durable memory for this project. Read at session start, update before session en
|
|||
|
||||
## Session log
|
||||
|
||||
### 2026-05-26 — Idle filter: suppress when `claude` is running in the distro
|
||||
|
||||
The idle indicator used to fire 5s after any silence, regardless of what the pane was doing. While the user reads a long `claude` response the pane is silent (claude is processing or the human is reading) and the red border + titlebar "N idle" count is just noise. Fixed: WSL panes now probe the backend before flagging idle, and stay quiet if `claude` is running anywhere in the distro.
|
||||
|
||||
**Granularity is per-distro, not per-pane.** Identifying which Windows pane corresponds to which Linux-side shell inside WSL is too complex (PIDs aren't visible from Windows; ProcMon-style probes are fragile). Agreed trade-off: if claude is running in distro X, ALL panes in distro X suppress. Over-suppression for multi-pane-same-distro users is fine — the previous always-notify bug was worse, and that user pattern is the minority.
|
||||
|
||||
**Architecture:**
|
||||
|
||||
1. New `src-tauri/src/probe.rs` module with `ProbeCache` — `parking_lot::Mutex<HashMap<String, (Instant, bool)>>` keyed by distro name, 3s TTL. Sized against the frontend's 1s idle-tick: ~one `wsl.exe` call per distro per 3 ticks even with many panes polling, while reacting to "claude finished" within a few seconds.
|
||||
2. Probe command runs `wsl.exe -d <distro> -- pgrep -x claude` via `quiet_command_pub` (new public alias of the existing `quiet_command` in pty.rs so cross-module callers don't re-implement the `CREATE_NO_WINDOW` dance). Exit 0 = match, exit 1 = no match, anything else = probe failure.
|
||||
3. **Fail-safe is suppression.** Any probe error (wsl.exe missing, distro stopped, pgrep not installed) resolves to `true` → frontend suppresses the idle indicator. Matches the agreed trade-off: over-suppression beats false-positive notifications.
|
||||
4. New Tauri command `is_watch_process_running(distro)`. Wrapped in `tokio::task::spawn_blocking` because the shell-out can take 100-300ms — keep it off the async runtime's thread pool.
|
||||
5. `LeafPane.tsx` idle-detection effect rewritten: when the tick says "now idle", branch by `shellKind`. WSL → probe backend, suppress if true. PowerShell + SSH → skip the probe and fall back to legacy behaviour (PS has no portable `ps`; SSH processes live on a remote box; out of scope for v1). Includes `inFlight` guard so a slow probe doesn't stack with subsequent ticks, and a `cancelled` flag for the React-18-StrictMode cleanup pattern we always use here.
|
||||
|
||||
**Watched list is currently hardcoded.** `DEFAULT_WATCH_PROCESSES: &[&str] = &["claude"]` in probe.rs. Comment marks the v2 follow-up: surface as a workspace-config field, key the cache by `(distro, sorted_list)` if it becomes per-pane, and validate user-supplied strings against `pgrep` shell-injection (no `-` prefix, no shell metachars).
|
||||
|
||||
**Files touched:**
|
||||
|
||||
- `src-tauri/src/probe.rs` — new module (~150 lines).
|
||||
- `src-tauri/src/pty.rs` — `quiet_command_pub` exposed for cross-module use.
|
||||
- `src-tauri/src/lib.rs` — register the module, the `ProbeCache` state, and the command in `invoke_handler`.
|
||||
- `src-tauri/src/commands.rs` — `is_watch_process_running` Tauri command.
|
||||
- `src/ipc.ts` — `isWatchProcessRunning` TS wrapper.
|
||||
- `src/lib/layout/LeafPane.tsx` — idle-detection effect now branches on shellKind and gates WSL transitions through the probe.
|
||||
|
||||
**Validated:**
|
||||
|
||||
- `pnpm check` clean (0 errors).
|
||||
- `pnpm test` clean (72 tree.ts tests pass — no UI tests yet, so the React-side change isn't covered automatically).
|
||||
- Rust side authored in WSL; user to run `cargo build / cargo check -p tiletopia_lib` from Windows before merging.
|
||||
|
||||
Open follow-ups specific to this session:
|
||||
|
||||
- **`[[user-watch-list]]` config surface.** See open-questions section above. Probably 30 min of work: add `watchProcesses?: string[]` to workspace.json, validate per-name (no `-`, no shell metachars, length cap), thread through to a new `is_watch_process_running_for` command that takes the list, key the cache by `(distro, sorted_list_hash)`.
|
||||
- **Probe latency-as-jitter.** First idle tick after 5s silence triggers a 100-300ms `wsl.exe` shell-out. The user sees the red border flicker on for ~one tick before the probe resolves and clears it. Not visually obvious in practice (the red is already a transient signal), but could pre-warm the cache on a slower interval if it bites.
|
||||
- **PowerShell idle filter.** PS has no `ps` equivalent we can probe cheaply; closest is `Get-Process` + a watched-list mapping (`claude` doesn't exist on Windows, but `cargo`, `npm`, `python` do). Defer until someone actually runs a long-running CLI in PS and complains.
|
||||
- **Workspace-edit migration of the `LeafPane.svelte` mention** in the open-question section about the 5000ms threshold — file says `.svelte` but we're React now. Drive-by, not done here ("don't refactor unrelated code").
|
||||
|
||||
### 2026-05-26 — Hard-deny: PowerShell patterns + label list de-duplicated
|
||||
|
||||
Mirrors the POSIX hard-deny rules with their Windows/PowerShell equivalents. Four new patterns:
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ use crate::creds;
|
|||
use crate::hosts::{self, SshHost, SshHostView};
|
||||
use crate::mcp::{self, McpMirror, McpServerHandle, McpState, PendingActions, RunningServer};
|
||||
use crate::mcp_policy::McpPolicy;
|
||||
use crate::probe::ProbeCache;
|
||||
use crate::pty::{list_wsl_distros, PaneId, PtyManager, SpawnSpec};
|
||||
|
||||
const WORKSPACE_FILE: &str = "workspace.json";
|
||||
|
|
@ -302,3 +303,30 @@ pub async fn mcp_policy_save(app: AppHandle, policy: McpPolicy) -> Result<(), St
|
|||
pub async fn mcp_hard_deny_labels() -> Result<Vec<&'static str>, String> {
|
||||
Ok(crate::mcp_policy::hard_deny_rules().to_vec())
|
||||
}
|
||||
|
||||
// ---- idle-detection filter -------------------------------------------------
|
||||
|
||||
/// Probe whether any of the built-in watched processes (currently
|
||||
/// `["claude"]`) is running in the given WSL distro. Result is cached
|
||||
/// per-distro for ~3s — see {@link ProbeCache}. Fail-safe: any probe error
|
||||
/// resolves to `true` so the caller suppresses the idle indicator (the
|
||||
/// agreed trade-off; the previous "always notify" bug was worse than the
|
||||
/// occasional over-suppression).
|
||||
///
|
||||
/// Frontend only calls this for WSL panes. PowerShell + SSH skip the probe
|
||||
/// and fall back to the legacy always-notify behaviour. Empty distro names
|
||||
/// resolve to `true` (no info → fail-safe).
|
||||
#[tauri::command]
|
||||
pub async fn is_watch_process_running(
|
||||
cache: tauri::State<'_, Arc<ProbeCache>>,
|
||||
distro: String,
|
||||
) -> Result<bool, String> {
|
||||
// Probe shells out — keep it off the async runtime's thread.
|
||||
let cache_arc: Arc<ProbeCache> = (*cache).clone();
|
||||
let running = tokio::task::spawn_blocking(move || {
|
||||
cache_arc.is_watch_process_running(&distro)
|
||||
})
|
||||
.await
|
||||
.map_err(|e| format!("probe join failed: {e}"))?;
|
||||
Ok(running)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,11 +5,13 @@ mod creds;
|
|||
mod hosts;
|
||||
mod mcp;
|
||||
mod mcp_policy;
|
||||
mod probe;
|
||||
mod pty;
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::mcp::{McpServerHandle, McpState, PendingActions};
|
||||
use crate::probe::ProbeCache;
|
||||
use crate::pty::PtyManager;
|
||||
|
||||
pub fn run() {
|
||||
|
|
@ -40,6 +42,9 @@ pub fn run() {
|
|||
// Pending action registry — separate managed state so mcp_action_reply can
|
||||
// grab it without needing to lock McpState or reach into TileService.
|
||||
let pending_actions: Arc<PendingActions> = Arc::new(PendingActions::default());
|
||||
// Idle-filter probe cache: shared across all is_watch_process_running
|
||||
// calls so a per-distro answer is reused for a few seconds. See probe.rs.
|
||||
let probe_cache: Arc<ProbeCache> = Arc::new(ProbeCache::new());
|
||||
|
||||
tauri::Builder::default()
|
||||
.plugin(tauri_plugin_clipboard_manager::init())
|
||||
|
|
@ -48,6 +53,7 @@ pub fn run() {
|
|||
.manage(mcp_state)
|
||||
.manage(McpServerHandle::default())
|
||||
.manage(pending_actions)
|
||||
.manage(probe_cache)
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
commands::list_distros,
|
||||
commands::spawn_pane,
|
||||
|
|
@ -70,6 +76,7 @@ pub fn run() {
|
|||
commands::mcp_policy_load,
|
||||
commands::mcp_policy_save,
|
||||
commands::mcp_hard_deny_labels,
|
||||
commands::is_watch_process_running,
|
||||
])
|
||||
.run(tauri::generate_context!())
|
||||
.expect("error while running tauri application");
|
||||
|
|
|
|||
172
src-tauri/src/probe.rs
Normal file
172
src-tauri/src/probe.rs
Normal file
|
|
@ -0,0 +1,172 @@
|
|||
//! "Is a watched process running in distro X?" probe for the idle-detection
|
||||
//! filter.
|
||||
//!
|
||||
//! Background: tiletopia's idle indicator fires whenever a pane goes 5s
|
||||
//! without PTY output. When the user is reading a long `claude` response,
|
||||
//! the pane is silent but there's nothing actionable to surface — the
|
||||
//! indicator becomes noise. This module lets the frontend ask the backend
|
||||
//! "is `claude` (or any other watched process) running in this distro?"
|
||||
//! before flagging a pane idle, and suppresses the indicator if so.
|
||||
//!
|
||||
//! Granularity is per-distro, not per-pane. Identifying which Windows pane
|
||||
//! corresponds to which Linux-side shell inside the distro is too complex
|
||||
//! (PIDs aren't visible from Windows; ProcMon-style probes are fragile). If
|
||||
//! `claude` is running anywhere in distro X, idle is suppressed for ALL
|
||||
//! panes in distro X. Over-suppression for multi-pane-same-distro users is
|
||||
//! the agreed trade-off; the previous bug (always notify) was worse.
|
||||
//!
|
||||
//! PowerShell + SSH panes don't go through this probe — the frontend short-
|
||||
//! circuits to "always idle" for them. (PowerShell has no portable `ps`
|
||||
//! equivalent; SSH processes live on a remote box and would need a separate
|
||||
//! transport.)
|
||||
//!
|
||||
//! The probe shells out (`wsl.exe -d <distro> -- pgrep -x ...`), which costs
|
||||
//! ~100-300ms per call. We cache the answer per-distro for a few seconds so
|
||||
//! the frontend can poll on every idle tick without storming `wsl.exe`.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use parking_lot::Mutex;
|
||||
|
||||
/// Built-in list of process names that suppress idle when running. v1 ships
|
||||
/// with just `claude`; the user can extend it via the workspace config later.
|
||||
///
|
||||
/// [[user-watch-list]] TODO: surface this as a user-editable list (workspace
|
||||
/// config field or dedicated `watch.json`). For now the constant covers the
|
||||
/// only real-world use case (Anthropic's `claude` CLI taking its time on a
|
||||
/// long response). Adding entries to the constant is the only knob.
|
||||
pub const DEFAULT_WATCH_PROCESSES: &[&str] = &["claude"];
|
||||
|
||||
/// How long a per-distro probe result is reused before we re-shell. Sized
|
||||
/// against the frontend's 1s idle-tick interval — 3s means roughly one
|
||||
/// probe per distro per 3 ticks even with many panes polling, while still
|
||||
/// reacting to "claude just finished" within a few seconds. Trade-off: too
|
||||
/// short = wsl.exe spam, too long = stale "claude is running" once the
|
||||
/// process actually exits.
|
||||
const CACHE_TTL: Duration = Duration::from_secs(3);
|
||||
|
||||
/// Cache entry: timestamp the probe ran + whether any watched process was
|
||||
/// found in the distro.
|
||||
#[derive(Clone, Copy)]
|
||||
struct CacheEntry {
|
||||
at: Instant,
|
||||
running: bool,
|
||||
}
|
||||
|
||||
/// Per-distro probe cache. Keyed by distro name (the same string the user
|
||||
/// sees in the shell picker; the same string we pass as `wsl.exe -d`).
|
||||
pub struct ProbeCache {
|
||||
cache: Mutex<HashMap<String, CacheEntry>>,
|
||||
}
|
||||
|
||||
impl ProbeCache {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
cache: Mutex::new(HashMap::new()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns true iff one of the watched processes is running in the
|
||||
/// distro. Cached for {@link CACHE_TTL}; cache misses (or stale entries)
|
||||
/// trigger a fresh probe. On probe failure the result is `true` —
|
||||
/// **fail-safe is to suppress** the idle indicator, matching the
|
||||
/// agreed trade-off ("over-suppression beats the previous always-notify
|
||||
/// behaviour").
|
||||
pub fn is_watch_process_running(&self, distro: &str) -> bool {
|
||||
// Fast path: fresh cached answer.
|
||||
{
|
||||
let guard = self.cache.lock();
|
||||
if let Some(entry) = guard.get(distro) {
|
||||
if entry.at.elapsed() < CACHE_TTL {
|
||||
return entry.running;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Slow path: re-probe. Drop the lock before shelling out so other
|
||||
// distros' probes aren't blocked.
|
||||
let running = probe_distro(distro, DEFAULT_WATCH_PROCESSES);
|
||||
|
||||
let mut guard = self.cache.lock();
|
||||
guard.insert(
|
||||
distro.to_string(),
|
||||
CacheEntry {
|
||||
at: Instant::now(),
|
||||
running,
|
||||
},
|
||||
);
|
||||
running
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for ProbeCache {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
/// Run `wsl.exe -d <distro> -- pgrep -x <name>` for each watched name.
|
||||
/// Returns true on the first hit. On any failure (wsl.exe missing, distro
|
||||
/// not running, pgrep not installed, timeout) returns true — fail-safe is
|
||||
/// suppression.
|
||||
fn probe_distro(distro: &str, watched: &[&str]) -> bool {
|
||||
if !cfg!(windows) {
|
||||
// Non-Windows builds don't actually ship the app; pretend no watched
|
||||
// process so the idle indicator works for developer test runs.
|
||||
return false;
|
||||
}
|
||||
if distro.is_empty() {
|
||||
// We can't probe an empty distro name; treat as "no info" → fail-safe.
|
||||
tracing::debug!("probe: empty distro name; defaulting to suppression");
|
||||
return true;
|
||||
}
|
||||
|
||||
for name in watched {
|
||||
match probe_one(distro, name) {
|
||||
Ok(true) => return true,
|
||||
Ok(false) => continue,
|
||||
Err(e) => {
|
||||
tracing::debug!(
|
||||
"probe: wsl pgrep for {name:?} in {distro:?} failed: {e} — suppressing idle"
|
||||
);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
/// Single `pgrep -x <name>` invocation. Ok(true) on a match, Ok(false) on
|
||||
/// exit code 1 (no match), Err on anything else. Wrapped in our standard
|
||||
/// `quiet_command` so the console window doesn't flash on the Windows
|
||||
/// desktop every probe.
|
||||
fn probe_one(distro: &str, name: &str) -> std::io::Result<bool> {
|
||||
// `pgrep -x` matches the exact comm (no substring), which avoids
|
||||
// `claude-something-else` false-positives. Stdout/stderr are silenced
|
||||
// — exit code carries the answer.
|
||||
//
|
||||
// Note: `name` is a compile-time string literal in DEFAULT_WATCH_PROCESSES
|
||||
// (no user input), so shell-quoting concerns don't apply. If we ever
|
||||
// wire user-supplied process names through here we MUST validate / shell-
|
||||
// quote them before this point.
|
||||
let out = crate::pty::quiet_command_pub("wsl.exe")
|
||||
.args(["-d", distro, "--", "pgrep", "-x", name])
|
||||
.stdout(std::process::Stdio::null())
|
||||
.stderr(std::process::Stdio::null())
|
||||
.output()?;
|
||||
|
||||
match out.status.code() {
|
||||
Some(0) => Ok(true), // pgrep found at least one match
|
||||
Some(1) => Ok(false), // pgrep ran but found nothing
|
||||
Some(other) => {
|
||||
// 2 = syntax error in pgrep itself; 3 = fatal error; 127 = command
|
||||
// not found. None of these mean "definitively no claude running",
|
||||
// so treat as a probe failure (caller fails-safe to true).
|
||||
Err(std::io::Error::other(format!(
|
||||
"pgrep exit code {other}"
|
||||
)))
|
||||
}
|
||||
None => Err(std::io::Error::other("pgrep killed by signal")),
|
||||
}
|
||||
}
|
||||
|
|
@ -457,6 +457,13 @@ fn looks_like_password_prompt(buf: &[u8]) -> bool {
|
|||
|
||||
/// Run a process without flashing a console window on Windows.
|
||||
fn quiet_command(program: &str) -> std::process::Command {
|
||||
quiet_command_pub(program)
|
||||
}
|
||||
|
||||
/// Public variant for cross-module callers (currently {@link crate::probe}).
|
||||
/// Same behaviour as the in-module `quiet_command`; the wrapper exists so
|
||||
/// other modules don't each re-implement the CREATE_NO_WINDOW dance.
|
||||
pub fn quiet_command_pub(program: &str) -> std::process::Command {
|
||||
let mut c = std::process::Command::new(program);
|
||||
#[cfg(windows)]
|
||||
{
|
||||
|
|
|
|||
|
|
@ -39,6 +39,14 @@ export interface SshHost {
|
|||
|
||||
export const listDistros = (): Promise<string[]> => invoke("list_distros");
|
||||
|
||||
/** Ask the backend whether any built-in "watched" process (currently just
|
||||
* `claude`) is running in the given WSL distro. Cached per-distro for ~3s
|
||||
* on the Rust side. Fail-safe: probe failures resolve to `true` so the
|
||||
* caller suppresses the idle indicator. Only meaningful for WSL panes —
|
||||
* PowerShell + SSH should skip this and fall back to always-notify. */
|
||||
export const isWatchProcessRunning = (distro: string): Promise<boolean> =>
|
||||
invoke("is_watch_process_running", { distro });
|
||||
|
||||
export const spawnPane = (args: {
|
||||
spec: SpawnSpec;
|
||||
cols: number;
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ import {
|
|||
import { type LeafNode, resolveFontSize, type LeafShellSpec } from "./tree";
|
||||
import { useOrchestration } from "./orchestration";
|
||||
import XtermPane from "../../components/XtermPane";
|
||||
import type { SpawnSpec } from "../../ipc";
|
||||
import { isWatchProcessRunning, type SpawnSpec } from "../../ipc";
|
||||
import "./LeafPane.css";
|
||||
|
||||
const IDLE_THRESHOLD_MS = 5000;
|
||||
|
|
@ -116,8 +116,24 @@ export default function LeafPane({ leaf }: { leaf: LeafNode }) {
|
|||
// ---- 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) => {
|
||||
|
|
@ -126,17 +142,81 @@ export default function LeafPane({ leaf }: { leaf: LeafNode }) {
|
|||
});
|
||||
}, [orch.reportLeafIdle, leaf.id]);
|
||||
useEffect(() => {
|
||||
const id = window.setInterval(() => {
|
||||
// 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;
|
||||
setIsIdle((cur) => {
|
||||
if (cur === nowIdle) return cur;
|
||||
orch.reportLeafIdle(leaf.id, nowIdle);
|
||||
return nowIdle;
|
||||
});
|
||||
}, 1000);
|
||||
return () => clearInterval(id);
|
||||
}, [leaf.id, orch.reportLeafIdle]);
|
||||
|
||||
// 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);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue