diff --git a/web/src/components/AttentionInbox.tsx b/web/src/components/AttentionInbox.tsx new file mode 100644 index 0000000..126fdef --- /dev/null +++ b/web/src/components/AttentionInbox.tsx @@ -0,0 +1,136 @@ +/** + * The "Needs you" inbox — one cross-session queue of everything blocking + * (conductor-frontends-design §8). + * + * Ambient count in the status bar, opening a ranked list. §8 asks for the + * count to be always visible and the queue one click away: the point is that + * you stop polling N sessions to find the one that stopped. + * + * Ranking and collection are pure (`lib/attention.ts`); this is the surface. + */ + +import { Component, For, Show, createSignal, onCleanup } from "solid-js"; + +import { relativeTime } from "../lib/format"; +import type { AttentionItem, AttentionKind } from "../lib/attention"; +import { attentionCount, attentionItems } from "../state/attention"; +import { nowTick } from "../state/clock"; +import { focusSession } from "../state/sessions"; + +const KIND_STYLE: Record = { + question: { cls: "border-warn/60 bg-warn/15 text-warn", label: "asks" }, + approval: { cls: "border-warn/60 bg-warn/15 text-warn", label: "approve" }, + blocked: { cls: "border-danger/60 bg-danger/15 text-danger", label: "blocked" }, + failed: { cls: "border-danger/40 bg-danger/10 text-danger", label: "failed" }, +}; + +const AttentionInbox: Component = () => { + const [open, setOpen] = createSignal(false); + + // Close on Escape — the panel is an overlay, and a keyboard user must be + // able to dismiss it without hunting for the toggle. + const onKey = (e: KeyboardEvent) => { + if (e.key === "Escape") setOpen(false); + }; + if (typeof window !== "undefined") { + window.addEventListener("keydown", onKey); + onCleanup(() => window.removeEventListener("keydown", onKey)); + } + + return ( +
+ + + + {/* Click-away backdrop, behind the panel. */} +
setOpen(false)} aria-hidden="true" /> +
+ 0} + fallback={ +

+ Nothing is waiting on you. Agents that wedge on an approval, ask a + question, or hit the failure limit show up here. +

+ } + > +
    + + {(item) => setOpen(false)} />} + +
+
+
+ +
+ ); +}; + +const Row: Component<{ item: AttentionItem; onGo: () => void }> = (props) => { + const style = () => KIND_STYLE[props.item.kind]; + const body = ( + <> +
+ + {style().label} + + + {props.item.label} + + + {relativeTime(new Date(props.item.since).toISOString(), nowTick())} + +
+

+ {props.item.detail} +

+ + ); + + return ( +
  • + {/* A stopped task whose worker was torn down has nowhere to go, so it + renders as a plain row rather than a button that does nothing. */} + {body}
  • } + > + {(id) => ( + + )} + + + ); +}; + +export default AttentionInbox; diff --git a/web/src/components/StatusBar.tsx b/web/src/components/StatusBar.tsx index 990b768..7f7f539 100644 --- a/web/src/components/StatusBar.tsx +++ b/web/src/components/StatusBar.tsx @@ -10,6 +10,7 @@ import { Component, Show } from "solid-js"; +import AttentionInbox from "./AttentionInbox"; import HomeToggle from "./HomeToggle"; import { @@ -54,6 +55,8 @@ const StatusBar: Component = () => { + {/* Ambient — §8 wants the count always visible, the queue one click away. */} + diff --git a/web/src/lib/attention.test.ts b/web/src/lib/attention.test.ts new file mode 100644 index 0000000..9a950ca --- /dev/null +++ b/web/src/lib/attention.test.ts @@ -0,0 +1,225 @@ +import { describe, it, expect } from "vitest"; + +import { + attentionQueue, + attentionScore, + collectAttention, + rankAttention, + type AttentionItem, + type AttentionSources, +} from "./attention"; +import type { FleetTaskWire, SessionInfo, SessionUiRequestMsg } from "../protocol/types"; + +const NOW = 1_000_000_000_000; +const MIN = 60_000; + +const session = (id: string, over: Partial = {}): SessionInfo => + ({ id, name: id, status: "idle", ...over }) as SessionInfo; + +const uiReq = (sessionId: string, requestId: string, title: string): SessionUiRequestMsg => + ({ type: "session.ui_request", sessionId, requestId, method: "confirm", title }) as SessionUiRequestMsg; + +const task = (id: string, over: Partial = {}): FleetTaskWire => ({ + id, + kind: "spawn", + shape: "scout", + status: "blocked", + attempts: 2, + createdAt: NOW, + createdBy: "agent:conductor", + ...over, +}); + +const sources = (over: Partial = {}): AttentionSources => ({ + sessions: [], + uiRequests: {}, + tasks: [], + taskSession: () => null, + ...over, +}); + +const iso = (ms: number) => new Date(ms).toISOString(); + +describe("collectAttention", () => { + it("gathers dialogs, wedged sessions and stopped tasks into one queue", () => { + const items = collectAttention( + sources({ + sessions: [ + session("a", { status: "waiting_approval" }), + session("b"), + session("c", { status: "idle" }), + ], + uiRequests: { b: [uiReq("b", "r1", "Pick a branch")] }, + tasks: [task("t1"), task("t2", { status: "failed" }), task("t3", { status: "done" })], + }), + NOW, + ); + expect(items.map((i) => i.kind).sort()).toEqual([ + "approval", + "blocked", + "failed", + "question", + ]); + // A `done` task is not an interruption. + expect(items.some((i) => i.key.includes("t3"))).toBe(false); + }); + + it("does not double-count one stoppage as both a dialog and an approval", () => { + // A session showing a provider dialog is usually ALSO waiting_approval. + // Counting both would tell the operator two things need them when one does. + const items = collectAttention( + sources({ + sessions: [session("a", { status: "waiting_approval" })], + uiRequests: { a: [uiReq("a", "r1", "Confirm?")] }, + }), + NOW, + ); + expect(items).toHaveLength(1); + expect(items[0]!.kind).toBe("question"); + expect(items[0]!.detail).toBe("Confirm?"); + }); + + it("skips a dialog whose session this client cannot see", () => { + // Clicking it would go nowhere, so it is not actionable. + const items = collectAttention( + sources({ sessions: [], uiRequests: { ghost: [uiReq("ghost", "r1", "?")] } }), + NOW, + ); + expect(items).toEqual([]); + }); + + it("labels a task by its session when it has one, and by id when it does not", () => { + const worker = session("w1", { name: "worker-scout-abc" }); + const withSession = collectAttention( + sources({ + sessions: [worker], + tasks: [task("t1", { workerSessionId: "w1" })], + taskSession: () => worker, + }), + NOW, + ); + expect(withSession[0]!.label).toBe("worker-scout-abc"); + expect(withSession[0]!.sessionId).toBe("w1"); + + // A finished worker is torn down, so a stopped task often has no session. + const orphan = collectAttention(sources({ tasks: [task("abcdef12")] }), NOW); + expect(orphan[0]!.label).toBe("spawn abcdef12"); + expect(orphan[0]!.sessionId).toBeNull(); + }); + + it("carries the task's real error as the detail", () => { + const items = collectAttention( + sources({ tasks: [task("t1", { error: "reclaimed: stale claim" })] }), + NOW, + ); + expect(items[0]!.detail).toBe("reclaimed: stale claim"); + }); + + it("treats an unknown or unparseable age as brand new, never as ancient", () => { + // An unknown must not jump the queue. + const items = collectAttention( + sources({ + sessions: [ + session("a", { status: "waiting_approval" }), + session("b", { status: "waiting_approval", lastActivityAt: "not-a-date" }), + ], + }), + NOW, + ); + expect(items.every((i) => i.since === NOW)).toBe(true); + }); +}); + +describe("attentionScore / rankAttention", () => { + const item = (over: Partial): AttentionItem => ({ + key: "k", + kind: "approval", + sessionId: "s", + label: "s", + detail: "", + since: NOW, + ...over, + }); + + it("ranks a live wedged agent above a stopped task of the same age", () => { + const q = rankAttention( + [ + item({ key: "task", kind: "blocked" }), + item({ key: "agent", kind: "approval" }), + ], + NOW, + ); + expect(q.map((i) => i.key)).toEqual(["agent", "task"]); + }); + + it("floats a long-stuck yes/no above a fresher one — §8's worked example", () => { + const q = rankAttention( + [ + item({ key: "fresh", kind: "question", since: NOW }), + item({ key: "stuck8m", kind: "question", since: NOW - 8 * MIN }), + ], + NOW, + ); + expect(q[0]!.key).toBe("stuck8m"); + }); + + it("lets kind dominate while everything is new", () => { + // Without the age floor every fresh item scores zero and the queue would + // rank purely by age exactly when a wedged agent most needs to be first. + const q = rankAttention( + [ + item({ key: "failed", kind: "failed", since: NOW }), + item({ key: "question", kind: "question", since: NOW }), + ], + NOW, + ); + expect(q[0]!.key).toBe("question"); + }); + + it("lets staleness eventually overtake kind", () => { + const q = rankAttention( + [ + item({ key: "freshQuestion", kind: "question", since: NOW }), + item({ key: "oldFailure", kind: "failed", since: NOW - 30 * MIN }), + ], + NOW, + ); + expect(q[0]!.key).toBe("oldFailure"); + }); + + it("orders ties totally and stably", () => { + // A queue that reshuffles equal rows between renders is one you cannot + // click accurately. + const a = item({ key: "aaa" }); + const b = item({ key: "bbb" }); + expect(rankAttention([b, a], NOW).map((i) => i.key)).toEqual(["aaa", "bbb"]); + expect(rankAttention([a, b], NOW).map((i) => i.key)).toEqual(["aaa", "bbb"]); + }); + + it("scores a future timestamp as brand new rather than negative", () => { + // Clock skew between daemon and browser is normal; it must not produce a + // negative score that buries a real interruption. + const future = item({ since: NOW + 5 * MIN }); + expect(attentionScore(future, NOW)).toBeGreaterThan(0); + expect(attentionScore(future, NOW)).toBe(attentionScore(item({ since: NOW }), NOW)); + }); +}); + +describe("attentionQueue", () => { + it("collects and ranks in one call", () => { + const q = attentionQueue( + sources({ + sessions: [session("wedged", { status: "waiting_approval", lastActivityAt: iso(NOW) })], + tasks: [task("old", { createdAt: NOW - 60 * MIN })], + }), + NOW, + ); + expect(q).toHaveLength(2); + // The hour-old blocked task outranks the just-wedged agent. + expect(q[0]!.key).toBe("task:old"); + }); + + it("returns an empty queue when nothing needs you", () => { + expect(attentionQueue(sources({ sessions: [session("a")] }), NOW)).toEqual([]); + }); +}); diff --git a/web/src/lib/attention.ts b/web/src/lib/attention.ts new file mode 100644 index 0000000..a78c3ae --- /dev/null +++ b/web/src/lib/attention.ts @@ -0,0 +1,176 @@ +/** + * The cross-session attention queue — one ranked "Needs you" list + * (conductor-frontends-design §8). + * + * Every blocking event across every agent, in one place: a session wedged on a + * tool approval, a provider dialog waiting on an answer, a dispatch task that + * hit the anti-spin limit. Without this the operator polls N sessions to find + * the one that stopped; with it, the work comes to them. + * + * Pure functions — collection and ranking are the decisions worth testing, and + * they need no reactive root. + */ + +import type { FleetTaskWire, SessionInfo, SessionUiRequestMsg } from "../protocol/types"; + +export type AttentionKind = "question" | "approval" | "blocked" | "failed"; + +export interface AttentionItem { + /** Stable across re-collection, so a list keyed on it does not thrash. */ + key: string; + kind: AttentionKind; + /** The session to open. Null for a task whose session is gone or not yet spawned. */ + sessionId: string | null; + /** What to show as the row's subject — a session name, or a task id. */ + label: string; + detail: string; + /** Epoch ms this started blocking. See `sinceFor` on why it is approximate. */ + since: number; +} + +/** + * Blocking cost per kind — the multiplier in §8's (blocking-cost × staleness). + * + * A `question` or `approval` has stopped a LIVE agent mid-turn: the work is + * paid for, in flight, and going nowhere until a human answers. A `blocked` + * task is stopped too but is not burning anything and will never self-clear. + * A `failed` task ranks lowest because it is the one kind that can still + * resolve without you — it retries with backoff (§6). + */ +const COST: Record = { + question: 3, + approval: 3, + blocked: 2, + failed: 1, +}; + +/** + * Floor added to every item's age before scoring, in ms. + * + * Without it a brand-new item scores zero regardless of kind, so the queue + * would rank purely by age for the first moments of anything's life — exactly + * when a live wedged agent most needs to be at the top. Thirty seconds means + * kind dominates early and staleness takes over as things sit, which is the + * behaviour §8 describes ("an agent stuck 8 minutes on a yes/no floats to the + * top"). + */ +const AGE_FLOOR_MS = 30_000; + +/** §8's (blocking-cost × staleness). Higher is more urgent. */ +export function attentionScore(item: AttentionItem, now: number): number { + const age = Math.max(0, now - item.since); + return COST[item.kind] * (age + AGE_FLOOR_MS); +} + +export interface AttentionSources { + sessions: readonly SessionInfo[]; + /** Pending provider dialogs, keyed by session id. */ + uiRequests: Readonly>; + /** Fleet board tasks; only the stopped ones contribute. */ + tasks: readonly FleetTaskWire[]; + /** Resolve a task to its session, when it still has one. */ + taskSession: (task: FleetTaskWire) => SessionInfo | null; +} + +/** + * When an item started blocking. + * + * Approximate, and worth being honest about: neither a `ui_request` nor a + * `FleetTaskWire` carries the moment it began waiting. A session's + * `lastActivityAt` is the closest true signal — a wedged session stops + * producing activity the instant it wedges — and a task falls back to its + * creation time. Both err toward looking OLDER than reality for a task that + * ran a while before stopping, which biases the queue toward surfacing things + * rather than hiding them. That is the right direction for this list. + */ +function sinceFor(iso: string | undefined, fallback: number): number { + if (!iso) return fallback; + const t = Date.parse(iso); + return Number.isFinite(t) ? t : fallback; +} + +/** + * Gather every blocking item across the whole session population. + * + * `now` is the fallback timestamp for anything with no usable time, so an item + * with unknown age reads as brand new rather than as infinitely stale — an + * unknown must not jump the queue. + */ +export function collectAttention(src: AttentionSources, now: number): AttentionItem[] { + const items: AttentionItem[] = []; + const byId = new Map(src.sessions.map((s) => [s.id, s])); + + // Provider dialogs first: they name their own question, so they carry more + // information than the session status that accompanies them. + const asked = new Set(); + for (const [sessionId, reqs] of Object.entries(src.uiRequests)) { + for (const req of reqs) { + const session = byId.get(sessionId); + // A request for a session this client cannot see is not actionable — + // clicking it would go nowhere — so it is skipped rather than shown. + if (!session) continue; + asked.add(sessionId); + items.push({ + key: `ui:${sessionId}:${req.requestId}`, + kind: "question", + sessionId, + label: session.name, + detail: req.title, + since: sinceFor(session.lastActivityAt, now), + }); + } + } + + // Sessions wedged on a tool approval. Skipped when a dialog is already + // listed for that session: they are the same stoppage, and showing both + // would double-count one interruption in a queue whose whole job is to say + // how many things need you. + for (const s of src.sessions) { + if (s.status !== "waiting_approval" || asked.has(s.id)) continue; + items.push({ + key: `approval:${s.id}`, + kind: "approval", + sessionId: s.id, + label: s.name, + detail: "waiting for tool approval", + since: sinceFor(s.lastActivityAt, now), + }); + } + + // Stopped dispatch tasks. + for (const task of src.tasks) { + if (task.status !== "blocked" && task.status !== "failed") continue; + const session = src.taskSession(task); + items.push({ + key: `task:${task.id}`, + kind: task.status, + sessionId: session?.id ?? null, + label: session?.name ?? `${task.kind} ${task.id.slice(0, 8)}`, + detail: task.error ?? (task.status === "blocked" ? "hit the failure limit" : "task failed"), + since: task.createdAt, + }); + } + + return items; +} + +/** + * Rank most-urgent first. + * + * Ties break on `since` (oldest first) and then on `key`, so the order is + * total and stable: a queue that reshuffles equal-priority rows between + * renders is one you cannot click accurately. + */ +export function rankAttention(items: readonly AttentionItem[], now: number): AttentionItem[] { + return [...items].sort((a, b) => { + const d = attentionScore(b, now) - attentionScore(a, now); + if (d !== 0) return d; + if (a.since !== b.since) return a.since - b.since; + return a.key < b.key ? -1 : a.key > b.key ? 1 : 0; + }); +} + +/** Everything in one call — collect, then rank. */ +export function attentionQueue(src: AttentionSources, now: number): AttentionItem[] { + return rankAttention(collectAttention(src, now), now); +} diff --git a/web/src/state/attention.ts b/web/src/state/attention.ts new file mode 100644 index 0000000..989e11c --- /dev/null +++ b/web/src/state/attention.ts @@ -0,0 +1,38 @@ +/** + * The cross-session attention queue, wired to live state (§8). + * + * Thin by design: `lib/attention.ts` owns collection and ranking, and this + * only joins the three sources — the session population, pending provider + * dialogs, and the fleet board — and re-ranks as the clock moves. + */ + +import { createMemo } from "solid-js"; + +import { attentionQueue, type AttentionItem } from "../lib/attention"; +import { nowTick } from "./clock"; +import { fleetBoard, taskSession } from "./fleet"; +import { sessionList } from "./sessions"; +import { allPendingUiRequests } from "./ui-requests"; + +/** + * Ranked "needs you" items, most urgent first. + * + * Depends on `nowTick` because the ranking is (blocking-cost × staleness) — + * an item that nobody touches still climbs as it ages, so the order has to be + * recomputed on the shared clock rather than only when the data changes. + */ +export const attentionItems = createMemo(() => { + const board = fleetBoard(); + return attentionQueue( + { + sessions: sessionList(), + uiRequests: allPendingUiRequests(), + tasks: board.tasks, + taskSession: (t) => taskSession(board, t), + }, + nowTick(), + ); +}); + +/** How many things are waiting on you — for the ambient badge. */ +export const attentionCount = createMemo(() => attentionItems().length); diff --git a/web/src/state/ui-requests.ts b/web/src/state/ui-requests.ts index 77aeea9..6e75cbb 100644 --- a/web/src/state/ui-requests.ts +++ b/web/src/state/ui-requests.ts @@ -50,6 +50,15 @@ export function pendingUiRequest(sessionId: string | null): SessionUiRequestMsg return state.bySession[sessionId]?.[0] ?? null; } +/** + * Every pending dialog, keyed by session — the cross-session attention queue + * (lib/attention.ts) needs the whole population, not one session's slice. + * Reading the store directly keeps it reactive for callers inside a memo. + */ +export function allPendingUiRequests(): Readonly> { + return state.bySession; +} + /** Count of pending dialogs for a session (badge / tests). */ export function pendingUiRequestCount(sessionId: string): number { return state.bySession[sessionId]?.length ?? 0;