Q31: a Library page to read and manage documents and knowledge

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
ZacharyZhang-NYandClaude Opus 5.5 committed 2026-10-08 06:26:15 -04:00
1 parent 4c475e5ceb
commit 5c8233d258
14 files changed
+620 -1

No files matched your search

+19
View File
@@ -90,6 +90,14 @@ export function registerIpcHandlers(deps: IpcDeps): void {
}
};
const query = <T>(code: string, read: () => T): DesktopResult<T> => {
try {
return ok(read());
} catch (error) {
return fail(code, error instanceof Error ? error.message : String(error));
}
};
ipcMain.handle(IPC_CHANNELS.loadSnapshot, () => {
try {
return ok(snapshot.load());
@@ -163,6 +171,17 @@ export function registerIpcHandlers(deps: IpcDeps): void {
ipcMain.handle(IPC_CHANNELS.deleteDocument, (_event, input) => mutate(() => documents.delete(input)));
ipcMain.handle(IPC_CHANNELS.createKnowledgeEntry, (_event, input) => mutate(() => knowledge.create(input)));
ipcMain.handle(IPC_CHANNELS.deleteKnowledgeEntry, (_event, input) => mutate(() => knowledge.delete(input)));
ipcMain.handle(IPC_CHANNELS.updateKnowledgeEntry, (_event, input) => mutate(() => knowledge.update(input)));
ipcMain.handle(IPC_CHANNELS.listDocumentVersions, (_event, input) => query("document_versions_failed", () =>
documents.versions(input.orgId, input.documentId).map((v) => ({
version: v.version, title: v.title, content: v.content, status: v.status,
changedByType: v.changed_by_type, changedById: v.changed_by_id, reason: v.reason, createdAt: v.created_at,
}))));
ipcMain.handle(IPC_CHANNELS.listKnowledgeRevisions, (_event, input) => query("knowledge_revisions_failed", () =>
knowledge.revisions(input.orgId, input.entryId).map((r) => ({
revision: r.revision, topic: r.topic, category: r.category, content: r.content, importance: r.importance,
changedByType: r.changed_by_type, changedById: r.changed_by_id, reason: r.reason, createdAt: r.created_at,
}))));
ipcMain.handle(IPC_CHANNELS.createAutomationRule, (_event, input) => mutate(() => automation.create(input)));
ipcMain.handle(IPC_CHANNELS.updateAutomationRule, (_event, input) => mutate(() => automation.update(input)));
ipcMain.handle(IPC_CHANNELS.deleteAutomationRule, (_event, input) => mutate(() => automation.delete(input)));
+3
View File
@@ -32,6 +32,9 @@ const api: DesktopApi = {
deleteDocument: (input) => invoke(IPC_CHANNELS.deleteDocument, input),
createKnowledgeEntry: (input) => invoke(IPC_CHANNELS.createKnowledgeEntry, input),
deleteKnowledgeEntry: (input) => invoke(IPC_CHANNELS.deleteKnowledgeEntry, input),
updateKnowledgeEntry: (input) => invoke(IPC_CHANNELS.updateKnowledgeEntry, input),
listDocumentVersions: (input) => invoke(IPC_CHANNELS.listDocumentVersions, input),
listKnowledgeRevisions: (input) => invoke(IPC_CHANNELS.listKnowledgeRevisions, input),
createAutomationRule: (input) => invoke(IPC_CHANNELS.createAutomationRule, input),
updateAutomationRule: (input) => invoke(IPC_CHANNELS.updateAutomationRule, input),
deleteAutomationRule: (input) => invoke(IPC_CHANNELS.deleteAutomationRule, input),
+2
View File
@@ -8,6 +8,7 @@ import { TeamPage } from "../pages/TeamPage";
import { WorkPage } from "../pages/WorkPage";
import { ChatPage } from "../pages/ChatPage";
import { FilesPage } from "../pages/FilesPage";
import { LibraryPage } from "../pages/LibraryPage";
import { ApprovalsPage } from "../pages/ApprovalsPage";
import { ConnectorsPage } from "../pages/ConnectorsPage";
import { RunsPage } from "../pages/RunsPage";
@@ -81,6 +82,7 @@ export function App() {
work: <WorkPage {...pageProps} />,
chat: <ChatPage {...pageProps} />,
files: <FilesPage {...pageProps} />,
library: <LibraryPage {...pageProps} />,
runs: <RunsPage {...pageProps} />,
approvals: <ApprovalsPage {...pageProps} />,
connectors: <ConnectorsPage {...pageProps} />,
+27
View File
@@ -27,6 +27,33 @@ export const zh: Record<string, string> = {
"Advance": "推进",
"Advanced": "高级",
"Integrations": "集成密钥",
"Applies to": "适用范围",
"Category": "类别",
"Content": "内容",
"Delete “{title}”? Agents will stop seeing it; its revision history stays recorded.": "删除“{title}”?Agent 将不再看到它,修订历史仍会保留。",
"Delete “{title}”? Its {count} versions stay recorded in the history.": "删除“{title}”?它的 {count} 个版本仍会保留在历史中。",
"Delete document": "删除文档",
"Delete knowledge": "删除知识",
"Documents": "文档",
"Edit document": "编辑文档",
"Expired": "已过期",
"History": "历史",
"Importance": "重要度",
"Knowledge": "知识",
"New": "新建",
"New knowledge": "新建知识",
"No documents yet.": "还没有文档。",
"Nothing here.": "这里还没有内容。",
"Produced by task: {title}": "产出任务:{title}",
"Revise": "修订",
"Revise knowledge": "修订知识",
"Save new version": "保存为新版本",
"Save revision": "保存修订",
"Superseded": "已被取代",
"Valid until (optional)": "有效期至(可选)",
"Valid until {time}": "有效期至 {time}",
"Which one": "具体对象",
"Why this change": "修改原因",
"Read by {names}": "已读:{names}",
"Context window (tokens)": "上下文窗口(token)",
"{label} context window": "{label} 上下文窗口",
+63
View File
@@ -0,0 +1,63 @@
import { useMemo, useState } from "react";
import { DocumentsPanel } from "./library/DocumentsPanel";
import { KnowledgePanel } from "./library/KnowledgePanel";
import type { PageProps } from "./types";
import { activeOrgId, requireActiveEmployee } from "./types";
import { t } from "../i18n/i18n";
type LibraryTab = "documents" | "knowledge";
const TABS: Array<{ id: LibraryTab; label: string }> = [
{ id: "documents", label: t("Documents") },
{ id: "knowledge", label: t("Knowledge") },
];
export function LibraryPage({ snapshot, actions }: PageProps) {
const orgId = activeOrgId(snapshot);
const employee = requireActiveEmployee(snapshot);
const [tab, setTab] = useState<LibraryTab>("documents");
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]);
const scopes = useMemo(() => ({
projects: inOrg(snapshot.projects, orgId),
goals: inOrg(snapshot.goals, orgId),
tasks,
agents: inOrg(snapshot.agents, orgId),
}), [snapshot.projects, snapshot.goals, snapshot.agents, tasks, orgId]);
return (
<div className="split-main">
<aside className="secondary-pane">
<div style={{ fontSize: 14, fontWeight: 600, padding: "4px 8px 14px" }}>{t("Library")}</div>
<div className="nav" style={{ gap: 1 }}>
{TABS.map((item) => (
<button
key={item.id}
type="button"
aria-current={item.id === tab}
className={`nav-item ${item.id === tab ? "active" : ""}`.trim()}
onClick={() => setTab(item.id)}
>
<span>{item.label}</span>
</button>
))}
</div>
</aside>
<div className="main" style={{ flex: 1, minWidth: 0, overflow: "auto" }}>
<div className="content">
<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} />}
</div>
</div>
</div>
</div>
);
}
function inOrg<T extends { orgId: string }>(items: T[], orgId: string): T[] {
return items.filter((item) => item.orgId === orgId);
}
@@ -0,0 +1,155 @@
import { useEffect, useState, type FormEvent } from "react";
import type { DocumentRecord, TaskRecord } from "../../../../shared/domain";
import type { DocumentVersionRecord } from "../../../../shared/commands";
import type { SnapshotActions } from "../../app/useSnapshot";
import { Dialog } from "../../shared/Dialog";
import { Button, Field, Pill } from "../../shared/ui";
import { formatTime } from "../types";
import { t } from "../../i18n/i18n";
export function DocumentsPanel({
orgId,
employeeId,
documents,
tasks,
actions,
}: {
orgId: string;
employeeId: string;
documents: DocumentRecord[];
tasks: TaskRecord[];
actions: SnapshotActions;
}) {
const [selectedId, setSelectedId] = useState<string | null>(null);
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);
const versions = loaded && loaded.documentId === selectedId ? loaded.versions : [];
const [editing, setEditing] = useState(false);
const [confirmDelete, setConfirmDelete] = useState(false);
const [error, setError] = useState<string | null>(null);
const currentVersion = selected?.version ?? null;
useEffect(() => {
if (!selectedId || currentVersion === null) return;
let cancelled = false;
void window.agentCompany.listDocumentVersions({ orgId, documentId: selectedId }).then((result) => {
if (cancelled) return;
if (!result.ok) {
setError(result.error.message);
return;
}
setLoaded({ documentId: selectedId, versions: result.data });
setShownVersion(currentVersion);
});
return () => { cancelled = true; };
}, [orgId, selectedId, currentVersion]);
const perform = async (operation: () => Promise<unknown>, done: () => void) => {
setError(null);
try {
await operation();
done();
} catch (failure) {
setError(failure instanceof Error ? failure.message : String(failure));
}
};
const submitEdit = (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
if (!selected) return;
const form = new FormData(event.currentTarget);
void perform(() => actions.run(() => window.agentCompany.updateDocument({
orgId,
documentId: selected.id,
title: String(form.get("title") ?? ""),
content: String(form.get("content") ?? ""),
status: String(form.get("status") ?? selected.status),
reason: String(form.get("reason") ?? ""),
changedByType: "employee",
changedById: employeeId,
})), () => setEditing(false));
};
const shown = versions.find((version) => version.version === shownVersion) ?? null;
const producedBy = selected?.taskId ? tasks.find((task) => task.id === selected.taskId)?.title : null;
return (
<div style={{ display: "grid", gridTemplateColumns: "minmax(220px, 320px) 1fr", gap: 20, minHeight: 0 }}>
<div role="list" aria-label={t("Documents")} style={{ display: "grid", gap: 4, alignContent: "start" }}>
{documents.length === 0 && <p style={{ color: "var(--text-2)" }}>{t("No documents yet.")}</p>}
{documents.map((doc) => (
<button
key={doc.id}
type="button"
role="listitem"
aria-current={doc.id === selectedId}
className={`nav-item ${doc.id === selectedId ? "active" : ""}`.trim()}
onClick={() => { setSelectedId(doc.id); setError(null); }}
style={{ display: "grid", justifyItems: "start", gap: 2 }}
>
<span>{doc.title}</span>
<span style={{ fontSize: 12, color: "var(--text-3)" }}>{doc.docType} · v{doc.version} · {formatTime(doc.updatedAt)}</span>
</button>
))}
</div>
{selected && (
<article aria-label={selected.title} style={{ minWidth: 0 }}>
<div className="row" style={{ gap: 8, marginBottom: 8, flexWrap: "wrap" }}>
<h2 style={{ margin: 0, fontSize: 18 }}>{shown?.title ?? selected.title}</h2>
<Pill>{selected.docType}</Pill>
<Pill tone={selected.status === "approved" ? "green" : undefined}>{shown?.status ?? selected.status}</Pill>
</div>
{producedBy && <p style={{ color: "var(--text-2)", margin: "0 0 8px" }}>{t("Produced by task: {title}", { title: producedBy })}</p>}
<div className="row" style={{ gap: 8, marginBottom: 12, flexWrap: "wrap" }}>
<label className="row" style={{ gap: 6 }}>
<span>{t("Version")}</span>
<select className="input" value={shownVersion ?? ""} onChange={(event) => setShownVersion(Number(event.target.value))}>
{versions.map((version) => (
<option key={version.version} value={version.version}>
v{version.version} — {version.reason} ({formatTime(version.createdAt)})
</option>
))}
</select>
</label>
<Button onClick={() => setEditing(true)}>{t("Edit")}</Button>
<Button onClick={() => setConfirmDelete(true)}>{t("Delete")}</Button>
</div>
{error && <div className="form-error" role="alert">{error}</div>}
<pre style={{ whiteSpace: "pre-wrap", wordBreak: "break-word", fontFamily: "inherit", lineHeight: 1.55 }}>{shown?.content ?? selected.content}</pre>
</article>
)}
{selected && (
<Dialog title={t("Edit document")} open={editing} submitLabel={t("Save new version")} onClose={() => setEditing(false)} onSubmit={submitEdit}>
<Field label={t("Title")}><input className="input" name="title" defaultValue={selected.title} required /></Field>
<Field label={t("Status")}>
<select className="input" name="status" defaultValue={selected.status}>
{["draft", "review", "approved", "archived"].map((status) => <option key={status} value={status}>{status}</option>)}
</select>
</Field>
<Field label={t("Content")}><textarea className="input" name="content" defaultValue={selected.content} rows={14} required /></Field>
<Field label={t("Why this change")}><input className="input" name="reason" required /></Field>
{error && <div className="form-error" role="alert">{error}</div>}
</Dialog>
)}
{selected && (
<Dialog
title={t("Delete document")}
open={confirmDelete}
submitLabel={t("Delete")}
onClose={() => setConfirmDelete(false)}
onSubmit={(event) => {
event.preventDefault();
void perform(() => actions.run(() => window.agentCompany.deleteDocument({ orgId, documentId: selected.id })), () => {
setConfirmDelete(false);
setSelectedId(null);
});
}}
>
<p>{t("Delete “{title}”? Its {count} versions stay recorded in the history.", { title: selected.title, count: versions.length })}</p>
{error && <div className="form-error" role="alert">{error}</div>}
</Dialog>
)}
</div>
);
}
@@ -0,0 +1,217 @@
import { useEffect, useState, type FormEvent } from "react";
import type { AgentRecord, GoalRecord, KnowledgeEntryRecord, ProjectRecord, TaskRecord } from "../../../../shared/domain";
import type { KnowledgeRevisionRecord, KnowledgeScopeType } from "../../../../shared/commands";
import type { SnapshotActions } from "../../app/useSnapshot";
import { Dialog } from "../../shared/Dialog";
import { Button, Field, Pill } from "../../shared/ui";
import { formatTime } from "../types";
import { entryState, type EntryState } from "./library-helpers";
import { t } from "../../i18n/i18n";
const FILTER_LABELS: Record<EntryState, string> = {
active: t("Active"),
superseded: t("Superseded"),
expired: t("Expired"),
};
export function KnowledgePanel({
orgId,
employeeId,
entries,
scopes,
actions,
}: {
orgId: string;
employeeId: string;
entries: KnowledgeEntryRecord[];
scopes: { projects: ProjectRecord[]; goals: GoalRecord[]; tasks: TaskRecord[]; agents: AgentRecord[] };
actions: SnapshotActions;
}) {
const now = new Date().toISOString();
const [filter, setFilter] = useState<EntryState>("active");
const [selectedId, setSelectedId] = useState<string | null>(null);
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");
const [error, setError] = useState<string | null>(null);
const visible = entries.filter((entry) => entryState(entry, now) === filter);
const selected = entries.find((entry) => entry.id === selectedId) ?? null;
const revisions = loaded && loaded.entryId === selectedId ? loaded.revisions : [];
const selectedUpdatedAt = selected?.updatedAt ?? null;
useEffect(() => {
if (!selectedId || selectedUpdatedAt === null) return;
let cancelled = false;
void window.agentCompany.listKnowledgeRevisions({ orgId, entryId: selectedId }).then((result) => {
if (cancelled) return;
if (result.ok) setLoaded({ entryId: selectedId, revisions: result.data });
else setError(result.error.message);
});
return () => { cancelled = true; };
}, [orgId, selectedId, selectedUpdatedAt]);
const perform = (operation: () => Promise<unknown>, done: () => void) => {
setError(null);
void operation().then(done, (failure: unknown) => setError(failure instanceof Error ? failure.message : String(failure)));
};
const text = (form: FormData, key: string) => String(form.get(key) ?? "");
const submitNew = (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
const form = new FormData(event.currentTarget);
const validUntil = text(form, "validUntil");
perform(() => actions.run(() => window.agentCompany.createKnowledgeEntry({
orgId,
topic: text(form, "topic"),
category: text(form, "category"),
content: text(form, "content"),
importance: text(form, "importance"),
sourceType: "employee",
sourceId: employeeId,
scopeType,
scopeId: scopeType === "organization" ? null : text(form, "scopeId"),
validUntil: validUntil ? new Date(validUntil).toISOString() : null,
supersedesId: null,
})), () => setDialog(null));
};
const submitRevise = (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
if (!selected) return;
const form = new FormData(event.currentTarget);
perform(() => actions.run(() => window.agentCompany.updateKnowledgeEntry({
orgId,
entryId: selected.id,
topic: text(form, "topic"),
category: text(form, "category"),
content: text(form, "content"),
importance: text(form, "importance"),
reason: text(form, "reason"),
changedByType: "employee",
changedById: employeeId,
})), () => setDialog(null));
};
const scopeOptions: Record<Exclude<KnowledgeScopeType, "organization">, Array<{ id: string; label: string }>> = {
project: scopes.projects.map((item) => ({ id: item.id, label: item.name })),
goal: scopes.goals.map((item) => ({ id: item.id, label: item.title })),
task: scopes.tasks.map((item) => ({ id: item.id, label: item.title })),
agent: scopes.agents.map((item) => ({ id: item.id, label: item.name })),
};
return (
<div style={{ display: "grid", gridTemplateColumns: "minmax(220px, 340px) 1fr", gap: 20, minHeight: 0 }}>
<div style={{ display: "grid", gap: 8, alignContent: "start" }}>
<div className="row" style={{ gap: 6 }}>
{(["active", "superseded", "expired"] as const).map((state) => (
<Button key={state} size="small" tone={filter === state ? "primary" : undefined} onClick={() => setFilter(state)}>
{FILTER_LABELS[state]}
</Button>
))}
<Button size="small" onClick={() => { setScopeType("organization"); setDialog("new"); }}>{t("New")}</Button>
</div>
<div role="list" aria-label={t("Knowledge")} style={{ display: "grid", gap: 4 }}>
{visible.length === 0 && <p style={{ color: "var(--text-2)" }}>{t("Nothing here.")}</p>}
{visible.map((entry) => (
<button
key={entry.id}
type="button"
role="listitem"
aria-current={entry.id === selectedId}
className={`nav-item ${entry.id === selectedId ? "active" : ""}`.trim()}
onClick={() => { setSelectedId(entry.id); setError(null); }}
style={{ display: "grid", justifyItems: "start", gap: 2 }}
>
<span>{entry.topic}</span>
<span style={{ fontSize: 12, color: "var(--text-3)" }}>{entry.category} · {entry.scopeType} · {entry.importance}</span>
</button>
))}
</div>
</div>
{selected && (
<article aria-label={selected.topic} style={{ minWidth: 0 }}>
<div className="row" style={{ gap: 8, marginBottom: 8, flexWrap: "wrap" }}>
<h2 style={{ margin: 0, fontSize: 18 }}>{selected.topic}</h2>
<Pill>{selected.category}</Pill>
<Pill>{selected.scopeType}</Pill>
<Pill tone={entryState(selected, now) === "active" ? "green" : "amber"}>{entryState(selected, now)}</Pill>
</div>
{selected.validUntil && <p style={{ color: "var(--text-2)", margin: "0 0 8px" }}>{t("Valid until {time}", { time: formatTime(selected.validUntil) })}</p>}
<div className="row" style={{ gap: 8, marginBottom: 12 }}>
<Button onClick={() => setDialog("revise")}>{t("Revise")}</Button>
<Button onClick={() => setDialog("delete")}>{t("Delete")}</Button>
</div>
{error && <div className="form-error" role="alert">{error}</div>}
<p style={{ whiteSpace: "pre-wrap", lineHeight: 1.55 }}>{selected.content}</p>
<div className="section-label" style={{ margin: "18px 0 8px" }}>{t("History")}</div>
<ol style={{ display: "grid", gap: 10, paddingLeft: 18 }}>
{revisions.map((revision) => (
<li key={revision.revision}>
<div style={{ fontSize: 12, color: "var(--text-3)" }}>
r{revision.revision} · {formatTime(revision.createdAt)} · {revision.changedByType} · {revision.reason}
</div>
<div style={{ whiteSpace: "pre-wrap" }}>{revision.content}</div>
</li>
))}
</ol>
</article>
)}
<Dialog title={t("New knowledge")} open={dialog === "new"} submitLabel={t("Save")} onClose={() => setDialog(null)} onSubmit={submitNew}>
<Field label={t("Topic")}><input className="input" name="topic" required /></Field>
<Field label={t("Category")}><input className="input" name="category" placeholder="decision" required /></Field>
<Field label={t("Content")}><textarea className="input" name="content" rows={6} required /></Field>
<Field label={t("Importance")}>
<select className="input" name="importance" defaultValue="normal">
{["low", "normal", "high"].map((level) => <option key={level} value={level}>{level}</option>)}
</select>
</Field>
<Field label={t("Applies to")}>
<select className="input" value={scopeType} onChange={(event) => setScopeType(event.target.value as KnowledgeScopeType)}>
{(["organization", "project", "goal", "task", "agent"] as const).map((scope) => <option key={scope} value={scope}>{scope}</option>)}
</select>
</Field>
{scopeType !== "organization" && (
<Field label={t("Which one")}>
<select className="input" name="scopeId" required>
{scopeOptions[scopeType].map((option) => <option key={option.id} value={option.id}>{option.label}</option>)}
</select>
</Field>
)}
<Field label={t("Valid until (optional)")}><input className="input" name="validUntil" type="datetime-local" /></Field>
{error && <div className="form-error" role="alert">{error}</div>}
</Dialog>
{selected && (
<Dialog title={t("Revise knowledge")} open={dialog === "revise"} submitLabel={t("Save revision")} onClose={() => setDialog(null)} onSubmit={submitRevise}>
<Field label={t("Topic")}><input className="input" name="topic" defaultValue={selected.topic} required /></Field>
<Field label={t("Category")}><input className="input" name="category" defaultValue={selected.category} required /></Field>
<Field label={t("Content")}><textarea className="input" name="content" defaultValue={selected.content} rows={6} required /></Field>
<Field label={t("Importance")}>
<select className="input" name="importance" defaultValue={selected.importance}>
{["low", "normal", "high"].map((level) => <option key={level} value={level}>{level}</option>)}
</select>
</Field>
<Field label={t("Why this change")}><input className="input" name="reason" required /></Field>
{error && <div className="form-error" role="alert">{error}</div>}
</Dialog>
)}
{selected && (
<Dialog
title={t("Delete knowledge")}
open={dialog === "delete"}
submitLabel={t("Delete")}
onClose={() => setDialog(null)}
onSubmit={(event) => {
event.preventDefault();
perform(() => actions.run(() => window.agentCompany.deleteKnowledgeEntry({ orgId, entryId: selected.id })), () => {
setDialog(null);
setSelectedId(null);
});
}}
>
<p>{t("Delete “{title}”? Agents will stop seeing it; its revision history stays recorded.", { title: selected.topic })}</p>
{error && <div className="form-error" role="alert">{error}</div>}
</Dialog>
)}
</div>
);
}
@@ -0,0 +1,9 @@
import type { KnowledgeEntryRecord } from "../../../../shared/domain";
export type EntryState = "active" | "superseded" | "expired";
export function entryState(entry: KnowledgeEntryRecord, now: string): EntryState {
if (entry.supersededBy) return "superseded";
if (entry.validUntil && entry.validUntil <= now) return "expired";
return "active";
}
+2
View File
@@ -1,4 +1,5 @@
import {
BookOpen,
Briefcase,
Buildings2,
ChatRoundDots,
@@ -20,6 +21,7 @@ const NAV_ICONS: Record<RouteId, typeof Home> = {
chat: ChatRoundDots,
runs: Send,
files: Folder,
library: BookOpen,
approvals: ShieldCheck,
connectors: Plug2,
office: Buildings2,
+3 -1
View File
@@ -15,6 +15,7 @@ export type RouteId =
| "work"
| "chat"
| "files"
| "library"
| "runs"
| "approvals"
| "connectors"
@@ -28,7 +29,7 @@ interface NavSpec {
/** Routes whose pages own a full-height, multi-pane layout with their own
* internal scroll regions, so the content area must not scroll as one block. */
const FLUSH_ROUTES = new Set<RouteId>(["chat", "settings", "files", "runs", "office", "work"]);
const FLUSH_ROUTES = new Set<RouteId>(["chat", "settings", "files", "library", "runs", "office", "work"]);
const allNav: NavSpec[] = [
{ id: "home", label: t("Home") },
@@ -38,6 +39,7 @@ const allNav: NavSpec[] = [
{ id: "chat", label: t("Messages") },
{ id: "runs", label: t("Runs") },
{ id: "files", label: t("Files") },
{ id: "library", label: t("Library") },
{ id: "approvals", label: t("Approvals") },
{ id: "connectors", label: t("Connectors") },
];
+33
View File
@@ -59,6 +59,39 @@ export interface UpdateKnowledgeEntryInput {
changedById: string | null;
}
export interface DocumentVersionRecord {
version: number;
title: string;
content: string;
status: string;
changedByType: string;
changedById: string | null;
reason: string;
createdAt: string;
}
export interface KnowledgeRevisionRecord {
revision: number;
topic: string;
category: string;
content: string;
importance: string;
changedByType: string;
changedById: string | null;
reason: string;
createdAt: string;
}
export interface ListDocumentVersionsInput {
orgId: string;
documentId: string;
}
export interface ListKnowledgeRevisionsInput {
orgId: string;
entryId: string;
}
export interface DeleteKnowledgeEntryInput {
orgId: string;
entryId: string;
+8
View File
@@ -20,6 +20,11 @@ import type {
DeleteMeetingInput,
DeleteSprintInput,
CreateKnowledgeEntryInput,
UpdateKnowledgeEntryInput,
DocumentVersionRecord,
KnowledgeRevisionRecord,
ListDocumentVersionsInput,
ListKnowledgeRevisionsInput,
CreateProjectInput,
CreateWorkspaceInput,
DeleteAutomationRuleInput,
@@ -128,6 +133,9 @@ export interface DesktopApi {
deleteDocument(input: DeleteDocumentInput): Promise<DesktopResult<AppSnapshot>>;
createKnowledgeEntry(input: CreateKnowledgeEntryInput): Promise<DesktopResult<AppSnapshot>>;
deleteKnowledgeEntry(input: DeleteKnowledgeEntryInput): Promise<DesktopResult<AppSnapshot>>;
updateKnowledgeEntry(input: UpdateKnowledgeEntryInput): Promise<DesktopResult<AppSnapshot>>;
listDocumentVersions(input: ListDocumentVersionsInput): Promise<DesktopResult<DocumentVersionRecord[]>>;
listKnowledgeRevisions(input: ListKnowledgeRevisionsInput): Promise<DesktopResult<KnowledgeRevisionRecord[]>>;
createAutomationRule(input: CreateAutomationRuleInput): Promise<DesktopResult<AppSnapshot>>;
updateAutomationRule(input: UpdateAutomationRuleInput): Promise<DesktopResult<AppSnapshot>>;
deleteAutomationRule(input: DeleteAutomationRuleInput): Promise<DesktopResult<AppSnapshot>>;
+3
View File
@@ -68,6 +68,9 @@ export const IPC_CHANNELS = {
deleteDocument: "agentcompany:delete-document",
createKnowledgeEntry: "agentcompany:create-knowledge-entry",
deleteKnowledgeEntry: "agentcompany:delete-knowledge-entry",
updateKnowledgeEntry: "agentcompany:update-knowledge-entry",
listDocumentVersions: "agentcompany:list-document-versions",
listKnowledgeRevisions: "agentcompany:list-knowledge-revisions",
createAutomationRule: "agentcompany:create-automation-rule",
updateAutomationRule: "agentcompany:update-automation-rule",
deleteAutomationRule: "agentcompany:delete-automation-rule",
+76
View File
@@ -0,0 +1,76 @@
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";
test("the library manages documents with versions and knowledge with history", async () => {
const profileDir = join(tmpdir(), `agentcompany-new-library-${randomUUID()}`);
mkdirSync(profileDir, { recursive: true });
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: seedBuiltinSkillsFixture(join(profileDir, "builtin-src")),
},
});
try {
const page = await app.firstWindow();
await page.setViewportSize({ width: 1440, height: 900 });
await page.getByRole("button", { name: "Get started" }).click();
await page.getByLabel("API key").fill("library-e2e-key");
await page.getByLabel("Model").fill("gpt-5.4");
await page.getByRole("button", { name: "Continue" }).click();
await page.getByLabel("Company name").fill("Stack Works");
await page.getByLabel("Your name").fill("Robin Park");
await page.getByLabel("Description").fill("Library e2e coverage.");
await page.getByRole("button", { name: "Continue" }).click();
await page.getByRole("button", { name: "Continue" }).click();
await page.getByLabel("Goal").fill("Keep company knowledge.");
await page.getByLabel("Acceptance criteria").fill("Documents and knowledge are manageable.");
await page.getByRole("button", { name: "Launch" }).click();
await page.getByRole("button", { name: "Finish" }).click();
await expect(page.getByRole("button", { name: /Stack Works/ })).toBeVisible();
const orgId = await page.evaluate(async () => {
const snapshot = await window.agentCompany.loadSnapshot();
if (!snapshot.ok || !snapshot.data.activeOrgId) throw new Error("no org");
const created = await window.agentCompany.createDocument({
orgId: snapshot.data.activeOrgId, title: "Pricing memo", docType: "memo", content: "Price is 10.",
authorAgentId: null, reviewerAgentId: null, projectId: null, goalId: null, taskId: null,
});
if (!created.ok) throw new Error(created.error.message);
return snapshot.data.activeOrgId;
});
expect(orgId).toBeTruthy();
await page.getByRole("button", { name: "Library", exact: true }).click();
await page.getByRole("listitem").filter({ hasText: "Pricing memo" }).click();
await page.getByRole("button", { name: "Edit", exact: true }).click();
await page.getByLabel("Content").fill("Price is 12.");
await page.getByLabel("Why this change").fill("Costs went up");
await page.getByRole("button", { name: "Save new version" }).click();
await expect(page.getByText("Price is 12.")).toBeVisible();
await page.getByLabel("Version").selectOption("1");
await expect(page.getByText("Price is 10.")).toBeVisible();
await page.getByRole("button", { name: "Knowledge", exact: true }).click();
await page.getByRole("button", { name: "New", exact: true }).click();
await page.getByLabel("Topic").fill("Refund window");
await page.getByLabel("Category").fill("policy");
await page.getByLabel("Content").fill("Refunds within 14 days.");
await page.getByRole("button", { name: "Save", exact: true }).click();
await page.getByRole("listitem").filter({ hasText: "Refund window" }).click();
await page.getByRole("button", { name: "Revise", exact: true }).click();
await page.getByLabel("Content").fill("Refunds within 30 days.");
await page.getByLabel("Why this change").fill("Legal update");
await page.getByRole("button", { name: "Save revision" }).click();
await expect(page.getByText("Legal update")).toBeVisible();
await expect(page.getByText("Refunds within 14 days.")).toBeVisible();
} finally {
await app.close();
}
});