Add permission aware search

This commit is contained in:
ZacharyZhang-NY committed 2026-05-23 17:34:50 -04:00
1 parent 38c68ca4af
commit 1ee877e630
17 files changed
+608 -4

No files matched your search

@@ -14,6 +14,7 @@ import { FileService } from "../services/file-service";
import { MigrationService } from "../services/migration-service";
import { OnboardingService } from "../services/onboarding-service";
import { ComplianceService } from "../services/compliance-service";
import { SearchService } from "../services/search-service";
export interface IpcDeps {
connection: DatabaseConnection;
@@ -32,6 +33,7 @@ export function registerIpcHandlers(deps: IpcDeps): void {
const files = new FileService(deps.connection.db, deps.artifactRoot);
const migrations = new MigrationService(deps.connection.db);
const compliance = new ComplianceService(deps.connection.db);
const search = new SearchService(deps.connection.db);
const onboarding = new OnboardingService({
db: deps.connection.db,
saveProviderCredential: (input) => providers.saveCredential(input),
@@ -151,6 +153,13 @@ export function registerIpcHandlers(deps: IpcDeps): void {
ipcMain.handle(IPC_CHANNELS.setLegalHoldState, (_event, input) => mutate(() => compliance.setLegalHoldState(input)));
ipcMain.handle(IPC_CHANNELS.createRetentionFreeze, (_event, input) => mutate(() => compliance.createRetentionFreeze(input)));
ipcMain.handle(IPC_CHANNELS.setRetentionFreezeState, (_event, input) => mutate(() => compliance.setRetentionFreezeState(input)));
ipcMain.handle(IPC_CHANNELS.search, (_event, input) => {
try {
return ok(search.search(input));
} catch (error) {
return fail("search_failed", error instanceof Error ? error.message : String(error));
}
});
ipcMain.handle(IPC_CHANNELS.windowMinimize, () => deps.mainWindow.minimize());
ipcMain.handle(IPC_CHANNELS.windowMaximize, () => deps.mainWindow.isMaximized() ? deps.mainWindow.unmaximize() : deps.mainWindow.maximize());
@@ -0,0 +1,13 @@
export const searchDocumentsSchema = `
create virtual table if not exists search_documents using fts5(
org_id unindexed,
entity_type unindexed,
entity_id unindexed,
title,
body,
facet_json unindexed
);
insert into schema_migrations(version, name, applied_at)
values (13, 'search_documents', datetime('now'));
`;
@@ -11,6 +11,7 @@ import { socialGovernanceSchema } from "./migrations/009-social-governance";
import { fileHandoffSecuritySchema } from "./migrations/010-file-handoff-security";
import { boardInterventionSchema } from "./migrations/011-board-interventions";
import { complianceControlsSchema } from "./migrations/012-compliance-controls";
import { searchDocumentsSchema } from "./migrations/013-search-documents";
interface Migration {
version: number;
@@ -31,6 +32,7 @@ const MIGRATIONS: Migration[] = [
{ version: 10, name: "file_handoff_security", sql: fileHandoffSecuritySchema },
{ version: 11, name: "board_interventions", sql: boardInterventionSchema },
{ version: 12, name: "compliance_controls", sql: complianceControlsSchema },
{ version: 13, name: "search_documents", sql: searchDocumentsSchema },
];
const SCHEMA_VERSION = MIGRATIONS[MIGRATIONS.length - 1].version;
@@ -47,6 +47,7 @@ const LEGACY_TABLES = [
"compliance_exports",
"legal_holds",
"retention_freezes",
"search_documents",
"audit_events",
];
@@ -0,0 +1,238 @@
import type { DatabaseSync } from "node:sqlite";
import type { SearchInput } from "../../shared/commands";
import { SEARCH_ENTITY_TYPES, type SearchEntityType, type SearchResultRecord } from "../../shared/domain";
interface SearchRow {
entity_type: SearchEntityType;
entity_id: string;
title: string;
body: string;
facet_json: string;
}
interface SearchDoc {
entityType: SearchEntityType;
entityId: string;
title: string;
body: string;
facets: Record<string, string>;
}
export class SearchService {
constructor(private readonly db: DatabaseSync) {}
search(input: SearchInput): SearchResultRecord[] {
this.assertEmployee(input.orgId, input.employeeId);
const query = normalizeQuery(input.query);
const limit = Math.max(1, Math.min(input.limit ?? 20, 50));
const entityTypes = validEntityTypes(input.entityTypes ?? [...SEARCH_ENTITY_TYPES]);
this.reindexOrg(input.orgId);
const rows = query
? this.db.prepare(`
select entity_type, entity_id, title, body, facet_json
from search_documents
where search_documents match ? and org_id = ?
limit ?
`).all(query, input.orgId, limit * 4) as unknown as SearchRow[]
: this.db.prepare(`
select entity_type, entity_id, title, body, facet_json
from search_documents
where org_id = ?
order by rowid desc
limit ?
`).all(input.orgId, limit * 4) as unknown as SearchRow[];
return rows
.filter((row) => entityTypes.has(row.entity_type))
.filter((row) => this.canRead(input.orgId, input.employeeId, row.entity_type, row.entity_id))
.slice(0, limit)
.map((row) => ({
entityType: row.entity_type,
entityId: row.entity_id,
title: row.title,
preview: preview(row.body),
facets: parseFacets(row.facet_json),
}));
}
private reindexOrg(orgId: string): void {
this.db.exec("savepoint search_reindex");
try {
this.db.prepare("delete from search_documents where org_id = ?").run(orgId);
const insert = this.db.prepare(`
insert into search_documents(org_id, entity_type, entity_id, title, body, facet_json)
values (?, ?, ?, ?, ?, ?)
`);
for (const doc of this.documents(orgId)) {
insert.run(orgId, doc.entityType, doc.entityId, doc.title, doc.body, JSON.stringify(doc.facets));
}
this.db.exec("release search_reindex");
} catch (error) {
this.db.exec("rollback to search_reindex");
this.db.exec("release search_reindex");
throw error;
}
}
private documents(orgId: string): SearchDoc[] {
return [
...this.employeeDocs(orgId),
...this.agentDocs(orgId),
...this.taskDocs(orgId),
...this.projectDocs(orgId),
...this.goalDocs(orgId),
...this.workspaceDocs(orgId),
...this.approvalDocs(orgId),
...this.chatSpaceDocs(orgId),
...this.chatMessageDocs(orgId),
...this.fileDocs(orgId),
...this.providerDocs(orgId),
...this.socialDocs(orgId),
...this.scheduledPostDocs(orgId),
];
}
private employeeDocs(orgId: string): SearchDoc[] {
const rows = this.db.prepare("select id, display_name, role from employees where org_id = ?").all(orgId) as Array<{ id: string; display_name: string; role: string }>;
return rows.map((row) => doc("employee", row.id, row.display_name, row.role, { type: "People" }));
}
private agentDocs(orgId: string): SearchDoc[] {
const rows = this.db.prepare("select id, name, role, department, title from agents where org_id = ?").all(orgId) as Array<{ id: string; name: string; role: string; department: string; title: string }>;
return rows.map((row) => doc("agent", row.id, row.name, [row.role, row.department, row.title].join(" "), { type: "People", department: row.department }));
}
private taskDocs(orgId: string): SearchDoc[] {
const rows = this.db.prepare("select id, title, description, status, risk from tasks where org_id = ?").all(orgId) as Array<{ id: string; title: string; description: string; status: string; risk: string }>;
return rows.map((row) => doc("task", row.id, row.title, row.description, { type: "Work", status: row.status, risk: row.risk }));
}
private projectDocs(orgId: string): SearchDoc[] {
const rows = this.db.prepare("select id, name, description, status from projects where org_id = ?").all(orgId) as Array<{ id: string; name: string; description: string; status: string }>;
return rows.map((row) => doc("project", row.id, row.name, row.description, { type: "Work", status: row.status }));
}
private goalDocs(orgId: string): SearchDoc[] {
const rows = this.db.prepare("select id, title, description, status from goals where org_id = ?").all(orgId) as Array<{ id: string; title: string; description: string; status: string }>;
return rows.map((row) => doc("goal", row.id, row.title, row.description, { type: "Work", status: row.status }));
}
private workspaceDocs(orgId: string): SearchDoc[] {
const rows = this.db.prepare("select id, name, local_path, repo_url from workspaces where org_id = ?").all(orgId) as Array<{ id: string; name: string; local_path: string; repo_url: string }>;
return rows.map((row) => doc("workspace", row.id, row.name, `${row.local_path} ${row.repo_url}`, { type: "Work" }));
}
private approvalDocs(orgId: string): SearchDoc[] {
const rows = this.db.prepare("select id, title, details, state, risk from approvals where org_id = ?").all(orgId) as Array<{ id: string; title: string; details: string; state: string; risk: string }>;
return rows.map((row) => doc("approval", row.id, row.title, row.details, { type: "Governance", state: row.state, risk: row.risk }));
}
private chatSpaceDocs(orgId: string): SearchDoc[] {
const rows = this.db.prepare("select id, name, topic, visibility from chat_spaces where org_id = ?").all(orgId) as Array<{ id: string; name: string; topic: string; visibility: string }>;
return rows.map((row) => doc("chat_space", row.id, row.name, row.topic, { type: "Messages", visibility: row.visibility }));
}
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 }));
}
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 }>;
return rows.map((row) => doc("file", row.id, row.name, `${row.kind} ${row.sha256}`, { type: "Files", state: row.scan_state }));
}
private providerDocs(orgId: string): SearchDoc[] {
const rows = this.db.prepare("select id, label, provider_key, auth_state, health_state from providers where org_id = ?").all(orgId) as Array<{ id: string; label: string; provider_key: string; auth_state: string; health_state: string }>;
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 canRead(orgId: string, employeeId: string, entityType: SearchEntityType, entityId: string): boolean {
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;
}
private canReadSpace(spaceId: string, employeeId: string): boolean {
const row = this.db.prepare("select space_id from chat_memberships where space_id = ? and principal_type = 'employee' and principal_id = ?")
.get(spaceId, employeeId);
return Boolean(row);
}
private canReadMessage(orgId: string, messageId: string, employeeId: string): boolean {
const row = this.db.prepare("select space_id from chat_messages where id = ? and org_id = ?").get(messageId, orgId) as { space_id: string } | undefined;
return row ? this.canReadSpace(row.space_id, employeeId) : false;
}
private canReadFile(orgId: string, fileId: string, employeeId: string): boolean {
const file = this.db.prepare("select owner_type, owner_id from file_objects where id = ? and org_id = ?").get(fileId, orgId) as { owner_type: string; owner_id: string | null } | undefined;
if (!file) return false;
if (file.owner_type === "employee" && file.owner_id === employeeId) return true;
const workspaceGroupId = `${orgId}:workspace`;
const grant = this.db.prepare(`
select id from file_access_grants
where org_id = ? and file_id = ? and access_level in ('read', 'manage')
and ((granted_to_type = 'employee' and granted_to_id = ?) or (granted_to_type = 'org_group' and granted_to_id = ?))
limit 1
`).get(orgId, fileId, employeeId, workspaceGroupId);
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.");
}
}
function doc(entityType: SearchEntityType, entityId: string, title: string, body: string, facets: Record<string, string>): SearchDoc {
return { entityType, entityId, title: title.trim(), body: body.trim(), facets };
}
function normalizeQuery(query: string): string {
const terms = query.toLowerCase().match(/[a-z0-9_@.-]+/g) ?? [];
return terms.map((term) => `${term.replaceAll('"', "")}*`).join(" AND ");
}
function validEntityTypes(entityTypes: SearchEntityType[]): Set<SearchEntityType> {
const allowed = new Set<string>(SEARCH_ENTITY_TYPES);
return new Set(entityTypes.filter((type) => allowed.has(type)));
}
function createTitle(value: string): string {
const firstLine = value.split(/\r?\n/, 1)[0].trim();
if (!firstLine) return "Untitled";
return firstLine.length > 80 ? `${firstLine.slice(0, 77)}...` : firstLine;
}
function preview(value: string): string {
const text = value.replace(/\s+/g, " ").trim();
return text.length > 140 ? `${text.slice(0, 137)}...` : text;
}
function parseFacets(value: string): Record<string, string> {
const parsed = JSON.parse(value) as unknown;
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) throw new Error("Search facet payload is invalid.");
return Object.fromEntries(Object.entries(parsed).map(([key, item]) => [key, String(item)]));
}
+1
View File
@@ -40,6 +40,7 @@ const api: DesktopApi = {
setLegalHoldState: (input) => invoke(IPC_CHANNELS.setLegalHoldState, input),
createRetentionFreeze: (input) => invoke(IPC_CHANNELS.createRetentionFreeze, input),
setRetentionFreezeState: (input) => invoke(IPC_CHANNELS.setRetentionFreezeState, input),
search: (input) => invoke(IPC_CHANNELS.search, input),
subscribe: (callback) => {
const listener = (_event: Electron.IpcRendererEvent, payload: RendererEvent) => callback(payload);
ipcRenderer.on(IPC_CHANNELS.rendererEvent, listener);
@@ -0,0 +1,179 @@
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 { Button, Icon, Pill } from "./ui";
type SearchFilter = "all" | SearchEntityType;
const LABELS: Record<SearchEntityType, string> = {
employee: "Employee",
agent: "Agent",
task: "Task",
goal: "Goal",
project: "Project",
workspace: "Workspace",
approval: "Approval",
chat_space: "Space",
chat_message: "Message",
file: "File",
provider: "Provider",
social_account: "Social",
scheduled_post: "Post",
};
export function SearchDialog({
snapshot,
open,
onClose,
onNavigate,
}: {
snapshot: AppSnapshot;
open: boolean;
onClose: () => void;
onNavigate: (route: RouteId) => void;
}) {
const [query, setQuery] = useState("");
const [filter, setFilter] = useState<SearchFilter>("all");
const [results, setResults] = useState<SearchResultRecord[]>([]);
const orgId = snapshot.activeOrgId;
const employee = useMemo(() => snapshot.employees.find((item) => item.orgId === orgId), [snapshot.employees, orgId]);
const canSearch = Boolean(orgId && employee && query.trim().length >= 2);
const visibleResults = canSearch ? results : [];
useEffect(() => {
if (!open) return;
const trimmed = query.trim();
if (!orgId || !employee || trimmed.length < 2) return;
const timeout = window.setTimeout(() => {
void window.agentCompany.search({
orgId,
employeeId: employee.id,
query: trimmed,
entityTypes: filter === "all" ? undefined : [filter],
limit: 12,
}).then((result) => {
if (!result.ok) throw new Error(result.error.message);
setResults(result.data);
});
}, 120);
return () => window.clearTimeout(timeout);
}, [employee, filter, open, orgId, query]);
useEffect(() => {
if (!open) return;
const handler = (event: KeyboardEvent) => {
if (event.key === "Escape") onClose();
};
window.addEventListener("keydown", handler);
return () => window.removeEventListener("keydown", handler);
}, [onClose, open]);
if (!open) return null;
const submit = (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
const first = visibleResults[0];
if (!first) return;
onNavigate(routeFor(first.entityType));
onClose();
};
return (
<div role="dialog" aria-modal="true" aria-label="Search" style={overlayStyle}>
<form onSubmit={submit} style={panelStyle}>
<div className="row" style={{ padding: "10px 12px", borderBottom: "1px solid var(--border-soft)" }}>
<Icon name="search" size={14} color="var(--text-3)" />
<input
autoFocus
aria-label="Search query"
value={query}
onChange={(event) => setQuery(event.target.value)}
style={inputStyle}
/>
<select
aria-label="Search filter"
className="input"
value={filter}
onChange={(event) => setFilter(event.target.value as SearchFilter)}
style={{ width: 150, height: 28, padding: "2px 8px", fontSize: 12 }}
>
<option value="all">All</option>
{SEARCH_ENTITY_TYPES.map((type) => <option key={type} value={type}>{LABELS[type]}</option>)}
</select>
<Button size="small" onClick={onClose}>Close</Button>
</div>
<div style={{ maxHeight: 420, overflow: "auto", padding: 8 }}>
{visibleResults.map((result) => (
<button
key={`${result.entityType}:${result.entityId}`}
type="button"
onClick={() => {
onNavigate(routeFor(result.entityType));
onClose();
}}
style={rowStyle}
>
<Pill>{LABELS[result.entityType]}</Pill>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontSize: 13, fontWeight: 540, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{result.title}</div>
<div style={{ fontSize: 12, color: "var(--text-3)", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{result.preview}</div>
</div>
<span style={{ fontSize: 11, color: "var(--text-3)" }}>{result.facets.type}</span>
</button>
))}
</div>
</form>
</div>
);
}
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 "social";
return "work";
}
const overlayStyle = {
position: "fixed",
inset: 0,
zIndex: 80,
background: "rgba(9, 9, 11, 0.16)",
display: "grid",
placeItems: "start center",
paddingTop: "12vh",
} satisfies CSSProperties;
const panelStyle = {
width: "min(680px, calc(100vw - 32px))",
border: "1px solid var(--border)",
borderRadius: 12,
background: "var(--panel)",
boxShadow: "var(--shadow-md)",
overflow: "hidden",
} satisfies CSSProperties;
const inputStyle = {
flex: 1,
minWidth: 0,
border: 0,
outline: "none",
background: "transparent",
fontSize: 14,
} satisfies CSSProperties;
const rowStyle = {
width: "100%",
border: 0,
borderRadius: 8,
background: "transparent",
display: "flex",
alignItems: "center",
gap: 10,
padding: "9px 10px",
textAlign: "left",
} satisfies CSSProperties;
@@ -1,8 +1,9 @@
import type { ReactNode } from "react";
import { useEffect, useState, type ReactNode } from "react";
import type { AppSnapshot } from "../../../shared/domain";
import { Avatar, Icon, IconButton } from "./ui";
import type { IconName } from "./Icon";
import { OrgSwitcher } from "./OrgSwitcher";
import { SearchDialog } from "./SearchDialog";
export type RouteId =
| "home"
@@ -49,6 +50,18 @@ export function Shell({
onNewOrg: () => void;
children: ReactNode;
}) {
const [searchOpen, setSearchOpen] = useState(false);
useEffect(() => {
const handler = (event: KeyboardEvent) => {
if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === "k") {
event.preventDefault();
setSearchOpen(true);
}
};
window.addEventListener("keydown", handler);
return () => window.removeEventListener("keydown", handler);
}, []);
const organization = snapshot.organizations.find((item) => item.id === snapshot.activeOrgId);
const employee = snapshot.employees.find((item) => item.orgId === snapshot.activeOrgId);
if (!organization) throw new Error("Active organization is missing.");
@@ -66,7 +79,7 @@ export function Shell({
onNew={onNewOrg}
onDelete={onDeleteOrg}
/>
<button type="button" className="search-bar" aria-label="Search">
<button type="button" className="search-bar" aria-label="Search" onClick={() => setSearchOpen(true)}>
<Icon name="search" size={13} />
<span>Search</span>
<span className="search-kbd">⌘K</span>
@@ -121,6 +134,12 @@ export function Shell({
</header>
<div className="content">{children}</div>
</main>
<SearchDialog
snapshot={snapshot}
open={searchOpen}
onClose={() => setSearchOpen(false)}
onNavigate={setRoute}
/>
</div>
);
}
+9
View File
@@ -5,6 +5,7 @@ import type {
LegalHoldEntityType,
RetentionFreezeScopeType,
RiskLevel,
SearchEntityType,
SocialPlatform,
TaskPriority,
TaskStatus,
@@ -176,6 +177,14 @@ export interface CreateMigrationReportInput {
sourceDatabasePath: string;
}
export interface SearchInput {
orgId: string;
employeeId: string;
query: string;
entityTypes?: SearchEntityType[];
limit?: number;
}
export interface CreateComplianceExportInput {
orgId: string;
subjectType: ComplianceSubjectType;
+3 -1
View File
@@ -1,5 +1,5 @@
import type { DesktopResult } from "./result";
import type { AppSnapshot } from "./domain";
import type { AppSnapshot, SearchResultRecord } from "./domain";
import type {
BindSkillInput,
BootstrapOnboardingInput,
@@ -26,6 +26,7 @@ import type {
LaunchSocialLoginInput,
ResolveAgentSkillsInput,
SaveProviderCredentialInput,
SearchInput,
SendMessageInput,
SetActiveOrganizationInput,
SetFilePinnedInput,
@@ -73,6 +74,7 @@ export interface DesktopApi {
setLegalHoldState(input: SetLegalHoldStateInput): Promise<DesktopResult<AppSnapshot>>;
createRetentionFreeze(input: CreateRetentionFreezeInput): Promise<DesktopResult<AppSnapshot>>;
setRetentionFreezeState(input: SetRetentionFreezeStateInput): Promise<DesktopResult<AppSnapshot>>;
search(input: SearchInput): Promise<DesktopResult<SearchResultRecord[]>>;
subscribe(callback: (event: RendererEvent) => void): () => void;
platform: "win32" | "darwin" | "linux";
windowMinimize(): Promise<void>;
+1
View File
@@ -2,6 +2,7 @@ import type { BoardInterventionRecord as BoardInterventionRecordModel, ChatThrea
import type { ComplianceExportRecord, LegalHoldRecord, RetentionFreezeRecord } from "./compliance-domain";
export { BOARD_INTERVENTION_ACTION, BOARD_INTERVENTION_MODE, CHAT_THREAD_BOARD_STATE, type BoardInterventionAction, type BoardInterventionMode, type BoardInterventionRecord, type ChatThreadBoardState } from "./board-domain";
export { COMPLIANCE_EXPORT_STATE, COMPLIANCE_EXPORT_TYPE, type ComplianceExportRecord, type ComplianceExportState, type ComplianceExportType, type ComplianceSubjectType, type LegalHoldEntityType, type LegalHoldRecord, type RetentionFreezeRecord, type RetentionFreezeScopeType } from "./compliance-domain";
export { SEARCH_ENTITY_TYPES, type SearchEntityType, type SearchResultRecord } from "./search-domain";
export const TASK_STATUS = {
BACKLOG: "backlog",
IN_PROGRESS: "in_progress",
+1
View File
@@ -32,6 +32,7 @@ export const IPC_CHANNELS = {
setLegalHoldState: "agentcompany:set-legal-hold-state",
createRetentionFreeze: "agentcompany:create-retention-freeze",
setRetentionFreezeState: "agentcompany:set-retention-freeze-state",
search: "agentcompany:search",
rendererEvent: "agentcompany:renderer-event",
windowMinimize: "agentcompany:window-minimize",
windowMaximize: "agentcompany:window-maximize",
@@ -0,0 +1,25 @@
export const SEARCH_ENTITY_TYPES = [
"employee",
"agent",
"task",
"goal",
"project",
"workspace",
"approval",
"chat_space",
"chat_message",
"file",
"provider",
"social_account",
"scheduled_post",
] as const;
export type SearchEntityType = (typeof SEARCH_ENTITY_TYPES)[number];
export interface SearchResultRecord {
entityType: SearchEntityType;
entityId: string;
title: string;
preview: string;
facets: Record<string, string>;
}