From 5a716d06d9a085319ae5fb061fe7d4d2c1321b4d Mon Sep 17 00:00:00 2001 From: dpmishler Date: Wed, 18 Feb 2026 18:44:22 -0500 Subject: [PATCH] feat: client-side triage queue and PTY resize support Add a queue view (#/queue) that watches wsh sessions and surfaces ones needing human attention as a card stack. Detection is entirely client-side via three heuristics: prompt patterns, error/red text, and idle-after-activity-burst (for TUIs like Claude Code). Also add a `resize` JSON-RPC method so the web terminal can resize the PTY to match the browser viewport, fixing the issue where terminal output only filled half the screen. New files: - web/src/queue/detector.ts (QueueDetector class) - web/src/components/QueueCard.tsx - web/src/components/CardStack.tsx - web/src/components/QueueView.tsx Key changes: - Hash-based routing in main.tsx (#/queue vs default) - Queue link icon in StatusBar - ResizeObserver in Terminal sends resize to server - resize method added to WshClient and ws_methods.rs - Fix session_created race condition in app.tsx Co-Authored-By: Claude Opus 4.6 (1M context) --- .../2026-02-18-client-side-queue-design.md | 76 +++++ src/api/ws_methods.rs | 40 +++ web/src/api/ws.ts | 4 + web/src/app.tsx | 32 +- web/src/components/CardStack.tsx | 51 +++ web/src/components/QueueCard.tsx | 71 ++++ web/src/components/QueueView.tsx | 307 ++++++++++++++++++ web/src/components/SessionPane.tsx | 2 +- web/src/components/StatusBar.tsx | 11 + web/src/components/Terminal.tsx | 40 ++- web/src/main.tsx | 20 +- web/src/queue/detector.ts | 272 ++++++++++++++++ web/src/styles/terminal.css | 224 +++++++++++++ 13 files changed, 1140 insertions(+), 10 deletions(-) create mode 100644 docs/plans/2026-02-18-client-side-queue-design.md create mode 100644 web/src/components/CardStack.tsx create mode 100644 web/src/components/QueueCard.tsx create mode 100644 web/src/components/QueueView.tsx create mode 100644 web/src/queue/detector.ts diff --git a/docs/plans/2026-02-18-client-side-queue-design.md b/docs/plans/2026-02-18-client-side-queue-design.md new file mode 100644 index 0000000..f2a5983 --- /dev/null +++ b/docs/plans/2026-02-18-client-side-queue-design.md @@ -0,0 +1,76 @@ +# Client-Side Triage Queue + +A web UI at `#/queue` that watches wsh sessions and surfaces ones needing +human attention as a card stack. Entirely client-side -- no separate backend. + +## Detection + +The `QueueDetector` class subscribes to all sessions via `WshClient` and +runs three detection strategies: + +**Prompt detection** -- Session is quiescent and the last non-empty screen +line matches interactive prompt patterns: `?`, `[y/n]`, `(yes/no)`, +`password:`, `Enter to continue`, `(Y/n)`. + +**Error detection** -- Screen contains error indicators: red foreground +spans (ANSI color 1/9), lines matching `error:`, `FAILED`, `panic`, +`Traceback`. Debounced to avoid firing per-line in a stack trace. + +**Idle timeout** -- Session quiescent for 5+ seconds after activity. +Configurable threshold. Catch-all for missed patterns. + +Each strategy emits a `QueueEntry` with session name, trigger type, and +relevant screen text. + +## Card Model + +Each card has: +- `id`: session name + generation counter +- `sessionName`: the wsh session +- `trigger`: `"prompt"` | `"error"` | `"idle"` +- `triggerText`: the line(s) that triggered detection +- `timestamp`: when detected + +Cards show: +1. **Header** -- session name, trigger type badge, timestamp +2. **Live terminal** -- full interactive `Terminal` + `InputBar` +3. **Action bar** -- trigger text, buttons (Respond/Skip for prompts, + Resolved for errors, Check/Dismiss for idle), text input + send + +## Dismiss Behavior + +Dismiss removes the card and bumps a generation counter for that session. +If the same session triggers again later (new prompt, new error), a new +card appears. No cooldown, no permanent mute. + +## Layout + +Full-viewport card stack at `#/queue`. Top card interactive, subsequent +cards peek behind (4px stagger, up to 4 visible edges). Status pill shows +count. Empty state: "All clear." Dismiss animates card up, next scales in. + +## Architecture + +Four new files in the existing Vite app: + +| File | Purpose | +|------|---------| +| `web/src/queue/detector.ts` | QueueDetector class, heuristics, signal | +| `web/src/components/QueueView.tsx` | Top-level view, WshClient setup | +| `web/src/components/QueueCard.tsx` | Card with terminal + action bar | +| `web/src/components/CardStack.tsx` | Stack rendering, CSS offsets | + +Plus routing in `main.tsx` and a link in `StatusBar.tsx`. + +No new backend. No proxy rules. Uses existing `WshClient`, auth, and +session APIs. The `QueueDetector` replaces the orchestrator by using +heuristics on live terminal output. + +## Build Order + +1. `detector.ts` -- core detection logic + signal +2. `QueueCard.tsx` -- card component +3. `CardStack.tsx` -- stack layout +4. `QueueView.tsx` -- page wiring +5. `main.tsx` routing + `StatusBar` link +6. CSS for queue components diff --git a/src/api/ws_methods.rs b/src/api/ws_methods.rs index a4d4dbf..dd31325 100644 --- a/src/api/ws_methods.rs +++ b/src/api/ws_methods.rs @@ -188,6 +188,13 @@ pub enum InputEncoding { Base64, } +/// Parameters for the `resize` method. +#[derive(Debug, Deserialize)] +pub struct ResizeParams { + pub cols: u16, + pub rows: u16, +} + // --------------------------------------------------------------------------- // Overlay param types // --------------------------------------------------------------------------- @@ -618,6 +625,39 @@ pub async fn dispatch(req: &WsRequest, session: &Session) -> WsResponse { ), } } + "resize" => { + let params: ResizeParams = match parse_params(req) { + Ok(p) => p, + Err(e) => return e, + }; + if params.cols == 0 || params.rows == 0 { + return WsResponse::error( + id, + method, + "invalid_request", + "cols and rows must be positive.", + ); + } + // Resize the PTY + if let Err(e) = session.pty.lock().resize(params.rows, params.cols) { + return WsResponse::error( + id, + method, + "resize_failed", + &format!("PTY resize failed: {}.", e), + ); + } + // Resize the parser (terminal state machine) + if let Err(e) = session.parser.resize(params.cols as usize, params.rows as usize).await { + return WsResponse::error( + id, + method, + "resize_failed", + &format!("Parser resize failed: {}.", e), + ); + } + WsResponse::success(id, method, serde_json::json!({})) + } "list_panels" => { let mode = *session.screen_mode.read(); let panels = session.panels.list_by_mode(mode); diff --git a/web/src/api/ws.ts b/web/src/api/ws.ts index 27bae8c..8b94e04 100644 --- a/web/src/api/ws.ts +++ b/web/src/api/ws.ts @@ -388,6 +388,10 @@ export class WshClient { await this.request("send_input", { data }, session); } + async resize(session: string, cols: number, rows: number): Promise { + await this.request("resize", { cols, rows }, session); + } + subscribe( session: string, events: EventType[], diff --git a/web/src/app.tsx b/web/src/app.tsx index c13b833..b85c28e 100644 --- a/web/src/app.tsx +++ b/web/src/app.tsx @@ -125,13 +125,26 @@ export function App() { }; }, []); - // Keyboard shortcut for overview toggle + // Keyboard shortcuts useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { if ((e.metaKey || e.ctrlKey) && e.shiftKey && e.key === "o") { e.preventDefault(); viewMode.value = viewMode.value === "overview" ? "focused" : "overview"; } + // Ctrl+[ / Ctrl+] to switch sessions + if ((e.metaKey || e.ctrlKey) && (e.key === "[" || e.key === "]")) { + e.preventDefault(); + const order = sessionOrder.value; + const current = focusedSession.value; + if (order.length < 2 || !current) return; + const idx = order.indexOf(current); + if (idx < 0) return; + const next = e.key === "]" + ? order[(idx + 1) % order.length] + : order[(idx - 1 + order.length) % order.length]; + focusedSession.value = next; + } }; window.addEventListener("keydown", handleKeyDown); return () => window.removeEventListener("keydown", handleKeyDown); @@ -239,12 +252,17 @@ function handleLifecycleEvent(client: WshClient, raw: any): void { switch (raw.event) { case "session_created": { const name = raw.params?.name; - if (!name || sessions.value.includes(name)) break; - sessions.value = [...sessions.value, name]; - sessionOrder.value = [...sessionOrder.value, name]; - setupSession(client, name).catch((e) => { - console.error(`Failed to set up new session "${name}":`, e); - }); + if (!name) break; + if (!sessions.value.includes(name)) { + sessions.value = [...sessions.value, name]; + sessionOrder.value = [...sessionOrder.value, name]; + } + // Always set up if not already subscribed (handles race with eager update) + if (!unsubscribes.has(name)) { + setupSession(client, name).catch((e) => { + console.error(`Failed to set up new session "${name}":`, e); + }); + } break; } diff --git a/web/src/components/CardStack.tsx b/web/src/components/CardStack.tsx new file mode 100644 index 0000000..4f45b58 --- /dev/null +++ b/web/src/components/CardStack.tsx @@ -0,0 +1,51 @@ +import { QueueCard } from "./QueueCard"; +import type { QueueEntry } from "../queue/detector"; +import type { WshClient } from "../api/ws"; + +const MAX_VISIBLE = 4; + +interface CardStackProps { + entries: QueueEntry[]; + client: WshClient; + onDismiss: (sessionName: string) => void; +} + +export function CardStack({ entries, client, onDismiss }: CardStackProps) { + if (entries.length === 0) { + return ( +
+ All clear. +
+ ); + } + + const visible = entries.slice(0, MAX_VISIBLE); + + return ( +
+ {visible.map((entry, i) => ( +
+ +
+ ))} + {entries.length > 1 && ( +
{entries.length} items
+ )} +
+ ); +} diff --git a/web/src/components/QueueCard.tsx b/web/src/components/QueueCard.tsx new file mode 100644 index 0000000..fe0bd03 --- /dev/null +++ b/web/src/components/QueueCard.tsx @@ -0,0 +1,71 @@ +import { Terminal } from "./Terminal"; +import { InputBar } from "./InputBar"; +import type { QueueEntry, TriggerType } from "../queue/detector"; +import type { WshClient } from "../api/ws"; + +function triggerBadge(trigger: TriggerType): string { + switch (trigger) { + case "prompt": + return "prompt"; + case "error": + return "error"; + case "idle": + return "idle"; + } +} + +function relativeTime(ts: number): string { + const seconds = Math.floor((Date.now() - ts) / 1000); + if (seconds < 5) return "just now"; + if (seconds < 60) return `${seconds}s ago`; + const minutes = Math.floor(seconds / 60); + if (minutes < 60) return `${minutes}m ago`; + return `${Math.floor(minutes / 60)}h ago`; +} + +interface QueueCardProps { + entry: QueueEntry; + client: WshClient; + onDismiss: (sessionName: string) => void; + interactive: boolean; +} + +export function QueueCard({ + entry, + client, + onDismiss, + interactive, +}: QueueCardProps) { + return ( +
+
+ {entry.sessionName} + + {triggerBadge(entry.trigger)} + + {relativeTime(entry.timestamp)} +
+ +
+ +
+ + {interactive && ( + <> + +
+ + {entry.triggerText} + + +
+ + )} +
+ ); +} diff --git a/web/src/components/QueueView.tsx b/web/src/components/QueueView.tsx new file mode 100644 index 0000000..f897a89 --- /dev/null +++ b/web/src/components/QueueView.tsx @@ -0,0 +1,307 @@ +import { useEffect, useRef } from "preact/hooks"; +import { WshClient } from "../api/ws"; +import { + sessions, + sessionOrder, + focusedSession, + connectionState, + authToken, + authRequired, + authError, + theme, +} from "../state/sessions"; +import { + setFullScreen, + updateScreen, + updateLine, + removeScreen, + getScreen, +} from "../state/terminal"; +import { QueueDetector, queueEntries } from "../queue/detector"; +import { CardStack } from "./CardStack"; + +// Per-session unsubscribe functions (scoped to this view's lifetime) +const unsubscribes = new Map void>(); + +export function QueueView() { + const clientRef = useRef(null); + const detectorRef = useRef(null); + + useEffect(() => { + const client = new WshClient(); + clientRef.current = client; + + if (authToken.value) { + client.setToken(authToken.value); + } + + client.onStateChange = (state) => { + connectionState.value = state; + if (state === "connected") { + initQueueSessions(client).then(() => { + if (!detectorRef.current) { + const detector = new QueueDetector(); + detectorRef.current = detector; + detector.start(); + } + }); + } + }; + + client.onAuthRequired = (reason) => { + if (reason === "invalid") { + authError.value = "Invalid token. Please try again."; + } else { + authError.value = null; + } + authRequired.value = true; + }; + + const proto = location.protocol === "https:" ? "wss:" : "ws:"; + client.connect(`${proto}//${location.host}/ws/json`); + + return () => { + detectorRef.current?.stop(); + detectorRef.current = null; + for (const unsub of unsubscribes.values()) unsub(); + unsubscribes.clear(); + client.disconnect(); + clientRef.current = null; + }; + }, []); + + // Sync theme class + const currentTheme = theme.value; + useEffect(() => { + const root = document.documentElement; + root.classList.remove("theme-glass", "theme-neon", "theme-minimal"); + root.classList.add(`theme-${currentTheme}`); + }, [currentTheme]); + + const entries = queueEntries.value; + const connected = connectionState.value; + const client = clientRef.current; + + const handleDismiss = (sessionName: string) => { + detectorRef.current?.dismiss(sessionName); + }; + + if (!client || connected !== "connected") { + return ( +
+
+ + + + + + Queue +
+
Connecting...
+
+ ); + } + + return ( +
+
+ + + + + + Queue + {entries.length} +
+ +
+ ); +} + +// --- Session initialization (mirrors app.tsx logic for this view) --- + +async function initQueueSessions(client: WshClient): Promise { + try { + for (const unsub of unsubscribes.values()) unsub(); + unsubscribes.clear(); + client.clearAllSubscriptions(); + + const list = await client.listSessions(); + let names = list.map((s) => s.name); + + if (names.length === 0) { + const created = await client.createSession(); + names = [created.name]; + } + + sessions.value = names; + sessionOrder.value = [...names]; + + if (!focusedSession.value || !names.includes(focusedSession.value)) { + focusedSession.value = names[0]; + } + + await Promise.all(names.map((name) => setupQueueSession(client, name))); + + client.onLifecycleEvent = (event) => + handleQueueLifecycle(client, event); + } catch (e) { + console.error("Failed to initialize queue sessions:", e); + } +} + +async function setupQueueSession( + client: WshClient, + name: string, +): Promise { + const screen = await client.getScreen(name, "styled"); + setFullScreen(name, { + lines: screen.lines, + cursor: screen.cursor, + alternateActive: screen.alternate_active, + cols: screen.cols, + rows: screen.rows, + firstLineIndex: screen.first_line_index, + }); + + const unsub = client.subscribe( + name, + ["lines", "cursor", "mode"], + (event) => { + const target = (event.session as string) ?? name; + handleQueueEvent(client, target, event); + }, + ); + unsubscribes.set(name, unsub); +} + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +function handleQueueLifecycle(client: WshClient, raw: any): void { + switch (raw.event) { + case "session_created": { + const name = raw.params?.name; + if (!name || sessions.value.includes(name)) break; + sessions.value = [...sessions.value, name]; + sessionOrder.value = [...sessionOrder.value, name]; + setupQueueSession(client, name).catch((e) => { + console.error(`Failed to set up session "${name}":`, e); + }); + break; + } + + case "session_destroyed": { + const name = raw.params?.name; + if (!name) break; + const unsub = unsubscribes.get(name); + if (unsub) { + unsub(); + unsubscribes.delete(name); + } + removeScreen(name); + sessions.value = sessions.value.filter((s) => s !== name); + sessionOrder.value = sessionOrder.value.filter((s) => s !== name); + if (focusedSession.value === name) { + focusedSession.value = sessionOrder.value[0] ?? null; + } + break; + } + + case "session_renamed": { + const oldName = raw.params?.old_name; + const newName = raw.params?.new_name; + if (!oldName || !newName) break; + + sessions.value = sessions.value.map((s) => + s === oldName ? newName : s, + ); + sessionOrder.value = sessionOrder.value.map((s) => + s === oldName ? newName : s, + ); + + const screenState = getScreen(oldName); + removeScreen(oldName); + setFullScreen(newName, screenState); + + const unsub = unsubscribes.get(oldName); + if (unsub) { + unsubscribes.delete(oldName); + unsubscribes.set(newName, unsub); + } + client.rekeySubscription(oldName, newName); + + if (focusedSession.value === oldName) { + focusedSession.value = newName; + } + break; + } + } +} + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +function handleQueueEvent(client: WshClient, session: string, raw: any): void { + switch (raw.event) { + case "sync": + case "diff": { + const screen = raw.params?.screen ?? raw.screen; + if (!screen) break; + setFullScreen(session, { + lines: screen.lines, + cursor: screen.cursor, + alternateActive: screen.alternate_active, + cols: screen.cols, + rows: screen.rows, + firstLineIndex: screen.first_line_index, + }); + break; + } + + case "line": + updateLine(session, raw.index, raw.line); + break; + + case "cursor": + updateScreen(session, { + cursor: { row: raw.row, col: raw.col, visible: raw.visible }, + }); + break; + + case "mode": + updateScreen(session, { alternateActive: raw.alternate_active }); + break; + + case "reset": + client + .getScreen(session, "styled") + .then((screen) => { + setFullScreen(session, { + lines: screen.lines, + cursor: screen.cursor, + alternateActive: screen.alternate_active, + cols: screen.cols, + rows: screen.rows, + firstLineIndex: screen.first_line_index, + }); + }) + .catch((e) => { + console.error( + `Failed to re-fetch screen after reset for "${session}":`, + e, + ); + }); + break; + } +} diff --git a/web/src/components/SessionPane.tsx b/web/src/components/SessionPane.tsx index 3a17ac1..51b5d8d 100644 --- a/web/src/components/SessionPane.tsx +++ b/web/src/components/SessionPane.tsx @@ -10,7 +10,7 @@ interface SessionPaneProps { export function SessionPane({ session, client }: SessionPaneProps) { return (
- +
); diff --git a/web/src/components/StatusBar.tsx b/web/src/components/StatusBar.tsx index a9f7680..cef73c1 100644 --- a/web/src/components/StatusBar.tsx +++ b/web/src/components/StatusBar.tsx @@ -114,6 +114,17 @@ export function StatusBar({ client }: StatusBarProps) { /> + + + + + + +