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.
- package/dist/cli.js +47 -1
- package/package.json +1 -2
- package/src/app/api/documents/[id]/route.ts +5 -1
- package/src/app/api/documents/[id]/versions/route.ts +53 -0
- package/src/app/api/documents/route.ts +5 -1
- package/src/app/api/projects/[id]/documents/route.ts +124 -0
- package/src/app/api/projects/[id]/route.ts +35 -3
- package/src/app/api/projects/__tests__/delete-project.test.ts +1 -0
- package/src/app/api/schedules/route.ts +19 -1
- package/src/app/api/tasks/[id]/route.ts +37 -2
- package/src/app/api/tasks/[id]/siblings/route.ts +48 -0
- package/src/app/api/tasks/route.ts +8 -9
- package/src/app/api/workflows/[id]/documents/route.ts +209 -0
- package/src/app/api/workflows/[id]/execute/route.ts +6 -2
- package/src/app/api/workflows/[id]/route.ts +16 -3
- package/src/app/api/workflows/[id]/status/route.ts +18 -2
- package/src/app/api/workflows/route.ts +13 -2
- package/src/app/documents/page.tsx +5 -1
- package/src/app/layout.tsx +0 -1
- package/src/app/manifest.ts +3 -3
- package/src/app/projects/[id]/page.tsx +62 -2
- package/src/components/documents/document-chip-bar.tsx +17 -1
- package/src/components/documents/document-detail-view.tsx +51 -0
- package/src/components/documents/document-grid.tsx +5 -0
- package/src/components/documents/document-table.tsx +4 -0
- package/src/components/documents/types.ts +3 -0
- package/src/components/projects/project-form-sheet.tsx +133 -2
- package/src/components/schedules/schedule-form.tsx +113 -1
- package/src/components/shared/document-picker-sheet.tsx +283 -0
- package/src/components/tasks/task-card.tsx +8 -1
- package/src/components/tasks/task-create-panel.tsx +137 -14
- package/src/components/tasks/task-detail-view.tsx +47 -0
- package/src/components/tasks/task-edit-dialog.tsx +125 -2
- package/src/components/workflows/workflow-form-view.tsx +231 -7
- package/src/components/workflows/workflow-kanban-card.tsx +8 -1
- package/src/components/workflows/workflow-list.tsx +90 -45
- package/src/components/workflows/workflow-status-view.tsx +167 -22
- package/src/lib/__tests__/npx-process-cwd.test.ts +17 -2
- package/src/lib/agents/profiles/registry.ts +6 -3
- package/src/lib/book/__tests__/chapter-slugs.test.ts +80 -0
- package/src/lib/book/chapter-generator.ts +4 -19
- package/src/lib/book/chapter-mapping.ts +17 -0
- package/src/lib/book/content.ts +5 -16
- package/src/lib/book/update-detector.ts +3 -16
- package/src/lib/chat/engine.ts +1 -0
- package/src/lib/chat/system-prompt.ts +9 -1
- package/src/lib/chat/tool-catalog.ts +1 -0
- package/src/lib/chat/tools/settings-tools.ts +109 -0
- package/src/lib/chat/tools/workflow-tools.ts +145 -2
- package/src/lib/data/clear.ts +12 -0
- package/src/lib/db/bootstrap.ts +48 -0
- package/src/lib/db/migrations/0016_add_workflow_document_inputs.sql +13 -0
- package/src/lib/db/migrations/0017_add_document_picker_tables.sql +25 -0
- package/src/lib/db/migrations/0018_add_workflow_run_number.sql +2 -0
- package/src/lib/db/schema.ts +77 -0
- package/src/lib/docs/reader.ts +2 -3
- package/src/lib/documents/context-builder.ts +75 -2
- package/src/lib/documents/document-resolver.ts +119 -0
- package/src/lib/documents/processors/spreadsheet.ts +2 -1
- package/src/lib/schedules/scheduler.ts +31 -1
- package/src/lib/utils/app-root.ts +20 -0
- package/src/lib/validators/__tests__/task.test.ts +43 -10
- package/src/lib/validators/task.ts +7 -1
- package/src/lib/workflows/blueprints/registry.ts +3 -3
- package/src/lib/workflows/engine.ts +24 -8
- package/src/lib/workflows/types.ts +14 -0
- package/public/icon.svg +0 -13
- 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({
|
|
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
|
|
38
|
+
// Edit name/definition — draft, completed, or failed
|
|
39
39
|
if (name !== undefined || definition !== undefined) {
|
|
40
|
-
if (
|
|
40
|
+
if (!["draft", "completed", "failed"].includes(workflow.status)) {
|
|
41
41
|
return NextResponse.json(
|
|
42
|
-
{ error: "
|
|
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
|
|
package/src/app/layout.tsx
CHANGED
|
@@ -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" },
|
package/src/app/manifest.ts
CHANGED
|
@@ -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 →
|
|
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
|
<> · {workflowCount} workflow task{workflowCount !== 1 ? "s" : ""} across {workflowGroupCount} workflow{workflowGroupCount !== 1 ? "s" : ""}</>
|
|
153
210
|
)}
|
|
211
|
+
{docCount > 0 && (
|
|
212
|
+
<> · {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 —
|
|
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>
|