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",