diff --git a/apps/ui/src/app.css b/apps/ui/src/app.css index 1aafd2738..18c1795ea 100644 --- a/apps/ui/src/app.css +++ b/apps/ui/src/app.css @@ -824,9 +824,10 @@ .agent-view { display: grid; grid-template-columns: 1fr auto; - grid-template-rows: auto 1fr; + grid-template-rows: auto auto 1fr; grid-template-areas: "header header" + "board board" "body log"; height: 100%; overflow: hidden; @@ -850,6 +851,129 @@ grid-area: header; } +/* Agent-authored board summary and task ledger, on its own row under the header. */ +.agent-board { + grid-area: board; + padding: 0 14px; + border-bottom: 1px solid var(--cx-border); + min-width: 0; + max-height: min(34vh, 300px); + overflow-y: auto; + color: var(--cx-text-dim); + font-size: 11px; + font-family: var(--cx-font-ui); +} + +.agent-board-topline, +.agent-board-task-head, +.agent-board-ref-row, +.agent-board-wait-row, +.agent-board-untracked, +.agent-board-asks { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 5px; +} + +.agent-board-topline { + padding-bottom: 5px; + border-bottom: 1px solid var(--cx-border); +} + +.agent-board-focus { + color: var(--cx-text); +} + +.agent-board-updated { + color: var(--cx-text-faint); +} + +.agent-board-chip { + display: inline-flex; + align-items: center; + padding: 1px 5px; + border: 1px solid var(--cx-border); + color: var(--cx-text-dim); + font-size: 10px; + line-height: 1.5; +} + +.agent-board-halt, +.agent-board-stale { + border-color: var(--cx-warn); + color: var(--cx-warn); +} + +.agent-board-task { + padding: 5px 0; + border-bottom: 1px solid var(--cx-border); +} + +.agent-board-task-head { + color: var(--cx-text); +} + +.agent-board-ref-primary, +.agent-board-ask-ref { + color: var(--cx-accent); +} + +.agent-board-ref-row, +.agent-board-wait-row { + padding: 2px 0 0 12px; +} + +.agent-board-note { + margin: 3px 0 0 12px; + white-space: pre-wrap; + color: var(--cx-text-faint); +} + +.agent-board-wait-chip { + color: var(--cx-st-waiting); +} + +.agent-board-block-ref { + color: var(--cx-text-faint); +} + +.agent-board-subagents { + margin: 3px 0 0 12px; + padding-left: 14px; +} + +.agent-board-section { + padding: 5px 0; +} + +.agent-board-section h3 { + margin: 0 0 4px; + color: var(--cx-text-faint); + font-size: 10px; + text-transform: uppercase; +} + +.agent-board-ask { + display: inline-flex; + align-items: center; + gap: 5px; + padding: 2px 5px; + border: 1px solid var(--cx-border); + background: transparent; + color: var(--cx-text); + font-size: 11px; + cursor: pointer; +} + +.agent-board-ask:hover { + background: var(--cx-bg-hover); +} + +.agent-board-ask[data-answered="true"] { + color: var(--cx-text-faint); +} + .av-header .av-name { font-size: 14px; } @@ -3290,9 +3414,10 @@ @container view-panel (width < 560px) { .agent-view { grid-template-columns: minmax(0, 1fr); - grid-template-rows: auto minmax(0, 1fr) fit-content(40%); + grid-template-rows: auto auto minmax(0, 1fr) fit-content(40%); grid-template-areas: "header" + "board" "body" "log"; } diff --git a/apps/ui/src/components/AgentBoard.test.tsx b/apps/ui/src/components/AgentBoard.test.tsx new file mode 100644 index 000000000..6c0e33494 --- /dev/null +++ b/apps/ui/src/components/AgentBoard.test.tsx @@ -0,0 +1,171 @@ +import { expect, test } from "bun:test"; +import { render } from "@solidjs/testing-library"; +import type { AgentBoard } from "../stub-data"; +import { AgentBoardView } from "./AgentBoard"; + +const BOARD: AgentBoard = { + agentId: "acc-compass-ui", + handle: "compass-ui", + focusTaskKey: "task-a", + halt: { kind: "blocked", ref: "RIG-12", reason: "Needs review" }, + tasks: [ + { + key: "task-a", + title: "Ship status board", + primaryRef: "RigelBuild/compass#12", + refs: ["RigelBuild/compass#12", "RIG-9"], + state: "blocked", + note: "Waiting for checks", + waiting: [ + { on: "operator", what: "Review the patch", blocks: ["RIG-9"] }, + { on: "agent", agentHandle: "builder", blocks: ["RIG-12"] }, + { on: "external", blocks: [] }, + ], + subagents: [ + { + id: "sub-1", + agent: "builder", + description: "Run integration tests", + status: "working", + startedAtUnixMs: 10, + }, + ], + }, + ], + refs: [ + { + ref: "RigelBuild/compass#12", + kind: "pr", + title: "Agent board panel", + url: "https://github.com/RigelBuild/compass/pull/12", + gate: "awaiting_enqueue", + draft: true, + checksState: "success", + stackPosition: 2, + stackTipRef: "RigelBuild/compass#13", + owned: true, + staleApproval: false, + }, + { + ref: "RigelBuild/compass#13", + kind: "pr", + title: "Next stack item", + url: "https://github.com/RigelBuild/compass/pull/13", + draft: false, + stackPosition: 3, + stackTipRef: "RigelBuild/compass#13", + owned: true, + staleApproval: false, + }, + { + ref: "RIG-9", + kind: "issue", + title: "Review board UX", + draft: false, + owned: true, + staleApproval: false, + }, + { + ref: "RIG-20", + kind: "issue", + title: "Untracked issue", + url: "https://linear.app/RIG/issue/RIG-20", + draft: false, + owned: true, + staleApproval: false, + }, + ], + untrackedRefs: ["RIG-20"], + asks: [ + { + askId: "ask-answered", + messageId: "message-answered", + channelId: "channel-answered", + header: "Answered question", + answered: true, + postedAtUnixMs: 20, + }, + { + askId: "ask-pending", + messageId: "message-pending", + channelId: "channel-pending", + header: "Choose a rollout", + answered: false, + postedAtUnixMs: 10, + }, + ], + writtenAtUnixMs: Date.now() - 120_000, + stale: true, +}; + +test("AgentBoardView renders board details and pending asks first", () => { + const { container } = render(() => ); + const text = container.textContent ?? ""; + for (const value of [ + "blocked · RIG-12 · Needs review", + "Ship status board", + "updated 2 min ago", + "Stale", + "awaiting enqueue", + "checks: success", + "draft", + "stack 2/3", + "tip RigelBuild/compass#13", + "Review the patch", + "operator", + "@builder", + "external", + "RIG-9", + "Run integration tests", + "Choose a rollout", + "Answered question", + "Untracked", + "RIG-20", + ]) + expect(text).toContain(value); + const asks = [...container.querySelectorAll(".agent-board-ask")]; + expect(asks.map((ask) => ask.textContent)).toEqual([ + "Choose a rollout", + "Answered question", + ]); +}); + +const withPrimaryUrl = (url: string): AgentBoard => ({ + ...BOARD, + refs: BOARD.refs.map((ref) => + ref.ref === "RigelBuild/compass#12" ? { ...ref, url } : ref, + ), +}); + +test("AgentBoardView opens a safe primary ref externally", () => { + const opened: string[] = []; + const open = window.open; + window.open = (url) => { + opened.push(String(url)); + return null; + }; + try { + const { container } = render(() => ); + const link = container.querySelector( + "a.agent-board-ref-primary", + ); + expect(link?.getAttribute("href")).toBe( + "https://github.com/RigelBuild/compass/pull/12", + ); + expect(link?.getAttribute("rel")).toBe("noreferrer noopener"); + link?.click(); + expect(opened).toEqual(["https://github.com/RigelBuild/compass/pull/12"]); + } finally { + window.open = open; + } +}); + +test("AgentBoardView renders an unsafe primary ref as plain text", () => { + const { container } = render(() => ( + + )); + expect(container.querySelector("a.agent-board-ref-primary")).toBeNull(); + expect( + container.querySelector("span.agent-board-ref-primary")?.textContent, + ).toBe("RigelBuild/compass#12"); +}); diff --git a/apps/ui/src/components/AgentBoard.tsx b/apps/ui/src/components/AgentBoard.tsx new file mode 100644 index 000000000..36ab2e26f --- /dev/null +++ b/apps/ui/src/components/AgentBoard.tsx @@ -0,0 +1,274 @@ +import { CommsService } from "@compass/client"; +import { skipToken } from "@tanstack/solid-query"; +import { type Component, createEffect, For, Show, untrack } from "solid-js"; +import { useStore } from "../context"; +import { adaptAgentBoard } from "../live/adapt"; +import { createConnectQuery } from "../live/query"; +import { openExternal } from "../open-external"; +import { safeHref } from "../safe-url"; +import type { + AgentBoard, + BoardAsk, + BoardRef, + BoardTask, + BoardWaiting, +} from "../stub-data"; + +/** AgentBoardView renders one agent's board details without performing a board read. */ +export const AgentBoardView: Component<{ + board: AgentBoard; + onOpenAsk?: (ask: BoardAsk) => void; +}> = (props) => { + const tasks = () => props.board.tasks; + const focus = () => + props.board.tasks.find((task) => task.key === props.board.focusTaskKey); + const asks = () => + [...props.board.asks].sort( + (left, right) => Number(left.answered) - Number(right.answered), + ); + const ageMinutes = () => + Math.max( + 0, + Math.floor((Date.now() - props.board.writtenAtUnixMs) / 60_000), + ); + const refsFor = (task: BoardTask) => + task.refs.flatMap((ref) => { + const boardRef = props.board.refs.find((item) => item.ref === ref); + return boardRef ? [boardRef] : []; + }); + const primaryRef = (task: BoardTask): BoardRef | undefined => + task.primaryRef + ? props.board.refs.find((ref) => ref.ref === task.primaryRef) + : undefined; + const stackSize = (ref: BoardRef) => { + if (!ref.stackTipRef) return undefined; + const members = props.board.refs.filter( + (item) => item.stackTipRef === ref.stackTipRef && item.stackPosition, + ); + return Math.max( + ref.stackPosition ?? 0, + ...members.map((item) => item.stackPosition ?? 0), + ); + }; + const waitingLabel = (waiting: BoardWaiting) => + waiting.on === "agent" + ? `@${waiting.agentHandle ?? "agent"}` + : (waiting.on ?? "waiting"); + return ( +
+
+ {props.board.halt && ( + + {props.board.halt.kind} + {props.board.halt.ref && <> · {props.board.halt.ref}} + {props.board.halt.reason && <> · {props.board.halt.reason}} + + )} + + {(task) => {task().title}} + + updated {ageMinutes()} min ago + + Stale + +
+
+ + {(task) => { + const primary = () => primaryRef(task); + const primaryHref = () => safeHref(primary()?.url); + return ( +
+
+ + {(href) => ( + { + event.preventDefault(); + openExternal(href()); + }} + > + {task.primaryRef} + + )} + + + {(ref) => ( + {ref()} + )} + + {task.title} + + {task.state ?? "unknown"} + +
+ 0}> +
+ + {(ref) => ( +
+ {ref.ref} + + {(gate) => ( + + {gate().replaceAll("_", " ")} + + )} + + + {(state) => ( + + checks: {state()} + + )} + + + draft + + + {(position) => ( + + stack {position()}/{stackSize(ref)} + + )} + + + {(tip) => ( + tip {tip()} + )} + + + stale approval + +
+ )} +
+
+
+ + {(note) =>

{note()}

} +
+ 0}> +
+ + {(waiting) => ( +
+ + {waitingLabel(waiting)} + + + {(what) => {what()}} + + + {(ref) => ( + + blocks {ref} + + )} + +
+ )} +
+
+
+ 0}> +
    + + {(subagent) => ( +
  • + + {subagent.status} + + {subagent.agent}{" "} + {subagent.description} +
  • + )} +
    +
+
+
+ ); + }} +
+
+ 0}> +
+

Asks

+
+ + {(ask) => ( + + )} + +
+
+
+ 0}> +
+

Untracked

+
+ + {(ref) => {ref}} + +
+
+
+
+ ); +}; + +/** AgentBoardPanel loads an agent's board from the shared live query cache. */ +export const AgentBoardPanel: Component<{ agentId: string }> = (props) => { + const store = useStore(); + // An ask opens the topic holding its message; an unloaded message opens its channel. + const openAsk = (ask: BoardAsk) => { + const message = store.messages().find((item) => item.id === ask.messageId); + if (message) store.openTopic(message.topicId, ask.channelId); + else store.openChannel(ask.channelId); + }; + const deps = store.connectQueryDeps; + if (!deps) return null; + const query = createConnectQuery( + CommsService.method.listAgentBoards, + () => { + const agent = store + .agents() + .find((item) => item.account.id === props.agentId); + return agent + ? { agentHandles: [agent.account.handle], limit: 1 } + : skipToken; + }, + { ...deps, refetchInterval: 60_000 }, + ); + // The mount read covers the events so far; each later AgentBoardChanged re-reads. + const events = () => store.agentView(props.agentId)?.boardEvents ?? 0; + let seen = untrack(events); + createEffect(events, (next) => { + if (next === seen) return; + seen = next; + void query.refetch(); + }); + return ( + + {(board) => ( + + )} + + ); +}; diff --git a/apps/ui/src/components/AgentBoardPanel.live.test.tsx b/apps/ui/src/components/AgentBoardPanel.live.test.tsx new file mode 100644 index 000000000..bfc40a12c --- /dev/null +++ b/apps/ui/src/components/AgentBoardPanel.live.test.tsx @@ -0,0 +1,155 @@ +import { describe, expect, test } from "bun:test"; +import { + AccountSchema, + AgentAccountSchema, + AgentBoardSchema, + BoardAskSchema, + ChannelKind, + ChannelSchema, + CommsService, + create, + createRouterTransport, + UserAccountSchema, +} from "@compass/client"; +import { render } from "@solidjs/testing-library"; +import { flush } from "solid-js"; +import { StoreContext } from "../context"; +import { createFakeComms, type FakeComms } from "../live/comms-fake"; +import { type AppStore, createAppStore } from "../store"; +import { testQueryClient } from "../test-support"; +import { AgentBoardPanel } from "./AgentBoard"; + +const CALLER = "acc-me"; +const AGENT = "acc-cook"; +const ASK_CHANNEL = "chan-asks"; + +const wireUser = create(AccountSchema, { + id: CALLER, + handle: CALLER, + displayName: CALLER, + kind: { case: "user", value: create(UserAccountSchema, {}) }, +}); +const wireAgent = create(AccountSchema, { + id: AGENT, + handle: "cook", + displayName: "cook", + kind: { + case: "agent", + value: create(AgentAccountSchema, { ownerUserId: CALLER }), + }, +}); +const wireChannel = create(ChannelSchema, { + id: ASK_CHANNEL, + name: "asks", + kind: ChannelKind.CHANNEL, + memberAccountIds: [CALLER, AGENT], + subscriberAccountIds: [CALLER, AGENT], +}); + +const board = (writtenAtUnixMs: bigint) => + create(AgentBoardSchema, { + agentAccountId: AGENT, + handle: "cook", + writtenAtUnixMs, + asks: [ + create(BoardAskSchema, { + askId: "ask-1", + messageId: "msg-unloaded", + channelId: ASK_CHANNEL, + header: "Pick a plan", + }), + ], + }); + +/** Pump microtasks and Solid flushes until `ready` holds; fail after a bounded count. */ +async function until(ready: () => boolean): Promise { + for (let i = 0; i < 5000 && !ready(); i++) { + await Promise.resolve(); + flush(); + } + expect(ready()).toBe(true); +} + +async function mountPanel(): Promise<{ + store: AppStore; + fake: FakeComms; + container: HTMLElement; + reads: () => number; +}> { + let reads = 0; + const transport = createRouterTransport(({ service }) => + service(CommsService, { + listAgentBoards: (request) => { + reads++; + expect(request.agentHandles).toEqual(["cook"]); + return { boards: [board(BigInt(reads))] }; + }, + }), + ); + const fake = createFakeComms({ + accounts: [wireUser, wireAgent], + channels: [wireChannel], + }); + let store!: AppStore; + const { container } = render(() => { + store = createAppStore({ + comms: fake.client, + transport, + callerId: CALLER, + queryClient: testQueryClient(), + }); + return ( + + + + ); + }); + await until(() => reads > 0 && container.textContent !== ""); + return { store, fake, container, reads: () => reads }; +} + +describe("AgentBoardPanel", () => { + test("an agentBoardChanged for the agent re-reads its board", async () => { + const { store, fake, container, reads } = await mountPanel(); + expect(reads()).toBe(1); + expect(container.textContent).toContain("Pick a plan"); + await fake.emit( + { + case: "agentBoardChanged", + value: { agentAccountId: AGENT, board: board(99n) }, + }, + 1n, + ); + await until(() => reads() >= 2); + // The roster re-read that follows the event must not read the board again. + await until(() => store.agentView(AGENT)?.board?.writtenAtUnixMs === 99); + for (let i = 0; i < 200; i++) await Promise.resolve(); + flush(); + expect(reads()).toBe(2); + }); + + test("an automatic patch that keeps the write time still re-reads", async () => { + const { store, fake, reads } = await mountPanel(); + for (const seq of [1n, 2n]) { + await fake.emit( + { + case: "agentBoardChanged", + value: { agentAccountId: AGENT, board: board(50n) }, + }, + seq, + ); + await until(() => reads() === Number(seq) + 1); + } + expect(store.agentView(AGENT)?.board?.writtenAtUnixMs).toBe(50); + }); + + test("an ask whose message is not loaded opens its channel", async () => { + const { store, container } = await mountPanel(); + container.querySelector(".agent-board-ask")?.click(); + await until(() => store.focusedView().route().view === "channel"); + expect(store.focusedView().route()).toMatchObject({ + view: "channel", + channelId: ASK_CHANNEL, + }); + }); +}); diff --git a/apps/ui/src/components/AgentView.tsx b/apps/ui/src/components/AgentView.tsx index d8c9f94cc..1391c5c95 100644 --- a/apps/ui/src/components/AgentView.tsx +++ b/apps/ui/src/components/AgentView.tsx @@ -9,6 +9,7 @@ import { import { useStore } from "../context"; import type { Agent, Terminal } from "../stub-data"; import { useView } from "../view-scope"; +import { AgentBoardPanel } from "./AgentBoard"; import { ChannelView } from "./ChannelView"; import { Glyph } from "./Glyph"; import { LogPanel } from "./LogPanel"; @@ -237,6 +238,7 @@ export const AgentView: Component = () => { Share +
diff --git a/apps/ui/src/keyboard/commands.ts b/apps/ui/src/keyboard/commands.ts index ee760535d..f4b642ac4 100644 --- a/apps/ui/src/keyboard/commands.ts +++ b/apps/ui/src/keyboard/commands.ts @@ -53,6 +53,7 @@ export type DestinationKind = | "issue" | "pr" | "message" + | "task" | "view"; /** diff --git a/apps/ui/src/keyboard/destination-surface.ts b/apps/ui/src/keyboard/destination-surface.ts index 43e96a4f5..6c2bb6729 100644 --- a/apps/ui/src/keyboard/destination-surface.ts +++ b/apps/ui/src/keyboard/destination-surface.ts @@ -11,25 +11,27 @@ export function setSearchDebounceMsForTest(ms: number): void { export function resetSearchDebounceForTest(): void { SEARCH_DEBOUNCE_MS = DEFAULT_SEARCH_DEBOUNCE_MS; } -export const KIND_LABELS: Record = { +export const KIND_LABELS = { view: "Views", agent: "Agents", channel: "Channels", topic: "Topics", issue: "Issues", message: "Messages", + task: "Tasks", pr: "Pull requests", -}; +} satisfies Record; -export const KIND_ORDER: readonly DestinationKind[] = [ +export const KIND_ORDER = [ "view", "agent", "channel", "topic", + "task", "issue", "pr", "message", -]; +] satisfies readonly DestinationKind[]; export interface DestinationSurfaceRow { readonly key: string; diff --git a/apps/ui/src/keyboard/destinations.test.ts b/apps/ui/src/keyboard/destinations.test.ts index f68c7e0eb..0bb4c267d 100644 --- a/apps/ui/src/keyboard/destinations.test.ts +++ b/apps/ui/src/keyboard/destinations.test.ts @@ -1,7 +1,10 @@ import { describe, expect, test } from "bun:test"; import { + AgentBoardSchema, AskQuestionSchema, AskSchema, + BoardTaskSchema, + BoardTaskState, CommsService, CompassService, create, @@ -581,6 +584,88 @@ describe("createStoreDestinationProviders", () => { ]); }); }); + test("the tasks provider finds a reference and opens its agent", async () => { + await withStoreAsync(async (store) => { + const task = create(BoardTaskSchema, { + key: "task-a", + title: "Ship the board panel", + primaryRef: "RIG-123", + state: BoardTaskState.ACTIVE, + }); + const board = create(AgentBoardSchema, { + agentAccountId: "acc-compass-ui", + handle: "compass-ui", + tasks: [task], + matchedTaskKeys: ["task-a"], + }); + let rejectedRefCalls = 0; + const transport = createRouterTransport(({ service }) => { + service(CommsService, { + listAgentBoards: (request) => { + if (request.ref) { + rejectedRefCalls++; + throw new Error("InvalidArgument: unsupported ref"); + } + return { boards: [board] }; + }, + searchMessages: () => ({ messages: [] }), + }); + service(CompassService, { + searchIssues: () => ({ issues: [] }), + }); + }); + const clients = { + comms: createCommsClient(transport), + compass: createCompassClient(transport), + }; + const provider = createStoreDestinationProviders(store, clients).find( + (item) => item.id === "tasks", + ); + const tasks = await provider?.query("RIG-123"); + expect(rejectedRefCalls).toBe(1); + expect(tasks?.map((row) => [row.id, row.title, row.kind])).toEqual([ + ["acc-compass-ui:task-a", "RIG-123 · Ship the board panel", "task"], + ]); + tasks?.[0]?.navigate(); + await flush(); + expect(store.selectedAgentId()).toBe("acc-compass-ui"); + }); + }); + test("the tasks provider keeps server order across boards", async () => { + await withStoreAsync(async (store) => { + const boardOf = (agent: string, keys: string[]) => + create(AgentBoardSchema, { + agentAccountId: agent, + handle: agent, + tasks: keys.map((key) => + create(BoardTaskSchema, { key, title: key }), + ), + matchedTaskKeys: keys, + }); + const transport = createRouterTransport(({ service }) => { + service(CommsService, { + listAgentBoards: () => ({ + boards: [boardOf("acc-a", ["a1", "a2"]), boardOf("acc-b", ["b1"])], + }), + searchMessages: () => ({ messages: [] }), + }); + service(CompassService, { searchIssues: () => ({ issues: [] }) }); + }); + const clients = { + comms: createCommsClient(transport), + compass: createCompassClient(transport), + }; + const providers = createStoreDestinationProviders(store, clients).filter( + (item) => item.id === "tasks", + ); + const groups = await queryDestinations(providers, "ship", 1, () => 1); + expect(groups?.get("task")?.map((row) => row.title)).toEqual([ + "a1", + "a2", + "b1", + ]); + }); + }); }); describe("queryDestinations", () => { diff --git a/apps/ui/src/keyboard/destinations.ts b/apps/ui/src/keyboard/destinations.ts index f3f9dae5f..6351c48fa 100644 --- a/apps/ui/src/keyboard/destinations.ts +++ b/apps/ui/src/keyboard/destinations.ts @@ -1,6 +1,6 @@ /** Destination providers for palette and top-bar search (RIG-2483, A4/D9). - * Agents, channels, topics, and views are local. Issues, PRs, and messages use - * live search clients; without clients, issues and PRs fall back to the held board. + * Agents, channels, topics, and views are local. Issues, PRs, messages, and tasks + * use live search clients; without clients, issues and PRs fall back to the held board. * * Providers are async; `queryDestinations` isolates failures and drops stale * results. Local rows use fuzzy scores; remote rows retain server rank. @@ -8,7 +8,7 @@ import type { Message } from "@compass/client"; import { prRows } from "../board"; -import { adaptIssue } from "../live/adapt"; +import { adaptAgentBoard, adaptIssue } from "../live/adapt"; import type { LiveClients } from "../live/client"; import type { AppStore } from "../store"; import type { Issue } from "../stub-data"; @@ -145,7 +145,54 @@ export function createStoreDestinationProviders( inFlight = entry; return entry.promise; }; + const searchBoards = async (query: string, ref: string) => { + if (!clients) return []; + const search = (request: { query?: string; ref?: string }) => + clients.comms.listAgentBoards({ ...request, limit: 20 }); + const queryRead = search({ query }); + const refRead = ref ? search({ ref }).catch(() => undefined) : undefined; + const [queryResponse, refResponse] = await Promise.all([ + queryRead, + refRead, + ]); + const unique = new Set(); + const destinations: Destination[] = []; + for (const response of [queryResponse, refResponse]) { + if (!response) continue; + for (const wireBoard of response.boards) { + const board = adaptAgentBoard(wireBoard); + for (const key of wireBoard.matchedTaskKeys) { + const task = board.tasks.find((candidate) => candidate.key === key); + if (!task) continue; + const id = `${board.agentId}:${key}`; + if (unique.has(id)) continue; + unique.add(id); + destinations.push({ + id, + title: [task.primaryRef, task.title].filter(Boolean).join(" · "), + kind: "task", + // Server order across boards; a task both reads return keeps its first place. + score: -destinations.length, + navigate: () => store.openAgent(board.agentId), + }); + } + } + } + return destinations; + }; return [ + { + id: "tasks", + query: async (input) => { + const query = input.trim(); + if (!query || !clients) return []; + const looksLikeRef = + /^(?:[A-Z][A-Z0-9]{1,9}-\d+|[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+#\d+)$/.test( + query, + ); + return searchBoards(query, looksLikeRef ? query : ""); + }, + }, { id: "agents", query: (input) => diff --git a/apps/ui/src/live/adapt.ts b/apps/ui/src/live/adapt.ts index b700b3cb6..e74ff982d 100644 --- a/apps/ui/src/live/adapt.ts +++ b/apps/ui/src/live/adapt.ts @@ -753,6 +753,8 @@ export interface AgentPresenceInfo { readonly lifecycle?: AgentState; readonly activity?: string; readonly board?: AgentBoardSummary; + /** Counts AgentBoardChanged events; an automatic patch keeps the board's write time. */ + readonly boardEvents?: number; } /** Map one wire `RosterEntry` to its presence-map entry: `[agentAccountId, diff --git a/apps/ui/src/live/comms-state.test.ts b/apps/ui/src/live/comms-state.test.ts index 9088b2448..94e29df3a 100644 --- a/apps/ui/src/live/comms-state.test.ts +++ b/apps/ui/src/live/comms-state.test.ts @@ -395,6 +395,7 @@ describe("applyEvent — board update", () => { writtenAtUnixMs: 20, stale: false, }, + boardEvents: 1, }); expect(next.presence).not.toBe(seeded.presence); }); diff --git a/apps/ui/src/live/comms-state.ts b/apps/ui/src/live/comms-state.ts index 071707518..aa5383a05 100644 --- a/apps/ui/src/live/comms-state.ts +++ b/apps/ui/src/live/comms-state.ts @@ -259,6 +259,7 @@ export function applyEvent(state: CommsState, event: CommsEvent): CommsState { presence.set(event.accountId, { ...prev, board: applyBoardEvent(prev?.board, event.board), + boardEvents: (prev?.boardEvents ?? 0) + 1, }); return { ...state, presence }; } diff --git a/apps/ui/src/live/query.ts b/apps/ui/src/live/query.ts index 6a9668410..7a0b5ec47 100644 --- a/apps/ui/src/live/query.ts +++ b/apps/ui/src/live/query.ts @@ -41,11 +41,14 @@ export function createConnectQuery< >( schema: DescMethodUnary, input: () => MessageInitShape | SkipToken, - opts: ConnectQueryDeps, + opts: ConnectQueryDeps & { readonly refetchInterval?: number }, ): CreateQueryResult> { return useQuery( () => ({ ...createQueryOptions(schema, input(), { transport: opts.transport }), + ...(opts.refetchInterval === undefined + ? {} + : { refetchInterval: opts.refetchInterval }), }), () => opts.queryClient, ); diff --git a/apps/ui/src/roster.ts b/apps/ui/src/roster.ts index 1b725b041..50cd7720e 100644 --- a/apps/ui/src/roster.ts +++ b/apps/ui/src/roster.ts @@ -38,6 +38,7 @@ export function joinAgents( activity: info?.activity, runtime: runtime.get(account.id), board: info?.board, + boardEvents: info?.boardEvents, terminals: [], }); } diff --git a/apps/ui/src/store.ts b/apps/ui/src/store.ts index 0c1982407..5110fbc28 100644 --- a/apps/ui/src/store.ts +++ b/apps/ui/src/store.ts @@ -57,7 +57,7 @@ import { adaptMessage } from "./live/adapt"; import { loadRefLinkRules, probeServer } from "./live/client"; import { type CommsState, EMPTY_COMMS_STATE } from "./live/comms-state"; import { type AccountSession, runEventStream } from "./live/events"; -import { createConnectQuery } from "./live/query"; +import { type ConnectQueryDeps, createConnectQuery } from "./live/query"; import { appendSessionEvent, isTerminalSessionState, @@ -244,6 +244,8 @@ export interface TourClient { * type rather than coupling to `ReturnType`. */ export interface AppStore { + /** The shared live transport and query cache; absent when the app is offline. */ + readonly connectQueryDeps: ConnectQueryDeps | undefined; /** Per-agent most recent focused-view open time, persisted per workspace. */ lastOpened: Accessor>; // ── View routing ── @@ -2584,5 +2586,8 @@ export function createAppStore(options: AppStoreOptions): AppStore { assignedIssues, trackerConfig: () => DEFAULT_TRACKER_CONFIG, modelRegistry, + connectQueryDeps: options.transport + ? { transport: options.transport, queryClient: options.queryClient } + : undefined, }; } diff --git a/apps/ui/src/stub-data.ts b/apps/ui/src/stub-data.ts index 7b71b96ad..7dc19b87e 100644 --- a/apps/ui/src/stub-data.ts +++ b/apps/ui/src/stub-data.ts @@ -472,6 +472,8 @@ export interface Agent { /** UI-only. Optional for a live agent. */ cwd?: string; board?: AgentBoardSummary; + /** Live-only: AgentBoardChanged events seen for this agent. */ + boardEvents?: number; /** Fixture-only. */ terminals: Terminal[]; }