Q189: search says how many match, pages through all of them, and says when nothing matches
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
1b7106d511
commit
bc7e214b65
12 files changed
+163
-27
No files matched your search
@@ -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<C.SearchInput>,
|
||||
};
|
||||
@@ -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 {
|
||||
|
||||
@@ -697,6 +697,9 @@ export const zh: Record<string, string> = {
|
||||
"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} 的数据。",
|
||||
|
||||
@@ -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<SearchEntityType, string> = {
|
||||
@@ -38,40 +41,51 @@ export function SearchDialog({
|
||||
const [query, setQuery] = useState("");
|
||||
const [filter, setFilter] = useState<SearchFilter>("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<SearchAnswer | null>(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({
|
||||
<span style={{ fontSize: 12, color: "var(--text-3)" }}>{result.facets.type}</span>
|
||||
</button>
|
||||
))}
|
||||
{current && !current.error && current.total === 0 && (
|
||||
<div role="status" style={{ padding: 8, fontSize: 13, color: "var(--text-3)" }}>{t("Nothing matches “{words}”.", { words: trimmed })}</div>
|
||||
)}
|
||||
{current && current.total > 0 && (
|
||||
<div className="row" style={{ padding: "6px 8px", fontSize: 12, color: "var(--text-3)" }}>
|
||||
<span role="status">{t("Showing {shown} of {total}", { shown: current.results.length, total: current.total })}</span>
|
||||
{current.results.length < current.total && (
|
||||
<span style={{ marginLeft: "auto" }}><Button size="small" onClick={showMore} disabled={loadingMore}>{loadingMore ? t("Loading…") : t("Show more")}</Button></span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -48,4 +48,6 @@ export interface SearchInput {
|
||||
query: string;
|
||||
entityTypes?: SearchEntityType[];
|
||||
limit?: number;
|
||||
/** Hits to skip, for the next page. */
|
||||
offset?: number;
|
||||
}
|
||||
@@ -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<DesktopResult<RunHistoryPage>>;
|
||||
chatSpaceHistory(input: { orgId: string; spaceId: string; beforeMessageId: string | null } & ({ limit: number } | { fromMessageId: string })): Promise<DesktopResult<SpaceHistoryPage>>;
|
||||
revokeConnector(input: RevokeConnectorInput): Promise<DesktopResult<AppSnapshot>>;
|
||||
search(input: SearchInput): Promise<DesktopResult<SearchResultRecord[]>>;
|
||||
search(input: SearchInput): Promise<DesktopResult<SearchPage>>;
|
||||
subscribe(callback: (event: RendererEvent) => void): () => void;
|
||||
platform: "win32" | "darwin" | "linux";
|
||||
language: "en" | "zh";
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user