feat(app): search history by workspace, agent, and branch (#2995)
History was a date-grouped list you scrolled. Finding a session from last week meant eyeballing every row. This adds a search field to the existing filter rail, next to the host picker. Search runs on the daemon, not the client. The daemon already holds every persisted agent in memory, so it ranks the whole history and the client never has to warn that it only looked at the pages it had loaded. A searched response is one ranked page with no cursor. The global top K is always contained in the union of each host's own top K, so merging per-host pages and slicing back to the limit is the exact global answer — no k-way merge, and no offset into a ranking that is recomputed per request, which would duplicate and skip rows as history changes underneath it. Past result 200 of a relevance order the answer is a narrower query, so the footer says that instead of offering a "Load more" that cannot deliver. Matching is the app's existing picker matcher, moved to the protocol package so app and daemon share one implementation instead of drifting into two "why didn't this show up?" rule sets. Typo tolerance is opt-in, so the comboboxes keep exact narrowing; the budget is per token, and four-character tokens get transpositions only, because at that length a free substitution turns "main" into "mail", "maid" and "rain". Ranked rows carry the spans they matched on, so results can mark them. The subsequence and typo tiers match characters the eye would never find unaided, and an unexplained row reads as a bug. The ranker computes the spans anyway; sending them keeps the client from deriving a second opinion that could disagree with the ranking it is explaining. Hosts that fail or are offline are now named above the list. Without that, "No sessions match" is a claim the app has no basis for. Gated on server_info.features.agentHistorySearch, checked once against the connected hosts. Reachability is reported by the banner instead, so one offline host in the registry no longer hides the field.
This commit is contained in:
@@ -0,0 +1,148 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { expect, type Page } from "@playwright/test";
|
||||
import { test } from "../support/fixtures";
|
||||
import { gotoAppShell } from "../support/helpers/app";
|
||||
import { openSessions } from "../support/helpers/archive-tab";
|
||||
import { createIdleAgent } from "../support/helpers/archive-tab";
|
||||
import { addConnectedHostAndReload, addOfflineHostAndReload } from "../support/helpers/hosts";
|
||||
import { startIsolatedHostDaemon } from "../support/helpers/isolated-host-daemon";
|
||||
import { connectSeedClient, seedWorkspace } from "../support/helpers/seed-client";
|
||||
import { getServerId } from "../support/helpers/server-id";
|
||||
// The constants module is plain TypeScript, so the Playwright runner can import
|
||||
// it — reaching through the `.tsx` picker would drag React Native into Node.
|
||||
import { ALL_HOSTS_OPTION_ID } from "@/components/hosts/host-picker-constants";
|
||||
|
||||
const AGENT_ROW = '[data-testid^="agent-row-"]';
|
||||
const PRIMARY_LABEL = "Primary box";
|
||||
const SECONDARY_LABEL = "Secondary box";
|
||||
|
||||
/** Scopes every assertion to this spec's sessions on a shared daemon. */
|
||||
const NONCE = `hsx${randomUUID().replaceAll("-", "").slice(0, 8)}`;
|
||||
|
||||
async function search(page: Page, query: string): Promise<void> {
|
||||
await page.getByTestId("sessions-search-input").fill(query);
|
||||
}
|
||||
|
||||
async function selectHost(page: Page, serverId: string): Promise<void> {
|
||||
await page.getByTestId("sessions-host-filter-trigger").click();
|
||||
await page.getByTestId(`sessions-host-filter-item-${serverId}`).click();
|
||||
}
|
||||
|
||||
async function expectRankedTitles(page: Page, titles: string[]): Promise<void> {
|
||||
const rows = page.locator(AGENT_ROW).filter({ hasText: NONCE });
|
||||
await expect(rows).toHaveCount(titles.length, { timeout: 30_000 });
|
||||
for (const [index, title] of titles.entries()) {
|
||||
await expect(rows.nth(index)).toContainText(title, { timeout: 30_000 });
|
||||
}
|
||||
}
|
||||
|
||||
// Searching across hosts is the case a single-daemon spec cannot see: each host
|
||||
// ranks only its own sessions, so the merge is the only thing that can put the
|
||||
// best match first.
|
||||
test.describe("History search across hosts", () => {
|
||||
test.describe.configure({ timeout: 420_000 });
|
||||
|
||||
test("ranks matches from every host together and narrows to one host on demand", async ({
|
||||
page,
|
||||
}) => {
|
||||
const secondaryServerId = `srv_hist_${randomUUID().replaceAll("-", "").slice(0, 12)}`;
|
||||
const secondaryDaemon = await startIsolatedHostDaemon(secondaryServerId);
|
||||
const primaryClient = await connectSeedClient();
|
||||
let primaryWorkspace: Awaited<ReturnType<typeof seedWorkspace>> | null = null;
|
||||
let secondaryWorkspace: Awaited<ReturnType<typeof seedWorkspace>> | null = null;
|
||||
|
||||
// The weak match is the newest session on the primary host and the strong
|
||||
// match is the oldest on the secondary, so only a real cross-host merge on
|
||||
// relevance can order them correctly.
|
||||
const weakTitle = `${NONCE} Unbilled usage report`;
|
||||
const strongTitle = `${NONCE} Bill the customer`;
|
||||
const unrelatedTitle = `${NONCE} Terminal resize fix`;
|
||||
|
||||
try {
|
||||
secondaryWorkspace = await seedWorkspace({
|
||||
repoPrefix: "sessions-search-secondary-",
|
||||
port: secondaryDaemon.port,
|
||||
});
|
||||
await createIdleAgent(secondaryWorkspace.client, {
|
||||
cwd: secondaryWorkspace.repoPath,
|
||||
workspaceId: secondaryWorkspace.workspaceId,
|
||||
title: strongTitle,
|
||||
});
|
||||
|
||||
primaryWorkspace = await seedWorkspace({ repoPrefix: "sessions-search-primary-" });
|
||||
for (const title of [unrelatedTitle, weakTitle]) {
|
||||
await createIdleAgent(primaryClient, {
|
||||
cwd: primaryWorkspace.repoPath,
|
||||
workspaceId: primaryWorkspace.workspaceId,
|
||||
title,
|
||||
});
|
||||
}
|
||||
|
||||
await gotoAppShell(page);
|
||||
await addConnectedHostAndReload(page, {
|
||||
serverId: secondaryDaemon.serverId,
|
||||
label: SECONDARY_LABEL,
|
||||
port: secondaryDaemon.port,
|
||||
primaryLabel: PRIMARY_LABEL,
|
||||
});
|
||||
await openSessions(page);
|
||||
|
||||
// All hosts: the stronger match leads despite being the older session on
|
||||
// the other daemon.
|
||||
await search(page, `${NONCE} bill`);
|
||||
await expectRankedTitles(page, [strongTitle, weakTitle]);
|
||||
|
||||
// One host: the other daemon's stronger match drops out entirely.
|
||||
await selectHost(page, getServerId());
|
||||
await expectRankedTitles(page, [weakTitle]);
|
||||
|
||||
await selectHost(page, secondaryDaemon.serverId);
|
||||
await expectRankedTitles(page, [strongTitle]);
|
||||
|
||||
// Back to all hosts, and clearing the query returns every seeded session.
|
||||
await selectHost(page, ALL_HOSTS_OPTION_ID);
|
||||
await page.getByTestId("sessions-search-clear").click();
|
||||
const rows = page.locator(AGENT_ROW).filter({ hasText: NONCE });
|
||||
await expect(rows).toHaveCount(3, { timeout: 30_000 });
|
||||
} finally {
|
||||
await primaryWorkspace?.cleanup().catch(() => undefined);
|
||||
await secondaryWorkspace?.cleanup().catch(() => undefined);
|
||||
await primaryClient.close().catch(() => undefined);
|
||||
await secondaryDaemon.close().catch(() => undefined);
|
||||
}
|
||||
});
|
||||
|
||||
test("names a host it could not reach instead of shrinking the results silently", async ({
|
||||
page,
|
||||
}) => {
|
||||
const workspace = await seedWorkspace({ repoPrefix: "sessions-search-offline-" });
|
||||
const reachableTitle = `${NONCE} Bill the reachable customer`;
|
||||
|
||||
try {
|
||||
await createIdleAgent(workspace.client, {
|
||||
cwd: workspace.repoPath,
|
||||
workspaceId: workspace.workspaceId,
|
||||
title: reachableTitle,
|
||||
});
|
||||
|
||||
await gotoAppShell(page);
|
||||
await addOfflineHostAndReload(page, {
|
||||
serverId: "sessions-search-unreachable",
|
||||
label: SECONDARY_LABEL,
|
||||
primaryLabel: PRIMARY_LABEL,
|
||||
});
|
||||
await openSessions(page);
|
||||
|
||||
// The unreachable host's sessions were never searched. Reporting only the
|
||||
// matches that were found, with no word about the host that never
|
||||
// answered, is the failure this asserts against.
|
||||
await search(page, `${NONCE} bill`);
|
||||
await expect(page.getByTestId("sessions-host-errors")).toContainText(SECONDARY_LABEL, {
|
||||
timeout: 30_000,
|
||||
});
|
||||
await expectRankedTitles(page, [reachableTitle]);
|
||||
} finally {
|
||||
await workspace.cleanup().catch(() => undefined);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,142 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { expect, type Page } from "@playwright/test";
|
||||
import { test } from "../support/fixtures";
|
||||
import { connectSeedClient } from "../support/helpers/seed-client";
|
||||
import { createTempGitRepo } from "../support/helpers/workspace";
|
||||
import {
|
||||
createIdleAgent,
|
||||
openSessions,
|
||||
resetSeededPageState,
|
||||
} from "../support/helpers/archive-tab";
|
||||
|
||||
const AGENT_ROW = '[data-testid^="agent-row-"]';
|
||||
|
||||
/**
|
||||
* Every seeded title opens with the same nonce, so a query of "<nonce> term"
|
||||
* can only reach this spec's sessions. The daemon is shared with the rest of
|
||||
* the browser suite and its history is whatever those specs left behind.
|
||||
*/
|
||||
const NONCE = `hsq${randomUUID().replaceAll("-", "").slice(0, 8)}`;
|
||||
|
||||
const TITLES = {
|
||||
billing: `${NONCE} Add Stripe billing`,
|
||||
unbilled: `${NONCE} Unbilled usage report`,
|
||||
terminal: `${NONCE} Terminal resize fix`,
|
||||
} as const;
|
||||
|
||||
async function search(page: Page, query: string): Promise<void> {
|
||||
await page.getByTestId("sessions-search-input").fill(query);
|
||||
}
|
||||
|
||||
function rowTitles(page: Page) {
|
||||
return page.locator(AGENT_ROW);
|
||||
}
|
||||
|
||||
async function expectVisibleTitles(page: Page, titles: string[]): Promise<void> {
|
||||
const rows = rowTitles(page).filter({ hasText: NONCE });
|
||||
await expect(rows).toHaveCount(titles.length, { timeout: 30_000 });
|
||||
for (const [index, title] of titles.entries()) {
|
||||
await expect(rows.nth(index)).toContainText(title, { timeout: 30_000 });
|
||||
}
|
||||
}
|
||||
|
||||
test.describe("History search", () => {
|
||||
let client: Awaited<ReturnType<typeof connectSeedClient>>;
|
||||
let tempRepo: { path: string; cleanup: () => Promise<void> };
|
||||
let projectId: string;
|
||||
|
||||
test.describe.configure({ timeout: 300_000 });
|
||||
|
||||
test.beforeAll(async () => {
|
||||
tempRepo = await createTempGitRepo("sessions-search-");
|
||||
client = await connectSeedClient();
|
||||
const created = await client.createWorkspace({
|
||||
source: { kind: "directory", path: tempRepo.path },
|
||||
});
|
||||
if (!created.workspace) {
|
||||
throw new Error(created.error ?? `Failed to create workspace ${tempRepo.path}`);
|
||||
}
|
||||
projectId = created.workspace.projectId;
|
||||
const workspaceId = created.workspace.id;
|
||||
|
||||
for (const title of [TITLES.terminal, TITLES.unbilled, TITLES.billing]) {
|
||||
await createIdleAgent(client, { cwd: tempRepo.path, workspaceId, title });
|
||||
}
|
||||
});
|
||||
|
||||
test.afterAll(async () => {
|
||||
await client?.removeProject(projectId).catch(() => undefined);
|
||||
await client?.close().catch(() => undefined);
|
||||
await tempRepo?.cleanup();
|
||||
});
|
||||
|
||||
test("typing narrows history to matching sessions and clearing restores them", async ({
|
||||
page,
|
||||
}) => {
|
||||
await resetSeededPageState(page);
|
||||
await openSessions(page);
|
||||
|
||||
// Seeded newest-first, and at rest history is chronological.
|
||||
await expectVisibleTitles(page, [TITLES.billing, TITLES.unbilled, TITLES.terminal]);
|
||||
await expect(page.getByText("Today", { exact: true })).toHaveCount(1, { timeout: 30_000 });
|
||||
|
||||
await search(page, `${NONCE} billing`);
|
||||
await expectVisibleTitles(page, [TITLES.billing]);
|
||||
|
||||
// Ranked results are one flat list — a day heading would claim an order
|
||||
// the list no longer has.
|
||||
await expect(page.getByText("Today", { exact: true })).toHaveCount(0, { timeout: 30_000 });
|
||||
|
||||
await page.getByTestId("sessions-search-clear").click();
|
||||
await expect(page.getByTestId("sessions-search-input")).toHaveValue("");
|
||||
await expectVisibleTitles(page, [TITLES.billing, TITLES.unbilled, TITLES.terminal]);
|
||||
await expect(page.getByText("Today", { exact: true })).toHaveCount(1, { timeout: 30_000 });
|
||||
});
|
||||
|
||||
test("ranks a whole-word hit above one buried inside a word", async ({ page }) => {
|
||||
await resetSeededPageState(page);
|
||||
await openSessions(page);
|
||||
|
||||
// "bill" starts a word in "billing" and hides inside "unbilled", so the
|
||||
// stronger match leads even though both sessions are equally recent.
|
||||
await search(page, `${NONCE} bill`);
|
||||
await expectVisibleTitles(page, [TITLES.billing, TITLES.unbilled]);
|
||||
});
|
||||
|
||||
test("marks the characters each result matched on", async ({ page }) => {
|
||||
await resetSeededPageState(page);
|
||||
await openSessions(page);
|
||||
|
||||
// The mark is a nested Text run, so the matched slice is its own element.
|
||||
await search(page, `${NONCE} billing`);
|
||||
const row = page.locator(AGENT_ROW).filter({ hasText: NONCE }).first();
|
||||
await expect(row).toBeVisible({ timeout: 30_000 });
|
||||
await expect(row.getByText("billing", { exact: true })).toBeVisible({ timeout: 30_000 });
|
||||
|
||||
// A typo has no characters in the text to point at, so the whole word it
|
||||
// resolved to is marked.
|
||||
await search(page, `${NONCE} bulling`);
|
||||
const typoRow = page.locator(AGENT_ROW).filter({ hasText: NONCE }).first();
|
||||
await expect(typoRow.getByText("billing", { exact: true })).toBeVisible({ timeout: 30_000 });
|
||||
});
|
||||
|
||||
test("finds a session through a typo", async ({ page }) => {
|
||||
await resetSeededPageState(page);
|
||||
await openSessions(page);
|
||||
|
||||
await search(page, `${NONCE} bulling`);
|
||||
await expectVisibleTitles(page, [TITLES.billing]);
|
||||
});
|
||||
|
||||
test("says the query found nothing, not that history is empty", async ({ page }) => {
|
||||
await resetSeededPageState(page);
|
||||
await openSessions(page);
|
||||
|
||||
await search(page, `${NONCE} kubernetes`);
|
||||
await expect(page.getByTestId("sessions-empty")).toBeVisible({ timeout: 30_000 });
|
||||
await expect(page.getByText("No sessions match")).toBeVisible({ timeout: 30_000 });
|
||||
|
||||
await page.getByText("Clear search").click();
|
||||
await expectVisibleTitles(page, [TITLES.billing, TITLES.unbilled, TITLES.terminal]);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user