From 6bedba850c3f5013a03812937b6f1907fcdcbe86 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9B=B7=E7=94=B5=E8=8A=BD=E8=A1=A3?= Date: Fri, 9 Oct 2026 08:10:22 -0400 Subject: [PATCH] Q187: a search hit opens the object itself and marks it Co-Authored-By: Claude Opus 5.5 --- src/main/services/search-service.ts | 31 ++-------- src/renderer/src/app/App.tsx | 1 + src/renderer/src/pages/ChatPage.tsx | 7 ++- src/renderer/src/pages/FilesPage.tsx | 9 ++- src/renderer/src/pages/LibraryPage.tsx | 8 +-- src/renderer/src/pages/SettingsPage.tsx | 4 +- src/renderer/src/pages/TeamPage.tsx | 7 ++- .../src/pages/chat/ChatConversation.tsx | 5 +- .../src/pages/chat/ChatMessageRow.tsx | 2 +- src/renderer/src/pages/files/FilesGrid.tsx | 1 + src/renderer/src/pages/files/FilesTable.tsx | 1 + .../src/pages/library/DocumentsPanel.tsx | 8 ++- .../src/pages/library/KnowledgePanel.tsx | 11 +++- .../src/pages/settings/ProvidersPanel.tsx | 2 + src/renderer/src/pages/types.ts | 5 +- src/renderer/src/shared/SearchDialog.tsx | 53 ++++++++++------- src/renderer/src/shared/Shell.tsx | 6 +- src/renderer/src/shared/reveal.ts | 28 +++++++++ src/renderer/src/styles.css | 3 + src/shared/search-domain.ts | 2 - tests/e2e/desktop.spec.ts | 4 ++ tests/e2e/search-open.spec.ts | 57 +++++++++++++++++++ tests/unit/search-service.test.ts | 26 ++++++++- 23 files changed, 213 insertions(+), 68 deletions(-) create mode 100644 src/renderer/src/shared/reveal.ts create mode 100644 tests/e2e/search-open.spec.ts diff --git a/src/main/services/search-service.ts b/src/main/services/search-service.ts index ea658bab..ad7b928f 100644 --- a/src/main/services/search-service.ts +++ b/src/main/services/search-service.ts @@ -86,8 +86,6 @@ export class SearchService { ...this.chatMessageDocs(orgId), ...this.fileDocs(orgId), ...this.providerDocs(orgId), - ...this.socialDocs(orgId), - ...this.scheduledPostDocs(orgId), ...this.documentDocs(orgId), ...this.knowledgeDocs(orgId), ]; @@ -134,12 +132,13 @@ export class SearchService { } private chatMessageDocs(orgId: string): SearchDoc[] { - const rows = this.db.prepare("select id, thread_id, body_md, sender_type from chat_messages where org_id = ? and deleted_at is null").all(orgId) as Array<{ id: string; thread_id: string; body_md: string; sender_type: string }>; - return rows.map((row) => doc("chat_message", row.id, createTitle(row.body_md), row.body_md, { type: "Messages", sender: row.sender_type, thread: row.thread_id })); + const rows = this.db.prepare("select id, space_id, thread_id, body_md, sender_type from chat_messages where org_id = ? and deleted_at is null").all(orgId) as Array<{ id: string; space_id: string; thread_id: string; body_md: string; sender_type: string }>; + return rows.map((row) => doc("chat_message", row.id, createTitle(row.body_md), row.body_md, { type: "Messages", sender: row.sender_type, space: row.space_id, thread: row.thread_id })); } private fileDocs(orgId: string): SearchDoc[] { - const rows = this.db.prepare("select id, name, kind, sha256, scan_state from file_objects where org_id = ?").all(orgId) as Array<{ id: string; name: string; kind: string; sha256: string; scan_state: string }>; + // Superseded versions are kept for records but listed nowhere, so they are not search hits either. + const rows = this.db.prepare("select id, name, kind, sha256, scan_state from file_objects where org_id = ? and superseded_at is null").all(orgId) as Array<{ id: string; name: string; kind: string; sha256: string; scan_state: string }>; return rows.map((row) => doc("file", row.id, row.name, `${row.kind} ${row.sha256}`, { type: "Files", state: row.scan_state })); } @@ -148,16 +147,6 @@ export class SearchService { return rows.map((row) => doc("provider", row.id, row.label, row.provider_key, { type: "Settings", auth: row.auth_state, health: row.health_state })); } - private socialDocs(orgId: string): SearchDoc[] { - const rows = this.db.prepare("select id, platform, handle, display_name from social_accounts where org_id = ?").all(orgId) as Array<{ id: string; platform: string; handle: string; display_name: string }>; - return rows.map((row) => doc("social_account", row.id, row.display_name, `${row.platform} ${row.handle}`, { type: "Social", platform: row.platform })); - } - - private scheduledPostDocs(orgId: string): SearchDoc[] { - const rows = this.db.prepare("select id, social_account_id, body_md, state from scheduled_posts where org_id = ?").all(orgId) as Array<{ id: string; social_account_id: string; body_md: string; state: string }>; - return rows.map((row) => doc("scheduled_post", row.id, createTitle(row.body_md), row.body_md, { type: "Social", state: row.state, account: row.social_account_id })); - } - private documentDocs(orgId: string): SearchDoc[] { const rows = this.db.prepare("select id, title, doc_type, status, content from documents where org_id = ?").all(orgId) as Array<{ id: string; title: string; doc_type: string; status: string; content: string }>; return rows.map((row) => doc("document", row.id, row.title, row.content, { type: "Library", kind: row.doc_type, status: row.status })); @@ -180,8 +169,6 @@ export class SearchService { if (entityType === "chat_space") return this.canReadSpace(entityId, employeeId); if (entityType === "chat_message") return this.canReadMessage(orgId, entityId, employeeId); if (entityType === "file") return this.canReadFile(orgId, entityId, employeeId); - if (entityType === "social_account") return this.canReadSocialAccount(entityId, employeeId); - if (entityType === "scheduled_post") return this.canReadScheduledPost(entityId, employeeId); return true; } @@ -210,16 +197,6 @@ export class SearchService { return Boolean(grant); } - private canReadSocialAccount(accountId: string, employeeId: string): boolean { - const grant = this.db.prepare("select id from social_account_grants where social_account_id = ? and employee_id = ? limit 1").get(accountId, employeeId); - return Boolean(grant); - } - - private canReadScheduledPost(postId: string, employeeId: string): boolean { - const row = this.db.prepare("select social_account_id from scheduled_posts where id = ?").get(postId) as { social_account_id: string } | undefined; - return row ? this.canReadSocialAccount(row.social_account_id, employeeId) : false; - } - private assertEmployee(orgId: string, employeeId: string): void { const row = this.db.prepare("select id from employees where id = ? and org_id = ?").get(employeeId, orgId); if (!row) throw new Error("Search employee was not found."); diff --git a/src/renderer/src/app/App.tsx b/src/renderer/src/app/App.tsx index 9e7a02c8..3bb4406d 100644 --- a/src/renderer/src/app/App.tsx +++ b/src/renderer/src/app/App.tsx @@ -126,6 +126,7 @@ export function App() { onSwitchOrg={(orgId) => requestLeave(() => void actions.run(() => window.agentCompany.setActiveOrganization({ orgId })))} onDeleteOrg={(orgId) => requestLeave(() => void actions.run(() => window.agentCompany.deleteOrganization({ orgId })))} onNewOrg={() => requestLeave(() => setCreatingOrg(true))} + onOpen={navigate} > {page} diff --git a/src/renderer/src/pages/ChatPage.tsx b/src/renderer/src/pages/ChatPage.tsx index 6b3fd668..a2e32712 100644 --- a/src/renderer/src/pages/ChatPage.tsx +++ b/src/renderer/src/pages/ChatPage.tsx @@ -1,3 +1,4 @@ +import { useRevealObject } from "../shared/reveal"; import { isIdOrNull, usePersistentState } from "../shared/view-state"; import { useLeaveGuard } from "../app/leave-guard"; import { setDrafts, useDrafts } from "./chat/draft-store"; @@ -23,7 +24,7 @@ function value(form: FormData, key: string): string { const IDLE_SEND: SendState = { pending: false, error: null }; const EMPTY_DRAFT: Draft = { body: "", attachmentFileIds: [] }; -export function ChatPage({ snapshot, actions }: PageProps) { +export function ChatPage({ snapshot, actions, focus }: PageProps) { const orgId = activeOrgId(snapshot); const employee = requireActiveEmployee(snapshot); const spaces = useMemo(() => snapshot.chatSpaces.filter((space) => space.orgId === orgId), [snapshot.chatSpaces, orgId]); @@ -67,7 +68,8 @@ export function ChatPage({ snapshot, actions }: PageProps) { }), [spaces, snapshot.chatMemberships, messagesAll, snapshot.chatReadStates, employees, agents, employee.id, ceoAgentId], ); - const [selectedSpaceId, setSelectedSpaceId] = usePersistentState(`${orgId}:chat.space`, null, isIdOrNull); + const [selectedSpaceId, setSelectedSpaceId] = usePersistentState(`${orgId}:chat.space`, null, isIdOrNull, focus?.kind === "space" ? focus.id : focus?.kind === "message" ? focus.spaceId : undefined); + useRevealObject(focus?.kind === "message" ? focus.id : null); const [createOpen, setCreateOpen] = useState(false); const requestLeave = useLeaveGuard(); // Text and attachment are kept per conversation, outside the page, so switching conversations or pages loses neither. @@ -236,6 +238,7 @@ export function ChatPage({ snapshot, actions }: PageProps) { { return (section.kind === "department" || section.kind === "agent") && typeof section.value === "string"; }; -export function FilesPage({ snapshot, actions }: PageProps) { +export function FilesPage({ snapshot, actions, focus }: PageProps) { const orgId = activeOrgId(snapshot); const employees = snapshot.employees.filter((employee) => employee.orgId === orgId); const agents = snapshot.agents.filter((agent) => agent.orgId === orgId); @@ -38,9 +39,11 @@ export function FilesPage({ snapshot, actions }: PageProps) { () => snapshot.fileAccessGrants.filter((grant) => grant.orgId === orgId), [snapshot.fileAccessGrants, orgId], ); - const [section, setSection] = usePersistentState
(`${orgId}:files.section`, { kind: "all" }, isSection); + const fileFocus = focus?.kind === "file" ? focus.id : null; + const [section, setSection] = usePersistentState
(`${orgId}:files.section`, { kind: "all" }, isSection, fileFocus ? { kind: "all" } : undefined); + useRevealObject(fileFocus); const [view, setView] = usePersistentState(`${orgId}:files.view`, "list", oneOf(["grid", "list"] as const)); - const [scanFilter, setScanFilter] = usePersistentState(`${orgId}:files.scan`, "all", (value): value is FileScanState | "all" => typeof value === "string"); + const [scanFilter, setScanFilter] = usePersistentState(`${orgId}:files.scan`, "all", (value): value is FileScanState | "all" => typeof value === "string", fileFocus ? "all" : undefined); const departments = collectDepartments(files, agents); const filesByAgent = collectAgentFiles(files, agents); diff --git a/src/renderer/src/pages/LibraryPage.tsx b/src/renderer/src/pages/LibraryPage.tsx index ec517829..de89d572 100644 --- a/src/renderer/src/pages/LibraryPage.tsx +++ b/src/renderer/src/pages/LibraryPage.tsx @@ -13,10 +13,10 @@ const TABS: Array<{ id: LibraryTab; label: string }> = [ { id: "knowledge", label: t("Knowledge") }, ]; -export function LibraryPage({ snapshot, actions }: PageProps) { +export function LibraryPage({ snapshot, actions, focus }: PageProps) { const orgId = activeOrgId(snapshot); const employee = requireActiveEmployee(snapshot); - const [tab, setTab] = usePersistentState(`${orgId}:library.tab`, "documents", oneOf(["documents", "knowledge"] as const)); + const [tab, setTab] = usePersistentState(`${orgId}:library.tab`, "documents", oneOf(["documents", "knowledge"] as const), focus?.kind === "document" ? "documents" : focus?.kind === "knowledge" ? "knowledge" : undefined); const documents = useMemo(() => inOrg(snapshot.documents, orgId), [snapshot.documents, orgId]); const entries = useMemo(() => inOrg(snapshot.knowledgeEntries, orgId), [snapshot.knowledgeEntries, orgId]); const tasks = useMemo(() => inOrg(snapshot.tasks, orgId), [snapshot.tasks, orgId]); @@ -50,8 +50,8 @@ export function LibraryPage({ snapshot, actions }: PageProps) {

{tab === "documents" ? t("Documents") : t("Knowledge")}

{tab === "documents" - ? - : } + ? + : }
diff --git a/src/renderer/src/pages/SettingsPage.tsx b/src/renderer/src/pages/SettingsPage.tsx index ccbc9e95..ef475502 100644 --- a/src/renderer/src/pages/SettingsPage.tsx +++ b/src/renderer/src/pages/SettingsPage.tsx @@ -1,3 +1,4 @@ +import { useRevealObject } from "../shared/reveal"; import { oneOf, usePersistentState } from "../shared/view-state"; import { useLeaveGuard } from "../app/leave-guard"; import { useMemo } from "react"; @@ -29,7 +30,8 @@ export function SettingsPage({ snapshot, actions, focus }: PageProps) { const organization = requireActiveOrganization(snapshot); const employee = requireActiveEmployee(snapshot); // A workspace link opens General, where workspaces are, whatever section was left open. - const [tab, setTab] = usePersistentState(`${orgId}:settings.tab`, "general", oneOf(SETTINGS_TABS), focus?.kind === "workspace" ? "general" : undefined); + const [tab, setTab] = usePersistentState(`${orgId}:settings.tab`, "general", oneOf(SETTINGS_TABS), focus?.kind === "workspace" ? "general" : focus?.kind === "provider" ? "providers" : undefined); + useRevealObject(focus?.kind === "provider" ? focus.id : null); const requestLeave = useLeaveGuard(); const agents = useMemo(() => snapshot.agents.filter((item) => item.orgId === orgId), [snapshot.agents, orgId]); diff --git a/src/renderer/src/pages/TeamPage.tsx b/src/renderer/src/pages/TeamPage.tsx index 103031f8..51d030e9 100644 --- a/src/renderer/src/pages/TeamPage.tsx +++ b/src/renderer/src/pages/TeamPage.tsx @@ -1,3 +1,4 @@ +import { useRevealObject } from "../shared/reveal"; import { oneOf, usePersistentState } from "../shared/view-state"; import { agentModel } from "../../../shared/agent-model"; import { useMemo, useState, type FormEvent } from "react"; @@ -19,13 +20,14 @@ function optional(form: FormData, key: string): string | null { return text(form, key) || null; } -export function TeamPage({ snapshot, actions }: PageProps) { +export function TeamPage({ snapshot, actions, focus }: PageProps) { const orgId = activeOrgId(snapshot); const [hireOpen, setHireOpen] = useState(false); const [hireProviderId, setHireProviderId] = useState(""); const [hireModelId, setHireModelId] = useState(""); const [editAgentId, setEditAgentId] = useState(null); - const [filter, setFilter] = usePersistentState<"all" | "human" | "agent">(`${orgId}:team.filter`, "all", oneOf(["all", "human", "agent"] as const)); + const [filter, setFilter] = usePersistentState<"all" | "human" | "agent">(`${orgId}:team.filter`, "all", oneOf(["all", "human", "agent"] as const), focus?.kind === "agent" || focus?.kind === "employee" ? "all" : undefined); + useRevealObject(focus?.kind === "agent" || focus?.kind === "employee" ? focus.id : null); const employees = useMemo(() => snapshot.employees.filter((item) => item.orgId === orgId), [snapshot.employees, orgId]); const agents = useMemo(() => snapshot.agents.filter((item) => item.orgId === orgId), [snapshot.agents, orgId]); @@ -291,6 +293,7 @@ function ModelSuggestions({ id, providerId, providers, catalog }: { id: string; function RosterRow({ row, last, onEdit }: { row: RosterEntry; last: boolean; onEdit: (() => void) | null }) { return (
diff --git a/src/renderer/src/pages/chat/ChatConversation.tsx b/src/renderer/src/pages/chat/ChatConversation.tsx index 84097b23..d4b44544 100644 --- a/src/renderer/src/pages/chat/ChatConversation.tsx +++ b/src/renderer/src/pages/chat/ChatConversation.tsx @@ -24,6 +24,7 @@ import { Popover } from "../../shared/Popover"; import { t } from "../../i18n/i18n"; export function ChatConversation({ + initialThreadId, space, members, messagesInSpace, @@ -52,6 +53,8 @@ export function ChatConversation({ send, composerRef, }: { + /** The thread to show at once, as when a search hit opens one of its messages. */ + initialThreadId: string | null; space: ChatSpaceRecord | null; members: SpaceMember[]; messagesInSpace: ChatMessageRecord[]; @@ -83,7 +86,7 @@ export function ChatConversation({ }) { const [query, setQuery] = useState(""); // The component is keyed by space, so an open thread closes when the conversation changes. - const [openThreadId, setOpenThreadId] = useState(null); + const [openThreadId, setOpenThreadId] = useState(initialThreadId); // A reply typed in one thread is lost by opening another or closing the panel. const requestLeave = useLeaveGuard(); // The snapshot holds recent chat only; older messages come from the database, paged back from the oldest one loaded. diff --git a/src/renderer/src/pages/chat/ChatMessageRow.tsx b/src/renderer/src/pages/chat/ChatMessageRow.tsx index cacf043c..34754bf9 100644 --- a/src/renderer/src/pages/chat/ChatMessageRow.tsx +++ b/src/renderer/src/pages/chat/ChatMessageRow.tsx @@ -80,7 +80,7 @@ export function ChatMessageRow({ }); }; return ( -
+
diff --git a/src/renderer/src/pages/files/FilesGrid.tsx b/src/renderer/src/pages/files/FilesGrid.tsx index a95a79c5..751aa48a 100644 --- a/src/renderer/src/pages/files/FilesGrid.tsx +++ b/src/renderer/src/pages/files/FilesGrid.tsx @@ -57,6 +57,7 @@ export function PinnedCard({ const canOpen = file.scanState === "approved"; return (
(`${orgId}:library.document`, null, isIdOrNull); + const [selectedId, setSelectedId] = usePersistentState(`${orgId}:library.document`, null, isIdOrNull, focusId ?? undefined); + useRevealObject(focusId); const selected = documents.find((doc) => doc.id === selectedId) ?? null; const [loaded, setLoaded] = useState<{ documentId: string; versions: DocumentVersionRecord[] } | null>(null); const [shownVersion, setShownVersion] = useState(null); @@ -83,6 +88,7 @@ export function DocumentsPanel({ {documents.map((doc) => (
); diff --git a/src/renderer/src/shared/reveal.ts b/src/renderer/src/shared/reveal.ts new file mode 100644 index 00000000..b491b4b9 --- /dev/null +++ b/src/renderer/src/shared/reveal.ts @@ -0,0 +1,28 @@ +import { useEffect } from "react"; + +const TRIES = 30; +const EVERY_MS = 100; + +/** Brings the object a link opened into view and marks it, waiting briefly for content that loads after the page. */ +export function useRevealObject(id: string | null | undefined): void { + useEffect(() => { + if (!id) return; + let tries = 0; + let timer: ReturnType | null = null; + const find = () => { + const node = document.querySelector(`[data-object-id="${CSS.escape(id)}"]`); + if (node) { + node.scrollIntoView({ block: "center" }); + node.setAttribute("data-revealed", "true"); + return; + } + tries += 1; + if (tries < TRIES) timer = setTimeout(find, EVERY_MS); + else console.warn(`[reveal] ${id} is not on screen`); + }; + find(); + return () => { + if (timer) clearTimeout(timer); + }; + }, [id]); +} diff --git a/src/renderer/src/styles.css b/src/renderer/src/styles.css index f6a4dd5a..e0b3333b 100644 --- a/src/renderer/src/styles.css +++ b/src/renderer/src/styles.css @@ -670,3 +670,6 @@ a { color: inherit; } transition-duration: 0.01ms !important; } } + +/* The object a link or search hit opened. */ +[data-revealed="true"] { outline: 2px solid var(--accent); outline-offset: 2px; border-radius: 6px; } diff --git a/src/shared/search-domain.ts b/src/shared/search-domain.ts index 74f8cbfe..22c53205 100644 --- a/src/shared/search-domain.ts +++ b/src/shared/search-domain.ts @@ -10,8 +10,6 @@ export const SEARCH_ENTITY_TYPES = [ "chat_message", "file", "provider", - "social_account", - "scheduled_post", "document", "knowledge", ] as const; diff --git a/tests/e2e/desktop.spec.ts b/tests/e2e/desktop.spec.ts index 4ad67e90..c69305bd 100644 --- a/tests/e2e/desktop.spec.ts +++ b/tests/e2e/desktop.spec.ts @@ -176,6 +176,10 @@ test("onboards, hires, and creates work in the Electron shell", async () => { await page.getByRole("textbox", { name: "Search query" }).fill("production"); await expect(page.getByText("Production deploy").first()).toBeVisible(); await page.getByRole("dialog", { name: "Search" }).getByRole("button").filter({ hasText: "Production deploy" }).first().click(); + // The hit opens the task itself. + const deployTask = page.getByRole("dialog", { name: "Production deploy" }); + await expect(deployTask).toBeVisible(); + await deployTask.getByRole("button", { name: "Close" }).click(); await page.getByRole("button", { name: "Approvals" }).click(); await expect(page.getByText("Production deploy").first()).toBeVisible(); diff --git a/tests/e2e/search-open.spec.ts b/tests/e2e/search-open.spec.ts new file mode 100644 index 00000000..67bdde43 --- /dev/null +++ b/tests/e2e/search-open.spec.ts @@ -0,0 +1,57 @@ +import { _electron as electron, expect, test } from "@playwright/test"; +import { mkdirSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; +import { randomUUID } from "node:crypto"; +import { seedBuiltinSkillsFixture } from "./builtin-skills-fixture"; +import { closeApp, onboard } from "./onboard"; + +test("a search hit opens the object itself, not just its page", async () => { + const profileDir = join(tmpdir(), `agentcompany-new-e2e-search-${randomUUID()}`); + mkdirSync(profileDir, { recursive: true }); + const builtinDir = seedBuiltinSkillsFixture(join(tmpdir(), `agentcompany-new-e2e-search-builtin-${randomUUID()}`)); + const app = await electron.launch({ + args: [resolve("out/main/index.js")], + env: { ...process.env, AGENTCOMPANY_NEW_PROFILE_DIR: profileDir, AGENTCOMPANY_NEW_AUTORUN: "0", AGENTCOMPANY_NEW_BUILTIN_SKILLS_DIR: builtinDir }, + }); + try { + const page = await app.firstWindow(); + await onboard(page, "Search Co"); + await page.getByRole("button", { name: "Messages" }).click(); + await page.getByText("general").first().click(); + await page.getByRole("textbox", { name: "Message" }).fill("zebra crossing note"); + await page.getByRole("button", { name: "Send" }).click(); + await page.evaluate(async () => { + const api = (window as unknown as { agentCompany: { + loadSnapshot: () => Promise<{ ok: true; data: { activeOrgId: string } }>; + createDocument: (input: unknown) => Promise<{ ok: boolean }>; + } }).agentCompany; + const orgId = (await api.loadSnapshot()).data.activeOrgId; + await api.createDocument({ orgId, title: "Walrus pricing memo", docType: "memo", content: "Tiers.", authorAgentId: null, reviewerAgentId: null, projectId: null, goalId: null, taskId: null }); + }); + await page.getByRole("button", { name: "Home" }).click(); + + const search = async (query: string, title: RegExp) => { + await page.locator(".search-kbd").click(); + await page.getByRole("dialog", { name: "Search" }).getByRole("textbox").fill(query); + await page.getByRole("dialog", { name: "Search" }).getByRole("button", { name: title }).first().click(); + }; + + // A message opens its conversation with its thread shown and the message marked. + await search("zebra", /zebra crossing note/); + await expect(page.getByRole("complementary", { name: "Thread" })).toBeVisible(); + await expect(page.locator("[data-revealed=true]").first()).toContainText("zebra crossing note"); + + // A task opens its detail. + await search("Operationalize", /Operationalize goal/); + await expect(page.getByRole("dialog", { name: /Operationalize goal/ })).toBeVisible(); + await page.getByRole("dialog", { name: /Operationalize goal/ }).getByRole("button", { name: "Close" }).click(); + + // A document opens in the library, selected and marked. + await search("Walrus", /Walrus pricing memo/); + await expect(page.locator("[data-revealed=true]")).toContainText("Walrus pricing memo"); + await expect(page.getByRole("heading", { name: "Walrus pricing memo" })).toBeVisible(); + } finally { + await closeApp(app); + } +}); diff --git a/tests/unit/search-service.test.ts b/tests/unit/search-service.test.ts index 6cbd3665..852b4b63 100644 --- a/tests/unit/search-service.test.ts +++ b/tests/unit/search-service.test.ts @@ -92,7 +92,12 @@ describe("search service", () => { }); }); - expect(search.search({ orgId, employeeId: ownerId, query: "secret", entityTypes: ["chat_message"] })).toHaveLength(1); + const hits = search.search({ orgId, employeeId: ownerId, query: "secret", entityTypes: ["chat_message"] }); + 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); connection.close(); }); @@ -126,4 +131,23 @@ describe("search service", () => { expect(entries.map((r) => r.facets.state).sort()).toEqual(["active", "superseded"]); connection.close(); }); + + test("lists only current file versions, as the Files page does", () => { + const connection = createConnection(); + const organization = new OrganizationService(connection.db); + const search = new SearchService(connection.db); + let orgId = ""; + connection.transaction(() => { + orgId = organization.createOrganization({ organizationName: "Files Co", employeeName: "Dana Lee", mission: "m", goal: "g" }); + }); + const employeeId = (connection.db.prepare("select id from employees where org_id = ?").get(orgId) as { id: string }).id; + const file = (id: string, supersededAt: string | null) => connection.db.prepare(` + insert into file_objects(id, org_id, name, kind, size_bytes, sha256, storage_path, owner_type, owner_id, created_at, superseded_at) + values (?, ?, 'roadmap.md', 'md', 1, ?, '/tmp/x', 'employee', ?, ?, ?) + `).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"]); + connection.close(); + }); });