diff --git a/src/main/app/ipc-contracts/platform.ts b/src/main/app/ipc-contracts/platform.ts index 74e7c8ec..0a4d6f8f 100644 --- a/src/main/app/ipc-contracts/platform.ts +++ b/src/main/app/ipc-contracts/platform.ts @@ -80,6 +80,6 @@ export const platformContracts = { z.strictObject({ orgId: id, spaceId: id, beforeMessageId: id.nullable(), fromMessageId: id }), ]), search: z.strictObject({ - orgId: id, employeeId: id, query: z.string(), entityTypes: z.array(z.enum(SEARCH_ENTITY_TYPES)).optional(), limit: z.number().optional(), + orgId: id, employeeId: id, query: z.string(), entityTypes: z.array(z.enum(SEARCH_ENTITY_TYPES)).optional(), limit: z.number().optional(), offset: z.number().int().min(0).optional(), }) satisfies z.ZodType, }; diff --git a/src/main/services/search-service.ts b/src/main/services/search-service.ts index ad7b928f..dae07ac6 100644 --- a/src/main/services/search-service.ts +++ b/src/main/services/search-service.ts @@ -1,6 +1,6 @@ import type { DatabaseSync } from "node:sqlite"; import type { SearchInput } from "../../shared/commands"; -import { SEARCH_ENTITY_TYPES, type SearchEntityType, type SearchResultRecord } from "../../shared/domain"; +import { SEARCH_ENTITY_TYPES, type SearchEntityType, type SearchPage } from "../../shared/domain"; interface SearchRow { entity_type: SearchEntityType; @@ -21,10 +21,12 @@ interface SearchDoc { export class SearchService { constructor(private readonly db: DatabaseSync) {} - search(input: SearchInput): SearchResultRecord[] { + /** Every match the person may read, best first, counted in full before one page is cut from it. */ + search(input: SearchInput): SearchPage { this.assertEmployee(input.orgId, input.employeeId); const query = normalizeQuery(input.query); const limit = Math.max(1, Math.min(input.limit ?? 20, 50)); + const offset = Math.max(0, input.offset ?? 0); const entityTypes = validEntityTypes(input.entityTypes ?? [...SEARCH_ENTITY_TYPES]); this.reindexOrg(input.orgId); const rows = query @@ -32,19 +34,19 @@ export class SearchService { select entity_type, entity_id, title, body, facet_json from search_documents where search_documents match ? and org_id = ? - limit ? - `).all(query, input.orgId, limit * 4) as unknown as SearchRow[] + order by rank + `).all(query, input.orgId) as unknown as SearchRow[] : this.db.prepare(` select entity_type, entity_id, title, body, facet_json from search_documents where org_id = ? order by rowid desc - limit ? - `).all(input.orgId, limit * 4) as unknown as SearchRow[]; - return rows + `).all(input.orgId) as unknown as SearchRow[]; + const readable = rows .filter((row) => entityTypes.has(row.entity_type)) - .filter((row) => this.canRead(input.orgId, input.employeeId, row.entity_type, row.entity_id)) - .slice(0, limit) + .filter((row) => this.canRead(input.orgId, input.employeeId, row.entity_type, row.entity_id)); + const results = readable + .slice(offset, offset + limit) .map((row) => ({ entityType: row.entity_type, entityId: row.entity_id, @@ -52,6 +54,7 @@ export class SearchService { preview: preview(row.body), facets: parseFacets(row.facet_json), })); + return { results, total: readable.length }; } private reindexOrg(orgId: string): void { diff --git a/src/renderer/src/i18n/zh.ts b/src/renderer/src/i18n/zh.ts index 054572bb..74bffa34 100644 --- a/src/renderer/src/i18n/zh.ts +++ b/src/renderer/src/i18n/zh.ts @@ -697,6 +697,9 @@ export const zh: Record = { "That did not work": "操作未成功", "Working…": "处理中…", "Searching…": "正在搜索…", + "Nothing matches “{words}”.": "没有与“{words}”匹配的结果。", + "Show more": "显示更多", + "Loading…": "正在加载…", "Search failed: {reason}": "搜索失败:{reason}", "Not up to date.": "数据不是最新的。", "Showing data from {time}.": "当前显示 {time} 的数据。", diff --git a/src/renderer/src/shared/SearchDialog.tsx b/src/renderer/src/shared/SearchDialog.tsx index fff2f196..c36dad75 100644 --- a/src/renderer/src/shared/SearchDialog.tsx +++ b/src/renderer/src/shared/SearchDialog.tsx @@ -1,3 +1,4 @@ +import { withNextPage, type SearchAnswer } from "./search-pages"; import { useEffect, useMemo, useState, type CSSProperties, type FormEvent } from "react"; import type { AppSnapshot, SearchEntityType, SearchResultRecord } from "../../../shared/domain"; import { SEARCH_ENTITY_TYPES } from "../../../shared/domain"; @@ -6,6 +7,8 @@ import type { Focus } from "../pages/types"; import { Button, Icon, Pill } from "./ui"; import { t } from "../i18n/i18n"; +const PAGE = 12; + type SearchFilter = "all" | SearchEntityType; const LABELS: Record = { @@ -38,40 +41,51 @@ export function SearchDialog({ const [query, setQuery] = useState(""); const [filter, setFilter] = useState("all"); // An answer belongs to the company, filter and words it was asked for; only the current question's answer is shown. - const [answer, setAnswer] = useState<{ key: string; results: SearchResultRecord[]; error: string | null } | null>(null); + const [answer, setAnswer] = useState(null); + const [loadingMore, setLoadingMore] = useState(false); const orgId = snapshot.activeOrgId; const employee = useMemo(() => snapshot.employees.find((item) => item.orgId === orgId), [snapshot.employees, orgId]); + // The id, not the record: a snapshot refresh must not ask again and reset the pages already shown. + const employeeId = employee?.id ?? null; const trimmed = query.trim(); const canSearch = Boolean(orgId && employee && trimmed.length >= 2); const key = `${orgId}|${filter}|${trimmed}`; const current = canSearch && answer?.key === key ? answer : null; const visibleResults = current?.results ?? []; const searching = canSearch && !current; + const ask = (words: string, offset: number) => window.agentCompany.search({ + orgId: orgId ?? "", + employeeId: employeeId ?? "", + query: words, + entityTypes: filter === "all" ? undefined : [filter], + limit: PAGE, + offset, + }); useEffect(() => { - if (!open || !orgId || !employee || trimmed.length < 2) return; + if (!open || !orgId || !employeeId || trimmed.length < 2) return; // A reply to a question that has since changed is dropped, so a slow old answer never replaces a newer one. let live = true; const asked = `${orgId}|${filter}|${trimmed}`; const timeout = window.setTimeout(() => { void window.agentCompany.search({ orgId, - employeeId: employee.id, + employeeId, query: trimmed, entityTypes: filter === "all" ? undefined : [filter], - limit: 12, + limit: PAGE, }).then((result) => { if (!live) return; - setAnswer(result.ok ? { key: asked, results: result.data, error: null } : { key: asked, results: [], error: result.error.message }); + setAnswer(result.ok ? { key: asked, ...result.data, error: null } : { key: asked, results: [], total: 0, error: result.error.message }); }, (reason: unknown) => { - if (live) setAnswer({ key: asked, results: [], error: reason instanceof Error ? reason.message : String(reason) }); + if (live) setAnswer({ key: asked, results: [], total: 0, error: reason instanceof Error ? reason.message : String(reason) }); }); }, 120); return () => { live = false; window.clearTimeout(timeout); }; - }, [employee, filter, open, orgId, trimmed]); + }, [employeeId, filter, open, orgId, trimmed]); useEffect(() => { if (!open) return; @@ -83,6 +97,19 @@ export function SearchDialog({ }, [onClose, open]); if (!open) return null; + // The next page joins the shown one only if the words are still the same. + const showMore = () => { + if (!current || loadingMore) return; + const asked = key; + setLoadingMore(true); + void ask(trimmed, current.results.length).then((result) => { + setLoadingMore(false); + setAnswer((shown) => withNextPage(shown, asked, result.ok ? { page: result.data } : { error: result.error.message })); + }, (reason: unknown) => { + setLoadingMore(false); + setAnswer((shown) => withNextPage(shown, asked, { error: reason instanceof Error ? reason.message : String(reason) })); + }); + }; const openResult = (result: SearchResultRecord) => { const target = targetFor(result); onNavigate(target.route, target.focus); @@ -144,6 +171,17 @@ export function SearchDialog({ {result.facets.type} ))} + {current && !current.error && current.total === 0 && ( +
{t("Nothing matches “{words}”.", { words: trimmed })}
+ )} + {current && current.total > 0 && ( +
+ {t("Showing {shown} of {total}", { shown: current.results.length, total: current.total })} + {current.results.length < current.total && ( + + )} +
+ )} diff --git a/src/renderer/src/shared/search-pages.ts b/src/renderer/src/shared/search-pages.ts new file mode 100644 index 00000000..cb7a5577 --- /dev/null +++ b/src/renderer/src/shared/search-pages.ts @@ -0,0 +1,16 @@ +import type { SearchPage, SearchResultRecord } from "../../../shared/domain"; + +/** The answer shown for one set of words: its hits so far, how many there are, and the last failure, if any. */ +export interface SearchAnswer { + key: string; + results: SearchResultRecord[]; + total: number; + error: string | null; +} + +/** Adds a later page to the answer it was asked for; a page for other words is dropped, and a success clears an earlier failure. */ +export function withNextPage(shown: SearchAnswer | null, asked: string, outcome: { page: SearchPage } | { error: string }): SearchAnswer | null { + if (!shown || shown.key !== asked) return shown; + if ("error" in outcome) return { ...shown, error: outcome.error }; + return { key: shown.key, results: [...shown.results, ...outcome.page.results], total: outcome.page.total, error: null }; +} diff --git a/src/shared/commands/onboarding.ts b/src/shared/commands/onboarding.ts index 3a98f179..2754ca96 100644 --- a/src/shared/commands/onboarding.ts +++ b/src/shared/commands/onboarding.ts @@ -48,4 +48,6 @@ export interface SearchInput { query: string; entityTypes?: SearchEntityType[]; limit?: number; + /** Hits to skip, for the next page. */ + offset?: number; } diff --git a/src/shared/desktop-api.ts b/src/shared/desktop-api.ts index bf1ee2c6..de8acf90 100644 --- a/src/shared/desktop-api.ts +++ b/src/shared/desktop-api.ts @@ -3,7 +3,7 @@ import type { SpaceHistoryPage, ThreadHistory } from "./chat-domain"; import type { RunRequestRecord } from "./provider-runtime-domain"; import type { ApprovalDrift } from "./approval-pin"; import type { DesktopResult } from "./result"; -import type { AppSnapshot, SearchResultRecord } from "./domain"; +import type { AppSnapshot, SearchPage } from "./domain"; import type { ProviderModelEntry } from "./provider-models"; import type { BootstrapOnboardingInput, @@ -238,7 +238,7 @@ export interface DesktopApi { runHistory(input: { orgId: string; beforeRunId: string | null } & ({ limit: number } | { from: RunCursor })): Promise>; chatSpaceHistory(input: { orgId: string; spaceId: string; beforeMessageId: string | null } & ({ limit: number } | { fromMessageId: string })): Promise>; revokeConnector(input: RevokeConnectorInput): Promise>; - search(input: SearchInput): Promise>; + search(input: SearchInput): Promise>; subscribe(callback: (event: RendererEvent) => void): () => void; platform: "win32" | "darwin" | "linux"; language: "en" | "zh"; diff --git a/src/shared/domain.ts b/src/shared/domain.ts index c6a87d7c..1387372f 100644 --- a/src/shared/domain.ts +++ b/src/shared/domain.ts @@ -9,7 +9,7 @@ export type { ProviderAuthProfileRecord, ProviderAuthSessionRecord } from "./pro export { PROVIDER_DATA_POLICY, PROVIDER_EXECUTION_MODE, type ProviderDataPolicy, type ProviderExecutionMode } from "./provider-policy-domain"; export { PROVIDER_REQUEST_STATE, type ProviderRequestRecord, type ProviderRequestState, type ProviderStreamRendererEvent } from "./provider-request-domain"; export type { ProviderRateLimitObservationRecord, ProviderRateLimitStateRecord } from "./provider-runtime-domain"; -export { SEARCH_ENTITY_TYPES, type SearchEntityType, type SearchResultRecord } from "./search-domain"; +export { SEARCH_ENTITY_TYPES, type SearchEntityType, type SearchPage, type SearchResultRecord } from "./search-domain"; export type { BrowserActionPolicyRecord, BrowserActionRecord, BrowserSessionContainerRecord, SocialAccountGrantRecord, SocialAccountRecord, SocialApprovalRouteRecord, SocialAuditRecord } from "./social-domain"; export type { AppSnapshot, UiLanguage, UiPreferences, UiTheme } from "./snapshot-domain"; export const TASK_STATUS = { BACKLOG: "backlog", IN_PROGRESS: "in_progress", REVIEW: "review", DONE: "done" } as const; diff --git a/src/shared/search-domain.ts b/src/shared/search-domain.ts index 22c53205..069a328b 100644 --- a/src/shared/search-domain.ts +++ b/src/shared/search-domain.ts @@ -16,6 +16,12 @@ export const SEARCH_ENTITY_TYPES = [ export type SearchEntityType = (typeof SEARCH_ENTITY_TYPES)[number]; +/** One page of the hits the person may read, and how many there are in all. */ +export interface SearchPage { + results: SearchResultRecord[]; + total: number; +} + export interface SearchResultRecord { entityType: SearchEntityType; entityId: string; diff --git a/tests/e2e/search-open.spec.ts b/tests/e2e/search-open.spec.ts index 555b73a3..9be02ca4 100644 --- a/tests/e2e/search-open.spec.ts +++ b/tests/e2e/search-open.spec.ts @@ -45,7 +45,32 @@ test("a search hit opens the object itself, not just its page", async () => { await searchDialog.getByRole("textbox").fill("Walrus"); await expect(searchDialog.getByRole("button", { name: /Walrus pricing memo/ })).toBeVisible(); await expect(searchDialog.getByRole("button", { name: /zebra crossing note/ })).toHaveCount(0); - await expect(searchDialog.getByRole("status")).toHaveCount(0); + await expect(searchDialog.getByText("Searching…")).toHaveCount(0); + await page.keyboard.press("Escape"); + + // Search says how many match, pages through the rest, and says when nothing matches. + await page.evaluate(async () => { + const api = (window as unknown as { agentCompany: { + loadSnapshot: () => Promise<{ ok: true; data: { activeOrgId: string } }>; + createTask: (input: unknown) => Promise<{ ok: boolean }>; + } }).agentCompany; + const orgId = (await api.loadSnapshot()).data.activeOrgId; + for (let index = 0; index < 15; index += 1) { + await api.createTask({ + orgId, title: `Lantern check ${index}`, description: "d", status: "backlog", assigneeAgentId: null, risk: "low", + projectId: null, goalId: null, workspaceId: null, priority: "normal", requiresFilesystem: false, + }); + } + }); + await page.locator(".search-kbd").click(); + await searchDialog.getByRole("textbox").fill("lantern"); + await expect(searchDialog.getByText("Showing 12 of 15")).toBeVisible(); + await searchDialog.getByRole("button", { name: "Show more" }).click(); + await expect(searchDialog.getByText("Showing 15 of 15")).toBeVisible(); + await expect(searchDialog.getByRole("button", { name: /Lantern check/ })).toHaveCount(15); + await expect(searchDialog.getByRole("button", { name: "Show more" })).toHaveCount(0); + await searchDialog.getByRole("textbox").fill("qqzzxx"); + await expect(searchDialog.getByText("Nothing matches “qqzzxx”.")).toBeVisible(); await page.keyboard.press("Escape"); // A message opens its conversation with its thread shown and the message marked. diff --git a/tests/unit/search-pages.test.ts b/tests/unit/search-pages.test.ts new file mode 100644 index 00000000..8c44fc38 --- /dev/null +++ b/tests/unit/search-pages.test.ts @@ -0,0 +1,17 @@ +import { describe, expect, test } from "vitest"; +import { withNextPage, type SearchAnswer } from "../../src/renderer/src/shared/search-pages"; + +const hit = (id: string) => ({ entityType: "task" as const, entityId: id, title: id, preview: "", facets: {} }); + +describe("search pages", () => { + test("a later page joins its own answer; a success after a failure clears the failure", () => { + const shown: SearchAnswer = { key: "org|all|lantern", results: [hit("a")], total: 3, error: null }; + const failed = withNextPage(shown, "org|all|lantern", { error: "IPC failed" }); + expect(failed).toEqual({ ...shown, error: "IPC failed" }); + const recovered = withNextPage(failed, "org|all|lantern", { page: { results: [hit("b"), hit("c")], total: 3 } }); + expect(recovered).toEqual({ key: "org|all|lantern", results: [hit("a"), hit("b"), hit("c")], total: 3, error: null }); + // A page asked for other words never lands in this answer. + expect(withNextPage(recovered, "org|all|other", { page: { results: [hit("z")], total: 9 } })).toBe(recovered); + expect(withNextPage(null, "org|all|lantern", { page: { results: [hit("z")], total: 1 } })).toBeNull(); + }); +}); diff --git a/tests/unit/search-service.test.ts b/tests/unit/search-service.test.ts index 852b4b63..0ab9eca7 100644 --- a/tests/unit/search-service.test.ts +++ b/tests/unit/search-service.test.ts @@ -46,7 +46,7 @@ describe("search service", () => { }); }); - const results = search.search({ orgId, employeeId, query: "production", entityTypes: ["task"] }); + const results = search.search({ orgId, employeeId, query: "production", entityTypes: ["task"] }).results; expect(results).toHaveLength(1); expect(results[0]).toMatchObject({ entityType: "task", title: "Production deploy", facets: { type: "Work", risk: "high" } }); connection.close(); @@ -92,13 +92,13 @@ describe("search service", () => { }); }); - const hits = search.search({ orgId, employeeId: ownerId, query: "secret", entityTypes: ["chat_message"] }); + const hits = search.search({ orgId, employeeId: ownerId, query: "secret", entityTypes: ["chat_message"] }).results; expect(hits).toHaveLength(1); // A hit names its conversation and thread, so opening it shows the message itself. const message = connection.db.prepare("select space_id, thread_id from chat_messages where id = ?").get(hits[0]!.entityId) as { space_id: string; thread_id: string }; expect(hits[0]!.facets).toMatchObject({ space: spaceId, thread: message.thread_id }); expect(message.space_id).toBe(spaceId); - expect(search.search({ orgId, employeeId: outsiderId, query: "secret", entityTypes: ["chat_message"] })).toHaveLength(0); + expect(search.search({ orgId, employeeId: outsiderId, query: "secret", entityTypes: ["chat_message"] }).results).toHaveLength(0); connection.close(); }); @@ -125,9 +125,9 @@ describe("search service", () => { sourceType: "employee", sourceId: null, scopeType: "organization", scopeId: null, validUntil: null, supersedesId: oldId, }); - const docs = search.search({ orgId, employeeId, query: "twelve hundred" }); + const docs = search.search({ orgId, employeeId, query: "twelve hundred" }).results; expect(docs.map((r) => [r.entityType, r.title, r.facets.kind])).toEqual([["document", "Pricing memo", "memo"]]); - const entries = search.search({ orgId, employeeId, query: "refunds", entityTypes: ["knowledge"] }); + const entries = search.search({ orgId, employeeId, query: "refunds", entityTypes: ["knowledge"] }).results; expect(entries.map((r) => r.facets.state).sort()).toEqual(["active", "superseded"]); connection.close(); }); @@ -147,7 +147,33 @@ describe("search service", () => { `).run(id, orgId, id, employeeId, new Date().toISOString(), supersededAt); file("fil_old", new Date().toISOString()); file("fil_new", null); - expect(search.search({ orgId, employeeId, query: "roadmap", entityTypes: ["file"] }).map((hit) => hit.entityId)).toEqual(["fil_new"]); + expect(search.search({ orgId, employeeId, query: "roadmap", entityTypes: ["file"] }).results.map((hit) => hit.entityId)).toEqual(["fil_new"]); + connection.close(); + }); + + test("counts every readable match and pages through them", () => { + const connection = createConnection(); + const organization = new OrganizationService(connection.db); + const search = new SearchService(connection.db); + let orgId = ""; + connection.transaction(() => { + orgId = organization.createOrganization({ organizationName: "Paging Co", employeeName: "Dana Lee", mission: "m", goal: "g" }); + for (let index = 0; index < 30; index += 1) { + organization.createTask({ + orgId, title: `Quarterly audit ${index}`, description: "d", status: TASK_STATUS.BACKLOG, assigneeAgentId: null, risk: "low", + projectId: null, goalId: null, workspaceId: null, priority: "normal", requiresFilesystem: false, + }); + } + }); + const employeeId = (connection.db.prepare("select id from employees where org_id = ?").get(orgId) as { id: string }).id; + const first = search.search({ orgId, employeeId, query: "quarterly", entityTypes: ["task"], limit: 12 }); + expect(first.results).toHaveLength(12); + expect(first.total).toBe(30); + const last = search.search({ orgId, employeeId, query: "quarterly", entityTypes: ["task"], limit: 12, offset: 24 }); + expect(last.results).toHaveLength(6); + const seen = new Set([...first.results, ...search.search({ orgId, employeeId, query: "quarterly", entityTypes: ["task"], limit: 12, offset: 12 }).results, ...last.results].map((hit) => hit.entityId)); + expect(seen.size).toBe(30); + expect(search.search({ orgId, employeeId, query: "nothinglikethis", limit: 12 })).toEqual({ results: [], total: 0 }); connection.close(); }); });