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:
ZacharyZhang-NYandClaude Opus 5.5 committed 2026-10-09 08:27:51 -04:00
1 parent 1b7106d511
commit bc7e214b65
12 files changed
+163 -27

No files matched your search

+3
View File
@@ -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} 的数据。",
+45 -7
View File
@@ -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>
+16
View File
@@ -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 };
}