@assistkick/create 1.8.0 → 1.10.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.
Files changed (26) hide show
  1. package/package.json +1 -1
  2. package/templates/assistkick-product-system/packages/backend/src/routes/git.ts +1 -1
  3. package/templates/assistkick-product-system/packages/backend/src/routes/kanban.ts +18 -2
  4. package/templates/assistkick-product-system/packages/backend/src/routes/workflows.ts +9 -5
  5. package/templates/assistkick-product-system/packages/backend/src/server.ts +1 -22
  6. package/templates/assistkick-product-system/packages/backend/src/services/init.ts +16 -0
  7. package/templates/assistkick-product-system/packages/backend/src/services/ssh_key_service.ts +20 -6
  8. package/templates/assistkick-product-system/packages/backend/src/services/workflow_service.ts +30 -7
  9. package/templates/assistkick-product-system/packages/frontend/src/api/client.ts +2 -2
  10. package/templates/assistkick-product-system/packages/frontend/src/components/IterationCommentModal.tsx +80 -0
  11. package/templates/assistkick-product-system/packages/frontend/src/components/KanbanView.tsx +67 -2
  12. package/templates/assistkick-product-system/packages/frontend/src/components/workflow/GenerateTTSNode.tsx +52 -0
  13. package/templates/assistkick-product-system/packages/frontend/src/components/workflow/RebuildBundleNode.tsx +20 -0
  14. package/templates/assistkick-product-system/packages/frontend/src/components/workflow/RenderVideoNode.tsx +72 -0
  15. package/templates/assistkick-product-system/packages/frontend/src/components/workflow/WorkflowCanvas.tsx +6 -0
  16. package/templates/assistkick-product-system/packages/frontend/src/components/workflow/WorkflowMonitorModal.tsx +9 -0
  17. package/templates/assistkick-product-system/packages/frontend/src/components/workflow/monitor_nodes.tsx +39 -1
  18. package/templates/assistkick-product-system/packages/frontend/src/components/workflow/workflow_types.ts +30 -1
  19. package/templates/assistkick-product-system/packages/shared/db/migrations/0014_nifty_punisher.sql +15 -0
  20. package/templates/assistkick-product-system/packages/shared/db/migrations/meta/0014_snapshot.json +1545 -0
  21. package/templates/assistkick-product-system/packages/shared/db/migrations/meta/_journal.json +7 -0
  22. package/templates/assistkick-product-system/packages/shared/db/schema.ts +1 -0
  23. package/templates/assistkick-product-system/packages/shared/lib/claude-service.ts +1 -1
  24. package/templates/assistkick-product-system/packages/shared/lib/workflow_engine.test.ts +247 -1
  25. package/templates/assistkick-product-system/packages/shared/lib/workflow_engine.ts +158 -2
  26. package/templates/assistkick-product-system/tests/video_render_service.test.ts +6 -4
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@assistkick/create",
3
- "version": "1.8.0",
3
+ "version": "1.10.0",
4
4
  "description": "Scaffold assistkick-product-system into any project",
5
5
  "type": "module",
6
6
  "bin": {
@@ -241,7 +241,7 @@ export const createGitRoutes = ({ projectService, githubAppService, workspaceSer
241
241
 
242
242
  try {
243
243
  if (!sshKeyService.isConfigured()) {
244
- res.status(400).json({ error: 'ENCRYPTION_KEY environment variable is not configured' });
244
+ res.status(400).json({ error: `ENCRYPTION_KEY environment variable is not configured. ${SshKeyService.KEY_HELP}` });
245
245
  return;
246
246
  }
247
247
 
@@ -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
- log('WORKFLOWS', `GET /api/workflows projectId=${projectId || 'none'}`);
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
 
@@ -30,15 +30,29 @@ export class SshKeyService {
30
30
  this.log = log;
31
31
  }
32
32
 
33
+ static readonly KEY_HELP = 'Set ENCRYPTION_KEY in your .env file (any string with at least 32 characters). Generate one with: openssl rand -hex 32';
34
+
35
+ /** Parse ENCRYPTION_KEY: try hex first, then base64, then use raw UTF-8 bytes via SHA-256. */
33
36
  private getEncryptionKey = (): Buffer => {
34
37
  const key = process.env.ENCRYPTION_KEY;
35
- if (!key) throw new Error('ENCRYPTION_KEY environment variable is not set');
36
- // Expect a 64-char hex string (32 bytes)
37
- const buf = Buffer.from(key, 'hex');
38
- if (buf.length !== 32) {
39
- throw new Error('ENCRYPTION_KEY must be a 64-character hex string (32 bytes for AES-256)');
38
+ if (!key) {
39
+ throw new Error(`ENCRYPTION_KEY environment variable is not set. ${SshKeyService.KEY_HELP}`);
40
+ }
41
+ if (key.length < 32) {
42
+ throw new Error(`ENCRYPTION_KEY must be at least 32 characters. ${SshKeyService.KEY_HELP}`);
43
+ }
44
+ // Try hex (64-char hex = 32 bytes)
45
+ if (/^[0-9a-fA-F]{64}$/.test(key)) {
46
+ return Buffer.from(key, 'hex');
47
+ }
48
+ // Try base64 (44 chars with padding = 32 bytes)
49
+ if (/^[A-Za-z0-9+/]{43}=?$/.test(key)) {
50
+ const buf = Buffer.from(key, 'base64');
51
+ if (buf.length === 32) return buf;
40
52
  }
41
- return buf;
53
+ // Fallback: SHA-256 hash of the raw string to get exactly 32 bytes
54
+ const { createHash } = require('node:crypto');
55
+ return createHash('sha256').update(key).digest();
42
56
  };
43
57
 
44
58
  /** Generate an ED25519 SSH keypair. */
@@ -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
- list = async (projectId?: string): Promise<WorkflowListItem[]> => {
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 condition = projectId
53
- ? or(isNull(workflows.projectId), eq(workflows.projectId, projectId))
54
- : isNull(workflows.projectId);
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(condition);
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">&times;</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={card.column === 'todo' && !card.devBlocked && !(card.nodeType === 'epic' && playAllRunning && playAllEpicId === card.id)}
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
+ }