feat(documents): surface filesystem work-products in Document Center

The Document Center only read the `documents` table, which is prose
content POSTed via /api/documents. Everything the agents wrote via
`bash` / `write` / `edit` — the bulk of their output on a real
project — landed in `work_products` and never appeared on the page.
Operators saw "0 items" after a full night's run.

Lift work_products into the same UI:

  - `listWorkProductsForCompany` returns a newest-first slice with a
    500-row cap so the snapshot stays bounded.
  - `ProfileSnapshot.workProducts` flows through `useCompanyData` as
    `companyWorkProducts`, filtered to the current company.
  - `DocumentCenter` renders two labelled sections in order — prose
    documents (editable) on top, files & deliverables below. Each
    work-product row opens the underlying URL or filesystem path
    when clickable, so the agent's bash-generated markdown, PRs,
    and artifacts are one click away.

`WorkProductRecord` lifts into `@shared/types/records-extended` so
the renderer, snapshot, and database shells all reference the same
shape. The store-test fixture learns about the new field.
This commit is contained in:
ZacharyZhang-NY committed 2026-04-19 13:24:50 -04:00
1 parent 734a2b04e0
commit 22ba7a9004
15 files changed
+890 -158

No files matched your search

@@ -1,6 +1,6 @@
import { useMemo, useState } from "react";
import { FileText, FloppyDisk, NotePencil, Trash } from "../ui/icons";
import type { AgentRecord, DocumentRecord, GoalRecord, ProjectRecord } from "@shared/types";
import type { AgentRecord, DocumentRecord, GoalRecord, ProjectRecord, WorkProductRecord } from "@shared/types";
import { ActionButton, ConfirmDialog, InlineNotice, Input, ListFrame, ListRow, Modal, Select, StatusPill, TextArea } from "../ui";
import { useT } from "../../i18n";
import { documentTypeOptions, documentStatusOptions, splitTags, parseTags } from "./helpers";
@@ -65,38 +65,101 @@ function DocumentEditor({ companyId, agents, projects, goals, document, onSaved
);
}
export function DocumentCenter({ companyId, documents, agents, projects, goals, onRefresh }: {
companyId: string; documents: DocumentRecord[]; agents: AgentRecord[]; projects: ProjectRecord[];
goals: GoalRecord[]; onRefresh: () => Promise<void>;
}) {
function WorkProductRow({ product }: { product: WorkProductRecord }) {
const subtitle = [
product.type.replaceAll("_", " "),
product.provider,
product.externalId ?? product.url ?? null,
product.updatedAt.slice(0, 10),
].filter((v): v is string => Boolean(v)).join(" · ");
const primaryAction = product.url
? () => { void window.agentCompany.openPath(product.url!); }
: product.provider === "filesystem" && product.externalId
? () => { void window.agentCompany.openPath(product.externalId!); }
: null;
const rowBody = (
<div className="min-w-0 flex-1 text-left">
<div className="flex items-center gap-2 text-[13px] font-medium text-foreground">
<FileText size={15} className="text-muted-foreground" />
<span className="truncate">{product.title}</span>
</div>
<div className="mt-1 text-[11px] text-muted-foreground">{subtitle}</div>
</div>
);
return (
<ListRow>
<div className="flex items-start justify-between gap-3">
{primaryAction
? <button type="button" className="min-w-0 flex-1 text-left" onClick={primaryAction}>{rowBody}</button>
: rowBody}
<div className="flex items-center gap-2">
<StatusPill status={product.status} />
</div>
</div>
</ListRow>
);
}
interface DocumentCenterProps {
companyId: string;
documents: DocumentRecord[];
workProducts: WorkProductRecord[];
agents: AgentRecord[];
projects: ProjectRecord[];
goals: GoalRecord[];
onRefresh: () => Promise<void>;
}
export function DocumentCenter({ companyId, documents, workProducts, agents, projects, goals, onRefresh }: DocumentCenterProps) {
const t = useT();
const [editingId, setEditingId] = useState<string | null>(null);
const [deleteId, setDeleteId] = useState<string | null>(null);
const activeDocument = useMemo(() => documents.find((document) => document.id === editingId) ?? null, [documents, editingId]);
const totalCount = documents.length + workProducts.length;
return (
<div>
<SectionHeader title={t("ops.documentCenter")} count={documents.length} action={() => setEditingId(NEW_ENTITY_ID)} />
<ListFrame>
{documents.map((document) => (
<ListRow key={document.id}>
<div className="flex items-start justify-between gap-3">
<button type="button" className="min-w-0 flex-1 text-left" onClick={() => setEditingId(document.id)}>
<div className="flex items-center gap-2 text-[13px] font-medium text-foreground">
<FileText size={15} className="text-primary" />
<span className="truncate">{document.title}</span>
<SectionHeader title={t("ops.documentCenter")} count={totalCount} action={() => setEditingId(NEW_ENTITY_ID)} />
{documents.length > 0 ? (
<div>
<div className="px-4 pt-3 pb-1 text-[11px] font-medium uppercase tracking-wide text-muted-foreground">
{t("ops.documentsProse")}
</div>
<ListFrame>
{documents.map((document) => (
<ListRow key={document.id}>
<div className="flex items-start justify-between gap-3">
<button type="button" className="min-w-0 flex-1 text-left" onClick={() => setEditingId(document.id)}>
<div className="flex items-center gap-2 text-[13px] font-medium text-foreground">
<FileText size={15} className="text-primary" />
<span className="truncate">{document.title}</span>
</div>
<div className="mt-1 text-[11px] text-muted-foreground">{document.type.replaceAll("_", " ")} · v{document.version} · {document.updatedAt.slice(0, 10)}</div>
</button>
<div className="flex items-center gap-2">
<StatusPill status={document.status} />
<button type="button" aria-label="Edit document" className="rounded p-1 text-muted-foreground hover:text-foreground" onClick={() => setEditingId(document.id)}><NotePencil size={14} /></button>
<button type="button" aria-label="Delete document" className="rounded p-1 text-muted-foreground hover:text-destructive" onClick={() => setDeleteId(document.id)}><Trash size={14} /></button>
</div>
</div>
<div className="mt-1 text-[11px] text-muted-foreground">{document.type.replaceAll("_", " ")} · v{document.version} · {document.updatedAt.slice(0, 10)}</div>
</button>
<div className="flex items-center gap-2">
<StatusPill status={document.status} />
<button type="button" aria-label="Edit document" className="rounded p-1 text-muted-foreground hover:text-foreground" onClick={() => setEditingId(document.id)}><NotePencil size={14} /></button>
<button type="button" aria-label="Delete document" className="rounded p-1 text-muted-foreground hover:text-destructive" onClick={() => setDeleteId(document.id)}><Trash size={14} /></button>
</div>
</div>
</ListRow>
))}
</ListFrame>
</ListRow>
))}
</ListFrame>
</div>
) : null}
{workProducts.length > 0 ? (
<div>
<div className="px-4 pt-4 pb-1 text-[11px] font-medium uppercase tracking-wide text-muted-foreground">
{t("ops.documentsWorkProducts")}
</div>
<ListFrame>
{workProducts.map((product) => (
<WorkProductRow key={product.id} product={product} />
))}
</ListFrame>
</div>
) : null}
<Modal open={editingId !== null} title={activeDocument ? activeDocument.title : "New document"} onClose={() => setEditingId(null)} width="760px">
<DocumentEditor companyId={companyId} agents={agents} projects={projects} goals={goals} document={editingId === NEW_ENTITY_ID ? null : activeDocument} onSaved={async () => { setEditingId(null); await onRefresh(); }} />
</Modal>
@@ -172,6 +172,7 @@ export function useCompanyController() {
filteredActivity: companyData.filteredActivity,
companySecrets: companyData.companySecrets,
companyDocuments: companyData.companyDocuments,
companyWorkProducts: companyData.companyWorkProducts,
companyKnowledge: companyData.companyKnowledge,
companyMeetings: companyData.companyMeetings,
companySprints: companyData.companySprints,
+3
View File
@@ -37,6 +37,7 @@ export function useCompanyData(
const filteredActivity = useMemo(() => filterByCompany(snapshot?.activity, currentCompany), [snapshot, currentCompany]);
const companySecrets = useMemo(() => filterByCompany(snapshot?.secrets, currentCompany), [snapshot, currentCompany]);
const companyDocuments = useMemo(() => filterByCompany(snapshot?.documents, currentCompany), [snapshot, currentCompany]);
const companyWorkProducts = useMemo(() => filterByCompany(snapshot?.workProducts, currentCompany), [snapshot, currentCompany]);
const companyKnowledge = useMemo(() => filterByCompany(snapshot?.knowledgeBase, currentCompany), [snapshot, currentCompany]);
const companyMeetings = useMemo(() => filterByCompany(snapshot?.meetings, currentCompany), [snapshot, currentCompany]);
const companySprints = useMemo(() => filterByCompany(snapshot?.sprints, currentCompany), [snapshot, currentCompany]);
@@ -109,6 +110,7 @@ export function useCompanyData(
filteredActivity,
companySecrets,
companyDocuments,
companyWorkProducts,
companyKnowledge,
companyMeetings,
companySprints,
@@ -134,6 +136,7 @@ export function useCompanyData(
filteredActivity,
companySecrets,
companyDocuments,
companyWorkProducts,
companyKnowledge,
companyMeetings,
companySprints,
+2
View File
@@ -871,6 +871,8 @@ export const en: Record<string, string> = {
// ── Company Operations ──
"ops.documentCenter": "Document Center",
"ops.documentsProse": "Prose documents",
"ops.documentsWorkProducts": "Files & deliverables",
"ops.knowledgeBase": "Knowledge Base",
"ops.meetings": "Meetings",
"ops.sprintBoard": "Sprint Board",
+2
View File
@@ -871,6 +871,8 @@ export const zh: Record<string, string> = {
// ── 公司运营 ──
"ops.documentCenter": "文档中心",
"ops.documentsProse": "文稿",
"ops.documentsWorkProducts": "文件与交付物",
"ops.knowledgeBase": "知识库",
"ops.meetings": "会议",
"ops.sprintBoard": "迭代看板",
@@ -38,6 +38,7 @@ export function OperationsSections({ section }: OperationsSectionsProps) {
companyProjects,
companyWorkspaces,
companyDocuments,
companyWorkProducts,
companyKnowledge,
companyMeetings,
companySprints,
@@ -215,6 +216,7 @@ export function OperationsSections({ section }: OperationsSectionsProps) {
<DocumentCenter
companyId={currentCompany.id}
documents={companyDocuments}
workProducts={companyWorkProducts}
agents={companyAgents}
projects={companyProjects}
goals={companyGoals}