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:
1 parent
734a2b04e0
commit
22ba7a9004
15 files changed
+890
-158
No files matched your search
@@ -299,6 +299,34 @@ export function buildDocumentMethods(
|
||||
}));
|
||||
},
|
||||
|
||||
/**
|
||||
* Company-scoped work-product listing, newest first. Used by the
|
||||
* renderer snapshot so the DocumentCenter can surface filesystem
|
||||
* artifacts (type = "file") the agents produce via bash/write/edit
|
||||
* — these are the deliverables operators expect to see alongside
|
||||
* prose documents, so the page stops looking empty when every
|
||||
* artifact of the night lives in `work_products`.
|
||||
*/
|
||||
listWorkProductsForCompany(companyId: string, limit = 500) {
|
||||
return readAll<{
|
||||
id: string; company_id: string; project_id: string | null; task_id: string | null;
|
||||
type: string; provider: string; external_id: string | null; title: string;
|
||||
url: string | null; status: string; review_state: string; is_primary: number;
|
||||
summary: string | null; metadata_json: string | null; created_by_run_id: string | null;
|
||||
created_at: string; updated_at: string;
|
||||
}>(
|
||||
"SELECT * FROM work_products WHERE company_id = ? ORDER BY updated_at DESC LIMIT ?",
|
||||
companyId, limit,
|
||||
).map((r) => ({
|
||||
id: r.id, companyId: r.company_id, projectId: r.project_id, taskId: r.task_id,
|
||||
type: r.type, provider: r.provider, externalId: r.external_id, title: r.title,
|
||||
url: r.url, status: r.status, reviewState: r.review_state,
|
||||
isPrimary: Boolean(r.is_primary), summary: r.summary,
|
||||
metadataJson: r.metadata_json, createdByRunId: r.created_by_run_id,
|
||||
createdAt: r.created_at, updatedAt: r.updated_at,
|
||||
}));
|
||||
},
|
||||
|
||||
deleteWorkProduct(id: string, companyId: string): number {
|
||||
const result = getDb()
|
||||
.prepare("DELETE FROM work_products WHERE id = ? AND company_id = ?")
|
||||
|
||||
@@ -129,6 +129,17 @@ export function buildSnapshotMethods(
|
||||
goalId: r.goal_id, parentDocId: r.parent_doc_id, version: r.version, status: r.status,
|
||||
tagsJson: r.tags_json, latestRevisionId: r.latest_revision_id ?? null, createdAt: r.created_at, updatedAt: r.updated_at,
|
||||
})),
|
||||
workProducts: readAll<SqliteRow>(
|
||||
`select id, company_id, project_id, task_id, type, provider, external_id, title, url, status, review_state, is_primary, summary, metadata_json, created_by_run_id, created_at, updated_at from work_products${companyWhere} order by updated_at desc limit 500`,
|
||||
...companyArgs,
|
||||
).map((r) => ({
|
||||
id: r.id, companyId: r.company_id, projectId: r.project_id, taskId: r.task_id,
|
||||
type: r.type, provider: r.provider, externalId: r.external_id, title: r.title,
|
||||
url: r.url, status: r.status, reviewState: r.review_state,
|
||||
isPrimary: Boolean(r.is_primary), summary: r.summary,
|
||||
metadataJson: r.metadata_json, createdByRunId: r.created_by_run_id,
|
||||
createdAt: r.created_at, updatedAt: r.updated_at,
|
||||
})),
|
||||
knowledgeBase: readAll<SqliteRow>(
|
||||
`select id, company_id, category, topic, content, author_agent_id, importance, tags_json, referenced_entity_type, referenced_entity_id, created_at, updated_at from knowledge_base${companyWhere} order by updated_at desc`,
|
||||
...companyArgs,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -97,6 +97,26 @@ export interface DocumentRecord {
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface WorkProductRecord {
|
||||
id: string;
|
||||
companyId: string;
|
||||
projectId: string | null;
|
||||
taskId: string | null;
|
||||
type: string;
|
||||
provider: string;
|
||||
externalId: string | null;
|
||||
title: string;
|
||||
url: string | null;
|
||||
status: string;
|
||||
reviewState: string;
|
||||
isPrimary: boolean;
|
||||
summary: string | null;
|
||||
metadataJson: string | null;
|
||||
createdByRunId: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface KnowledgeEntryRecord {
|
||||
id: string;
|
||||
companyId: string;
|
||||
|
||||
@@ -22,6 +22,7 @@ import type {
|
||||
AutomationRuleRecord,
|
||||
AgentMessageRecord,
|
||||
WorkflowPipelineRecord,
|
||||
WorkProductRecord,
|
||||
} from "./records-extended";
|
||||
|
||||
export interface ProfileSnapshot {
|
||||
@@ -51,6 +52,7 @@ export interface ProfileSnapshot {
|
||||
socialAccounts: SocialAccountRecord[];
|
||||
meetings: MeetingRecord[];
|
||||
documents: DocumentRecord[];
|
||||
workProducts: WorkProductRecord[];
|
||||
knowledgeBase: KnowledgeEntryRecord[];
|
||||
sprints: SprintRecord[];
|
||||
automationRules: AutomationRuleRecord[];
|
||||
@@ -86,6 +88,7 @@ export const COMPANY_SCOPED_KEYS = [
|
||||
"socialAccounts",
|
||||
"meetings",
|
||||
"documents",
|
||||
"workProducts",
|
||||
"knowledgeBase",
|
||||
"sprints",
|
||||
"automationRules",
|
||||
|
||||
Reference in new issue
Block a user