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:
1 parent
6f6ed48549
commit
6bedba850c
23 files changed
+213
-68
No files matched your search
@@ -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.");
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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]);
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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]);
|
||||
}
|
||||
@@ -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; }
|
||||
@@ -10,8 +10,6 @@ export const SEARCH_ENTITY_TYPES = [
|
||||
"chat_message",
|
||||
"file",
|
||||
"provider",
|
||||
"social_account",
|
||||
"scheduled_post",
|
||||
"document",
|
||||
"knowledge",
|
||||
] as const;
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user