From 0f20e6dfe4c2573e203dea2aae00aa5983ce4d62 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Wed, 16 Sep 2026 16:17:46 +0200 Subject: [PATCH] Keep History search chronological and make matches clear (#4945) * fix(history): preserve chronology and clarify search matches Keep workspace names primary and highlight each searched field so results explain why they matched. Filter before cursor pagination and retain date buckets. Constrain shared subsequence matching to each whitespace-delimited word while preserving fuzzy abbreviations in Command Center and other consumers. * test(history): reuse response helper in pagination regression * test(history): express browser journey through named helpers * fix(app): start menu entering animation only once the surface is placed Reanimated's web entering path snapshots the element rect at animationend and writes top/left back when it cleans up the keyframe 750ms later. When the 150ms animation ends before anchor and content measurement resolve, that snapshot is the off-screen measuring position and the open menu jumps back to -9999. Remount the surface when its position resolves so the animation starts from the final position. --- docs/menus.md | 4 + .../e2e/browser/sessions-search-hosts.spec.ts | 27 +-- .../app/e2e/browser/sessions-search.spec.ts | 149 ++++++++----- .../app/src/command-center/results.test.ts | 10 +- packages/app/src/command-center/results.ts | 12 +- packages/app/src/components/agent-list.tsx | 167 ++++++--------- .../ui/highlighted-text-segments.test.ts | 39 +++- .../ui/highlighted-text-segments.ts | 28 ++- .../src/components/ui/menu/menu-overlay.tsx | 17 +- .../app/src/hooks/use-agent-history.test.ts | 25 +-- packages/app/src/hooks/use-agent-history.ts | 112 +--------- packages/app/src/screens/sessions-screen.tsx | 10 +- packages/protocol/src/messages.ts | 18 +- .../protocol/src/search/text-match.test.ts | 25 ++- packages/protocol/src/search/text-match.ts | 19 +- .../src/server/agent-history-search.test.ts | 162 +++----------- .../server/src/server/agent-history-search.ts | 198 ++---------------- .../agent-rpc-durability.e2e.test.ts | 45 +++- packages/server/src/server/session.ts | 76 +------ .../src/server/session.workspaces.test.ts | 46 ++-- 20 files changed, 435 insertions(+), 754 deletions(-) diff --git a/docs/menus.md b/docs/menus.md index 4a8da1c202..3a6829d738 100644 --- a/docs/menus.md +++ b/docs/menus.md @@ -179,6 +179,10 @@ its own. - **Released height.** Reanimated's web entering animation leaves an inline height snapshot on the surface. `AnchoredSurface` clears it, and a `revision` prop re-clears it when content identity changes — a pushed page taller than the one it replaced is clipped without that. +- **Animate only once placed.** The same snapshot carries top/left, and Reanimated writes it back + 750ms after mount. `AnchoredSurface` remounts the surface when its position resolves so the + entering animation never ends at the off-screen measuring position; on a slow machine that + write moved the open menu back off-screen. - **Sheets size to content.** `enableDynamicSizing`, not fixed snap points. A pushed page is rarely the height of the page before it. - **The sheet's content is teleported out of the menu's subtree**, so `MenuSheetSurface` rebuilds diff --git a/packages/app/e2e/browser/sessions-search-hosts.spec.ts b/packages/app/e2e/browser/sessions-search-hosts.spec.ts index e1518a946e..3192f513bc 100644 --- a/packages/app/e2e/browser/sessions-search-hosts.spec.ts +++ b/packages/app/e2e/browser/sessions-search-hosts.spec.ts @@ -12,7 +12,6 @@ import { getServerId } from "../support/helpers/server-id"; // 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"; @@ -28,21 +27,19 @@ async function selectHost(page: Page, serverId: string): Promise { 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 }); +async function expectChronologicalTitles(page: Page, titles: string[]): Promise { + const rows = page.getByRole("button").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. +// Searching across hosts merges each host's chronological matching sessions. 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 ({ + test("orders matches from every host chronologically and narrows to one host on demand", async ({ page, }) => { const secondaryServerId = `srv_hist_${randomUUID().replaceAll("-", "").slice(0, 12)}`; @@ -52,8 +49,7 @@ test.describe("History search across hosts", () => { 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. + // match is the oldest on the secondary; chronology must win over relevance. const weakTitle = `${NONCE} Unbilled usage report`; const strongTitle = `${NONCE} Bill the customer`; const unrelatedTitle = `${NONCE} Terminal resize fix`; @@ -87,22 +83,21 @@ test.describe("History search across hosts", () => { }); await openSessions(page); - // All hosts: the stronger match leads despite being the older session on - // the other daemon. + // All hosts: the newer partial match leads. await search(page, `${NONCE} bill`); - await expectRankedTitles(page, [strongTitle, weakTitle]); + await expectChronologicalTitles(page, [weakTitle, strongTitle]); // One host: the other daemon's stronger match drops out entirely. await selectHost(page, getServerId()); - await expectRankedTitles(page, [weakTitle]); + await expectChronologicalTitles(page, [weakTitle]); await selectHost(page, secondaryDaemon.serverId); - await expectRankedTitles(page, [strongTitle]); + await expectChronologicalTitles(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 }); + const rows = page.getByRole("button").filter({ hasText: NONCE }); await expect(rows).toHaveCount(3, { timeout: 30_000 }); } finally { await primaryWorkspace?.cleanup().catch(() => undefined); @@ -140,7 +135,7 @@ test.describe("History search across hosts", () => { await expect(page.getByTestId("sessions-host-errors")).toContainText(SECONDARY_LABEL, { timeout: 30_000, }); - await expectRankedTitles(page, [reachableTitle]); + await expectChronologicalTitles(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 index eac218b264..7f54617304 100644 --- a/packages/app/e2e/browser/sessions-search.spec.ts +++ b/packages/app/e2e/browser/sessions-search.spec.ts @@ -9,8 +9,6 @@ import { 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 @@ -19,9 +17,9 @@ const AGENT_ROW = '[data-testid^="agent-row-"]'; 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`, + billing: `${NONCE} main Add Stripe billing`, + unbilled: `${NONCE} main Unbilled usage report`, + terminal: `${NONCE} main Terminal resize fix`, } as const; async function search(page: Page, query: string): Promise { @@ -29,7 +27,7 @@ async function search(page: Page, query: string): Promise { } function rowTitles(page: Page) { - return page.locator(AGENT_ROW); + return page.getByRole("button"); } async function expectVisibleTitles(page: Page, titles: string[]): Promise { @@ -40,6 +38,75 @@ async function expectVisibleTitles(page: Page, titles: string[]): Promise } } +async function verifyChronologicalFiltering(page: Page): Promise { + await test.step("narrows history while preserving chronological grouping", async () => { + await expectVisibleTitles(page, [TITLES.unbilled, TITLES.billing, TITLES.terminal]); + await expect(page.getByText("Today", { exact: true })).toHaveCount(1, { + timeout: 30_000, + }); + + await search(page, `${NONCE} billing`); + await expectVisibleTitles(page, [TITLES.billing]); + await expect(page.getByText("Today", { exact: true })).toHaveCount(1, { + timeout: 30_000, + }); + + await page.getByTestId("sessions-search-clear").click(); + await expect(page.getByTestId("sessions-search-input")).toHaveValue(""); + await expectVisibleTitles(page, [TITLES.unbilled, TITLES.billing, TITLES.terminal]); + await expect(page.getByText("Today", { exact: true })).toHaveCount(1, { + timeout: 30_000, + }); + }); +} + +async function verifyRecencyBeforeMatchStrength(page: Page): Promise { + await test.step("keeps newer partial matches before older stronger matches", async () => { + await search(page, `${NONCE} bill`); + await expectVisibleTitles(page, [TITLES.unbilled, TITLES.billing]); + }); +} + +async function verifyExactAndTypoHighlights(page: Page): Promise { + await test.step("highlights exact and typo-resolved matches", async () => { + await search(page, `${NONCE} billing`); + const row = page.getByRole("button").filter({ hasText: NONCE }).first(); + await expect(row.getByText("billing", { exact: true })).toBeVisible({ timeout: 30_000 }); + + await search(page, `${NONCE} bulling`); + await expectVisibleTitles(page, [TITLES.billing]); + const typoRow = page.getByRole("button").filter({ hasText: NONCE }).first(); + await expect(typoRow.getByText("billing", { exact: true })).toBeVisible({ + timeout: 30_000, + }); + }); +} + +async function verifyAllSearchFieldHighlights(page: Page): Promise { + await test.step("highlights workspace, agent, project and branch independently", async () => { + await search(page, `${NONCE} main`); + await expectVisibleTitles(page, [TITLES.unbilled, TITLES.billing, TITLES.terminal]); + const row = rowTitles(page).filter({ hasText: NONCE }).first(); + for (const field of ["workspace", "title", "project", "branch"]) { + await expect( + row.getByTestId(new RegExp(`^agent-row-${field}-`)).getByText(/^main$/i), + ).toBeVisible(); + } + await expect(page.getByText("Yesterday", { exact: true })).toHaveCount(0); + }); +} + +async function verifyEmptySearchAndRecovery(page: Page): Promise { + await test.step("distinguishes no matches from empty history", async () => { + 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.unbilled, TITLES.billing, TITLES.terminal]); + }); +} + test.describe("History search", () => { let client: Awaited>; let tempRepo: { path: string; cleanup: () => Promise }; @@ -48,10 +115,11 @@ test.describe("History search", () => { test.describe.configure({ timeout: 300_000 }); test.beforeAll(async () => { - tempRepo = await createTempGitRepo("sessions-search-"); + tempRepo = await createTempGitRepo("main-sessions-search-"); client = await connectSeedClient(); const created = await client.createWorkspace({ source: { kind: "directory", path: tempRepo.path }, + title: "Main history", }); if (!created.workspace) { throw new Error(created.error ?? `Failed to create workspace ${tempRepo.path}`); @@ -59,7 +127,7 @@ test.describe("History search", () => { projectId = created.workspace.projectId; const workspaceId = created.workspace.id; - for (const title of [TITLES.terminal, TITLES.unbilled, TITLES.billing]) { + for (const title of [TITLES.terminal, TITLES.billing, TITLES.unbilled]) { await createIdleAgent(client, { cwd: tempRepo.path, workspaceId, title }); } }); @@ -70,55 +138,26 @@ test.describe("History search", () => { await tempRepo?.cleanup(); }); - test("searches, ranks, highlights, and clears session history", async ({ page }) => { - await resetSeededPageState(page); - await openSessions(page); + for (const viewport of [ + { width: 1440, height: 1000 }, + { width: 390, height: 844 }, + ]) { + test(`filters chronological history and highlights matches at ${viewport.width}px`, async ({ + page, + }) => { + await resetSeededPageState(page); + await openSessions(page); + await page.setViewportSize(viewport); - await test.step("narrows history and restores chronological grouping", async () => { - await expectVisibleTitles(page, [TITLES.billing, TITLES.unbilled, TITLES.terminal]); - await expect(page.getByText("Today", { exact: true })).toHaveCount(1, { - timeout: 30_000, - }); + await verifyChronologicalFiltering(page); - await search(page, `${NONCE} billing`); - await expectVisibleTitles(page, [TITLES.billing]); - await expect(page.getByText("Today", { exact: true })).toHaveCount(0, { - timeout: 30_000, - }); + await verifyRecencyBeforeMatchStrength(page); - 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, - }); + await verifyExactAndTypoHighlights(page); + + await verifyAllSearchFieldHighlights(page); + + await verifyEmptySearchAndRecovery(page); }); - - await test.step("ranks whole-word matches above substrings", async () => { - await search(page, `${NONCE} bill`); - await expectVisibleTitles(page, [TITLES.billing, TITLES.unbilled]); - }); - - await test.step("highlights exact and typo-resolved matches", async () => { - await search(page, `${NONCE} billing`); - const row = page.locator(AGENT_ROW).filter({ hasText: NONCE }).first(); - await expect(row.getByText("billing", { exact: true })).toBeVisible({ timeout: 30_000 }); - - await search(page, `${NONCE} bulling`); - await expectVisibleTitles(page, [TITLES.billing]); - const typoRow = page.locator(AGENT_ROW).filter({ hasText: NONCE }).first(); - await expect(typoRow.getByText("billing", { exact: true })).toBeVisible({ - timeout: 30_000, - }); - }); - - await test.step("distinguishes no matches from empty history", async () => { - 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/command-center/results.test.ts b/packages/app/src/command-center/results.test.ts index d62ea92f35..4f6a43d704 100644 --- a/packages/app/src/command-center/results.test.ts +++ b/packages/app/src/command-center/results.test.ts @@ -363,11 +363,11 @@ describe("Command Center query tokenization", () => { } }); - it("requires each token to be adjacent characters, not a subsequence", () => { - // Unlike the composer's slash-command list, where "/pasbab" finds "/paseo-babysit". Dropping - // the subsequence tier is what keeps "No matches" meaning no matches: this list preselects - // its first row, so a query that should find nothing must not put an action under Enter. - for (const query of ["labdes", "lbl", "lasdsgn"]) { + it("matches subsequences within words without joining separate words", () => { + expect(sectionResultIds(buildContributionSections([labelAsDesign()], "lbl dsgn"))).toEqual([ + "label:design", + ]); + for (const query of ["labdes", "lasdsgn"]) { expect(sectionResultIds(buildContributionSections([labelAsDesign()], query)), query).toEqual( [], ); diff --git a/packages/app/src/command-center/results.ts b/packages/app/src/command-center/results.ts index ff9d5c90ad..8f3528cd2c 100644 --- a/packages/app/src/command-center/results.ts +++ b/packages/app/src/command-center/results.ts @@ -126,8 +126,6 @@ export interface CommandCenterSearchFields { hidden: readonly string[]; } -const MATCH_OPTIONS = { subsequence: false } as const; - /** * The one definition of "how well did this row match", shared by every scored row kind. Null * means the row is filtered out, so scoring and filtering are the same pass — a separate boolean @@ -138,19 +136,17 @@ const MATCH_OPTIONS = { subsequence: false } as const; * only ever widen the result set: tokens hold no whitespace, so any token that was found in the * old space-joined haystack lies entirely within one field and is still found here. * - * Subsequence matching is off, unlike the composer's slash-command list. Each token has to be a - * run of adjacent characters, so `lab des` matches and `labdes` does not. It widens hard enough - * that almost any typo finds something, and this list preselects its first row: a query the user - * expects to find nothing would instead put an unrelated action under Enter. + * Subsequence matches stay within each word, so `lbl dsgn` finds "Label as Design" + * while `labdes` cannot assemble a match across those words. */ export function scoreSearchFields( query: string, fields: CommandCenterSearchFields, ): CommandCenterScore | null { - const visible = scoreTextFields(query, [...fields.visible], MATCH_OPTIONS); + const visible = scoreTextFields(query, [...fields.visible]); if (visible) return { fieldRank: VISIBLE_FIELD_RANK, match: visible }; if (fields.hidden.length === 0) return null; - const match = scoreTextFields(query, [...fields.visible, ...fields.hidden], MATCH_OPTIONS); + const match = scoreTextFields(query, [...fields.visible, ...fields.hidden]); return match ? { fieldRank: KEYWORD_FIELD_RANK, match } : null; } diff --git a/packages/app/src/components/agent-list.tsx b/packages/app/src/components/agent-list.tsx index 58023da386..0ff450739d 100644 --- a/packages/app/src/components/agent-list.tsx +++ b/packages/app/src/components/agent-list.tsx @@ -23,8 +23,7 @@ import { navigateToAgent } from "@/utils/navigate-to-agent"; import { useArchiveAgent } from "@/hooks/use-archive-agent"; import { HighlightedText } from "@/components/ui/highlighted-text"; import { StatusBadge, type StatusBadgeVariant } from "@/components/ui/status-badge"; -import type { AgentSearchMatch } from "@getpaseo/protocol/messages"; -import type { MatchRange } from "@getpaseo/protocol/search/text-match"; +import { findHighlightRanges } from "@/components/ui/highlighted-text-segments"; interface AgentListProps { agents: AggregatedAgent[]; @@ -36,18 +35,7 @@ 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; + search?: string; } type DateSectionKey = "today" | "yesterday" | "thisWeek" | "thisMonth" | "older"; @@ -122,39 +110,6 @@ function SessionBadge({ return ; } -function WorkspaceTitlePrefix({ - visible, - workspaceName, - ranges, - testID, - iconSize, - color, -}: { - visible: boolean; - workspaceName: string; - ranges?: readonly MatchRange[]; - testID: string; - iconSize: number; - color: string; -}) { - if (!visible) { - return null; - } - - return ( - <> - - - - ); -} - function SessionRowBadges({ agent, archivedIcon, @@ -207,7 +162,7 @@ function SessionRowTrailingAttention({ function SessionRow({ agent, - searchMatches, + search, isMobile, selectedAgentId, showAttentionIndicator, @@ -216,7 +171,7 @@ function SessionRow({ onLongPress, }: { agent: AggregatedAgent; - searchMatches?: readonly AgentSearchMatch[]; + search?: string; isMobile: boolean; selectedAgentId?: string; showAttentionIndicator: boolean; @@ -234,10 +189,14 @@ function SessionRow({ const workspaceName = agent.projectPlacement?.workspaceName ?? ""; const ProviderIcon = getProviderIcon(agent.provider, agent.serverId); const pendingPermissionCount = agent.pendingPermissionCount ?? 0; - const rangesFor = useCallback( - (field: AgentSearchMatch["field"]) => - searchMatches?.find((match) => match.field === field)?.ranges, - [searchMatches], + const ranges = useMemo( + () => ({ + workspace: findHighlightRanges(search ?? "", workspaceName), + title: findHighlightRanges(search ?? "", agent.title ?? ""), + branch: findHighlightRanges(search ?? "", branch), + project: findHighlightRanges(search ?? "", projectName), + }), + [search, workspaceName, agent.title, branch, projectName], ); const pressableStyle = useCallback( @@ -253,11 +212,6 @@ function SessionRow({ const handlePress = useCallback(() => onPress(agent), [onPress, agent]); const handleLongPress = useCallback(() => onLongPress(agent), [onLongPress, agent]); - const sessionTitleStyle = useMemo( - () => [styles.sessionTitle, isSelected && styles.sessionTitleHighlighted], - [isSelected], - ); - const archivedIcon = useMemo( () => , [theme.fontSize.sm, theme.colors.foregroundMuted], @@ -265,32 +219,44 @@ function SessionRow({ const showDesktopAttention = !isMobile && showAttentionIndicator && Boolean(agent.requiresAttention); + const agentTitle = ( + + + + + + + ); + return ( - - - - + {!isMobile ? ( + <> + + {agentTitle} + + ) : null} + {isMobile ? agentTitle : null} {isMobile ? ( · · - - · {timeAgo} {showHostColumn && agent.serverLabel ? ( <> @@ -340,7 +299,7 @@ function SessionRow({ { - 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); @@ -477,7 +427,7 @@ export function AgentList({ } } return result; - }, [agents, flat]); + }, [agents]); const renderItem: ListRenderItem = useCallback( ({ item }) => { @@ -491,7 +441,7 @@ export function AgentList({ return ( ({ gap: theme.spacing[2], overflow: "hidden", }, - providerIconWrap: { - width: theme.iconSize.md, + agentTitleRow: { + flexDirection: "row", alignItems: "center", - justifyContent: "center", + gap: theme.spacing[2], + flexShrink: 1, + minWidth: 0, + }, + providerIconWrap: { + flexShrink: 0, }, workspaceTitleText: { - flexShrink: 0, - maxWidth: 220, + flexShrink: { xs: 1, md: 0 }, + maxWidth: { xs: "100%", md: 320 }, fontSize: theme.fontSize.base, - color: theme.colors.foregroundMuted, + color: theme.colors.foreground, }, rowMetaRow: { flexDirection: "row", @@ -682,11 +637,7 @@ const styles = StyleSheet.create((theme) => ({ minWidth: 0, fontSize: theme.fontSize.base, fontWeight: "400", - color: theme.colors.foreground, - opacity: 0.86, - }, - sessionTitleHighlighted: { - opacity: 1, + color: theme.colors.foregroundMuted, }, sessionMetaText: { maxWidth: "100%", diff --git a/packages/app/src/components/ui/highlighted-text-segments.test.ts b/packages/app/src/components/ui/highlighted-text-segments.test.ts index b38ed4664b..6f42fe823d 100644 --- a/packages/app/src/components/ui/highlighted-text-segments.test.ts +++ b/packages/app/src/components/ui/highlighted-text-segments.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { toHighlightSegments } from "./highlighted-text-segments"; +import { findHighlightRanges, 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 { @@ -48,3 +48,40 @@ describe("toHighlightSegments", () => { expect(toHighlightSegments("main", [])).toEqual([{ start: 0, text: "main", marked: false }]); }); }); + +describe("findHighlightRanges", () => { + it("highlights the same case-insensitive query independently in every field", () => { + for (const text of ["Billing fixes", "Fix BILLING", "feat/billing", "billing-project"]) { + expect(marked(text, findHighlightRanges("bill", text))).toContain( + `[${text.slice(text.toLowerCase().indexOf("bill"), text.toLowerCase().indexOf("bill") + 4)}]`, + ); + } + }); + it("highlights the tokens present even when other words match another field", () => { + expect(marked("Stripe billing", findHighlightRanges("stripe main", "Stripe billing"))).toBe( + "[Stripe] billing", + ); + }); + it("uses the search matcher for typo and scattered-character matches", () => { + expect(marked("billing", findHighlightRanges("bulling", "billing"))).toBe("[billing]"); + expect(marked("paseo-babysit", findHighlightRanges("pasbab", "paseo-babysit"))).toBe( + "[pas]eo-[bab]ysit", + ); + }); + it("does not highlight a query assembled across separate words", () => { + expect( + findHighlightRanges( + "terminal", + "Let me diagnose this problem in a diagnose this problem and", + ), + ).toEqual([]); + expect(marked("check configuration", findHighlightRanges("confg", "check configuration"))).toBe( + "check [conf]i[g]uration", + ); + }); + it("merges overlapping tokens and ignores empty or unmatched queries", () => { + expect(marked("billing", findHighlightRanges("bill billing", "billing"))).toBe("[billing]"); + expect(findHighlightRanges("", "billing")).toEqual([]); + expect(findHighlightRanges("xyz", "billing")).toEqual([]); + }); +}); diff --git a/packages/app/src/components/ui/highlighted-text-segments.ts b/packages/app/src/components/ui/highlighted-text-segments.ts index 65fdbe2e7c..0bd4f1e0bd 100644 --- a/packages/app/src/components/ui/highlighted-text-segments.ts +++ b/packages/app/src/components/ui/highlighted-text-segments.ts @@ -1,4 +1,10 @@ -import type { MatchRange } from "@getpaseo/protocol/search/text-match"; +import { + fuzzyPolicyForToken, + matchRanges, + scoreMatch, + tokenizeQuery, + 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. */ @@ -33,3 +39,23 @@ export function toHighlightSegments( } return segments; } + +/** Best-effort presentation of each query token in this field, independently of other fields. */ +export function findHighlightRanges(query: string, text: string): MatchRange[] { + const ranges = tokenizeQuery(query) + .flatMap((token) => { + const score = scoreMatch(token, text, { fuzzy: fuzzyPolicyForToken(token) }); + return score ? matchRanges(token, text, score) : []; + }) + .sort((left, right) => left.start - right.start); + const merged: MatchRange[] = []; + for (const range of ranges) { + 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); + } else { + merged.push({ ...range }); + } + } + return merged; +} diff --git a/packages/app/src/components/ui/menu/menu-overlay.tsx b/packages/app/src/components/ui/menu/menu-overlay.tsx index 32aed88abf..b001c056fc 100644 --- a/packages/app/src/components/ui/menu/menu-overlay.tsx +++ b/packages/app/src/components/ui/menu/menu-overlay.tsx @@ -292,8 +292,16 @@ export function AnchoredSurface({ revision, }); + // The surface is placed once both measurements land. Until then it sits off-screen so it can + // be measured, and it must not animate: Reanimated's web entering path snapshots the element's + // rect when the animation ends and writes that rect back as inline top/left when it cleans the + // keyframe up 750ms later. If the animation ends before placement, the snapshot is the + // off-screen rect and the menu jumps back off-screen. Remounting on placement starts the + // animation from the final position. + const placed = position !== null; + useEffect(() => { - if (!isWeb || !open || !contentSize || typeof document === "undefined") return undefined; + if (!isWeb || !open || !placed || typeof document === "undefined") return undefined; const frame = requestAnimationFrame(() => { document .getElementById(surfaceNativeID) @@ -301,7 +309,7 @@ export function AnchoredSurface({ ?.focus(); }); return () => cancelAnimationFrame(frame); - }, [contentSize, open, surfaceNativeID]); + }, [open, placed, surfaceNativeID]); const frameStyle = useMemo>(() => { const { width: screenWidth } = Dimensions.get("window"); @@ -371,6 +379,7 @@ export function AnchoredSurface({ /> ) : null} { "worklet"; diff --git a/packages/app/src/hooks/use-agent-history.test.ts b/packages/app/src/hooks/use-agent-history.test.ts index 3fcdd3df5c..d4da1f01bb 100644 --- a/packages/app/src/hooks/use-agent-history.test.ts +++ b/packages/app/src/hooks/use-agent-history.test.ts @@ -311,10 +311,10 @@ describe("fetchAgentHistoryPage", () => { }); expect(client.calls[0]?.search).toBe("stripe"); - expect(page.searchScoreByAgentKey).toEqual({ "server-1:match": 1000 }); + expect(page.agents.map((agent) => agent.id)).toEqual(["match"]); }); - it("keeps per-host scores apart when two hosts issue the same agent id", async () => { + it("keeps sessions with the same id on different hosts and sorts by recency", async () => { const sharedId = "collision"; const serverAClient = createClient([ historyPayload({ @@ -352,13 +352,9 @@ describe("fetchAgentHistoryPage", () => { 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", + "Strong match on B", ]); }); @@ -395,9 +391,8 @@ describe("fetchAgentHistoryPage", () => { }); }); - 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. + it("preserves every matching row when merging complete host pages", async () => { + // Each host owns its cursor; merging must not discard rows already consumed. const buildHost = (serverId: string, count: number) => createClient([ historyPayload({ @@ -422,8 +417,8 @@ describe("fetchAgentHistoryPage", () => { search: "match", }); - expect(page.isSearchTruncated).toBe(true); - expect(page.agents).toHaveLength(200); + expect(page.isSearchTruncated).toBe(false); + expect(page.agents).toHaveLength(300); }); it("names the host that failed instead of quietly shortening the list", async () => { @@ -515,7 +510,7 @@ describe("fetchAgentHistoryPage", () => { }), ).toEqual([{ serverId: "server-b", serverName: "Linux box" }]); }); - it("orders a searched all-host page by relevance instead of recency", async () => { + it("orders searched history chronologically regardless of match strength", async () => { const serverAClient = createClient([ historyPayload({ entries: [ @@ -553,8 +548,8 @@ describe("fetchAgentHistoryPage", () => { }); expect(page.agents.map((agent) => agent.id)).toEqual([ - "older-strong-match", "newer-weak-match", + "older-strong-match", ]); }); @@ -588,6 +583,7 @@ describe("fetchAgentHistoryPage", () => { { serverId: "server-b", serverLabel: "Linux box", client: serverBClient }, ] satisfies AgentHistoryHost[], cursorByServerId: { "server-b": "cursor-b" }, + search: "match", }); expect(page.agents.map((agent) => agent.id)).toEqual(["next-b"]); @@ -596,6 +592,7 @@ describe("fetchAgentHistoryPage", () => { { sort: [{ key: "updated_at", direction: "desc" }], page: { limit: 200, cursor: "cursor-b" }, + search: "match", } satisfies FetchAgentHistoryOptions, ]); }); diff --git a/packages/app/src/hooks/use-agent-history.ts b/packages/app/src/hooks/use-agent-history.ts index 3ac397e719..96b0395e76 100644 --- a/packages/app/src/hooks/use-agent-history.ts +++ b/packages/app/src/hooks/use-agent-history.ts @@ -3,7 +3,6 @@ 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"; @@ -31,8 +30,6 @@ export interface AgentHistoryResult { 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; @@ -41,26 +38,11 @@ export interface AgentHistoryResult { 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; /** @@ -81,17 +63,12 @@ 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: { @@ -112,21 +89,7 @@ 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, @@ -156,30 +119,6 @@ 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. * @@ -265,30 +204,13 @@ 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: input.search - ? mergeByRelevance(agents, searchScoreByAgentKey, AGENT_HISTORY_PAGE_LIMIT) - : sortByLatestActivity(agents), - searchScoreByAgentKey, - searchMatchesByAgentKey, + agents: sortByLatestActivity(agents), pageInfoByServerId, hostErrors, - ...(input.search ? { isSearchTruncated: truncated } : {}), + ...(input.search + ? { isSearchTruncated: pages.some(({ page }) => page.isSearchTruncated) } + : {}), }; } @@ -420,28 +342,11 @@ export function useAgentHistory(options: { serverLabel: serverLabelById.get(agent.serverId) ?? agent.serverLabel, }), ); - 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]); + return sortByLatestActivity(labelledAgents); + }, [data?.pages, 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], @@ -453,13 +358,10 @@ export function useAgentHistory(options: { isInitialLoad, isRevalidating, isError, - // 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, + hasMore: hasNextPage, isLoadingMore: isFetchingNextPage, isSearchSupported, isSearchTruncated, - searchMatchesByAgentKey, hostErrors, refreshAll, loadMore, diff --git a/packages/app/src/screens/sessions-screen.tsx b/packages/app/src/screens/sessions-screen.tsx index 7cc3294ef5..bcd1e4f740 100644 --- a/packages/app/src/screens/sessions-screen.tsx +++ b/packages/app/src/screens/sessions-screen.tsx @@ -87,7 +87,6 @@ function SessionsScreenContent() { isError, isSearchSupported, isSearchTruncated, - searchMatchesByAgentKey, hostErrors, loadMore, refreshAll, @@ -113,7 +112,7 @@ function SessionsScreenContent() { void refreshAll().finally(() => setIsManualRefresh(false)); }, [refreshAll]); - // `useAgentHistory` owns the order: recency at rest, relevance under a query. + // Searching filters the chronological history without changing its date buckets. const emptyText = resolveEmptyText({ t, isSearching, @@ -130,8 +129,8 @@ function SessionsScreenContent() { 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. + // COMPAT(historyPagination): old daemons return a truncated relevance page. + // Added in v0.8.0; remove after 2027-03-16 once the daemon floor supports search pagination. if (isSearchTruncated) { return ( @@ -217,8 +216,7 @@ function SessionsScreenContent() { listFooterComponent={listFooterComponent} showAttentionIndicator={false} showHostColumn - searchMatchesByAgentKey={isSearching ? searchMatchesByAgentKey : undefined} - flat={isSearching} + search={isSearching ? search : undefined} /> ) : null} {importSession.sheet} diff --git a/packages/protocol/src/messages.ts b/packages/protocol/src/messages.ts index 517bec615b..d98a35682d 100644 --- a/packages/protocol/src/messages.ts +++ b/packages/protocol/src/messages.ts @@ -1356,9 +1356,8 @@ 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. + // A free-text filter over agent title, workspace name, branch, and project name. + // Matching rows follow the requested sort and cursor pagination. search: z.string().optional(), sort: z .array( @@ -4095,13 +4094,10 @@ 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. + // Legacy relevance metadata remains accepted from older daemons. + // Current history responses use the requested chronological sort. 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. + // Legacy server-generated highlights. Current clients highlight displayed text locally. searchMatches: z.array(AgentSearchMatchSchema).optional(), // COMPAT(directorySync): sequence of this latest directory projection. syncSeq: z.number().int().positive().optional(), @@ -4130,9 +4126,7 @@ 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. + // Older daemons truncate relevance-ranked searches instead of returning a cursor. searchTruncated: z.boolean().optional(), }), }); diff --git a/packages/protocol/src/search/text-match.test.ts b/packages/protocol/src/search/text-match.test.ts index 05037fdee7..8adff41f62 100644 --- a/packages/protocol/src/search/text-match.test.ts +++ b/packages/protocol/src/search/text-match.test.ts @@ -54,6 +54,24 @@ describe("scoreMatch", () => { expect(scoreMatch("art", "party")).toEqual({ tier: 4, offset: 1 }); }); + it("does not assemble one query word across whitespace-separated words", () => { + for (const separator of [" ", "\t", "\n", "\u00a0"]) { + expect(scoreMatch("terminal", `term${separator}inal`)).toBeNull(); + } + expect( + scoreMatch("terminal", "Let me diagnose this problem in a diagnose this problem and", { + fuzzy: fuzzyPolicyForToken("terminal"), + }), + ).toBeNull(); + }); + + it("keeps subsequences within a word, including names and later words", () => { + expect(scoreMatch("trmnl", "Fix terminal resizing")).toEqual({ tier: 5, offset: 4, spread: 8 }); + expect(scoreMatch("abc", "AgentBrowserConfig")?.tier).toBe(5); + expect(scoreMatch("pasbab", "paseo-babysit")?.tier).toBe(5); + expect(scoreMatch("confg", "check configuration")?.offset).toBe(6); + }); + it("returns null when query is not found", () => { expect(scoreMatch("xyz", "feat/pi-direct-sdk")).toBeNull(); }); @@ -225,7 +243,8 @@ describe("scoreTextFields", () => { // Splitting on whitespace is what the opt-out keeps; only the run-together form goes. expect(scoreTextFields("lab des", fields, { subsequence: false })).not.toBeNull(); expect(scoreTextFields("labdes", fields, { subsequence: false })).toBeNull(); - expect(scoreTextFields("labdes", fields)).not.toBeNull(); + expect(scoreTextFields("labdes", fields)).toBeNull(); + expect(scoreTextFields("lbl dsgn", fields)).not.toBeNull(); }); }); @@ -258,6 +277,10 @@ describe("matchRanges", () => { expect(markUp("confg", "configuration")).toBe("[conf]i[g]uration"); }); + it("highlights only the word selected after an earlier partial subsequence", () => { + expect(markUp("confg", "check configuration")).toBe("check [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. diff --git a/packages/protocol/src/search/text-match.ts b/packages/protocol/src/search/text-match.ts index db73acb305..14bce34a41 100644 --- a/packages/protocol/src/search/text-match.ts +++ b/packages/protocol/src/search/text-match.ts @@ -18,11 +18,8 @@ export interface MatchOptions { /** Omit or pass null to match exactly. `fuzzyPolicyForToken` picks a policy. */ fuzzy?: FuzzyPolicy | null; /** - * Match characters that appear in order but not adjacently, so `pasbab` finds - * `paseo-babysit`. Defaults to on. Turn it off where a near miss has to read as - * no match at all: it widens far enough that most typos hit something, and a - * list that preselects its first row would then act on that row when the user - * presses Enter expecting nothing to happen. + * Match characters in order within one whitespace-delimited word, so `pasbab` + * finds `paseo-babysit`, but `labdes` cannot join "Label as Design". Defaults to on. */ subsequence?: boolean; } @@ -74,6 +71,12 @@ function scoreSubsequenceMatch(query: string, text: string): MatchScore | null { let firstIndex = -1; let lastIndex = -1; for (let textIndex = 0; textIndex < text.length && queryIndex < query.length; textIndex += 1) { + if (/\s/u.test(text[textIndex])) { + queryIndex = 0; + firstIndex = -1; + lastIndex = -1; + continue; + } if (text[textIndex] !== query[queryIndex]) continue; if (firstIndex === -1) firstIndex = textIndex; lastIndex = textIndex; @@ -286,7 +289,11 @@ export function matchRanges(query: string, text: string, score: MatchScore): Mat if (score.tier === TIER_SUBSEQUENCE) { const indices: number[] = []; let queryIndex = 0; - for (let textIndex = 0; textIndex < t.length && queryIndex < q.length; textIndex += 1) { + for ( + let textIndex = score.offset; + textIndex < t.length && queryIndex < q.length; + textIndex += 1 + ) { if (t[textIndex] !== q[queryIndex]) continue; indices.push(textIndex); queryIndex += 1; diff --git a/packages/server/src/server/agent-history-search.test.ts b/packages/server/src/server/agent-history-search.test.ts index 036efa7abb..35108fff58 100644 --- a/packages/server/src/server/agent-history-search.test.ts +++ b/packages/server/src/server/agent-history-search.test.ts @@ -1,9 +1,7 @@ import { describe, expect, it } from "vitest"; import { type AgentHistorySearchCandidate, - describeAgentHistoryMatches, - rankAgentHistoryCandidates, - scoreAgentHistoryCandidate, + matchesAgentHistoryQuery, } from "./agent-history-search.js"; function candidate(input: { @@ -39,157 +37,63 @@ function candidate(input: { } 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("matchesAgentHistoryQuery", () => { + it("rejects scattered letters across words while keeping within-word subsequences", () => { + expect( + matchesAgentHistoryQuery( + "terminal", + candidate({ + title: "Let me diagnose this problem in a diagnose this problem and", + }), + ), + ).toBe(false); + expect(matchesAgentHistoryQuery("trmnl", candidate({ title: "Fix terminal resizing" }))).toBe( + true, + ); + }); -describe("scoreAgentHistoryCandidate", () => { it("matches the workspace name", () => { expect( - scoreAgentHistoryCandidate("stripe", candidate({ workspaceName: "Add Stripe billing" })), - ).not.toBeNull(); + matchesAgentHistoryQuery("stripe", candidate({ workspaceName: "Add Stripe billing" })), + ).toBe(true); }); it("matches the agent title", () => { expect( - scoreAgentHistoryCandidate("entitlements", candidate({ title: "Reshape entitlements" })), - ).not.toBeNull(); + matchesAgentHistoryQuery("entitlements", candidate({ title: "Reshape entitlements" })), + ).toBe(true); }); it("matches the branch name", () => { - expect( - scoreAgentHistoryCandidate("billing", candidate({ branch: "add-stripe-billing" })), - ).not.toBeNull(); + expect(matchesAgentHistoryQuery("billing", candidate({ branch: "add-stripe-billing" }))).toBe( + true, + ); }); it("matches the project name", () => { - expect( - scoreAgentHistoryCandidate("paseo", candidate({ projectName: "getpaseo/paseo" })), - ).not.toBeNull(); + expect(matchesAgentHistoryQuery("paseo", candidate({ projectName: "getpaseo/paseo" }))).toBe( + true, + ); }); 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(); + expect(matchesAgentHistoryQuery("stripe main", entry)).toBe(true); + expect(matchesAgentHistoryQuery("stripe rosetta", entry)).toBe(false); }); it("tolerates a typo", () => { expect( - scoreAgentHistoryCandidate("bulling", candidate({ workspaceName: "Add Stripe billing" })), - ).not.toBeNull(); + matchesAgentHistoryQuery("bulling", candidate({ workspaceName: "Add Stripe billing" })), + ).toBe(true); }); it("tolerates a transposed branch name", () => { - expect(scoreAgentHistoryCandidate("mian", candidate({ branch: "main" }))).not.toBeNull(); - expect(scoreAgentHistoryCandidate("rain", candidate({ branch: "main" }))).toBeNull(); + expect(matchesAgentHistoryQuery("mian", candidate({ branch: "main" }))).toBe(true); + expect(matchesAgentHistoryQuery("rain", candidate({ branch: "main" }))).toBe(false); }); - 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([]); + it("keeps candidates for a blank query", () => { + expect(matchesAgentHistoryQuery(" ", candidate({ title: "anything" }))).toBe(true); }); }); diff --git a/packages/server/src/server/agent-history-search.ts b/packages/server/src/server/agent-history-search.ts index 9960fb3d1e..ff49284e1a 100644 --- a/packages/server/src/server/agent-history-search.ts +++ b/packages/server/src/server/agent-history-search.ts @@ -1,188 +1,26 @@ -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. - */ +import type { AgentSnapshotPayload, ProjectPlacementPayload } from "@getpaseo/protocol/messages"; +import { scoreTextFields } from "@getpaseo/protocol/search/text-match"; 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( +/** Search the names people recall, never an incomplete subset of transcripts. */ +export function matchesAgentHistoryQuery( 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; + { agent, project }: AgentHistorySearchCandidate, +): boolean { + return ( + scoreTextFields( + query, + [ + project.workspaceName ?? "", + agent.title ?? "", + project.checkout.currentBranch ?? "", + project.projectName, + ], + { typoTolerant: true }, + ) !== null + ); } diff --git a/packages/server/src/server/daemon-e2e/agent-rpc-durability.e2e.test.ts b/packages/server/src/server/daemon-e2e/agent-rpc-durability.e2e.test.ts index ad56ac382e..b8a9cd7cfc 100644 --- a/packages/server/src/server/daemon-e2e/agent-rpc-durability.e2e.test.ts +++ b/packages/server/src/server/daemon-e2e/agent-rpc-durability.e2e.test.ts @@ -1,4 +1,4 @@ -import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import os from "node:os"; import path from "node:path"; import { expect, test } from "vitest"; @@ -74,6 +74,49 @@ test("agent fetch RPCs tolerate an agent whose workspace project record is gone" } }); +test("history search filters before pagination and keeps newest matches first", async () => { + const fixture = seedStaleAgentFixture(); + let daemon: TestPaseoDaemon | null = null; + let client: DaemonClient | null = null; + try { + const agentsDir = path.join(fixture.paseoHomeRoot, ".paseo", "agents"); + const template = JSON.parse( + readFileSync(path.join(agentsDir, `${fixture.healthyAgentId}.json`), "utf8"), + ); + for (const [id, title, updatedAt] of [ + ["newer-partial", "Unbilled usage", "2026-06-29T13:00:00.000Z"], + ["unrelated", "Terminal resizing", "2026-06-29T12:00:00.000Z"], + ["older-exact", "bill", "2026-06-28T12:00:00.000Z"], + ]) { + writeJson(path.join(agentsDir, `${id}.json`), { + ...template, + id, + title, + updatedAt, + lastActivityAt: updatedAt, + }); + } + daemon = await createTestPaseoDaemon({ paseoHomeRoot: fixture.paseoHomeRoot, cleanup: false }); + client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` }); + await client.connect(); + const first = await client.fetchAgentHistory({ search: "bill", page: { limit: 1 } }); + expect(first.entries.map((entry) => entry.agent.id)).toEqual(["newer-partial"]); + expect(first.pageInfo.hasMore).toBe(true); + expect(first.pageInfo.nextCursor).toBeTypeOf("string"); + const second = await client.fetchAgentHistory({ + search: "bill", + page: { limit: 1, cursor: first.pageInfo.nextCursor! }, + }); + expect(second.entries.map((entry) => entry.agent.id)).toEqual(["older-exact"]); + expect(second.pageInfo.hasMore).toBe(false); + expect(second.entries[0].searchMatches).toBeUndefined(); + } finally { + await client?.close().catch(() => undefined); + await daemon?.close().catch(() => undefined); + for (const target of fixture.cleanupPaths) rmSync(target, { recursive: true, force: true }); + } +}); + function seedStaleAgentFixture(): StaleAgentFixture { const healthyCwd = mkdtempSync(path.join(os.tmpdir(), "paseo-healthy-agent-")); const orphanCwd = mkdtempSync(path.join(os.tmpdir(), "paseo-orphan-agent-")); diff --git a/packages/server/src/server/session.ts b/packages/server/src/server/session.ts index 753c163caf..4d72c3fd57 100644 --- a/packages/server/src/server/session.ts +++ b/packages/server/src/server/session.ts @@ -42,7 +42,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 { matchesAgentHistoryQuery } from "./agent-history-search.js"; import type { SpeechToTextProvider, TextToSpeechProvider } from "./speech/speech-provider.js"; import type { TurnDetectionProvider } from "./speech/turn-detection-provider.js"; import { @@ -5297,8 +5297,9 @@ export class Session { limit: number; getPlacement: (workspaceId: string | undefined) => Promise; filter: AgentUpdatesFilter | undefined; + search?: string; }): Promise { - const { candidates, limit, getPlacement, filter } = params; + const { candidates, limit, getPlacement, filter, search } = params; const matchedEntries: FetchAgentsResponseEntry[] = []; const batchSize = 25; for ( @@ -5326,6 +5327,7 @@ export class Session { ) { continue; } + if (search && !matchesAgentHistoryQuery(search, entry)) continue; matchedEntries.push(entry); if (matchedEntries.length > limit) { break; @@ -5384,17 +5386,6 @@ export class Session { }; 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; @@ -5412,6 +5403,7 @@ export class Session { limit, getPlacement, filter, + search, }); const pagedEntries = matchedEntries.slice(0, limit); @@ -5431,64 +5423,6 @@ 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 2866732abe..1be52bc997 100644 --- a/packages/server/src/server/session.workspaces.test.ts +++ b/packages/server/src/server/session.workspaces.test.ts @@ -2967,7 +2967,7 @@ 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 () => { +test("fetch_agent_history_request filters across history and paginates chronologically", async () => { const emitted: SessionOutboundMessage[] = []; const session = createSessionForWorkspaceTests(); const historyCwd = path.resolve("/tmp/history-search"); @@ -2996,8 +2996,7 @@ test("fetch_agent_history_request ranks a search across the whole history, not o 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. + // Search keeps chronological order, skipping unrelated rows between pages. { ...makeAgent({ id: "weak", @@ -3037,38 +3036,24 @@ test("fetch_agent_history_request ranks a search across the whole history, not o 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, - }); - + expect(emitted).toHaveLength(1); + const first = filterByType(emitted, "fetch_agent_history_response")[0]; + expect(first.payload.entries.map((entry) => entry.agent.id)).toEqual(["weak"]); + expect(first.payload.pageInfo.hasMore).toBe(true); + expect(first.payload.entries[0].searchScore).toBeUndefined(); await session.handleMessage({ type: "fetch_agent_history_request", - requestId: "req-search-whole", + requestId: "req-search-next", search: "bill", - page: { limit: 25 }, + page: { limit: 1, cursor: first.payload.pageInfo.nextCursor! }, }); - - 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); + expect(emitted).toHaveLength(2); + const second = filterByType(emitted, "fetch_agent_history_response")[1]; + expect(second.payload.entries.map((entry) => entry.agent.id)).toEqual(["strong"]); + expect(second.payload.pageInfo.hasMore).toBe(false); }); -test("fetch_agent_history_request rejects a cursor on a searched request", async () => { +test("fetch_agent_history_request rejects a malformed search cursor", async () => { const emitted: SessionOutboundMessage[] = []; const session = createSessionForWorkspaceTests(); const historyCwd = path.resolve("/tmp/history-cursor"); @@ -3109,8 +3094,7 @@ test("fetch_agent_history_request rejects a cursor on a searched request", async }, ]; - // 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. + // Search uses the same validated chronological cursor as unfiltered history. await session.handleMessage({ type: "fetch_agent_history_request", requestId: "req-cursor",