claude-kanban 0.1.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/README.md +167 -0
- package/dist/bin/cli.d.ts +1 -0
- package/dist/bin/cli.js +3413 -0
- package/dist/bin/cli.js.map +1 -0
- package/dist/server/index.d.ts +5 -0
- package/dist/server/index.js +3073 -0
- package/dist/server/index.js.map +1 -0
- package/package.json +53 -0
package/dist/bin/cli.js
ADDED
|
@@ -0,0 +1,3413 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// src/bin/cli.ts
|
|
4
|
+
import { Command } from "commander";
|
|
5
|
+
import chalk from "chalk";
|
|
6
|
+
import open from "open";
|
|
7
|
+
|
|
8
|
+
// src/server/index.ts
|
|
9
|
+
import express from "express";
|
|
10
|
+
import { createServer as createHttpServer } from "http";
|
|
11
|
+
import { Server as SocketIOServer } from "socket.io";
|
|
12
|
+
import { join as join5, dirname } from "path";
|
|
13
|
+
import { fileURLToPath } from "url";
|
|
14
|
+
import { existsSync as existsSync3 } from "fs";
|
|
15
|
+
|
|
16
|
+
// src/server/services/executor.ts
|
|
17
|
+
import { spawn } from "child_process";
|
|
18
|
+
import { join as join4 } from "path";
|
|
19
|
+
import { writeFileSync as writeFileSync4, unlinkSync, mkdirSync as mkdirSync2, existsSync as existsSync2, appendFileSync as appendFileSync2, readFileSync as readFileSync4 } from "fs";
|
|
20
|
+
import { EventEmitter } from "events";
|
|
21
|
+
|
|
22
|
+
// src/server/services/project.ts
|
|
23
|
+
import { existsSync, mkdirSync, writeFileSync, readFileSync } from "fs";
|
|
24
|
+
import { join, basename } from "path";
|
|
25
|
+
var KANBAN_DIR = ".claude-kanban";
|
|
26
|
+
var SCRIPTS_DIR = "scripts";
|
|
27
|
+
async function isProjectInitialized(projectPath) {
|
|
28
|
+
const kanbanDir = join(projectPath, KANBAN_DIR);
|
|
29
|
+
const prdPath = join(kanbanDir, "prd.json");
|
|
30
|
+
const configPath = join(kanbanDir, "config.json");
|
|
31
|
+
return existsSync(kanbanDir) && existsSync(prdPath) && existsSync(configPath);
|
|
32
|
+
}
|
|
33
|
+
function detectPackageManager(projectPath) {
|
|
34
|
+
if (existsSync(join(projectPath, "pnpm-lock.yaml"))) return "pnpm";
|
|
35
|
+
if (existsSync(join(projectPath, "yarn.lock"))) return "yarn";
|
|
36
|
+
if (existsSync(join(projectPath, "bun.lockb"))) return "bun";
|
|
37
|
+
return "npm";
|
|
38
|
+
}
|
|
39
|
+
function detectProjectCommands(projectPath) {
|
|
40
|
+
const pm = detectPackageManager(projectPath);
|
|
41
|
+
const run = pm === "npm" ? "npm run" : pm;
|
|
42
|
+
const packageJsonPath = join(projectPath, "package.json");
|
|
43
|
+
let scripts = {};
|
|
44
|
+
if (existsSync(packageJsonPath)) {
|
|
45
|
+
try {
|
|
46
|
+
const pkg = JSON.parse(readFileSync(packageJsonPath, "utf-8"));
|
|
47
|
+
scripts = pkg.scripts || {};
|
|
48
|
+
} catch {
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
const testCommand = scripts.test ? `${run} test` : 'echo "No test command configured"';
|
|
52
|
+
const typecheckCommand = scripts.typecheck ? `${run} typecheck` : scripts["type-check"] ? `${run} type-check` : existsSync(join(projectPath, "tsconfig.json")) ? `${run === "npm run" ? "npx" : pm} tsc --noEmit` : 'echo "No typecheck command configured"';
|
|
53
|
+
const buildCommand = scripts.build ? `${run} build` : 'echo "No build command configured"';
|
|
54
|
+
return { testCommand, typecheckCommand, buildCommand };
|
|
55
|
+
}
|
|
56
|
+
function createInitialPRD(projectName) {
|
|
57
|
+
return {
|
|
58
|
+
version: "1.0",
|
|
59
|
+
projectName,
|
|
60
|
+
tasks: []
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
function createInitialConfig(projectPath) {
|
|
64
|
+
const { testCommand, typecheckCommand, buildCommand } = detectProjectCommands(projectPath);
|
|
65
|
+
return {
|
|
66
|
+
version: "1.0",
|
|
67
|
+
agent: {
|
|
68
|
+
command: "claude",
|
|
69
|
+
permissionMode: "acceptEdits",
|
|
70
|
+
model: null
|
|
71
|
+
},
|
|
72
|
+
project: {
|
|
73
|
+
testCommand,
|
|
74
|
+
typecheckCommand,
|
|
75
|
+
buildCommand
|
|
76
|
+
},
|
|
77
|
+
ui: {
|
|
78
|
+
port: 4242,
|
|
79
|
+
theme: "system"
|
|
80
|
+
},
|
|
81
|
+
execution: {
|
|
82
|
+
maxConcurrent: 3,
|
|
83
|
+
timeout: 30
|
|
84
|
+
// 30 minutes
|
|
85
|
+
}
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
function createRalphScript(config) {
|
|
89
|
+
const { testCommand, typecheckCommand } = config.project;
|
|
90
|
+
return `#!/bin/bash
|
|
91
|
+
set -e
|
|
92
|
+
|
|
93
|
+
if [ -z "$1" ]; then
|
|
94
|
+
echo "Usage: $0 <iterations>"
|
|
95
|
+
exit 1
|
|
96
|
+
fi
|
|
97
|
+
|
|
98
|
+
KANBAN_DIR=".claude-kanban"
|
|
99
|
+
|
|
100
|
+
for ((i=1; i<=$1; i++)); do
|
|
101
|
+
echo ""
|
|
102
|
+
echo "=== Iteration $i of $1 ==="
|
|
103
|
+
echo ""
|
|
104
|
+
|
|
105
|
+
result=$(${config.agent.command} --permission-mode ${config.agent.permissionMode} -p \\
|
|
106
|
+
"@$KANBAN_DIR/prd.json" \\
|
|
107
|
+
"@$KANBAN_DIR/progress.txt" \\
|
|
108
|
+
"You are working on tasks from a Kanban board. Follow these steps:
|
|
109
|
+
|
|
110
|
+
1. Read the PRD to find the highest-priority task with status 'ready'.
|
|
111
|
+
Priority order: critical > high > medium > low
|
|
112
|
+
If multiple tasks have the same priority, pick the first one.
|
|
113
|
+
|
|
114
|
+
2. Work on ONLY that single task. Do not touch other tasks.
|
|
115
|
+
|
|
116
|
+
3. Check that types pass via: ${typecheckCommand}
|
|
117
|
+
Check that tests pass via: ${testCommand}
|
|
118
|
+
|
|
119
|
+
4. When the task is complete:
|
|
120
|
+
- Update the task's 'passes' field to true
|
|
121
|
+
- Update the task's 'status' field to 'completed'
|
|
122
|
+
|
|
123
|
+
5. Append your progress to progress.txt with:
|
|
124
|
+
- Date and time
|
|
125
|
+
- Task ID and title
|
|
126
|
+
- What you implemented
|
|
127
|
+
- Files changed
|
|
128
|
+
- Any notes for future work
|
|
129
|
+
|
|
130
|
+
6. Make a git commit with a descriptive message.
|
|
131
|
+
|
|
132
|
+
IMPORTANT: Only work on ONE task per iteration.
|
|
133
|
+
|
|
134
|
+
If all tasks with status 'ready' are complete (passes: true), output <promise>COMPLETE</promise>.")
|
|
135
|
+
|
|
136
|
+
echo "$result"
|
|
137
|
+
|
|
138
|
+
if [[ "$result" == *"<promise>COMPLETE</promise>"* ]]; then
|
|
139
|
+
echo ""
|
|
140
|
+
echo "=== All tasks complete! ==="
|
|
141
|
+
exit 0
|
|
142
|
+
fi
|
|
143
|
+
done
|
|
144
|
+
|
|
145
|
+
echo ""
|
|
146
|
+
echo "=== Completed $1 iterations ==="
|
|
147
|
+
`;
|
|
148
|
+
}
|
|
149
|
+
function createRalphOnceScript(config) {
|
|
150
|
+
const { testCommand, typecheckCommand } = config.project;
|
|
151
|
+
return `#!/bin/bash
|
|
152
|
+
set -e
|
|
153
|
+
|
|
154
|
+
KANBAN_DIR=".claude-kanban"
|
|
155
|
+
TASK_ID="$1"
|
|
156
|
+
|
|
157
|
+
if [ -z "$TASK_ID" ]; then
|
|
158
|
+
# No task ID specified, let Claude pick
|
|
159
|
+
${config.agent.command} --permission-mode ${config.agent.permissionMode} -p \\
|
|
160
|
+
"@$KANBAN_DIR/prd.json" \\
|
|
161
|
+
"@$KANBAN_DIR/progress.txt" \\
|
|
162
|
+
"You are working on tasks from a Kanban board. Follow these steps:
|
|
163
|
+
|
|
164
|
+
1. Read the PRD to find the highest-priority task with status 'ready'.
|
|
165
|
+
Priority order: critical > high > medium > low
|
|
166
|
+
|
|
167
|
+
2. Work on ONLY that single task.
|
|
168
|
+
|
|
169
|
+
3. Check that types pass via: ${typecheckCommand}
|
|
170
|
+
Check that tests pass via: ${testCommand}
|
|
171
|
+
|
|
172
|
+
4. When complete:
|
|
173
|
+
- Update the task's 'passes' field to true
|
|
174
|
+
- Update the task's 'status' field to 'completed'
|
|
175
|
+
|
|
176
|
+
5. Append progress to progress.txt.
|
|
177
|
+
|
|
178
|
+
6. Make a git commit.
|
|
179
|
+
|
|
180
|
+
If all 'ready' tasks are complete, output <promise>COMPLETE</promise>."
|
|
181
|
+
else
|
|
182
|
+
# Specific task ID provided
|
|
183
|
+
${config.agent.command} --permission-mode ${config.agent.permissionMode} -p \\
|
|
184
|
+
"@$KANBAN_DIR/prd.json" \\
|
|
185
|
+
"@$KANBAN_DIR/progress.txt" \\
|
|
186
|
+
"You are working on a specific task from the Kanban board.
|
|
187
|
+
|
|
188
|
+
TASK ID: $TASK_ID
|
|
189
|
+
|
|
190
|
+
Find this task in the PRD and work on it. Follow these steps:
|
|
191
|
+
|
|
192
|
+
1. Locate the task with id '$TASK_ID' in the PRD.
|
|
193
|
+
|
|
194
|
+
2. Implement the feature as described.
|
|
195
|
+
|
|
196
|
+
3. Check that types pass via: ${typecheckCommand}
|
|
197
|
+
Check that tests pass via: ${testCommand}
|
|
198
|
+
|
|
199
|
+
4. When complete:
|
|
200
|
+
- Update the task's 'passes' field to true
|
|
201
|
+
- Update the task's 'status' field to 'completed'
|
|
202
|
+
|
|
203
|
+
5. Append progress to progress.txt.
|
|
204
|
+
|
|
205
|
+
6. Make a git commit.
|
|
206
|
+
|
|
207
|
+
If the task is complete, output <promise>COMPLETE</promise>."
|
|
208
|
+
fi
|
|
209
|
+
`;
|
|
210
|
+
}
|
|
211
|
+
async function initializeProject(projectPath, reset = false) {
|
|
212
|
+
const kanbanDir = join(projectPath, KANBAN_DIR);
|
|
213
|
+
const scriptsDir = join(projectPath, SCRIPTS_DIR);
|
|
214
|
+
const projectName = basename(projectPath);
|
|
215
|
+
if (!existsSync(kanbanDir)) {
|
|
216
|
+
mkdirSync(kanbanDir, { recursive: true });
|
|
217
|
+
}
|
|
218
|
+
if (!existsSync(scriptsDir)) {
|
|
219
|
+
mkdirSync(scriptsDir, { recursive: true });
|
|
220
|
+
}
|
|
221
|
+
const prdPath = join(kanbanDir, "prd.json");
|
|
222
|
+
if (!existsSync(prdPath) || reset) {
|
|
223
|
+
const prd = createInitialPRD(projectName);
|
|
224
|
+
writeFileSync(prdPath, JSON.stringify(prd, null, 2));
|
|
225
|
+
}
|
|
226
|
+
const configPath = join(kanbanDir, "config.json");
|
|
227
|
+
let config;
|
|
228
|
+
if (!existsSync(configPath)) {
|
|
229
|
+
config = createInitialConfig(projectPath);
|
|
230
|
+
writeFileSync(configPath, JSON.stringify(config, null, 2));
|
|
231
|
+
} else {
|
|
232
|
+
config = JSON.parse(readFileSync(configPath, "utf-8"));
|
|
233
|
+
}
|
|
234
|
+
const progressPath = join(kanbanDir, "progress.txt");
|
|
235
|
+
if (!existsSync(progressPath)) {
|
|
236
|
+
const date = (/* @__PURE__ */ new Date()).toISOString().split("T")[0];
|
|
237
|
+
writeFileSync(progressPath, `# Claude Kanban Progress Log
|
|
238
|
+
|
|
239
|
+
Project: ${projectName}
|
|
240
|
+
Created: ${date}
|
|
241
|
+
|
|
242
|
+
---
|
|
243
|
+
|
|
244
|
+
`);
|
|
245
|
+
}
|
|
246
|
+
const ralphPath = join(scriptsDir, "ralph.sh");
|
|
247
|
+
const ralphOncePath = join(scriptsDir, "ralph-once.sh");
|
|
248
|
+
writeFileSync(ralphPath, createRalphScript(config));
|
|
249
|
+
writeFileSync(ralphOncePath, createRalphOnceScript(config));
|
|
250
|
+
try {
|
|
251
|
+
const { chmodSync } = await import("fs");
|
|
252
|
+
chmodSync(ralphPath, 493);
|
|
253
|
+
chmodSync(ralphOncePath, 493);
|
|
254
|
+
} catch {
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
function getConfig(projectPath) {
|
|
258
|
+
const configPath = join(projectPath, KANBAN_DIR, "config.json");
|
|
259
|
+
return JSON.parse(readFileSync(configPath, "utf-8"));
|
|
260
|
+
}
|
|
261
|
+
function saveConfig(projectPath, config) {
|
|
262
|
+
const configPath = join(projectPath, KANBAN_DIR, "config.json");
|
|
263
|
+
writeFileSync(configPath, JSON.stringify(config, null, 2));
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
// src/server/services/prd.ts
|
|
267
|
+
import { readFileSync as readFileSync2, writeFileSync as writeFileSync2 } from "fs";
|
|
268
|
+
import { join as join2 } from "path";
|
|
269
|
+
import { nanoid } from "nanoid";
|
|
270
|
+
var KANBAN_DIR2 = ".claude-kanban";
|
|
271
|
+
function getPRDPath(projectPath) {
|
|
272
|
+
return join2(projectPath, KANBAN_DIR2, "prd.json");
|
|
273
|
+
}
|
|
274
|
+
function readPRD(projectPath) {
|
|
275
|
+
const path = getPRDPath(projectPath);
|
|
276
|
+
return JSON.parse(readFileSync2(path, "utf-8"));
|
|
277
|
+
}
|
|
278
|
+
function writePRD(projectPath, prd) {
|
|
279
|
+
const path = getPRDPath(projectPath);
|
|
280
|
+
writeFileSync2(path, JSON.stringify(prd, null, 2));
|
|
281
|
+
}
|
|
282
|
+
function getAllTasks(projectPath) {
|
|
283
|
+
const prd = readPRD(projectPath);
|
|
284
|
+
return prd.tasks;
|
|
285
|
+
}
|
|
286
|
+
function getTaskById(projectPath, taskId) {
|
|
287
|
+
const prd = readPRD(projectPath);
|
|
288
|
+
return prd.tasks.find((t) => t.id === taskId);
|
|
289
|
+
}
|
|
290
|
+
function getTasksByStatus(projectPath, status) {
|
|
291
|
+
const prd = readPRD(projectPath);
|
|
292
|
+
return prd.tasks.filter((t) => t.status === status);
|
|
293
|
+
}
|
|
294
|
+
function createTask(projectPath, request) {
|
|
295
|
+
const prd = readPRD(projectPath);
|
|
296
|
+
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
297
|
+
const task = {
|
|
298
|
+
id: `task_${nanoid(8)}`,
|
|
299
|
+
title: request.title,
|
|
300
|
+
description: request.description,
|
|
301
|
+
category: request.category || "functional",
|
|
302
|
+
priority: request.priority || "medium",
|
|
303
|
+
status: request.status || "draft",
|
|
304
|
+
steps: request.steps || [],
|
|
305
|
+
passes: false,
|
|
306
|
+
createdAt: now,
|
|
307
|
+
updatedAt: now,
|
|
308
|
+
executionHistory: []
|
|
309
|
+
};
|
|
310
|
+
prd.tasks.push(task);
|
|
311
|
+
writePRD(projectPath, prd);
|
|
312
|
+
return task;
|
|
313
|
+
}
|
|
314
|
+
function updateTask(projectPath, taskId, updates) {
|
|
315
|
+
const prd = readPRD(projectPath);
|
|
316
|
+
const taskIndex = prd.tasks.findIndex((t) => t.id === taskId);
|
|
317
|
+
if (taskIndex === -1) {
|
|
318
|
+
return null;
|
|
319
|
+
}
|
|
320
|
+
const task = prd.tasks[taskIndex];
|
|
321
|
+
const updatedTask = {
|
|
322
|
+
...task,
|
|
323
|
+
...updates,
|
|
324
|
+
updatedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
325
|
+
};
|
|
326
|
+
prd.tasks[taskIndex] = updatedTask;
|
|
327
|
+
writePRD(projectPath, prd);
|
|
328
|
+
return updatedTask;
|
|
329
|
+
}
|
|
330
|
+
function deleteTask(projectPath, taskId) {
|
|
331
|
+
const prd = readPRD(projectPath);
|
|
332
|
+
const initialLength = prd.tasks.length;
|
|
333
|
+
prd.tasks = prd.tasks.filter((t) => t.id !== taskId);
|
|
334
|
+
if (prd.tasks.length < initialLength) {
|
|
335
|
+
writePRD(projectPath, prd);
|
|
336
|
+
return true;
|
|
337
|
+
}
|
|
338
|
+
return false;
|
|
339
|
+
}
|
|
340
|
+
function addExecutionEntry(projectPath, taskId, entry) {
|
|
341
|
+
const prd = readPRD(projectPath);
|
|
342
|
+
const taskIndex = prd.tasks.findIndex((t) => t.id === taskId);
|
|
343
|
+
if (taskIndex === -1) {
|
|
344
|
+
return null;
|
|
345
|
+
}
|
|
346
|
+
prd.tasks[taskIndex].executionHistory.push(entry);
|
|
347
|
+
prd.tasks[taskIndex].updatedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
348
|
+
writePRD(projectPath, prd);
|
|
349
|
+
return prd.tasks[taskIndex];
|
|
350
|
+
}
|
|
351
|
+
function getNextReadyTask(projectPath) {
|
|
352
|
+
const readyTasks = getTasksByStatus(projectPath, "ready");
|
|
353
|
+
if (readyTasks.length === 0) {
|
|
354
|
+
return null;
|
|
355
|
+
}
|
|
356
|
+
const priorityOrder = {
|
|
357
|
+
critical: 0,
|
|
358
|
+
high: 1,
|
|
359
|
+
medium: 2,
|
|
360
|
+
low: 3
|
|
361
|
+
};
|
|
362
|
+
readyTasks.sort((a, b) => {
|
|
363
|
+
const priorityDiff = (priorityOrder[a.priority] || 2) - (priorityOrder[b.priority] || 2);
|
|
364
|
+
if (priorityDiff !== 0) return priorityDiff;
|
|
365
|
+
return new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime();
|
|
366
|
+
});
|
|
367
|
+
return readyTasks[0];
|
|
368
|
+
}
|
|
369
|
+
function getTaskCounts(projectPath) {
|
|
370
|
+
const tasks = getAllTasks(projectPath);
|
|
371
|
+
const counts = {
|
|
372
|
+
draft: 0,
|
|
373
|
+
ready: 0,
|
|
374
|
+
in_progress: 0,
|
|
375
|
+
completed: 0,
|
|
376
|
+
failed: 0
|
|
377
|
+
};
|
|
378
|
+
for (const task of tasks) {
|
|
379
|
+
counts[task.status]++;
|
|
380
|
+
}
|
|
381
|
+
return counts;
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
// src/server/services/progress.ts
|
|
385
|
+
import { readFileSync as readFileSync3, writeFileSync as writeFileSync3, appendFileSync } from "fs";
|
|
386
|
+
import { join as join3 } from "path";
|
|
387
|
+
var KANBAN_DIR3 = ".claude-kanban";
|
|
388
|
+
function getProgressPath(projectPath) {
|
|
389
|
+
return join3(projectPath, KANBAN_DIR3, "progress.txt");
|
|
390
|
+
}
|
|
391
|
+
function readProgress(projectPath) {
|
|
392
|
+
const path = getProgressPath(projectPath);
|
|
393
|
+
return readFileSync3(path, "utf-8");
|
|
394
|
+
}
|
|
395
|
+
function appendProgress(projectPath, entry) {
|
|
396
|
+
const path = getProgressPath(projectPath);
|
|
397
|
+
appendFileSync(path, entry + "\n");
|
|
398
|
+
}
|
|
399
|
+
function logTaskExecution(projectPath, options) {
|
|
400
|
+
const now = /* @__PURE__ */ new Date();
|
|
401
|
+
const dateStr = now.toISOString().split("T")[0];
|
|
402
|
+
const timeStr = now.toTimeString().split(" ")[0];
|
|
403
|
+
const durationStr = formatDuration(options.duration);
|
|
404
|
+
const statusEmoji = options.status === "completed" ? "\u2713" : options.status === "failed" ? "\u2717" : "\u25CB";
|
|
405
|
+
let entry = `
|
|
406
|
+
## ${dateStr} ${timeStr}
|
|
407
|
+
|
|
408
|
+
`;
|
|
409
|
+
entry += `### Task: ${options.taskId} - ${options.taskTitle}
|
|
410
|
+
`;
|
|
411
|
+
entry += `Status: ${statusEmoji} ${options.status.toUpperCase()}
|
|
412
|
+
`;
|
|
413
|
+
entry += `Duration: ${durationStr}
|
|
414
|
+
`;
|
|
415
|
+
if (options.error) {
|
|
416
|
+
entry += `
|
|
417
|
+
Error: ${options.error}
|
|
418
|
+
`;
|
|
419
|
+
}
|
|
420
|
+
entry += "\n---\n";
|
|
421
|
+
appendProgress(projectPath, entry);
|
|
422
|
+
}
|
|
423
|
+
function formatDuration(ms) {
|
|
424
|
+
const seconds = Math.floor(ms / 1e3);
|
|
425
|
+
const minutes = Math.floor(seconds / 60);
|
|
426
|
+
const hours = Math.floor(minutes / 60);
|
|
427
|
+
if (hours > 0) {
|
|
428
|
+
return `${hours}h ${minutes % 60}m ${seconds % 60}s`;
|
|
429
|
+
}
|
|
430
|
+
if (minutes > 0) {
|
|
431
|
+
return `${minutes}m ${seconds % 60}s`;
|
|
432
|
+
}
|
|
433
|
+
return `${seconds}s`;
|
|
434
|
+
}
|
|
435
|
+
function getRecentProgress(projectPath, lines = 100) {
|
|
436
|
+
const content = readProgress(projectPath);
|
|
437
|
+
const allLines = content.split("\n");
|
|
438
|
+
if (allLines.length <= lines) {
|
|
439
|
+
return content;
|
|
440
|
+
}
|
|
441
|
+
return allLines.slice(-lines).join("\n");
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
// src/server/services/executor.ts
|
|
445
|
+
var KANBAN_DIR4 = ".claude-kanban";
|
|
446
|
+
var LOGS_DIR = "logs";
|
|
447
|
+
var TaskExecutor = class extends EventEmitter {
|
|
448
|
+
projectPath;
|
|
449
|
+
runningTasks = /* @__PURE__ */ new Map();
|
|
450
|
+
afkMode = false;
|
|
451
|
+
afkIteration = 0;
|
|
452
|
+
afkMaxIterations = 0;
|
|
453
|
+
afkTasksCompleted = 0;
|
|
454
|
+
constructor(projectPath) {
|
|
455
|
+
super();
|
|
456
|
+
this.projectPath = projectPath;
|
|
457
|
+
this.ensureLogsDir();
|
|
458
|
+
}
|
|
459
|
+
/**
|
|
460
|
+
* Ensure logs directory exists
|
|
461
|
+
*/
|
|
462
|
+
ensureLogsDir() {
|
|
463
|
+
const logsPath = join4(this.projectPath, KANBAN_DIR4, LOGS_DIR);
|
|
464
|
+
if (!existsSync2(logsPath)) {
|
|
465
|
+
mkdirSync2(logsPath, { recursive: true });
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
/**
|
|
469
|
+
* Get log file path for a task
|
|
470
|
+
*/
|
|
471
|
+
getLogFilePath(taskId) {
|
|
472
|
+
return join4(this.projectPath, KANBAN_DIR4, LOGS_DIR, `${taskId}.log`);
|
|
473
|
+
}
|
|
474
|
+
/**
|
|
475
|
+
* Initialize log file for a task (clear existing)
|
|
476
|
+
*/
|
|
477
|
+
initLogFile(taskId) {
|
|
478
|
+
const logPath = this.getLogFilePath(taskId);
|
|
479
|
+
writeFileSync4(logPath, "");
|
|
480
|
+
}
|
|
481
|
+
/**
|
|
482
|
+
* Append to task log file
|
|
483
|
+
*/
|
|
484
|
+
appendToLog(taskId, text) {
|
|
485
|
+
const logPath = this.getLogFilePath(taskId);
|
|
486
|
+
appendFileSync2(logPath, text);
|
|
487
|
+
}
|
|
488
|
+
/**
|
|
489
|
+
* Read task log file
|
|
490
|
+
*/
|
|
491
|
+
getTaskLog(taskId) {
|
|
492
|
+
const logPath = this.getLogFilePath(taskId);
|
|
493
|
+
if (!existsSync2(logPath)) return null;
|
|
494
|
+
return readFileSync4(logPath, "utf-8");
|
|
495
|
+
}
|
|
496
|
+
/**
|
|
497
|
+
* Get number of currently running tasks
|
|
498
|
+
*/
|
|
499
|
+
getRunningCount() {
|
|
500
|
+
return this.runningTasks.size;
|
|
501
|
+
}
|
|
502
|
+
/**
|
|
503
|
+
* Check if a task is running
|
|
504
|
+
*/
|
|
505
|
+
isTaskRunning(taskId) {
|
|
506
|
+
return this.runningTasks.has(taskId);
|
|
507
|
+
}
|
|
508
|
+
/**
|
|
509
|
+
* Get all running task IDs
|
|
510
|
+
*/
|
|
511
|
+
getRunningTaskIds() {
|
|
512
|
+
return Array.from(this.runningTasks.keys());
|
|
513
|
+
}
|
|
514
|
+
/**
|
|
515
|
+
* Get running task output
|
|
516
|
+
*/
|
|
517
|
+
getTaskOutput(taskId) {
|
|
518
|
+
return this.runningTasks.get(taskId)?.output;
|
|
519
|
+
}
|
|
520
|
+
/**
|
|
521
|
+
* Build the prompt for a specific task
|
|
522
|
+
*/
|
|
523
|
+
buildTaskPrompt(task, config) {
|
|
524
|
+
const kanbanDir = join4(this.projectPath, KANBAN_DIR4);
|
|
525
|
+
const prdPath = join4(kanbanDir, "prd.json");
|
|
526
|
+
const progressPath = join4(kanbanDir, "progress.txt");
|
|
527
|
+
const stepsText = task.steps.length > 0 ? `
|
|
528
|
+
Verification steps:
|
|
529
|
+
${task.steps.map((s, i) => `${i + 1}. ${s}`).join("\n")}` : "";
|
|
530
|
+
return `You are an AI coding agent. Complete the following task:
|
|
531
|
+
|
|
532
|
+
## TASK
|
|
533
|
+
Title: ${task.title}
|
|
534
|
+
Category: ${task.category}
|
|
535
|
+
Priority: ${task.priority}
|
|
536
|
+
|
|
537
|
+
${task.description}
|
|
538
|
+
${stepsText}
|
|
539
|
+
|
|
540
|
+
## INSTRUCTIONS
|
|
541
|
+
1. Implement this task as described above.
|
|
542
|
+
|
|
543
|
+
2. Verify your work:
|
|
544
|
+
- Run typecheck: ${config.project.typecheckCommand}
|
|
545
|
+
- Run tests: ${config.project.testCommand}
|
|
546
|
+
|
|
547
|
+
3. When complete, update the task in ${prdPath}:
|
|
548
|
+
- Find the task with id "${task.id}"
|
|
549
|
+
- Set "passes": true
|
|
550
|
+
- Set "status": "completed"
|
|
551
|
+
|
|
552
|
+
4. Document your work in ${progressPath}:
|
|
553
|
+
- What you implemented and files changed
|
|
554
|
+
- Key decisions made and why
|
|
555
|
+
- Gotchas, edge cases, or tricky parts discovered
|
|
556
|
+
- Useful patterns or approaches that worked well
|
|
557
|
+
- Anything a future agent should know about this area of the codebase
|
|
558
|
+
|
|
559
|
+
5. Make a git commit with a descriptive message.
|
|
560
|
+
|
|
561
|
+
Focus only on this task. When successfully complete, output: <promise>COMPLETE</promise>`;
|
|
562
|
+
}
|
|
563
|
+
/**
|
|
564
|
+
* Run a specific task
|
|
565
|
+
*/
|
|
566
|
+
async runTask(taskId) {
|
|
567
|
+
const config = getConfig(this.projectPath);
|
|
568
|
+
const task = getTaskById(this.projectPath, taskId);
|
|
569
|
+
if (!task) {
|
|
570
|
+
throw new Error(`Task not found: ${taskId}`);
|
|
571
|
+
}
|
|
572
|
+
if (this.isTaskRunning(taskId)) {
|
|
573
|
+
throw new Error(`Task already running: ${taskId}`);
|
|
574
|
+
}
|
|
575
|
+
const maxConcurrent = config.execution.maxConcurrent || 3;
|
|
576
|
+
if (this.getRunningCount() >= maxConcurrent) {
|
|
577
|
+
throw new Error(`Maximum concurrent tasks (${maxConcurrent}) reached`);
|
|
578
|
+
}
|
|
579
|
+
updateTask(this.projectPath, taskId, { status: "in_progress" });
|
|
580
|
+
const startedAt = /* @__PURE__ */ new Date();
|
|
581
|
+
const prompt = this.buildTaskPrompt(task, config);
|
|
582
|
+
const kanbanDir = join4(this.projectPath, KANBAN_DIR4);
|
|
583
|
+
const prdPath = join4(kanbanDir, "prd.json");
|
|
584
|
+
const progressPath = join4(kanbanDir, "progress.txt");
|
|
585
|
+
const promptFile = join4(kanbanDir, `prompt-${taskId}.txt`);
|
|
586
|
+
writeFileSync4(promptFile, prompt);
|
|
587
|
+
const args = [];
|
|
588
|
+
if (config.agent.model) {
|
|
589
|
+
args.push("--model", config.agent.model);
|
|
590
|
+
}
|
|
591
|
+
args.push("--permission-mode", config.agent.permissionMode);
|
|
592
|
+
args.push("-p");
|
|
593
|
+
args.push("--verbose");
|
|
594
|
+
args.push("--output-format", "stream-json");
|
|
595
|
+
args.push(`@${promptFile}`);
|
|
596
|
+
const commandDisplay = `${config.agent.command} ${args.join(" ")}`;
|
|
597
|
+
const fullCommand = `${config.agent.command} ${args.join(" ")}`;
|
|
598
|
+
console.log("[executor] Command:", fullCommand);
|
|
599
|
+
console.log("[executor] CWD:", this.projectPath);
|
|
600
|
+
const childProcess = spawn("bash", ["-c", fullCommand], {
|
|
601
|
+
cwd: this.projectPath,
|
|
602
|
+
env: {
|
|
603
|
+
...process.env,
|
|
604
|
+
TERM: "xterm-256color",
|
|
605
|
+
FORCE_COLOR: "0",
|
|
606
|
+
// Disable colors to avoid escape codes
|
|
607
|
+
NO_COLOR: "1"
|
|
608
|
+
// Standard way to disable colors
|
|
609
|
+
},
|
|
610
|
+
stdio: ["ignore", "pipe", "pipe"]
|
|
611
|
+
// Close stdin since we don't need interactive input
|
|
612
|
+
});
|
|
613
|
+
const runningTask = {
|
|
614
|
+
taskId,
|
|
615
|
+
process: childProcess,
|
|
616
|
+
startedAt,
|
|
617
|
+
output: []
|
|
618
|
+
};
|
|
619
|
+
this.runningTasks.set(taskId, runningTask);
|
|
620
|
+
this.initLogFile(taskId);
|
|
621
|
+
const logOutput = (line) => {
|
|
622
|
+
this.appendToLog(taskId, line);
|
|
623
|
+
runningTask.output.push(line);
|
|
624
|
+
this.emit("task:output", { taskId, line, lineType: "stdout" });
|
|
625
|
+
};
|
|
626
|
+
this.emit("task:started", { taskId, timestamp: startedAt.toISOString() });
|
|
627
|
+
logOutput(`[claude-kanban] Starting task: ${task.title}
|
|
628
|
+
`);
|
|
629
|
+
logOutput(`[claude-kanban] Command: ${commandDisplay}
|
|
630
|
+
`);
|
|
631
|
+
logOutput(`[claude-kanban] Process spawned (PID: ${childProcess.pid})
|
|
632
|
+
`);
|
|
633
|
+
let stdoutBuffer = "";
|
|
634
|
+
childProcess.stdout?.on("data", (data) => {
|
|
635
|
+
stdoutBuffer += data.toString();
|
|
636
|
+
const lines = stdoutBuffer.split("\n");
|
|
637
|
+
stdoutBuffer = lines.pop() || "";
|
|
638
|
+
for (const line of lines) {
|
|
639
|
+
if (!line.trim()) continue;
|
|
640
|
+
try {
|
|
641
|
+
const json = JSON.parse(line);
|
|
642
|
+
let text = "";
|
|
643
|
+
if (json.type === "assistant" && json.message?.content) {
|
|
644
|
+
for (const block of json.message.content) {
|
|
645
|
+
if (block.type === "text") {
|
|
646
|
+
text += block.text;
|
|
647
|
+
} else if (block.type === "tool_use") {
|
|
648
|
+
text += `[Tool: ${block.name}]
|
|
649
|
+
`;
|
|
650
|
+
}
|
|
651
|
+
}
|
|
652
|
+
} else if (json.type === "content_block_delta" && json.delta?.text) {
|
|
653
|
+
text = json.delta.text;
|
|
654
|
+
} else if (json.type === "result" && json.result) {
|
|
655
|
+
text = `
|
|
656
|
+
[Result: ${json.result}]
|
|
657
|
+
`;
|
|
658
|
+
}
|
|
659
|
+
if (text) {
|
|
660
|
+
logOutput(text);
|
|
661
|
+
}
|
|
662
|
+
} catch {
|
|
663
|
+
const cleanText = line.replace(/\x1B\[[0-9;]*[A-Za-z]/g, "");
|
|
664
|
+
if (cleanText.trim()) {
|
|
665
|
+
logOutput(cleanText + "\n");
|
|
666
|
+
}
|
|
667
|
+
}
|
|
668
|
+
}
|
|
669
|
+
});
|
|
670
|
+
childProcess.stderr?.on("data", (data) => {
|
|
671
|
+
const text = data.toString();
|
|
672
|
+
logOutput(`[stderr] ${text}`);
|
|
673
|
+
});
|
|
674
|
+
childProcess.on("spawn", () => {
|
|
675
|
+
console.log("[executor] Process spawned successfully");
|
|
676
|
+
});
|
|
677
|
+
childProcess.on("error", (error) => {
|
|
678
|
+
console.log("[executor] Spawn error:", error.message);
|
|
679
|
+
this.emit("task:output", { taskId, line: `[claude-kanban] Error: ${error.message}
|
|
680
|
+
`, lineType: "stderr" });
|
|
681
|
+
try {
|
|
682
|
+
unlinkSync(promptFile);
|
|
683
|
+
} catch {
|
|
684
|
+
}
|
|
685
|
+
updateTask(this.projectPath, taskId, { status: "failed", passes: false });
|
|
686
|
+
const endedAt = /* @__PURE__ */ new Date();
|
|
687
|
+
addExecutionEntry(this.projectPath, taskId, {
|
|
688
|
+
startedAt: startedAt.toISOString(),
|
|
689
|
+
endedAt: endedAt.toISOString(),
|
|
690
|
+
status: "failed",
|
|
691
|
+
duration: endedAt.getTime() - startedAt.getTime(),
|
|
692
|
+
error: error.message
|
|
693
|
+
});
|
|
694
|
+
this.emit("task:failed", { taskId, error: error.message });
|
|
695
|
+
this.runningTasks.delete(taskId);
|
|
696
|
+
});
|
|
697
|
+
childProcess.on("close", (code, signal) => {
|
|
698
|
+
console.log("[executor] Process closed with code:", code, "signal:", signal);
|
|
699
|
+
try {
|
|
700
|
+
unlinkSync(promptFile);
|
|
701
|
+
} catch {
|
|
702
|
+
}
|
|
703
|
+
logOutput(`[claude-kanban] Process exited with code ${code}
|
|
704
|
+
`);
|
|
705
|
+
this.handleTaskComplete(taskId, code, startedAt);
|
|
706
|
+
});
|
|
707
|
+
const timeoutMs = (config.execution.timeout || 30) * 60 * 1e3;
|
|
708
|
+
setTimeout(() => {
|
|
709
|
+
if (this.isTaskRunning(taskId)) {
|
|
710
|
+
this.cancelTask(taskId, "Timeout exceeded");
|
|
711
|
+
}
|
|
712
|
+
}, timeoutMs);
|
|
713
|
+
}
|
|
714
|
+
/**
|
|
715
|
+
* Handle task completion
|
|
716
|
+
*/
|
|
717
|
+
handleTaskComplete(taskId, exitCode, startedAt) {
|
|
718
|
+
const runningTask = this.runningTasks.get(taskId);
|
|
719
|
+
if (!runningTask) return;
|
|
720
|
+
const endedAt = /* @__PURE__ */ new Date();
|
|
721
|
+
const duration = endedAt.getTime() - startedAt.getTime();
|
|
722
|
+
const output = runningTask.output.join("");
|
|
723
|
+
const isComplete = output.includes("<promise>COMPLETE</promise>");
|
|
724
|
+
const task = getTaskById(this.projectPath, taskId);
|
|
725
|
+
if (isComplete || exitCode === 0) {
|
|
726
|
+
updateTask(this.projectPath, taskId, {
|
|
727
|
+
status: "completed",
|
|
728
|
+
passes: true
|
|
729
|
+
});
|
|
730
|
+
addExecutionEntry(this.projectPath, taskId, {
|
|
731
|
+
startedAt: startedAt.toISOString(),
|
|
732
|
+
endedAt: endedAt.toISOString(),
|
|
733
|
+
status: "completed",
|
|
734
|
+
duration
|
|
735
|
+
});
|
|
736
|
+
logTaskExecution(this.projectPath, {
|
|
737
|
+
taskId,
|
|
738
|
+
taskTitle: task?.title || "Unknown",
|
|
739
|
+
status: "completed",
|
|
740
|
+
duration
|
|
741
|
+
});
|
|
742
|
+
this.emit("task:completed", { taskId, duration });
|
|
743
|
+
this.afkTasksCompleted++;
|
|
744
|
+
} else {
|
|
745
|
+
updateTask(this.projectPath, taskId, {
|
|
746
|
+
status: "failed",
|
|
747
|
+
passes: false
|
|
748
|
+
});
|
|
749
|
+
const error = `Process exited with code ${exitCode}`;
|
|
750
|
+
addExecutionEntry(this.projectPath, taskId, {
|
|
751
|
+
startedAt: startedAt.toISOString(),
|
|
752
|
+
endedAt: endedAt.toISOString(),
|
|
753
|
+
status: "failed",
|
|
754
|
+
duration,
|
|
755
|
+
error
|
|
756
|
+
});
|
|
757
|
+
logTaskExecution(this.projectPath, {
|
|
758
|
+
taskId,
|
|
759
|
+
taskTitle: task?.title || "Unknown",
|
|
760
|
+
status: "failed",
|
|
761
|
+
duration,
|
|
762
|
+
error
|
|
763
|
+
});
|
|
764
|
+
this.emit("task:failed", { taskId, error });
|
|
765
|
+
}
|
|
766
|
+
this.runningTasks.delete(taskId);
|
|
767
|
+
if (this.afkMode) {
|
|
768
|
+
this.continueAFKMode();
|
|
769
|
+
}
|
|
770
|
+
}
|
|
771
|
+
/**
|
|
772
|
+
* Cancel a running task
|
|
773
|
+
*/
|
|
774
|
+
cancelTask(taskId, reason = "Cancelled by user") {
|
|
775
|
+
const runningTask = this.runningTasks.get(taskId);
|
|
776
|
+
if (!runningTask) return false;
|
|
777
|
+
const startedAt = runningTask.startedAt;
|
|
778
|
+
const endedAt = /* @__PURE__ */ new Date();
|
|
779
|
+
const duration = endedAt.getTime() - startedAt.getTime();
|
|
780
|
+
const task = getTaskById(this.projectPath, taskId);
|
|
781
|
+
try {
|
|
782
|
+
runningTask.process.kill("SIGTERM");
|
|
783
|
+
setTimeout(() => {
|
|
784
|
+
try {
|
|
785
|
+
if (!runningTask.process.killed) {
|
|
786
|
+
runningTask.process.kill("SIGKILL");
|
|
787
|
+
}
|
|
788
|
+
} catch {
|
|
789
|
+
}
|
|
790
|
+
}, 2e3);
|
|
791
|
+
} catch {
|
|
792
|
+
}
|
|
793
|
+
updateTask(this.projectPath, taskId, {
|
|
794
|
+
status: "ready"
|
|
795
|
+
});
|
|
796
|
+
addExecutionEntry(this.projectPath, taskId, {
|
|
797
|
+
startedAt: startedAt.toISOString(),
|
|
798
|
+
endedAt: endedAt.toISOString(),
|
|
799
|
+
status: "cancelled",
|
|
800
|
+
duration,
|
|
801
|
+
error: reason
|
|
802
|
+
});
|
|
803
|
+
logTaskExecution(this.projectPath, {
|
|
804
|
+
taskId,
|
|
805
|
+
taskTitle: task?.title || "Unknown",
|
|
806
|
+
status: "cancelled",
|
|
807
|
+
duration,
|
|
808
|
+
error: reason
|
|
809
|
+
});
|
|
810
|
+
this.emit("task:cancelled", { taskId });
|
|
811
|
+
this.runningTasks.delete(taskId);
|
|
812
|
+
return true;
|
|
813
|
+
}
|
|
814
|
+
/**
|
|
815
|
+
* Start AFK mode
|
|
816
|
+
*/
|
|
817
|
+
startAFKMode(maxIterations, concurrent) {
|
|
818
|
+
if (this.afkMode) {
|
|
819
|
+
throw new Error("AFK mode already running");
|
|
820
|
+
}
|
|
821
|
+
this.afkMode = true;
|
|
822
|
+
this.afkIteration = 0;
|
|
823
|
+
this.afkMaxIterations = maxIterations;
|
|
824
|
+
this.afkTasksCompleted = 0;
|
|
825
|
+
this.emitAFKStatus();
|
|
826
|
+
this.continueAFKMode(concurrent);
|
|
827
|
+
}
|
|
828
|
+
/**
|
|
829
|
+
* Continue AFK mode - pick up next tasks
|
|
830
|
+
*/
|
|
831
|
+
continueAFKMode(concurrent = 1) {
|
|
832
|
+
if (!this.afkMode) return;
|
|
833
|
+
if (this.afkIteration >= this.afkMaxIterations) {
|
|
834
|
+
this.stopAFKMode();
|
|
835
|
+
return;
|
|
836
|
+
}
|
|
837
|
+
const config = getConfig(this.projectPath);
|
|
838
|
+
const maxConcurrent = Math.min(concurrent, config.execution.maxConcurrent || 3);
|
|
839
|
+
while (this.getRunningCount() < maxConcurrent) {
|
|
840
|
+
const nextTask = getNextReadyTask(this.projectPath);
|
|
841
|
+
if (!nextTask) {
|
|
842
|
+
if (this.getRunningCount() === 0) {
|
|
843
|
+
this.stopAFKMode();
|
|
844
|
+
}
|
|
845
|
+
break;
|
|
846
|
+
}
|
|
847
|
+
this.afkIteration++;
|
|
848
|
+
this.runTask(nextTask.id).catch((error) => {
|
|
849
|
+
console.error("AFK task error:", error);
|
|
850
|
+
});
|
|
851
|
+
this.emitAFKStatus();
|
|
852
|
+
}
|
|
853
|
+
}
|
|
854
|
+
/**
|
|
855
|
+
* Stop AFK mode
|
|
856
|
+
*/
|
|
857
|
+
stopAFKMode() {
|
|
858
|
+
this.afkMode = false;
|
|
859
|
+
this.emitAFKStatus();
|
|
860
|
+
}
|
|
861
|
+
/**
|
|
862
|
+
* Emit AFK status
|
|
863
|
+
*/
|
|
864
|
+
emitAFKStatus() {
|
|
865
|
+
this.emit("afk:status", {
|
|
866
|
+
running: this.afkMode,
|
|
867
|
+
currentIteration: this.afkIteration,
|
|
868
|
+
maxIterations: this.afkMaxIterations,
|
|
869
|
+
tasksCompleted: this.afkTasksCompleted
|
|
870
|
+
});
|
|
871
|
+
}
|
|
872
|
+
/**
|
|
873
|
+
* Get AFK status
|
|
874
|
+
*/
|
|
875
|
+
getAFKStatus() {
|
|
876
|
+
return {
|
|
877
|
+
running: this.afkMode,
|
|
878
|
+
currentIteration: this.afkIteration,
|
|
879
|
+
maxIterations: this.afkMaxIterations,
|
|
880
|
+
tasksCompleted: this.afkTasksCompleted
|
|
881
|
+
};
|
|
882
|
+
}
|
|
883
|
+
/**
|
|
884
|
+
* Cancel all running tasks
|
|
885
|
+
*/
|
|
886
|
+
cancelAll() {
|
|
887
|
+
for (const [taskId, runningTask] of this.runningTasks.entries()) {
|
|
888
|
+
try {
|
|
889
|
+
runningTask.process.kill("SIGKILL");
|
|
890
|
+
} catch {
|
|
891
|
+
}
|
|
892
|
+
this.runningTasks.delete(taskId);
|
|
893
|
+
}
|
|
894
|
+
this.stopAFKMode();
|
|
895
|
+
}
|
|
896
|
+
};
|
|
897
|
+
|
|
898
|
+
// src/server/services/templates.ts
|
|
899
|
+
var taskTemplates = [
|
|
900
|
+
{
|
|
901
|
+
id: "auth-login",
|
|
902
|
+
name: "Authentication - Login",
|
|
903
|
+
icon: "\u{1F510}",
|
|
904
|
+
description: "User login functionality with email/password",
|
|
905
|
+
category: "functional",
|
|
906
|
+
priority: "high",
|
|
907
|
+
titleTemplate: "Add user login functionality",
|
|
908
|
+
descriptionTemplate: `Implement user login with email and password authentication.
|
|
909
|
+
|
|
910
|
+
Requirements:
|
|
911
|
+
- Login form with email and password fields
|
|
912
|
+
- Form validation with error messages
|
|
913
|
+
- Submit credentials to auth API
|
|
914
|
+
- Handle success (redirect) and failure (show error)
|
|
915
|
+
- Store auth token/session`,
|
|
916
|
+
stepsTemplate: [
|
|
917
|
+
"Navigate to /login",
|
|
918
|
+
"Verify login form displays with email and password fields",
|
|
919
|
+
"Enter invalid credentials and verify error message",
|
|
920
|
+
"Enter valid credentials and submit",
|
|
921
|
+
"Verify redirect to dashboard/home",
|
|
922
|
+
"Verify auth state persists on page reload"
|
|
923
|
+
]
|
|
924
|
+
},
|
|
925
|
+
{
|
|
926
|
+
id: "auth-signup",
|
|
927
|
+
name: "Authentication - Signup",
|
|
928
|
+
icon: "\u{1F510}",
|
|
929
|
+
description: "User registration with validation",
|
|
930
|
+
category: "functional",
|
|
931
|
+
priority: "high",
|
|
932
|
+
titleTemplate: "Add user signup/registration",
|
|
933
|
+
descriptionTemplate: `Implement user registration with form validation.
|
|
934
|
+
|
|
935
|
+
Requirements:
|
|
936
|
+
- Signup form with name, email, password fields
|
|
937
|
+
- Password confirmation field
|
|
938
|
+
- Client-side validation
|
|
939
|
+
- Submit to registration API
|
|
940
|
+
- Handle success and error states`,
|
|
941
|
+
stepsTemplate: [
|
|
942
|
+
"Navigate to /signup",
|
|
943
|
+
"Verify signup form displays all required fields",
|
|
944
|
+
"Submit with invalid data and verify validation errors",
|
|
945
|
+
"Submit with valid data",
|
|
946
|
+
"Verify success message or redirect",
|
|
947
|
+
"Verify new user can log in"
|
|
948
|
+
]
|
|
949
|
+
},
|
|
950
|
+
{
|
|
951
|
+
id: "auth-logout",
|
|
952
|
+
name: "Authentication - Logout",
|
|
953
|
+
icon: "\u{1F510}",
|
|
954
|
+
description: "User logout functionality",
|
|
955
|
+
category: "functional",
|
|
956
|
+
priority: "medium",
|
|
957
|
+
titleTemplate: "Add user logout functionality",
|
|
958
|
+
descriptionTemplate: `Implement logout functionality.
|
|
959
|
+
|
|
960
|
+
Requirements:
|
|
961
|
+
- Logout button/link in navigation
|
|
962
|
+
- Clear auth token/session on logout
|
|
963
|
+
- Redirect to login or home page
|
|
964
|
+
- Protect routes after logout`,
|
|
965
|
+
stepsTemplate: [
|
|
966
|
+
"Log in as a user",
|
|
967
|
+
"Click logout button",
|
|
968
|
+
"Verify redirect to login/home page",
|
|
969
|
+
"Verify auth state is cleared",
|
|
970
|
+
"Verify protected routes redirect to login"
|
|
971
|
+
]
|
|
972
|
+
},
|
|
973
|
+
{
|
|
974
|
+
id: "crud-create",
|
|
975
|
+
name: "CRUD - Create",
|
|
976
|
+
icon: "\u{1F4DD}",
|
|
977
|
+
description: "Create new entity form",
|
|
978
|
+
category: "functional",
|
|
979
|
+
priority: "medium",
|
|
980
|
+
titleTemplate: "Add create [Entity] form",
|
|
981
|
+
descriptionTemplate: `Implement form to create a new [Entity].
|
|
982
|
+
|
|
983
|
+
Requirements:
|
|
984
|
+
- Form with all required fields
|
|
985
|
+
- Client-side validation
|
|
986
|
+
- Submit to create API endpoint
|
|
987
|
+
- Handle loading, success, and error states
|
|
988
|
+
- Redirect or show success message after creation`,
|
|
989
|
+
stepsTemplate: [
|
|
990
|
+
"Navigate to create form",
|
|
991
|
+
"Verify all form fields display correctly",
|
|
992
|
+
"Submit empty form and verify validation",
|
|
993
|
+
"Fill in valid data and submit",
|
|
994
|
+
"Verify entity is created",
|
|
995
|
+
"Verify redirect or success message"
|
|
996
|
+
]
|
|
997
|
+
},
|
|
998
|
+
{
|
|
999
|
+
id: "crud-read",
|
|
1000
|
+
name: "CRUD - Read/List",
|
|
1001
|
+
icon: "\u{1F4DD}",
|
|
1002
|
+
description: "List and view entities",
|
|
1003
|
+
category: "functional",
|
|
1004
|
+
priority: "medium",
|
|
1005
|
+
titleTemplate: "Add [Entity] list view",
|
|
1006
|
+
descriptionTemplate: `Implement list view for [Entity] items.
|
|
1007
|
+
|
|
1008
|
+
Requirements:
|
|
1009
|
+
- Fetch and display list of entities
|
|
1010
|
+
- Show loading state
|
|
1011
|
+
- Handle empty state
|
|
1012
|
+
- Display relevant fields for each item
|
|
1013
|
+
- Link to detail view`,
|
|
1014
|
+
stepsTemplate: [
|
|
1015
|
+
"Navigate to list page",
|
|
1016
|
+
"Verify loading state displays",
|
|
1017
|
+
"Verify items render correctly",
|
|
1018
|
+
"Verify empty state when no items",
|
|
1019
|
+
"Click item and verify navigation to detail"
|
|
1020
|
+
]
|
|
1021
|
+
},
|
|
1022
|
+
{
|
|
1023
|
+
id: "crud-update",
|
|
1024
|
+
name: "CRUD - Update",
|
|
1025
|
+
icon: "\u{1F4DD}",
|
|
1026
|
+
description: "Edit existing entity",
|
|
1027
|
+
category: "functional",
|
|
1028
|
+
priority: "medium",
|
|
1029
|
+
titleTemplate: "Add edit [Entity] form",
|
|
1030
|
+
descriptionTemplate: `Implement form to edit an existing [Entity].
|
|
1031
|
+
|
|
1032
|
+
Requirements:
|
|
1033
|
+
- Pre-populate form with existing data
|
|
1034
|
+
- Allow modification of fields
|
|
1035
|
+
- Submit changes to update API
|
|
1036
|
+
- Handle validation and errors
|
|
1037
|
+
- Show success feedback`,
|
|
1038
|
+
stepsTemplate: [
|
|
1039
|
+
"Navigate to edit form for existing item",
|
|
1040
|
+
"Verify form pre-populates with current data",
|
|
1041
|
+
"Modify fields",
|
|
1042
|
+
"Submit form",
|
|
1043
|
+
"Verify changes are saved",
|
|
1044
|
+
"Verify updated data displays correctly"
|
|
1045
|
+
]
|
|
1046
|
+
},
|
|
1047
|
+
{
|
|
1048
|
+
id: "crud-delete",
|
|
1049
|
+
name: "CRUD - Delete",
|
|
1050
|
+
icon: "\u{1F4DD}",
|
|
1051
|
+
description: "Delete entity with confirmation",
|
|
1052
|
+
category: "functional",
|
|
1053
|
+
priority: "medium",
|
|
1054
|
+
titleTemplate: "Add delete [Entity] functionality",
|
|
1055
|
+
descriptionTemplate: `Implement delete functionality for [Entity].
|
|
1056
|
+
|
|
1057
|
+
Requirements:
|
|
1058
|
+
- Delete button on item or list
|
|
1059
|
+
- Confirmation dialog before delete
|
|
1060
|
+
- Call delete API endpoint
|
|
1061
|
+
- Remove item from UI on success
|
|
1062
|
+
- Handle errors gracefully`,
|
|
1063
|
+
stepsTemplate: [
|
|
1064
|
+
"Navigate to item with delete option",
|
|
1065
|
+
"Click delete button",
|
|
1066
|
+
"Verify confirmation dialog appears",
|
|
1067
|
+
"Confirm deletion",
|
|
1068
|
+
"Verify item is removed from list",
|
|
1069
|
+
"Verify item no longer accessible"
|
|
1070
|
+
]
|
|
1071
|
+
},
|
|
1072
|
+
{
|
|
1073
|
+
id: "api-endpoint",
|
|
1074
|
+
name: "API Endpoint",
|
|
1075
|
+
icon: "\u{1F310}",
|
|
1076
|
+
description: "REST API endpoint with validation",
|
|
1077
|
+
category: "functional",
|
|
1078
|
+
priority: "medium",
|
|
1079
|
+
titleTemplate: "Add [METHOD] /api/[resource] endpoint",
|
|
1080
|
+
descriptionTemplate: `Implement REST API endpoint.
|
|
1081
|
+
|
|
1082
|
+
Requirements:
|
|
1083
|
+
- Define route handler
|
|
1084
|
+
- Validate request body/params
|
|
1085
|
+
- Implement business logic
|
|
1086
|
+
- Return appropriate response codes
|
|
1087
|
+
- Add error handling`,
|
|
1088
|
+
stepsTemplate: [
|
|
1089
|
+
"Endpoint responds to correct HTTP method",
|
|
1090
|
+
"Invalid requests return 400 with error details",
|
|
1091
|
+
"Valid requests process correctly",
|
|
1092
|
+
"Returns appropriate status codes",
|
|
1093
|
+
"Handles edge cases gracefully"
|
|
1094
|
+
]
|
|
1095
|
+
},
|
|
1096
|
+
{
|
|
1097
|
+
id: "ui-component",
|
|
1098
|
+
name: "UI Component",
|
|
1099
|
+
icon: "\u{1F3A8}",
|
|
1100
|
+
description: "Reusable UI component",
|
|
1101
|
+
category: "ui",
|
|
1102
|
+
priority: "medium",
|
|
1103
|
+
titleTemplate: "Create [ComponentName] component",
|
|
1104
|
+
descriptionTemplate: `Create a reusable UI component.
|
|
1105
|
+
|
|
1106
|
+
Requirements:
|
|
1107
|
+
- Component accepts appropriate props
|
|
1108
|
+
- Handles different states (loading, error, empty)
|
|
1109
|
+
- Follows design system/styling conventions
|
|
1110
|
+
- Accessible (keyboard, screen reader)
|
|
1111
|
+
- Includes any needed interactivity`,
|
|
1112
|
+
stepsTemplate: [
|
|
1113
|
+
"Component renders without errors",
|
|
1114
|
+
"Props affect rendering correctly",
|
|
1115
|
+
"Different states display appropriately",
|
|
1116
|
+
"Component is accessible",
|
|
1117
|
+
"Interactive elements work correctly"
|
|
1118
|
+
]
|
|
1119
|
+
},
|
|
1120
|
+
{
|
|
1121
|
+
id: "form-validation",
|
|
1122
|
+
name: "Form with Validation",
|
|
1123
|
+
icon: "\u{1F4C4}",
|
|
1124
|
+
description: "Form with client-side validation",
|
|
1125
|
+
category: "ui",
|
|
1126
|
+
priority: "medium",
|
|
1127
|
+
titleTemplate: "Create [FormName] form with validation",
|
|
1128
|
+
descriptionTemplate: `Create a form with comprehensive validation.
|
|
1129
|
+
|
|
1130
|
+
Requirements:
|
|
1131
|
+
- All necessary input fields
|
|
1132
|
+
- Real-time validation feedback
|
|
1133
|
+
- Clear error messages
|
|
1134
|
+
- Submit button state management
|
|
1135
|
+
- Form submission handling`,
|
|
1136
|
+
stepsTemplate: [
|
|
1137
|
+
"Form displays all required fields",
|
|
1138
|
+
"Invalid input shows error message",
|
|
1139
|
+
"Valid input clears error",
|
|
1140
|
+
"Submit disabled when form invalid",
|
|
1141
|
+
"Form submits with valid data"
|
|
1142
|
+
]
|
|
1143
|
+
},
|
|
1144
|
+
{
|
|
1145
|
+
id: "test-unit",
|
|
1146
|
+
name: "Unit Tests",
|
|
1147
|
+
icon: "\u{1F9EA}",
|
|
1148
|
+
description: "Unit test suite for module",
|
|
1149
|
+
category: "testing",
|
|
1150
|
+
priority: "medium",
|
|
1151
|
+
titleTemplate: "Add unit tests for [module/component]",
|
|
1152
|
+
descriptionTemplate: `Write comprehensive unit tests.
|
|
1153
|
+
|
|
1154
|
+
Requirements:
|
|
1155
|
+
- Test all public functions/methods
|
|
1156
|
+
- Cover edge cases
|
|
1157
|
+
- Test error handling
|
|
1158
|
+
- Achieve good coverage
|
|
1159
|
+
- Tests should be fast and isolated`,
|
|
1160
|
+
stepsTemplate: [
|
|
1161
|
+
"All tests pass",
|
|
1162
|
+
"Core functionality covered",
|
|
1163
|
+
"Edge cases tested",
|
|
1164
|
+
"Error cases handled",
|
|
1165
|
+
"No flaky tests"
|
|
1166
|
+
]
|
|
1167
|
+
},
|
|
1168
|
+
{
|
|
1169
|
+
id: "test-e2e",
|
|
1170
|
+
name: "E2E Tests",
|
|
1171
|
+
icon: "\u{1F9EA}",
|
|
1172
|
+
description: "End-to-end test for user flow",
|
|
1173
|
+
category: "testing",
|
|
1174
|
+
priority: "medium",
|
|
1175
|
+
titleTemplate: "Add E2E tests for [feature/flow]",
|
|
1176
|
+
descriptionTemplate: `Write end-to-end tests for user flow.
|
|
1177
|
+
|
|
1178
|
+
Requirements:
|
|
1179
|
+
- Test complete user journey
|
|
1180
|
+
- Use realistic test data
|
|
1181
|
+
- Verify UI and data changes
|
|
1182
|
+
- Handle async operations
|
|
1183
|
+
- Clean up test data`,
|
|
1184
|
+
stepsTemplate: [
|
|
1185
|
+
"Tests run successfully",
|
|
1186
|
+
"User flow completes correctly",
|
|
1187
|
+
"UI updates verified",
|
|
1188
|
+
"Data persisted correctly",
|
|
1189
|
+
"Tests are reliable (not flaky)"
|
|
1190
|
+
]
|
|
1191
|
+
}
|
|
1192
|
+
];
|
|
1193
|
+
function getAllTemplates() {
|
|
1194
|
+
return taskTemplates;
|
|
1195
|
+
}
|
|
1196
|
+
function getTemplateById(id) {
|
|
1197
|
+
return taskTemplates.find((t) => t.id === id);
|
|
1198
|
+
}
|
|
1199
|
+
|
|
1200
|
+
// src/server/services/ai.ts
|
|
1201
|
+
import { spawn as spawn2 } from "child_process";
|
|
1202
|
+
async function generateTaskFromPrompt(projectPath, userPrompt) {
|
|
1203
|
+
const config = getConfig(projectPath);
|
|
1204
|
+
const systemPrompt = `You are a task generator for a Kanban board used in software development.
|
|
1205
|
+
Given a user's description of what they want to build, generate a structured task.
|
|
1206
|
+
|
|
1207
|
+
Respond with ONLY valid JSON in this exact format:
|
|
1208
|
+
{
|
|
1209
|
+
"title": "Short, action-oriented title (max 80 chars)",
|
|
1210
|
+
"description": "Detailed description of what needs to be implemented",
|
|
1211
|
+
"category": "functional|ui|bug|enhancement|testing|refactor",
|
|
1212
|
+
"priority": "low|medium|high|critical",
|
|
1213
|
+
"steps": ["Step 1 to verify", "Step 2 to verify", "..."]
|
|
1214
|
+
}
|
|
1215
|
+
|
|
1216
|
+
Guidelines:
|
|
1217
|
+
- Title should be concise and start with a verb (Add, Create, Implement, Fix, etc.)
|
|
1218
|
+
- Description should be comprehensive but focused
|
|
1219
|
+
- Steps should be verification steps to confirm the feature works
|
|
1220
|
+
- Include 3-7 verification steps
|
|
1221
|
+
- Choose appropriate category based on the work type
|
|
1222
|
+
- Priority should reflect typical importance (most features are medium)
|
|
1223
|
+
|
|
1224
|
+
Respond with ONLY the JSON, no other text.`;
|
|
1225
|
+
const fullPrompt = `${systemPrompt}
|
|
1226
|
+
|
|
1227
|
+
User request: ${userPrompt}`;
|
|
1228
|
+
return new Promise((resolve, reject) => {
|
|
1229
|
+
const args = [
|
|
1230
|
+
"--permission-mode",
|
|
1231
|
+
"ask",
|
|
1232
|
+
"-p",
|
|
1233
|
+
fullPrompt
|
|
1234
|
+
];
|
|
1235
|
+
if (config.agent.model) {
|
|
1236
|
+
args.unshift("--model", config.agent.model);
|
|
1237
|
+
}
|
|
1238
|
+
let output = "";
|
|
1239
|
+
let errorOutput = "";
|
|
1240
|
+
const proc = spawn2(config.agent.command, args, {
|
|
1241
|
+
cwd: projectPath,
|
|
1242
|
+
shell: true,
|
|
1243
|
+
env: { ...process.env }
|
|
1244
|
+
});
|
|
1245
|
+
proc.stdout?.on("data", (data) => {
|
|
1246
|
+
output += data.toString();
|
|
1247
|
+
});
|
|
1248
|
+
proc.stderr?.on("data", (data) => {
|
|
1249
|
+
errorOutput += data.toString();
|
|
1250
|
+
});
|
|
1251
|
+
proc.on("close", (code) => {
|
|
1252
|
+
if (code !== 0) {
|
|
1253
|
+
reject(new Error(`AI generation failed: ${errorOutput || "Unknown error"}`));
|
|
1254
|
+
return;
|
|
1255
|
+
}
|
|
1256
|
+
try {
|
|
1257
|
+
const jsonMatch = output.match(/\{[\s\S]*\}/);
|
|
1258
|
+
if (!jsonMatch) {
|
|
1259
|
+
throw new Error("No JSON found in response");
|
|
1260
|
+
}
|
|
1261
|
+
const parsed = JSON.parse(jsonMatch[0]);
|
|
1262
|
+
if (!parsed.title || !parsed.description) {
|
|
1263
|
+
throw new Error("Missing required fields in response");
|
|
1264
|
+
}
|
|
1265
|
+
const task = {
|
|
1266
|
+
title: String(parsed.title).slice(0, 200),
|
|
1267
|
+
description: String(parsed.description),
|
|
1268
|
+
category: validateCategory(parsed.category),
|
|
1269
|
+
priority: validatePriority(parsed.priority),
|
|
1270
|
+
steps: Array.isArray(parsed.steps) ? parsed.steps.map(String) : [],
|
|
1271
|
+
status: "draft"
|
|
1272
|
+
};
|
|
1273
|
+
resolve(task);
|
|
1274
|
+
} catch (parseError) {
|
|
1275
|
+
reject(new Error(`Failed to parse AI response: ${parseError}`));
|
|
1276
|
+
}
|
|
1277
|
+
});
|
|
1278
|
+
proc.on("error", (error) => {
|
|
1279
|
+
reject(new Error(`Failed to spawn AI process: ${error.message}`));
|
|
1280
|
+
});
|
|
1281
|
+
setTimeout(() => {
|
|
1282
|
+
proc.kill();
|
|
1283
|
+
reject(new Error("AI generation timed out"));
|
|
1284
|
+
}, 6e4);
|
|
1285
|
+
});
|
|
1286
|
+
}
|
|
1287
|
+
function validateCategory(category) {
|
|
1288
|
+
const valid = ["functional", "ui", "bug", "enhancement", "testing", "refactor"];
|
|
1289
|
+
if (typeof category === "string" && valid.includes(category)) {
|
|
1290
|
+
return category;
|
|
1291
|
+
}
|
|
1292
|
+
return "functional";
|
|
1293
|
+
}
|
|
1294
|
+
function validatePriority(priority) {
|
|
1295
|
+
const valid = ["low", "medium", "high", "critical"];
|
|
1296
|
+
if (typeof priority === "string" && valid.includes(priority)) {
|
|
1297
|
+
return priority;
|
|
1298
|
+
}
|
|
1299
|
+
return "medium";
|
|
1300
|
+
}
|
|
1301
|
+
|
|
1302
|
+
// src/server/index.ts
|
|
1303
|
+
var __dirname2 = dirname(fileURLToPath(import.meta.url));
|
|
1304
|
+
async function createServer(projectPath, port) {
|
|
1305
|
+
const app = express();
|
|
1306
|
+
const httpServer = createHttpServer(app);
|
|
1307
|
+
const io = new SocketIOServer(httpServer, {
|
|
1308
|
+
cors: { origin: "*" }
|
|
1309
|
+
});
|
|
1310
|
+
app.use(express.json());
|
|
1311
|
+
const executor = new TaskExecutor(projectPath);
|
|
1312
|
+
executor.on("task:started", (data) => io.emit("task:started", data));
|
|
1313
|
+
executor.on("task:output", (data) => io.emit("task:output", data));
|
|
1314
|
+
executor.on("task:completed", (data) => io.emit("task:completed", data));
|
|
1315
|
+
executor.on("task:failed", (data) => io.emit("task:failed", data));
|
|
1316
|
+
executor.on("task:cancelled", (data) => io.emit("task:cancelled", data));
|
|
1317
|
+
executor.on("afk:status", (data) => io.emit("afk:status", data));
|
|
1318
|
+
app.get("/api/tasks", (_req, res) => {
|
|
1319
|
+
try {
|
|
1320
|
+
const tasks = getAllTasks(projectPath);
|
|
1321
|
+
res.json({ tasks });
|
|
1322
|
+
} catch (error) {
|
|
1323
|
+
res.status(500).json({ error: String(error) });
|
|
1324
|
+
}
|
|
1325
|
+
});
|
|
1326
|
+
app.post("/api/tasks", (req, res) => {
|
|
1327
|
+
try {
|
|
1328
|
+
const request = req.body;
|
|
1329
|
+
if (!request.title || !request.description) {
|
|
1330
|
+
res.status(400).json({ error: "Title and description are required" });
|
|
1331
|
+
return;
|
|
1332
|
+
}
|
|
1333
|
+
const task = createTask(projectPath, request);
|
|
1334
|
+
io.emit("task:created", task);
|
|
1335
|
+
res.status(201).json({ task });
|
|
1336
|
+
} catch (error) {
|
|
1337
|
+
res.status(500).json({ error: String(error) });
|
|
1338
|
+
}
|
|
1339
|
+
});
|
|
1340
|
+
app.post("/api/tasks/generate", async (req, res) => {
|
|
1341
|
+
try {
|
|
1342
|
+
const { prompt } = req.body;
|
|
1343
|
+
if (!prompt) {
|
|
1344
|
+
res.status(400).json({ error: "Prompt is required" });
|
|
1345
|
+
return;
|
|
1346
|
+
}
|
|
1347
|
+
const taskRequest = await generateTaskFromPrompt(projectPath, prompt);
|
|
1348
|
+
res.json({ task: taskRequest });
|
|
1349
|
+
} catch (error) {
|
|
1350
|
+
res.status(500).json({ error: String(error) });
|
|
1351
|
+
}
|
|
1352
|
+
});
|
|
1353
|
+
app.get("/api/tasks/:id", (req, res) => {
|
|
1354
|
+
try {
|
|
1355
|
+
const task = getTaskById(projectPath, req.params.id);
|
|
1356
|
+
if (!task) {
|
|
1357
|
+
res.status(404).json({ error: "Task not found" });
|
|
1358
|
+
return;
|
|
1359
|
+
}
|
|
1360
|
+
res.json({ task });
|
|
1361
|
+
} catch (error) {
|
|
1362
|
+
res.status(500).json({ error: String(error) });
|
|
1363
|
+
}
|
|
1364
|
+
});
|
|
1365
|
+
app.put("/api/tasks/:id", (req, res) => {
|
|
1366
|
+
try {
|
|
1367
|
+
const updates = req.body;
|
|
1368
|
+
const task = updateTask(projectPath, req.params.id, updates);
|
|
1369
|
+
if (!task) {
|
|
1370
|
+
res.status(404).json({ error: "Task not found" });
|
|
1371
|
+
return;
|
|
1372
|
+
}
|
|
1373
|
+
io.emit("task:updated", task);
|
|
1374
|
+
res.json({ task });
|
|
1375
|
+
} catch (error) {
|
|
1376
|
+
res.status(500).json({ error: String(error) });
|
|
1377
|
+
}
|
|
1378
|
+
});
|
|
1379
|
+
app.delete("/api/tasks/:id", (req, res) => {
|
|
1380
|
+
try {
|
|
1381
|
+
const deleted = deleteTask(projectPath, req.params.id);
|
|
1382
|
+
if (!deleted) {
|
|
1383
|
+
res.status(404).json({ error: "Task not found" });
|
|
1384
|
+
return;
|
|
1385
|
+
}
|
|
1386
|
+
io.emit("task:deleted", { id: req.params.id });
|
|
1387
|
+
res.json({ success: true });
|
|
1388
|
+
} catch (error) {
|
|
1389
|
+
res.status(500).json({ error: String(error) });
|
|
1390
|
+
}
|
|
1391
|
+
});
|
|
1392
|
+
app.post("/api/tasks/:id/run", async (req, res) => {
|
|
1393
|
+
try {
|
|
1394
|
+
await executor.runTask(req.params.id);
|
|
1395
|
+
res.json({ success: true });
|
|
1396
|
+
} catch (error) {
|
|
1397
|
+
res.status(400).json({ error: String(error) });
|
|
1398
|
+
}
|
|
1399
|
+
});
|
|
1400
|
+
app.post("/api/tasks/:id/cancel", (req, res) => {
|
|
1401
|
+
try {
|
|
1402
|
+
const cancelled = executor.cancelTask(req.params.id);
|
|
1403
|
+
if (!cancelled) {
|
|
1404
|
+
res.status(404).json({ error: "Task not running" });
|
|
1405
|
+
return;
|
|
1406
|
+
}
|
|
1407
|
+
res.json({ success: true });
|
|
1408
|
+
} catch (error) {
|
|
1409
|
+
res.status(500).json({ error: String(error) });
|
|
1410
|
+
}
|
|
1411
|
+
});
|
|
1412
|
+
app.post("/api/tasks/:id/retry", async (req, res) => {
|
|
1413
|
+
try {
|
|
1414
|
+
const task = updateTask(projectPath, req.params.id, {
|
|
1415
|
+
status: "ready",
|
|
1416
|
+
passes: false
|
|
1417
|
+
});
|
|
1418
|
+
if (!task) {
|
|
1419
|
+
res.status(404).json({ error: "Task not found" });
|
|
1420
|
+
return;
|
|
1421
|
+
}
|
|
1422
|
+
io.emit("task:updated", task);
|
|
1423
|
+
if (req.body.autoRun) {
|
|
1424
|
+
await executor.runTask(req.params.id);
|
|
1425
|
+
}
|
|
1426
|
+
res.json({ task });
|
|
1427
|
+
} catch (error) {
|
|
1428
|
+
res.status(500).json({ error: String(error) });
|
|
1429
|
+
}
|
|
1430
|
+
});
|
|
1431
|
+
app.get("/api/tasks/:id/logs", (req, res) => {
|
|
1432
|
+
try {
|
|
1433
|
+
const logs = executor.getTaskLog(req.params.id);
|
|
1434
|
+
if (logs === null) {
|
|
1435
|
+
res.json({ logs: "" });
|
|
1436
|
+
return;
|
|
1437
|
+
}
|
|
1438
|
+
res.json({ logs });
|
|
1439
|
+
} catch (error) {
|
|
1440
|
+
res.status(500).json({ error: String(error) });
|
|
1441
|
+
}
|
|
1442
|
+
});
|
|
1443
|
+
app.get("/api/tasks/:id/output", (req, res) => {
|
|
1444
|
+
try {
|
|
1445
|
+
const output = executor.getTaskOutput(req.params.id);
|
|
1446
|
+
if (!output) {
|
|
1447
|
+
res.status(404).json({ error: "Task not running or not found" });
|
|
1448
|
+
return;
|
|
1449
|
+
}
|
|
1450
|
+
res.json({ output });
|
|
1451
|
+
} catch (error) {
|
|
1452
|
+
res.status(500).json({ error: String(error) });
|
|
1453
|
+
}
|
|
1454
|
+
});
|
|
1455
|
+
app.get("/api/progress", (req, res) => {
|
|
1456
|
+
try {
|
|
1457
|
+
const lines = parseInt(String(req.query.lines)) || 100;
|
|
1458
|
+
const content = getRecentProgress(projectPath, lines);
|
|
1459
|
+
res.json({ content });
|
|
1460
|
+
} catch (error) {
|
|
1461
|
+
res.status(500).json({ error: String(error) });
|
|
1462
|
+
}
|
|
1463
|
+
});
|
|
1464
|
+
app.get("/api/config", (_req, res) => {
|
|
1465
|
+
try {
|
|
1466
|
+
const config = getConfig(projectPath);
|
|
1467
|
+
res.json({ config });
|
|
1468
|
+
} catch (error) {
|
|
1469
|
+
res.status(500).json({ error: String(error) });
|
|
1470
|
+
}
|
|
1471
|
+
});
|
|
1472
|
+
app.put("/api/config", (req, res) => {
|
|
1473
|
+
try {
|
|
1474
|
+
const currentConfig = getConfig(projectPath);
|
|
1475
|
+
const updatedConfig = { ...currentConfig, ...req.body };
|
|
1476
|
+
saveConfig(projectPath, updatedConfig);
|
|
1477
|
+
res.json({ config: updatedConfig });
|
|
1478
|
+
} catch (error) {
|
|
1479
|
+
res.status(500).json({ error: String(error) });
|
|
1480
|
+
}
|
|
1481
|
+
});
|
|
1482
|
+
app.get("/api/templates", (_req, res) => {
|
|
1483
|
+
try {
|
|
1484
|
+
const templates = getAllTemplates();
|
|
1485
|
+
res.json({ templates });
|
|
1486
|
+
} catch (error) {
|
|
1487
|
+
res.status(500).json({ error: String(error) });
|
|
1488
|
+
}
|
|
1489
|
+
});
|
|
1490
|
+
app.get("/api/templates/:id", (req, res) => {
|
|
1491
|
+
try {
|
|
1492
|
+
const template = getTemplateById(req.params.id);
|
|
1493
|
+
if (!template) {
|
|
1494
|
+
res.status(404).json({ error: "Template not found" });
|
|
1495
|
+
return;
|
|
1496
|
+
}
|
|
1497
|
+
res.json({ template });
|
|
1498
|
+
} catch (error) {
|
|
1499
|
+
res.status(500).json({ error: String(error) });
|
|
1500
|
+
}
|
|
1501
|
+
});
|
|
1502
|
+
app.post("/api/afk/start", (req, res) => {
|
|
1503
|
+
try {
|
|
1504
|
+
const { maxIterations, concurrent } = req.body;
|
|
1505
|
+
executor.startAFKMode(maxIterations || 10, concurrent || 1);
|
|
1506
|
+
res.json({ success: true, status: executor.getAFKStatus() });
|
|
1507
|
+
} catch (error) {
|
|
1508
|
+
res.status(400).json({ error: String(error) });
|
|
1509
|
+
}
|
|
1510
|
+
});
|
|
1511
|
+
app.post("/api/afk/stop", (_req, res) => {
|
|
1512
|
+
try {
|
|
1513
|
+
executor.stopAFKMode();
|
|
1514
|
+
res.json({ success: true });
|
|
1515
|
+
} catch (error) {
|
|
1516
|
+
res.status(500).json({ error: String(error) });
|
|
1517
|
+
}
|
|
1518
|
+
});
|
|
1519
|
+
app.get("/api/afk/status", (_req, res) => {
|
|
1520
|
+
try {
|
|
1521
|
+
const status = executor.getAFKStatus();
|
|
1522
|
+
res.json({ status });
|
|
1523
|
+
} catch (error) {
|
|
1524
|
+
res.status(500).json({ error: String(error) });
|
|
1525
|
+
}
|
|
1526
|
+
});
|
|
1527
|
+
app.get("/api/running", (_req, res) => {
|
|
1528
|
+
try {
|
|
1529
|
+
const taskIds = executor.getRunningTaskIds();
|
|
1530
|
+
res.json({ running: taskIds, count: taskIds.length });
|
|
1531
|
+
} catch (error) {
|
|
1532
|
+
res.status(500).json({ error: String(error) });
|
|
1533
|
+
}
|
|
1534
|
+
});
|
|
1535
|
+
app.get("/api/stats", (_req, res) => {
|
|
1536
|
+
try {
|
|
1537
|
+
const counts = getTaskCounts(projectPath);
|
|
1538
|
+
const running = executor.getRunningCount();
|
|
1539
|
+
const afk = executor.getAFKStatus();
|
|
1540
|
+
res.json({ counts, running, afk });
|
|
1541
|
+
} catch (error) {
|
|
1542
|
+
res.status(500).json({ error: String(error) });
|
|
1543
|
+
}
|
|
1544
|
+
});
|
|
1545
|
+
const clientPath = join5(__dirname2, "..", "client");
|
|
1546
|
+
if (existsSync3(clientPath)) {
|
|
1547
|
+
app.use(express.static(clientPath));
|
|
1548
|
+
}
|
|
1549
|
+
app.get("*", (_req, res) => {
|
|
1550
|
+
res.send(getClientHTML());
|
|
1551
|
+
});
|
|
1552
|
+
io.on("connection", (socket) => {
|
|
1553
|
+
console.log("Client connected");
|
|
1554
|
+
const runningIds = executor.getRunningTaskIds();
|
|
1555
|
+
const taskLogs = {};
|
|
1556
|
+
for (const taskId of runningIds) {
|
|
1557
|
+
const logs = executor.getTaskLog(taskId);
|
|
1558
|
+
if (logs) {
|
|
1559
|
+
taskLogs[taskId] = logs;
|
|
1560
|
+
}
|
|
1561
|
+
}
|
|
1562
|
+
socket.emit("init", {
|
|
1563
|
+
tasks: getAllTasks(projectPath),
|
|
1564
|
+
running: runningIds,
|
|
1565
|
+
afk: executor.getAFKStatus(),
|
|
1566
|
+
taskLogs
|
|
1567
|
+
// Include logs for running tasks
|
|
1568
|
+
});
|
|
1569
|
+
socket.on("get-logs", (taskId) => {
|
|
1570
|
+
const logs = executor.getTaskLog(taskId);
|
|
1571
|
+
socket.emit("task-logs", { taskId, logs: logs || "" });
|
|
1572
|
+
});
|
|
1573
|
+
socket.on("disconnect", () => {
|
|
1574
|
+
console.log("Client disconnected");
|
|
1575
|
+
});
|
|
1576
|
+
});
|
|
1577
|
+
return new Promise((resolve, reject) => {
|
|
1578
|
+
httpServer.on("error", (error) => {
|
|
1579
|
+
if (error.code === "EADDRINUSE") {
|
|
1580
|
+
reject(new Error(`Port ${port} is already in use. Try a different port with --port`));
|
|
1581
|
+
} else {
|
|
1582
|
+
reject(error);
|
|
1583
|
+
}
|
|
1584
|
+
});
|
|
1585
|
+
httpServer.listen(port, () => {
|
|
1586
|
+
httpServer.cleanup = () => {
|
|
1587
|
+
console.log("Cleaning up executor...");
|
|
1588
|
+
executor.cancelAll();
|
|
1589
|
+
io.close();
|
|
1590
|
+
};
|
|
1591
|
+
resolve(httpServer);
|
|
1592
|
+
});
|
|
1593
|
+
});
|
|
1594
|
+
}
|
|
1595
|
+
function getClientHTML() {
|
|
1596
|
+
return `<!DOCTYPE html>
|
|
1597
|
+
<html lang="en">
|
|
1598
|
+
<head>
|
|
1599
|
+
<meta charset="UTF-8">
|
|
1600
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
1601
|
+
<title>Claude Kanban</title>
|
|
1602
|
+
<link rel="preconnect" href="https://fonts.googleapis.com">
|
|
1603
|
+
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
|
1604
|
+
<link href="https://fonts.googleapis.com/css2?family=Fraunces:opsz,wght@9..144,400;9..144,500;9..144,600&family=DM+Sans:wght@400;500;600&family=IBM+Plex+Mono:wght@400;500&display=swap" rel="stylesheet">
|
|
1605
|
+
<script src="https://cdn.tailwindcss.com"></script>
|
|
1606
|
+
<script src="/socket.io/socket.io.js"></script>
|
|
1607
|
+
<script>
|
|
1608
|
+
tailwind.config = {
|
|
1609
|
+
theme: {
|
|
1610
|
+
extend: {
|
|
1611
|
+
fontFamily: {
|
|
1612
|
+
display: ['DM Sans', 'system-ui', 'sans-serif'],
|
|
1613
|
+
sans: ['DM Sans', 'system-ui', 'sans-serif'],
|
|
1614
|
+
mono: ['IBM Plex Mono', 'monospace'],
|
|
1615
|
+
},
|
|
1616
|
+
colors: {
|
|
1617
|
+
canvas: {
|
|
1618
|
+
DEFAULT: '#ffffff',
|
|
1619
|
+
50: '#fafafa',
|
|
1620
|
+
100: '#f5f5f5',
|
|
1621
|
+
200: '#e5e5e5',
|
|
1622
|
+
300: '#d4d4d4',
|
|
1623
|
+
400: '#a3a3a3',
|
|
1624
|
+
500: '#737373',
|
|
1625
|
+
600: '#525252',
|
|
1626
|
+
700: '#404040',
|
|
1627
|
+
800: '#262626',
|
|
1628
|
+
900: '#171717',
|
|
1629
|
+
},
|
|
1630
|
+
accent: {
|
|
1631
|
+
DEFAULT: '#f97316',
|
|
1632
|
+
light: '#fb923c',
|
|
1633
|
+
dark: '#ea580c',
|
|
1634
|
+
muted: 'rgba(249, 115, 22, 0.1)',
|
|
1635
|
+
},
|
|
1636
|
+
status: {
|
|
1637
|
+
draft: '#a3a3a3',
|
|
1638
|
+
ready: '#3b82f6',
|
|
1639
|
+
running: '#f97316',
|
|
1640
|
+
success: '#22c55e',
|
|
1641
|
+
failed: '#ef4444',
|
|
1642
|
+
}
|
|
1643
|
+
},
|
|
1644
|
+
},
|
|
1645
|
+
},
|
|
1646
|
+
};
|
|
1647
|
+
</script>
|
|
1648
|
+
<style>
|
|
1649
|
+
/* \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550
|
|
1650
|
+
CLEAN LIGHT THEME - Inspired by vibe-kanban
|
|
1651
|
+
\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550 */
|
|
1652
|
+
|
|
1653
|
+
:root {
|
|
1654
|
+
--ease-out-expo: cubic-bezier(0.16, 1, 0.3, 1);
|
|
1655
|
+
--ease-out-quart: cubic-bezier(0.25, 1, 0.5, 1);
|
|
1656
|
+
}
|
|
1657
|
+
|
|
1658
|
+
* { box-sizing: border-box; }
|
|
1659
|
+
|
|
1660
|
+
html {
|
|
1661
|
+
background: #fafafa;
|
|
1662
|
+
color: #171717;
|
|
1663
|
+
}
|
|
1664
|
+
|
|
1665
|
+
body {
|
|
1666
|
+
font-family: 'DM Sans', system-ui, sans-serif;
|
|
1667
|
+
background: #fafafa;
|
|
1668
|
+
min-height: 100vh;
|
|
1669
|
+
overflow-x: hidden;
|
|
1670
|
+
}
|
|
1671
|
+
|
|
1672
|
+
/* Clean scrollbar */
|
|
1673
|
+
::-webkit-scrollbar { width: 6px; height: 6px; }
|
|
1674
|
+
::-webkit-scrollbar-track { background: transparent; }
|
|
1675
|
+
::-webkit-scrollbar-thumb {
|
|
1676
|
+
background: rgba(0, 0, 0, 0.15);
|
|
1677
|
+
border-radius: 3px;
|
|
1678
|
+
}
|
|
1679
|
+
::-webkit-scrollbar-thumb:hover { background: rgba(0, 0, 0, 0.25); }
|
|
1680
|
+
|
|
1681
|
+
/* \u2500\u2500\u2500 Typography \u2500\u2500\u2500 */
|
|
1682
|
+
.font-display { font-family: 'DM Sans', system-ui, sans-serif; }
|
|
1683
|
+
.font-mono { font-family: 'IBM Plex Mono', monospace; }
|
|
1684
|
+
|
|
1685
|
+
/* \u2500\u2500\u2500 Card System \u2500\u2500\u2500 */
|
|
1686
|
+
.card {
|
|
1687
|
+
background: #ffffff;
|
|
1688
|
+
border: 1px solid #e5e5e5;
|
|
1689
|
+
border-radius: 8px;
|
|
1690
|
+
transition: all 0.2s var(--ease-out-expo);
|
|
1691
|
+
}
|
|
1692
|
+
.card:hover {
|
|
1693
|
+
border-color: #d4d4d4;
|
|
1694
|
+
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
|
|
1695
|
+
}
|
|
1696
|
+
|
|
1697
|
+
/* \u2500\u2500\u2500 Task Card - Minimal like vibe-kanban \u2500\u2500\u2500 */
|
|
1698
|
+
.task-card {
|
|
1699
|
+
cursor: pointer;
|
|
1700
|
+
padding: 12px 14px;
|
|
1701
|
+
}
|
|
1702
|
+
.task-card:hover {
|
|
1703
|
+
background: #fafafa;
|
|
1704
|
+
}
|
|
1705
|
+
.task-card.selected {
|
|
1706
|
+
border-color: #f97316;
|
|
1707
|
+
background: rgba(249, 115, 22, 0.03);
|
|
1708
|
+
}
|
|
1709
|
+
|
|
1710
|
+
/* \u2500\u2500\u2500 Drag & Drop \u2500\u2500\u2500 */
|
|
1711
|
+
.dragging {
|
|
1712
|
+
opacity: 0.9;
|
|
1713
|
+
transform: scale(1.02) rotate(0.5deg);
|
|
1714
|
+
box-shadow: 0 12px 24px rgba(0, 0, 0, 0.15);
|
|
1715
|
+
z-index: 1000;
|
|
1716
|
+
}
|
|
1717
|
+
.drag-over {
|
|
1718
|
+
background: rgba(249, 115, 22, 0.05);
|
|
1719
|
+
border: 2px dashed #f97316;
|
|
1720
|
+
border-radius: 8px;
|
|
1721
|
+
}
|
|
1722
|
+
|
|
1723
|
+
/* \u2500\u2500\u2500 Column Headers - Minimal \u2500\u2500\u2500 */
|
|
1724
|
+
.column-header {
|
|
1725
|
+
display: flex;
|
|
1726
|
+
align-items: center;
|
|
1727
|
+
gap: 8px;
|
|
1728
|
+
padding: 8px 4px;
|
|
1729
|
+
margin-bottom: 8px;
|
|
1730
|
+
border-bottom: 1px solid #f0f0f0;
|
|
1731
|
+
}
|
|
1732
|
+
.status-dot {
|
|
1733
|
+
width: 8px;
|
|
1734
|
+
height: 8px;
|
|
1735
|
+
border-radius: 50%;
|
|
1736
|
+
}
|
|
1737
|
+
.status-dot-draft { background: #a3a3a3; }
|
|
1738
|
+
.status-dot-ready { background: #3b82f6; }
|
|
1739
|
+
.status-dot-in_progress { background: #f97316; }
|
|
1740
|
+
.status-dot-completed { background: #22c55e; }
|
|
1741
|
+
.status-dot-failed { background: #ef4444; }
|
|
1742
|
+
|
|
1743
|
+
/* \u2500\u2500\u2500 Buttons \u2500\u2500\u2500 */
|
|
1744
|
+
.btn {
|
|
1745
|
+
font-weight: 500;
|
|
1746
|
+
border-radius: 6px;
|
|
1747
|
+
transition: all 0.15s ease;
|
|
1748
|
+
cursor: pointer;
|
|
1749
|
+
border: none;
|
|
1750
|
+
}
|
|
1751
|
+
.btn:active { transform: scale(0.98); }
|
|
1752
|
+
|
|
1753
|
+
.btn-primary {
|
|
1754
|
+
background: #f97316;
|
|
1755
|
+
color: white;
|
|
1756
|
+
}
|
|
1757
|
+
.btn-primary:hover {
|
|
1758
|
+
background: #ea580c;
|
|
1759
|
+
}
|
|
1760
|
+
|
|
1761
|
+
.btn-ghost {
|
|
1762
|
+
background: transparent;
|
|
1763
|
+
border: 1px solid #e5e5e5;
|
|
1764
|
+
color: #525252;
|
|
1765
|
+
}
|
|
1766
|
+
.btn-ghost:hover {
|
|
1767
|
+
background: #f5f5f5;
|
|
1768
|
+
border-color: #d4d4d4;
|
|
1769
|
+
}
|
|
1770
|
+
|
|
1771
|
+
.btn-danger {
|
|
1772
|
+
background: #fef2f2;
|
|
1773
|
+
color: #ef4444;
|
|
1774
|
+
border: 1px solid #fecaca;
|
|
1775
|
+
}
|
|
1776
|
+
.btn-danger:hover {
|
|
1777
|
+
background: #fee2e2;
|
|
1778
|
+
}
|
|
1779
|
+
|
|
1780
|
+
/* \u2500\u2500\u2500 Side Panel (pushes content, not overlay) \u2500\u2500\u2500 */
|
|
1781
|
+
.side-panel {
|
|
1782
|
+
width: 420px;
|
|
1783
|
+
flex-shrink: 0;
|
|
1784
|
+
background: #ffffff;
|
|
1785
|
+
border-left: 1px solid #e5e5e5;
|
|
1786
|
+
display: flex;
|
|
1787
|
+
flex-direction: column;
|
|
1788
|
+
height: calc(100vh - 57px);
|
|
1789
|
+
overflow: hidden;
|
|
1790
|
+
}
|
|
1791
|
+
.main-content {
|
|
1792
|
+
flex: 1;
|
|
1793
|
+
min-width: 0;
|
|
1794
|
+
transition: all 0.2s var(--ease-out-expo);
|
|
1795
|
+
}
|
|
1796
|
+
.side-panel-header {
|
|
1797
|
+
padding: 16px 20px;
|
|
1798
|
+
border-bottom: 1px solid #e5e5e5;
|
|
1799
|
+
flex-shrink: 0;
|
|
1800
|
+
}
|
|
1801
|
+
.side-panel-body {
|
|
1802
|
+
flex: 1;
|
|
1803
|
+
overflow: hidden;
|
|
1804
|
+
display: flex;
|
|
1805
|
+
flex-direction: column;
|
|
1806
|
+
min-height: 0;
|
|
1807
|
+
}
|
|
1808
|
+
.side-panel-tabs {
|
|
1809
|
+
display: flex;
|
|
1810
|
+
border-bottom: 1px solid #e5e5e5;
|
|
1811
|
+
padding: 0 20px;
|
|
1812
|
+
flex-shrink: 0;
|
|
1813
|
+
}
|
|
1814
|
+
.side-panel-tab {
|
|
1815
|
+
padding: 12px 16px;
|
|
1816
|
+
color: #737373;
|
|
1817
|
+
cursor: pointer;
|
|
1818
|
+
border-bottom: 2px solid transparent;
|
|
1819
|
+
transition: all 0.15s ease;
|
|
1820
|
+
}
|
|
1821
|
+
.side-panel-tab:hover { color: #171717; }
|
|
1822
|
+
.side-panel-tab.active {
|
|
1823
|
+
color: #171717;
|
|
1824
|
+
border-bottom-color: #f97316;
|
|
1825
|
+
}
|
|
1826
|
+
|
|
1827
|
+
/* \u2500\u2500\u2500 Terminal/Log Panel \u2500\u2500\u2500 */
|
|
1828
|
+
.log-container {
|
|
1829
|
+
background: #1a1a1a;
|
|
1830
|
+
color: #e5e5e5;
|
|
1831
|
+
font-family: 'IBM Plex Mono', monospace;
|
|
1832
|
+
font-size: 12px;
|
|
1833
|
+
line-height: 1.6;
|
|
1834
|
+
padding: 16px;
|
|
1835
|
+
flex: 1;
|
|
1836
|
+
overflow-y: auto;
|
|
1837
|
+
overflow-x: hidden;
|
|
1838
|
+
min-height: 0;
|
|
1839
|
+
}
|
|
1840
|
+
.log-line {
|
|
1841
|
+
padding: 2px 0;
|
|
1842
|
+
word-wrap: break-word;
|
|
1843
|
+
white-space: pre-wrap;
|
|
1844
|
+
}
|
|
1845
|
+
.log-line:hover {
|
|
1846
|
+
background: rgba(255, 255, 255, 0.05);
|
|
1847
|
+
}
|
|
1848
|
+
|
|
1849
|
+
/* ANSI colors */
|
|
1850
|
+
.ansi-red { color: #f87171; }
|
|
1851
|
+
.ansi-green { color: #86efac; }
|
|
1852
|
+
.ansi-yellow { color: #fde047; }
|
|
1853
|
+
.ansi-blue { color: #93c5fd; }
|
|
1854
|
+
.ansi-magenta { color: #e879f9; }
|
|
1855
|
+
.ansi-cyan { color: #67e8f9; }
|
|
1856
|
+
.ansi-bold { font-weight: 600; }
|
|
1857
|
+
.log-path { color: #93c5fd; }
|
|
1858
|
+
.log-error { color: #fca5a5; }
|
|
1859
|
+
.log-success { color: #86efac; }
|
|
1860
|
+
|
|
1861
|
+
/* \u2500\u2500\u2500 Tab System \u2500\u2500\u2500 */
|
|
1862
|
+
.tab {
|
|
1863
|
+
padding: 10px 16px;
|
|
1864
|
+
color: #737373;
|
|
1865
|
+
cursor: pointer;
|
|
1866
|
+
border-bottom: 2px solid transparent;
|
|
1867
|
+
transition: all 0.15s ease;
|
|
1868
|
+
}
|
|
1869
|
+
.tab:hover { color: #171717; }
|
|
1870
|
+
.tab.active {
|
|
1871
|
+
color: #171717;
|
|
1872
|
+
border-bottom-color: #f97316;
|
|
1873
|
+
}
|
|
1874
|
+
|
|
1875
|
+
/* \u2500\u2500\u2500 Modal \u2500\u2500\u2500 */
|
|
1876
|
+
.modal-backdrop {
|
|
1877
|
+
background: rgba(0, 0, 0, 0.5);
|
|
1878
|
+
backdrop-filter: blur(4px);
|
|
1879
|
+
}
|
|
1880
|
+
.modal-content {
|
|
1881
|
+
animation: modal-enter 0.2s var(--ease-out-expo);
|
|
1882
|
+
}
|
|
1883
|
+
@keyframes modal-enter {
|
|
1884
|
+
from { opacity: 0; transform: scale(0.95); }
|
|
1885
|
+
to { opacity: 1; transform: scale(1); }
|
|
1886
|
+
}
|
|
1887
|
+
|
|
1888
|
+
/* \u2500\u2500\u2500 Form Inputs \u2500\u2500\u2500 */
|
|
1889
|
+
.input {
|
|
1890
|
+
background: #ffffff;
|
|
1891
|
+
border: 1px solid #e5e5e5;
|
|
1892
|
+
border-radius: 6px;
|
|
1893
|
+
padding: 10px 12px;
|
|
1894
|
+
color: #171717;
|
|
1895
|
+
transition: all 0.15s ease;
|
|
1896
|
+
font-size: 14px;
|
|
1897
|
+
}
|
|
1898
|
+
.input::placeholder { color: #a3a3a3; }
|
|
1899
|
+
.input:focus {
|
|
1900
|
+
outline: none;
|
|
1901
|
+
border-color: #f97316;
|
|
1902
|
+
box-shadow: 0 0 0 3px rgba(249, 115, 22, 0.1);
|
|
1903
|
+
}
|
|
1904
|
+
|
|
1905
|
+
/* \u2500\u2500\u2500 Toast Notifications \u2500\u2500\u2500 */
|
|
1906
|
+
.toast-container {
|
|
1907
|
+
position: fixed;
|
|
1908
|
+
bottom: 24px;
|
|
1909
|
+
right: 24px;
|
|
1910
|
+
z-index: 9999;
|
|
1911
|
+
display: flex;
|
|
1912
|
+
flex-direction: column;
|
|
1913
|
+
gap: 8px;
|
|
1914
|
+
}
|
|
1915
|
+
.toast {
|
|
1916
|
+
padding: 12px 16px;
|
|
1917
|
+
border-radius: 8px;
|
|
1918
|
+
background: #ffffff;
|
|
1919
|
+
border: 1px solid #e5e5e5;
|
|
1920
|
+
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
|
1921
|
+
animation: toast-enter 0.3s var(--ease-out-expo);
|
|
1922
|
+
display: flex;
|
|
1923
|
+
align-items: center;
|
|
1924
|
+
gap: 8px;
|
|
1925
|
+
color: #171717;
|
|
1926
|
+
}
|
|
1927
|
+
@keyframes toast-enter {
|
|
1928
|
+
from { opacity: 0; transform: translateY(8px); }
|
|
1929
|
+
to { opacity: 1; transform: translateY(0); }
|
|
1930
|
+
}
|
|
1931
|
+
.toast-success { border-left: 3px solid #22c55e; }
|
|
1932
|
+
.toast-error { border-left: 3px solid #ef4444; }
|
|
1933
|
+
.toast-info { border-left: 3px solid #3b82f6; }
|
|
1934
|
+
.toast-warning { border-left: 3px solid #f97316; }
|
|
1935
|
+
|
|
1936
|
+
/* \u2500\u2500\u2500 Status Badges \u2500\u2500\u2500 */
|
|
1937
|
+
.status-badge {
|
|
1938
|
+
font-size: 12px;
|
|
1939
|
+
font-weight: 500;
|
|
1940
|
+
padding: 4px 10px;
|
|
1941
|
+
border-radius: 12px;
|
|
1942
|
+
display: inline-flex;
|
|
1943
|
+
align-items: center;
|
|
1944
|
+
gap: 6px;
|
|
1945
|
+
}
|
|
1946
|
+
.status-badge-draft { background: #f5f5f5; color: #737373; }
|
|
1947
|
+
.status-badge-ready { background: #eff6ff; color: #3b82f6; }
|
|
1948
|
+
.status-badge-in_progress { background: #fff7ed; color: #f97316; }
|
|
1949
|
+
.status-badge-completed { background: #f0fdf4; color: #22c55e; }
|
|
1950
|
+
.status-badge-failed { background: #fef2f2; color: #ef4444; }
|
|
1951
|
+
|
|
1952
|
+
/* \u2500\u2500\u2500 Priority Badges \u2500\u2500\u2500 */
|
|
1953
|
+
.badge {
|
|
1954
|
+
font-size: 11px;
|
|
1955
|
+
font-weight: 500;
|
|
1956
|
+
padding: 2px 8px;
|
|
1957
|
+
border-radius: 4px;
|
|
1958
|
+
}
|
|
1959
|
+
.badge-critical { background: #fef2f2; color: #ef4444; }
|
|
1960
|
+
.badge-high { background: #fff7ed; color: #f97316; }
|
|
1961
|
+
.badge-medium { background: #eff6ff; color: #3b82f6; }
|
|
1962
|
+
.badge-low { background: #f5f5f5; color: #737373; }
|
|
1963
|
+
|
|
1964
|
+
/* \u2500\u2500\u2500 Stats Pills \u2500\u2500\u2500 */
|
|
1965
|
+
.stat-pill {
|
|
1966
|
+
display: inline-flex;
|
|
1967
|
+
align-items: center;
|
|
1968
|
+
gap: 6px;
|
|
1969
|
+
padding: 4px 10px;
|
|
1970
|
+
background: #f5f5f5;
|
|
1971
|
+
border-radius: 12px;
|
|
1972
|
+
font-size: 12px;
|
|
1973
|
+
font-weight: 500;
|
|
1974
|
+
color: #525252;
|
|
1975
|
+
}
|
|
1976
|
+
.stat-dot {
|
|
1977
|
+
width: 6px;
|
|
1978
|
+
height: 6px;
|
|
1979
|
+
border-radius: 50%;
|
|
1980
|
+
}
|
|
1981
|
+
|
|
1982
|
+
/* \u2500\u2500\u2500 Column Container \u2500\u2500\u2500 */
|
|
1983
|
+
.column-container {
|
|
1984
|
+
background: #ffffff;
|
|
1985
|
+
border: 1px solid #e5e5e5;
|
|
1986
|
+
border-radius: 12px;
|
|
1987
|
+
padding: 12px;
|
|
1988
|
+
min-height: 500px;
|
|
1989
|
+
}
|
|
1990
|
+
|
|
1991
|
+
/* \u2500\u2500\u2500 Column Drop Zone \u2500\u2500\u2500 */
|
|
1992
|
+
.column-zone {
|
|
1993
|
+
min-height: 400px;
|
|
1994
|
+
padding: 4px;
|
|
1995
|
+
transition: all 0.2s ease;
|
|
1996
|
+
}
|
|
1997
|
+
|
|
1998
|
+
/* \u2500\u2500\u2500 Chat Input \u2500\u2500\u2500 */
|
|
1999
|
+
.chat-input-container {
|
|
2000
|
+
position: fixed;
|
|
2001
|
+
bottom: 0;
|
|
2002
|
+
left: 0;
|
|
2003
|
+
right: 0;
|
|
2004
|
+
padding: 16px 24px;
|
|
2005
|
+
background: #ffffff;
|
|
2006
|
+
border-top: 1px solid #e5e5e5;
|
|
2007
|
+
z-index: 50;
|
|
2008
|
+
}
|
|
2009
|
+
.chat-input {
|
|
2010
|
+
display: flex;
|
|
2011
|
+
align-items: center;
|
|
2012
|
+
gap: 12px;
|
|
2013
|
+
background: #f5f5f5;
|
|
2014
|
+
border: 1px solid #e5e5e5;
|
|
2015
|
+
border-radius: 8px;
|
|
2016
|
+
padding: 10px 16px;
|
|
2017
|
+
}
|
|
2018
|
+
.chat-input input {
|
|
2019
|
+
flex: 1;
|
|
2020
|
+
background: transparent;
|
|
2021
|
+
border: none;
|
|
2022
|
+
outline: none;
|
|
2023
|
+
font-size: 14px;
|
|
2024
|
+
color: #171717;
|
|
2025
|
+
}
|
|
2026
|
+
.chat-input input::placeholder { color: #a3a3a3; }
|
|
2027
|
+
.chat-input button {
|
|
2028
|
+
background: #f97316;
|
|
2029
|
+
color: white;
|
|
2030
|
+
border: none;
|
|
2031
|
+
border-radius: 6px;
|
|
2032
|
+
padding: 8px 16px;
|
|
2033
|
+
font-weight: 500;
|
|
2034
|
+
cursor: pointer;
|
|
2035
|
+
transition: background 0.15s;
|
|
2036
|
+
}
|
|
2037
|
+
.chat-input button:hover { background: #ea580c; }
|
|
2038
|
+
|
|
2039
|
+
/* \u2500\u2500\u2500 Details Grid \u2500\u2500\u2500 */
|
|
2040
|
+
.details-grid {
|
|
2041
|
+
display: grid;
|
|
2042
|
+
grid-template-columns: repeat(2, 1fr);
|
|
2043
|
+
gap: 16px;
|
|
2044
|
+
padding: 16px 20px;
|
|
2045
|
+
border-bottom: 1px solid #e5e5e5;
|
|
2046
|
+
flex-shrink: 0;
|
|
2047
|
+
}
|
|
2048
|
+
.details-item {
|
|
2049
|
+
display: flex;
|
|
2050
|
+
flex-direction: column;
|
|
2051
|
+
gap: 4px;
|
|
2052
|
+
}
|
|
2053
|
+
.details-label {
|
|
2054
|
+
font-size: 11px;
|
|
2055
|
+
font-weight: 500;
|
|
2056
|
+
color: #737373;
|
|
2057
|
+
text-transform: uppercase;
|
|
2058
|
+
letter-spacing: 0.5px;
|
|
2059
|
+
}
|
|
2060
|
+
.details-value {
|
|
2061
|
+
font-size: 13px;
|
|
2062
|
+
color: #171717;
|
|
2063
|
+
}
|
|
2064
|
+
|
|
2065
|
+
/* \u2500\u2500\u2500 Page animations \u2500\u2500\u2500 */
|
|
2066
|
+
.fade-in {
|
|
2067
|
+
animation: fade-in 0.3s ease;
|
|
2068
|
+
}
|
|
2069
|
+
@keyframes fade-in {
|
|
2070
|
+
from { opacity: 0; }
|
|
2071
|
+
to { opacity: 1; }
|
|
2072
|
+
}
|
|
2073
|
+
</style>
|
|
2074
|
+
</head>
|
|
2075
|
+
<body class="grain">
|
|
2076
|
+
<div id="app"></div>
|
|
2077
|
+
<div id="toast-container" class="toast-container"></div>
|
|
2078
|
+
<script type="module">
|
|
2079
|
+
${getClientJS()}
|
|
2080
|
+
</script>
|
|
2081
|
+
</body>
|
|
2082
|
+
</html>`;
|
|
2083
|
+
}
|
|
2084
|
+
function getClientJS() {
|
|
2085
|
+
return `
|
|
2086
|
+
// State
|
|
2087
|
+
let state = {
|
|
2088
|
+
tasks: [],
|
|
2089
|
+
running: [],
|
|
2090
|
+
afk: { running: false, currentIteration: 0, maxIterations: 0, tasksCompleted: 0 },
|
|
2091
|
+
templates: [],
|
|
2092
|
+
config: null,
|
|
2093
|
+
selectedTask: null,
|
|
2094
|
+
taskOutput: {},
|
|
2095
|
+
taskStartTime: {},
|
|
2096
|
+
activeTab: null,
|
|
2097
|
+
showModal: null,
|
|
2098
|
+
aiPrompt: '',
|
|
2099
|
+
aiGenerating: false,
|
|
2100
|
+
editingTask: null,
|
|
2101
|
+
searchQuery: '',
|
|
2102
|
+
logSearch: '',
|
|
2103
|
+
showLineNumbers: false,
|
|
2104
|
+
autoScroll: true,
|
|
2105
|
+
logFullscreen: false,
|
|
2106
|
+
closedTabs: new Set(),
|
|
2107
|
+
sidePanel: null, // task id for side panel
|
|
2108
|
+
sidePanelTab: 'logs', // 'logs' or 'details'
|
|
2109
|
+
darkMode: localStorage.getItem('darkMode') === 'true', // Add dark mode state
|
|
2110
|
+
};
|
|
2111
|
+
|
|
2112
|
+
// Toast notifications
|
|
2113
|
+
function showToast(message, type = 'info') {
|
|
2114
|
+
const container = document.getElementById('toast-container');
|
|
2115
|
+
const toast = document.createElement('div');
|
|
2116
|
+
toast.className = 'toast toast-' + type;
|
|
2117
|
+
const icons = { success: '\u2713', error: '\u2715', info: '\u2139', warning: '\u26A0' };
|
|
2118
|
+
toast.innerHTML = '<span>' + (icons[type] || '') + '</span><span>' + escapeHtml(message) + '</span>';
|
|
2119
|
+
container.appendChild(toast);
|
|
2120
|
+
setTimeout(() => toast.remove(), 4000);
|
|
2121
|
+
}
|
|
2122
|
+
|
|
2123
|
+
// ANSI color parser
|
|
2124
|
+
function parseAnsi(text) {
|
|
2125
|
+
const ansiRegex = /\\x1B\\[([0-9;]*)m/g;
|
|
2126
|
+
let result = '';
|
|
2127
|
+
let lastIndex = 0;
|
|
2128
|
+
let currentClasses = [];
|
|
2129
|
+
|
|
2130
|
+
text.replace(ansiRegex, (match, codes, offset) => {
|
|
2131
|
+
result += escapeHtml(text.slice(lastIndex, offset));
|
|
2132
|
+
lastIndex = offset + match.length;
|
|
2133
|
+
|
|
2134
|
+
codes.split(';').forEach(code => {
|
|
2135
|
+
const c = parseInt(code);
|
|
2136
|
+
if (c === 0) currentClasses = [];
|
|
2137
|
+
else if (c === 1) currentClasses.push('ansi-bold');
|
|
2138
|
+
else if (c === 2) currentClasses.push('ansi-dim');
|
|
2139
|
+
else if (c === 3) currentClasses.push('ansi-italic');
|
|
2140
|
+
else if (c === 4) currentClasses.push('ansi-underline');
|
|
2141
|
+
else if (c === 30) currentClasses.push('ansi-black');
|
|
2142
|
+
else if (c === 31) currentClasses.push('ansi-red');
|
|
2143
|
+
else if (c === 32) currentClasses.push('ansi-green');
|
|
2144
|
+
else if (c === 33) currentClasses.push('ansi-yellow');
|
|
2145
|
+
else if (c === 34) currentClasses.push('ansi-blue');
|
|
2146
|
+
else if (c === 35) currentClasses.push('ansi-magenta');
|
|
2147
|
+
else if (c === 36) currentClasses.push('ansi-cyan');
|
|
2148
|
+
else if (c === 37) currentClasses.push('ansi-white');
|
|
2149
|
+
else if (c === 90) currentClasses.push('ansi-bright-black');
|
|
2150
|
+
else if (c === 91) currentClasses.push('ansi-bright-red');
|
|
2151
|
+
else if (c === 92) currentClasses.push('ansi-bright-green');
|
|
2152
|
+
else if (c === 93) currentClasses.push('ansi-bright-yellow');
|
|
2153
|
+
else if (c === 94) currentClasses.push('ansi-bright-blue');
|
|
2154
|
+
else if (c === 95) currentClasses.push('ansi-bright-magenta');
|
|
2155
|
+
else if (c === 96) currentClasses.push('ansi-bright-cyan');
|
|
2156
|
+
else if (c === 97) currentClasses.push('ansi-bright-white');
|
|
2157
|
+
});
|
|
2158
|
+
|
|
2159
|
+
if (currentClasses.length > 0) {
|
|
2160
|
+
result += '<span class="' + currentClasses.join(' ') + '">';
|
|
2161
|
+
}
|
|
2162
|
+
});
|
|
2163
|
+
|
|
2164
|
+
result += escapeHtml(text.slice(lastIndex));
|
|
2165
|
+
return result;
|
|
2166
|
+
}
|
|
2167
|
+
|
|
2168
|
+
// Syntax highlight log lines
|
|
2169
|
+
function highlightLog(text) {
|
|
2170
|
+
let highlighted = parseAnsi(text);
|
|
2171
|
+
// Highlight file paths
|
|
2172
|
+
highlighted = highlighted.replace(/([\\/\\w.-]+\\.(ts|js|tsx|jsx|json|md|css|html))/g, '<span class="log-path">$1</span>');
|
|
2173
|
+
// Highlight errors
|
|
2174
|
+
highlighted = highlighted.replace(/(error|Error|ERROR|failed|Failed|FAILED)/g, '<span class="log-error">$1</span>');
|
|
2175
|
+
// Highlight success
|
|
2176
|
+
highlighted = highlighted.replace(/(success|Success|SUCCESS|complete|Complete|COMPLETE|passed|Passed|PASSED)/g, '<span class="log-success">$1</span>');
|
|
2177
|
+
// Highlight warnings
|
|
2178
|
+
highlighted = highlighted.replace(/(warning|Warning|WARNING|warn|Warn|WARN)/g, '<span class="log-warning">$1</span>');
|
|
2179
|
+
return highlighted;
|
|
2180
|
+
}
|
|
2181
|
+
|
|
2182
|
+
// Format elapsed time
|
|
2183
|
+
function formatElapsed(startTime) {
|
|
2184
|
+
if (!startTime) return '';
|
|
2185
|
+
const elapsed = Date.now() - new Date(startTime).getTime();
|
|
2186
|
+
const seconds = Math.floor(elapsed / 1000);
|
|
2187
|
+
const minutes = Math.floor(seconds / 60);
|
|
2188
|
+
const hours = Math.floor(minutes / 60);
|
|
2189
|
+
if (hours > 0) return hours + 'h ' + (minutes % 60) + 'm';
|
|
2190
|
+
if (minutes > 0) return minutes + 'm ' + (seconds % 60) + 's';
|
|
2191
|
+
return seconds + 's';
|
|
2192
|
+
}
|
|
2193
|
+
|
|
2194
|
+
// Socket connection
|
|
2195
|
+
const socket = io();
|
|
2196
|
+
|
|
2197
|
+
socket.on('init', (data) => {
|
|
2198
|
+
state.tasks = data.tasks;
|
|
2199
|
+
state.running = data.running;
|
|
2200
|
+
state.afk = data.afk;
|
|
2201
|
+
|
|
2202
|
+
// Load persisted logs for running tasks
|
|
2203
|
+
if (data.taskLogs) {
|
|
2204
|
+
for (const [taskId, logs] of Object.entries(data.taskLogs)) {
|
|
2205
|
+
if (logs) {
|
|
2206
|
+
// Parse logs into lines for the taskOutput format
|
|
2207
|
+
state.taskOutput[taskId] = String(logs).split('\\n').filter(l => l).map(text => ({
|
|
2208
|
+
text: text + '\\n',
|
|
2209
|
+
timestamp: new Date().toISOString()
|
|
2210
|
+
}));
|
|
2211
|
+
}
|
|
2212
|
+
}
|
|
2213
|
+
}
|
|
2214
|
+
|
|
2215
|
+
render();
|
|
2216
|
+
});
|
|
2217
|
+
|
|
2218
|
+
// Handle logs response from server
|
|
2219
|
+
socket.on('task-logs', ({ taskId, logs }) => {
|
|
2220
|
+
if (logs) {
|
|
2221
|
+
state.taskOutput[taskId] = logs.split('\\n').filter(l => l).map(text => ({
|
|
2222
|
+
text: text + '\\n',
|
|
2223
|
+
timestamp: new Date().toISOString()
|
|
2224
|
+
}));
|
|
2225
|
+
render();
|
|
2226
|
+
if (state.autoScroll) {
|
|
2227
|
+
requestAnimationFrame(() => scrollSidePanelLog());
|
|
2228
|
+
}
|
|
2229
|
+
}
|
|
2230
|
+
});
|
|
2231
|
+
|
|
2232
|
+
socket.on('task:created', (task) => {
|
|
2233
|
+
state.tasks.push(task);
|
|
2234
|
+
showToast('Task created: ' + task.title, 'success');
|
|
2235
|
+
render();
|
|
2236
|
+
});
|
|
2237
|
+
|
|
2238
|
+
socket.on('task:updated', (task) => {
|
|
2239
|
+
const idx = state.tasks.findIndex(t => t.id === task.id);
|
|
2240
|
+
if (idx >= 0) state.tasks[idx] = task;
|
|
2241
|
+
render();
|
|
2242
|
+
});
|
|
2243
|
+
|
|
2244
|
+
socket.on('task:deleted', ({ id }) => {
|
|
2245
|
+
state.tasks = state.tasks.filter(t => t.id !== id);
|
|
2246
|
+
render();
|
|
2247
|
+
});
|
|
2248
|
+
|
|
2249
|
+
socket.on('task:started', ({ taskId, timestamp }) => {
|
|
2250
|
+
if (!state.running.includes(taskId)) state.running.push(taskId);
|
|
2251
|
+
const task = state.tasks.find(t => t.id === taskId);
|
|
2252
|
+
if (task) task.status = 'in_progress';
|
|
2253
|
+
state.taskOutput[taskId] = [];
|
|
2254
|
+
state.taskStartTime[taskId] = timestamp || new Date().toISOString();
|
|
2255
|
+
state.activeTab = taskId;
|
|
2256
|
+
state.closedTabs.delete(taskId);
|
|
2257
|
+
showToast('Task started: ' + (task?.title || taskId), 'info');
|
|
2258
|
+
render();
|
|
2259
|
+
});
|
|
2260
|
+
|
|
2261
|
+
socket.on('task:output', ({ taskId, line }) => {
|
|
2262
|
+
if (!state.taskOutput[taskId]) state.taskOutput[taskId] = [];
|
|
2263
|
+
state.taskOutput[taskId].push({ text: line, timestamp: new Date().toISOString() });
|
|
2264
|
+
render();
|
|
2265
|
+
// Auto-scroll after DOM update
|
|
2266
|
+
if (state.autoScroll) {
|
|
2267
|
+
requestAnimationFrame(() => {
|
|
2268
|
+
scrollLogToBottom();
|
|
2269
|
+
scrollSidePanelLog();
|
|
2270
|
+
});
|
|
2271
|
+
}
|
|
2272
|
+
});
|
|
2273
|
+
|
|
2274
|
+
socket.on('task:completed', ({ taskId }) => {
|
|
2275
|
+
state.running = state.running.filter(id => id !== taskId);
|
|
2276
|
+
const task = state.tasks.find(t => t.id === taskId);
|
|
2277
|
+
if (task) { task.status = 'completed'; task.passes = true; }
|
|
2278
|
+
showToast('Task completed: ' + (task?.title || taskId), 'success');
|
|
2279
|
+
render();
|
|
2280
|
+
});
|
|
2281
|
+
|
|
2282
|
+
socket.on('task:failed', ({ taskId }) => {
|
|
2283
|
+
state.running = state.running.filter(id => id !== taskId);
|
|
2284
|
+
const task = state.tasks.find(t => t.id === taskId);
|
|
2285
|
+
if (task) { task.status = 'failed'; task.passes = false; }
|
|
2286
|
+
showToast('Task failed: ' + (task?.title || taskId), 'error');
|
|
2287
|
+
render();
|
|
2288
|
+
});
|
|
2289
|
+
|
|
2290
|
+
socket.on('task:cancelled', ({ taskId }) => {
|
|
2291
|
+
state.running = state.running.filter(id => id !== taskId);
|
|
2292
|
+
const task = state.tasks.find(t => t.id === taskId);
|
|
2293
|
+
if (task) task.status = 'ready';
|
|
2294
|
+
showToast('Task cancelled', 'warning');
|
|
2295
|
+
render();
|
|
2296
|
+
});
|
|
2297
|
+
|
|
2298
|
+
socket.on('afk:status', (status) => {
|
|
2299
|
+
state.afk = status;
|
|
2300
|
+
render();
|
|
2301
|
+
});
|
|
2302
|
+
|
|
2303
|
+
// Load templates
|
|
2304
|
+
fetch('/api/templates').then(r => r.json()).then(data => {
|
|
2305
|
+
state.templates = data.templates;
|
|
2306
|
+
});
|
|
2307
|
+
|
|
2308
|
+
// Load config
|
|
2309
|
+
fetch('/api/config').then(r => r.json()).then(data => {
|
|
2310
|
+
state.config = data.config;
|
|
2311
|
+
});
|
|
2312
|
+
|
|
2313
|
+
// API calls
|
|
2314
|
+
async function createTask(task) {
|
|
2315
|
+
const res = await fetch('/api/tasks', {
|
|
2316
|
+
method: 'POST',
|
|
2317
|
+
headers: { 'Content-Type': 'application/json' },
|
|
2318
|
+
body: JSON.stringify(task)
|
|
2319
|
+
});
|
|
2320
|
+
return res.json();
|
|
2321
|
+
}
|
|
2322
|
+
|
|
2323
|
+
async function updateTask(id, updates) {
|
|
2324
|
+
const res = await fetch('/api/tasks/' + id, {
|
|
2325
|
+
method: 'PUT',
|
|
2326
|
+
headers: { 'Content-Type': 'application/json' },
|
|
2327
|
+
body: JSON.stringify(updates)
|
|
2328
|
+
});
|
|
2329
|
+
return res.json();
|
|
2330
|
+
}
|
|
2331
|
+
|
|
2332
|
+
async function deleteTask(id) {
|
|
2333
|
+
await fetch('/api/tasks/' + id, { method: 'DELETE' });
|
|
2334
|
+
}
|
|
2335
|
+
|
|
2336
|
+
async function runTask(id) {
|
|
2337
|
+
await fetch('/api/tasks/' + id + '/run', { method: 'POST' });
|
|
2338
|
+
}
|
|
2339
|
+
|
|
2340
|
+
async function cancelTask(id) {
|
|
2341
|
+
await fetch('/api/tasks/' + id + '/cancel', { method: 'POST' });
|
|
2342
|
+
}
|
|
2343
|
+
|
|
2344
|
+
async function retryTask(id) {
|
|
2345
|
+
await fetch('/api/tasks/' + id + '/retry', {
|
|
2346
|
+
method: 'POST',
|
|
2347
|
+
headers: { 'Content-Type': 'application/json' },
|
|
2348
|
+
body: JSON.stringify({ autoRun: false })
|
|
2349
|
+
});
|
|
2350
|
+
}
|
|
2351
|
+
|
|
2352
|
+
async function generateTask(prompt) {
|
|
2353
|
+
state.aiGenerating = true;
|
|
2354
|
+
render();
|
|
2355
|
+
try {
|
|
2356
|
+
const res = await fetch('/api/tasks/generate', {
|
|
2357
|
+
method: 'POST',
|
|
2358
|
+
headers: { 'Content-Type': 'application/json' },
|
|
2359
|
+
body: JSON.stringify({ prompt })
|
|
2360
|
+
});
|
|
2361
|
+
const data = await res.json();
|
|
2362
|
+
state.aiGenerating = false;
|
|
2363
|
+
return data.task;
|
|
2364
|
+
} catch (e) {
|
|
2365
|
+
state.aiGenerating = false;
|
|
2366
|
+
throw e;
|
|
2367
|
+
}
|
|
2368
|
+
}
|
|
2369
|
+
|
|
2370
|
+
async function startAFK(maxIterations, concurrent) {
|
|
2371
|
+
await fetch('/api/afk/start', {
|
|
2372
|
+
method: 'POST',
|
|
2373
|
+
headers: { 'Content-Type': 'application/json' },
|
|
2374
|
+
body: JSON.stringify({ maxIterations, concurrent })
|
|
2375
|
+
});
|
|
2376
|
+
}
|
|
2377
|
+
|
|
2378
|
+
async function stopAFK() {
|
|
2379
|
+
await fetch('/api/afk/stop', { method: 'POST' });
|
|
2380
|
+
}
|
|
2381
|
+
|
|
2382
|
+
// Enhanced Drag and drop
|
|
2383
|
+
let draggedTask = null;
|
|
2384
|
+
let draggedElement = null;
|
|
2385
|
+
|
|
2386
|
+
function handleDragStart(e, taskId) {
|
|
2387
|
+
draggedTask = taskId;
|
|
2388
|
+
draggedElement = e.target;
|
|
2389
|
+
e.target.classList.add('dragging');
|
|
2390
|
+
e.dataTransfer.effectAllowed = 'move';
|
|
2391
|
+
e.dataTransfer.setData('text/plain', taskId);
|
|
2392
|
+
|
|
2393
|
+
// Add visual feedback to valid drop zones
|
|
2394
|
+
setTimeout(() => {
|
|
2395
|
+
document.querySelectorAll('.column-drop-zone').forEach(zone => {
|
|
2396
|
+
const status = zone.dataset.status;
|
|
2397
|
+
if (status !== 'in_progress') {
|
|
2398
|
+
zone.classList.add('border-dashed', 'border-2', 'border-blue-400/30');
|
|
2399
|
+
}
|
|
2400
|
+
});
|
|
2401
|
+
}, 0);
|
|
2402
|
+
}
|
|
2403
|
+
|
|
2404
|
+
function handleDragEnd(e) {
|
|
2405
|
+
e.target.classList.remove('dragging');
|
|
2406
|
+
document.querySelectorAll('.drag-over').forEach(el => el.classList.remove('drag-over'));
|
|
2407
|
+
document.querySelectorAll('.column-drop-zone').forEach(zone => {
|
|
2408
|
+
zone.classList.remove('border-dashed', 'border-2', 'border-blue-400/30');
|
|
2409
|
+
});
|
|
2410
|
+
draggedTask = null;
|
|
2411
|
+
draggedElement = null;
|
|
2412
|
+
}
|
|
2413
|
+
|
|
2414
|
+
function handleDragOver(e) {
|
|
2415
|
+
e.preventDefault();
|
|
2416
|
+
e.dataTransfer.dropEffect = 'move';
|
|
2417
|
+
const zone = e.currentTarget;
|
|
2418
|
+
if (!zone.classList.contains('drag-over') && zone.dataset.status !== 'in_progress') {
|
|
2419
|
+
zone.classList.add('drag-over');
|
|
2420
|
+
}
|
|
2421
|
+
}
|
|
2422
|
+
|
|
2423
|
+
function handleDragLeave(e) {
|
|
2424
|
+
// Only remove if actually leaving the zone
|
|
2425
|
+
const rect = e.currentTarget.getBoundingClientRect();
|
|
2426
|
+
const x = e.clientX;
|
|
2427
|
+
const y = e.clientY;
|
|
2428
|
+
if (x < rect.left || x > rect.right || y < rect.top || y > rect.bottom) {
|
|
2429
|
+
e.currentTarget.classList.remove('drag-over');
|
|
2430
|
+
}
|
|
2431
|
+
}
|
|
2432
|
+
|
|
2433
|
+
function handleDrop(e, newStatus) {
|
|
2434
|
+
e.preventDefault();
|
|
2435
|
+
e.currentTarget.classList.remove('drag-over');
|
|
2436
|
+
if (draggedTask && newStatus !== 'in_progress') {
|
|
2437
|
+
const task = state.tasks.find(t => t.id === draggedTask);
|
|
2438
|
+
if (task && task.status !== newStatus) {
|
|
2439
|
+
updateTask(draggedTask, { status: newStatus });
|
|
2440
|
+
showToast('Moved to ' + newStatus.replace('_', ' '), 'info');
|
|
2441
|
+
}
|
|
2442
|
+
}
|
|
2443
|
+
draggedTask = null;
|
|
2444
|
+
}
|
|
2445
|
+
|
|
2446
|
+
function scrollLogToBottom() {
|
|
2447
|
+
const logEl = document.getElementById('log-content');
|
|
2448
|
+
if (logEl) logEl.scrollTop = logEl.scrollHeight;
|
|
2449
|
+
}
|
|
2450
|
+
|
|
2451
|
+
function scrollSidePanelLog() {
|
|
2452
|
+
const logEl = document.getElementById('side-panel-log');
|
|
2453
|
+
if (logEl) logEl.scrollTop = logEl.scrollHeight;
|
|
2454
|
+
}
|
|
2455
|
+
|
|
2456
|
+
function copyLogToClipboard() {
|
|
2457
|
+
const output = state.taskOutput[state.activeTab] || [];
|
|
2458
|
+
const text = output.map(l => l.text || l).join('');
|
|
2459
|
+
navigator.clipboard.writeText(text).then(() => {
|
|
2460
|
+
showToast('Log copied to clipboard', 'success');
|
|
2461
|
+
});
|
|
2462
|
+
}
|
|
2463
|
+
|
|
2464
|
+
function clearLog(taskId) {
|
|
2465
|
+
state.taskOutput[taskId] = [];
|
|
2466
|
+
render();
|
|
2467
|
+
}
|
|
2468
|
+
|
|
2469
|
+
function closeLogTab(taskId) {
|
|
2470
|
+
state.closedTabs.add(taskId);
|
|
2471
|
+
if (state.activeTab === taskId) {
|
|
2472
|
+
const remaining = Object.keys(state.taskOutput).filter(id => !state.closedTabs.has(id));
|
|
2473
|
+
state.activeTab = remaining[0] || null;
|
|
2474
|
+
}
|
|
2475
|
+
render();
|
|
2476
|
+
}
|
|
2477
|
+
|
|
2478
|
+
function toggleLogFullscreen() {
|
|
2479
|
+
state.logFullscreen = !state.logFullscreen;
|
|
2480
|
+
render();
|
|
2481
|
+
}
|
|
2482
|
+
|
|
2483
|
+
const categoryIcons = {
|
|
2484
|
+
functional: '\u2699\uFE0F',
|
|
2485
|
+
ui: '\u{1F3A8}',
|
|
2486
|
+
bug: '\u{1F41B}',
|
|
2487
|
+
enhancement: '\u2728',
|
|
2488
|
+
testing: '\u{1F9EA}',
|
|
2489
|
+
refactor: '\u{1F527}'
|
|
2490
|
+
};
|
|
2491
|
+
|
|
2492
|
+
// Render functions
|
|
2493
|
+
function renderCard(task) {
|
|
2494
|
+
const isRunning = state.running.includes(task.id);
|
|
2495
|
+
const isSelected = state.sidePanel === task.id;
|
|
2496
|
+
|
|
2497
|
+
return \`
|
|
2498
|
+
<div class="card task-card mb-2 group \${isRunning ? 'running' : ''} \${isSelected ? 'selected' : ''}"
|
|
2499
|
+
onclick="openSidePanel('\${task.id}')"
|
|
2500
|
+
draggable="\${!isRunning}"
|
|
2501
|
+
ondragstart="handleDragStart(event, '\${task.id}')"
|
|
2502
|
+
ondragend="handleDragEnd(event)">
|
|
2503
|
+
<div class="flex justify-between items-start gap-2">
|
|
2504
|
+
<h3 class="font-medium text-canvas-800 text-sm leading-snug">\${escapeHtml(task.title)}</h3>
|
|
2505
|
+
<button onclick="event.stopPropagation(); showTaskMenu('\${task.id}')"
|
|
2506
|
+
class="text-canvas-400 hover:text-canvas-600 p-1 -mr-1 opacity-0 group-hover:opacity-100">
|
|
2507
|
+
\u22EF
|
|
2508
|
+
</button>
|
|
2509
|
+
</div>
|
|
2510
|
+
<p class="text-xs text-canvas-500 mt-1 line-clamp-2">\${escapeHtml(task.description.substring(0, 80))}\${task.description.length > 80 ? '...' : ''}</p>
|
|
2511
|
+
\${isRunning ? \`
|
|
2512
|
+
<div class="flex items-center gap-2 mt-2 text-xs text-status-running">
|
|
2513
|
+
<span class="w-1.5 h-1.5 bg-status-running rounded-full animate-pulse"></span>
|
|
2514
|
+
Running...
|
|
2515
|
+
</div>
|
|
2516
|
+
\` : ''}
|
|
2517
|
+
</div>
|
|
2518
|
+
\`;
|
|
2519
|
+
}
|
|
2520
|
+
|
|
2521
|
+
function openSidePanel(taskId) {
|
|
2522
|
+
state.sidePanel = taskId;
|
|
2523
|
+
state.activeTab = taskId;
|
|
2524
|
+
state.closedTabs.delete(taskId);
|
|
2525
|
+
|
|
2526
|
+
// Request logs from server if not already loaded
|
|
2527
|
+
if (!state.taskOutput[taskId] || state.taskOutput[taskId].length === 0) {
|
|
2528
|
+
socket.emit('get-logs', taskId);
|
|
2529
|
+
}
|
|
2530
|
+
|
|
2531
|
+
render();
|
|
2532
|
+
|
|
2533
|
+
// Scroll to bottom after render
|
|
2534
|
+
if (state.autoScroll) {
|
|
2535
|
+
requestAnimationFrame(() => scrollSidePanelLog());
|
|
2536
|
+
}
|
|
2537
|
+
}
|
|
2538
|
+
|
|
2539
|
+
function closeSidePanel() {
|
|
2540
|
+
state.sidePanel = null;
|
|
2541
|
+
render();
|
|
2542
|
+
}
|
|
2543
|
+
|
|
2544
|
+
function showTaskMenu(taskId) {
|
|
2545
|
+
// For now just open side panel, could add dropdown menu later
|
|
2546
|
+
openSidePanel(taskId);
|
|
2547
|
+
}
|
|
2548
|
+
|
|
2549
|
+
function renderColumn(status, title, tasks) {
|
|
2550
|
+
const columnTasks = tasks.filter(t => t.status === status);
|
|
2551
|
+
const statusLabels = {
|
|
2552
|
+
draft: 'To Do',
|
|
2553
|
+
ready: 'Ready',
|
|
2554
|
+
in_progress: 'In Progress',
|
|
2555
|
+
completed: 'Done',
|
|
2556
|
+
failed: 'Failed'
|
|
2557
|
+
};
|
|
2558
|
+
const taskCount = columnTasks.length;
|
|
2559
|
+
|
|
2560
|
+
return \`
|
|
2561
|
+
<div class="flex-1 min-w-[240px] max-w-[300px]">
|
|
2562
|
+
<div class="column-container">
|
|
2563
|
+
<div class="column-header">
|
|
2564
|
+
<span class="status-dot status-dot-\${status}"></span>
|
|
2565
|
+
<span class="font-medium text-canvas-700 text-sm">\${statusLabels[status] || title}</span>
|
|
2566
|
+
<span class="text-xs text-canvas-400 ml-auto">\${taskCount}</span>
|
|
2567
|
+
</div>
|
|
2568
|
+
<div class="column-zone column-drop-zone max-h-[calc(100vh-280px)] overflow-y-auto"
|
|
2569
|
+
data-status="\${status}"
|
|
2570
|
+
ondragover="handleDragOver(event)"
|
|
2571
|
+
ondragleave="handleDragLeave(event)"
|
|
2572
|
+
ondrop="handleDrop(event, '\${status}')">
|
|
2573
|
+
\${columnTasks.map(t => renderCard(t)).join('')}
|
|
2574
|
+
\${columnTasks.length === 0 ? \`
|
|
2575
|
+
<div class="flex items-center justify-center py-12 text-canvas-400 text-sm">
|
|
2576
|
+
No tasks
|
|
2577
|
+
</div>
|
|
2578
|
+
\` : ''}
|
|
2579
|
+
</div>
|
|
2580
|
+
</div>
|
|
2581
|
+
</div>
|
|
2582
|
+
\`;
|
|
2583
|
+
}
|
|
2584
|
+
|
|
2585
|
+
function renderLog() {
|
|
2586
|
+
const allTabs = Object.keys(state.taskOutput).filter(id => !state.closedTabs.has(id));
|
|
2587
|
+
const activeOutput = state.taskOutput[state.activeTab] || [];
|
|
2588
|
+
const activeTask = state.tasks.find(t => t.id === state.activeTab);
|
|
2589
|
+
const isRunning = state.running.includes(state.activeTab);
|
|
2590
|
+
|
|
2591
|
+
// Filter output based on search
|
|
2592
|
+
let filteredOutput = activeOutput;
|
|
2593
|
+
if (state.logSearch) {
|
|
2594
|
+
const searchLower = state.logSearch.toLowerCase();
|
|
2595
|
+
filteredOutput = activeOutput.filter(l => {
|
|
2596
|
+
const text = l.text || l;
|
|
2597
|
+
return text.toLowerCase().includes(searchLower);
|
|
2598
|
+
});
|
|
2599
|
+
}
|
|
2600
|
+
|
|
2601
|
+
const fullscreenClass = state.logFullscreen ? 'fixed inset-4 z-50' : 'mt-6';
|
|
2602
|
+
const logHeight = state.logFullscreen ? 'calc(100vh - 180px)' : '300px';
|
|
2603
|
+
|
|
2604
|
+
return \`
|
|
2605
|
+
<div class="terminal \${fullscreenClass} \${state.logFullscreen ? 'shadow-2xl' : ''}">
|
|
2606
|
+
<!-- Tab bar -->
|
|
2607
|
+
<div class="terminal-header flex items-center rounded-t-xl">
|
|
2608
|
+
<div class="flex-1 flex items-center overflow-x-auto">
|
|
2609
|
+
\${allTabs.length > 0 ? allTabs.map(id => {
|
|
2610
|
+
const task = state.tasks.find(t => t.id === id);
|
|
2611
|
+
const isActive = state.activeTab === id;
|
|
2612
|
+
const isTaskRunning = state.running.includes(id);
|
|
2613
|
+
return \`
|
|
2614
|
+
<div class="tab flex items-center gap-2 group \${isActive ? 'active' : ''}"
|
|
2615
|
+
onclick="state.activeTab = '\${id}'; render();">
|
|
2616
|
+
<span class="w-2 h-2 rounded-full \${isTaskRunning ? 'bg-status-running animate-pulse' : task?.status === 'completed' ? 'bg-status-success' : task?.status === 'failed' ? 'bg-status-failed' : 'bg-canvas-400'}"></span>
|
|
2617
|
+
<span class="text-sm truncate max-w-[150px]">\${task?.title?.substring(0, 25) || id}</span>
|
|
2618
|
+
<button onclick="event.stopPropagation(); closeLogTab('\${id}')"
|
|
2619
|
+
class="opacity-0 group-hover:opacity-100 text-canvas-500 hover:text-status-failed transition-opacity ml-1">
|
|
2620
|
+
\u2715
|
|
2621
|
+
</button>
|
|
2622
|
+
</div>
|
|
2623
|
+
\`;
|
|
2624
|
+
}).join('') : '<div class="px-4 py-2.5 text-canvas-500 text-sm">No logs</div>'}
|
|
2625
|
+
</div>
|
|
2626
|
+
|
|
2627
|
+
<!-- Controls -->
|
|
2628
|
+
<div class="flex items-center gap-1 px-3 border-l border-white/5">
|
|
2629
|
+
<button onclick="state.showLineNumbers = !state.showLineNumbers; render();"
|
|
2630
|
+
class="btn btn-ghost p-1.5 text-xs tooltip" data-tooltip="\${state.showLineNumbers ? 'Hide' : 'Show'} line numbers">
|
|
2631
|
+
#
|
|
2632
|
+
</button>
|
|
2633
|
+
<button onclick="state.autoScroll = !state.autoScroll; render();"
|
|
2634
|
+
class="btn btn-ghost p-1.5 text-xs \${state.autoScroll ? 'text-accent' : ''} tooltip" data-tooltip="Auto-scroll \${state.autoScroll ? 'on' : 'off'}">
|
|
2635
|
+
\u2193
|
|
2636
|
+
</button>
|
|
2637
|
+
<button onclick="copyLogToClipboard()"
|
|
2638
|
+
class="btn btn-ghost p-1.5 text-xs tooltip" data-tooltip="Copy to clipboard">
|
|
2639
|
+
\u{1F4CB}
|
|
2640
|
+
</button>
|
|
2641
|
+
<button onclick="clearLog('\${state.activeTab}')"
|
|
2642
|
+
class="btn btn-ghost p-1.5 text-xs tooltip" data-tooltip="Clear log">
|
|
2643
|
+
\u{1F5D1}
|
|
2644
|
+
</button>
|
|
2645
|
+
<button onclick="toggleLogFullscreen()"
|
|
2646
|
+
class="btn btn-ghost p-1.5 text-xs tooltip" data-tooltip="\${state.logFullscreen ? 'Exit' : 'Enter'} fullscreen">
|
|
2647
|
+
\${state.logFullscreen ? '\u2299' : '\u26F6'}
|
|
2648
|
+
</button>
|
|
2649
|
+
</div>
|
|
2650
|
+
</div>
|
|
2651
|
+
|
|
2652
|
+
<!-- Search and task info bar -->
|
|
2653
|
+
<div class="flex items-center justify-between px-4 py-2.5 bg-canvas-50/50 border-b border-white/5">
|
|
2654
|
+
<div class="flex items-center gap-3">
|
|
2655
|
+
<span class="text-sm font-display font-medium text-canvas-700">
|
|
2656
|
+
\${activeTask ? activeTask.title : 'Execution Log'}
|
|
2657
|
+
</span>
|
|
2658
|
+
\${isRunning ? \`
|
|
2659
|
+
<span class="stat-pill text-status-running border-status-running/20">
|
|
2660
|
+
<span class="stat-dot bg-status-running animate-pulse"></span>
|
|
2661
|
+
Running \${formatElapsed(state.taskStartTime[state.activeTab])}
|
|
2662
|
+
</span>
|
|
2663
|
+
\` : ''}
|
|
2664
|
+
<span class="text-xs text-canvas-500 font-mono">\${filteredOutput.length} lines</span>
|
|
2665
|
+
</div>
|
|
2666
|
+
<div class="flex items-center gap-2">
|
|
2667
|
+
<div class="relative">
|
|
2668
|
+
<input type="text"
|
|
2669
|
+
placeholder="Search logs..."
|
|
2670
|
+
value="\${escapeHtml(state.logSearch)}"
|
|
2671
|
+
oninput="state.logSearch = this.value; render();"
|
|
2672
|
+
class="input text-xs py-1.5 pr-8 w-48">
|
|
2673
|
+
\${state.logSearch ? \`
|
|
2674
|
+
<button onclick="state.logSearch = ''; render();"
|
|
2675
|
+
class="absolute right-2 top-1/2 -translate-y-1/2 text-canvas-500 hover:text-canvas-700">
|
|
2676
|
+
\u2715
|
|
2677
|
+
</button>
|
|
2678
|
+
\` : ''}
|
|
2679
|
+
</div>
|
|
2680
|
+
</div>
|
|
2681
|
+
</div>
|
|
2682
|
+
|
|
2683
|
+
<!-- Log content -->
|
|
2684
|
+
<div id="log-content"
|
|
2685
|
+
class="p-4 overflow-y-auto text-canvas-600 rounded-b-xl font-mono"
|
|
2686
|
+
style="height: \${logHeight}">
|
|
2687
|
+
\${filteredOutput.length > 0
|
|
2688
|
+
? filteredOutput.map((l, i) => {
|
|
2689
|
+
const text = l.text || l;
|
|
2690
|
+
const timestamp = l.timestamp ? new Date(l.timestamp).toLocaleTimeString() : '';
|
|
2691
|
+
return \`
|
|
2692
|
+
<div class="log-line flex items-start gap-3">
|
|
2693
|
+
\${state.showLineNumbers ? \`<span class="text-canvas-400 select-none w-8 text-right flex-shrink-0">\${i + 1}</span>\` : ''}
|
|
2694
|
+
\${timestamp ? \`<span class="text-canvas-400 select-none flex-shrink-0">\${timestamp}</span>\` : ''}
|
|
2695
|
+
<span class="whitespace-pre-wrap flex-1">\${highlightLog(text)}</span>
|
|
2696
|
+
</div>
|
|
2697
|
+
\`;
|
|
2698
|
+
}).join('')
|
|
2699
|
+
: \`<div class="flex flex-col items-center justify-center h-full text-canvas-500">
|
|
2700
|
+
<span class="text-4xl mb-3 opacity-20">\u{1F4CB}</span>
|
|
2701
|
+
<span class="text-sm">\${state.logSearch ? 'No matching lines' : 'No output yet. Run a task to see logs here.'}</span>
|
|
2702
|
+
</div>\`}
|
|
2703
|
+
</div>
|
|
2704
|
+
</div>
|
|
2705
|
+
\`;
|
|
2706
|
+
}
|
|
2707
|
+
|
|
2708
|
+
function renderModal() {
|
|
2709
|
+
if (!state.showModal) return '';
|
|
2710
|
+
|
|
2711
|
+
if (state.showModal === 'new' || state.showModal === 'edit') {
|
|
2712
|
+
const task = state.editingTask || { title: '', description: '', category: 'functional', priority: 'medium', steps: [], status: 'draft' };
|
|
2713
|
+
const isEdit = state.showModal === 'edit';
|
|
2714
|
+
|
|
2715
|
+
return \`
|
|
2716
|
+
<div class="modal-backdrop fixed inset-0 flex items-center justify-center z-50" onclick="if(event.target === event.currentTarget) { state.showModal = null; state.editingTask = null; render(); }">
|
|
2717
|
+
<div class="modal-content card rounded-xl w-full max-w-lg mx-4 max-h-[90vh] overflow-y-auto">
|
|
2718
|
+
<div class="px-6 py-4 border-b border-white/5 flex justify-between items-center">
|
|
2719
|
+
<h3 class="font-display font-semibold text-canvas-800 text-lg">\${isEdit ? '\u270F\uFE0F Edit Task' : '\u{1F4DD} New Task'}</h3>
|
|
2720
|
+
<button onclick="state.showModal = null; state.editingTask = null; render();" class="btn btn-ghost p-1.5 text-canvas-500 hover:text-canvas-700">\u2715</button>
|
|
2721
|
+
</div>
|
|
2722
|
+
<form onsubmit="handleTaskSubmit(event, \${isEdit})" class="p-6 space-y-5">
|
|
2723
|
+
<div>
|
|
2724
|
+
<label class="block text-sm font-medium text-canvas-700 mb-2">Title *</label>
|
|
2725
|
+
<input type="text" name="title" value="\${escapeHtml(task.title)}" required
|
|
2726
|
+
class="input w-full" placeholder="Add login functionality">
|
|
2727
|
+
</div>
|
|
2728
|
+
<div>
|
|
2729
|
+
<label class="block text-sm font-medium text-canvas-700 mb-2">Description *</label>
|
|
2730
|
+
<textarea name="description" rows="4" required
|
|
2731
|
+
class="input w-full resize-none" placeholder="Describe what needs to be done...">\${escapeHtml(task.description)}</textarea>
|
|
2732
|
+
</div>
|
|
2733
|
+
<div class="grid grid-cols-2 gap-4">
|
|
2734
|
+
<div>
|
|
2735
|
+
<label class="block text-sm font-medium text-canvas-700 mb-2">Category</label>
|
|
2736
|
+
<select name="category" class="input w-full">
|
|
2737
|
+
<option value="functional" \${task.category === 'functional' ? 'selected' : ''}>\u2699\uFE0F Functional</option>
|
|
2738
|
+
<option value="ui" \${task.category === 'ui' ? 'selected' : ''}>\u{1F3A8} UI</option>
|
|
2739
|
+
<option value="bug" \${task.category === 'bug' ? 'selected' : ''}>\u{1F41B} Bug</option>
|
|
2740
|
+
<option value="enhancement" \${task.category === 'enhancement' ? 'selected' : ''}>\u2728 Enhancement</option>
|
|
2741
|
+
<option value="testing" \${task.category === 'testing' ? 'selected' : ''}>\u{1F9EA} Testing</option>
|
|
2742
|
+
<option value="refactor" \${task.category === 'refactor' ? 'selected' : ''}>\u{1F527} Refactor</option>
|
|
2743
|
+
</select>
|
|
2744
|
+
</div>
|
|
2745
|
+
<div>
|
|
2746
|
+
<label class="block text-sm font-medium text-canvas-700 mb-2">Priority</label>
|
|
2747
|
+
<select name="priority" class="input w-full">
|
|
2748
|
+
<option value="low" \${task.priority === 'low' ? 'selected' : ''}>Low</option>
|
|
2749
|
+
<option value="medium" \${task.priority === 'medium' ? 'selected' : ''}>Medium</option>
|
|
2750
|
+
<option value="high" \${task.priority === 'high' ? 'selected' : ''}>High</option>
|
|
2751
|
+
<option value="critical" \${task.priority === 'critical' ? 'selected' : ''}>Critical</option>
|
|
2752
|
+
</select>
|
|
2753
|
+
</div>
|
|
2754
|
+
</div>
|
|
2755
|
+
<div>
|
|
2756
|
+
<label class="block text-sm font-medium text-canvas-700 mb-2">Verification Steps (one per line)</label>
|
|
2757
|
+
<textarea name="steps" rows="4"
|
|
2758
|
+
class="input w-full resize-none font-mono text-sm" placeholder="Navigate to /login Enter valid credentials Click submit button Verify redirect">\${task.steps.join('\\n')}</textarea>
|
|
2759
|
+
</div>
|
|
2760
|
+
<div class="flex justify-end gap-3 pt-4 border-t border-white/5">
|
|
2761
|
+
<button type="button" onclick="state.showModal = null; state.editingTask = null; render();"
|
|
2762
|
+
class="btn btn-ghost px-4 py-2.5">Cancel</button>
|
|
2763
|
+
\${!isEdit ? \`
|
|
2764
|
+
<button type="submit" name="action" value="draft"
|
|
2765
|
+
class="btn btn-ghost px-4 py-2.5">Save as Draft</button>
|
|
2766
|
+
\` : ''}
|
|
2767
|
+
<button type="submit" name="action" value="\${isEdit ? 'save' : 'ready'}"
|
|
2768
|
+
class="btn btn-primary px-5 py-2.5">\${isEdit ? 'Save Changes' : 'Create Ready'}</button>
|
|
2769
|
+
</div>
|
|
2770
|
+
</form>
|
|
2771
|
+
</div>
|
|
2772
|
+
</div>
|
|
2773
|
+
\`;
|
|
2774
|
+
}
|
|
2775
|
+
|
|
2776
|
+
if (state.showModal === 'templates') {
|
|
2777
|
+
return \`
|
|
2778
|
+
<div class="modal-backdrop fixed inset-0 flex items-center justify-center z-50" onclick="if(event.target === event.currentTarget) { state.showModal = null; render(); }">
|
|
2779
|
+
<div class="modal-content card rounded-xl w-full max-w-2xl mx-4 max-h-[80vh] overflow-y-auto">
|
|
2780
|
+
<div class="px-6 py-4 border-b border-white/5 flex justify-between items-center sticky top-0 bg-canvas-100/95 backdrop-blur rounded-t-xl z-10">
|
|
2781
|
+
<h3 class="font-display font-semibold text-canvas-800 text-lg">\u{1F4CB} Task Templates</h3>
|
|
2782
|
+
<button onclick="state.showModal = null; render();" class="btn btn-ghost p-1.5 text-canvas-500 hover:text-canvas-700">\u2715</button>
|
|
2783
|
+
</div>
|
|
2784
|
+
<div class="p-6 grid grid-cols-2 gap-4">
|
|
2785
|
+
\${state.templates.map(t => \`
|
|
2786
|
+
<button onclick="applyTemplate('\${t.id}')"
|
|
2787
|
+
class="card text-left p-4 hover:border-accent/30 transition-all group">
|
|
2788
|
+
<div class="font-display font-medium text-canvas-800 group-hover:text-accent transition-colors">\${t.icon} \${t.name}</div>
|
|
2789
|
+
<div class="text-sm text-canvas-500 mt-1">\${t.description}</div>
|
|
2790
|
+
</button>
|
|
2791
|
+
\`).join('')}
|
|
2792
|
+
</div>
|
|
2793
|
+
</div>
|
|
2794
|
+
</div>
|
|
2795
|
+
\`;
|
|
2796
|
+
}
|
|
2797
|
+
|
|
2798
|
+
if (state.showModal === 'afk') {
|
|
2799
|
+
return \`
|
|
2800
|
+
<div class="modal-backdrop fixed inset-0 flex items-center justify-center z-50" onclick="if(event.target === event.currentTarget) { state.showModal = null; render(); }">
|
|
2801
|
+
<div class="modal-content card rounded-xl w-full max-w-md mx-4">
|
|
2802
|
+
<div class="px-6 py-4 border-b border-white/5 flex justify-between items-center">
|
|
2803
|
+
<h3 class="font-display font-semibold text-canvas-800 text-lg">\u{1F504} AFK Mode</h3>
|
|
2804
|
+
<button onclick="state.showModal = null; render();" class="btn btn-ghost p-1.5 text-canvas-500 hover:text-canvas-700">\u2715</button>
|
|
2805
|
+
</div>
|
|
2806
|
+
<div class="p-6">
|
|
2807
|
+
<p class="text-sm text-canvas-600 mb-5">Run the agent in a loop, automatically picking up tasks from the "Ready" column until complete.</p>
|
|
2808
|
+
<div class="space-y-4">
|
|
2809
|
+
<div>
|
|
2810
|
+
<label class="block text-sm font-medium text-canvas-700 mb-2">Maximum Iterations</label>
|
|
2811
|
+
<input type="number" id="afk-iterations" value="10" min="1" max="100"
|
|
2812
|
+
class="input w-full">
|
|
2813
|
+
</div>
|
|
2814
|
+
<div>
|
|
2815
|
+
<label class="block text-sm font-medium text-canvas-700 mb-2">Concurrent Tasks</label>
|
|
2816
|
+
<select id="afk-concurrent" class="input w-full">
|
|
2817
|
+
<option value="1">1 (Sequential)</option>
|
|
2818
|
+
<option value="2">2</option>
|
|
2819
|
+
<option value="3">3 (Max)</option>
|
|
2820
|
+
</select>
|
|
2821
|
+
</div>
|
|
2822
|
+
<div class="bg-status-running/10 border border-status-running/20 rounded-lg p-3">
|
|
2823
|
+
<p class="text-xs text-status-running">\u26A0\uFE0F You can close this tab - the agent will continue running. Check back later or watch the terminal output.</p>
|
|
2824
|
+
</div>
|
|
2825
|
+
</div>
|
|
2826
|
+
<div class="flex justify-end gap-3 mt-6">
|
|
2827
|
+
<button onclick="state.showModal = null; render();"
|
|
2828
|
+
class="btn btn-ghost px-4 py-2.5">Cancel</button>
|
|
2829
|
+
<button onclick="handleStartAFK()"
|
|
2830
|
+
class="btn px-5 py-2.5 bg-status-success text-canvas font-medium">\u{1F680} Start AFK Mode</button>
|
|
2831
|
+
</div>
|
|
2832
|
+
</div>
|
|
2833
|
+
</div>
|
|
2834
|
+
</div>
|
|
2835
|
+
\`;
|
|
2836
|
+
}
|
|
2837
|
+
|
|
2838
|
+
return '';
|
|
2839
|
+
}
|
|
2840
|
+
|
|
2841
|
+
function renderSidePanel() {
|
|
2842
|
+
if (!state.sidePanel) return '';
|
|
2843
|
+
|
|
2844
|
+
const task = state.tasks.find(t => t.id === state.sidePanel);
|
|
2845
|
+
if (!task) return '';
|
|
2846
|
+
|
|
2847
|
+
const isRunning = state.running.includes(task.id);
|
|
2848
|
+
const output = state.taskOutput[task.id] || [];
|
|
2849
|
+
const startTime = state.taskStartTime[task.id];
|
|
2850
|
+
const lastExec = task.executionHistory?.[task.executionHistory.length - 1];
|
|
2851
|
+
|
|
2852
|
+
return \`
|
|
2853
|
+
<div class="side-panel">
|
|
2854
|
+
<!-- Header -->
|
|
2855
|
+
<div class="side-panel-header">
|
|
2856
|
+
<div class="flex justify-between items-start">
|
|
2857
|
+
<div class="flex-1 pr-4">
|
|
2858
|
+
<h2 class="font-semibold text-canvas-900 text-lg leading-tight">\${escapeHtml(task.title)}</h2>
|
|
2859
|
+
<div class="flex items-center gap-2 mt-2">
|
|
2860
|
+
<span class="status-badge status-badge-\${task.status}">
|
|
2861
|
+
<span class="w-1.5 h-1.5 rounded-full bg-current \${isRunning ? 'animate-pulse' : ''}"></span>
|
|
2862
|
+
\${task.status.replace('_', ' ')}
|
|
2863
|
+
</span>
|
|
2864
|
+
</div>
|
|
2865
|
+
</div>
|
|
2866
|
+
<div class="flex items-center gap-1">
|
|
2867
|
+
<button onclick="state.editingTask = state.tasks.find(t => t.id === '\${task.id}'); state.showModal = 'edit'; render();"
|
|
2868
|
+
class="btn btn-ghost p-2 text-canvas-500 hover:text-canvas-700" title="Edit">
|
|
2869
|
+
\u270F\uFE0F
|
|
2870
|
+
</button>
|
|
2871
|
+
<button onclick="if(confirm('Delete this task?')) { deleteTask('\${task.id}'); closeSidePanel(); }"
|
|
2872
|
+
class="btn btn-ghost p-2 text-canvas-500 hover:text-status-failed" title="Delete">
|
|
2873
|
+
\u{1F5D1}\uFE0F
|
|
2874
|
+
</button>
|
|
2875
|
+
<button onclick="closeSidePanel()" class="btn btn-ghost p-2 text-canvas-500 hover:text-canvas-700" title="Close">
|
|
2876
|
+
\u2715
|
|
2877
|
+
</button>
|
|
2878
|
+
</div>
|
|
2879
|
+
</div>
|
|
2880
|
+
</div>
|
|
2881
|
+
|
|
2882
|
+
<!-- Description -->
|
|
2883
|
+
<div class="px-5 py-4 border-b border-canvas-200 flex-shrink-0">
|
|
2884
|
+
<p class="text-sm text-canvas-600 leading-relaxed">\${escapeHtml(task.description)}</p>
|
|
2885
|
+
\${task.steps && task.steps.length > 0 ? \`
|
|
2886
|
+
<div class="mt-3">
|
|
2887
|
+
<div class="text-xs font-medium text-canvas-500 mb-2">Steps:</div>
|
|
2888
|
+
<ul class="text-sm text-canvas-600 space-y-1">
|
|
2889
|
+
\${task.steps.map(s => \`<li class="flex gap-2"><span class="text-canvas-400">\u2022</span>\${escapeHtml(s)}</li>\`).join('')}
|
|
2890
|
+
</ul>
|
|
2891
|
+
</div>
|
|
2892
|
+
\` : ''}
|
|
2893
|
+
</div>
|
|
2894
|
+
|
|
2895
|
+
<!-- Task Details Grid -->
|
|
2896
|
+
<div class="details-grid">
|
|
2897
|
+
<div class="details-item">
|
|
2898
|
+
<span class="details-label">Priority</span>
|
|
2899
|
+
<span class="details-value capitalize">\${task.priority}</span>
|
|
2900
|
+
</div>
|
|
2901
|
+
<div class="details-item">
|
|
2902
|
+
<span class="details-label">Category</span>
|
|
2903
|
+
<span class="details-value">\${categoryIcons[task.category] || ''} \${task.category}</span>
|
|
2904
|
+
</div>
|
|
2905
|
+
\${startTime || lastExec ? \`
|
|
2906
|
+
<div class="details-item">
|
|
2907
|
+
<span class="details-label">Started</span>
|
|
2908
|
+
<span class="details-value">\${new Date(startTime || lastExec?.startedAt).toLocaleString()}</span>
|
|
2909
|
+
</div>
|
|
2910
|
+
\` : ''}
|
|
2911
|
+
\${lastExec?.duration ? \`
|
|
2912
|
+
<div class="details-item">
|
|
2913
|
+
<span class="details-label">Duration</span>
|
|
2914
|
+
<span class="details-value">\${Math.round(lastExec.duration / 1000)}s</span>
|
|
2915
|
+
</div>
|
|
2916
|
+
\` : ''}
|
|
2917
|
+
</div>
|
|
2918
|
+
|
|
2919
|
+
<!-- Action Buttons -->
|
|
2920
|
+
<div class="px-5 py-4 border-b border-canvas-200 flex gap-2 flex-shrink-0">
|
|
2921
|
+
\${task.status === 'draft' ? \`
|
|
2922
|
+
<button onclick="updateTask('\${task.id}', { status: 'ready' })" class="btn btn-primary px-4 py-2 text-sm">
|
|
2923
|
+
\u2192 Move to Ready
|
|
2924
|
+
</button>
|
|
2925
|
+
\` : ''}
|
|
2926
|
+
\${task.status === 'ready' ? \`
|
|
2927
|
+
<button onclick="runTask('\${task.id}')" class="btn btn-primary px-4 py-2 text-sm">
|
|
2928
|
+
\u25B6 Run Task
|
|
2929
|
+
</button>
|
|
2930
|
+
\` : ''}
|
|
2931
|
+
\${task.status === 'in_progress' ? \`
|
|
2932
|
+
<button onclick="cancelTask('\${task.id}')" class="btn btn-danger px-4 py-2 text-sm">
|
|
2933
|
+
\u23F9 Stop Attempt
|
|
2934
|
+
</button>
|
|
2935
|
+
\` : ''}
|
|
2936
|
+
\${task.status === 'failed' ? \`
|
|
2937
|
+
<button onclick="retryTask('\${task.id}')" class="btn btn-primary px-4 py-2 text-sm">
|
|
2938
|
+
\u21BB Retry
|
|
2939
|
+
</button>
|
|
2940
|
+
\` : ''}
|
|
2941
|
+
</div>
|
|
2942
|
+
|
|
2943
|
+
<!-- Tabs -->
|
|
2944
|
+
<div class="side-panel-tabs">
|
|
2945
|
+
<div class="side-panel-tab \${state.sidePanelTab === 'logs' ? 'active' : ''}" onclick="state.sidePanelTab = 'logs'; render();">
|
|
2946
|
+
\u{1F4CB} Logs
|
|
2947
|
+
</div>
|
|
2948
|
+
<div class="side-panel-tab \${state.sidePanelTab === 'details' ? 'active' : ''}" onclick="state.sidePanelTab = 'details'; render();">
|
|
2949
|
+
\u{1F4C4} Details
|
|
2950
|
+
</div>
|
|
2951
|
+
</div>
|
|
2952
|
+
|
|
2953
|
+
<!-- Tab Content -->
|
|
2954
|
+
<div class="side-panel-body">
|
|
2955
|
+
\${state.sidePanelTab === 'logs' ? \`
|
|
2956
|
+
<div class="log-container" id="side-panel-log">
|
|
2957
|
+
\${output.length > 0
|
|
2958
|
+
? output.map((l, i) => {
|
|
2959
|
+
const text = l.text || l;
|
|
2960
|
+
return \`<div class="log-line">\${highlightLog(text)}</div>\`;
|
|
2961
|
+
}).join('')
|
|
2962
|
+
: \`<div class="text-canvas-400 text-sm">\${isRunning ? 'Waiting for output...' : 'No logs available. Run the task to see output.'}</div>\`
|
|
2963
|
+
}
|
|
2964
|
+
</div>
|
|
2965
|
+
\` : \`
|
|
2966
|
+
<div class="p-5">
|
|
2967
|
+
<div class="text-sm text-canvas-600">
|
|
2968
|
+
<div class="mb-4">
|
|
2969
|
+
<div class="text-xs font-medium text-canvas-500 mb-1">Created</div>
|
|
2970
|
+
<div>\${new Date(task.createdAt).toLocaleString()}</div>
|
|
2971
|
+
</div>
|
|
2972
|
+
<div class="mb-4">
|
|
2973
|
+
<div class="text-xs font-medium text-canvas-500 mb-1">Last Updated</div>
|
|
2974
|
+
<div>\${new Date(task.updatedAt).toLocaleString()}</div>
|
|
2975
|
+
</div>
|
|
2976
|
+
\${task.executionHistory && task.executionHistory.length > 0 ? \`
|
|
2977
|
+
<div>
|
|
2978
|
+
<div class="text-xs font-medium text-canvas-500 mb-2">Execution History</div>
|
|
2979
|
+
<div class="space-y-2">
|
|
2980
|
+
\${task.executionHistory.slice(-5).reverse().map(exec => \`
|
|
2981
|
+
<div class="bg-canvas-100 rounded p-2 text-xs">
|
|
2982
|
+
<div class="flex justify-between">
|
|
2983
|
+
<span class="\${exec.status === 'completed' ? 'text-status-success' : 'text-status-failed'}">\${exec.status}</span>
|
|
2984
|
+
<span class="text-canvas-500">\${Math.round(exec.duration / 1000)}s</span>
|
|
2985
|
+
</div>
|
|
2986
|
+
<div class="text-canvas-500 mt-1">\${new Date(exec.startedAt).toLocaleString()}</div>
|
|
2987
|
+
\${exec.error ? \`<div class="text-status-failed mt-1">\${escapeHtml(exec.error)}</div>\` : ''}
|
|
2988
|
+
</div>
|
|
2989
|
+
\`).join('')}
|
|
2990
|
+
</div>
|
|
2991
|
+
</div>
|
|
2992
|
+
\` : ''}
|
|
2993
|
+
</div>
|
|
2994
|
+
</div>
|
|
2995
|
+
\`}
|
|
2996
|
+
</div>
|
|
2997
|
+
</div>
|
|
2998
|
+
\`;
|
|
2999
|
+
}
|
|
3000
|
+
|
|
3001
|
+
function renderAFKBar() {
|
|
3002
|
+
if (!state.afk.running) return '';
|
|
3003
|
+
const progress = (state.afk.currentIteration / state.afk.maxIterations) * 100;
|
|
3004
|
+
return \`
|
|
3005
|
+
<div class="bg-gradient-to-r from-status-success/10 to-status-success/5 border-b border-status-success/20 px-6 py-3">
|
|
3006
|
+
<div class="flex items-center justify-between">
|
|
3007
|
+
<div class="flex items-center gap-5">
|
|
3008
|
+
<div class="flex items-center gap-2">
|
|
3009
|
+
<span class="text-xl animate-pulse">\u{1F504}</span>
|
|
3010
|
+
<span class="text-sm font-display font-semibold text-status-success">AFK Mode Active</span>
|
|
3011
|
+
</div>
|
|
3012
|
+
<div class="h-4 w-px bg-status-success/20"></div>
|
|
3013
|
+
<div class="flex items-center gap-4">
|
|
3014
|
+
<div class="stat-pill border-status-success/20">
|
|
3015
|
+
<span class="text-xs text-canvas-500">Iteration</span>
|
|
3016
|
+
<span class="text-sm font-mono text-status-success">\${state.afk.currentIteration}/\${state.afk.maxIterations}</span>
|
|
3017
|
+
</div>
|
|
3018
|
+
<div class="w-32 h-1.5 bg-canvas-200 rounded-full overflow-hidden">
|
|
3019
|
+
<div class="h-full bg-gradient-to-r from-status-success to-status-success/70 rounded-full transition-all" style="width: \${progress}%"></div>
|
|
3020
|
+
</div>
|
|
3021
|
+
</div>
|
|
3022
|
+
<div class="h-4 w-px bg-status-success/20"></div>
|
|
3023
|
+
<div class="stat-pill border-status-success/20">
|
|
3024
|
+
<span class="text-xs text-canvas-500">Completed</span>
|
|
3025
|
+
<span class="text-sm font-mono text-status-success">\${state.afk.tasksCompleted}</span>
|
|
3026
|
+
</div>
|
|
3027
|
+
</div>
|
|
3028
|
+
<button onclick="stopAFK()"
|
|
3029
|
+
class="btn text-sm px-4 py-1.5 bg-status-failed/15 hover:bg-status-failed/25 text-status-failed border border-status-failed/20">
|
|
3030
|
+
\u23F9 Stop AFK
|
|
3031
|
+
</button>
|
|
3032
|
+
</div>
|
|
3033
|
+
</div>
|
|
3034
|
+
\`;
|
|
3035
|
+
}
|
|
3036
|
+
|
|
3037
|
+
function renderStats() {
|
|
3038
|
+
const counts = {
|
|
3039
|
+
draft: state.tasks.filter(t => t.status === 'draft').length,
|
|
3040
|
+
ready: state.tasks.filter(t => t.status === 'ready').length,
|
|
3041
|
+
in_progress: state.tasks.filter(t => t.status === 'in_progress').length,
|
|
3042
|
+
completed: state.tasks.filter(t => t.status === 'completed').length,
|
|
3043
|
+
failed: state.tasks.filter(t => t.status === 'failed').length,
|
|
3044
|
+
};
|
|
3045
|
+
const total = state.tasks.length;
|
|
3046
|
+
|
|
3047
|
+
return \`
|
|
3048
|
+
<div class="flex items-center gap-3">
|
|
3049
|
+
<div class="stat-pill">
|
|
3050
|
+
<span class="text-xs text-canvas-500">Total</span>
|
|
3051
|
+
<span class="text-sm font-semibold text-canvas-700">\${total}</span>
|
|
3052
|
+
</div>
|
|
3053
|
+
\${counts.in_progress > 0 ? \`
|
|
3054
|
+
<div class="stat-pill border-status-running/20">
|
|
3055
|
+
<span class="stat-dot bg-status-running animate-pulse"></span>
|
|
3056
|
+
<span class="text-xs text-status-running font-medium">\${counts.in_progress} running</span>
|
|
3057
|
+
</div>
|
|
3058
|
+
\` : ''}
|
|
3059
|
+
\${counts.completed > 0 ? \`
|
|
3060
|
+
<div class="stat-pill border-status-success/20">
|
|
3061
|
+
<span class="stat-dot bg-status-success"></span>
|
|
3062
|
+
<span class="text-xs text-status-success">\${counts.completed} done</span>
|
|
3063
|
+
</div>
|
|
3064
|
+
\` : ''}
|
|
3065
|
+
</div>
|
|
3066
|
+
\`;
|
|
3067
|
+
}
|
|
3068
|
+
|
|
3069
|
+
// Event handlers
|
|
3070
|
+
async function handleTaskSubmit(e, isEdit) {
|
|
3071
|
+
e.preventDefault();
|
|
3072
|
+
const form = e.target;
|
|
3073
|
+
const data = new FormData(form);
|
|
3074
|
+
const action = e.submitter?.value || 'ready';
|
|
3075
|
+
|
|
3076
|
+
const task = {
|
|
3077
|
+
title: data.get('title'),
|
|
3078
|
+
description: data.get('description'),
|
|
3079
|
+
category: data.get('category'),
|
|
3080
|
+
priority: data.get('priority'),
|
|
3081
|
+
steps: data.get('steps').split('\\n').filter(s => s.trim()),
|
|
3082
|
+
status: action === 'draft' ? 'draft' : 'ready'
|
|
3083
|
+
};
|
|
3084
|
+
|
|
3085
|
+
if (isEdit && state.editingTask) {
|
|
3086
|
+
await updateTask(state.editingTask.id, task);
|
|
3087
|
+
} else {
|
|
3088
|
+
await createTask(task);
|
|
3089
|
+
}
|
|
3090
|
+
|
|
3091
|
+
state.showModal = null;
|
|
3092
|
+
state.editingTask = null;
|
|
3093
|
+
render();
|
|
3094
|
+
}
|
|
3095
|
+
|
|
3096
|
+
async function handleAIGenerate() {
|
|
3097
|
+
const prompt = document.getElementById('ai-prompt').value;
|
|
3098
|
+
if (!prompt.trim()) return;
|
|
3099
|
+
|
|
3100
|
+
state.aiPrompt = prompt;
|
|
3101
|
+
try {
|
|
3102
|
+
const task = await generateTask(prompt);
|
|
3103
|
+
state.editingTask = task;
|
|
3104
|
+
state.showModal = 'edit';
|
|
3105
|
+
state.aiPrompt = '';
|
|
3106
|
+
} catch (e) {
|
|
3107
|
+
alert('Failed to generate task: ' + e.message);
|
|
3108
|
+
}
|
|
3109
|
+
render();
|
|
3110
|
+
}
|
|
3111
|
+
|
|
3112
|
+
function applyTemplate(templateId) {
|
|
3113
|
+
const template = state.templates.find(t => t.id === templateId);
|
|
3114
|
+
if (!template) return;
|
|
3115
|
+
|
|
3116
|
+
state.editingTask = {
|
|
3117
|
+
title: template.titleTemplate,
|
|
3118
|
+
description: template.descriptionTemplate,
|
|
3119
|
+
category: template.category,
|
|
3120
|
+
priority: template.priority,
|
|
3121
|
+
steps: template.stepsTemplate,
|
|
3122
|
+
status: 'draft'
|
|
3123
|
+
};
|
|
3124
|
+
state.showModal = 'edit';
|
|
3125
|
+
render();
|
|
3126
|
+
}
|
|
3127
|
+
|
|
3128
|
+
function handleStartAFK() {
|
|
3129
|
+
const iterations = parseInt(document.getElementById('afk-iterations').value) || 10;
|
|
3130
|
+
const concurrent = parseInt(document.getElementById('afk-concurrent').value) || 1;
|
|
3131
|
+
startAFK(iterations, concurrent);
|
|
3132
|
+
state.showModal = null;
|
|
3133
|
+
render();
|
|
3134
|
+
}
|
|
3135
|
+
|
|
3136
|
+
// Filter tasks based on search query
|
|
3137
|
+
function filterTasks(tasks) {
|
|
3138
|
+
if (!state.searchQuery) return tasks;
|
|
3139
|
+
const query = state.searchQuery.toLowerCase();
|
|
3140
|
+
return tasks.filter(t =>
|
|
3141
|
+
t.title.toLowerCase().includes(query) ||
|
|
3142
|
+
t.description.toLowerCase().includes(query) ||
|
|
3143
|
+
t.category.toLowerCase().includes(query)
|
|
3144
|
+
);
|
|
3145
|
+
}
|
|
3146
|
+
|
|
3147
|
+
// Utility
|
|
3148
|
+
function escapeHtml(str) {
|
|
3149
|
+
if (!str) return '';
|
|
3150
|
+
return String(str).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
|
3151
|
+
}
|
|
3152
|
+
|
|
3153
|
+
// Main render
|
|
3154
|
+
function render() {
|
|
3155
|
+
const app = document.getElementById('app');
|
|
3156
|
+
const hasSidePanel = state.sidePanel !== null;
|
|
3157
|
+
|
|
3158
|
+
app.innerHTML = \`
|
|
3159
|
+
<div class="min-h-screen flex flex-col bg-canvas-50">
|
|
3160
|
+
<!-- Header -->
|
|
3161
|
+
<header class="sticky top-0 z-40 bg-white border-b border-canvas-200">
|
|
3162
|
+
<div class="px-6 py-3 flex items-center justify-between">
|
|
3163
|
+
<div class="flex items-center gap-6">
|
|
3164
|
+
<h1 class="text-lg font-semibold text-canvas-900">Claude Kanban</h1>
|
|
3165
|
+
<div class="flex items-center gap-2">
|
|
3166
|
+
<input type="text"
|
|
3167
|
+
placeholder="Search tasks..."
|
|
3168
|
+
value="\${escapeHtml(state.searchQuery)}"
|
|
3169
|
+
oninput="state.searchQuery = this.value; render();"
|
|
3170
|
+
class="input text-sm py-1.5 w-48">
|
|
3171
|
+
</div>
|
|
3172
|
+
</div>
|
|
3173
|
+
<div class="flex items-center gap-2">
|
|
3174
|
+
<button onclick="state.showModal = 'new'; render();"
|
|
3175
|
+
class="btn btn-primary px-4 py-2 text-sm">
|
|
3176
|
+
+ Add Task
|
|
3177
|
+
</button>
|
|
3178
|
+
<button onclick="state.showModal = 'afk'; render();"
|
|
3179
|
+
class="btn btn-ghost px-3 py-2 text-sm \${state.afk.running ? 'text-status-success' : ''}"
|
|
3180
|
+
title="AFK Mode">
|
|
3181
|
+
\u{1F504} \${state.afk.running ? 'AFK On' : 'AFK'}
|
|
3182
|
+
</button>
|
|
3183
|
+
</div>
|
|
3184
|
+
</div>
|
|
3185
|
+
</header>
|
|
3186
|
+
|
|
3187
|
+
\${renderAFKBar()}
|
|
3188
|
+
|
|
3189
|
+
<!-- Main Content Area - Flex container for board + sidebar -->
|
|
3190
|
+
<div class="flex flex-1 overflow-hidden">
|
|
3191
|
+
<!-- Kanban Board -->
|
|
3192
|
+
<main class="main-content overflow-x-auto p-6">
|
|
3193
|
+
<div class="flex gap-4">
|
|
3194
|
+
\${renderColumn('draft', 'To Do', filterTasks(state.tasks))}
|
|
3195
|
+
\${renderColumn('ready', 'Ready', filterTasks(state.tasks))}
|
|
3196
|
+
\${renderColumn('in_progress', 'In Progress', filterTasks(state.tasks))}
|
|
3197
|
+
\${renderColumn('completed', 'Done', filterTasks(state.tasks))}
|
|
3198
|
+
\${renderColumn('failed', 'Failed', filterTasks(state.tasks))}
|
|
3199
|
+
</div>
|
|
3200
|
+
</main>
|
|
3201
|
+
|
|
3202
|
+
<!-- Side Panel (pushes content when open) -->
|
|
3203
|
+
\${hasSidePanel ? renderSidePanel() : ''}
|
|
3204
|
+
</div>
|
|
3205
|
+
|
|
3206
|
+
\${renderModal()}
|
|
3207
|
+
</div>
|
|
3208
|
+
\`;
|
|
3209
|
+
|
|
3210
|
+
// Auto-scroll side panel logs
|
|
3211
|
+
if (hasSidePanel && state.sidePanelTab === 'logs' && state.autoScroll) {
|
|
3212
|
+
const logEl = document.getElementById('side-panel-log');
|
|
3213
|
+
if (logEl) logEl.scrollTop = logEl.scrollHeight;
|
|
3214
|
+
}
|
|
3215
|
+
|
|
3216
|
+
// Update running task elapsed times every second
|
|
3217
|
+
if (state.running.length > 0 && !window.elapsedInterval) {
|
|
3218
|
+
window.elapsedInterval = setInterval(() => {
|
|
3219
|
+
if (state.running.length > 0) render();
|
|
3220
|
+
else {
|
|
3221
|
+
clearInterval(window.elapsedInterval);
|
|
3222
|
+
window.elapsedInterval = null;
|
|
3223
|
+
}
|
|
3224
|
+
}, 1000);
|
|
3225
|
+
}
|
|
3226
|
+
}
|
|
3227
|
+
|
|
3228
|
+
// Expose to window for inline handlers
|
|
3229
|
+
window.state = state;
|
|
3230
|
+
window.render = render;
|
|
3231
|
+
window.createTask = createTask;
|
|
3232
|
+
window.updateTask = updateTask;
|
|
3233
|
+
window.deleteTask = deleteTask;
|
|
3234
|
+
window.runTask = runTask;
|
|
3235
|
+
window.cancelTask = cancelTask;
|
|
3236
|
+
window.retryTask = retryTask;
|
|
3237
|
+
window.generateTask = generateTask;
|
|
3238
|
+
window.startAFK = startAFK;
|
|
3239
|
+
window.stopAFK = stopAFK;
|
|
3240
|
+
window.handleDragStart = handleDragStart;
|
|
3241
|
+
window.handleDragEnd = handleDragEnd;
|
|
3242
|
+
window.handleDragOver = handleDragOver;
|
|
3243
|
+
window.handleDragLeave = handleDragLeave;
|
|
3244
|
+
window.handleDrop = handleDrop;
|
|
3245
|
+
window.handleTaskSubmit = handleTaskSubmit;
|
|
3246
|
+
window.handleAIGenerate = handleAIGenerate;
|
|
3247
|
+
window.applyTemplate = applyTemplate;
|
|
3248
|
+
window.handleStartAFK = handleStartAFK;
|
|
3249
|
+
window.showToast = showToast;
|
|
3250
|
+
window.scrollLogToBottom = scrollLogToBottom;
|
|
3251
|
+
window.copyLogToClipboard = copyLogToClipboard;
|
|
3252
|
+
window.clearLog = clearLog;
|
|
3253
|
+
window.closeLogTab = closeLogTab;
|
|
3254
|
+
window.toggleLogFullscreen = toggleLogFullscreen;
|
|
3255
|
+
window.escapeHtml = escapeHtml;
|
|
3256
|
+
window.openSidePanel = openSidePanel;
|
|
3257
|
+
window.closeSidePanel = closeSidePanel;
|
|
3258
|
+
window.showTaskMenu = showTaskMenu;
|
|
3259
|
+
window.filterTasks = filterTasks;
|
|
3260
|
+
window.scrollSidePanelLog = scrollSidePanelLog;
|
|
3261
|
+
|
|
3262
|
+
// Keyboard shortcuts
|
|
3263
|
+
document.addEventListener('keydown', (e) => {
|
|
3264
|
+
// Ignore if typing in input
|
|
3265
|
+
if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') return;
|
|
3266
|
+
|
|
3267
|
+
// ? - Show help
|
|
3268
|
+
if (e.key === '?') {
|
|
3269
|
+
showToast('Shortcuts: N=New, T=Templates, F=AFK, Esc=Close modal', 'info');
|
|
3270
|
+
}
|
|
3271
|
+
// n - New task
|
|
3272
|
+
if (e.key === 'n' && !e.metaKey && !e.ctrlKey) {
|
|
3273
|
+
state.showModal = 'new';
|
|
3274
|
+
render();
|
|
3275
|
+
}
|
|
3276
|
+
// t - Templates
|
|
3277
|
+
if (e.key === 't' && !e.metaKey && !e.ctrlKey) {
|
|
3278
|
+
state.showModal = 'templates';
|
|
3279
|
+
render();
|
|
3280
|
+
}
|
|
3281
|
+
// f - AFK mode
|
|
3282
|
+
if (e.key === 'f' && !e.metaKey && !e.ctrlKey && !state.afk.running) {
|
|
3283
|
+
state.showModal = 'afk';
|
|
3284
|
+
render();
|
|
3285
|
+
}
|
|
3286
|
+
// Escape - Close modal or side panel
|
|
3287
|
+
if (e.key === 'Escape') {
|
|
3288
|
+
if (state.showModal) {
|
|
3289
|
+
state.showModal = null;
|
|
3290
|
+
state.editingTask = null;
|
|
3291
|
+
render();
|
|
3292
|
+
} else if (state.sidePanel) {
|
|
3293
|
+
closeSidePanel();
|
|
3294
|
+
}
|
|
3295
|
+
}
|
|
3296
|
+
// / - Focus log search
|
|
3297
|
+
if (e.key === '/' && !state.showModal) {
|
|
3298
|
+
e.preventDefault();
|
|
3299
|
+
const searchInput = document.querySelector('#log-content ~ input, input[placeholder="Search logs..."]');
|
|
3300
|
+
if (searchInput) searchInput.focus();
|
|
3301
|
+
}
|
|
3302
|
+
});
|
|
3303
|
+
|
|
3304
|
+
// Initial render
|
|
3305
|
+
render();
|
|
3306
|
+
`;
|
|
3307
|
+
}
|
|
3308
|
+
|
|
3309
|
+
// src/server/utils/port.ts
|
|
3310
|
+
import { createServer as createServer2 } from "net";
|
|
3311
|
+
function isPortAvailable(port) {
|
|
3312
|
+
return new Promise((resolve) => {
|
|
3313
|
+
const server = createServer2();
|
|
3314
|
+
server.once("error", () => {
|
|
3315
|
+
resolve(false);
|
|
3316
|
+
});
|
|
3317
|
+
server.once("listening", () => {
|
|
3318
|
+
server.close();
|
|
3319
|
+
resolve(true);
|
|
3320
|
+
});
|
|
3321
|
+
server.listen(port, "127.0.0.1");
|
|
3322
|
+
});
|
|
3323
|
+
}
|
|
3324
|
+
async function findAvailablePort(startPort, maxAttempts = 10) {
|
|
3325
|
+
for (let i = 0; i < maxAttempts; i++) {
|
|
3326
|
+
const port = startPort + i;
|
|
3327
|
+
if (await isPortAvailable(port)) {
|
|
3328
|
+
return port;
|
|
3329
|
+
}
|
|
3330
|
+
}
|
|
3331
|
+
throw new Error(`No available port found in range ${startPort}-${startPort + maxAttempts - 1}`);
|
|
3332
|
+
}
|
|
3333
|
+
|
|
3334
|
+
// src/bin/cli.ts
|
|
3335
|
+
var VERSION = "0.1.0";
|
|
3336
|
+
var banner = `
|
|
3337
|
+
${chalk.cyan("\u2554\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2557")}
|
|
3338
|
+
${chalk.cyan("\u2551")} ${chalk.bold.white("Claude Kanban")} ${chalk.gray(`v${VERSION}`)} ${chalk.cyan("\u2551")}
|
|
3339
|
+
${chalk.cyan("\u2551")} ${chalk.gray("Visual AI-powered development")} ${chalk.cyan("\u2551")}
|
|
3340
|
+
${chalk.cyan("\u255A\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u255D")}
|
|
3341
|
+
`;
|
|
3342
|
+
async function main() {
|
|
3343
|
+
const program = new Command();
|
|
3344
|
+
program.name("claude-kanban").description("Visual Kanban board for AI-powered development with Claude").version(VERSION).option("-p, --port <number>", "Port to run server on", "4242").option("-n, --no-open", "Do not auto-open browser").option("--init", "Re-initialize project files").option("--reset", "Reset all tasks (keeps config)").action(async (options) => {
|
|
3345
|
+
console.log(banner);
|
|
3346
|
+
const cwd = process.cwd();
|
|
3347
|
+
const initialized = await isProjectInitialized(cwd);
|
|
3348
|
+
if (!initialized || options.init) {
|
|
3349
|
+
console.log(chalk.yellow("Initializing project..."));
|
|
3350
|
+
try {
|
|
3351
|
+
await initializeProject(cwd, options.reset);
|
|
3352
|
+
console.log(chalk.green("\u2713 Project initialized successfully"));
|
|
3353
|
+
} catch (error) {
|
|
3354
|
+
console.error(chalk.red("Failed to initialize project:"), error);
|
|
3355
|
+
process.exit(1);
|
|
3356
|
+
}
|
|
3357
|
+
} else {
|
|
3358
|
+
console.log(chalk.gray("Found existing configuration"));
|
|
3359
|
+
}
|
|
3360
|
+
let port = parseInt(options.port, 10);
|
|
3361
|
+
try {
|
|
3362
|
+
port = await findAvailablePort(port);
|
|
3363
|
+
} catch {
|
|
3364
|
+
console.error(chalk.red(`Port ${port} is in use and no alternatives found`));
|
|
3365
|
+
process.exit(1);
|
|
3366
|
+
}
|
|
3367
|
+
console.log(chalk.gray(`Starting server on port ${port}...`));
|
|
3368
|
+
try {
|
|
3369
|
+
const server = await createServer(cwd, port);
|
|
3370
|
+
const url = `http://localhost:${port}`;
|
|
3371
|
+
console.log(chalk.green(`
|
|
3372
|
+
\u2713 Server running at ${chalk.bold(url)}
|
|
3373
|
+
`));
|
|
3374
|
+
if (options.open !== false) {
|
|
3375
|
+
console.log(chalk.gray("Opening browser..."));
|
|
3376
|
+
await open(url);
|
|
3377
|
+
}
|
|
3378
|
+
let isShuttingDown = false;
|
|
3379
|
+
const shutdown = () => {
|
|
3380
|
+
if (isShuttingDown) {
|
|
3381
|
+
console.log(chalk.red("\nForce exiting..."));
|
|
3382
|
+
process.exit(1);
|
|
3383
|
+
}
|
|
3384
|
+
isShuttingDown = true;
|
|
3385
|
+
console.log(chalk.yellow("\nShutting down..."));
|
|
3386
|
+
if (server.cleanup) {
|
|
3387
|
+
server.cleanup();
|
|
3388
|
+
}
|
|
3389
|
+
const forceExitTimeout = setTimeout(() => {
|
|
3390
|
+
console.log(chalk.red("Forcing exit after timeout"));
|
|
3391
|
+
process.exit(0);
|
|
3392
|
+
}, 3e3);
|
|
3393
|
+
server.close(() => {
|
|
3394
|
+
clearTimeout(forceExitTimeout);
|
|
3395
|
+
console.log(chalk.green("\u2713 Server stopped"));
|
|
3396
|
+
process.exit(0);
|
|
3397
|
+
});
|
|
3398
|
+
};
|
|
3399
|
+
process.on("SIGINT", shutdown);
|
|
3400
|
+
process.on("SIGTERM", shutdown);
|
|
3401
|
+
console.log(chalk.gray("Press Ctrl+C to stop\n"));
|
|
3402
|
+
} catch (error) {
|
|
3403
|
+
console.error(chalk.red("Failed to start server:"), error);
|
|
3404
|
+
process.exit(1);
|
|
3405
|
+
}
|
|
3406
|
+
});
|
|
3407
|
+
await program.parseAsync();
|
|
3408
|
+
}
|
|
3409
|
+
main().catch((error) => {
|
|
3410
|
+
console.error(chalk.red("Fatal error:"), error);
|
|
3411
|
+
process.exit(1);
|
|
3412
|
+
});
|
|
3413
|
+
//# sourceMappingURL=cli.js.map
|