stagent 0.6.2 → 0.6.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (68) hide show
  1. package/dist/cli.js +47 -1
  2. package/package.json +1 -2
  3. package/src/app/api/documents/[id]/route.ts +5 -1
  4. package/src/app/api/documents/[id]/versions/route.ts +53 -0
  5. package/src/app/api/documents/route.ts +5 -1
  6. package/src/app/api/projects/[id]/documents/route.ts +124 -0
  7. package/src/app/api/projects/[id]/route.ts +35 -3
  8. package/src/app/api/projects/__tests__/delete-project.test.ts +1 -0
  9. package/src/app/api/schedules/route.ts +19 -1
  10. package/src/app/api/tasks/[id]/route.ts +37 -2
  11. package/src/app/api/tasks/[id]/siblings/route.ts +48 -0
  12. package/src/app/api/tasks/route.ts +8 -9
  13. package/src/app/api/workflows/[id]/documents/route.ts +209 -0
  14. package/src/app/api/workflows/[id]/execute/route.ts +6 -2
  15. package/src/app/api/workflows/[id]/route.ts +16 -3
  16. package/src/app/api/workflows/[id]/status/route.ts +18 -2
  17. package/src/app/api/workflows/route.ts +13 -2
  18. package/src/app/documents/page.tsx +5 -1
  19. package/src/app/layout.tsx +0 -1
  20. package/src/app/manifest.ts +3 -3
  21. package/src/app/projects/[id]/page.tsx +62 -2
  22. package/src/components/documents/document-chip-bar.tsx +17 -1
  23. package/src/components/documents/document-detail-view.tsx +51 -0
  24. package/src/components/documents/document-grid.tsx +5 -0
  25. package/src/components/documents/document-table.tsx +4 -0
  26. package/src/components/documents/types.ts +3 -0
  27. package/src/components/projects/project-form-sheet.tsx +133 -2
  28. package/src/components/schedules/schedule-form.tsx +113 -1
  29. package/src/components/shared/document-picker-sheet.tsx +283 -0
  30. package/src/components/tasks/task-card.tsx +8 -1
  31. package/src/components/tasks/task-create-panel.tsx +137 -14
  32. package/src/components/tasks/task-detail-view.tsx +47 -0
  33. package/src/components/tasks/task-edit-dialog.tsx +125 -2
  34. package/src/components/workflows/workflow-form-view.tsx +231 -7
  35. package/src/components/workflows/workflow-kanban-card.tsx +8 -1
  36. package/src/components/workflows/workflow-list.tsx +90 -45
  37. package/src/components/workflows/workflow-status-view.tsx +167 -22
  38. package/src/lib/__tests__/npx-process-cwd.test.ts +17 -2
  39. package/src/lib/agents/profiles/registry.ts +6 -3
  40. package/src/lib/book/__tests__/chapter-slugs.test.ts +80 -0
  41. package/src/lib/book/chapter-generator.ts +4 -19
  42. package/src/lib/book/chapter-mapping.ts +17 -0
  43. package/src/lib/book/content.ts +5 -16
  44. package/src/lib/book/update-detector.ts +3 -16
  45. package/src/lib/chat/engine.ts +1 -0
  46. package/src/lib/chat/system-prompt.ts +9 -1
  47. package/src/lib/chat/tool-catalog.ts +1 -0
  48. package/src/lib/chat/tools/settings-tools.ts +109 -0
  49. package/src/lib/chat/tools/workflow-tools.ts +145 -2
  50. package/src/lib/data/clear.ts +12 -0
  51. package/src/lib/db/bootstrap.ts +48 -0
  52. package/src/lib/db/migrations/0016_add_workflow_document_inputs.sql +13 -0
  53. package/src/lib/db/migrations/0017_add_document_picker_tables.sql +25 -0
  54. package/src/lib/db/migrations/0018_add_workflow_run_number.sql +2 -0
  55. package/src/lib/db/schema.ts +77 -0
  56. package/src/lib/docs/reader.ts +2 -3
  57. package/src/lib/documents/context-builder.ts +75 -2
  58. package/src/lib/documents/document-resolver.ts +119 -0
  59. package/src/lib/documents/processors/spreadsheet.ts +2 -1
  60. package/src/lib/schedules/scheduler.ts +31 -1
  61. package/src/lib/utils/app-root.ts +20 -0
  62. package/src/lib/validators/__tests__/task.test.ts +43 -10
  63. package/src/lib/validators/task.ts +7 -1
  64. package/src/lib/workflows/blueprints/registry.ts +3 -3
  65. package/src/lib/workflows/engine.ts +24 -8
  66. package/src/lib/workflows/types.ts +14 -0
  67. package/public/icon.svg +0 -13
  68. package/src/components/tasks/file-upload.tsx +0 -120
@@ -0,0 +1,209 @@
1
+ import { NextRequest, NextResponse } from "next/server";
2
+ import { db } from "@/lib/db";
3
+ import {
4
+ workflowDocumentInputs,
5
+ documents,
6
+ workflows,
7
+ } from "@/lib/db/schema";
8
+ import { eq, and, inArray } from "drizzle-orm";
9
+
10
+ type RouteContext = { params: Promise<{ id: string }> };
11
+
12
+ /**
13
+ * GET /api/workflows/[id]/documents
14
+ * List all document bindings for a workflow, with document metadata.
15
+ */
16
+ export async function GET(
17
+ _request: NextRequest,
18
+ context: RouteContext
19
+ ) {
20
+ const { id: workflowId } = await context.params;
21
+
22
+ try {
23
+ const bindings = await db
24
+ .select()
25
+ .from(workflowDocumentInputs)
26
+ .where(eq(workflowDocumentInputs.workflowId, workflowId));
27
+
28
+ if (bindings.length === 0) {
29
+ return NextResponse.json([]);
30
+ }
31
+
32
+ const docIds = bindings.map((b) => b.documentId);
33
+ const docs = await db
34
+ .select()
35
+ .from(documents)
36
+ .where(inArray(documents.id, docIds));
37
+
38
+ const docMap = new Map(docs.map((d) => [d.id, d]));
39
+
40
+ const result = bindings.map((binding) => {
41
+ const doc = docMap.get(binding.documentId);
42
+ return {
43
+ bindingId: binding.id,
44
+ documentId: binding.documentId,
45
+ stepId: binding.stepId,
46
+ createdAt: binding.createdAt,
47
+ document: doc
48
+ ? {
49
+ id: doc.id,
50
+ originalName: doc.originalName,
51
+ filename: doc.filename,
52
+ mimeType: doc.mimeType,
53
+ size: doc.size,
54
+ direction: doc.direction,
55
+ status: doc.status,
56
+ category: doc.category,
57
+ }
58
+ : null,
59
+ };
60
+ });
61
+
62
+ return NextResponse.json(result);
63
+ } catch (error) {
64
+ console.error("[workflow-documents] GET failed:", error);
65
+ return NextResponse.json(
66
+ { error: "Failed to fetch workflow documents" },
67
+ { status: 500 }
68
+ );
69
+ }
70
+ }
71
+
72
+ /**
73
+ * POST /api/workflows/[id]/documents
74
+ * Attach document IDs to a workflow.
75
+ * Body: { documentIds: string[], stepId?: string }
76
+ */
77
+ export async function POST(
78
+ request: NextRequest,
79
+ context: RouteContext
80
+ ) {
81
+ const { id: workflowId } = await context.params;
82
+
83
+ try {
84
+ const body = await request.json();
85
+ const { documentIds, stepId } = body as {
86
+ documentIds: string[];
87
+ stepId?: string;
88
+ };
89
+
90
+ if (!Array.isArray(documentIds) || documentIds.length === 0) {
91
+ return NextResponse.json(
92
+ { error: "documentIds must be a non-empty array" },
93
+ { status: 400 }
94
+ );
95
+ }
96
+
97
+ // Verify workflow exists
98
+ const [workflow] = await db
99
+ .select({ id: workflows.id, projectId: workflows.projectId })
100
+ .from(workflows)
101
+ .where(eq(workflows.id, workflowId));
102
+
103
+ if (!workflow) {
104
+ return NextResponse.json(
105
+ { error: "Workflow not found" },
106
+ { status: 404 }
107
+ );
108
+ }
109
+
110
+ // Verify all documents exist
111
+ const existingDocs = await db
112
+ .select({ id: documents.id })
113
+ .from(documents)
114
+ .where(inArray(documents.id, documentIds));
115
+
116
+ const existingIds = new Set(existingDocs.map((d) => d.id));
117
+ const missing = documentIds.filter((id) => !existingIds.has(id));
118
+ if (missing.length > 0) {
119
+ return NextResponse.json(
120
+ { error: `Documents not found: ${missing.join(", ")}` },
121
+ { status: 404 }
122
+ );
123
+ }
124
+
125
+ // Insert bindings (ignore duplicates via ON CONFLICT)
126
+ const now = new Date();
127
+ const values = documentIds.map((docId) => ({
128
+ id: crypto.randomUUID(),
129
+ workflowId,
130
+ documentId: docId,
131
+ stepId: stepId ?? null,
132
+ createdAt: now,
133
+ }));
134
+
135
+ for (const value of values) {
136
+ try {
137
+ await db.insert(workflowDocumentInputs).values(value);
138
+ } catch (err) {
139
+ // Skip duplicates (unique constraint violation)
140
+ const msg = err instanceof Error ? err.message : "";
141
+ if (!msg.includes("UNIQUE constraint")) throw err;
142
+ }
143
+ }
144
+
145
+ return NextResponse.json(
146
+ { attached: documentIds.length, workflowId, stepId: stepId ?? null },
147
+ { status: 201 }
148
+ );
149
+ } catch (error) {
150
+ console.error("[workflow-documents] POST failed:", error);
151
+ return NextResponse.json(
152
+ { error: "Failed to attach documents" },
153
+ { status: 500 }
154
+ );
155
+ }
156
+ }
157
+
158
+ /**
159
+ * DELETE /api/workflows/[id]/documents
160
+ * Remove document bindings from a workflow.
161
+ * Body: { documentIds: string[], stepId?: string }
162
+ * If no body, removes all bindings.
163
+ */
164
+ export async function DELETE(
165
+ request: NextRequest,
166
+ context: RouteContext
167
+ ) {
168
+ const { id: workflowId } = await context.params;
169
+
170
+ try {
171
+ let body: { documentIds?: string[]; stepId?: string } = {};
172
+ try {
173
+ body = await request.json();
174
+ } catch {
175
+ // Empty body = remove all
176
+ }
177
+
178
+ const { documentIds, stepId } = body;
179
+
180
+ if (documentIds && documentIds.length > 0) {
181
+ // Remove specific bindings
182
+ for (const docId of documentIds) {
183
+ const conditions = [
184
+ eq(workflowDocumentInputs.workflowId, workflowId),
185
+ eq(workflowDocumentInputs.documentId, docId),
186
+ ];
187
+ if (stepId !== undefined) {
188
+ conditions.push(eq(workflowDocumentInputs.stepId, stepId));
189
+ }
190
+ await db
191
+ .delete(workflowDocumentInputs)
192
+ .where(and(...conditions));
193
+ }
194
+ } else {
195
+ // Remove all bindings for this workflow
196
+ await db
197
+ .delete(workflowDocumentInputs)
198
+ .where(eq(workflowDocumentInputs.workflowId, workflowId));
199
+ }
200
+
201
+ return NextResponse.json({ ok: true });
202
+ } catch (error) {
203
+ console.error("[workflow-documents] DELETE failed:", error);
204
+ return NextResponse.json(
205
+ { error: "Failed to remove document bindings" },
206
+ { status: 500 }
207
+ );
208
+ }
209
+ }
@@ -1,7 +1,7 @@
1
1
  import { NextRequest, NextResponse } from "next/server";
2
2
  import { db } from "@/lib/db";
3
3
  import { workflows } from "@/lib/db/schema";
4
- import { eq, and } from "drizzle-orm";
4
+ import { eq, and, sql } from "drizzle-orm";
5
5
  import { executeWorkflow } from "@/lib/workflows/engine";
6
6
  import type { WorkflowDefinition } from "@/lib/workflows/types";
7
7
 
@@ -57,7 +57,11 @@ export async function POST(
57
57
  // Prevents concurrent double-execution from parallel requests.
58
58
  const claimResult = db
59
59
  .update(workflows)
60
- .set({ status: "active", updatedAt: new Date() })
60
+ .set({
61
+ status: "active",
62
+ runNumber: sql`${workflows.runNumber} + 1`,
63
+ updatedAt: new Date(),
64
+ })
61
65
  .where(
62
66
  and(
63
67
  eq(workflows.id, id),
@@ -35,17 +35,30 @@ export async function PATCH(
35
35
  return NextResponse.json({ error: "Workflow not found" }, { status: 404 });
36
36
  }
37
37
 
38
- // Edit name/definition — draft only
38
+ // Edit name/definition — draft, completed, or failed
39
39
  if (name !== undefined || definition !== undefined) {
40
- if (workflow.status !== "draft") {
40
+ if (!["draft", "completed", "failed"].includes(workflow.status)) {
41
41
  return NextResponse.json(
42
- { error: "Can only edit draft workflows" },
42
+ { error: "Cannot edit active or paused workflows" },
43
43
  { status: 409 }
44
44
  );
45
45
  }
46
46
 
47
47
  const updates: Record<string, unknown> = { updatedAt: new Date() };
48
48
 
49
+ // Reset non-draft workflows to draft and strip execution state
50
+ if (workflow.status !== "draft") {
51
+ updates.status = "draft";
52
+ try {
53
+ const existingDef = JSON.parse(workflow.definition) as Record<string, unknown>;
54
+ delete existingDef._state;
55
+ delete existingDef._loopState;
56
+ updates.definition = JSON.stringify(existingDef);
57
+ } catch {
58
+ // Definition will be overwritten below if provided
59
+ }
60
+ }
61
+
49
62
  if (name !== undefined) {
50
63
  if (!name.trim()) {
51
64
  return NextResponse.json({ error: "Name is required" }, { status: 400 });
@@ -1,7 +1,7 @@
1
1
  import { NextRequest, NextResponse } from "next/server";
2
2
  import { db } from "@/lib/db";
3
- import { workflows, documents } from "@/lib/db/schema";
4
- import { eq, and, inArray } from "drizzle-orm";
3
+ import { workflows, tasks, documents } from "@/lib/db/schema";
4
+ import { eq, and, inArray, count, desc, sql as drizzleSql } from "drizzle-orm";
5
5
  import { parseWorkflowState } from "@/lib/workflows/engine";
6
6
 
7
7
  /** Collect output documents for workflow step tasks + input documents from parent task */
@@ -81,6 +81,18 @@ export async function GET(
81
81
  return NextResponse.json({ error: "Workflow not found" }, { status: 404 });
82
82
  }
83
83
 
84
+ const runHistory = await db
85
+ .select({
86
+ runNumber: tasks.workflowRunNumber,
87
+ taskCount: count(tasks.id),
88
+ completedCount: drizzleSql<number>`SUM(CASE WHEN ${tasks.status} = 'completed' THEN 1 ELSE 0 END)`,
89
+ failedCount: drizzleSql<number>`SUM(CASE WHEN ${tasks.status} = 'failed' THEN 1 ELSE 0 END)`,
90
+ })
91
+ .from(tasks)
92
+ .where(eq(tasks.workflowId, id))
93
+ .groupBy(tasks.workflowRunNumber)
94
+ .orderBy(desc(tasks.workflowRunNumber));
95
+
84
96
  const { definition, state, loopState } = parseWorkflowState(workflow.definition);
85
97
  const sourceTaskId: string | undefined = definition.sourceTaskId;
86
98
  const { stepDocuments, parentDocuments } = await getWorkflowDocuments(state, sourceTaskId);
@@ -100,6 +112,8 @@ export async function GET(
100
112
  steps: definition.steps,
101
113
  stepDocuments,
102
114
  parentDocuments,
115
+ runNumber: workflow.runNumber,
116
+ runHistory,
103
117
  });
104
118
  }
105
119
 
@@ -118,5 +132,7 @@ export async function GET(
118
132
  workflowState: state,
119
133
  stepDocuments,
120
134
  parentDocuments,
135
+ runNumber: workflow.runNumber,
136
+ runHistory,
121
137
  });
122
138
  }
@@ -1,14 +1,25 @@
1
1
  import { NextRequest, NextResponse } from "next/server";
2
2
  import { db } from "@/lib/db";
3
3
  import { workflows } from "@/lib/db/schema";
4
- import { desc, eq } from "drizzle-orm";
4
+ import { desc, eq, sql } from "drizzle-orm";
5
5
  import type { WorkflowDefinition } from "@/lib/workflows/types";
6
6
  import { validateWorkflowDefinitionAssignments } from "@/lib/agents/profiles/assignment-validation";
7
7
  import { validateWorkflowDefinition } from "@/lib/workflows/definition-validation";
8
8
 
9
9
  export async function GET() {
10
10
  const result = await db
11
- .select()
11
+ .select({
12
+ id: workflows.id,
13
+ name: workflows.name,
14
+ projectId: workflows.projectId,
15
+ definition: workflows.definition,
16
+ status: workflows.status,
17
+ runNumber: workflows.runNumber,
18
+ createdAt: workflows.createdAt,
19
+ updatedAt: workflows.updatedAt,
20
+ taskCount: sql<number>`(SELECT COUNT(*) FROM tasks t WHERE t.workflow_id = "workflows"."id")`.as("taskCount"),
21
+ outputDocCount: sql<number>`(SELECT COUNT(*) FROM documents d WHERE d.task_id IN (SELECT t2.id FROM tasks t2 WHERE t2.workflow_id = "workflows"."id") AND d.direction = 'output')`.as("outputDocCount"),
22
+ })
12
23
  .from(workflows)
13
24
  .orderBy(desc(workflows.createdAt));
14
25
 
@@ -1,5 +1,5 @@
1
1
  import { db } from "@/lib/db";
2
- import { documents, tasks, projects } from "@/lib/db/schema";
2
+ import { documents, tasks, projects, workflows } from "@/lib/db/schema";
3
3
  import { desc, eq } from "drizzle-orm";
4
4
  import { DocumentBrowser } from "@/components/documents/document-browser";
5
5
  import { PageShell } from "@/components/shared/page-shell";
@@ -31,9 +31,13 @@ export default async function DocumentsPage() {
31
31
  updatedAt: documents.updatedAt,
32
32
  taskTitle: tasks.title,
33
33
  projectName: projects.name,
34
+ workflowId: workflows.id,
35
+ workflowName: workflows.name,
36
+ workflowRunNumber: tasks.workflowRunNumber,
34
37
  })
35
38
  .from(documents)
36
39
  .leftJoin(tasks, eq(documents.taskId, tasks.id))
40
+ .leftJoin(workflows, eq(tasks.workflowId, workflows.id))
37
41
  .leftJoin(projects, eq(documents.projectId, projects.id))
38
42
  .orderBy(desc(documents.createdAt));
39
43
 
@@ -25,7 +25,6 @@ export const metadata: Metadata = {
25
25
  icons: {
26
26
  icon: [
27
27
  { url: "/stagent-s-64.png", sizes: "64x64", type: "image/png" },
28
- { url: "/icon.svg", type: "image/svg+xml" },
29
28
  ],
30
29
  apple: [
31
30
  { url: "/stagent-s-128.png", sizes: "128x128", type: "image/png" },
@@ -23,9 +23,9 @@ export default function manifest(): MetadataRoute.Manifest {
23
23
  purpose: "maskable",
24
24
  },
25
25
  {
26
- src: "/icon.svg",
27
- sizes: "any",
28
- type: "image/svg+xml",
26
+ src: "/stagent-s-64.png",
27
+ sizes: "64x64",
28
+ type: "image/png",
29
29
  },
30
30
  ],
31
31
  };
@@ -1,12 +1,14 @@
1
1
  import { notFound } from "next/navigation";
2
2
  import { db } from "@/lib/db";
3
- import { projects, tasks, workflows } from "@/lib/db/schema";
4
- import { eq, count, getTableColumns } from "drizzle-orm";
3
+ import { projects, tasks, workflows, documents } from "@/lib/db/schema";
4
+ import { eq, count, desc, getTableColumns } from "drizzle-orm";
5
5
  import { Badge } from "@/components/ui/badge";
6
6
  import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
7
7
  import { COLUMN_ORDER } from "@/lib/constants/task-status";
8
8
  import { PageShell } from "@/components/shared/page-shell";
9
9
  import { ProjectDetailClient } from "@/components/projects/project-detail";
10
+ import Link from "next/link";
11
+ import { FileText } from "lucide-react";
10
12
  import { Sparkline } from "@/components/charts/sparkline";
11
13
  import { getProjectCompletionTrend } from "@/lib/queries/chart-data";
12
14
  import { EnvironmentSummaryCard } from "@/components/environment/environment-summary-card";
@@ -38,6 +40,27 @@ export default async function ProjectDetailPage({
38
40
  .where(eq(tasks.projectId, id))
39
41
  .orderBy(tasks.priority, tasks.createdAt);
40
42
 
43
+ // Document count and recent docs
44
+ const [{ docCount }] = await db
45
+ .select({ docCount: count(documents.id) })
46
+ .from(documents)
47
+ .where(eq(documents.projectId, id));
48
+
49
+ const recentDocs = docCount > 0 ? await db
50
+ .select({
51
+ id: documents.id,
52
+ originalName: documents.originalName,
53
+ direction: documents.direction,
54
+ version: documents.version,
55
+ size: documents.size,
56
+ createdAt: documents.createdAt,
57
+ })
58
+ .from(documents)
59
+ .where(eq(documents.projectId, id))
60
+ .orderBy(desc(documents.createdAt))
61
+ .limit(5)
62
+ : [];
63
+
41
64
  // Status breakdown (standalone tasks only for headline metrics)
42
65
  const statusCounts: Record<string, number> = {};
43
66
  const standaloneForCounts = projectTasks.filter((t) => !t.workflowId);
@@ -144,6 +167,40 @@ export default async function ProjectDetailPage({
144
167
  </div>
145
168
  )}
146
169
 
170
+ {/* Recent documents */}
171
+ {recentDocs.length > 0 && (
172
+ <div className="mb-6">
173
+ <Card>
174
+ <CardHeader className="pb-2">
175
+ <div className="flex items-center justify-between">
176
+ <CardTitle className="text-sm">Recent Documents</CardTitle>
177
+ <Link href={`/documents?projectId=${id}`} className="text-xs text-muted-foreground hover:text-foreground transition-colors">
178
+ View all &rarr;
179
+ </Link>
180
+ </div>
181
+ </CardHeader>
182
+ <CardContent className="divide-y divide-border">
183
+ {recentDocs.map((doc) => (
184
+ <Link
185
+ key={doc.id}
186
+ href={`/documents/${doc.id}`}
187
+ className="flex items-center gap-3 py-2 text-xs hover:bg-accent/50 transition-colors -mx-6 px-6"
188
+ >
189
+ <FileText className="h-3.5 w-3.5 text-muted-foreground shrink-0" />
190
+ <span className="truncate flex-1">{doc.originalName}</span>
191
+ <Badge variant="outline" className="text-[10px]">
192
+ {doc.direction}
193
+ </Badge>
194
+ {doc.direction === "output" && (
195
+ <span className="text-muted-foreground">v{doc.version}</span>
196
+ )}
197
+ </Link>
198
+ ))}
199
+ </CardContent>
200
+ </Card>
201
+ </div>
202
+ )}
203
+
147
204
  {/* Task count summary */}
148
205
  {(standaloneCount > 0 || workflowCount > 0) && (
149
206
  <p className="text-xs text-muted-foreground mb-4">
@@ -151,6 +208,9 @@ export default async function ProjectDetailPage({
151
208
  {workflowCount > 0 && (
152
209
  <> &middot; {workflowCount} workflow task{workflowCount !== 1 ? "s" : ""} across {workflowGroupCount} workflow{workflowGroupCount !== 1 ? "s" : ""}</>
153
210
  )}
211
+ {docCount > 0 && (
212
+ <> &middot; {docCount} document{docCount !== 1 ? "s" : ""}</>
213
+ )}
154
214
  </p>
155
215
  )}
156
216
 
@@ -18,6 +18,7 @@ import {
18
18
  ArrowDownLeft,
19
19
  Link2,
20
20
  FolderKanban,
21
+ GitBranch,
21
22
  } from "lucide-react";
22
23
  import {
23
24
  getFileIcon,
@@ -127,8 +128,23 @@ export function DocumentChipBar({
127
128
  </Badge>
128
129
  </div>
129
130
 
130
- {/* Row 3: Links — task, workflow, project */}
131
+ {/* Row 3: Links — workflow, task, project */}
131
132
  <div className="flex flex-wrap items-center gap-2">
133
+ {/* Workflow source */}
134
+ {doc.workflowId && doc.workflowName && (
135
+ <Badge
136
+ variant="secondary"
137
+ className="text-xs cursor-pointer hover:bg-accent gap-1"
138
+ onClick={() => router.push(`/workflows/${doc.workflowId}`)}
139
+ >
140
+ <GitBranch className="h-3 w-3" />
141
+ {doc.workflowName}
142
+ {doc.workflowRunNumber != null && doc.workflowRunNumber > 0 && (
143
+ <span className="text-muted-foreground ml-1">Run #{doc.workflowRunNumber}</span>
144
+ )}
145
+ </Badge>
146
+ )}
147
+
132
148
  {/* Task link */}
133
149
  {doc.taskTitle ? (
134
150
  <Badge
@@ -3,9 +3,12 @@
3
3
  import { useState, useEffect, useCallback } from "react";
4
4
  import { useRouter } from "next/navigation";
5
5
  import { Skeleton } from "@/components/ui/skeleton";
6
+ import { Badge } from "@/components/ui/badge";
7
+ import { FileText } from "lucide-react";
6
8
  import { toast } from "sonner";
7
9
  import { DocumentChipBar } from "./document-chip-bar";
8
10
  import { DocumentContentRenderer } from "./document-content-renderer";
11
+ import { formatSize, formatRelativeTime } from "./utils";
9
12
  import type { DocumentWithRelations } from "./types";
10
13
 
11
14
  /** Serialized version of DocumentWithRelations (Date fields become strings from server) */
@@ -28,6 +31,14 @@ export function DocumentDetailView({ documentId, initialDocument }: DocumentDeta
28
31
  const [projects, setProjects] = useState<{ id: string; name: string }[]>([]);
29
32
  const [deleting, setDeleting] = useState(false);
30
33
  const [linking, setLinking] = useState(false);
34
+ const [versions, setVersions] = useState<Array<{
35
+ id: string;
36
+ version: number;
37
+ size: number;
38
+ status: string;
39
+ createdAt: string;
40
+ workflowRunNumber: number | null;
41
+ }>>([]);
31
42
 
32
43
  const refresh = useCallback(async () => {
33
44
  try {
@@ -56,6 +67,16 @@ export function DocumentDetailView({ documentId, initialDocument }: DocumentDeta
56
67
  .catch(() => {});
57
68
  }, [refresh, initialDocument]);
58
69
 
70
+ // Fetch version history for output documents
71
+ useEffect(() => {
72
+ if (doc?.direction === "output") {
73
+ fetch(`/api/documents/${documentId}/versions`)
74
+ .then((r) => r.ok ? r.json() : [])
75
+ .then(setVersions)
76
+ .catch(() => {});
77
+ }
78
+ }, [doc?.direction, documentId]);
79
+
59
80
  async function handleDelete() {
60
81
  if (!doc) return;
61
82
  setDeleting(true);
@@ -136,6 +157,36 @@ export function DocumentDetailView({ documentId, initialDocument }: DocumentDeta
136
157
  deleting={deleting}
137
158
  linking={linking}
138
159
  />
160
+ {/* Version History */}
161
+ {doc.direction === "output" && versions.length > 1 && (
162
+ <div className="space-y-2">
163
+ <h3 className="text-sm font-medium text-muted-foreground">Version History</h3>
164
+ <div className="surface-control rounded-lg divide-y divide-border">
165
+ {versions.map((v) => (
166
+ <button
167
+ key={v.id}
168
+ className={`w-full flex items-center gap-3 px-3 py-2 text-xs hover:bg-accent/50 transition-colors ${v.id === doc.id ? "bg-accent/30" : ""}`}
169
+ onClick={() => v.id !== doc.id && router.push(`/documents/${v.id}`)}
170
+ disabled={v.id === doc.id}
171
+ >
172
+ <FileText className="h-3.5 w-3.5 text-muted-foreground shrink-0" />
173
+ <span className="font-medium">v{v.version}</span>
174
+ {v.id === doc.id && (
175
+ <Badge variant="outline" className="text-[10px] py-0 px-1.5">current</Badge>
176
+ )}
177
+ <span className="text-muted-foreground">{formatSize(v.size)}</span>
178
+ {v.workflowRunNumber != null && v.workflowRunNumber > 0 && (
179
+ <span className="text-muted-foreground">Run #{v.workflowRunNumber}</span>
180
+ )}
181
+ <span className="text-muted-foreground ml-auto">
182
+ {formatRelativeTime(typeof v.createdAt === "number" ? v.createdAt : new Date(v.createdAt).getTime())}
183
+ </span>
184
+ </button>
185
+ ))}
186
+ </div>
187
+ </div>
188
+ )}
189
+
139
190
  <div className="prose-reader-surface">
140
191
  <DocumentContentRenderer doc={doc} />
141
192
  </div>
@@ -70,6 +70,11 @@ export function DocumentGrid({
70
70
  <span className="capitalize">{doc.direction}</span>
71
71
  {doc.direction === "output" && <span>v{doc.version}</span>}
72
72
  </div>
73
+ {doc.workflowName && (
74
+ <p className="text-[10px] text-muted-foreground truncate mt-0.5">
75
+ {doc.workflowName}
76
+ </p>
77
+ )}
73
78
  </Card>
74
79
  );
75
80
  })}
@@ -46,6 +46,7 @@ export function DocumentTable({
46
46
  <TableHead>Name</TableHead>
47
47
  <TableHead className="hidden md:table-cell">Size</TableHead>
48
48
  <TableHead className="hidden md:table-cell">Direction</TableHead>
49
+ <TableHead className="hidden lg:table-cell">Workflow</TableHead>
49
50
  <TableHead className="hidden lg:table-cell">Task</TableHead>
50
51
  <TableHead className="hidden lg:table-cell">Project</TableHead>
51
52
  <TableHead>Status</TableHead>
@@ -87,6 +88,9 @@ export function DocumentTable({
87
88
  )}
88
89
  </div>
89
90
  </TableCell>
91
+ <TableCell className="hidden lg:table-cell text-muted-foreground text-sm truncate max-w-[140px]">
92
+ {doc.workflowName ?? "—"}
93
+ </TableCell>
90
94
  <TableCell className="hidden lg:table-cell text-sm">
91
95
  {doc.taskTitle ? (
92
96
  <span className="truncate max-w-[150px] block">{doc.taskTitle}</span>
@@ -3,4 +3,7 @@ import type { DocumentRow } from "@/lib/db/schema";
3
3
  export type DocumentWithRelations = DocumentRow & {
4
4
  taskTitle: string | null;
5
5
  projectName: string | null;
6
+ workflowId?: string | null;
7
+ workflowName?: string | null;
8
+ workflowRunNumber?: number | null;
6
9
  };