Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
76 changes: 76 additions & 0 deletions docs/plans/2026-02-18-client-side-queue-design.md
Original file line number Diff line number Diff line change
@@ -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
40 changes: 40 additions & 0 deletions src/api/ws_methods.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -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);
Expand Down
4 changes: 4 additions & 0 deletions web/src/api/ws.ts
Original file line number Diff line number Diff line change
Expand Up @@ -388,6 +388,10 @@ export class WshClient {
await this.request("send_input", { data }, session);
}

async resize(session: string, cols: number, rows: number): Promise<void> {
await this.request("resize", { cols, rows }, session);
}

subscribe(
session: string,
events: EventType[],
Expand Down
32 changes: 25 additions & 7 deletions web/src/app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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;
}

Expand Down
51 changes: 51 additions & 0 deletions web/src/components/CardStack.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div class="card-stack-empty">
<span class="card-stack-empty-text">All clear.</span>
</div>
);
}

const visible = entries.slice(0, MAX_VISIBLE);

return (
<div class="card-stack">
{visible.map((entry, i) => (
<div
key={entry.id}
class="card-stack-slot"
style={{
zIndex: MAX_VISIBLE - i,
transform:
i === 0 ? "none" : `translateY(${i * 4}px) scale(${1 - i * 0.02})`,
opacity: i === 0 ? 1 : Math.max(0.3, 1 - i * 0.25),
pointerEvents: i === 0 ? "auto" : "none",
}}
>
<QueueCard
entry={entry}
client={client}
onDismiss={onDismiss}
interactive={i === 0}
/>
</div>
))}
{entries.length > 1 && (
<div class="card-stack-count">{entries.length} items</div>
)}
</div>
);
}
71 changes: 71 additions & 0 deletions web/src/components/QueueCard.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div class={`queue-card ${interactive ? "" : "queue-card-behind"}`}>
<div class="queue-card-header">
<span class="queue-card-session">{entry.sessionName}</span>
<span class={`queue-card-badge queue-card-badge-${entry.trigger}`}>
{triggerBadge(entry.trigger)}
</span>
<span class="queue-card-time">{relativeTime(entry.timestamp)}</span>
</div>

<div class="queue-card-terminal">
<Terminal session={entry.sessionName} client={client} />
</div>

{interactive && (
<>
<InputBar session={entry.sessionName} client={client} />
<div class="queue-card-actions">
<span class="queue-card-trigger-text" title={entry.triggerText}>
{entry.triggerText}
</span>
<button
class="queue-card-dismiss"
onClick={() => onDismiss(entry.sessionName)}
>
Dismiss
</button>
</div>
</>
)}
</div>
);
}
Loading