Add permission aware search
This commit is contained in:
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)]));
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -47,6 +47,10 @@ test("onboards, hires, and creates work in the Electron shell", async () => {
|
||||
await page.getByLabel("Risk").selectOption("high");
|
||||
await page.locator(".modal").getByRole("button", { name: "Create" }).click();
|
||||
await expect(page.getByText("Production deploy").first()).toBeVisible();
|
||||
await page.getByRole("button", { name: "Search" }).click();
|
||||
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();
|
||||
|
||||
await page.getByRole("button", { name: "Approvals" }).click();
|
||||
await expect(page.getByText("Production deploy").first()).toBeVisible();
|
||||
|
||||
@@ -23,7 +23,7 @@ describe("domain services", () => {
|
||||
const connection = createConnection();
|
||||
|
||||
const rows = connection.db.prepare("select version from schema_migrations order by version asc").all() as Array<{ version: number }>;
|
||||
expect(rows.map((row) => row.version)).toEqual([1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12]);
|
||||
expect(rows.map((row) => row.version)).toEqual([1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13]);
|
||||
connection.close();
|
||||
});
|
||||
|
||||
|
||||
@@ -131,6 +131,7 @@ describe("migration service", () => {
|
||||
create table compliance_exports(id text primary key, org_id text, requested_by_employee_id text);
|
||||
create table legal_holds(id text primary key, org_id text, created_by_employee_id text);
|
||||
create table retention_freezes(id text primary key, org_id text, created_by_employee_id text);
|
||||
create table search_documents(entity_id text);
|
||||
create table audit_events(id text primary key, org_id text);
|
||||
insert into organizations(id) values ('org-1');
|
||||
insert into employees(id, org_id) values ('emp-1', 'org-1');
|
||||
@@ -172,6 +173,7 @@ describe("migration service", () => {
|
||||
insert into compliance_exports(id, org_id, requested_by_employee_id) values ('cmp-1', 'org-1', 'emp-1');
|
||||
insert into legal_holds(id, org_id, created_by_employee_id) values ('hold-1', 'org-1', 'emp-1');
|
||||
insert into retention_freezes(id, org_id, created_by_employee_id) values ('freeze-1', 'org-1', 'emp-1');
|
||||
insert into search_documents(entity_id) values ('task-1');
|
||||
insert into audit_events(id, org_id) values ('audit-1', 'org-1');
|
||||
`);
|
||||
let orgId = "";
|
||||
@@ -190,6 +192,7 @@ describe("migration service", () => {
|
||||
expect(body.tableCounts.file_access_grants).toBe(1);
|
||||
expect(body.tableCounts.provider_requests).toBe(1);
|
||||
expect(body.tableCounts.compliance_exports).toBe(1);
|
||||
expect(body.tableCounts.search_documents).toBe(1);
|
||||
expect(body.tableChecksums.audit_events).toHaveLength(64);
|
||||
expect(body.idMaps.skill_runtime_snapshots).toEqual(["snapshot-1"]);
|
||||
expect(body.idMaps.board_interventions).toEqual(["bdi-1"]);
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { DatabaseConnection } from "../../src/main/database/connection";
|
||||
import { ChatService } from "../../src/main/services/chat-service";
|
||||
import { OrganizationService } from "../../src/main/services/organization-service";
|
||||
import { SearchService } from "../../src/main/services/search-service";
|
||||
import { TASK_STATUS } from "../../src/shared/domain";
|
||||
import { createId } from "../../src/shared/ids";
|
||||
|
||||
function createConnection(): DatabaseConnection {
|
||||
return new DatabaseConnection(join(tmpdir(), `agentcompany-new-search-${randomUUID()}.db`));
|
||||
}
|
||||
|
||||
describe("search service", () => {
|
||||
test("returns faceted full-text results", () => {
|
||||
const connection = createConnection();
|
||||
const organization = new OrganizationService(connection.db);
|
||||
const search = new SearchService(connection.db);
|
||||
let orgId = "";
|
||||
let employeeId = "";
|
||||
|
||||
connection.transaction(() => {
|
||||
orgId = organization.createOrganization({
|
||||
organizationName: "Acme Operations",
|
||||
employeeName: "Dana Lee",
|
||||
mission: "Coordinate agents with auditable approvals.",
|
||||
goal: "Ship a verified control plane.",
|
||||
});
|
||||
employeeId = (connection.db.prepare("select id from employees where org_id = ?").get(orgId) as { id: string }).id;
|
||||
organization.createTask({
|
||||
orgId,
|
||||
title: "Production deploy",
|
||||
description: "Release the production deploy checklist.",
|
||||
status: TASK_STATUS.BACKLOG,
|
||||
assigneeAgentId: null,
|
||||
risk: "high",
|
||||
projectId: null,
|
||||
goalId: null,
|
||||
workspaceId: null,
|
||||
priority: "normal",
|
||||
requiresFilesystem: false,
|
||||
});
|
||||
});
|
||||
|
||||
const results = search.search({ orgId, employeeId, query: "production", entityTypes: ["task"] });
|
||||
expect(results).toHaveLength(1);
|
||||
expect(results[0]).toMatchObject({ entityType: "task", title: "Production deploy", facets: { type: "Work", risk: "high" } });
|
||||
connection.close();
|
||||
});
|
||||
|
||||
test("filters restricted chat messages by membership", () => {
|
||||
const connection = createConnection();
|
||||
const organization = new OrganizationService(connection.db);
|
||||
const chat = new ChatService(connection.db);
|
||||
const search = new SearchService(connection.db);
|
||||
let orgId = "";
|
||||
let ownerId = "";
|
||||
let outsiderId = "";
|
||||
let spaceId = "";
|
||||
|
||||
connection.transaction(() => {
|
||||
orgId = organization.createOrganization({
|
||||
organizationName: "Acme Operations",
|
||||
employeeName: "Dana Lee",
|
||||
mission: "Coordinate agents with auditable approvals.",
|
||||
goal: "Ship a verified control plane.",
|
||||
});
|
||||
ownerId = (connection.db.prepare("select id from employees where org_id = ?").get(orgId) as { id: string }).id;
|
||||
outsiderId = createId("emp");
|
||||
connection.db.prepare("insert into employees(id, org_id, display_name, role, created_at) values (?, ?, ?, ?, ?)")
|
||||
.run(outsiderId, orgId, "Riley Chen", "Observer", new Date().toISOString());
|
||||
spaceId = chat.createSpace({
|
||||
orgId,
|
||||
spaceType: "project",
|
||||
name: "restricted-release",
|
||||
topic: "Need-to-know release",
|
||||
visibility: "restricted",
|
||||
memberEmployeeIds: [ownerId],
|
||||
});
|
||||
chat.sendMessage({
|
||||
orgId,
|
||||
spaceId,
|
||||
threadId: null,
|
||||
senderType: "employee",
|
||||
senderId: ownerId,
|
||||
bodyMd: "Secret launch window",
|
||||
attachmentFileIds: [],
|
||||
});
|
||||
});
|
||||
|
||||
expect(search.search({ orgId, employeeId: ownerId, query: "secret", entityTypes: ["chat_message"] })).toHaveLength(1);
|
||||
expect(search.search({ orgId, employeeId: outsiderId, query: "secret", entityTypes: ["chat_message"] })).toHaveLength(0);
|
||||
connection.close();
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user