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[];
}