@assistkick/create 1.8.0 → 1.9.0
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/package.json +1 -1
- package/templates/assistkick-product-system/packages/backend/src/routes/kanban.ts +18 -2
- package/templates/assistkick-product-system/packages/backend/src/routes/workflows.ts +9 -5
- package/templates/assistkick-product-system/packages/backend/src/server.ts +1 -22
- package/templates/assistkick-product-system/packages/backend/src/services/init.ts +16 -0
- package/templates/assistkick-product-system/packages/backend/src/services/workflow_service.ts +30 -7
- package/templates/assistkick-product-system/packages/frontend/src/api/client.ts +2 -2
- package/templates/assistkick-product-system/packages/frontend/src/components/IterationCommentModal.tsx +80 -0
- package/templates/assistkick-product-system/packages/frontend/src/components/KanbanView.tsx +67 -2
- package/templates/assistkick-product-system/packages/frontend/src/components/workflow/GenerateTTSNode.tsx +52 -0
- package/templates/assistkick-product-system/packages/frontend/src/components/workflow/RebuildBundleNode.tsx +20 -0
- package/templates/assistkick-product-system/packages/frontend/src/components/workflow/RenderVideoNode.tsx +72 -0
- package/templates/assistkick-product-system/packages/frontend/src/components/workflow/WorkflowCanvas.tsx +6 -0
- package/templates/assistkick-product-system/packages/frontend/src/components/workflow/WorkflowMonitorModal.tsx +9 -0
- package/templates/assistkick-product-system/packages/frontend/src/components/workflow/monitor_nodes.tsx +39 -1
- package/templates/assistkick-product-system/packages/frontend/src/components/workflow/workflow_types.ts +30 -1
- package/templates/assistkick-product-system/packages/shared/db/migrations/0014_nifty_punisher.sql +15 -0
- package/templates/assistkick-product-system/packages/shared/db/migrations/meta/0014_snapshot.json +1545 -0
- package/templates/assistkick-product-system/packages/shared/db/migrations/meta/_journal.json +7 -0
- package/templates/assistkick-product-system/packages/shared/db/schema.ts +1 -0
- package/templates/assistkick-product-system/packages/shared/lib/claude-service.ts +1 -1
- package/templates/assistkick-product-system/packages/shared/lib/workflow_engine.test.ts +247 -1
- package/templates/assistkick-product-system/packages/shared/lib/workflow_engine.ts +158 -2
- package/templates/assistkick-product-system/tests/video_render_service.test.ts +6 -4
package/package.json
CHANGED
|
@@ -64,8 +64,8 @@ router.post('/:id/move', async (req, res) => {
|
|
|
64
64
|
const featureId = req.params.id;
|
|
65
65
|
log('API', `POST /api/kanban/${featureId}/move`);
|
|
66
66
|
try {
|
|
67
|
-
const { column } = req.body;
|
|
68
|
-
log('MOVE', `${featureId} → target: ${column}`);
|
|
67
|
+
const { column, comment } = req.body;
|
|
68
|
+
log('MOVE', `${featureId} → target: ${column}${comment ? ' (with comment)' : ''}`);
|
|
69
69
|
|
|
70
70
|
if (!column) {
|
|
71
71
|
log('MOVE', `REJECT: missing column`);
|
|
@@ -101,6 +101,22 @@ router.post('/:id/move', async (req, res) => {
|
|
|
101
101
|
log('MOVE', `QA rejection #${entry.rejection_count} for ${featureId}`);
|
|
102
102
|
}
|
|
103
103
|
|
|
104
|
+
// Store iteration comment as a note when moving backward with a comment
|
|
105
|
+
if (comment && typeof comment === 'string' && comment.trim()) {
|
|
106
|
+
const currentIdx = VALID_COLUMNS.indexOf(currentColumn);
|
|
107
|
+
const targetIdx = VALID_COLUMNS.indexOf(column);
|
|
108
|
+
if (targetIdx < currentIdx) {
|
|
109
|
+
if (!entry.notes) entry.notes = [];
|
|
110
|
+
entry.notes.push({
|
|
111
|
+
id: randomUUID().slice(0, 8),
|
|
112
|
+
text: `[Iteration feedback ${currentColumn} → ${column}] ${comment.trim()}`,
|
|
113
|
+
created_at: new Date().toISOString(),
|
|
114
|
+
updated_at: new Date().toISOString(),
|
|
115
|
+
});
|
|
116
|
+
log('MOVE', `Stored iteration comment for ${featureId}`);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
104
120
|
await saveKanbanEntry(featureId, entry);
|
|
105
121
|
log('MOVE', `OK: ${featureId} ${currentColumn} → ${column}`);
|
|
106
122
|
|
|
@@ -19,13 +19,15 @@ interface WorkflowRoutesDeps {
|
|
|
19
19
|
export const createWorkflowRoutes = ({ workflowService, log }: WorkflowRoutesDeps): Router => {
|
|
20
20
|
const router: Router = Router();
|
|
21
21
|
|
|
22
|
-
// GET /api/workflows?projectId=...
|
|
22
|
+
// GET /api/workflows?projectId=...&featureType=...&triggerColumn=...
|
|
23
23
|
router.get('/', async (req, res) => {
|
|
24
24
|
const projectId = req.query.projectId as string | undefined;
|
|
25
|
-
|
|
25
|
+
const featureType = req.query.featureType as string | undefined;
|
|
26
|
+
const triggerColumn = req.query.triggerColumn as string | undefined;
|
|
27
|
+
log('WORKFLOWS', `GET /api/workflows projectId=${projectId || 'none'} featureType=${featureType || 'none'} triggerColumn=${triggerColumn || 'none'}`);
|
|
26
28
|
|
|
27
29
|
try {
|
|
28
|
-
const workflowList = await workflowService.list(projectId);
|
|
30
|
+
const workflowList = await workflowService.list(projectId, featureType, triggerColumn);
|
|
29
31
|
res.json({ workflows: workflowList });
|
|
30
32
|
} catch (err: any) {
|
|
31
33
|
log('WORKFLOWS', `List workflows failed: ${err.message}`);
|
|
@@ -53,7 +55,7 @@ export const createWorkflowRoutes = ({ workflowService, log }: WorkflowRoutesDep
|
|
|
53
55
|
|
|
54
56
|
// POST /api/workflows
|
|
55
57
|
router.post('/', async (req, res) => {
|
|
56
|
-
const { name, description, projectId, featureType, graphData } = req.body;
|
|
58
|
+
const { name, description, projectId, featureType, triggerColumn, graphData } = req.body;
|
|
57
59
|
log('WORKFLOWS', `POST /api/workflows name="${name}"`);
|
|
58
60
|
|
|
59
61
|
if (!name || typeof name !== 'string' || !name.trim()) {
|
|
@@ -72,6 +74,7 @@ export const createWorkflowRoutes = ({ workflowService, log }: WorkflowRoutesDep
|
|
|
72
74
|
description: description || null,
|
|
73
75
|
projectId: projectId || null,
|
|
74
76
|
featureType: featureType || null,
|
|
77
|
+
triggerColumn: triggerColumn || null,
|
|
75
78
|
graphData,
|
|
76
79
|
});
|
|
77
80
|
res.status(201).json({ workflow });
|
|
@@ -84,7 +87,7 @@ export const createWorkflowRoutes = ({ workflowService, log }: WorkflowRoutesDep
|
|
|
84
87
|
// PUT /api/workflows/:id
|
|
85
88
|
router.put('/:id', async (req, res) => {
|
|
86
89
|
const { id } = req.params;
|
|
87
|
-
const { name, description, featureType, graphData } = req.body;
|
|
90
|
+
const { name, description, featureType, triggerColumn, graphData } = req.body;
|
|
88
91
|
log('WORKFLOWS', `PUT /api/workflows/${id}`);
|
|
89
92
|
|
|
90
93
|
if (name !== undefined && (typeof name !== 'string' || !name.trim())) {
|
|
@@ -97,6 +100,7 @@ export const createWorkflowRoutes = ({ workflowService, log }: WorkflowRoutesDep
|
|
|
97
100
|
name: name?.trim(),
|
|
98
101
|
description,
|
|
99
102
|
featureType,
|
|
103
|
+
triggerColumn,
|
|
100
104
|
graphData,
|
|
101
105
|
});
|
|
102
106
|
res.json({ workflow });
|
|
@@ -16,7 +16,7 @@ import { fileURLToPath } from 'node:url';
|
|
|
16
16
|
import { existsSync } from 'node:fs';
|
|
17
17
|
import { WebSocketServer } from 'ws';
|
|
18
18
|
import * as pty from 'node-pty';
|
|
19
|
-
import { initServices, log, githubAppService, sshKeyService, workspaceService, workflowEngine, orchestrator, workflowService as initWorkflowService, workflowGroupService as initWorkflowGroupService } from './services/init.js';
|
|
19
|
+
import { initServices, log, githubAppService, sshKeyService, workspaceService, workflowEngine, orchestrator, workflowService as initWorkflowService, workflowGroupService as initWorkflowGroupService, bundleService, ttsService, videoRenderService } from './services/init.js';
|
|
20
20
|
import { AuthService } from './services/auth_service.js';
|
|
21
21
|
import { EmailService } from './services/email_service.js';
|
|
22
22
|
import { PasswordResetService } from './services/password_reset_service.js';
|
|
@@ -41,9 +41,6 @@ import { AgentService } from './services/agent_service.js';
|
|
|
41
41
|
import { createAgentRoutes } from './routes/agents.js';
|
|
42
42
|
import { createWorkflowRoutes } from './routes/workflows.js';
|
|
43
43
|
import { createWorkflowGroupRoutes } from './routes/workflow_groups.js';
|
|
44
|
-
import { TtsService } from './services/tts_service.js';
|
|
45
|
-
import { BundleService } from './services/bundle_service.js';
|
|
46
|
-
import { VideoRenderService } from './services/video_render_service.js';
|
|
47
44
|
import { createVideoRoutes } from './routes/video.js';
|
|
48
45
|
|
|
49
46
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
@@ -145,24 +142,6 @@ const workflowGroupRoutes = createWorkflowGroupRoutes({ workflowGroupService: in
|
|
|
145
142
|
app.use('/api/workflow-groups', authMiddleware.requireAuth, workflowGroupRoutes);
|
|
146
143
|
|
|
147
144
|
// Video routes (authenticated) — TTS audio generation, bundle management, compositions
|
|
148
|
-
const VIDEO_PACKAGE_DIR = join(__dirname, '..', '..', 'video');
|
|
149
|
-
const BUNDLE_OUTPUT_DIR = process.env.REMOTION_BUNDLE_DIR || join(__dirname, '..', '..', '..', '..', 'data', 'remotion-bundle');
|
|
150
|
-
const ttsService = new TtsService({
|
|
151
|
-
workspacesDir: process.env.WORKSPACES_DIR || join(__dirname, '..', '..', '..', '..', 'workspaces'),
|
|
152
|
-
videoPackageDir: VIDEO_PACKAGE_DIR,
|
|
153
|
-
log,
|
|
154
|
-
});
|
|
155
|
-
const bundleService = new BundleService({
|
|
156
|
-
videoPackageDir: VIDEO_PACKAGE_DIR,
|
|
157
|
-
bundleOutputDir: BUNDLE_OUTPUT_DIR,
|
|
158
|
-
log,
|
|
159
|
-
});
|
|
160
|
-
const videoRenderService = new VideoRenderService({
|
|
161
|
-
getDb,
|
|
162
|
-
workspacesDir: process.env.WORKSPACES_DIR || join(__dirname, '..', '..', '..', '..', 'workspaces'),
|
|
163
|
-
bundleOutputDir: BUNDLE_OUTPUT_DIR,
|
|
164
|
-
log,
|
|
165
|
-
});
|
|
166
145
|
const videoRoutes = createVideoRoutes({ ttsService, bundleService, videoRenderService, log });
|
|
167
146
|
app.use('/api/video', authMiddleware.requireAuth, videoRoutes);
|
|
168
147
|
|
|
@@ -17,6 +17,9 @@ import { WorkflowGroupService } from './workflow_group_service.js';
|
|
|
17
17
|
import { GitHubAppService } from './github_app_service.js';
|
|
18
18
|
import { ProjectWorkspaceService } from './project_workspace_service.js';
|
|
19
19
|
import { SshKeyService } from './ssh_key_service.js';
|
|
20
|
+
import { BundleService } from './bundle_service.js';
|
|
21
|
+
import { TtsService } from './tts_service.js';
|
|
22
|
+
import { VideoRenderService } from './video_render_service.js';
|
|
20
23
|
|
|
21
24
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
22
25
|
// Navigate from packages/backend/src/services/ up to assistkick-product-system/
|
|
@@ -35,6 +38,8 @@ const WORKTREES_DIR = join(PROJECT_ROOT, '.worktrees');
|
|
|
35
38
|
const DEVELOPER_SKILL_PATH = join(SKILLS_DIR, 'product-developer', 'SKILL.md');
|
|
36
39
|
const REVIEWER_SKILL_PATH = join(SKILLS_DIR, 'product-code-reviewer', 'SKILL.md');
|
|
37
40
|
const DEBUGGER_SKILL_PATH = join(SKILLS_DIR, 'product-debugger', 'SKILL.md');
|
|
41
|
+
const VIDEO_PACKAGE_DIR = join(SKILL_ROOT, 'video');
|
|
42
|
+
const BUNDLE_OUTPUT_DIR = process.env.REMOTION_BUNDLE_DIR || join(PROJECT_ROOT, 'data', 'remotion-bundle');
|
|
38
43
|
|
|
39
44
|
export const log = (tag: string, ...args: any[]) => {
|
|
40
45
|
const ts = new Date().toISOString().slice(11, 23);
|
|
@@ -49,6 +54,9 @@ export let workflowGroupService: WorkflowGroupService;
|
|
|
49
54
|
export let githubAppService: GitHubAppService;
|
|
50
55
|
export let sshKeyService: SshKeyService;
|
|
51
56
|
export let workspaceService: ProjectWorkspaceService;
|
|
57
|
+
export let bundleService: BundleService;
|
|
58
|
+
export let ttsService: TtsService;
|
|
59
|
+
export let videoRenderService: VideoRenderService;
|
|
52
60
|
|
|
53
61
|
export const paths = {
|
|
54
62
|
projectRoot: PROJECT_ROOT,
|
|
@@ -77,12 +85,20 @@ export const initServices = (verbose: boolean) => {
|
|
|
77
85
|
workflowService = new WorkflowService({ getDb, log });
|
|
78
86
|
workflowGroupService = new WorkflowGroupService({ getDb, log });
|
|
79
87
|
|
|
88
|
+
// Video services for workflow video node types
|
|
89
|
+
bundleService = new BundleService({ videoPackageDir: VIDEO_PACKAGE_DIR, bundleOutputDir: BUNDLE_OUTPUT_DIR, log });
|
|
90
|
+
ttsService = new TtsService({ workspacesDir: WORKSPACES_DIR, videoPackageDir: VIDEO_PACKAGE_DIR, log });
|
|
91
|
+
videoRenderService = new VideoRenderService({ getDb, workspacesDir: WORKSPACES_DIR, bundleOutputDir: BUNDLE_OUTPUT_DIR, log });
|
|
92
|
+
|
|
80
93
|
// WorkflowEngine replaces Pipeline
|
|
81
94
|
workflowEngine = new WorkflowEngine({
|
|
82
95
|
db: getDb(),
|
|
83
96
|
kanban: { getKanbanEntry, saveKanbanEntry },
|
|
84
97
|
claudeService,
|
|
85
98
|
gitWorkflow,
|
|
99
|
+
bundleService,
|
|
100
|
+
ttsService,
|
|
101
|
+
videoRenderService,
|
|
86
102
|
log,
|
|
87
103
|
});
|
|
88
104
|
|
package/templates/assistkick-product-system/packages/backend/src/services/workflow_service.ts
CHANGED
|
@@ -19,6 +19,7 @@ export interface WorkflowRecord {
|
|
|
19
19
|
description: string | null;
|
|
20
20
|
projectId: string | null;
|
|
21
21
|
featureType: string | null;
|
|
22
|
+
triggerColumn: string | null;
|
|
22
23
|
isDefault: number;
|
|
23
24
|
graphData: string;
|
|
24
25
|
createdAt: string;
|
|
@@ -31,6 +32,7 @@ export interface WorkflowListItem {
|
|
|
31
32
|
description: string | null;
|
|
32
33
|
projectId: string | null;
|
|
33
34
|
featureType: string | null;
|
|
35
|
+
triggerColumn: string | null;
|
|
34
36
|
isDefault: number;
|
|
35
37
|
createdAt: string;
|
|
36
38
|
updatedAt: string;
|
|
@@ -45,13 +47,29 @@ export class WorkflowService {
|
|
|
45
47
|
this.log = log;
|
|
46
48
|
}
|
|
47
49
|
|
|
48
|
-
/** List workflows visible to the given scope (global + project-specific). Excludes graph_data.
|
|
49
|
-
|
|
50
|
+
/** List workflows visible to the given scope (global + project-specific). Excludes graph_data.
|
|
51
|
+
* Optional filters: featureType and triggerColumn for column-gated video workflow selection. */
|
|
52
|
+
list = async (projectId?: string, featureType?: string, triggerColumn?: string): Promise<WorkflowListItem[]> => {
|
|
50
53
|
const db = this.getDb();
|
|
51
54
|
|
|
52
|
-
const
|
|
53
|
-
|
|
54
|
-
|
|
55
|
+
const conditions = [];
|
|
56
|
+
|
|
57
|
+
// Scope: global + project-specific
|
|
58
|
+
if (projectId) {
|
|
59
|
+
conditions.push(or(isNull(workflows.projectId), eq(workflows.projectId, projectId)));
|
|
60
|
+
} else {
|
|
61
|
+
conditions.push(isNull(workflows.projectId));
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Optional featureType filter
|
|
65
|
+
if (featureType) {
|
|
66
|
+
conditions.push(eq(workflows.featureType, featureType));
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// Optional triggerColumn filter
|
|
70
|
+
if (triggerColumn) {
|
|
71
|
+
conditions.push(eq(workflows.triggerColumn, triggerColumn));
|
|
72
|
+
}
|
|
55
73
|
|
|
56
74
|
const rows = await db
|
|
57
75
|
.select({
|
|
@@ -60,12 +78,13 @@ export class WorkflowService {
|
|
|
60
78
|
description: workflows.description,
|
|
61
79
|
projectId: workflows.projectId,
|
|
62
80
|
featureType: workflows.featureType,
|
|
81
|
+
triggerColumn: workflows.triggerColumn,
|
|
63
82
|
isDefault: workflows.isDefault,
|
|
64
83
|
createdAt: workflows.createdAt,
|
|
65
84
|
updatedAt: workflows.updatedAt,
|
|
66
85
|
})
|
|
67
86
|
.from(workflows)
|
|
68
|
-
.where(
|
|
87
|
+
.where(and(...conditions));
|
|
69
88
|
|
|
70
89
|
this.log('WORKFLOWS', `Listed ${rows.length} workflows`);
|
|
71
90
|
return rows;
|
|
@@ -84,6 +103,7 @@ export class WorkflowService {
|
|
|
84
103
|
description?: string | null;
|
|
85
104
|
projectId?: string | null;
|
|
86
105
|
featureType?: string | null;
|
|
106
|
+
triggerColumn?: string | null;
|
|
87
107
|
graphData: string;
|
|
88
108
|
}): Promise<WorkflowRecord> => {
|
|
89
109
|
const db = this.getDb();
|
|
@@ -96,6 +116,7 @@ export class WorkflowService {
|
|
|
96
116
|
description: data.description || null,
|
|
97
117
|
projectId: data.projectId || null,
|
|
98
118
|
featureType: data.featureType || null,
|
|
119
|
+
triggerColumn: data.triggerColumn || null,
|
|
99
120
|
isDefault: 0,
|
|
100
121
|
graphData: data.graphData,
|
|
101
122
|
createdAt: now,
|
|
@@ -107,11 +128,12 @@ export class WorkflowService {
|
|
|
107
128
|
return record;
|
|
108
129
|
};
|
|
109
130
|
|
|
110
|
-
/** Update a workflow's name, description, featureType, and/or graph_data. */
|
|
131
|
+
/** Update a workflow's name, description, featureType, triggerColumn, and/or graph_data. */
|
|
111
132
|
update = async (id: string, data: {
|
|
112
133
|
name?: string;
|
|
113
134
|
description?: string;
|
|
114
135
|
featureType?: string | null;
|
|
136
|
+
triggerColumn?: string | null;
|
|
115
137
|
graphData?: string;
|
|
116
138
|
}): Promise<WorkflowRecord> => {
|
|
117
139
|
const db = this.getDb();
|
|
@@ -127,6 +149,7 @@ export class WorkflowService {
|
|
|
127
149
|
if (data.name !== undefined) updates.name = data.name;
|
|
128
150
|
if (data.description !== undefined) updates.description = data.description;
|
|
129
151
|
if (data.featureType !== undefined) updates.featureType = data.featureType;
|
|
152
|
+
if (data.triggerColumn !== undefined) updates.triggerColumn = data.triggerColumn;
|
|
130
153
|
if (data.graphData !== undefined) updates.graphData = data.graphData;
|
|
131
154
|
|
|
132
155
|
await db.update(workflows).set(updates).where(eq(workflows.id, id));
|
|
@@ -154,11 +154,11 @@ export class ApiClient {
|
|
|
154
154
|
return resp.json();
|
|
155
155
|
};
|
|
156
156
|
|
|
157
|
-
moveCard = async (featureId: string, column: string) => {
|
|
157
|
+
moveCard = async (featureId: string, column: string, comment?: string) => {
|
|
158
158
|
const resp = await this.fetchWithRefresh(`${this.baseUrl}/api/kanban/${featureId}/move`, {
|
|
159
159
|
method: 'POST',
|
|
160
160
|
headers: { 'Content-Type': 'application/json' },
|
|
161
|
-
body: JSON.stringify({ column }),
|
|
161
|
+
body: JSON.stringify({ column, ...(comment ? { comment } : {}) }),
|
|
162
162
|
});
|
|
163
163
|
if (!resp.ok) {
|
|
164
164
|
const err = await resp.json();
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Iteration Comment Modal — prompts for feedback when moving a card backward.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import React, { useState, useCallback, useRef, useEffect } from 'react';
|
|
6
|
+
|
|
7
|
+
interface IterationCommentModalProps {
|
|
8
|
+
isOpen: boolean;
|
|
9
|
+
featureName: string;
|
|
10
|
+
fromColumn: string;
|
|
11
|
+
toColumn: string;
|
|
12
|
+
onConfirm: (comment: string) => void;
|
|
13
|
+
onCancel: () => void;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function IterationCommentModal({ isOpen, featureName, fromColumn, toColumn, onConfirm, onCancel }: IterationCommentModalProps) {
|
|
17
|
+
const [comment, setComment] = useState('');
|
|
18
|
+
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
|
19
|
+
|
|
20
|
+
useEffect(() => {
|
|
21
|
+
if (isOpen) {
|
|
22
|
+
setComment('');
|
|
23
|
+
setTimeout(() => textareaRef.current?.focus(), 50);
|
|
24
|
+
}
|
|
25
|
+
}, [isOpen]);
|
|
26
|
+
|
|
27
|
+
const handleSubmit = useCallback((e: React.FormEvent) => {
|
|
28
|
+
e.preventDefault();
|
|
29
|
+
onConfirm(comment.trim());
|
|
30
|
+
}, [comment, onConfirm]);
|
|
31
|
+
|
|
32
|
+
if (!isOpen) return null;
|
|
33
|
+
|
|
34
|
+
return (
|
|
35
|
+
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-[1000]" onClick={onCancel}>
|
|
36
|
+
<div className="bg-surface border border-edge rounded-xl p-6 w-[440px] max-w-[90vw] shadow-[0_8px_32px_rgba(0,0,0,0.3)]" onClick={e => e.stopPropagation()}>
|
|
37
|
+
<div className="flex justify-between items-center mb-4">
|
|
38
|
+
<h2 className="m-0 text-lg text-content">Move Card Backward</h2>
|
|
39
|
+
<button className="bg-none border-none text-[22px] text-content-secondary cursor-pointer px-1 leading-none hover:text-content" onClick={onCancel} type="button">×</button>
|
|
40
|
+
</div>
|
|
41
|
+
|
|
42
|
+
<p className="text-[13px] text-content-secondary mb-3">
|
|
43
|
+
Moving <span className="font-semibold text-content">{featureName}</span> from <span className="font-mono text-accent">{fromColumn}</span> back to <span className="font-mono text-accent">{toColumn}</span>.
|
|
44
|
+
</p>
|
|
45
|
+
|
|
46
|
+
<form onSubmit={handleSubmit} noValidate>
|
|
47
|
+
<div className="flex flex-col gap-1">
|
|
48
|
+
<label className="font-mono text-[11px] font-semibold text-content-secondary uppercase tracking-wider" htmlFor="iteration-comment">
|
|
49
|
+
What needs to change? (optional)
|
|
50
|
+
</label>
|
|
51
|
+
<textarea
|
|
52
|
+
ref={textareaRef}
|
|
53
|
+
id="iteration-comment"
|
|
54
|
+
className="w-full h-24 px-2.5 py-2 bg-surface-raised border border-edge rounded text-content font-mono text-[13px] outline-none transition-[border-color] duration-150 focus:border-accent placeholder:text-content-muted resize-y"
|
|
55
|
+
value={comment}
|
|
56
|
+
onChange={e => setComment(e.target.value)}
|
|
57
|
+
placeholder="Describe what should be revised..."
|
|
58
|
+
/>
|
|
59
|
+
</div>
|
|
60
|
+
|
|
61
|
+
<div className="flex gap-2 mt-4">
|
|
62
|
+
<button
|
|
63
|
+
className="flex-1 h-9 bg-transparent border border-edge rounded text-content-secondary font-mono text-[13px] cursor-pointer transition-[background,color] duration-150 hover:bg-surface-raised"
|
|
64
|
+
type="button"
|
|
65
|
+
onClick={onCancel}
|
|
66
|
+
>
|
|
67
|
+
Cancel
|
|
68
|
+
</button>
|
|
69
|
+
<button
|
|
70
|
+
className="flex-1 h-9 bg-transparent border border-accent rounded text-accent font-mono text-[13px] cursor-pointer transition-[background,color] duration-150 hover:bg-accent hover:text-white"
|
|
71
|
+
type="submit"
|
|
72
|
+
>
|
|
73
|
+
Move
|
|
74
|
+
</button>
|
|
75
|
+
</div>
|
|
76
|
+
</form>
|
|
77
|
+
</div>
|
|
78
|
+
</div>
|
|
79
|
+
);
|
|
80
|
+
}
|
|
@@ -3,6 +3,7 @@ import { apiClient } from '../api/client';
|
|
|
3
3
|
import { COLUMNS, WORKFLOW_STATUS_LABELS } from '../constants/graph';
|
|
4
4
|
import { useToast } from '../hooks/useToast';
|
|
5
5
|
import { KanbanCard } from './ds/KanbanCard';
|
|
6
|
+
import { IterationCommentModal } from './IterationCommentModal';
|
|
6
7
|
import { WorkflowMonitorModal } from './workflow/WorkflowMonitorModal';
|
|
7
8
|
|
|
8
9
|
|
|
@@ -47,6 +48,7 @@ interface WorkflowListItem {
|
|
|
47
48
|
id: string;
|
|
48
49
|
name: string;
|
|
49
50
|
featureType: string | null;
|
|
51
|
+
triggerColumn: string | null;
|
|
50
52
|
isDefault: number;
|
|
51
53
|
}
|
|
52
54
|
|
|
@@ -63,12 +65,14 @@ export function KanbanView({ graphData, projectId, onCardClick, onIssuesClick }:
|
|
|
63
65
|
const [availableWorkflows, setAvailableWorkflows] = useState<WorkflowListItem[]>([]);
|
|
64
66
|
const [dropdownOpen, setDropdownOpen] = useState<string | null>(null);
|
|
65
67
|
const [monitorTarget, setMonitorTarget] = useState<{ featureId: string; featureName: string } | null>(null);
|
|
68
|
+
const [backwardMove, setBackwardMove] = useState<{ featureId: string; featureName: string; fromColumn: string; toColumn: string } | null>(null);
|
|
66
69
|
const { showToast } = useToast();
|
|
67
70
|
|
|
68
71
|
const pollersRef = useRef<Map<string, ReturnType<typeof setInterval>>>(new Map());
|
|
69
72
|
const orchestratorPollerRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
|
70
73
|
const kanbanDataRef = useRef(kanbanData);
|
|
71
74
|
kanbanDataRef.current = kanbanData;
|
|
75
|
+
const backwardMoveTargetRef = useRef<string | null>(null);
|
|
72
76
|
|
|
73
77
|
const fetchKanban = useCallback(async () => {
|
|
74
78
|
try {
|
|
@@ -248,6 +252,26 @@ export function KanbanView({ graphData, projectId, onCardClick, onIssuesClick }:
|
|
|
248
252
|
setDragOverColumn(null);
|
|
249
253
|
const featureId = e.dataTransfer.getData('text/plain');
|
|
250
254
|
if (!featureId || !targetColumn) return;
|
|
255
|
+
|
|
256
|
+
// Detect backward move — prompt for iteration comment
|
|
257
|
+
const currentEntry = kanbanData?.[featureId];
|
|
258
|
+
if (currentEntry) {
|
|
259
|
+
const colIds = COLUMNS.map(c => c.id);
|
|
260
|
+
const currentIdx = colIds.indexOf(currentEntry.column);
|
|
261
|
+
const targetIdx = colIds.indexOf(targetColumn);
|
|
262
|
+
if (targetIdx < currentIdx) {
|
|
263
|
+
// Find the card name from graphData
|
|
264
|
+
const node = graphData?.nodes?.find((n: any) => n.id === featureId);
|
|
265
|
+
const featureName = node?.name || featureId;
|
|
266
|
+
const fromLabel = COLUMNS.find(c => c.id === currentEntry.column)?.label || currentEntry.column;
|
|
267
|
+
const toLabel = COLUMNS.find(c => c.id === targetColumn)?.label || targetColumn;
|
|
268
|
+
setBackwardMove({ featureId, featureName, fromColumn: fromLabel, toColumn: toLabel });
|
|
269
|
+
// Store the target for the confirm handler
|
|
270
|
+
backwardMoveTargetRef.current = targetColumn;
|
|
271
|
+
return;
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
|
|
251
275
|
try {
|
|
252
276
|
await apiClient.moveCard(featureId, targetColumn);
|
|
253
277
|
await fetchKanban();
|
|
@@ -256,6 +280,25 @@ export function KanbanView({ graphData, projectId, onCardClick, onIssuesClick }:
|
|
|
256
280
|
}
|
|
257
281
|
};
|
|
258
282
|
|
|
283
|
+
const handleBackwardMoveConfirm = async (comment: string) => {
|
|
284
|
+
if (!backwardMove) return;
|
|
285
|
+
const targetColumn = backwardMoveTargetRef.current;
|
|
286
|
+
backwardMoveTargetRef.current = null;
|
|
287
|
+
setBackwardMove(null);
|
|
288
|
+
if (!targetColumn) return;
|
|
289
|
+
try {
|
|
290
|
+
await apiClient.moveCard(backwardMove.featureId, targetColumn, comment || undefined);
|
|
291
|
+
await fetchKanban();
|
|
292
|
+
} catch (err) {
|
|
293
|
+
console.error('Failed to move card:', err);
|
|
294
|
+
}
|
|
295
|
+
};
|
|
296
|
+
|
|
297
|
+
const handleBackwardMoveCancel = () => {
|
|
298
|
+
backwardMoveTargetRef.current = null;
|
|
299
|
+
setBackwardMove(null);
|
|
300
|
+
};
|
|
301
|
+
|
|
259
302
|
const handleStartWorkflow = async (featureId: string, workflowId?: string) => {
|
|
260
303
|
try {
|
|
261
304
|
await apiClient.startWorkflow(featureId, workflowId, projectId ?? undefined);
|
|
@@ -447,7 +490,18 @@ export function KanbanView({ graphData, projectId, onCardClick, onIssuesClick }:
|
|
|
447
490
|
pipelineLabel={wStatus && wStatus.status !== 'idle' ? getStatusBadgeText(wStatus.status) : undefined}
|
|
448
491
|
issueCount={card.notes.length}
|
|
449
492
|
issueLabel={issueLabel}
|
|
450
|
-
showPlay={
|
|
493
|
+
showPlay={(() => {
|
|
494
|
+
if (card.devBlocked) return false;
|
|
495
|
+
if (card.nodeType === 'epic' && playAllRunning && playAllEpicId === card.id) return false;
|
|
496
|
+
// Default column for non-video features
|
|
497
|
+
if (!card.featureType || card.featureType === 'code') return card.column === 'todo';
|
|
498
|
+
// For video features, show play when a workflow matches the column
|
|
499
|
+
const cardType = card.featureType;
|
|
500
|
+
return availableWorkflows.some(wf =>
|
|
501
|
+
(!wf.featureType || wf.featureType === cardType)
|
|
502
|
+
&& wf.triggerColumn === card.column
|
|
503
|
+
);
|
|
504
|
+
})()}
|
|
451
505
|
showPlayDropdown={card.nodeType !== 'epic' && availableWorkflows.length > 1}
|
|
452
506
|
showStop={card.nodeType === 'epic' && playAllRunning && playAllEpicId === card.id}
|
|
453
507
|
showResume={showResumeBtn}
|
|
@@ -471,7 +525,8 @@ export function KanbanView({ graphData, projectId, onCardClick, onIssuesClick }:
|
|
|
471
525
|
{dropdownOpen === card.id && (() => {
|
|
472
526
|
const cardFeatureType = card.featureType || 'code';
|
|
473
527
|
const filteredWorkflows = availableWorkflows.filter(wf =>
|
|
474
|
-
!wf.featureType || wf.featureType === cardFeatureType
|
|
528
|
+
(!wf.featureType || wf.featureType === cardFeatureType)
|
|
529
|
+
&& (!wf.triggerColumn || wf.triggerColumn === card.column)
|
|
475
530
|
);
|
|
476
531
|
return (
|
|
477
532
|
<div
|
|
@@ -513,6 +568,16 @@ export function KanbanView({ graphData, projectId, onCardClick, onIssuesClick }:
|
|
|
513
568
|
onClose={() => setMonitorTarget(null)}
|
|
514
569
|
/>
|
|
515
570
|
)}
|
|
571
|
+
|
|
572
|
+
{/* Iteration Comment Modal — backward card moves */}
|
|
573
|
+
<IterationCommentModal
|
|
574
|
+
isOpen={!!backwardMove}
|
|
575
|
+
featureName={backwardMove?.featureName || ''}
|
|
576
|
+
fromColumn={backwardMove?.fromColumn || ''}
|
|
577
|
+
toColumn={backwardMove?.toColumn || ''}
|
|
578
|
+
onConfirm={handleBackwardMoveConfirm}
|
|
579
|
+
onCancel={handleBackwardMoveCancel}
|
|
580
|
+
/>
|
|
516
581
|
</div>
|
|
517
582
|
);
|
|
518
583
|
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import React, { useCallback } from 'react';
|
|
2
|
+
import { Handle, Position, useReactFlow } from '@xyflow/react';
|
|
3
|
+
import type { NodeProps } from '@xyflow/react';
|
|
4
|
+
import { Volume2 } from 'lucide-react';
|
|
5
|
+
|
|
6
|
+
export function GenerateTTSNode({ id, data, selected }: NodeProps) {
|
|
7
|
+
const { updateNodeData } = useReactFlow();
|
|
8
|
+
const scriptPath = (data.scriptPath as string) || 'script.md';
|
|
9
|
+
const force = (data.force as boolean) || false;
|
|
10
|
+
|
|
11
|
+
const handleScriptPathChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
|
|
12
|
+
updateNodeData(id, { scriptPath: e.target.value });
|
|
13
|
+
}, [id, updateNodeData]);
|
|
14
|
+
|
|
15
|
+
const handleForceChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
|
|
16
|
+
updateNodeData(id, { force: e.target.checked });
|
|
17
|
+
}, [id, updateNodeData]);
|
|
18
|
+
|
|
19
|
+
return (
|
|
20
|
+
<div className={`px-4 py-3 rounded-lg border bg-surface-raised text-content min-w-[180px] ${
|
|
21
|
+
selected ? 'border-accent shadow-[0_0_0_1px_var(--accent)]' : 'border-edge'
|
|
22
|
+
}`}>
|
|
23
|
+
<Handle type="target" position={Position.Top} className="!w-3 !h-3 !bg-accent !border-2 !border-surface" />
|
|
24
|
+
<div className="flex items-center gap-2 mb-2">
|
|
25
|
+
<Volume2 size={14} className="text-emerald-400" />
|
|
26
|
+
<span className="text-[12px] font-semibold">Generate TTS</span>
|
|
27
|
+
</div>
|
|
28
|
+
<div className="space-y-1.5">
|
|
29
|
+
<div>
|
|
30
|
+
<label className="text-[10px] text-content-muted uppercase tracking-wider">Script</label>
|
|
31
|
+
<input
|
|
32
|
+
type="text"
|
|
33
|
+
value={scriptPath}
|
|
34
|
+
onChange={handleScriptPathChange}
|
|
35
|
+
className="w-full px-2 py-1 rounded border border-edge bg-surface text-[11px] text-content"
|
|
36
|
+
placeholder="script.md"
|
|
37
|
+
/>
|
|
38
|
+
</div>
|
|
39
|
+
<label className="flex items-center gap-1.5 text-[10px] text-content-muted cursor-pointer">
|
|
40
|
+
<input
|
|
41
|
+
type="checkbox"
|
|
42
|
+
checked={force}
|
|
43
|
+
onChange={handleForceChange}
|
|
44
|
+
className="rounded"
|
|
45
|
+
/>
|
|
46
|
+
Force regeneration
|
|
47
|
+
</label>
|
|
48
|
+
</div>
|
|
49
|
+
<Handle type="source" position={Position.Bottom} className="!w-3 !h-3 !bg-accent !border-2 !border-surface" />
|
|
50
|
+
</div>
|
|
51
|
+
);
|
|
52
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { Handle, Position } from '@xyflow/react';
|
|
3
|
+
import type { NodeProps } from '@xyflow/react';
|
|
4
|
+
import { Package } from 'lucide-react';
|
|
5
|
+
|
|
6
|
+
export function RebuildBundleNode({ id, selected }: NodeProps) {
|
|
7
|
+
return (
|
|
8
|
+
<div className={`px-4 py-3 rounded-lg border bg-surface-raised text-content min-w-[180px] ${
|
|
9
|
+
selected ? 'border-accent shadow-[0_0_0_1px_var(--accent)]' : 'border-edge'
|
|
10
|
+
}`}>
|
|
11
|
+
<Handle type="target" position={Position.Top} className="!w-3 !h-3 !bg-accent !border-2 !border-surface" />
|
|
12
|
+
<div className="flex items-center gap-2">
|
|
13
|
+
<Package size={14} className="text-orange-400" />
|
|
14
|
+
<span className="text-[12px] font-semibold">Rebuild Bundle</span>
|
|
15
|
+
</div>
|
|
16
|
+
<div className="text-[10px] text-content-muted mt-1">Remotion webpack build</div>
|
|
17
|
+
<Handle type="source" position={Position.Bottom} className="!w-3 !h-3 !bg-accent !border-2 !border-surface" />
|
|
18
|
+
</div>
|
|
19
|
+
);
|
|
20
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import React, { useCallback } from 'react';
|
|
2
|
+
import { Handle, Position, useReactFlow } from '@xyflow/react';
|
|
3
|
+
import type { NodeProps } from '@xyflow/react';
|
|
4
|
+
import { Film } from 'lucide-react';
|
|
5
|
+
|
|
6
|
+
export function RenderVideoNode({ id, data, selected }: NodeProps) {
|
|
7
|
+
const { updateNodeData } = useReactFlow();
|
|
8
|
+
const compositionId = (data.compositionId as string) || '';
|
|
9
|
+
const resolution = (data.resolution as string) || '1920x1080';
|
|
10
|
+
const fileOutputPrefix = (data.fileOutputPrefix as string) || '';
|
|
11
|
+
|
|
12
|
+
const handleCompositionChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
|
|
13
|
+
updateNodeData(id, { compositionId: e.target.value });
|
|
14
|
+
}, [id, updateNodeData]);
|
|
15
|
+
|
|
16
|
+
const handleResolutionChange = useCallback((e: React.ChangeEvent<HTMLSelectElement>) => {
|
|
17
|
+
updateNodeData(id, { resolution: e.target.value });
|
|
18
|
+
}, [id, updateNodeData]);
|
|
19
|
+
|
|
20
|
+
const handlePrefixChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
|
|
21
|
+
updateNodeData(id, { fileOutputPrefix: e.target.value });
|
|
22
|
+
}, [id, updateNodeData]);
|
|
23
|
+
|
|
24
|
+
return (
|
|
25
|
+
<div className={`px-4 py-3 rounded-lg border bg-surface-raised text-content min-w-[200px] ${
|
|
26
|
+
selected ? 'border-accent shadow-[0_0_0_1px_var(--accent)]' : 'border-edge'
|
|
27
|
+
}`}>
|
|
28
|
+
<Handle type="target" position={Position.Top} className="!w-3 !h-3 !bg-accent !border-2 !border-surface" />
|
|
29
|
+
<div className="flex items-center gap-2 mb-2">
|
|
30
|
+
<Film size={14} className="text-rose-400" />
|
|
31
|
+
<span className="text-[12px] font-semibold">Render Video</span>
|
|
32
|
+
</div>
|
|
33
|
+
<div className="space-y-1.5">
|
|
34
|
+
<div>
|
|
35
|
+
<label className="text-[10px] text-content-muted uppercase tracking-wider">Composition</label>
|
|
36
|
+
<input
|
|
37
|
+
type="text"
|
|
38
|
+
value={compositionId}
|
|
39
|
+
onChange={handleCompositionChange}
|
|
40
|
+
className="w-full px-2 py-1 rounded border border-edge bg-surface text-[11px] text-content"
|
|
41
|
+
placeholder="auto from context"
|
|
42
|
+
/>
|
|
43
|
+
</div>
|
|
44
|
+
<div>
|
|
45
|
+
<label className="text-[10px] text-content-muted uppercase tracking-wider">Resolution</label>
|
|
46
|
+
<select
|
|
47
|
+
value={resolution}
|
|
48
|
+
onChange={handleResolutionChange}
|
|
49
|
+
className="w-full px-2 py-1 rounded border border-edge bg-surface text-[11px] text-content appearance-none"
|
|
50
|
+
>
|
|
51
|
+
<option value="1920x1080">1920x1080 (16:9)</option>
|
|
52
|
+
<option value="1080x1920">1080x1920 (9:16)</option>
|
|
53
|
+
<option value="1280x720">1280x720 (16:9)</option>
|
|
54
|
+
<option value="720x1280">720x1280 (9:16)</option>
|
|
55
|
+
<option value="1080x1080">1080x1080 (1:1)</option>
|
|
56
|
+
</select>
|
|
57
|
+
</div>
|
|
58
|
+
<div>
|
|
59
|
+
<label className="text-[10px] text-content-muted uppercase tracking-wider">Output prefix</label>
|
|
60
|
+
<input
|
|
61
|
+
type="text"
|
|
62
|
+
value={fileOutputPrefix}
|
|
63
|
+
onChange={handlePrefixChange}
|
|
64
|
+
className="w-full px-2 py-1 rounded border border-edge bg-surface text-[11px] text-content"
|
|
65
|
+
placeholder="auto"
|
|
66
|
+
/>
|
|
67
|
+
</div>
|
|
68
|
+
</div>
|
|
69
|
+
<Handle type="source" position={Position.Bottom} className="!w-3 !h-3 !bg-accent !border-2 !border-surface" />
|
|
70
|
+
</div>
|
|
71
|
+
);
|
|
72
|
+
}
|