Skip to content
Merged
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
136 changes: 136 additions & 0 deletions web/src/components/AttentionInbox.tsx
Original file line number Diff line number Diff line change
@@ -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<AttentionKind, { cls: string; label: string }> = {
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 (
<div class="relative">
<button
type="button"
onClick={() => setOpen((v) => !v)}
aria-expanded={open()}
title={
attentionCount() > 0
? `${attentionCount()} thing(s) waiting on you`
: "Nothing is waiting on you"
}
class={`rounded border px-1.5 py-0.5 font-mono text-[11px] transition ${
attentionCount() > 0
? "border-warn/60 bg-warn/15 text-warn hover:bg-warn/25"
: "border-border bg-bg text-fg-faint hover:text-fg-muted"
}`}
>
{/* Zero is shown, not hidden: a badge that vanishes cannot be trusted
as an at-a-glance "nothing is stuck" signal — you would not know
whether it means clear or broken. */}
{attentionCount()} needs you
</button>

<Show when={open()}>
{/* Click-away backdrop, behind the panel. */}
<div class="fixed inset-0 z-40" onClick={() => setOpen(false)} aria-hidden="true" />
<div class="absolute right-0 z-50 mt-1 max-h-96 w-96 overflow-y-auto rounded border border-border bg-bg-elev shadow-2xl">
<Show
when={attentionItems().length > 0}
fallback={
<p class="px-3 py-4 text-xs text-fg-muted">
Nothing is waiting on you. Agents that wedge on an approval, ask a
question, or hit the failure limit show up here.
</p>
}
>
<ul class="flex flex-col">
<For each={attentionItems()}>
{(item) => <Row item={item} onGo={() => setOpen(false)} />}
</For>
</ul>
</Show>
</div>
</Show>
</div>
);
};

const Row: Component<{ item: AttentionItem; onGo: () => void }> = (props) => {
const style = () => KIND_STYLE[props.item.kind];
const body = (
<>
<div class="flex items-center gap-2">
<span
class={`shrink-0 rounded border px-1 font-mono text-[10px] uppercase tracking-wider ${style().cls}`}
>
{style().label}
</span>
<span class="min-w-0 flex-1 truncate font-mono text-[11px] text-fg">
{props.item.label}
</span>
<span class="shrink-0 font-mono text-[10px] text-fg-faint">
{relativeTime(new Date(props.item.since).toISOString(), nowTick())}
</span>
</div>
<p class="line-clamp-2 pl-1 text-[11px] leading-snug text-fg-muted">
{props.item.detail}
</p>
</>
);

return (
<li class="border-b border-border/40 last:border-b-0">
{/* 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. */}
<Show
when={props.item.sessionId}
fallback={<div class="flex flex-col gap-1 px-3 py-2">{body}</div>}
>
{(id) => (
<button
type="button"
onClick={() => {
focusSession(id());
props.onGo();
}}
class="flex w-full flex-col gap-1 px-3 py-2 text-left transition hover:bg-bg-hover"
>
{body}
</button>
)}
</Show>
</li>
);
};

export default AttentionInbox;
3 changes: 3 additions & 0 deletions web/src/components/StatusBar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@

import { Component, Show } from "solid-js";

import AttentionInbox from "./AttentionInbox";
import HomeToggle from "./HomeToggle";

import {
Expand Down Expand Up @@ -54,6 +55,8 @@ const StatusBar: Component = () => {
<IdentityChip />
<LocalModeChip />
<span class="ml-auto flex items-center gap-3">
{/* Ambient — §8 wants the count always visible, the queue one click away. */}
<AttentionInbox />
<SessionMetrics />
<SearchHotkey />
<SettingsButton />
Expand Down
225 changes: 225 additions & 0 deletions web/src/lib/attention.test.ts
Original file line number Diff line number Diff line change
@@ -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> = {}): 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> = {}): FleetTaskWire => ({
id,
kind: "spawn",
shape: "scout",
status: "blocked",
attempts: 2,
createdAt: NOW,
createdBy: "agent:conductor",
...over,
});

const sources = (over: Partial<AttentionSources> = {}): 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>): 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([]);
});
});
Loading
Loading