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
@@ -0,0 +1,148 @@
import { randomUUID } from "node:crypto";
import { expect, type Page } from "@playwright/test";
import { test } from "../support/fixtures";
import { gotoAppShell } from "../support/helpers/app";
import { openSessions } from "../support/helpers/archive-tab";
import { createIdleAgent } from "../support/helpers/archive-tab";
import { addConnectedHostAndReload, addOfflineHostAndReload } from "../support/helpers/hosts";
import { startIsolatedHostDaemon } from "../support/helpers/isolated-host-daemon";
import { connectSeedClient, seedWorkspace } from "../support/helpers/seed-client";
import { getServerId } from "../support/helpers/server-id";
// The constants module is plain TypeScript, so the Playwright runner can import
// it — reaching through the `.tsx` picker would drag React Native into Node.
import { ALL_HOSTS_OPTION_ID } from "@/components/hosts/host-picker-constants";
const AGENT_ROW = '[data-testid^="agent-row-"]';
const PRIMARY_LABEL = "Primary box";
const SECONDARY_LABEL = "Secondary box";
/** Scopes every assertion to this spec's sessions on a shared daemon. */
const NONCE = `hsx${randomUUID().replaceAll("-", "").slice(0, 8)}`;
async function search(page: Page, query: string): Promise<void> {
await page.getByTestId("sessions-search-input").fill(query);
}
async function selectHost(page: Page, serverId: string): Promise<void> {
await page.getByTestId("sessions-host-filter-trigger").click();
await page.getByTestId(`sessions-host-filter-item-${serverId}`).click();
}
async function expectRankedTitles(page: Page, titles: string[]): Promise<void> {
const rows = page.locator(AGENT_ROW).filter({ hasText: NONCE });
await expect(rows).toHaveCount(titles.length, { timeout: 30_000 });
for (const [index, title] of titles.entries()) {
await expect(rows.nth(index)).toContainText(title, { timeout: 30_000 });
}
}
// Searching across hosts is the case a single-daemon spec cannot see: each host
// ranks only its own sessions, so the merge is the only thing that can put the
// best match first.
test.describe("History search across hosts", () => {
test.describe.configure({ timeout: 420_000 });
test("ranks matches from every host together and narrows to one host on demand", async ({
page,
}) => {
const secondaryServerId = `srv_hist_${randomUUID().replaceAll("-", "").slice(0, 12)}`;
const secondaryDaemon = await startIsolatedHostDaemon(secondaryServerId);
const primaryClient = await connectSeedClient();
let primaryWorkspace: Awaited<ReturnType<typeof seedWorkspace>> | null = null;
let secondaryWorkspace: Awaited<ReturnType<typeof seedWorkspace>> | null = null;
// The weak match is the newest session on the primary host and the strong
// match is the oldest on the secondary, so only a real cross-host merge on
// relevance can order them correctly.
const weakTitle = `${NONCE} Unbilled usage report`;
const strongTitle = `${NONCE} Bill the customer`;
const unrelatedTitle = `${NONCE} Terminal resize fix`;
try {
secondaryWorkspace = await seedWorkspace({
repoPrefix: "sessions-search-secondary-",
port: secondaryDaemon.port,
});
await createIdleAgent(secondaryWorkspace.client, {
cwd: secondaryWorkspace.repoPath,
workspaceId: secondaryWorkspace.workspaceId,
title: strongTitle,
});
primaryWorkspace = await seedWorkspace({ repoPrefix: "sessions-search-primary-" });
for (const title of [unrelatedTitle, weakTitle]) {
await createIdleAgent(primaryClient, {
cwd: primaryWorkspace.repoPath,
workspaceId: primaryWorkspace.workspaceId,
title,
});
}
await gotoAppShell(page);
await addConnectedHostAndReload(page, {
serverId: secondaryDaemon.serverId,
label: SECONDARY_LABEL,
port: secondaryDaemon.port,
primaryLabel: PRIMARY_LABEL,
});
await openSessions(page);
// All hosts: the stronger match leads despite being the older session on
// the other daemon.
await search(page, `${NONCE} bill`);
await expectRankedTitles(page, [strongTitle, weakTitle]);
// One host: the other daemon's stronger match drops out entirely.
await selectHost(page, getServerId());
await expectRankedTitles(page, [weakTitle]);
await selectHost(page, secondaryDaemon.serverId);
await expectRankedTitles(page, [strongTitle]);
// Back to all hosts, and clearing the query returns every seeded session.
await selectHost(page, ALL_HOSTS_OPTION_ID);
await page.getByTestId("sessions-search-clear").click();
const rows = page.locator(AGENT_ROW).filter({ hasText: NONCE });
await expect(rows).toHaveCount(3, { timeout: 30_000 });
} finally {
await primaryWorkspace?.cleanup().catch(() => undefined);
await secondaryWorkspace?.cleanup().catch(() => undefined);
await primaryClient.close().catch(() => undefined);
await secondaryDaemon.close().catch(() => undefined);
}
});
test("names a host it could not reach instead of shrinking the results silently", async ({
page,
}) => {
const workspace = await seedWorkspace({ repoPrefix: "sessions-search-offline-" });
const reachableTitle = `${NONCE} Bill the reachable customer`;
try {
await createIdleAgent(workspace.client, {
cwd: workspace.repoPath,
workspaceId: workspace.workspaceId,
title: reachableTitle,
});
await gotoAppShell(page);
await addOfflineHostAndReload(page, {
serverId: "sessions-search-unreachable",
label: SECONDARY_LABEL,
primaryLabel: PRIMARY_LABEL,
});
await openSessions(page);
// The unreachable host's sessions were never searched. Reporting only the
// matches that were found, with no word about the host that never
// answered, is the failure this asserts against.
await search(page, `${NONCE} bill`);
await expect(page.getByTestId("sessions-host-errors")).toContainText(SECONDARY_LABEL, {
timeout: 30_000,
});
await expectRankedTitles(page, [reachableTitle]);
} finally {
await workspace.cleanup().catch(() => undefined);
}
});
});
@@ -0,0 +1,142 @@
import { randomUUID } from "node:crypto";
import { expect, type Page } from "@playwright/test";
import { test } from "../support/fixtures";
import { connectSeedClient } from "../support/helpers/seed-client";
import { createTempGitRepo } from "../support/helpers/workspace";
import {
createIdleAgent,
openSessions,
resetSeededPageState,
} from "../support/helpers/archive-tab";
const AGENT_ROW = '[data-testid^="agent-row-"]';
/**
* Every seeded title opens with the same nonce, so a query of "<nonce> term"
* can only reach this spec's sessions. The daemon is shared with the rest of
* the browser suite and its history is whatever those specs left behind.
*/
const NONCE = `hsq${randomUUID().replaceAll("-", "").slice(0, 8)}`;
const TITLES = {
billing: `${NONCE} Add Stripe billing`,
unbilled: `${NONCE} Unbilled usage report`,
terminal: `${NONCE} Terminal resize fix`,
} as const;
async function search(page: Page, query: string): Promise<void> {
await page.getByTestId("sessions-search-input").fill(query);
}
function rowTitles(page: Page) {
return page.locator(AGENT_ROW);
}
async function expectVisibleTitles(page: Page, titles: string[]): Promise<void> {
const rows = rowTitles(page).filter({ hasText: NONCE });
await expect(rows).toHaveCount(titles.length, { timeout: 30_000 });
for (const [index, title] of titles.entries()) {
await expect(rows.nth(index)).toContainText(title, { timeout: 30_000 });
}
}
test.describe("History search", () => {
let client: Awaited<ReturnType<typeof connectSeedClient>>;
let tempRepo: { path: string; cleanup: () => Promise<void> };
let projectId: string;
test.describe.configure({ timeout: 300_000 });
test.beforeAll(async () => {
tempRepo = await createTempGitRepo("sessions-search-");
client = await connectSeedClient();
const created = await client.createWorkspace({
source: { kind: "directory", path: tempRepo.path },
});
if (!created.workspace) {
throw new Error(created.error ?? `Failed to create workspace ${tempRepo.path}`);
}
projectId = created.workspace.projectId;
const workspaceId = created.workspace.id;
for (const title of [TITLES.terminal, TITLES.unbilled, TITLES.billing]) {
await createIdleAgent(client, { cwd: tempRepo.path, workspaceId, title });
}
});
test.afterAll(async () => {
await client?.removeProject(projectId).catch(() => undefined);
await client?.close().catch(() => undefined);
await tempRepo?.cleanup();
});
test("typing narrows history to matching sessions and clearing restores them", async ({
page,
}) => {
await resetSeededPageState(page);
await openSessions(page);
// Seeded newest-first, and at rest history is chronological.
await expectVisibleTitles(page, [TITLES.billing, TITLES.unbilled, TITLES.terminal]);
await expect(page.getByText("Today", { exact: true })).toHaveCount(1, { timeout: 30_000 });
await search(page, `${NONCE} billing`);
await expectVisibleTitles(page, [TITLES.billing]);
// Ranked results are one flat list — a day heading would claim an order
// the list no longer has.
await expect(page.getByText("Today", { exact: true })).toHaveCount(0, { timeout: 30_000 });
await page.getByTestId("sessions-search-clear").click();
await expect(page.getByTestId("sessions-search-input")).toHaveValue("");
await expectVisibleTitles(page, [TITLES.billing, TITLES.unbilled, TITLES.terminal]);
await expect(page.getByText("Today", { exact: true })).toHaveCount(1, { timeout: 30_000 });
});
test("ranks a whole-word hit above one buried inside a word", async ({ page }) => {
await resetSeededPageState(page);
await openSessions(page);
// "bill" starts a word in "billing" and hides inside "unbilled", so the
// stronger match leads even though both sessions are equally recent.
await search(page, `${NONCE} bill`);
await expectVisibleTitles(page, [TITLES.billing, TITLES.unbilled]);
});
test("marks the characters each result matched on", async ({ page }) => {
await resetSeededPageState(page);
await openSessions(page);
// The mark is a nested Text run, so the matched slice is its own element.
await search(page, `${NONCE} billing`);
const row = page.locator(AGENT_ROW).filter({ hasText: NONCE }).first();
await expect(row).toBeVisible({ timeout: 30_000 });
await expect(row.getByText("billing", { exact: true })).toBeVisible({ timeout: 30_000 });
// A typo has no characters in the text to point at, so the whole word it
// resolved to is marked.
await search(page, `${NONCE} bulling`);
const typoRow = page.locator(AGENT_ROW).filter({ hasText: NONCE }).first();
await expect(typoRow.getByText("billing", { exact: true })).toBeVisible({ timeout: 30_000 });
});
test("finds a session through a typo", async ({ page }) => {
await resetSeededPageState(page);
await openSessions(page);
await search(page, `${NONCE} bulling`);
await expectVisibleTitles(page, [TITLES.billing]);
});
test("says the query found nothing, not that history is empty", async ({ page }) => {
await resetSeededPageState(page);
await openSessions(page);
await search(page, `${NONCE} kubernetes`);
await expect(page.getByTestId("sessions-empty")).toBeVisible({ timeout: 30_000 });
await expect(page.getByText("No sessions match")).toBeVisible({ timeout: 30_000 });
await page.getByText("Clear search").click();
await expectVisibleTitles(page, [TITLES.billing, TITLES.unbilled, TITLES.terminal]);
});
});
+71 -27
View File
@@ -21,6 +21,9 @@ import { Archive, ChevronRight } from "lucide-react-native";
import { getProviderIcon } from "@/components/provider-icons";
import { navigateToAgent } from "@/utils/navigate-to-agent";
import { useArchiveAgent } from "@/hooks/use-archive-agent";
import { HighlightedText } from "@/components/ui/highlighted-text";
import type { AgentSearchMatch } from "@getpaseo/protocol/messages";
import type { MatchRange } from "@getpaseo/protocol/search/text-match";
interface AgentListProps {
agents: AggregatedAgent[];
@@ -32,6 +35,18 @@ interface AgentListProps {
listFooterComponent?: ReactElement | null;
showAttentionIndicator?: boolean;
showHostColumn?: boolean;
/**
* Where a search matched each row, keyed by `serverId:agentId`. Rows mark the
* spans so the list can explain why a result is in it — the subsequence and
* typo tiers match characters the eye would not find on its own.
*/
searchMatchesByAgentKey?: Record<string, AgentSearchMatch[]>;
/**
* Renders one flat list in the given order instead of grouping by day. Day
* headings claim the list is chronological, which is a lie once the caller
* has ordered it by something else — relevance, for instance.
*/
flat?: boolean;
}
type DateSectionKey = "today" | "yesterday" | "thisWeek" | "thisMonth" | "older";
@@ -127,12 +142,14 @@ function SessionBadge({
function WorkspaceTitlePrefix({
visible,
workspaceName,
ranges,
testID,
iconSize,
color,
}: {
visible: boolean;
workspaceName: string;
ranges?: readonly MatchRange[];
testID: string;
iconSize: number;
color: string;
@@ -143,9 +160,13 @@ function WorkspaceTitlePrefix({
return (
<>
<Text style={styles.workspaceTitleText} numberOfLines={1} testID={testID}>
{workspaceName}
</Text>
<HighlightedText
text={workspaceName}
ranges={ranges}
style={styles.workspaceTitleText}
numberOfLines={1}
testID={testID}
/>
<ChevronRight size={iconSize} color={color} />
</>
);
@@ -203,6 +224,7 @@ function SessionRowTrailingAttention({
function SessionRow({
agent,
searchMatches,
isMobile,
selectedAgentId,
showAttentionIndicator,
@@ -211,6 +233,7 @@ function SessionRow({
onLongPress,
}: {
agent: AggregatedAgent;
searchMatches?: readonly AgentSearchMatch[];
isMobile: boolean;
selectedAgentId?: string;
showAttentionIndicator: boolean;
@@ -228,6 +251,11 @@ function SessionRow({
const workspaceName = agent.projectPlacement?.workspaceName ?? "";
const ProviderIcon = getProviderIcon(agent.provider);
const pendingPermissionCount = agent.pendingPermissionCount ?? 0;
const rangesFor = useCallback(
(field: AgentSearchMatch["field"]) =>
searchMatches?.find((match) => match.field === field)?.ranges,
[searchMatches],
);
const pressableStyle = useCallback(
({ pressed, hovered = false }: PressableStateCallbackType & { hovered?: boolean }) => [
@@ -266,6 +294,7 @@ function SessionRow({
<WorkspaceTitlePrefix
visible={!isMobile && Boolean(workspaceName)}
workspaceName={workspaceName}
ranges={rangesFor("workspace")}
testID={`agent-row-workspace-${agent.serverId}-${agent.id}`}
iconSize={theme.iconSize.xs}
color={theme.colors.foregroundMuted}
@@ -273,9 +302,12 @@ function SessionRow({
<View style={styles.providerIconWrap}>
<ProviderIcon size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
</View>
<Text style={sessionTitleStyle} numberOfLines={1}>
{agent.title || t("agentList.fallbackTitle")}
</Text>
<HighlightedText
text={agent.title || t("agentList.fallbackTitle")}
ranges={agent.title ? rangesFor("title") : undefined}
style={sessionTitleStyle}
numberOfLines={1}
/>
<SessionRowBadges
agent={agent}
archivedIcon={archivedIcon}
@@ -285,29 +317,29 @@ function SessionRow({
</View>
{isMobile ? (
<View style={styles.rowMetaRow}>
<Text
<HighlightedText
text={projectName}
ranges={rangesFor("project")}
style={styles.sessionMetaText}
numberOfLines={1}
testID={`agent-row-project-${agent.serverId}-${agent.id}`}
>
{projectName}
</Text>
/>
<Text style={styles.sessionMetaSeparator}>·</Text>
<Text
<HighlightedText
text={branch}
ranges={rangesFor("branch")}
style={styles.sessionMetaText}
numberOfLines={1}
testID={`agent-row-branch-${agent.serverId}-${agent.id}`}
>
{branch}
</Text>
/>
<Text style={styles.sessionMetaSeparator}>·</Text>
<Text
<HighlightedText
text={workspaceName}
ranges={rangesFor("workspace")}
style={styles.sessionMetaText}
numberOfLines={1}
testID={`agent-row-workspace-${agent.serverId}-${agent.id}`}
>
{workspaceName}
</Text>
/>
<Text style={styles.sessionMetaSeparator}>·</Text>
<Text style={styles.sessionMetaText}>{timeAgo}</Text>
{showHostColumn && agent.serverLabel ? (
@@ -323,25 +355,25 @@ function SessionRow({
</View>
{!isMobile ? (
<View style={styles.rowColumns}>
<Text
<HighlightedText
text={projectName}
ranges={rangesFor("project")}
style={styles.columnMeta}
numberOfLines={1}
testID={`agent-row-project-${agent.serverId}-${agent.id}`}
>
{projectName}
</Text>
/>
{showHostColumn ? (
<Text style={styles.columnMetaHost} numberOfLines={1}>
{agent.serverLabel}
</Text>
) : null}
<Text
<HighlightedText
text={branch}
ranges={rangesFor("branch")}
style={styles.columnMeta}
numberOfLines={1}
testID={`agent-row-branch-${agent.serverId}-${agent.id}`}
>
{branch}
</Text>
/>
<Text style={styles.columnMetaFixed} numberOfLines={1}>
{timeAgo}
</Text>
@@ -365,6 +397,8 @@ export function AgentList({
listFooterComponent,
showAttentionIndicator = true,
showHostColumn = false,
searchMatchesByAgentKey,
flat = false,
}: AgentListProps) {
const { theme } = useUnistyles();
const { t } = useTranslation();
@@ -432,6 +466,14 @@ export function AgentList({
}, [actionAgent, actionClient, archiveAgent]);
const flatItems = useMemo((): FlatListItem[] => {
if (flat) {
return agents.map((agent) => ({
type: "agent" as const,
key: `${agent.serverId}:${agent.id}`,
agent,
}));
}
const buckets = new Map<DateSectionKey, AggregatedAgent[]>();
for (const agent of agents) {
const section = deriveDateSectionKey(agent.lastActivityAt);
@@ -452,7 +494,7 @@ export function AgentList({
}
}
return result;
}, [agents]);
}, [agents, flat]);
const renderItem: ListRenderItem<FlatListItem> = useCallback(
({ item }) => {
@@ -466,6 +508,7 @@ export function AgentList({
return (
<SessionRow
agent={item.agent}
searchMatches={searchMatchesByAgentKey?.[item.key]}
isMobile={isMobile}
selectedAgentId={selectedAgentId}
showAttentionIndicator={showAttentionIndicator}
@@ -479,6 +522,7 @@ export function AgentList({
handleAgentLongPress,
handleAgentPress,
isMobile,
searchMatchesByAgentKey,
selectedAgentId,
showAttentionIndicator,
showHostColumn,
@@ -20,6 +20,7 @@ export interface HostFilterProps {
selectedHost: string;
onSelectHost: (serverId: string) => void;
triggerTestID?: string;
hostOptionTestID?: (serverId: string) => string;
}
/**
@@ -32,6 +33,7 @@ export function HostFilter({
selectedHost,
onSelectHost,
triggerTestID,
hostOptionTestID,
}: HostFilterProps): ReactElement {
const [isFilterOpen, setIsFilterOpen] = useState(false);
const filterAnchorRef = useRef<View>(null);
@@ -64,6 +66,7 @@ export function HostFilter({
searchable={false}
title="Filter by host"
desktopPlacement="bottom-start"
hostOptionTestID={hostOptionTestID}
>
<View ref={filterAnchorRef} collapsable={false} style={styles.filterTriggerWrap}>
<Pressable
@@ -266,6 +266,7 @@ export function HostPicker({
active={active}
selected={selected}
onPress={onPress}
testID={hostOptionTestID?.(option.id)}
/>
);
}
@@ -23,7 +23,7 @@ import { useHostRuntimeClient } from "@/runtime/host-runtime";
import { settingsStyles } from "@/styles/settings";
import { resolveProviderLabel } from "@/utils/provider-definitions";
import { formatTimeAgo } from "@/utils/time";
import { compareMatchScores, scoreTextFields } from "@/utils/score-match";
import { compareMatchScores, scoreTextFields } from "@getpaseo/protocol/search/text-match";
import type { AgentModelDefinition, AgentProvider } from "@getpaseo/protocol/agent-types";
import type { ProviderProfileModel } from "@getpaseo/protocol/provider-config";
import {
@@ -1,4 +1,8 @@
import { compareMatchScores, type MatchScore, scoreTextFields } from "../../utils/score-match";
import {
compareMatchScores,
type MatchScore,
scoreTextFields,
} from "@getpaseo/protocol/search/text-match";
export type ComboboxOptionKind = "directory" | "file";
@@ -0,0 +1,50 @@
import { describe, expect, it } from "vitest";
import { toHighlightSegments } from "./highlighted-text-segments";
/** What the eye sees, written back out with the marked runs bracketed. */
function marked(text: string, ranges: { start: number; length: number }[]): string {
return toHighlightSegments(text, ranges)
.map((segment) => (segment.marked ? `[${segment.text}]` : segment.text))
.join("");
}
describe("toHighlightSegments", () => {
it("marks one span and keeps the rest intact", () => {
expect(marked("add stripe billing", [{ start: 4, length: 6 }])).toBe("add [stripe] billing");
});
it("marks several spans in order", () => {
expect(
marked("add stripe billing", [
{ start: 4, length: 6 },
{ start: 11, length: 4 },
]),
).toBe("add [stripe] [bill]ing");
});
it("marks a span at the very start and at the very end", () => {
expect(marked("main", [{ start: 0, length: 4 }])).toBe("[main]");
expect(marked("feat/main", [{ start: 5, length: 4 }])).toBe("feat/[main]");
});
it("clamps a range that runs past the end of the text", () => {
expect(marked("main", [{ start: 2, length: 99 }])).toBe("ma[in]");
});
it("drops a range that starts past the end of the text", () => {
expect(marked("main", [{ start: 99, length: 4 }])).toBe("main");
});
it("does not let overlapping ranges duplicate characters", () => {
expect(
marked("billing", [
{ start: 0, length: 4 },
{ start: 2, length: 5 },
]),
).toBe("[bill][ing]");
});
it("returns the whole text as one unmarked run when there are no ranges", () => {
expect(toHighlightSegments("main", [])).toEqual([{ start: 0, text: "main", marked: false }]);
});
});
@@ -0,0 +1,35 @@
import type { MatchRange } from "@getpaseo/protocol/search/text-match";
export interface HighlightSegment {
/** Offset into the source text; unique per segment, so it doubles as a key. */
start: number;
text: string;
marked: boolean;
}
/**
* Splits text into marked and unmarked runs. Ranges are clamped and skipped
* rather than trusted: they arrive over the wire, and a stale one must not slice
* a title into gibberish.
*/
export function toHighlightSegments(
text: string,
ranges: readonly MatchRange[],
): HighlightSegment[] {
const segments: HighlightSegment[] = [];
let cursor = 0;
for (const range of ranges) {
const start = Math.max(range.start, cursor);
const end = Math.min(start + range.length, text.length);
if (end <= start) continue;
if (start > cursor) {
segments.push({ start: cursor, text: text.slice(cursor, start), marked: false });
}
segments.push({ start, text: text.slice(start, end), marked: true });
cursor = end;
}
if (cursor < text.length) {
segments.push({ start: cursor, text: text.slice(cursor), marked: false });
}
return segments;
}
@@ -0,0 +1,65 @@
import { useMemo, type ReactElement } from "react";
import { Text, type StyleProp, type TextStyle } from "react-native";
import { StyleSheet } from "react-native-unistyles";
import type { MatchRange } from "@getpaseo/protocol/search/text-match";
import { toHighlightSegments } from "./highlighted-text-segments";
export interface HighlightedTextProps {
text: string;
/** Character spans to mark, in order and non-overlapping. */
ranges?: readonly MatchRange[];
style?: StyleProp<TextStyle>;
numberOfLines?: number;
testID?: string;
}
/**
* Text with some of its characters marked. Used to show why a search result is
* in the list — the subsequence and typo tiers can match on characters the eye
* would never find unaided.
*
* The mark is a surface tint rather than the accent: it appears many times on a
* screen, so it has to stay quiet and survive every theme, and the one accent
* per surface is spoken for.
*/
export function HighlightedText({
text,
ranges,
style,
numberOfLines,
testID,
}: HighlightedTextProps): ReactElement {
const segments = useMemo(
() => (ranges && ranges.length > 0 ? toHighlightSegments(text, ranges) : null),
[ranges, text],
);
if (!segments) {
return (
<Text style={style} numberOfLines={numberOfLines} testID={testID}>
{text}
</Text>
);
}
return (
<Text style={style} numberOfLines={numberOfLines} testID={testID}>
{segments.map((segment) =>
segment.marked ? (
<Text key={segment.start} style={styles.mark}>
{segment.text}
</Text>
) : (
<Text key={segment.start}>{segment.text}</Text>
),
)}
</Text>
);
}
const styles = StyleSheet.create((theme) => ({
mark: {
backgroundColor: theme.colors.surface3,
color: theme.colors.foreground,
},
}));
@@ -0,0 +1,113 @@
import { useCallback, useState, type ReactElement } from "react";
import { Pressable, TextInput, View } from "react-native";
import { Search, X } from "lucide-react-native";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import type { Theme } from "@/styles/theme";
const ThemedSearch = withUnistyles(Search);
const ThemedX = withUnistyles(X);
const ThemedTextInput = withUnistyles(TextInput, (theme: Theme) => ({
// Placeholders sit at foregroundMuted and no dimmer — see docs/design.md §14.
placeholderTextColor: theme.colors.foregroundMuted,
selectionColor: theme.colors.foreground,
}));
const mutedColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
export interface SearchFieldProps {
value: string;
onChangeText: (value: string) => void;
placeholder: string;
/** Falls back to `placeholder`, which already names the field. */
accessibilityLabel?: string;
clearAccessibilityLabel: string;
testID?: string;
clearTestID?: string;
}
/**
* The standalone search box that leads a screen's filter rail. Its chrome is
* the host filter pill's, so the two sit on the same rail and read as one row
* of controls; only the border colour moves on focus, which keeps the leading
* icon from shifting. It owns its own clear affordance — a caller rendering its
* own X has been handed the wrong component.
*/
export function SearchField({
value,
onChangeText,
placeholder,
accessibilityLabel,
clearAccessibilityLabel,
testID,
clearTestID,
}: SearchFieldProps): ReactElement {
const [isFocused, setIsFocused] = useState(false);
const handleFocus = useCallback(() => setIsFocused(true), []);
const handleBlur = useCallback(() => setIsFocused(false), []);
const handleClear = useCallback(() => onChangeText(""), [onChangeText]);
return (
<View style={[styles.field, isFocused && styles.fieldFocused]}>
<ThemedSearch size={14} uniProps={mutedColorMapping} />
<ThemedTextInput
testID={testID}
value={value}
onChangeText={onChangeText}
onFocus={handleFocus}
onBlur={handleBlur}
placeholder={placeholder}
accessibilityLabel={accessibilityLabel ?? placeholder}
autoCapitalize="none"
autoCorrect={false}
returnKeyType="search"
style={styles.input}
/>
{value.length > 0 ? (
<Pressable
testID={clearTestID}
onPress={handleClear}
accessibilityRole="button"
accessibilityLabel={clearAccessibilityLabel}
hitSlop={8}
>
<ThemedX size={14} uniProps={mutedColorMapping} />
</Pressable>
) : null}
</View>
);
}
const SEARCH_FIELD_MAX_WIDTH = 420;
const styles = StyleSheet.create((theme) => ({
field: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
flex: 1,
minWidth: 0,
maxWidth: SEARCH_FIELD_MAX_WIDTH,
paddingVertical: theme.spacing[1.5],
paddingHorizontal: theme.spacing[3],
borderRadius: theme.borderRadius.md,
backgroundColor: theme.colors.surface1,
borderWidth: theme.borderWidth[1],
borderColor: theme.colors.border,
},
fieldFocused: {
borderColor: theme.colors.borderAccent,
backgroundColor: theme.colors.surface2,
},
input: {
flex: 1,
minWidth: 0,
padding: 0,
height: 20,
// The browser's focus ring would sit inside the field's own focus border.
// `outlineWidth` is typed on ViewStyle since RN 0.81 and is a no-op on
// native, so this needs neither a cast nor a platform branch.
outlineWidth: 0,
color: theme.colors.foreground,
fontSize: theme.fontSize.sm,
},
}));
@@ -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;
}
+5
View File
@@ -231,8 +231,13 @@ export const ar: TranslationResources = {
sessions: {
title: "السجل",
empty: "لا توجد جلسات بعد",
noMatches: "لا توجد جلسات مطابقة",
tooManyMatches: "نتائج كثيرة جدًا — ضيّق نطاق البحث",
hostLoadFailed: "{{host}}: تعذر تحميل السجل",
searchPlaceholder: "البحث في السجل",
actions: {
loadMore: "تحميل المزيد",
clearSearch: "مسح البحث",
},
},
agentList: {
+5
View File
@@ -230,8 +230,13 @@ export const en = {
sessions: {
title: "History",
empty: "No sessions yet",
noMatches: "No sessions match",
tooManyMatches: "Too many matches — narrow your search",
hostLoadFailed: "{{host}}: Could not load history",
searchPlaceholder: "Search history",
actions: {
loadMore: "Load more",
clearSearch: "Clear search",
},
},
agentList: {
+5
View File
@@ -234,8 +234,13 @@ export const es: TranslationResources = {
sessions: {
title: "Historial",
empty: "Aún no hay sesiones",
noMatches: "No hay sesiones que coincidan",
tooManyMatches: "Demasiadas coincidencias: acota la búsqueda",
hostLoadFailed: "{{host}}: No se pudo cargar el historial",
searchPlaceholder: "Buscar en el historial",
actions: {
loadMore: "Cargar más",
clearSearch: "Borrar búsqueda",
},
},
agentList: {
+5
View File
@@ -235,8 +235,13 @@ export const fr: TranslationResources = {
sessions: {
title: "Historique",
empty: "Aucune séance pour l'instant",
noMatches: "Aucune séance correspondante",
tooManyMatches: "Trop de résultats — affinez la recherche",
hostLoadFailed: "{{host}} : Impossible de charger l'historique",
searchPlaceholder: "Rechercher dans l'historique",
actions: {
loadMore: "Charger plus",
clearSearch: "Effacer la recherche",
},
},
agentList: {
+5
View File
@@ -234,8 +234,13 @@ export const ja: TranslationResources = {
sessions: {
title: "履歴",
empty: "セッションがまだありません",
noMatches: "一致するセッションはありません",
tooManyMatches: "一致が多すぎます — 検索条件を絞ってください",
hostLoadFailed: "{{host}}: 履歴を読み込めませんでした",
searchPlaceholder: "履歴を検索",
actions: {
loadMore: "さらに読み込む",
clearSearch: "検索をクリア",
},
},
agentList: {
+5
View File
@@ -232,8 +232,13 @@ export const ko: TranslationResources = {
sessions: {
title: "기록",
empty: "아직 세션이 없습니다",
noMatches: "일치하는 세션이 없습니다",
tooManyMatches: "일치 항목이 너무 많습니다 — 검색 범위를 좁히세요",
hostLoadFailed: "{{host}}: 기록을 불러오지 못했습니다",
searchPlaceholder: "기록 검색",
actions: {
loadMore: "더 불러오기",
clearSearch: "검색 지우기",
},
},
agentList: {
+5
View File
@@ -234,8 +234,13 @@ export const ptBR: TranslationResources = {
sessions: {
title: "Histórico de agentes",
empty: "Nenhuma sessão ainda",
noMatches: "Nenhuma sessão corresponde",
tooManyMatches: "Muitos resultados — refine a busca",
hostLoadFailed: "{{host}}: Não foi possível carregar o histórico",
searchPlaceholder: "Buscar no histórico",
actions: {
loadMore: "Carregar mais",
clearSearch: "Limpar busca",
},
},
agentList: {
+5
View File
@@ -233,8 +233,13 @@ export const ru: TranslationResources = {
sessions: {
title: "История",
empty: "Сеансов пока нет",
noMatches: "Нет подходящих сеансов",
tooManyMatches: "Слишком много совпадений — уточните запрос",
hostLoadFailed: "{{host}}: Не удалось загрузить историю",
searchPlaceholder: "Поиск по истории",
actions: {
loadMore: "Загрузить больше",
clearSearch: "Очистить поиск",
},
},
agentList: {
+5
View File
@@ -231,8 +231,13 @@ export const zhCN: TranslationResources = {
sessions: {
title: "历史",
empty: "还没有会话",
noMatches: "没有匹配的会话",
tooManyMatches: "匹配过多 — 请缩小搜索范围",
hostLoadFailed: "{{host}}:无法加载历史",
searchPlaceholder: "搜索历史",
actions: {
loadMore: "加载更多",
clearSearch: "清除搜索",
},
},
agentList: {
@@ -8,7 +8,7 @@ import type { AgentProviderDefinition } from "@getpaseo/protocol/provider-manife
import type { DraftCommandConfig } from "@/hooks/use-agent-commands-query";
import { buildFavoriteModelKey, type FavoriteModelRow } from "@/hooks/use-form-preferences";
import { i18n } from "@/i18n/i18next";
import { compareMatchScores, scoreTextFields } from "@/utils/score-match";
import { compareMatchScores, scoreTextFields } from "@getpaseo/protocol/search/text-match";
import { filterSelectableModels } from "./model-catalog";
export type ProviderSelectionModelRow = FavoriteModelRow & { isDefault?: boolean };
+155 -36
View File
@@ -1,20 +1,64 @@
import { useMemo, useState, useCallback, useEffect } from "react";
import { useMemo, useState, useCallback, useEffect, type ReactElement } from "react";
import { View, Text } from "react-native";
import { useIsFocused } from "@react-navigation/native";
import { router } from "expo-router";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { ChevronLeft } from "lucide-react-native";
import { useTranslation } from "react-i18next";
import type { TFunction } from "i18next";
import { MenuHeader } from "@/components/headers/menu-header";
import { Button } from "@/components/ui/button";
import { LoadingSpinner } from "@/components/ui/loading-spinner";
import { AgentList } from "@/components/agent-list";
import { SearchField } from "@/components/ui/search-field";
import { HostFilter } from "@/components/hosts/host-filter";
import { ALL_HOSTS_OPTION_ID } from "@/components/hosts/host-picker";
import { useAgentHistory } from "@/hooks/use-agent-history";
import { type AgentHistoryHostError, useAgentHistory } from "@/hooks/use-agent-history";
import { useDebouncedValue } from "@/hooks/use-debounced-value";
import { useHosts } from "@/runtime/host-runtime";
import { buildOpenProjectRoute } from "@/utils/host-routes";
/** Long enough that a typed word is one request, short enough to feel live. */
const SEARCH_DEBOUNCE_MS = 200;
const sessionsHostOptionTestID = (serverId: string) => `sessions-host-filter-item-${serverId}`;
/**
* A host that failed while others answered. Without this the list silently
* under-reports, and under a query "No sessions match" becomes a claim the app
* has no basis for.
*/
function SessionHostErrorsBanner({
errors,
t,
}: {
errors: AgentHistoryHostError[];
t: TFunction;
}): ReactElement {
return (
<View style={styles.errorsBannerWrap}>
<View style={styles.errorsBanner} testID="sessions-host-errors">
{errors.map((error) => (
<Text key={error.serverId} style={styles.errorsBannerText}>
{t("sessions.hostLoadFailed", { host: error.serverName })}
</Text>
))}
</View>
</View>
);
}
/** An empty list means something different once a query is narrowing it. */
function resolveEmptyText(input: {
t: TFunction;
isSearching: boolean;
isAllHosts: boolean;
}): string {
if (input.isSearching) return input.t("sessions.noMatches");
if (input.isAllHosts) return input.t("sessions.empty");
return "No sessions for this host";
}
export function SessionsScreen() {
const isFocused = useIsFocused();
@@ -30,11 +74,26 @@ function SessionsScreenContent() {
const { t } = useTranslation();
const hosts = useHosts();
const [selectedHost, setSelectedHost] = useState(ALL_HOSTS_OPTION_ID);
const [searchInput, setSearchInput] = useState("");
const search = useDebouncedValue(searchInput, SEARCH_DEBOUNCE_MS).trim();
const historyServerId = selectedHost === ALL_HOSTS_OPTION_ID ? null : selectedHost;
const { agents, hasMore, isInitialLoad, isLoadingMore, isError, loadMore, refreshAll } =
useAgentHistory({
serverId: historyServerId,
});
const {
agents,
hasMore,
isInitialLoad,
isLoadingMore,
isError,
isSearchSupported,
isSearchTruncated,
searchMatchesByAgentKey,
hostErrors,
loadMore,
refreshAll,
} = useAgentHistory({
serverId: historyServerId,
search,
});
const isSearching = isSearchSupported && search.length > 0;
useEffect(() => {
if (
@@ -52,44 +111,71 @@ function SessionsScreenContent() {
void refreshAll().finally(() => setIsManualRefresh(false));
}, [refreshAll]);
const sortedAgents = useMemo(() => {
return [...agents].sort((a, b) => b.lastActivityAt.getTime() - a.lastActivityAt.getTime());
}, [agents]);
const emptyText =
selectedHost === ALL_HOSTS_OPTION_ID ? t("sessions.empty") : "No sessions for this host";
// `useAgentHistory` owns the order: recency at rest, relevance under a query.
const emptyText = resolveEmptyText({
t,
isSearching,
isAllHosts: selectedHost === ALL_HOSTS_OPTION_ID,
});
const showHostFilter = hosts.length > 1;
const showLoadError = isError && sortedAgents.length === 0;
const showFilterRow = showHostFilter || isSearchSupported;
const showLoadError = isError && agents.length === 0;
const handleBack = useCallback(() => {
router.navigate(buildOpenProjectRoute());
}, []);
const listFooterComponent = useMemo(
() =>
hasMore ? (
const handleClearSearch = useCallback(() => setSearchInput(""), []);
const listFooterComponent = useMemo(() => {
// A ranked result set has no next page — reaching a weaker match means
// narrowing the query, so the footer says that instead of offering a button.
if (isSearchTruncated) {
return (
<View style={styles.footer}>
<Button variant="ghost" onPress={loadMore} disabled={isLoadingMore}>
{isLoadingMore ? "Loading..." : t("sessions.actions.loadMore")}
</Button>
<Text style={styles.footerHint}>{t("sessions.tooManyMatches")}</Text>
</View>
) : null,
[hasMore, loadMore, isLoadingMore, t],
);
);
}
if (!hasMore) {
return null;
}
return (
<View style={styles.footer}>
<Button variant="ghost" onPress={loadMore} disabled={isLoadingMore}>
{isLoadingMore ? "Loading..." : t("sessions.actions.loadMore")}
</Button>
</View>
);
}, [hasMore, isLoadingMore, isSearchTruncated, loadMore, t]);
return (
<View style={styles.container}>
<MenuHeader title={t("sessions.title")} />
{showHostFilter ? (
{showFilterRow ? (
<View style={styles.filterContainer}>
<HostFilter
hosts={hosts}
selectedHost={selectedHost}
onSelectHost={setSelectedHost}
triggerTestID="sessions-host-filter-trigger"
/>
{isSearchSupported ? (
<SearchField
value={searchInput}
onChangeText={setSearchInput}
placeholder={t("sessions.searchPlaceholder")}
clearAccessibilityLabel={t("sessions.actions.clearSearch")}
testID="sessions-search-input"
clearTestID="sessions-search-clear"
/>
) : null}
{showHostFilter ? (
<HostFilter
hosts={hosts}
selectedHost={selectedHost}
onSelectHost={setSelectedHost}
triggerTestID="sessions-host-filter-trigger"
hostOptionTestID={sessionsHostOptionTestID}
/>
) : null}
</View>
) : null}
{hostErrors.length > 0 ? <SessionHostErrorsBanner errors={hostErrors} t={t} /> : null}
{isInitialLoad ? (
<View style={styles.loadingContainer}>
<LoadingSpinner size="large" color={theme.colors.foregroundMuted} />
@@ -103,23 +189,31 @@ function SessionsScreenContent() {
</Button>
</View>
) : null}
{!isInitialLoad && !showLoadError && sortedAgents.length === 0 ? (
<View style={styles.emptyContainer}>
{!isInitialLoad && !showLoadError && agents.length === 0 ? (
<View style={styles.emptyContainer} testID="sessions-empty">
<Text style={styles.emptyText}>{emptyText}</Text>
<Button variant="ghost" leftIcon={ChevronLeft} onPress={handleBack}>
Back
</Button>
{isSearching ? (
<Button variant="ghost" onPress={handleClearSearch}>
{t("sessions.actions.clearSearch")}
</Button>
) : (
<Button variant="ghost" leftIcon={ChevronLeft} onPress={handleBack}>
Back
</Button>
)}
</View>
) : null}
{!isInitialLoad && !showLoadError && sortedAgents.length > 0 ? (
{!isInitialLoad && !showLoadError && agents.length > 0 ? (
<AgentList
agents={sortedAgents}
agents={agents}
showCheckoutInfo={false}
isRefreshing={isManualRefresh}
onRefresh={handleRefresh}
listFooterComponent={listFooterComponent}
showAttentionIndicator={false}
showHostColumn
searchMatchesByAgentKey={isSearching ? searchMatchesByAgentKey : undefined}
flat={isSearching}
/>
) : null}
</View>
@@ -132,6 +226,9 @@ const styles = StyleSheet.create((theme) => ({
backgroundColor: theme.colors.surface0,
},
filterContainer: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
paddingHorizontal: {
xs: theme.spacing[3],
md: theme.spacing[6],
@@ -158,4 +255,26 @@ const styles = StyleSheet.create((theme) => ({
alignItems: "center",
paddingVertical: theme.spacing[4],
},
footerHint: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.sm,
},
errorsBannerWrap: {
paddingHorizontal: {
xs: theme.spacing[3],
md: theme.spacing[6],
},
paddingTop: theme.spacing[3],
},
errorsBanner: {
borderWidth: theme.borderWidth[1],
borderColor: theme.colors.border,
borderRadius: theme.borderRadius.lg,
padding: theme.spacing[3],
gap: theme.spacing[1],
},
errorsBannerText: {
color: theme.colors.palette.red[300],
fontSize: theme.fontSize.xs,
},
}));
@@ -1,4 +1,8 @@
import { compareMatchScores, type MatchScore, scoreTextFields } from "@/utils/score-match";
import {
compareMatchScores,
type MatchScore,
scoreTextFields,
} from "@getpaseo/protocol/search/text-match";
interface CommandAutocompleteEntry {
command: {
@@ -1,78 +0,0 @@
import { describe, expect, it } from "vitest";
import { compareMatchScores, scoreMatch } from "./score-match";
describe("scoreMatch", () => {
it("returns tier 0 for empty query", () => {
expect(scoreMatch("", "anything")).toEqual({ tier: 0, offset: 0 });
});
it("returns tier 0 for exact match ignoring case", () => {
expect(scoreMatch("pi", "pi")).toEqual({ tier: 0, offset: 0 });
expect(scoreMatch("PI", "pi")).toEqual({ tier: 0, offset: 0 });
expect(scoreMatch("Pi", "pI")).toEqual({ tier: 0, offset: 0 });
});
it("returns tier 1 for whole-word match at start", () => {
expect(scoreMatch("feat", "feat/pi-direct-sdk")).toEqual({ tier: 1, offset: 0 });
});
it("returns tier 1 for whole-word match in middle (word boundaries on both sides)", () => {
expect(scoreMatch("pi", "feat/pi-direct-sdk")).toEqual({ tier: 1, offset: 5 });
expect(scoreMatch("pi", "a b pi c d")).toEqual({ tier: 1, offset: 4 });
});
it("returns tier 2 for string prefix that does not complete a word", () => {
expect(scoreMatch("par", "party")).toEqual({ tier: 2, offset: 0 });
});
it("returns tier 3 for word-boundary start that does not complete a word", () => {
expect(scoreMatch("par", "a/party")).toEqual({ tier: 3, offset: 2 });
});
it("returns tier 4 for substring inside a word", () => {
expect(scoreMatch("art", "party")).toEqual({ tier: 4, offset: 1 });
});
it("returns null when query is not found", () => {
expect(scoreMatch("xyz", "feat/pi-direct-sdk")).toBeNull();
});
it("picks the best tier across multiple occurrences", () => {
expect(scoreMatch("ab", "xabxab-yy")).toEqual({ tier: 4, offset: 1 });
expect(scoreMatch("ab", "xxab x-ab-y")).toEqual({ tier: 1, offset: 7 });
});
it("prefers earlier offset when tiers are equal", () => {
expect(scoreMatch("pi", "pi-a pi-b")).toEqual({ tier: 1, offset: 0 });
});
it("treats common separators as word boundaries", () => {
expect(scoreMatch("pi", "x/pi")).toEqual({ tier: 1, offset: 2 });
expect(scoreMatch("pi", "x-pi")).toEqual({ tier: 1, offset: 2 });
expect(scoreMatch("pi", "x_pi")).toEqual({ tier: 1, offset: 2 });
expect(scoreMatch("pi", "x pi")).toEqual({ tier: 1, offset: 2 });
expect(scoreMatch("pi", "x.pi")).toEqual({ tier: 1, offset: 2 });
expect(scoreMatch("pi", "x:pi")).toEqual({ tier: 1, offset: 2 });
expect(scoreMatch("202", "#202 feat")).toEqual({ tier: 1, offset: 1 });
});
it("scores PR-title-shaped text realistically", () => {
const pr202 = "#202 feat(server): replace Pi ACP with direct SDK provider";
expect(scoreMatch("pi", pr202)?.tier).toBe(1);
expect(scoreMatch("202", pr202)).toEqual({ tier: 1, offset: 1 });
expect(scoreMatch("replace", pr202)?.tier).toBe(1);
});
});
describe("compareMatchScores", () => {
it("sorts by tier ascending", () => {
expect(compareMatchScores({ tier: 1, offset: 10 }, { tier: 2, offset: 0 })).toBeLessThan(0);
expect(compareMatchScores({ tier: 3, offset: 0 }, { tier: 1, offset: 99 })).toBeGreaterThan(0);
});
it("tie-breaks by offset ascending at the same tier", () => {
expect(compareMatchScores({ tier: 1, offset: 0 }, { tier: 1, offset: 5 })).toBeLessThan(0);
expect(compareMatchScores({ tier: 1, offset: 5 }, { tier: 1, offset: 5 })).toBe(0);
});
});
-93
View File
@@ -1,93 +0,0 @@
export interface MatchScore {
tier: number;
offset: number;
spread?: number;
}
function isWordBoundaryChar(ch: string | undefined): boolean {
if (ch === undefined) return true;
return !/[a-z0-9]/.test(ch);
}
function scoreSubstringMatch(query: string, text: string): MatchScore | null {
let best: MatchScore | null = null;
let pos = 0;
while (pos <= text.length - query.length) {
const found = text.indexOf(query, pos);
if (found === -1) break;
const before = found > 0 ? text[found - 1] : undefined;
const after = text[found + query.length];
const startsAtBoundary = found === 0 || isWordBoundaryChar(before);
const endsAtBoundary = after === undefined || isWordBoundaryChar(after);
let tier: number;
if (startsAtBoundary && endsAtBoundary) {
tier = 1;
} else if (found === 0) {
tier = 2;
} else if (startsAtBoundary) {
tier = 3;
} else {
tier = 4;
}
if (!best || tier < best.tier || (tier === best.tier && found < best.offset)) {
best = { tier, offset: found };
}
pos = found + 1;
}
return best;
}
function scoreSubsequenceMatch(query: string, text: string): MatchScore | null {
let queryIndex = 0;
let firstIndex = -1;
let lastIndex = -1;
for (let textIndex = 0; textIndex < text.length && queryIndex < query.length; textIndex += 1) {
if (text[textIndex] !== query[queryIndex]) continue;
if (firstIndex === -1) firstIndex = textIndex;
lastIndex = textIndex;
queryIndex += 1;
}
if (queryIndex !== query.length || firstIndex === -1) return null;
return { tier: 5, offset: firstIndex, spread: lastIndex - firstIndex + 1 };
}
export function scoreMatch(query: string, text: string): MatchScore | null {
if (!query) return { tier: 0, offset: 0 };
const q = query.toLowerCase();
const t = text.toLowerCase();
if (t === q) return { tier: 0, offset: 0 };
return scoreSubstringMatch(q, t) ?? scoreSubsequenceMatch(q, t);
}
export function compareMatchScores(a: MatchScore, b: MatchScore): number {
if (a.tier !== b.tier) return a.tier - b.tier;
if (a.offset !== b.offset) return a.offset - b.offset;
return (a.spread ?? 0) - (b.spread ?? 0);
}
export function scoreTextFields(query: string, fields: string[]): MatchScore | null {
const tokens = query
.trim()
.toLowerCase()
.split(/\s+/)
.filter((token) => token.length > 0);
if (tokens.length === 0) return { tier: 0, offset: 0, spread: 0 };
const aggregate: MatchScore = { tier: 0, offset: 0, spread: 0 };
for (const token of tokens) {
let best: MatchScore | null = null;
for (const field of fields) {
const score = scoreMatch(token, field);
if (score && (!best || compareMatchScores(score, best) < 0)) {
best = score;
}
}
if (!best) return null;
aggregate.tier += best.tier;
aggregate.offset += best.offset;
aggregate.spread = (aggregate.spread ?? 0) + (best.spread ?? token.length);
}
return aggregate;
}
@@ -1,4 +1,4 @@
import { scoreMatch } from "./score-match";
import { scoreMatch } from "@getpaseo/protocol/search/text-match";
export interface BuildWorkingDirectorySuggestionsInput {
recommendedPaths: string[];
+1
View File
@@ -2106,6 +2106,7 @@ export class DaemonClient {
type: "fetch_agent_history_request",
requestId: resolvedRequestId,
...(options?.filter ? { filter: options.filter } : {}),
...(options?.search ? { search: options.search } : {}),
...(options?.sort ? { sort: options.sort } : {}),
...(options?.page ? { page: options.page } : {}),
});
+32
View File
@@ -1138,6 +1138,10 @@ export const FetchAgentHistoryRequestMessageSchema = z.object({
type: z.literal("fetch_agent_history_request"),
requestId: z.string(),
filter: AgentDirectoryFilterSchema.optional(),
// A ranked free-text query over agent title, workspace name, branch, and
// project name. Present only on history: agent subscriptions filter on
// structure, not on relevance. Ranking replaces `sort` when it is set.
search: z.string().optional(),
sort: z
.array(
z.object({
@@ -2880,6 +2884,8 @@ export const ServerInfoStatusPayloadSchema = z
rewind: z.boolean().optional(),
// COMPAT(agentTimelinePromptIndex): added in v0.2.X, drop the gate when floor >= v0.2.X.
agentTimelinePromptIndex: z.boolean().optional(),
// COMPAT(agentHistorySearch): added in v0.3.0, remove gate after 2027-02-07.
agentHistorySearch: z.boolean().optional(),
// COMPAT(checkoutRefresh): added in v0.1.86, remove gate after 2026-11-29.
checkoutRefresh: z.boolean().optional(),
// COMPAT(workspaceMultiplicity): added in v0.1.97, drop the gate when floor >= v0.1.97
@@ -3307,9 +3313,31 @@ export const AgentListMessageSchema = z.object({
}),
});
export const AgentSearchMatchFieldSchema = z.enum(["workspace", "title", "branch", "project"]);
export const AgentSearchMatchSchema = z.object({
field: AgentSearchMatchFieldSchema,
ranges: z.array(
z.object({
start: z.number().int().nonnegative(),
length: z.number().int().positive(),
}),
),
});
export type AgentSearchMatch = z.infer<typeof AgentSearchMatchSchema>;
const AgentDirectoryResponseEntrySchema = z.object({
agent: AgentSnapshotPayloadSchema,
project: ProjectPlacementPayloadSchema,
// Relevance of this entry to the request's `search`, lower being better.
// Set only when the request carried a query; a client merging results from
// several hosts needs it to interleave their separately ranked pages.
searchScore: z.number().optional(),
// Where the query matched, so the row can mark it. The ranker computes this
// anyway; sending it keeps the client from re-deriving a second opinion that
// could disagree with the ranking it is explaining.
searchMatches: z.array(AgentSearchMatchSchema).optional(),
});
const AgentDirectoryPageInfoSchema = z.object({
@@ -3334,6 +3362,10 @@ export const FetchAgentHistoryResponseMessageSchema = z.object({
requestId: z.string(),
entries: z.array(AgentDirectoryResponseEntrySchema),
pageInfo: AgentDirectoryPageInfoSchema,
// More sessions matched the request's `search` than the page could hold.
// Distinct from `pageInfo.hasMore`, which promises a fetchable next page —
// a ranked result set has none, and the way on is a narrower query.
searchTruncated: z.boolean().optional(),
}),
});
@@ -0,0 +1,238 @@
import { describe, expect, it } from "vitest";
import {
compareMatchScores,
fuzzyPolicyForToken,
matchRanges,
scoreMatch,
scoreTextFields,
} from "./text-match.js";
function markUp(query: string, text: string, options?: Parameters<typeof scoreMatch>[2]): string {
const score = scoreMatch(query, text, options);
if (!score) return text;
let marked = "";
let cursor = 0;
for (const range of matchRanges(query, text, score)) {
marked += text.slice(cursor, range.start);
marked += `[${text.slice(range.start, range.start + range.length)}]`;
cursor = range.start + range.length;
}
return marked + text.slice(cursor);
}
describe("scoreMatch", () => {
it("returns tier 0 for empty query", () => {
expect(scoreMatch("", "anything")).toEqual({ tier: 0, offset: 0 });
});
it("returns tier 0 for exact match ignoring case", () => {
expect(scoreMatch("pi", "pi")).toEqual({ tier: 0, offset: 0 });
expect(scoreMatch("PI", "pi")).toEqual({ tier: 0, offset: 0 });
expect(scoreMatch("Pi", "pI")).toEqual({ tier: 0, offset: 0 });
});
it("returns tier 1 for whole-word match at start", () => {
expect(scoreMatch("feat", "feat/pi-direct-sdk")).toEqual({ tier: 1, offset: 0 });
});
it("returns tier 1 for whole-word match in middle (word boundaries on both sides)", () => {
expect(scoreMatch("pi", "feat/pi-direct-sdk")).toEqual({ tier: 1, offset: 5 });
expect(scoreMatch("pi", "a b pi c d")).toEqual({ tier: 1, offset: 4 });
});
it("returns tier 2 for string prefix that does not complete a word", () => {
expect(scoreMatch("par", "party")).toEqual({ tier: 2, offset: 0 });
});
it("returns tier 3 for word-boundary start that does not complete a word", () => {
expect(scoreMatch("par", "a/party")).toEqual({ tier: 3, offset: 2 });
});
it("returns tier 4 for substring inside a word", () => {
expect(scoreMatch("art", "party")).toEqual({ tier: 4, offset: 1 });
});
it("returns null when query is not found", () => {
expect(scoreMatch("xyz", "feat/pi-direct-sdk")).toBeNull();
});
it("picks the best tier across multiple occurrences", () => {
expect(scoreMatch("ab", "xabxab-yy")).toEqual({ tier: 4, offset: 1 });
expect(scoreMatch("ab", "xxab x-ab-y")).toEqual({ tier: 1, offset: 7 });
});
it("prefers earlier offset when tiers are equal", () => {
expect(scoreMatch("pi", "pi-a pi-b")).toEqual({ tier: 1, offset: 0 });
});
it("treats common separators as word boundaries", () => {
expect(scoreMatch("pi", "x/pi")).toEqual({ tier: 1, offset: 2 });
expect(scoreMatch("pi", "x-pi")).toEqual({ tier: 1, offset: 2 });
expect(scoreMatch("pi", "x_pi")).toEqual({ tier: 1, offset: 2 });
expect(scoreMatch("pi", "x pi")).toEqual({ tier: 1, offset: 2 });
expect(scoreMatch("pi", "x.pi")).toEqual({ tier: 1, offset: 2 });
expect(scoreMatch("pi", "x:pi")).toEqual({ tier: 1, offset: 2 });
expect(scoreMatch("202", "#202 feat")).toEqual({ tier: 1, offset: 1 });
});
it("scores PR-title-shaped text realistically", () => {
const pr202 = "#202 feat(server): replace Pi ACP with direct SDK provider";
expect(scoreMatch("pi", pr202)?.tier).toBe(1);
expect(scoreMatch("202", pr202)).toEqual({ tier: 1, offset: 1 });
expect(scoreMatch("replace", pr202)?.tier).toBe(1);
});
});
describe("scoreMatch typo tolerance", () => {
const typoTolerant = { fuzzy: { maxEdits: 2, transpositionsOnly: false } };
it("is off by default so exact pickers keep exact behavior", () => {
expect(scoreMatch("bulling", "add stripe billing")).toBeNull();
});
it("matches a transposition", () => {
expect(
scoreMatch("brnach", "brnach is not branch", {
fuzzy: { maxEdits: 1, transpositionsOnly: false },
})?.tier,
).toBe(1);
expect(
scoreMatch("brnach", "switch the branch", {
fuzzy: { maxEdits: 1, transpositionsOnly: false },
})?.tier,
).toBe(6);
});
it("matches a typo the subsequence tier cannot reach", () => {
// Dropped characters already land on the subsequence tier; the fuzzy tier
// exists for the ones that reorder or replace.
expect(scoreMatch("confg", "revamp configuration editor")?.tier).toBe(5);
expect(scoreMatch("confug", "revamp configuration editor")).toBeNull();
expect(scoreMatch("confug", "revamp configuration editor", typoTolerant)?.tier).toBe(6);
});
it("reports the offset and edit distance of the word it matched", () => {
expect(scoreMatch("bulling", "add stripe billing", typoTolerant)).toEqual({
tier: 6,
offset: 11,
spread: 1,
});
});
it("ranks every exact tier ahead of a fuzzy hit", () => {
const fuzzy = scoreMatch("bilingg", "billing", typoTolerant);
const worstExact = scoreMatch("iln", "billing");
if (!fuzzy || !worstExact) throw new Error("expected both scores");
expect(compareMatchScores(worstExact, fuzzy)).toBeLessThan(0);
});
it("refuses to fuzzy-match beyond the budget", () => {
// "stpie" is two edits from "stripe": insert the r, then swap p and i.
expect(
scoreMatch("stpie", "add stripe billing", {
fuzzy: { maxEdits: 2, transpositionsOnly: false },
})?.tier,
).toBe(6);
expect(
scoreMatch("stpie", "add stripe billing", {
fuzzy: { maxEdits: 1, transpositionsOnly: false },
}),
).toBeNull();
expect(
scoreMatch("zzzzzz", "add stripe billing", {
fuzzy: { maxEdits: 2, transpositionsOnly: false },
}),
).toBeNull();
});
it("forgives only transpositions in short tokens, where a substitution matches too much", () => {
expect(fuzzyPolicyForToken("pi")).toBeNull();
expect(fuzzyPolicyForToken("main")).toEqual({ maxEdits: 1, transpositionsOnly: true });
expect(fuzzyPolicyForToken("stipe")).toEqual({ maxEdits: 1, transpositionsOnly: false });
expect(fuzzyPolicyForToken("configration")).toEqual({ maxEdits: 2, transpositionsOnly: false });
// The branch typo that has to work.
expect(scoreMatch("mian", "switch to main", { fuzzy: fuzzyPolicyForToken("mian") })?.tier).toBe(
6,
);
// Words one substitution from "main" stay out.
for (const nearMiss of ["mial", "rain", "maid"]) {
expect(
scoreMatch(nearMiss, "switch to main", { fuzzy: fuzzyPolicyForToken(nearMiss) }),
).toBeNull();
}
});
it("finds a transposed short token at the head of a longer word", () => {
expect(
scoreMatch("mian", "feat/mainline-fix", { fuzzy: fuzzyPolicyForToken("mian") })?.tier,
).toBe(6);
});
});
describe("scoreTextFields", () => {
it("requires every token to match some field", () => {
expect(scoreTextFields("stripe billing", ["add stripe billing system"])).not.toBeNull();
expect(scoreTextFields("stripe rosetta", ["add stripe billing system"])).toBeNull();
});
it("lets tokens match across different fields", () => {
expect(scoreTextFields("stripe main", ["add stripe billing", "main"])).not.toBeNull();
});
it("carries typo tolerance down to each token", () => {
expect(
scoreTextFields("stipe bulling", ["add stripe billing"], { typoTolerant: true }),
).not.toBeNull();
expect(scoreTextFields("stipe bulling", ["add stripe billing"])).toBeNull();
});
});
describe("compareMatchScores", () => {
it("sorts by tier ascending", () => {
expect(compareMatchScores({ tier: 1, offset: 10 }, { tier: 2, offset: 0 })).toBeLessThan(0);
expect(compareMatchScores({ tier: 3, offset: 0 }, { tier: 1, offset: 99 })).toBeGreaterThan(0);
});
it("tie-breaks by offset ascending at the same tier", () => {
expect(compareMatchScores({ tier: 1, offset: 0 }, { tier: 1, offset: 5 })).toBeLessThan(0);
expect(compareMatchScores({ tier: 1, offset: 5 }, { tier: 1, offset: 5 })).toBe(0);
});
});
describe("matchRanges", () => {
it("marks a whole-word hit", () => {
expect(markUp("stripe", "add stripe billing")).toBe("add [stripe] billing");
});
it("marks a hit buried inside a word", () => {
expect(markUp("bill", "unbilled usage")).toBe("un[bill]ed usage");
});
it("marks the exact text when the whole field is the query", () => {
expect(markUp("main", "main")).toBe("[main]");
});
it("marks the scattered characters a subsequence walked", () => {
expect(markUp("confg", "configuration")).toBe("[conf]i[g]uration");
});
it("marks the whole word a typo resolved to", () => {
// The characters the user got wrong are not in the text to point at, so
// the word is the smallest honest thing to mark.
expect(markUp("bulling", "add stripe billing", { fuzzy: fuzzyPolicyForToken("bulling") })).toBe(
"add stripe [billing]",
);
});
it("marks a transposed short token", () => {
expect(markUp("mian", "switch to main", { fuzzy: fuzzyPolicyForToken("mian") })).toBe(
"switch to [main]",
);
});
it("returns nothing for an empty query", () => {
expect(matchRanges("", "anything", { tier: 0, offset: 0 })).toEqual([]);
});
});
+303
View File
@@ -0,0 +1,303 @@
/**
* Ranked text matching shared by the app's pickers and the daemon's history
* search. A match is a tier plus the offset it was found at; lower is better on
* both, so callers sort ascending and never have to invent a scale.
*
* Typo tolerance is opt-in via `fuzzy`. The pickers leave it off — a combobox
* over a known list wants exact narrowing — while history search turns it on
* because the user is recalling a title from memory.
*/
export interface MatchScore {
tier: number;
offset: number;
spread?: number;
}
export interface MatchOptions {
/** Omit or pass null to match exactly. `fuzzyPolicyForToken` picks a policy. */
fuzzy?: FuzzyPolicy | null;
}
/** Exact tiers, best to worst. The fuzzy tier always sorts after all of them. */
const TIER_EXACT = 0;
const TIER_WHOLE_WORD = 1;
const TIER_PREFIX = 2;
const TIER_WORD_START = 3;
const TIER_SUBSTRING = 4;
const TIER_SUBSEQUENCE = 5;
const TIER_FUZZY = 6;
function isWordBoundaryChar(ch: string | undefined): boolean {
if (ch === undefined) return true;
return !/[a-z0-9]/.test(ch);
}
function scoreSubstringMatch(query: string, text: string): MatchScore | null {
let best: MatchScore | null = null;
let pos = 0;
while (pos <= text.length - query.length) {
const found = text.indexOf(query, pos);
if (found === -1) break;
const before = found > 0 ? text[found - 1] : undefined;
const after = text[found + query.length];
const startsAtBoundary = found === 0 || isWordBoundaryChar(before);
const endsAtBoundary = after === undefined || isWordBoundaryChar(after);
let tier: number;
if (startsAtBoundary && endsAtBoundary) {
tier = TIER_WHOLE_WORD;
} else if (found === 0) {
tier = TIER_PREFIX;
} else if (startsAtBoundary) {
tier = TIER_WORD_START;
} else {
tier = TIER_SUBSTRING;
}
if (!best || tier < best.tier || (tier === best.tier && found < best.offset)) {
best = { tier, offset: found };
}
pos = found + 1;
}
return best;
}
function scoreSubsequenceMatch(query: string, text: string): MatchScore | null {
let queryIndex = 0;
let firstIndex = -1;
let lastIndex = -1;
for (let textIndex = 0; textIndex < text.length && queryIndex < query.length; textIndex += 1) {
if (text[textIndex] !== query[queryIndex]) continue;
if (firstIndex === -1) firstIndex = textIndex;
lastIndex = textIndex;
queryIndex += 1;
}
if (queryIndex !== query.length || firstIndex === -1) return null;
return { tier: TIER_SUBSEQUENCE, offset: firstIndex, spread: lastIndex - firstIndex + 1 };
}
/**
* Damerau-Levenshtein distance, abandoned as soon as every cell in a row is
* over budget. Bounding it is what keeps the fuzzy tier affordable to run
* against every word of every candidate.
*/
function boundedEditDistance(query: string, word: string, budget: number): number | null {
if (Math.abs(query.length - word.length) > budget) return null;
let twoRowsBack: number[] = [];
let previousRow: number[] = Array.from({ length: word.length + 1 }, (_, index) => index);
for (let queryIndex = 1; queryIndex <= query.length; queryIndex += 1) {
const currentRow = [queryIndex];
let rowBest = queryIndex;
for (let wordIndex = 1; wordIndex <= word.length; wordIndex += 1) {
const substitutionCost = query[queryIndex - 1] === word[wordIndex - 1] ? 0 : 1;
let cost = Math.min(
currentRow[wordIndex - 1] + 1,
previousRow[wordIndex] + 1,
previousRow[wordIndex - 1] + substitutionCost,
);
const isTransposition =
queryIndex > 1 &&
wordIndex > 1 &&
query[queryIndex - 1] === word[wordIndex - 2] &&
query[queryIndex - 2] === word[wordIndex - 1];
if (isTransposition) {
cost = Math.min(cost, twoRowsBack[wordIndex - 2] + 1);
}
currentRow.push(cost);
rowBest = Math.min(rowBest, cost);
}
if (rowBest > budget) return null;
twoRowsBack = previousRow;
previousRow = currentRow;
}
const distance = previousRow[word.length];
return distance <= budget ? distance : null;
}
function transpositionDistance(query: string, word: string): number | null {
return isAdjacentTransposition(query, word) ? 1 : null;
}
/** True when the two differ only by one swap of neighbouring characters. */
function isAdjacentTransposition(query: string, word: string): boolean {
if (query.length !== word.length) return false;
let index = 0;
while (index < query.length && query[index] === word[index]) index += 1;
if (index >= query.length - 1) return false;
if (query[index] !== word[index + 1] || query[index + 1] !== word[index]) return false;
return query.slice(index + 2) === word.slice(index + 2);
}
/**
* How much a typo in one query token is forgiven. Short tokens get
* transpositions and nothing else: at four characters a free substitution turns
* "main" into "mail", "maid", and "rain", while a swap can only ever reach the
* word the user meant. Null means the token is matched exactly.
*/
export interface FuzzyPolicy {
maxEdits: number;
transpositionsOnly: boolean;
}
export function fuzzyPolicyForToken(token: string): FuzzyPolicy | null {
if (token.length <= 3) return null;
if (token.length === 4) return { maxEdits: 1, transpositionsOnly: true };
if (token.length <= 7) return { maxEdits: 1, transpositionsOnly: false };
return { maxEdits: 2, transpositionsOnly: false };
}
/**
* Words are what people mistype, so the fuzzy tier compares the query against
* each word rather than against the whole string — otherwise a long title's
* length difference alone would blow the budget.
*/
function scoreFuzzyMatch(query: string, text: string, policy: FuzzyPolicy): MatchScore | null {
if (policy.maxEdits <= 0 || query.length <= policy.maxEdits) return null;
let best: MatchScore | null = null;
const wordPattern = /[a-z0-9]+/g;
let word = wordPattern.exec(text);
while (word !== null) {
// Compare against the whole word and against its leading slices, so a typo
// in a prefix ("confug" for "configuration") still lands — the length gap
// to the full word would otherwise blow the budget on its own.
const candidates = new Set([
word[0],
word[0].slice(0, query.length),
word[0].slice(0, query.length + policy.maxEdits),
]);
for (const candidate of candidates) {
const distance = policy.transpositionsOnly
? transpositionDistance(query, candidate)
: boundedEditDistance(query, candidate, policy.maxEdits);
if (distance === null) continue;
const score: MatchScore = { tier: TIER_FUZZY, offset: word.index, spread: distance };
if (!best || compareMatchScores(score, best) < 0) {
best = score;
}
}
word = wordPattern.exec(text);
}
return best;
}
export function scoreMatch(
query: string,
text: string,
options: MatchOptions = {},
): MatchScore | null {
if (!query) return { tier: TIER_EXACT, offset: 0 };
const q = query.toLowerCase();
const t = text.toLowerCase();
if (t === q) return { tier: TIER_EXACT, offset: 0 };
const exact = scoreSubstringMatch(q, t) ?? scoreSubsequenceMatch(q, t);
if (exact) return exact;
const fuzzy = options.fuzzy;
return fuzzy ? scoreFuzzyMatch(q, t, fuzzy) : null;
}
export interface MatchRange {
start: number;
length: number;
}
function mergeAdjacentRanges(indices: readonly number[]): MatchRange[] {
const ranges: MatchRange[] = [];
for (const index of indices) {
const last = ranges.at(-1);
if (last && last.start + last.length === index) {
last.length += 1;
continue;
}
ranges.push({ start: index, length: 1 });
}
return ranges;
}
function wordRangeAt(text: string, offset: number): MatchRange {
let end = offset;
while (end < text.length && /[a-z0-9]/.test(text[end])) end += 1;
return { start: offset, length: Math.max(end - offset, 1) };
}
/**
* Where a score's match actually landed, so a caller can mark it. Derived from
* a score rather than produced alongside one: ranking touches every candidate
* and needs no ranges, while only the handful of rows that get rendered do.
*
* The tier decides the shape. A substring hit is one span; a subsequence hit is
* the scattered characters it walked; a typo hit marks the whole word, because
* the characters the user got wrong are not in the text to point at.
*/
export function matchRanges(query: string, text: string, score: MatchScore): MatchRange[] {
if (!query) return [];
const q = query.toLowerCase();
const t = text.toLowerCase();
if (score.tier === TIER_EXACT) return [{ start: 0, length: text.length }];
if (score.tier === TIER_FUZZY) return [wordRangeAt(t, score.offset)];
if (score.tier === TIER_SUBSEQUENCE) {
const indices: number[] = [];
let queryIndex = 0;
for (let textIndex = 0; textIndex < t.length && queryIndex < q.length; textIndex += 1) {
if (t[textIndex] !== q[queryIndex]) continue;
indices.push(textIndex);
queryIndex += 1;
}
return mergeAdjacentRanges(indices);
}
return [{ start: score.offset, length: q.length }];
}
export function compareMatchScores(a: MatchScore, b: MatchScore): number {
if (a.tier !== b.tier) return a.tier - b.tier;
if (a.offset !== b.offset) return a.offset - b.offset;
return (a.spread ?? 0) - (b.spread ?? 0);
}
export function tokenizeQuery(query: string): string[] {
return query
.trim()
.toLowerCase()
.split(/\s+/)
.filter((token) => token.length > 0);
}
export interface TextFieldsOptions {
/**
* Forgive typos. The budget is per token rather than per query, because a
* query mixes long words that can absorb an edit with short ones that cannot.
*/
typoTolerant?: boolean;
}
export function scoreTextFields(
query: string,
fields: string[],
options: TextFieldsOptions = {},
): MatchScore | null {
const tokens = tokenizeQuery(query);
if (tokens.length === 0) return { tier: TIER_EXACT, offset: 0, spread: 0 };
const aggregate: MatchScore = { tier: TIER_EXACT, offset: 0, spread: 0 };
for (const token of tokens) {
const fuzzy = options.typoTolerant ? fuzzyPolicyForToken(token) : null;
let best: MatchScore | null = null;
for (const field of fields) {
const score = scoreMatch(token, field, { fuzzy });
if (score && (!best || compareMatchScores(score, best) < 0)) {
best = score;
}
}
if (!best) return null;
aggregate.tier += best.tier;
aggregate.offset += best.offset;
aggregate.spread = (aggregate.spread ?? 0) + (best.spread ?? token.length);
}
return aggregate;
}
@@ -0,0 +1,195 @@
import { describe, expect, it } from "vitest";
import {
type AgentHistorySearchCandidate,
describeAgentHistoryMatches,
rankAgentHistoryCandidates,
scoreAgentHistoryCandidate,
} from "./agent-history-search.js";
function candidate(input: {
title?: string | null;
workspaceName?: string | null;
branch?: string | null;
projectName?: string;
updatedAt?: string;
}): AgentHistorySearchCandidate & { agent: { id: string; updatedAt: string } } {
const branch = input.branch ?? null;
return {
agent: {
id: input.title ?? "agent",
title: input.title ?? null,
updatedAt: input.updatedAt ?? "2026-08-07T00:00:00.000Z",
},
project: {
projectKey: "key",
projectName: input.projectName ?? "getpaseo/paseo",
workspaceName: input.workspaceName ?? null,
checkout: {
cwd: "/tmp/repo",
isGit: branch !== null,
currentBranch: branch,
remoteUrl: null,
worktreeRoot: "/tmp/repo",
isPaseoOwnedWorktree: false,
mainRepoRoot: null,
},
},
// The module only reads the four names; the rest of the payload is the
// session's business.
} as unknown as AgentHistorySearchCandidate & { agent: { id: string; updatedAt: string } };
}
const byUpdatedAtDesc = (
left: { agent: { updatedAt: string } },
right: { agent: { updatedAt: string } },
) => right.agent.updatedAt.localeCompare(left.agent.updatedAt);
describe("scoreAgentHistoryCandidate", () => {
it("matches the workspace name", () => {
expect(
scoreAgentHistoryCandidate("stripe", candidate({ workspaceName: "Add Stripe billing" })),
).not.toBeNull();
});
it("matches the agent title", () => {
expect(
scoreAgentHistoryCandidate("entitlements", candidate({ title: "Reshape entitlements" })),
).not.toBeNull();
});
it("matches the branch name", () => {
expect(
scoreAgentHistoryCandidate("billing", candidate({ branch: "add-stripe-billing" })),
).not.toBeNull();
});
it("matches the project name", () => {
expect(
scoreAgentHistoryCandidate("paseo", candidate({ projectName: "getpaseo/paseo" })),
).not.toBeNull();
});
it("requires every token to match somewhere", () => {
const entry = candidate({ workspaceName: "Add Stripe billing", branch: "main" });
expect(scoreAgentHistoryCandidate("stripe main", entry)).not.toBeNull();
expect(scoreAgentHistoryCandidate("stripe rosetta", entry)).toBeNull();
});
it("tolerates a typo", () => {
expect(
scoreAgentHistoryCandidate("bulling", candidate({ workspaceName: "Add Stripe billing" })),
).not.toBeNull();
});
it("tolerates a transposed branch name", () => {
expect(scoreAgentHistoryCandidate("mian", candidate({ branch: "main" }))).not.toBeNull();
expect(scoreAgentHistoryCandidate("rain", candidate({ branch: "main" }))).toBeNull();
});
it("returns null for a blank query so an empty box never filters", () => {
expect(scoreAgentHistoryCandidate(" ", candidate({ title: "anything" }))).toBeNull();
});
});
describe("rankAgentHistoryCandidates", () => {
it("drops candidates that do not match", () => {
const ranked = rankAgentHistoryCandidates(
"stripe",
[candidate({ title: "Add Stripe billing" }), candidate({ title: "Fix terminal resize" })],
byUpdatedAtDesc,
);
expect(ranked.map((result) => result.candidate.agent.title)).toEqual(["Add Stripe billing"]);
});
it("ranks a whole-word hit above a hit buried inside a word", () => {
const ranked = rankAgentHistoryCandidates(
"bill",
[candidate({ title: "unbilled usage report" }), candidate({ title: "bill the customer" })],
byUpdatedAtDesc,
);
expect(ranked.map((result) => result.candidate.agent.title)).toEqual([
"bill the customer",
"unbilled usage report",
]);
});
it("ranks the workspace name above the project name that every session shares", () => {
const ranked = rankAgentHistoryCandidates(
"paseo",
[
candidate({ title: "unrelated work", projectName: "getpaseo/paseo" }),
candidate({ workspaceName: "paseo", projectName: "getpaseo/paseo" }),
],
byUpdatedAtDesc,
);
expect(ranked[0].candidate.project.workspaceName).toBe("paseo");
});
it("ranks every exact hit above a typo hit", () => {
const ranked = rankAgentHistoryCandidates(
"billing",
[
candidate({ title: "bulling the customer" }),
candidate({ title: "prorated billing edge cases" }),
],
byUpdatedAtDesc,
);
expect(ranked.map((result) => result.candidate.agent.title)).toEqual([
"prorated billing edge cases",
"bulling the customer",
]);
});
it("breaks ties with the caller's ordering", () => {
const ranked = rankAgentHistoryCandidates(
"stripe",
[
candidate({ title: "stripe one", updatedAt: "2026-08-01T00:00:00.000Z" }),
candidate({ title: "stripe two", updatedAt: "2026-08-06T00:00:00.000Z" }),
],
byUpdatedAtDesc,
);
expect(ranked.map((result) => result.candidate.agent.title)).toEqual([
"stripe two",
"stripe one",
]);
});
});
describe("describeAgentHistoryMatches", () => {
it("says which field matched and where", () => {
expect(
describeAgentHistoryMatches("stripe", candidate({ workspaceName: "Add Stripe billing" })),
).toEqual([{ field: "workspace", ranges: [{ start: 4, length: 6 }] }]);
});
it("describes each field a multi-token query landed on", () => {
expect(
describeAgentHistoryMatches(
"stripe main",
candidate({ workspaceName: "Add Stripe billing", branch: "main" }),
),
).toEqual([
{ field: "workspace", ranges: [{ start: 4, length: 6 }] },
{ field: "branch", ranges: [{ start: 0, length: 4 }] },
]);
});
it("merges overlapping spans two tokens found in one field", () => {
expect(
describeAgentHistoryMatches("bill billing", candidate({ title: "prorated billing" })),
).toEqual([{ field: "title", ranges: [{ start: 9, length: 7 }] }]);
});
it("marks the whole word behind a typo", () => {
expect(
describeAgentHistoryMatches("bulling", candidate({ title: "customer billing" })),
).toEqual([{ field: "title", ranges: [{ start: 9, length: 7 }] }]);
});
it("returns nothing when the candidate does not match", () => {
expect(
describeAgentHistoryMatches("rosetta", candidate({ title: "customer billing" })),
).toEqual([]);
});
});
@@ -0,0 +1,188 @@
import type {
AgentSearchMatch,
AgentSnapshotPayload,
ProjectPlacementPayload,
} from "@getpaseo/protocol/messages";
import {
compareMatchScores,
fuzzyPolicyForToken,
type MatchRange,
type MatchScore,
matchRanges,
scoreMatch,
tokenizeQuery,
} from "@getpaseo/protocol/search/text-match";
/**
* History search ranks what the daemon already knows about a session: the four
* names attached to it. Transcripts are deliberately out — they are unbounded,
* and a partial-transcript index would answer "not found" for sessions that do
* contain the phrase, which is worse than never claiming to search them.
*
* Field order is the tie-break order. Workspace and agent titles are what
* people actually recall; a project name matches every session in the repo, so
* it ranks last and only decides ties.
*/
export interface AgentHistorySearchCandidate {
agent: AgentSnapshotPayload;
project: ProjectPlacementPayload;
}
/** In tie-break order; the index into this list is a field's rank. */
const SEARCH_FIELDS = ["workspace", "title", "branch", "project"] as const;
type SearchField = (typeof SEARCH_FIELDS)[number];
function searchableFields(candidate: AgentHistorySearchCandidate): string[] {
const { agent, project } = candidate;
return [
project.workspaceName ?? "",
agent.title ?? "",
project.checkout.currentBranch ?? "",
project.projectName,
];
}
interface FieldMatch {
score: MatchScore;
fieldRank: number;
token: string;
}
function bestFieldMatch(token: string, fields: string[]): FieldMatch | null {
const fuzzy = fuzzyPolicyForToken(token);
let best: FieldMatch | null = null;
for (let fieldRank = 0; fieldRank < fields.length; fieldRank += 1) {
const field = fields[fieldRank];
if (!field) continue;
const score = scoreMatch(token, field, { fuzzy });
if (!score) continue;
if (!best || compareMatchScores(score, best.score) < 0) {
best = { score, fieldRank, token };
}
}
return best;
}
/** Overlapping spans from different tokens would double-mark the same glyphs. */
function mergeRanges(ranges: MatchRange[]): MatchRange[] {
const sorted = [...ranges].sort((left, right) => left.start - right.start);
const merged: MatchRange[] = [];
for (const range of sorted) {
const last = merged.at(-1);
if (last && range.start <= last.start + last.length) {
last.length = Math.max(last.length, range.start + range.length - last.start);
continue;
}
merged.push({ ...range });
}
return merged;
}
/**
* One number so the wire can carry relevance and several hosts' pages can be
* merged by it. Tier dominates, then which field matched, then how deep into
* that field the match sat.
*/
function toSearchScore(matches: readonly FieldMatch[]): number {
let total = 0;
for (const match of matches) {
total += match.score.tier * 1_000 + match.fieldRank * 100 + Math.min(match.score.offset, 99);
}
return total;
}
/**
* Every token has to match something, so adding a word always narrows. Tokens
* may land on different fields — "stripe main" finds the Stripe workspace on
* the main branch.
*/
export function scoreAgentHistoryCandidate(
query: string,
candidate: AgentHistorySearchCandidate,
): number | null {
const matches = matchAgentHistoryCandidate(query, candidate);
return matches ? toSearchScore(matches) : null;
}
function matchAgentHistoryCandidate(
query: string,
candidate: AgentHistorySearchCandidate,
): FieldMatch[] | null {
const tokens = tokenizeQuery(query);
if (tokens.length === 0) return null;
const fields = searchableFields(candidate);
const matches: FieldMatch[] = [];
for (const token of tokens) {
const match = bestFieldMatch(token, fields);
if (!match) return null;
matches.push(match);
}
return matches;
}
/**
* Where the query landed in each field, for the rows that will actually be
* rendered. The ranker already knows this; sending it means the client never
* has to re-derive a second opinion that could disagree with the ranking it is
* explaining.
*/
export function describeAgentHistoryMatches(
query: string,
candidate: AgentHistorySearchCandidate,
): AgentSearchMatch[] {
const matches = matchAgentHistoryCandidate(query, candidate);
if (!matches) return [];
const fields = searchableFields(candidate);
const rangesByField = new Map<SearchField, MatchRange[]>();
for (const match of matches) {
const field = SEARCH_FIELDS[match.fieldRank];
const ranges = matchRanges(match.token, fields[match.fieldRank], match.score);
rangesByField.set(field, [...(rangesByField.get(field) ?? []), ...ranges]);
}
return [...rangesByField.entries()].map(([field, ranges]) => ({
field,
ranges: mergeRanges(ranges),
}));
}
export interface RankedAgentHistoryCandidate<T extends AgentHistorySearchCandidate> {
candidate: T;
searchScore: number;
}
/**
* Ranks the whole candidate set rather than a page of it. The daemon holds
* every persisted agent in memory already, so search is complete by
* construction and the client never has to warn that it only looked at what it
* had loaded.
*
* A searched response is one page: the best `limit` matches, and a flag saying
* more matched. There is no search cursor. Offsets into a ranking that is
* recomputed per request duplicate and skip rows as history mutates underneath
* them, and a keyset cursor would buy the ability to walk past result 200 of a
* relevance order — which is browsing, not searching. Narrowing the query is
* the answer, and the global top K is exactly recoverable from each host's own
* top K, which is what lets several hosts merge without a federated pager.
*/
export function rankAgentHistoryCandidates<T extends AgentHistorySearchCandidate>(
query: string,
candidates: readonly T[],
compareTies: (left: T, right: T) => number,
): RankedAgentHistoryCandidate<T>[] {
const ranked: RankedAgentHistoryCandidate<T>[] = [];
for (const candidate of candidates) {
const searchScore = scoreAgentHistoryCandidate(query, candidate);
if (searchScore === null) continue;
ranked.push({ candidate, searchScore });
}
ranked.sort((left, right) => {
if (left.searchScore !== right.searchScore) return left.searchScore - right.searchScore;
return compareTies(left.candidate, right.candidate);
});
return ranked;
}
+81
View File
@@ -31,6 +31,7 @@ import type { TerminalActivity } from "@getpaseo/protocol/terminal-activity";
import type { BinaryFrame } from "@getpaseo/protocol/binary-frames/index";
import { CursorError } from "./pagination/cursor.js";
import { SortablePager, type SortSpec } from "./pagination/sortable-pager.js";
import { describeAgentHistoryMatches, rankAgentHistoryCandidates } from "./agent-history-search.js";
import type { SpeechToTextProvider, TextToSpeechProvider } from "./speech/speech-provider.js";
import type { TurnDetectionProvider } from "./speech/turn-detection-provider.js";
import {
@@ -348,6 +349,15 @@ type FetchAgentHistoryRequestMessage = Extract<
{ type: "fetch_agent_history_request" }
>;
type AgentDirectoryRequestMessage = FetchAgentsRequestMessage | FetchAgentHistoryRequestMessage;
/**
* Only history carries a query. The active-agents directory filters on
* structure and never ranks, so it always reads as no query at all.
*/
function agentDirectorySearchQuery(request: AgentDirectoryRequestMessage): string {
if (request.type !== "fetch_agent_history_request") return "";
return request.search?.trim() ?? "";
}
type FetchAgentsRequestFilter = NonNullable<FetchAgentsRequestMessage["filter"]>;
type FetchAgentsRequestSort = NonNullable<FetchAgentsRequestMessage["sort"]>[number];
type FetchAgentsResponsePayload = Extract<
@@ -4228,6 +4238,7 @@ export class Session {
private async listFetchAgentsEntries(request: AgentDirectoryRequestMessage): Promise<{
entries: FetchAgentsResponseEntry[];
pageInfo: FetchAgentsResponsePageInfo;
searchTruncated?: boolean;
}> {
const filter =
request.type === "fetch_agent_history_request" &&
@@ -4272,6 +4283,18 @@ export class Session {
return placementPromise;
};
const search = agentDirectorySearchQuery(request);
if (search) {
return this.listRankedAgentHistoryEntries({
search,
agents,
sort,
filter,
getPlacement,
page: request.page,
});
}
let candidates = [...agents];
candidates.sort((left, right) => this.agentsPager.compare(left, right, sort));
const cursorToken = request.page?.cursor;
@@ -4308,6 +4331,64 @@ export class Session {
};
}
/**
* The searched history page. Ranking has to see every candidate before it can
* name the best one, so this path resolves placements for the whole set
* instead of stopping at the page limit — that is what makes a query answer
* from all persisted sessions rather than from the first page of them.
*/
private async listRankedAgentHistoryEntries(params: {
search: string;
agents: AgentSnapshotPayload[];
sort: FetchAgentsRequestSort[];
filter: AgentUpdatesFilter | undefined;
getPlacement: (workspaceId: string | undefined) => Promise<ProjectPlacementPayload | null>;
page: AgentDirectoryRequestMessage["page"];
}): Promise<{
entries: FetchAgentsResponseEntry[];
pageInfo: FetchAgentsResponsePageInfo;
searchTruncated: boolean;
}> {
const { search, agents, sort, filter, getPlacement, page } = params;
if (page?.cursor) {
// A ranked result set has no pages to walk, so a cursor here is caller
// misuse. Returning the ranked head instead would hide it.
throw new SessionRequestError(
"invalid_cursor",
"A history search returns one ranked page; it cannot be paged with a cursor.",
);
}
const allEntries = await this.collectFetchAgentsEntries({
candidates: agents,
limit: Number.MAX_SAFE_INTEGER,
getPlacement,
filter,
});
const ranked = rankAgentHistoryCandidates(search, allEntries, (left, right) =>
this.agentsPager.compare(left.agent, right.agent, sort),
);
const limit = page?.limit ?? 200;
// Ranges are derived only for the rows that will be rendered; ranking
// itself never needs them.
const entries = ranked.slice(0, limit).map((result) =>
Object.assign({}, result.candidate, {
searchScore: result.searchScore,
searchMatches: describeAgentHistoryMatches(search, result.candidate),
}),
);
return {
entries,
// No next page exists, so `hasMore` is false and truncation is reported
// on its own field. See the note on rankAgentHistoryCandidates.
pageInfo: { nextCursor: null, prevCursor: null, hasMore: false },
searchTruncated: ranked.length > limit,
};
}
private readonly agentsPager = new SortablePager<
AgentSnapshotPayload,
FetchAgentsRequestSort["key"]
@@ -2786,6 +2786,169 @@ test("fetch_agent_history_request pages archived historical rows separately", as
expect(session.agentUpdates.hasSubscription()).toBe(false);
});
test("fetch_agent_history_request ranks a search across the whole history, not one page", async () => {
const emitted: SessionOutboundMessage[] = [];
const session = createSessionForWorkspaceTests();
const historyCwd = path.resolve("/tmp/history-search");
const project = createPersistedProjectRecord({
projectId: "proj-search",
rootPath: historyCwd,
kind: "non_git",
displayName: "history-search",
createdAt: "2026-03-01T12:00:00.000Z",
updatedAt: "2026-03-01T12:00:00.000Z",
});
const workspace = createPersistedWorkspaceRecord({
workspaceId: "ws-search",
projectId: project.projectId,
cwd: historyCwd,
kind: "directory",
displayName: "history-search",
createdAt: "2026-03-01T12:00:00.000Z",
updatedAt: "2026-03-01T12:00:00.000Z",
});
session.emit = (message) => {
if (isSessionOutboundMessage(message)) emitted.push(message);
};
session.projectRegistry.get = async () => project;
session.workspaceRegistry.list = async () => [workspace];
session.workspaceRegistry.get = async () => workspace;
session.listAgentPayloads = async () => [
// The strong match is the oldest row, so a chronological answer would rank
// it last and a first-page-only search would not see it at all.
{
...makeAgent({
id: "weak",
cwd: historyCwd,
workspaceId: "ws-search",
status: "idle",
updatedAt: "2026-03-03T12:00:00.000Z",
}),
title: "Unbilled usage report",
},
{
...makeAgent({
id: "unrelated",
cwd: historyCwd,
workspaceId: "ws-search",
status: "idle",
updatedAt: "2026-03-02T12:00:00.000Z",
}),
title: "Terminal resize fix",
},
{
...makeAgent({
id: "strong",
cwd: historyCwd,
workspaceId: "ws-search",
status: "idle",
updatedAt: "2026-03-01T12:00:00.000Z",
}),
title: "Add Stripe billing",
},
];
await session.handleMessage({
type: "fetch_agent_history_request",
requestId: "req-search-truncated",
search: "bill",
page: { limit: 1 },
});
const truncated = emitted[0];
if (truncated?.type !== "fetch_agent_history_response") {
throw new Error(`Expected a history response, got ${truncated?.type}`);
}
expect(truncated.payload.entries.map((entry) => entry.agent.id)).toEqual(["strong"]);
expect(truncated.payload.entries[0].searchScore).toBeTypeOf("number");
// More matched than fit. `hasMore` stays false because no page is fetchable;
// truncation is its own fact, so a rank offset can never go stale.
expect(truncated.payload.searchTruncated).toBe(true);
expect(truncated.payload.pageInfo).toEqual({
nextCursor: null,
prevCursor: null,
hasMore: false,
});
await session.handleMessage({
type: "fetch_agent_history_request",
requestId: "req-search-whole",
search: "bill",
page: { limit: 25 },
});
const whole = emitted[1];
if (whole?.type !== "fetch_agent_history_response") {
throw new Error(`Expected a history response, got ${whole?.type}`);
}
expect(whole.payload.entries.map((entry) => entry.agent.id)).toEqual(["strong", "weak"]);
expect(whole.payload.searchTruncated).toBe(false);
expect(whole.payload.pageInfo.hasMore).toBe(false);
});
test("fetch_agent_history_request rejects a cursor on a searched request", async () => {
const emitted: SessionOutboundMessage[] = [];
const session = createSessionForWorkspaceTests();
const historyCwd = path.resolve("/tmp/history-cursor");
const project = createPersistedProjectRecord({
projectId: "proj-cursor",
rootPath: historyCwd,
kind: "non_git",
displayName: "history-cursor",
createdAt: "2026-03-01T12:00:00.000Z",
updatedAt: "2026-03-01T12:00:00.000Z",
});
const workspace = createPersistedWorkspaceRecord({
workspaceId: "ws-cursor",
projectId: project.projectId,
cwd: historyCwd,
kind: "directory",
displayName: "history-cursor",
createdAt: "2026-03-01T12:00:00.000Z",
updatedAt: "2026-03-01T12:00:00.000Z",
});
session.emit = (message) => {
if (isSessionOutboundMessage(message)) emitted.push(message);
};
session.projectRegistry.get = async () => project;
session.workspaceRegistry.list = async () => [workspace];
session.workspaceRegistry.get = async () => workspace;
session.listAgentPayloads = async () => [
{
...makeAgent({
id: "match",
cwd: historyCwd,
workspaceId: "ws-cursor",
status: "idle",
updatedAt: "2026-03-01T12:00:00.000Z",
}),
title: "Add Stripe billing",
},
];
// A ranked result set has no pages to walk. Answering with the ranked head
// would let a caller believe it had paged, so this fails loudly instead.
await session.handleMessage({
type: "fetch_agent_history_request",
requestId: "req-cursor",
search: "billing",
page: { limit: 25, cursor: "eyJpZCI6ImFnZW50In0=" },
});
expect(emitted).toEqual([
{
type: "rpc_error",
payload: expect.objectContaining({
requestId: "req-cursor",
requestType: "fetch_agent_history_request",
code: "invalid_cursor",
}),
},
]);
});
test("fetch_agent_history_request skips rows whose workspace project record is missing", async () => {
const emitted: SessionOutboundMessage[] = [];
const session = createSessionForWorkspaceTests();
@@ -1545,6 +1545,8 @@ export class VoiceAssistantWebSocketServer {
rewind: true,
// COMPAT(agentTimelinePromptIndex): added in v0.2.X, drop the gate when floor >= v0.2.X.
agentTimelinePromptIndex: true,
// COMPAT(agentHistorySearch): added in v0.3.0, remove gate after 2027-02-07.
agentHistorySearch: true,
// COMPAT(checkoutRefresh): added in v0.1.86, remove gate after 2026-11-29.
checkoutRefresh: true,
// COMPAT(workspaceMultiplicity): added in v0.1.97, drop the gate when floor >= v0.1.97