Q187: a search hit opens the object itself and marks it

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:10:22 -04:00
1 parent 6f6ed48549
commit 6bedba850c
23 files changed
+213 -68

No files matched your search

+4 -27
View File
@@ -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.");
+1
View File
@@ -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}
>
<ErrorBoundary key={pageKey} scope="page">{page}</ErrorBoundary>
</Shell>
+5 -2
View File
@@ -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<string | null>(`${orgId}:chat.space`, null, isIdOrNull);
const [selectedSpaceId, setSelectedSpaceId] = usePersistentState<string | null>(`${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) {
<ChatConversation
key={activeSpace?.id ?? "none"}
initialThreadId={focus?.kind === "message" && focus.spaceId === activeSpace?.id ? focus.threadId ?? null : null}
space={activeSpace}
members={members}
messagesInSpace={messagesInSpace}
+6 -3
View File
@@ -1,3 +1,4 @@
import { useRevealObject } from "../shared/reveal";
import { oneOf, usePersistentState } from "../shared/view-state";
import { useMemo } from "react";
import { Button, Icon, Segmented } from "../shared/ui";
@@ -28,7 +29,7 @@ const isSection = (value: unknown): value is Section => {
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<Section>(`${orgId}:files.section`, { kind: "all" }, isSection);
const fileFocus = focus?.kind === "file" ? focus.id : null;
const [section, setSection] = usePersistentState<Section>(`${orgId}:files.section`, { kind: "all" }, isSection, fileFocus ? { kind: "all" } : undefined);
useRevealObject(fileFocus);
const [view, setView] = usePersistentState<ViewMode>(`${orgId}:files.view`, "list", oneOf(["grid", "list"] as const));
const [scanFilter, setScanFilter] = usePersistentState<FileScanState | "all">(`${orgId}:files.scan`, "all", (value): value is FileScanState | "all" => typeof value === "string");
const [scanFilter, setScanFilter] = usePersistentState<FileScanState | "all">(`${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);
+4 -4
View File
@@ -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<LibraryTab>(`${orgId}:library.tab`, "documents", oneOf(["documents", "knowledge"] as const));
const [tab, setTab] = usePersistentState<LibraryTab>(`${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) {
<div className="page">
<h1 className="page-h1">{tab === "documents" ? t("Documents") : t("Knowledge")}</h1>
{tab === "documents"
? <DocumentsPanel orgId={orgId} employeeId={employee.id} documents={documents} tasks={tasks} actions={actions} />
: <KnowledgePanel orgId={orgId} employeeId={employee.id} entries={entries} scopes={scopes} actions={actions} />}
? <DocumentsPanel focusId={focus?.kind === "document" ? focus.id : null} orgId={orgId} employeeId={employee.id} documents={documents} tasks={tasks} actions={actions} />
: <KnowledgePanel focusId={focus?.kind === "knowledge" ? focus.id : null} orgId={orgId} employeeId={employee.id} entries={entries} scopes={scopes} actions={actions} />}
</div>
</div>
</div>
+3 -1
View File
@@ -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<SettingsTab>(`${orgId}:settings.tab`, "general", oneOf(SETTINGS_TABS), focus?.kind === "workspace" ? "general" : undefined);
const [tab, setTab] = usePersistentState<SettingsTab>(`${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]);
+5 -2
View File
@@ -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<string | null>(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 (
<div
data-object-id={row.id}
className="table-row"
style={{ gridTemplateColumns: "minmax(0, 2fr) minmax(0, 1.2fr) minmax(0, 1.4fr) minmax(0, 1.4fr) 60px", borderBottom: last ? "none" : "1px solid var(--border-soft)" }}
>
@@ -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<string | null>(null);
const [openThreadId, setOpenThreadId] = useState<string | null>(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.
@@ -80,7 +80,7 @@ export function ChatMessageRow({
});
};
return (
<div className="chat-message" style={{ display: "flex", gap: 12, marginBottom: 18 }}>
<div className="chat-message" data-object-id={message.id} style={{ display: "flex", gap: 12, marginBottom: 18 }}>
<Avatar name={name} bot={bot} />
<div style={{ flex: 1, minWidth: 0 }}>
<div className="row" style={{ alignItems: "baseline", gap: 8, marginBottom: 2 }}>
@@ -57,6 +57,7 @@ export function PinnedCard({
const canOpen = file.scanState === "approved";
return (
<div
data-object-id={file.id}
className={canOpen ? "clickable-card" : ""}
role={canOpen ? "button" : undefined}
tabIndex={canOpen ? 0 : undefined}
@@ -66,6 +66,7 @@ function FileRow({
const canOpen = file.scanState === "approved";
return (
<div
data-object-id={file.id}
className={`table-row ${canOpen ? "clickable" : ""}`.trim()}
role={canOpen ? "button" : undefined}
tabIndex={canOpen ? 0 : undefined}
@@ -1,3 +1,4 @@
import { useRevealObject } from "../../shared/reveal";
import { isIdOrNull, usePersistentState } from "../../shared/view-state";
import { DOCUMENT_STATUS_VALUES, documentStatusLabel } from "../../i18n/labels";
import { useEffect, useState, type FormEvent } from "react";
@@ -10,19 +11,23 @@ import { formatTime } from "../types";
import { t } from "../../i18n/i18n";
export function DocumentsPanel({
focusId,
orgId,
employeeId,
documents,
tasks,
actions,
}: {
/** A document opened by a link: selected and shown. */
focusId: string | null;
orgId: string;
employeeId: string;
documents: DocumentRecord[];
tasks: TaskRecord[];
actions: SnapshotActions;
}) {
const [selectedId, setSelectedId] = usePersistentState<string | null>(`${orgId}:library.document`, null, isIdOrNull);
const [selectedId, setSelectedId] = usePersistentState<string | null>(`${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<number | null>(null);
@@ -83,6 +88,7 @@ export function DocumentsPanel({
{documents.map((doc) => (
<button
key={doc.id}
data-object-id={doc.id}
type="button"
role="listitem"
aria-current={doc.id === selectedId}
@@ -1,3 +1,4 @@
import { useRevealObject } from "../../shared/reveal";
import { isIdOrNull, oneOf, usePersistentState } from "../../shared/view-state";
import { useEffect, useState, type FormEvent } from "react";
import type { AgentRecord, GoalRecord, KnowledgeEntryRecord, ProjectRecord, TaskRecord } from "../../../../shared/domain";
@@ -16,12 +17,15 @@ const FILTER_LABELS: Record<EntryState, string> = {
};
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<EntryState>(`${orgId}:library.knowledgeFilter`, "active", oneOf(["active", "superseded", "expired"] as const));
const [selectedId, setSelectedId] = usePersistentState<string | null>(`${orgId}:library.entry`, null, isIdOrNull);
const focused = focusId ? entries.find((entry) => entry.id === focusId) ?? null : null;
const [filter, setFilter] = usePersistentState<EntryState>(`${orgId}:library.knowledgeFilter`, "active", oneOf(["active", "superseded", "expired"] as const), focused ? entryState(focused, now) : undefined);
const [selectedId, setSelectedId] = usePersistentState<string | null>(`${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<KnowledgeScopeType>("organization");
@@ -116,6 +122,7 @@ export function KnowledgePanel({
{visible.map((entry) => (
<button
key={entry.id}
data-object-id={entry.id}
type="button"
role="listitem"
aria-current={entry.id === selectedId}
@@ -250,6 +250,7 @@ function ProviderRow({
const boundCount = agents.filter((agent) => agent.modelProviderId === provider.id).length;
return (
<div
data-object-id={provider.id}
className="reveal-row"
style={{
display: "flex",
@@ -299,6 +300,7 @@ function ProviderHealth({ provider }: { provider: ProviderRecord }) {
function ProviderCard({ provider, onConnect }: { provider: ProviderRecord; onConnect: () => void }) {
return (
<div
data-object-id={provider.id}
style={{
display: "flex",
alignItems: "center",
+4 -1
View File
@@ -9,8 +9,11 @@ import { t } from "../i18n/i18n";
/** One object a page should open when navigated to: a task, run, approval, workspace, or the goal or project to scope work by. */
export interface Focus {
kind: "task" | "run" | "approval" | "goal" | "project" | "workspace";
kind: "task" | "run" | "approval" | "goal" | "project" | "workspace" | "space" | "message" | "document" | "knowledge" | "file" | "agent" | "employee" | "provider";
id: string;
/** A message's conversation and thread, so it can be shown even outside the loaded window. */
spaceId?: string;
threadId?: string;
}
export interface PageProps {
+34 -19
View File
@@ -1,7 +1,8 @@
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";
import type { RouteId } from "./Shell";
import type { RouteId } from "./routes";
import type { Focus } from "../pages/types";
import { Button, Icon, Pill } from "./ui";
import { t } from "../i18n/i18n";
@@ -19,8 +20,6 @@ const LABELS: Record<SearchEntityType, string> = {
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<SearchFilter>("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<HTMLFormElement>) => {
event.preventDefault();
const first = visibleResults[0];
if (!first) return;
onNavigate(routeFor(first.entityType));
onClose();
openResult(first);
};
return (
@@ -117,10 +120,7 @@ export function SearchDialog({
<button
key={`${result.entityType}:${result.entityId}`}
type="button"
onClick={() => {
onNavigate(routeFor(result.entityType));
onClose();
}}
onClick={() => openResult(result)}
style={rowStyle}
>
<Pill>{LABELS[result.entityType]}</Pill>
@@ -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 = {
+5 -1
View File
@@ -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}
/>
</div>
);
+28
View File
@@ -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<typeof setTimeout> | 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]);
}
+3
View File
@@ -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; }
-2
View File
@@ -10,8 +10,6 @@ export const SEARCH_ENTITY_TYPES = [
"chat_message",
"file",
"provider",
"social_account",
"scheduled_post",
"document",
"knowledge",
] as const;
+4
View File
@@ -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();
+57
View File
@@ -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);
}
});
+25 -1
View File
@@ -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();
});
});