feat(app): search history by workspace, agent, and branch (#2995)

History was a date-grouped list you scrolled. Finding a session from last
week meant eyeballing every row. This adds a search field to the existing
filter rail, next to the host picker.

Search runs on the daemon, not the client. The daemon already holds every
persisted agent in memory, so it ranks the whole history and the client
never has to warn that it only looked at the pages it had loaded.

A searched response is one ranked page with no cursor. The global top K is
always contained in the union of each host's own top K, so merging per-host
pages and slicing back to the limit is the exact global answer — no k-way
merge, and no offset into a ranking that is recomputed per request, which
would duplicate and skip rows as history changes underneath it. Past result
200 of a relevance order the answer is a narrower query, so the footer says
that instead of offering a "Load more" that cannot deliver.

Matching is the app's existing picker matcher, moved to the protocol package
so app and daemon share one implementation instead of drifting into two
"why didn't this show up?" rule sets. Typo tolerance is opt-in, so the
comboboxes keep exact narrowing; the budget is per token, and four-character
tokens get transpositions only, because at that length a free substitution
turns "main" into "mail", "maid" and "rain".

Ranked rows carry the spans they matched on, so results can mark them. The
subsequence and typo tiers match characters the eye would never find
unaided, and an unexplained row reads as a bug. The ranker computes the
spans anyway; sending them keeps the client from deriving a second opinion
that could disagree with the ranking it is explaining.

Hosts that fail or are offline are now named above the list. Without that,
"No sessions match" is a claim the app has no basis for.

Gated on server_info.features.agentHistorySearch, checked once against the
connected hosts. Reachability is reported by the banner instead, so one
offline host in the registry no longer hides the field.
This commit is contained in:
Mohamed Boudra
2026-08-08 10:24:51 +02:00
committed by GitHub
parent cc5ce1038d
commit b167ee02e1
38 changed files with 2549 additions and 253 deletions
@@ -17,11 +17,13 @@ type UseAgentHistoryModule = typeof import("./use-agent-history");
let fetchAgentHistoryBatch: UseAgentHistoryModule["fetchAgentHistoryBatch"];
let fetchAgentHistoryPage: UseAgentHistoryModule["fetchAgentHistoryPage"];
let collectAgentHistoryHostErrors: UseAgentHistoryModule["collectAgentHistoryHostErrors"];
beforeAll(async () => {
const module = await import("./use-agent-history");
fetchAgentHistoryBatch = module.fetchAgentHistoryBatch;
fetchAgentHistoryPage = module.fetchAgentHistoryPage;
collectAgentHistoryHostErrors = module.collectAgentHistoryHostErrors;
});
type FetchAgentHistory = DaemonClient["fetchAgentHistory"];
@@ -63,6 +65,7 @@ function historyPayload(input: {
entries: FetchAgentHistoryEntry[];
hasMore?: boolean;
nextCursor?: string | null;
searchTruncated?: boolean;
}): FetchAgentHistoryResult {
return {
requestId: "req_history",
@@ -72,6 +75,7 @@ function historyPayload(input: {
prevCursor: null,
hasMore: input.hasMore ?? false,
},
...(input.searchTruncated === undefined ? {} : { searchTruncated: input.searchTruncated }),
};
}
@@ -81,6 +85,7 @@ function historyEntry(input: {
updatedAt: string;
title?: string | null;
archivedAt?: string | null;
searchScore?: number;
}): FetchAgentHistoryEntry {
return {
agent: {
@@ -130,6 +135,7 @@ function historyEntry(input: {
mainRepoRoot: null,
},
},
...(input.searchScore === undefined ? {} : { searchScore: input.searchScore }),
};
}
@@ -282,6 +288,276 @@ describe("fetchAgentHistoryPage", () => {
]);
});
it("sends the query to the daemon rather than filtering the page locally", async () => {
const client = createClient([
historyPayload({
entries: [
historyEntry({
id: "match",
cwd: "/repo/a",
updatedAt: "2026-04-01T10:00:00.000Z",
title: "Add Stripe billing",
searchScore: 1000,
}),
],
}),
]);
const page = await fetchAgentHistoryPage({
client,
serverId: "server-1",
cursor: null,
search: "stripe",
});
expect(client.calls[0]?.search).toBe("stripe");
expect(page.searchScoreByAgentKey).toEqual({ "server-1:match": 1000 });
});
it("keeps per-host scores apart when two hosts issue the same agent id", async () => {
const sharedId = "collision";
const serverAClient = createClient([
historyPayload({
entries: [
historyEntry({
id: sharedId,
cwd: "/repo/a",
updatedAt: "2026-04-09T10:00:00.000Z",
title: "Weak match on A",
searchScore: 4000,
}),
],
}),
]);
const serverBClient = createClient([
historyPayload({
entries: [
historyEntry({
id: sharedId,
cwd: "/repo/b",
updatedAt: "2026-04-01T10:00:00.000Z",
title: "Strong match on B",
searchScore: 1000,
}),
],
}),
]);
const page = await fetchAgentHistoryBatch({
hosts: [
{ serverId: "server-a", serverLabel: "MacBook", client: serverAClient },
{ serverId: "server-b", serverLabel: "Linux box", client: serverBClient },
] satisfies AgentHistoryHost[],
cursorByServerId: null,
search: "match",
});
expect(page.searchScoreByAgentKey).toEqual({
"server-a:collision": 4000,
"server-b:collision": 1000,
});
expect(page.agents.map((agent) => agent.title)).toEqual([
"Strong match on B",
"Weak match on A",
]);
});
it("reports truncation when a host had more matches than its page could hold", async () => {
const client = createClient([
historyPayload({
entries: [
historyEntry({
id: "top",
cwd: "/repo/a",
updatedAt: "2026-04-01T10:00:00.000Z",
title: "Top match",
searchScore: 1000,
}),
],
searchTruncated: true,
}),
]);
const page = await fetchAgentHistoryBatch({
hosts: [
{ serverId: "server-a", serverLabel: "MacBook", client },
] satisfies AgentHistoryHost[],
cursorByServerId: null,
search: "match",
});
expect(page.isSearchTruncated).toBe(true);
// A ranked response promises no next page, so nothing can ask for one.
expect(page.pageInfoByServerId["server-a"]).toEqual({
nextCursor: null,
prevCursor: null,
hasMore: false,
});
});
it("reports truncation when two complete host pages overflow the merge", async () => {
// Neither host is locally truncated; together they exceed what the merged
// list can show, and the footer has to say so.
const buildHost = (serverId: string, count: number) =>
createClient([
historyPayload({
entries: Array.from({ length: count }, (_, index) =>
historyEntry({
id: `${serverId}-${index}`,
cwd: `/repo/${serverId}`,
updatedAt: "2026-04-01T10:00:00.000Z",
title: `Match ${index}`,
searchScore: 1000 + index,
}),
),
}),
]);
const page = await fetchAgentHistoryBatch({
hosts: [
{ serverId: "server-a", serverLabel: "MacBook", client: buildHost("server-a", 150) },
{ serverId: "server-b", serverLabel: "Linux box", client: buildHost("server-b", 150) },
] satisfies AgentHistoryHost[],
cursorByServerId: null,
search: "match",
});
expect(page.isSearchTruncated).toBe(true);
expect(page.agents).toHaveLength(200);
});
it("names the host that failed instead of quietly shortening the list", async () => {
const workingClient = createClient([
historyPayload({
entries: [
historyEntry({
id: "reachable",
cwd: "/repo/a",
updatedAt: "2026-04-01T10:00:00.000Z",
title: "Reachable match",
searchScore: 1000,
}),
],
}),
]);
const page = await fetchAgentHistoryBatch({
hosts: [
{ serverId: "server-a", serverLabel: "MacBook", client: workingClient },
{ serverId: "server-b", serverLabel: "Linux box", client: createFailingClient() },
] satisfies AgentHistoryHost[],
cursorByServerId: null,
search: "match",
});
expect(page.hostErrors).toEqual([{ serverId: "server-b", serverName: "Linux box" }]);
expect(page.agents.map((agent) => agent.id)).toEqual(["reachable"]);
});
it("keeps naming a host that failed page one when another host loads page two", async () => {
// A host that rejects the first page contributes no cursor, so it is never
// asked again. Reading only the newest page would drop its error while its
// history stays missing, so the projection has to see every page.
const workingClient = createClient([
historyPayload({
entries: [
historyEntry({ id: "page-1", cwd: "/repo/a", updatedAt: "2026-04-02T10:00:00.000Z" }),
],
hasMore: true,
nextCursor: "cursor-2",
}),
historyPayload({
entries: [
historyEntry({ id: "page-2", cwd: "/repo/a", updatedAt: "2026-04-01T10:00:00.000Z" }),
],
}),
]);
const hosts = [
{ serverId: "server-a", serverLabel: "MacBook", client: workingClient },
{ serverId: "server-b", serverLabel: "Linux box", client: createFailingClient() },
] satisfies AgentHistoryHost[];
const firstPage = await fetchAgentHistoryBatch({ hosts, cursorByServerId: null });
const secondPage = await fetchAgentHistoryBatch({
hosts,
cursorByServerId: { "server-a": "cursor-2" },
});
// Only the healthy host is re-fetched, so the second page carries no error
// of its own. That is exactly why the screen cannot read the newest page.
expect(secondPage.hostErrors).toEqual([]);
expect(secondPage.agents.map((agent) => agent.id)).toEqual(["page-2"]);
expect(
collectAgentHistoryHostErrors({
pages: [firstPage, secondPage],
unreachableHosts: [],
}),
).toEqual([{ serverId: "server-b", serverName: "Linux box" }]);
});
it("reports a host that is not connected alongside one whose request failed", () => {
expect(
collectAgentHistoryHostErrors({
pages: [{ hostErrors: [{ serverId: "server-b", serverName: "Linux box" }] }],
unreachableHosts: [{ serverId: "server-c", serverName: "Offline box" }],
}),
).toEqual([
{ serverId: "server-c", serverName: "Offline box" },
{ serverId: "server-b", serverName: "Linux box" },
]);
});
it("names a host once when it is both unreachable and failed", () => {
expect(
collectAgentHistoryHostErrors({
pages: [{ hostErrors: [{ serverId: "server-b", serverName: "Linux box" }] }],
unreachableHosts: [{ serverId: "server-b", serverName: "Linux box" }],
}),
).toEqual([{ serverId: "server-b", serverName: "Linux box" }]);
});
it("orders a searched all-host page by relevance instead of recency", async () => {
const serverAClient = createClient([
historyPayload({
entries: [
historyEntry({
id: "newer-weak-match",
cwd: "/repo/a",
updatedAt: "2026-04-09T10:00:00.000Z",
title: "Unbilled usage report",
searchScore: 4000,
}),
],
}),
]);
const serverBClient = createClient([
historyPayload({
entries: [
historyEntry({
id: "older-strong-match",
cwd: "/repo/b",
updatedAt: "2026-04-01T10:00:00.000Z",
title: "Bill the customer",
searchScore: 1000,
}),
],
}),
]);
const page = await fetchAgentHistoryBatch({
hosts: [
{ serverId: "server-a", serverLabel: "MacBook", client: serverAClient },
{ serverId: "server-b", serverLabel: "Linux box", client: serverBClient },
] satisfies AgentHistoryHost[],
cursorByServerId: null,
search: "bill",
});
expect(page.agents.map((agent) => agent.id)).toEqual([
"older-strong-match",
"newer-weak-match",
]);
});
it("fetches only hosts with a cursor when loading the next all-host page", async () => {
const serverAClient = createClient([
historyPayload({
+212 -14
View File
@@ -3,11 +3,13 @@ import type {
FetchAgentHistoryOptions,
FetchAgentHistoryPageInfo,
} from "@getpaseo/client/internal/daemon-client";
import type { AgentSearchMatch } from "@getpaseo/protocol/messages";
import { useInfiniteQuery } from "@tanstack/react-query";
import { useCallback, useMemo, useSyncExternalStore } from "react";
import { useTranslation } from "react-i18next";
import type { AggregatedAgent } from "@/hooks/use-aggregated-agents";
import { getHostRuntimeStore, isHostRuntimeConnected, useHosts } from "@/runtime/host-runtime";
import { useSessionStore } from "@/stores/session-store";
import { buildAgentDirectoryState } from "@/utils/agent-directory-sync";
import { agentHistoryQueryKey, allAgentHistoryQueryKey } from "./agent-history-query-key";
@@ -25,17 +27,52 @@ export interface AgentHistoryResult {
isError: boolean;
hasMore: boolean;
isLoadingMore: boolean;
/** False when any target host predates search; the screen hides the field. */
isSearchSupported: boolean;
/** More sessions matched than the ranked page holds. Narrow the query. */
isSearchTruncated: boolean;
/** Where the query matched each row, keyed by `serverId:agentId`. */
searchMatchesByAgentKey: Record<string, AgentSearchMatch[]>;
/** Hosts that failed while others succeeded. The list still renders. */
hostErrors: AgentHistoryHostError[];
refreshAll: () => Promise<void>;
loadMore: () => void;
}
export interface AgentHistoryPage {
agents: AggregatedAgent[];
/**
* Relevance of each agent to the request's search, keyed by `serverId:agentId`
* because that pair — not the bare agent id — is what identifies a row across
* hosts. Empty without a query. Each host ranks only its own sessions, so
* merging several of them into one list needs the scores rather than each
* host's row order.
*/
searchScoreByAgentKey: Record<string, number>;
/** Where the query matched in each row, keyed like `searchScoreByAgentKey`. */
searchMatchesByAgentKey: Record<string, AgentSearchMatch[]>;
pageInfo: FetchAgentHistoryPageInfo;
/** More matched this host's query than its page could hold. */
isSearchTruncated: boolean;
}
/** Identity of a history row. A bare agent id belongs to exactly one host. */
export function agentHistoryRowKey(agent: { serverId: string; id: string }): string {
return `${agent.serverId}:${agent.id}`;
}
export type AgentHistoryClient = Pick<DaemonClient, "fetchAgentHistory">;
/**
* A host that could not be reached while at least one other could. Its sessions
* are missing from the list, which under a query means "no matches" would be a
* claim the app cannot make.
*/
export interface AgentHistoryHostError {
serverId: string;
serverName: string;
}
export interface AgentHistoryHost {
serverId: string;
serverLabel: string;
@@ -44,17 +81,27 @@ export interface AgentHistoryHost {
interface AgentHistoryBatchPage {
agents: AggregatedAgent[];
searchScoreByAgentKey: Record<string, number>;
searchMatchesByAgentKey: Record<string, AgentSearchMatch[]>;
pageInfoByServerId: Record<string, FetchAgentHistoryPageInfo>;
hostErrors: AgentHistoryHostError[];
/** Set only under a query: more sessions matched than this page can hold. */
isSearchTruncated?: boolean;
}
/** Sorts unmatched rows last so a page missing scores keeps its own order. */
const UNRANKED = Number.POSITIVE_INFINITY;
type AgentHistoryCursorByServerId = Record<string, string | null>;
export async function fetchAgentHistoryPage(input: {
client: AgentHistoryClient;
serverId: string;
cursor: string | null;
search?: string;
}): Promise<AgentHistoryPage> {
const payload = await input.client.fetchAgentHistory({
...(input.search ? { search: input.search } : {}),
sort: AGENT_HISTORY_SORT,
page: input.cursor
? { limit: AGENT_HISTORY_PAGE_LIMIT, cursor: input.cursor }
@@ -65,8 +112,22 @@ export async function fetchAgentHistoryPage(input: {
serverId: input.serverId,
entries: payload.entries,
});
const searchScoreByAgentKey: Record<string, number> = {};
const searchMatchesByAgentKey: Record<string, AgentSearchMatch[]> = {};
for (const entry of payload.entries) {
const key = agentHistoryRowKey({ serverId: input.serverId, id: entry.agent.id });
if (entry.searchScore !== undefined) {
searchScoreByAgentKey[key] = entry.searchScore;
}
if (entry.searchMatches && entry.searchMatches.length > 0) {
searchMatchesByAgentKey[key] = entry.searchMatches;
}
}
return {
searchScoreByAgentKey,
searchMatchesByAgentKey,
isSearchTruncated: payload.searchTruncated === true,
agents: Array.from(agents.values(), (agent) => ({
id: agent.id,
serverId: input.serverId,
@@ -94,6 +155,55 @@ function sortByLatestActivity(agents: AggregatedAgent[]): AggregatedAgent[] {
return [...agents].sort((a, b) => b.lastActivityAt.getTime() - a.lastActivityAt.getTime());
}
/**
* Relevance first, recency to break ties. A search result list ordered by time
* would bury the row the user described in favour of the row they last touched.
*
* Truncating the merge back to one page's worth is what makes the merged list
* globally correct: the best N matches overall are always contained in the
* union of each host's own best N, so keeping more than N here would be showing
* rows that a further host could still outrank.
*/
function mergeByRelevance(
agents: AggregatedAgent[],
searchScoreByAgentKey: Record<string, number>,
limit: number,
): AggregatedAgent[] {
return [...agents]
.sort((a, b) => {
const scoreA = searchScoreByAgentKey[agentHistoryRowKey(a)] ?? UNRANKED;
const scoreB = searchScoreByAgentKey[agentHistoryRowKey(b)] ?? UNRANKED;
if (scoreA !== scoreB) return scoreA - scoreB;
return b.lastActivityAt.getTime() - a.lastActivityAt.getTime();
})
.slice(0, limit);
}
/**
* Which hosts are missing from the list the user is looking at.
*
* Failures are collected across every loaded page, not just the newest: a host
* that rejects page one contributes no cursor and is never asked again, so a
* later page carries no error of its own while that host's history is still
* absent. A refetch replaces every page, which is what clears an error that has
* healed.
*/
export function collectAgentHistoryHostErrors(input: {
pages: readonly Pick<AgentHistoryBatchPage, "hostErrors">[];
unreachableHosts: readonly AgentHistoryHostError[];
}): AgentHistoryHostError[] {
const byServerId = new Map<string, AgentHistoryHostError>();
for (const error of input.unreachableHosts) {
byServerId.set(error.serverId, error);
}
for (const page of input.pages) {
for (const error of page.hostErrors) {
byServerId.set(error.serverId, error);
}
}
return [...byServerId.values()];
}
function getNextAgentHistoryPageParam(
page: AgentHistoryBatchPage,
): AgentHistoryCursorByServerId | null {
@@ -110,6 +220,7 @@ function getNextAgentHistoryPageParam(
export async function fetchAgentHistoryBatch(input: {
hosts: readonly AgentHistoryHost[];
cursorByServerId: AgentHistoryCursorByServerId | null;
search?: string;
}): Promise<AgentHistoryBatchPage> {
const cursorByServerId = input.cursorByServerId ?? {};
const hasCursorFilter = Object.keys(cursorByServerId).length > 0;
@@ -123,6 +234,7 @@ export async function fetchAgentHistoryBatch(input: {
client: host.client,
serverId: host.serverId,
cursor: cursorByServerId[host.serverId] ?? null,
...(input.search ? { search: input.search } : {}),
});
return { host, page };
}),
@@ -133,6 +245,18 @@ export async function fetchAgentHistoryBatch(input: {
if (pages.length === 0) {
throw new Error(AGENT_HISTORY_ALL_HOSTS_FAILED_MESSAGE);
}
// allSettled preserves order, so a rejection still names the host it came
// from. Losing that name is what would let the list quietly under-report.
const hostErrors = settledPages.flatMap((result, index) =>
result.status === "rejected"
? [
{
serverId: hostsToFetch[index].serverId,
serverName: hostsToFetch[index].serverLabel,
},
]
: [],
);
const agents = pages.flatMap(({ host, page }) =>
page.agents.map((agent) => Object.assign({}, agent, { serverLabel: host.serverLabel })),
@@ -140,16 +264,37 @@ export async function fetchAgentHistoryBatch(input: {
const pageInfoByServerId = Object.fromEntries(
pages.map(({ host, page }) => [host.serverId, page.pageInfo]),
);
const searchScoreByAgentKey = Object.assign(
{},
...pages.map(({ page }) => page.searchScoreByAgentKey),
) as Record<string, number>;
const searchMatchesByAgentKey = Object.assign(
{},
...pages.map(({ page }) => page.searchMatchesByAgentKey),
) as Record<string, AgentSearchMatch[]>;
// Truncation has two independent sources: one host overflowed its own page,
// or several hosts each fit but their union does not. Two hosts returning 150
// matches apiece are both complete and still add up to more than the merge
// can show.
const truncated =
pages.some(({ page }) => page.isSearchTruncated) || agents.length > AGENT_HISTORY_PAGE_LIMIT;
return {
agents: sortByLatestActivity(agents),
agents: input.search
? mergeByRelevance(agents, searchScoreByAgentKey, AGENT_HISTORY_PAGE_LIMIT)
: sortByLatestActivity(agents),
searchScoreByAgentKey,
searchMatchesByAgentKey,
pageInfoByServerId,
hostErrors,
...(input.search ? { isSearchTruncated: truncated } : {}),
};
}
export function useAgentHistory(options: {
serverId?: string | null;
enabled?: boolean;
search?: string;
}): AgentHistoryResult {
const { t } = useTranslation();
const daemons = useHosts();
@@ -164,31 +309,54 @@ export function useAgentHistory(options: {
return typeof value === "string" && value.trim().length > 0 ? value.trim() : null;
}, [options.serverId]);
const enabled = options.enabled ?? true;
const targetHosts = useMemo(() => {
// A host the user asked about splits two ways: one this fetch can reach, and
// one whose sessions will be missing from the answer. Both come out of here,
// because dropping the unreachable ones silently is what lets the list — and
// worse, "No sessions match" — overstate what was actually searched.
const { targetHosts, unreachableHosts } = useMemo(() => {
void runtimeVersion;
const serverLabelById = new Map(daemons.map((daemon) => [daemon.serverId, daemon.label]));
const serverIds = serverId ? [serverId] : daemons.map((daemon) => daemon.serverId);
const hosts: AgentHistoryHost[] = [];
const unreachable: AgentHistoryHostError[] = [];
for (const targetServerId of serverIds) {
const snapshot = runtime.getSnapshot(targetServerId);
const client = runtime.getClient(targetServerId);
const serverName = serverLabelById.get(targetServerId) ?? targetServerId;
if (!client || !isHostRuntimeConnected(snapshot)) {
unreachable.push({ serverId: targetServerId, serverName });
continue;
}
hosts.push({
serverId: targetServerId,
serverLabel: serverLabelById.get(targetServerId) ?? targetServerId,
client,
});
hosts.push({ serverId: targetServerId, serverLabel: serverName, client });
}
return hosts;
return { targetHosts: hosts, unreachableHosts: unreachable };
}, [daemons, runtime, runtimeVersion, serverId]);
const targetServerIds = useMemo(() => targetHosts.map((host) => host.serverId), [targetHosts]);
// One gate, checked before the field is offered: a fleet where any host
// predates search has no search, rather than a list that silently omits that
// host's sessions.
const isSearchSupported = useSessionStore(
useCallback(
(state) =>
targetServerIds.length > 0 &&
targetServerIds.every(
(id) => state.sessions[id]?.serverInfo?.features?.agentHistorySearch === true,
),
[targetServerIds],
),
);
const search = useMemo(() => {
const trimmed = options.search?.trim() ?? "";
return isSearchSupported ? trimmed : "";
}, [isSearchSupported, options.search]);
const queryKey = useMemo(
() => (serverId ? agentHistoryQueryKey(serverId) : allAgentHistoryQueryKey(targetServerIds)),
[serverId, targetServerIds],
() => [
...(serverId ? agentHistoryQueryKey(serverId) : allAgentHistoryQueryKey(targetServerIds)),
search,
],
[search, serverId, targetServerIds],
);
const serverLabelById = useMemo(
() => new Map(daemons.map((daemon) => [daemon.serverId, daemon.label])),
@@ -214,6 +382,7 @@ export function useAgentHistory(options: {
return fetchAgentHistoryBatch({
hosts: targetHosts,
cursorByServerId: pageParam,
...(search ? { search } : {}),
});
},
});
@@ -243,16 +412,39 @@ export function useAgentHistory(options: {
}, [enabled, fetchNextPage, hasNextPage, isFetchingNextPage, targetHosts.length]);
const agents = useMemo(() => {
const historyAgents = (data?.pages ?? []).flatMap((page) => page.agents);
const pages = data?.pages ?? [];
const historyAgents = pages.flatMap((page) => page.agents);
const labelledAgents = historyAgents.map((agent) =>
Object.assign({}, agent, {
serverLabel: serverLabelById.get(agent.serverId) ?? agent.serverLabel,
}),
);
return sortByLatestActivity(labelledAgents);
}, [data?.pages, serverLabelById]);
if (!search) {
return sortByLatestActivity(labelledAgents);
}
// A searched query never has a second page, so this is re-merging one page
// after relabelling rather than stitching a stream back together.
const searchScoreByAgentKey = Object.assign(
{},
...pages.map((page) => page.searchScoreByAgentKey),
) as Record<string, number>;
return mergeByRelevance(labelledAgents, searchScoreByAgentKey, AGENT_HISTORY_PAGE_LIMIT);
}, [data?.pages, search, serverLabelById]);
const isInitialLoad = isLoading && agents.length === 0;
const isRevalidating = isFetching && !isFetchingNextPage && agents.length > 0;
const isSearchTruncated = Boolean(search && data?.pages.some((page) => page.isSearchTruncated));
const searchMatchesByAgentKey = useMemo(
() =>
Object.assign(
{},
...(data?.pages ?? []).map((page) => page.searchMatchesByAgentKey),
) as Record<string, AgentSearchMatch[]>,
[data?.pages],
);
const hostErrors = useMemo(
() => collectAgentHistoryHostErrors({ pages: data?.pages ?? [], unreachableHosts }),
[data?.pages, unreachableHosts],
);
return {
agents,
@@ -260,8 +452,14 @@ export function useAgentHistory(options: {
isInitialLoad,
isRevalidating,
isError,
hasMore: hasNextPage,
// Under a query the daemon returns the best matches and no cursor, so the
// list is complete as far as it goes and "Load more" would be a lie.
hasMore: search ? false : hasNextPage,
isLoadingMore: isFetchingNextPage,
isSearchSupported,
isSearchTruncated,
searchMatchesByAgentKey,
hostErrors,
refreshAll,
loadMore,
};
@@ -0,0 +1,17 @@
import { useEffect, useState } from "react";
/**
* Trails `value` by `delayMs`, so a value that drives a request settles before
* it is sent. The current value stays the caller's — this only decides when the
* expensive consumer gets to see it.
*/
export function useDebouncedValue<T>(value: T, delayMs: number): T {
const [debounced, setDebounced] = useState(value);
useEffect(() => {
const timer = setTimeout(() => setDebounced(value), delayMs);
return () => clearTimeout(timer);
}, [value, delayMs]);
return debounced;
}