diff --git a/packages/app/e2e/browser/sessions-search-hosts.spec.ts b/packages/app/e2e/browser/sessions-search-hosts.spec.ts new file mode 100644 index 000000000..e1518a946 --- /dev/null +++ b/packages/app/e2e/browser/sessions-search-hosts.spec.ts @@ -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 { + await page.getByTestId("sessions-search-input").fill(query); +} + +async function selectHost(page: Page, serverId: string): Promise { + 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 { + 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> | null = null; + let secondaryWorkspace: Awaited> | 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); + } + }); +}); diff --git a/packages/app/e2e/browser/sessions-search.spec.ts b/packages/app/e2e/browser/sessions-search.spec.ts new file mode 100644 index 000000000..f9fa0c596 --- /dev/null +++ b/packages/app/e2e/browser/sessions-search.spec.ts @@ -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 " 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 { + 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 { + 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>; + let tempRepo: { path: string; cleanup: () => Promise }; + 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]); + }); +}); diff --git a/packages/app/src/components/agent-list.tsx b/packages/app/src/components/agent-list.tsx index 980fd7848..386a8c2f9 100644 --- a/packages/app/src/components/agent-list.tsx +++ b/packages/app/src/components/agent-list.tsx @@ -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; + /** + * 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 ( <> - - {workspaceName} - + ); @@ -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({ - - {agent.title || t("agentList.fallbackTitle")} - + {isMobile ? ( - - {projectName} - + /> · - - {branch} - + /> · - - {workspaceName} - + /> · {timeAgo} {showHostColumn && agent.serverLabel ? ( @@ -323,25 +355,25 @@ function SessionRow({ {!isMobile ? ( - - {projectName} - + /> {showHostColumn ? ( {agent.serverLabel} ) : null} - - {branch} - + /> {timeAgo} @@ -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(); 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 = useCallback( ({ item }) => { @@ -466,6 +508,7 @@ export function AgentList({ return ( 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(null); @@ -64,6 +66,7 @@ export function HostFilter({ searchable={false} title="Filter by host" desktopPlacement="bottom-start" + hostOptionTestID={hostOptionTestID} > ); } diff --git a/packages/app/src/components/provider-diagnostic-sheet.tsx b/packages/app/src/components/provider-diagnostic-sheet.tsx index aecfaea4c..e77d20e3f 100644 --- a/packages/app/src/components/provider-diagnostic-sheet.tsx +++ b/packages/app/src/components/provider-diagnostic-sheet.tsx @@ -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 { diff --git a/packages/app/src/components/ui/combobox-options.ts b/packages/app/src/components/ui/combobox-options.ts index 7ec8545d5..e23f2315e 100644 --- a/packages/app/src/components/ui/combobox-options.ts +++ b/packages/app/src/components/ui/combobox-options.ts @@ -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"; diff --git a/packages/app/src/components/ui/highlighted-text-segments.test.ts b/packages/app/src/components/ui/highlighted-text-segments.test.ts new file mode 100644 index 000000000..b38ed4664 --- /dev/null +++ b/packages/app/src/components/ui/highlighted-text-segments.test.ts @@ -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 }]); + }); +}); diff --git a/packages/app/src/components/ui/highlighted-text-segments.ts b/packages/app/src/components/ui/highlighted-text-segments.ts new file mode 100644 index 000000000..65fdbe2e7 --- /dev/null +++ b/packages/app/src/components/ui/highlighted-text-segments.ts @@ -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; +} diff --git a/packages/app/src/components/ui/highlighted-text.tsx b/packages/app/src/components/ui/highlighted-text.tsx new file mode 100644 index 000000000..6f2dceb4a --- /dev/null +++ b/packages/app/src/components/ui/highlighted-text.tsx @@ -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; + 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} + + ); + } + + return ( + + {segments.map((segment) => + segment.marked ? ( + + {segment.text} + + ) : ( + {segment.text} + ), + )} + + ); +} + +const styles = StyleSheet.create((theme) => ({ + mark: { + backgroundColor: theme.colors.surface3, + color: theme.colors.foreground, + }, +})); diff --git a/packages/app/src/components/ui/search-field.tsx b/packages/app/src/components/ui/search-field.tsx new file mode 100644 index 000000000..242b84761 --- /dev/null +++ b/packages/app/src/components/ui/search-field.tsx @@ -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 ( + + + + {value.length > 0 ? ( + + + + ) : null} + + ); +} + +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, + }, +})); diff --git a/packages/app/src/hooks/use-agent-history.test.ts b/packages/app/src/hooks/use-agent-history.test.ts index 856d1b334..3fcdd3df5 100644 --- a/packages/app/src/hooks/use-agent-history.test.ts +++ b/packages/app/src/hooks/use-agent-history.test.ts @@ -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({ diff --git a/packages/app/src/hooks/use-agent-history.ts b/packages/app/src/hooks/use-agent-history.ts index 540ecedcf..b24c5f8ee 100644 --- a/packages/app/src/hooks/use-agent-history.ts +++ b/packages/app/src/hooks/use-agent-history.ts @@ -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; + /** Hosts that failed while others succeeded. The list still renders. */ + hostErrors: AgentHistoryHostError[]; refreshAll: () => Promise; 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; + /** Where the query matched in each row, keyed like `searchScoreByAgentKey`. */ + searchMatchesByAgentKey: Record; 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; +/** + * 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; + searchMatchesByAgentKey: Record; pageInfoByServerId: Record; + 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; export async function fetchAgentHistoryPage(input: { client: AgentHistoryClient; serverId: string; cursor: string | null; + search?: string; }): Promise { 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 = {}; + const searchMatchesByAgentKey: Record = {}; + 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, + 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[]; + unreachableHosts: readonly AgentHistoryHostError[]; +}): AgentHistoryHostError[] { + const byServerId = new Map(); + 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 { 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; + const searchMatchesByAgentKey = Object.assign( + {}, + ...pages.map(({ page }) => page.searchMatchesByAgentKey), + ) as Record; + // 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; + 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, + [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, }; diff --git a/packages/app/src/hooks/use-debounced-value.ts b/packages/app/src/hooks/use-debounced-value.ts new file mode 100644 index 000000000..a2d033c81 --- /dev/null +++ b/packages/app/src/hooks/use-debounced-value.ts @@ -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(value: T, delayMs: number): T { + const [debounced, setDebounced] = useState(value); + + useEffect(() => { + const timer = setTimeout(() => setDebounced(value), delayMs); + return () => clearTimeout(timer); + }, [value, delayMs]); + + return debounced; +} diff --git a/packages/app/src/i18n/resources/ar.ts b/packages/app/src/i18n/resources/ar.ts index f65391b2b..c84a2f098 100644 --- a/packages/app/src/i18n/resources/ar.ts +++ b/packages/app/src/i18n/resources/ar.ts @@ -231,8 +231,13 @@ export const ar: TranslationResources = { sessions: { title: "السجل", empty: "لا توجد جلسات بعد", + noMatches: "لا توجد جلسات مطابقة", + tooManyMatches: "نتائج كثيرة جدًا — ضيّق نطاق البحث", + hostLoadFailed: "{{host}}: تعذر تحميل السجل", + searchPlaceholder: "البحث في السجل", actions: { loadMore: "تحميل المزيد", + clearSearch: "مسح البحث", }, }, agentList: { diff --git a/packages/app/src/i18n/resources/en.ts b/packages/app/src/i18n/resources/en.ts index 1b0fad67a..67a066112 100644 --- a/packages/app/src/i18n/resources/en.ts +++ b/packages/app/src/i18n/resources/en.ts @@ -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: { diff --git a/packages/app/src/i18n/resources/es.ts b/packages/app/src/i18n/resources/es.ts index 1d408c45f..8536cc9eb 100644 --- a/packages/app/src/i18n/resources/es.ts +++ b/packages/app/src/i18n/resources/es.ts @@ -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: { diff --git a/packages/app/src/i18n/resources/fr.ts b/packages/app/src/i18n/resources/fr.ts index 027027527..2d3305cbc 100644 --- a/packages/app/src/i18n/resources/fr.ts +++ b/packages/app/src/i18n/resources/fr.ts @@ -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: { diff --git a/packages/app/src/i18n/resources/ja.ts b/packages/app/src/i18n/resources/ja.ts index aa024e30c..20590fa3d 100644 --- a/packages/app/src/i18n/resources/ja.ts +++ b/packages/app/src/i18n/resources/ja.ts @@ -234,8 +234,13 @@ export const ja: TranslationResources = { sessions: { title: "履歴", empty: "セッションがまだありません", + noMatches: "一致するセッションはありません", + tooManyMatches: "一致が多すぎます — 検索条件を絞ってください", + hostLoadFailed: "{{host}}: 履歴を読み込めませんでした", + searchPlaceholder: "履歴を検索", actions: { loadMore: "さらに読み込む", + clearSearch: "検索をクリア", }, }, agentList: { diff --git a/packages/app/src/i18n/resources/ko.ts b/packages/app/src/i18n/resources/ko.ts index e1c98ce4f..c2268ed65 100644 --- a/packages/app/src/i18n/resources/ko.ts +++ b/packages/app/src/i18n/resources/ko.ts @@ -232,8 +232,13 @@ export const ko: TranslationResources = { sessions: { title: "기록", empty: "아직 세션이 없습니다", + noMatches: "일치하는 세션이 없습니다", + tooManyMatches: "일치 항목이 너무 많습니다 — 검색 범위를 좁히세요", + hostLoadFailed: "{{host}}: 기록을 불러오지 못했습니다", + searchPlaceholder: "기록 검색", actions: { loadMore: "더 불러오기", + clearSearch: "검색 지우기", }, }, agentList: { diff --git a/packages/app/src/i18n/resources/pt-BR.ts b/packages/app/src/i18n/resources/pt-BR.ts index 5ddd452e5..99de443f8 100644 --- a/packages/app/src/i18n/resources/pt-BR.ts +++ b/packages/app/src/i18n/resources/pt-BR.ts @@ -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: { diff --git a/packages/app/src/i18n/resources/ru.ts b/packages/app/src/i18n/resources/ru.ts index af30f3a94..3d9cd2601 100644 --- a/packages/app/src/i18n/resources/ru.ts +++ b/packages/app/src/i18n/resources/ru.ts @@ -233,8 +233,13 @@ export const ru: TranslationResources = { sessions: { title: "История", empty: "Сеансов пока нет", + noMatches: "Нет подходящих сеансов", + tooManyMatches: "Слишком много совпадений — уточните запрос", + hostLoadFailed: "{{host}}: Не удалось загрузить историю", + searchPlaceholder: "Поиск по истории", actions: { loadMore: "Загрузить больше", + clearSearch: "Очистить поиск", }, }, agentList: { diff --git a/packages/app/src/i18n/resources/zh-CN.ts b/packages/app/src/i18n/resources/zh-CN.ts index a54eeba58..45b13f0cb 100644 --- a/packages/app/src/i18n/resources/zh-CN.ts +++ b/packages/app/src/i18n/resources/zh-CN.ts @@ -231,8 +231,13 @@ export const zhCN: TranslationResources = { sessions: { title: "历史", empty: "还没有会话", + noMatches: "没有匹配的会话", + tooManyMatches: "匹配过多 — 请缩小搜索范围", + hostLoadFailed: "{{host}}:无法加载历史", + searchPlaceholder: "搜索历史", actions: { loadMore: "加载更多", + clearSearch: "清除搜索", }, }, agentList: { diff --git a/packages/app/src/provider-selection/provider-selection.ts b/packages/app/src/provider-selection/provider-selection.ts index 00b9a766c..6e3a48de3 100644 --- a/packages/app/src/provider-selection/provider-selection.ts +++ b/packages/app/src/provider-selection/provider-selection.ts @@ -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 }; diff --git a/packages/app/src/screens/sessions-screen.tsx b/packages/app/src/screens/sessions-screen.tsx index 89ecfd431..d5e0cc7ea 100644 --- a/packages/app/src/screens/sessions-screen.tsx +++ b/packages/app/src/screens/sessions-screen.tsx @@ -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 ( + + + {errors.map((error) => ( + + {t("sessions.hostLoadFailed", { host: error.serverName })} + + ))} + + + ); +} + +/** 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 ( - + {t("sessions.tooManyMatches")} - ) : null, - [hasMore, loadMore, isLoadingMore, t], - ); + ); + } + if (!hasMore) { + return null; + } + return ( + + + + ); + }, [hasMore, isLoadingMore, isSearchTruncated, loadMore, t]); return ( - {showHostFilter ? ( + {showFilterRow ? ( - + {isSearchSupported ? ( + + ) : null} + {showHostFilter ? ( + + ) : null} ) : null} + {hostErrors.length > 0 ? : null} {isInitialLoad ? ( @@ -103,23 +189,31 @@ function SessionsScreenContent() { ) : null} - {!isInitialLoad && !showLoadError && sortedAgents.length === 0 ? ( - + {!isInitialLoad && !showLoadError && agents.length === 0 ? ( + {emptyText} - + {isSearching ? ( + + ) : ( + + )} ) : null} - {!isInitialLoad && !showLoadError && sortedAgents.length > 0 ? ( + {!isInitialLoad && !showLoadError && agents.length > 0 ? ( ) : null} @@ -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, + }, })); diff --git a/packages/app/src/utils/agent-command-autocomplete.ts b/packages/app/src/utils/agent-command-autocomplete.ts index a9abb2f9c..39ee7f94d 100644 --- a/packages/app/src/utils/agent-command-autocomplete.ts +++ b/packages/app/src/utils/agent-command-autocomplete.ts @@ -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: { diff --git a/packages/app/src/utils/score-match.test.ts b/packages/app/src/utils/score-match.test.ts deleted file mode 100644 index 88177a2e7..000000000 --- a/packages/app/src/utils/score-match.test.ts +++ /dev/null @@ -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); - }); -}); diff --git a/packages/app/src/utils/score-match.ts b/packages/app/src/utils/score-match.ts deleted file mode 100644 index 472100f26..000000000 --- a/packages/app/src/utils/score-match.ts +++ /dev/null @@ -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; -} diff --git a/packages/app/src/utils/working-directory-suggestions.ts b/packages/app/src/utils/working-directory-suggestions.ts index 13d53e8b8..50510fd9b 100644 --- a/packages/app/src/utils/working-directory-suggestions.ts +++ b/packages/app/src/utils/working-directory-suggestions.ts @@ -1,4 +1,4 @@ -import { scoreMatch } from "./score-match"; +import { scoreMatch } from "@getpaseo/protocol/search/text-match"; export interface BuildWorkingDirectorySuggestionsInput { recommendedPaths: string[]; diff --git a/packages/client/src/daemon-client.ts b/packages/client/src/daemon-client.ts index 50bc688eb..984fb7942 100644 --- a/packages/client/src/daemon-client.ts +++ b/packages/client/src/daemon-client.ts @@ -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 } : {}), }); diff --git a/packages/protocol/src/messages.ts b/packages/protocol/src/messages.ts index e9acb24bd..abc23a24d 100644 --- a/packages/protocol/src/messages.ts +++ b/packages/protocol/src/messages.ts @@ -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; + 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(), }), }); diff --git a/packages/protocol/src/search/text-match.test.ts b/packages/protocol/src/search/text-match.test.ts new file mode 100644 index 000000000..b7c09ee30 --- /dev/null +++ b/packages/protocol/src/search/text-match.test.ts @@ -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[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([]); + }); +}); diff --git a/packages/protocol/src/search/text-match.ts b/packages/protocol/src/search/text-match.ts new file mode 100644 index 000000000..63c2a36c7 --- /dev/null +++ b/packages/protocol/src/search/text-match.ts @@ -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; +} diff --git a/packages/server/src/server/agent-history-search.test.ts b/packages/server/src/server/agent-history-search.test.ts new file mode 100644 index 000000000..036efa7ab --- /dev/null +++ b/packages/server/src/server/agent-history-search.test.ts @@ -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([]); + }); +}); diff --git a/packages/server/src/server/agent-history-search.ts b/packages/server/src/server/agent-history-search.ts new file mode 100644 index 000000000..9960fb3d1 --- /dev/null +++ b/packages/server/src/server/agent-history-search.ts @@ -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(); + 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 { + 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( + query: string, + candidates: readonly T[], + compareTies: (left: T, right: T) => number, +): RankedAgentHistoryCandidate[] { + const ranked: RankedAgentHistoryCandidate[] = []; + 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; +} diff --git a/packages/server/src/server/session.ts b/packages/server/src/server/session.ts index ecbdcc309..c74d5d89b 100644 --- a/packages/server/src/server/session.ts +++ b/packages/server/src/server/session.ts @@ -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; type FetchAgentsRequestSort = NonNullable[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; + 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"] diff --git a/packages/server/src/server/session.workspaces.test.ts b/packages/server/src/server/session.workspaces.test.ts index 8ec48b34a..691164f8a 100644 --- a/packages/server/src/server/session.workspaces.test.ts +++ b/packages/server/src/server/session.workspaces.test.ts @@ -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(); diff --git a/packages/server/src/server/websocket-server.ts b/packages/server/src/server/websocket-server.ts index be1dc9cf7..18e9ac02a 100644 --- a/packages/server/src/server/websocket-server.ts +++ b/packages/server/src/server/websocket-server.ts @@ -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