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) => (
= {
};
export function KnowledgePanel({
+ focusId,
orgId,
employeeId,
entries,
scopes,
actions,
}: {
+ /** An entry opened by a link: selected, shown, and its state's list chosen. */
+ focusId: string | null;
orgId: string;
employeeId: string;
entries: KnowledgeEntryRecord[];
@@ -29,8 +33,10 @@ export function KnowledgePanel({
actions: SnapshotActions;
}) {
const now = new Date().toISOString();
- const [filter, setFilter] = usePersistentState(`${orgId}:library.knowledgeFilter`, "active", oneOf(["active", "superseded", "expired"] as const));
- const [selectedId, setSelectedId] = usePersistentState(`${orgId}:library.entry`, null, isIdOrNull);
+ const focused = focusId ? entries.find((entry) => entry.id === focusId) ?? null : null;
+ const [filter, setFilter] = usePersistentState(`${orgId}:library.knowledgeFilter`, "active", oneOf(["active", "superseded", "expired"] as const), focused ? entryState(focused, now) : undefined);
+ const [selectedId, setSelectedId] = usePersistentState(`${orgId}:library.entry`, null, isIdOrNull, focusId ?? undefined);
+ useRevealObject(focusId);
const [loaded, setLoaded] = useState<{ entryId: string; revisions: KnowledgeRevisionRecord[] } | null>(null);
const [dialog, setDialog] = useState<"new" | "revise" | "delete" | null>(null);
const [scopeType, setScopeType] = useState("organization");
@@ -116,6 +122,7 @@ export function KnowledgePanel({
{visible.map((entry) => (
agent.modelProviderId === provider.id).length;
return (
void }) {
return (
= {
chat_message: t("Message"),
file: t("File"),
provider: t("Provider"),
- social_account: t("Social"),
- scheduled_post: t("Post"),
document: t("Document"),
knowledge: t("Knowledge"),
};
@@ -34,7 +33,7 @@ export function SearchDialog({
snapshot: AppSnapshot;
open: boolean;
onClose: () => void;
- onNavigate: (route: RouteId) => void;
+ onNavigate: (route: RouteId, focus: Focus) => void;
}) {
const [query, setQuery] = useState("");
const [filter, setFilter] = useState
("all");
@@ -73,13 +72,17 @@ export function SearchDialog({
}, [onClose, open]);
if (!open) return null;
+ const openResult = (result: SearchResultRecord) => {
+ const target = targetFor(result);
+ onNavigate(target.route, target.focus);
+ onClose();
+ };
const submit = (event: FormEvent) => {
event.preventDefault();
const first = visibleResults[0];
if (!first) return;
- onNavigate(routeFor(first.entityType));
- onClose();
+ openResult(first);
};
return (
@@ -117,10 +120,7 @@ export function SearchDialog({
{
- onNavigate(routeFor(result.entityType));
- onClose();
- }}
+ onClick={() => openResult(result)}
style={rowStyle}
>
{LABELS[result.entityType]}
@@ -137,15 +137,30 @@ export function SearchDialog({
);
}
-function routeFor(entityType: SearchEntityType): RouteId {
- if (entityType === "employee" || entityType === "agent") return "team";
- if (entityType === "approval") return "approvals";
- if (entityType === "chat_space" || entityType === "chat_message") return "chat";
- if (entityType === "file") return "files";
- if (entityType === "provider") return "settings";
- if (entityType === "social_account" || entityType === "scheduled_post") return "connectors";
- if (entityType === "document" || entityType === "knowledge") return "library";
- return "work";
+/** The page and the object on it that a hit opens. */
+function targetFor(result: SearchResultRecord): { route: RouteId; focus: Focus } {
+ const id = result.entityId;
+ switch (result.entityType) {
+ case "employee": return { route: "team", focus: { kind: "employee", id } };
+ case "agent": return { route: "team", focus: { kind: "agent", id } };
+ case "task": return { route: "work", focus: { kind: "task", id } };
+ case "goal": return { route: "work", focus: { kind: "goal", id } };
+ case "project": return { route: "work", focus: { kind: "project", id } };
+ case "workspace": return { route: "settings", focus: { kind: "workspace", id } };
+ case "approval": return { route: "approvals", focus: { kind: "approval", id } };
+ case "chat_space": return { route: "chat", focus: { kind: "space", id } };
+ case "chat_message": return { route: "chat", focus: { kind: "message", id, spaceId: requireFacet(result, "space"), threadId: requireFacet(result, "thread") } };
+ case "file": return { route: "files", focus: { kind: "file", id } };
+ case "provider": return { route: "settings", focus: { kind: "provider", id } };
+ case "document": return { route: "library", focus: { kind: "document", id } };
+ case "knowledge": return { route: "library", focus: { kind: "knowledge", id } };
+ }
+}
+
+function requireFacet(result: SearchResultRecord, name: string): string {
+ const value = result.facets[name];
+ if (!value) throw new Error(`Search result ${result.entityId} has no ${name}.`);
+ return value;
}
const overlayStyle = {
diff --git a/src/renderer/src/shared/Shell.tsx b/src/renderer/src/shared/Shell.tsx
index 90eae97b..7358624e 100644
--- a/src/renderer/src/shared/Shell.tsx
+++ b/src/renderer/src/shared/Shell.tsx
@@ -1,3 +1,4 @@
+import type { Focus } from "../pages/types";
import type { RouteId } from "./routes";
import { shortcutLabel } from "../i18n/labels";
import { isBusinessTask } from "../pages/work/task-helpers";
@@ -45,11 +46,14 @@ export function Shell({
onSwitchOrg,
onDeleteOrg,
onNewOrg,
+ onOpen,
children,
}: {
snapshot: AppSnapshot;
route: RouteId;
setRoute: (route: RouteId) => void;
+ /** Opens an object on its page, as a search hit does. */
+ onOpen: (route: RouteId, focus: Focus) => void;
onSwitchOrg: (orgId: string) => void;
onDeleteOrg: (orgId: string) => void;
onNewOrg: () => void;
@@ -163,7 +167,7 @@ export function Shell({
snapshot={snapshot}
open={searchOpen}
onClose={() => setSearchOpen(false)}
- onNavigate={setRoute}
+ onNavigate={onOpen}
/>
);
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();
+ });
});