claude-ws 0.3.99 → 0.3.100
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/bin/claude-ws.js +36 -3
- package/bin/lib/cli-parser.js +109 -0
- package/bin/lib/commands/logs.js +73 -0
- package/bin/lib/commands/open.js +49 -0
- package/bin/lib/commands/start.js +73 -0
- package/bin/lib/commands/status.js +35 -0
- package/bin/lib/commands/stop.js +50 -0
- package/bin/lib/config.js +93 -0
- package/bin/lib/daemon.js +167 -0
- package/bin/lib/health.js +62 -0
- package/locales/de.json +2 -0
- package/locales/en.json +2 -0
- package/locales/es.json +2 -0
- package/locales/fr.json +2 -0
- package/locales/ja.json +2 -0
- package/locales/ko.json +2 -0
- package/locales/vi.json +2 -0
- package/locales/zh.json +2 -0
- package/package.json +1 -1
- package/src/app/api/tasks/route.ts +27 -25
- package/src/components/kanban/board.tsx +66 -18
- package/src/components/task/task-detail-panel.tsx +56 -1
- package/src/stores/panel-layout-store.ts +12 -1
- package/src/stores/task-store.ts +34 -0
package/bin/claude-ws.js
CHANGED
|
@@ -14,6 +14,18 @@ const path = require('path');
|
|
|
14
14
|
const fs = require('fs');
|
|
15
15
|
const os = require('os');
|
|
16
16
|
|
|
17
|
+
// ── Subcommand detection ──────────────────────────────────────────────
|
|
18
|
+
// If the first positional arg is a known subcommand, delegate and exit.
|
|
19
|
+
// Otherwise, fall through to the existing foreground startup logic.
|
|
20
|
+
const SUBCOMMANDS = ['start', 'stop', 'status', 'logs', 'open'];
|
|
21
|
+
const _firstArg = process.argv[2];
|
|
22
|
+
if (SUBCOMMANDS.includes(_firstArg)) {
|
|
23
|
+
require(`./lib/commands/${_firstArg}`).run(process.argv.slice(3));
|
|
24
|
+
// The command handles process.exit — nothing else to do here.
|
|
25
|
+
return;
|
|
26
|
+
}
|
|
27
|
+
// ── End subcommand detection ──────────────────────────────────────────
|
|
28
|
+
|
|
17
29
|
const isWindows = process.platform === 'win32';
|
|
18
30
|
|
|
19
31
|
/**
|
|
@@ -59,7 +71,23 @@ if (process.argv.includes('--help') || process.argv.includes('-h')) {
|
|
|
59
71
|
Claude Workspace - Visual workspace for Claude Code
|
|
60
72
|
|
|
61
73
|
Usage:
|
|
62
|
-
claude-ws [options]
|
|
74
|
+
claude-ws [options] Start server in foreground (blocks terminal)
|
|
75
|
+
claude-ws <command> [flags] Daemon management
|
|
76
|
+
|
|
77
|
+
Commands:
|
|
78
|
+
start Start as background daemon
|
|
79
|
+
--port, -p <port> Server port (default: 8556)
|
|
80
|
+
--host <host> Bind host (default: localhost)
|
|
81
|
+
--data-dir <dir> Data directory
|
|
82
|
+
--log-dir <dir> Log directory
|
|
83
|
+
--no-open Don't open browser after start
|
|
84
|
+
stop Stop the running daemon
|
|
85
|
+
status Show daemon PID, URL, and health
|
|
86
|
+
logs Tail daemon log files
|
|
87
|
+
-f, --follow Follow log output
|
|
88
|
+
-n, --lines <N> Number of lines (default: 50)
|
|
89
|
+
-e, --error Show error log instead
|
|
90
|
+
open Open browser to running instance
|
|
63
91
|
|
|
64
92
|
Options:
|
|
65
93
|
-v, --version Show version number
|
|
@@ -68,10 +96,15 @@ Options:
|
|
|
68
96
|
Environment:
|
|
69
97
|
.env: Loaded from current working directory (./.env)
|
|
70
98
|
Database: Stored in ./data/claude-ws.db (or DATA_DIR env)
|
|
99
|
+
Config: ~/.claude-ws/config.json (port, host, dataDir, logDir)
|
|
71
100
|
|
|
72
101
|
Examples:
|
|
73
|
-
claude-ws
|
|
74
|
-
|
|
102
|
+
claude-ws Start server in foreground
|
|
103
|
+
claude-ws start Start as daemon
|
|
104
|
+
claude-ws start --port 3000 Start daemon on port 3000
|
|
105
|
+
claude-ws status Check if daemon is running
|
|
106
|
+
claude-ws logs -f Follow daemon logs
|
|
107
|
+
claude-ws stop Stop the daemon
|
|
75
108
|
|
|
76
109
|
For more info: https://github.com/Claude-Workspace/claude-ws
|
|
77
110
|
`);
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Lightweight CLI argument parser.
|
|
3
|
+
* Zero dependencies - Node.js built-ins only.
|
|
4
|
+
*
|
|
5
|
+
* Supports:
|
|
6
|
+
* --port 3000, --port=3000
|
|
7
|
+
* --no-open (boolean negation)
|
|
8
|
+
* -f (short flags)
|
|
9
|
+
* -n 50 (short flags with values)
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* @param {string[]} argv - process.argv.slice(N) after subcommand
|
|
14
|
+
* @param {{ flags: Record<string, { type: 'string'|'boolean', alias?: string, default?: any }> }} schema
|
|
15
|
+
* @returns {{ flags: Record<string, any>, args: string[] }}
|
|
16
|
+
*/
|
|
17
|
+
function parse(argv, schema) {
|
|
18
|
+
const flags = {};
|
|
19
|
+
const args = [];
|
|
20
|
+
const defs = schema.flags || {};
|
|
21
|
+
|
|
22
|
+
// Set defaults
|
|
23
|
+
for (const [key, def] of Object.entries(defs)) {
|
|
24
|
+
if (def.default !== undefined) {
|
|
25
|
+
flags[key] = def.default;
|
|
26
|
+
} else {
|
|
27
|
+
flags[key] = def.type === 'boolean' ? false : undefined;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// Build alias map: alias -> canonical name
|
|
32
|
+
const aliasMap = {};
|
|
33
|
+
for (const [key, def] of Object.entries(defs)) {
|
|
34
|
+
if (def.alias) {
|
|
35
|
+
aliasMap[def.alias] = key;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
let i = 0;
|
|
40
|
+
while (i < argv.length) {
|
|
41
|
+
const arg = argv[i];
|
|
42
|
+
|
|
43
|
+
if (arg === '--') {
|
|
44
|
+
args.push(...argv.slice(i + 1));
|
|
45
|
+
break;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// --no-<flag> boolean negation
|
|
49
|
+
if (arg.startsWith('--no-')) {
|
|
50
|
+
const name = arg.slice(5);
|
|
51
|
+
if (defs[name] && defs[name].type === 'boolean') {
|
|
52
|
+
flags[name] = false;
|
|
53
|
+
i++;
|
|
54
|
+
continue;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// --flag=value
|
|
59
|
+
if (arg.startsWith('--') && arg.includes('=')) {
|
|
60
|
+
const eqIdx = arg.indexOf('=');
|
|
61
|
+
const name = arg.slice(2, eqIdx);
|
|
62
|
+
const value = arg.slice(eqIdx + 1);
|
|
63
|
+
if (defs[name]) {
|
|
64
|
+
flags[name] = defs[name].type === 'boolean' ? value !== 'false' : value;
|
|
65
|
+
}
|
|
66
|
+
i++;
|
|
67
|
+
continue;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// --flag [value]
|
|
71
|
+
if (arg.startsWith('--')) {
|
|
72
|
+
const name = arg.slice(2);
|
|
73
|
+
if (defs[name]) {
|
|
74
|
+
if (defs[name].type === 'boolean') {
|
|
75
|
+
flags[name] = true;
|
|
76
|
+
i++;
|
|
77
|
+
} else {
|
|
78
|
+
flags[name] = argv[i + 1];
|
|
79
|
+
i += 2;
|
|
80
|
+
}
|
|
81
|
+
continue;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// -f or -f value (short alias)
|
|
86
|
+
if (arg.startsWith('-') && !arg.startsWith('--') && arg.length === 2) {
|
|
87
|
+
const alias = arg.slice(1);
|
|
88
|
+
const canonical = aliasMap[alias];
|
|
89
|
+
if (canonical && defs[canonical]) {
|
|
90
|
+
if (defs[canonical].type === 'boolean') {
|
|
91
|
+
flags[canonical] = true;
|
|
92
|
+
i++;
|
|
93
|
+
} else {
|
|
94
|
+
flags[canonical] = argv[i + 1];
|
|
95
|
+
i += 2;
|
|
96
|
+
}
|
|
97
|
+
continue;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// Positional argument
|
|
102
|
+
args.push(arg);
|
|
103
|
+
i++;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
return { flags, args };
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
module.exports = { parse };
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `claude-ws logs` — Tail daemon log files.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
const fs = require('fs');
|
|
6
|
+
const path = require('path');
|
|
7
|
+
const { spawn } = require('child_process');
|
|
8
|
+
const { parse } = require('../cli-parser');
|
|
9
|
+
const config = require('../config');
|
|
10
|
+
|
|
11
|
+
const FLAG_SCHEMA = {
|
|
12
|
+
flags: {
|
|
13
|
+
follow: { type: 'boolean', alias: 'f', default: false },
|
|
14
|
+
lines: { type: 'string', alias: 'n', default: '50' },
|
|
15
|
+
error: { type: 'boolean', alias: 'e', default: false },
|
|
16
|
+
},
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
function run(argv) {
|
|
20
|
+
const { flags } = parse(argv, FLAG_SCHEMA);
|
|
21
|
+
const conf = config.resolve({});
|
|
22
|
+
|
|
23
|
+
const logFile = flags.error
|
|
24
|
+
? path.join(conf.logDir, 'claude-ws-error.log')
|
|
25
|
+
: path.join(conf.logDir, 'claude-ws.log');
|
|
26
|
+
|
|
27
|
+
if (!fs.existsSync(logFile)) {
|
|
28
|
+
console.log(`[claude-ws] No log file found at ${logFile}`);
|
|
29
|
+
console.log('[claude-ws] Has the daemon been started? Try: claude-ws start');
|
|
30
|
+
process.exit(1);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const tailArgs = [];
|
|
34
|
+
tailArgs.push('-n', flags.lines);
|
|
35
|
+
if (flags.follow) {
|
|
36
|
+
tailArgs.push('-f');
|
|
37
|
+
}
|
|
38
|
+
tailArgs.push(logFile);
|
|
39
|
+
|
|
40
|
+
console.log(`[claude-ws] ${flags.error ? 'Error log' : 'Log'}: ${logFile}`);
|
|
41
|
+
console.log('---');
|
|
42
|
+
|
|
43
|
+
const tail = spawn('tail', tailArgs, { stdio: 'inherit' });
|
|
44
|
+
|
|
45
|
+
tail.on('error', (err) => {
|
|
46
|
+
// tail not available (Windows) — fall back to reading file
|
|
47
|
+
if (err.code === 'ENOENT') {
|
|
48
|
+
const content = fs.readFileSync(logFile, 'utf-8');
|
|
49
|
+
const lines = content.split('\n');
|
|
50
|
+
const n = parseInt(flags.lines, 10) || 50;
|
|
51
|
+
const tail = lines.slice(-n);
|
|
52
|
+
console.log(tail.join('\n'));
|
|
53
|
+
|
|
54
|
+
if (flags.follow) {
|
|
55
|
+
console.log('[claude-ws] -f (follow) is not supported on this platform.');
|
|
56
|
+
}
|
|
57
|
+
} else {
|
|
58
|
+
console.error(`[claude-ws] Error: ${err.message}`);
|
|
59
|
+
}
|
|
60
|
+
process.exit(1);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
tail.on('close', (code) => {
|
|
64
|
+
process.exit(code || 0);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
// Forward Ctrl+C to tail
|
|
68
|
+
process.on('SIGINT', () => {
|
|
69
|
+
tail.kill('SIGINT');
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
module.exports = { run };
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `claude-ws open` — Open browser to the running instance.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
const { exec } = require('child_process');
|
|
6
|
+
const config = require('../config');
|
|
7
|
+
const daemon = require('../daemon');
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Open a URL in the default browser (cross-platform).
|
|
11
|
+
* @param {string} url
|
|
12
|
+
*/
|
|
13
|
+
function openUrl(url) {
|
|
14
|
+
const platform = process.platform;
|
|
15
|
+
let cmd;
|
|
16
|
+
if (platform === 'darwin') {
|
|
17
|
+
cmd = `open "${url}"`;
|
|
18
|
+
} else if (platform === 'win32') {
|
|
19
|
+
cmd = `start "" "${url}"`;
|
|
20
|
+
} else {
|
|
21
|
+
cmd = `xdg-open "${url}"`;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
exec(cmd, (err) => {
|
|
25
|
+
if (err) {
|
|
26
|
+
console.log(`[claude-ws] Could not open browser. Visit: ${url}`);
|
|
27
|
+
}
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
async function run(_argv) {
|
|
32
|
+
const { running, pid } = daemon.checkRunning();
|
|
33
|
+
|
|
34
|
+
if (!running) {
|
|
35
|
+
console.log('[claude-ws] No running daemon found.');
|
|
36
|
+
console.log('[claude-ws] Start one first: claude-ws start');
|
|
37
|
+
process.exit(1);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const conf = config.resolve({});
|
|
41
|
+
const url = `http://${conf.host}:${conf.port}`;
|
|
42
|
+
|
|
43
|
+
console.log(`[claude-ws] Opening ${url} (PID ${pid})`);
|
|
44
|
+
openUrl(url);
|
|
45
|
+
|
|
46
|
+
process.exit(0);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
module.exports = { run, openUrl };
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `claude-ws start` — Start claude-ws as a background daemon.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
const { parse } = require('../cli-parser');
|
|
6
|
+
const config = require('../config');
|
|
7
|
+
const daemon = require('../daemon');
|
|
8
|
+
const health = require('../health');
|
|
9
|
+
|
|
10
|
+
const FLAG_SCHEMA = {
|
|
11
|
+
flags: {
|
|
12
|
+
port: { type: 'string', alias: 'p' },
|
|
13
|
+
host: { type: 'string' },
|
|
14
|
+
'data-dir': { type: 'string' },
|
|
15
|
+
'log-dir': { type: 'string' },
|
|
16
|
+
'no-open': { type: 'boolean' },
|
|
17
|
+
},
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
async function run(argv) {
|
|
21
|
+
const { flags } = parse(argv, FLAG_SCHEMA);
|
|
22
|
+
const conf = config.resolve(flags);
|
|
23
|
+
|
|
24
|
+
// Check if already running
|
|
25
|
+
const { running, pid } = daemon.checkRunning();
|
|
26
|
+
if (running) {
|
|
27
|
+
console.log(`[claude-ws] Already running (PID ${pid})`);
|
|
28
|
+
console.log(`[claude-ws] URL: http://${conf.host}:${conf.port}`);
|
|
29
|
+
console.log('[claude-ws] Use "claude-ws stop" to stop it first.');
|
|
30
|
+
process.exit(1);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
console.log('[claude-ws] Starting daemon...');
|
|
34
|
+
console.log(`[claude-ws] Port: ${conf.port}`);
|
|
35
|
+
console.log(`[claude-ws] Host: ${conf.host}`);
|
|
36
|
+
console.log(`[claude-ws] Data: ${conf.dataDir}`);
|
|
37
|
+
console.log(`[claude-ws] Logs: ${conf.logDir}`);
|
|
38
|
+
|
|
39
|
+
const childPid = daemon.daemonize({
|
|
40
|
+
port: conf.port,
|
|
41
|
+
host: conf.host,
|
|
42
|
+
dataDir: conf.dataDir,
|
|
43
|
+
logDir: conf.logDir,
|
|
44
|
+
noOpen: flags['no-open'],
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
console.log(`[claude-ws] Daemon started (PID ${childPid})`);
|
|
48
|
+
console.log('[claude-ws] Waiting for server to become ready...');
|
|
49
|
+
|
|
50
|
+
const ready = await health.waitUntilReady(conf.host, conf.port, 60000, 2000);
|
|
51
|
+
|
|
52
|
+
if (ready) {
|
|
53
|
+
console.log(`[claude-ws] Server is ready at http://${conf.host}:${conf.port}`);
|
|
54
|
+
|
|
55
|
+
// Open browser unless --no-open
|
|
56
|
+
if (!flags['no-open']) {
|
|
57
|
+
try {
|
|
58
|
+
const openCmd = require('../commands/open');
|
|
59
|
+
openCmd.openUrl(`http://${conf.host}:${conf.port}`);
|
|
60
|
+
} catch {
|
|
61
|
+
// Non-critical — don't fail the start
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
} else {
|
|
65
|
+
console.log('[claude-ws] Server did not respond within 60s.');
|
|
66
|
+
console.log('[claude-ws] Check logs: claude-ws logs');
|
|
67
|
+
console.log('[claude-ws] The daemon may still be starting up (building, installing deps, etc.).');
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
process.exit(0);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
module.exports = { run };
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `claude-ws status` — Show daemon status, PID, URL, and health.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
const config = require('../config');
|
|
6
|
+
const daemon = require('../daemon');
|
|
7
|
+
const health = require('../health');
|
|
8
|
+
|
|
9
|
+
async function run(_argv) {
|
|
10
|
+
const { running, pid } = daemon.checkRunning();
|
|
11
|
+
|
|
12
|
+
if (!running) {
|
|
13
|
+
console.log('[claude-ws] Status: NOT RUNNING');
|
|
14
|
+
process.exit(0);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const conf = config.resolve({});
|
|
18
|
+
const result = await health.check(conf.host, conf.port);
|
|
19
|
+
|
|
20
|
+
console.log('[claude-ws] Status: RUNNING');
|
|
21
|
+
console.log(`[claude-ws] PID: ${pid}`);
|
|
22
|
+
console.log(`[claude-ws] URL: http://${conf.host}:${conf.port}`);
|
|
23
|
+
|
|
24
|
+
if (result.ok) {
|
|
25
|
+
console.log(`[claude-ws] Health: OK (HTTP ${result.statusCode})`);
|
|
26
|
+
} else {
|
|
27
|
+
console.log(`[claude-ws] Health: UNREACHABLE (${result.error})`);
|
|
28
|
+
console.log('[claude-ws] The server process is running but not responding to HTTP.');
|
|
29
|
+
console.log('[claude-ws] It may still be starting up. Check: claude-ws logs');
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
process.exit(0);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
module.exports = { run };
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `claude-ws stop` — Stop the running daemon.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
const daemon = require('../daemon');
|
|
6
|
+
|
|
7
|
+
async function run(_argv) {
|
|
8
|
+
const { running, pid } = daemon.checkRunning();
|
|
9
|
+
|
|
10
|
+
if (!running) {
|
|
11
|
+
console.log('[claude-ws] No running daemon found.');
|
|
12
|
+
process.exit(0);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
console.log(`[claude-ws] Stopping daemon (PID ${pid})...`);
|
|
16
|
+
|
|
17
|
+
// Send SIGTERM
|
|
18
|
+
const sent = daemon.sendSignal(pid, 'SIGTERM');
|
|
19
|
+
if (!sent) {
|
|
20
|
+
console.log('[claude-ws] Failed to send SIGTERM — process may have already exited.');
|
|
21
|
+
daemon.removePid();
|
|
22
|
+
process.exit(0);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// Poll for exit (up to 10s)
|
|
26
|
+
const exited = await daemon.waitForExit(pid, 10000);
|
|
27
|
+
|
|
28
|
+
if (exited) {
|
|
29
|
+
daemon.removePid();
|
|
30
|
+
console.log('[claude-ws] Daemon stopped.');
|
|
31
|
+
process.exit(0);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// Force kill
|
|
35
|
+
console.log('[claude-ws] Daemon did not exit gracefully, sending SIGKILL...');
|
|
36
|
+
daemon.sendSignal(pid, 'SIGKILL');
|
|
37
|
+
|
|
38
|
+
const killed = await daemon.waitForExit(pid, 5000);
|
|
39
|
+
daemon.removePid();
|
|
40
|
+
|
|
41
|
+
if (killed) {
|
|
42
|
+
console.log('[claude-ws] Daemon killed.');
|
|
43
|
+
} else {
|
|
44
|
+
console.log(`[claude-ws] Warning: Process ${pid} may still be running.`);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
process.exit(0);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
module.exports = { run };
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Configuration loader for claude-ws daemon mode.
|
|
3
|
+
*
|
|
4
|
+
* Priority: CLI flags > env vars > config file > defaults.
|
|
5
|
+
* Config file: ~/.claude-ws/config.json
|
|
6
|
+
* PID file: ~/.claude-ws/claude-ws.pid
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
const fs = require('fs');
|
|
10
|
+
const path = require('path');
|
|
11
|
+
const os = require('os');
|
|
12
|
+
|
|
13
|
+
const CLAUDE_WS_DIR = path.join(os.homedir(), '.claude-ws');
|
|
14
|
+
const CONFIG_PATH = path.join(CLAUDE_WS_DIR, 'config.json');
|
|
15
|
+
const PID_PATH = path.join(CLAUDE_WS_DIR, 'claude-ws.pid');
|
|
16
|
+
|
|
17
|
+
const DEFAULTS = {
|
|
18
|
+
port: 8556,
|
|
19
|
+
host: 'localhost',
|
|
20
|
+
dataDir: path.join(CLAUDE_WS_DIR, 'data'),
|
|
21
|
+
logDir: path.join(CLAUDE_WS_DIR, 'logs'),
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Ensure ~/.claude-ws/ directory exists.
|
|
26
|
+
*/
|
|
27
|
+
function ensureDir() {
|
|
28
|
+
if (!fs.existsSync(CLAUDE_WS_DIR)) {
|
|
29
|
+
fs.mkdirSync(CLAUDE_WS_DIR, { recursive: true });
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Load config file if it exists.
|
|
35
|
+
* @returns {object}
|
|
36
|
+
*/
|
|
37
|
+
function loadConfigFile() {
|
|
38
|
+
if (!fs.existsSync(CONFIG_PATH)) {
|
|
39
|
+
return {};
|
|
40
|
+
}
|
|
41
|
+
try {
|
|
42
|
+
const raw = fs.readFileSync(CONFIG_PATH, 'utf-8');
|
|
43
|
+
return JSON.parse(raw);
|
|
44
|
+
} catch (err) {
|
|
45
|
+
console.error(`[claude-ws] Warning: Failed to parse ${CONFIG_PATH}: ${err.message}`);
|
|
46
|
+
return {};
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Resolve the final configuration by merging defaults, config file, env vars,
|
|
52
|
+
* and CLI flags (passed as an object).
|
|
53
|
+
*
|
|
54
|
+
* @param {object} [cliFlags={}] - Parsed CLI flags (e.g. { port: '3000', host: '0.0.0.0' })
|
|
55
|
+
* @returns {{ port: number, host: string, dataDir: string, logDir: string }}
|
|
56
|
+
*/
|
|
57
|
+
function resolve(cliFlags = {}) {
|
|
58
|
+
ensureDir();
|
|
59
|
+
|
|
60
|
+
const file = loadConfigFile();
|
|
61
|
+
|
|
62
|
+
const port = parseInt(
|
|
63
|
+
cliFlags.port || process.env.PORT || file.port || DEFAULTS.port,
|
|
64
|
+
10,
|
|
65
|
+
);
|
|
66
|
+
|
|
67
|
+
const host =
|
|
68
|
+
cliFlags.host || process.env.HOST || file.host || DEFAULTS.host;
|
|
69
|
+
|
|
70
|
+
const dataDir =
|
|
71
|
+
cliFlags['data-dir'] || process.env.DATA_DIR || file.dataDir || DEFAULTS.dataDir;
|
|
72
|
+
|
|
73
|
+
const logDir =
|
|
74
|
+
cliFlags['log-dir'] || process.env.LOG_DIR || file.logDir || DEFAULTS.logDir;
|
|
75
|
+
|
|
76
|
+
// Ensure log and data dirs exist
|
|
77
|
+
for (const dir of [dataDir, logDir]) {
|
|
78
|
+
if (!fs.existsSync(dir)) {
|
|
79
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
return { port, host, dataDir, logDir };
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
module.exports = {
|
|
87
|
+
CLAUDE_WS_DIR,
|
|
88
|
+
CONFIG_PATH,
|
|
89
|
+
PID_PATH,
|
|
90
|
+
DEFAULTS,
|
|
91
|
+
ensureDir,
|
|
92
|
+
resolve,
|
|
93
|
+
};
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Daemon utilities: PID file management, process checks, daemonization.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
const fs = require('fs');
|
|
6
|
+
const path = require('path');
|
|
7
|
+
const { spawn } = require('child_process');
|
|
8
|
+
const config = require('./config');
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Read PID from PID file.
|
|
12
|
+
* @returns {number|null}
|
|
13
|
+
*/
|
|
14
|
+
function readPid() {
|
|
15
|
+
try {
|
|
16
|
+
const raw = fs.readFileSync(config.PID_PATH, 'utf-8').trim();
|
|
17
|
+
const pid = parseInt(raw, 10);
|
|
18
|
+
return isNaN(pid) ? null : pid;
|
|
19
|
+
} catch {
|
|
20
|
+
return null;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Write PID to PID file.
|
|
26
|
+
* @param {number} pid
|
|
27
|
+
*/
|
|
28
|
+
function writePid(pid) {
|
|
29
|
+
config.ensureDir();
|
|
30
|
+
fs.writeFileSync(config.PID_PATH, String(pid), 'utf-8');
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Remove PID file.
|
|
35
|
+
*/
|
|
36
|
+
function removePid() {
|
|
37
|
+
try {
|
|
38
|
+
fs.unlinkSync(config.PID_PATH);
|
|
39
|
+
} catch {
|
|
40
|
+
// Already gone — that's fine
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Check whether a process with the given PID is alive.
|
|
46
|
+
* @param {number} pid
|
|
47
|
+
* @returns {boolean}
|
|
48
|
+
*/
|
|
49
|
+
function isAlive(pid) {
|
|
50
|
+
try {
|
|
51
|
+
process.kill(pid, 0);
|
|
52
|
+
return true;
|
|
53
|
+
} catch {
|
|
54
|
+
return false;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Check if a daemon is already running.
|
|
60
|
+
* Cleans up stale PID file if the process is dead.
|
|
61
|
+
*
|
|
62
|
+
* @returns {{ running: boolean, pid: number|null }}
|
|
63
|
+
*/
|
|
64
|
+
function checkRunning() {
|
|
65
|
+
const pid = readPid();
|
|
66
|
+
if (pid === null) {
|
|
67
|
+
return { running: false, pid: null };
|
|
68
|
+
}
|
|
69
|
+
if (isAlive(pid)) {
|
|
70
|
+
return { running: true, pid };
|
|
71
|
+
}
|
|
72
|
+
// Stale PID file — clean up
|
|
73
|
+
removePid();
|
|
74
|
+
return { running: false, pid: null };
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Spawn the claude-ws foreground process as a detached daemon.
|
|
79
|
+
*
|
|
80
|
+
* @param {{ port: number, host: string, dataDir: string, logDir: string, noOpen?: boolean }} opts
|
|
81
|
+
* @returns {number} The child PID
|
|
82
|
+
*/
|
|
83
|
+
function daemonize(opts) {
|
|
84
|
+
const entryPoint = path.resolve(__dirname, '..', 'claude-ws.js');
|
|
85
|
+
|
|
86
|
+
const stdoutPath = path.join(opts.logDir, 'claude-ws.log');
|
|
87
|
+
const stderrPath = path.join(opts.logDir, 'claude-ws-error.log');
|
|
88
|
+
|
|
89
|
+
const stdoutFd = fs.openSync(stdoutPath, 'a');
|
|
90
|
+
const stderrFd = fs.openSync(stderrPath, 'a');
|
|
91
|
+
|
|
92
|
+
const env = {
|
|
93
|
+
...process.env,
|
|
94
|
+
PORT: String(opts.port),
|
|
95
|
+
HOST: opts.host,
|
|
96
|
+
DATA_DIR: opts.dataDir,
|
|
97
|
+
CLAUDE_WS_DAEMON: '1', // Signal to the entry point that this is a daemon child
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
const child = spawn(process.execPath, [entryPoint], {
|
|
101
|
+
cwd: process.cwd(),
|
|
102
|
+
detached: true,
|
|
103
|
+
stdio: ['ignore', stdoutFd, stderrFd],
|
|
104
|
+
env,
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
child.unref();
|
|
108
|
+
|
|
109
|
+
// Close the fd handles in this (parent) process
|
|
110
|
+
fs.closeSync(stdoutFd);
|
|
111
|
+
fs.closeSync(stderrFd);
|
|
112
|
+
|
|
113
|
+
const pid = child.pid;
|
|
114
|
+
writePid(pid);
|
|
115
|
+
|
|
116
|
+
return pid;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Send a signal to the daemon process.
|
|
121
|
+
*
|
|
122
|
+
* @param {number} pid
|
|
123
|
+
* @param {string} signal - e.g. 'SIGTERM', 'SIGKILL'
|
|
124
|
+
* @returns {boolean} true if signal was sent successfully
|
|
125
|
+
*/
|
|
126
|
+
function sendSignal(pid, signal) {
|
|
127
|
+
try {
|
|
128
|
+
process.kill(pid, signal);
|
|
129
|
+
return true;
|
|
130
|
+
} catch {
|
|
131
|
+
return false;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Wait for a process to exit, polling every interval ms.
|
|
137
|
+
*
|
|
138
|
+
* @param {number} pid
|
|
139
|
+
* @param {number} timeoutMs
|
|
140
|
+
* @param {number} [intervalMs=500]
|
|
141
|
+
* @returns {Promise<boolean>} true if process exited within timeout
|
|
142
|
+
*/
|
|
143
|
+
function waitForExit(pid, timeoutMs, intervalMs = 500) {
|
|
144
|
+
return new Promise((resolve) => {
|
|
145
|
+
const start = Date.now();
|
|
146
|
+
const timer = setInterval(() => {
|
|
147
|
+
if (!isAlive(pid)) {
|
|
148
|
+
clearInterval(timer);
|
|
149
|
+
resolve(true);
|
|
150
|
+
} else if (Date.now() - start >= timeoutMs) {
|
|
151
|
+
clearInterval(timer);
|
|
152
|
+
resolve(false);
|
|
153
|
+
}
|
|
154
|
+
}, intervalMs);
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
module.exports = {
|
|
159
|
+
readPid,
|
|
160
|
+
writePid,
|
|
161
|
+
removePid,
|
|
162
|
+
isAlive,
|
|
163
|
+
checkRunning,
|
|
164
|
+
daemonize,
|
|
165
|
+
sendSignal,
|
|
166
|
+
waitForExit,
|
|
167
|
+
};
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* HTTP health check against a running claude-ws server.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
const http = require('http');
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Perform a simple HTTP GET to check if the server is responding.
|
|
9
|
+
*
|
|
10
|
+
* @param {string} host
|
|
11
|
+
* @param {number} port
|
|
12
|
+
* @param {number} [timeoutMs=3000]
|
|
13
|
+
* @returns {Promise<{ ok: boolean, statusCode?: number, error?: string }>}
|
|
14
|
+
*/
|
|
15
|
+
function check(host, port, timeoutMs = 3000) {
|
|
16
|
+
return new Promise((resolve) => {
|
|
17
|
+
const req = http.get(`http://${host}:${port}/`, { timeout: timeoutMs }, (res) => {
|
|
18
|
+
// Any HTTP response means the server is alive
|
|
19
|
+
resolve({ ok: true, statusCode: res.statusCode });
|
|
20
|
+
res.resume(); // Drain the response
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
req.on('error', (err) => {
|
|
24
|
+
resolve({ ok: false, error: err.message });
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
req.on('timeout', () => {
|
|
28
|
+
req.destroy();
|
|
29
|
+
resolve({ ok: false, error: 'timeout' });
|
|
30
|
+
});
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Poll until the server responds or timeout is reached.
|
|
36
|
+
*
|
|
37
|
+
* @param {string} host
|
|
38
|
+
* @param {number} port
|
|
39
|
+
* @param {number} [timeoutMs=30000]
|
|
40
|
+
* @param {number} [intervalMs=1000]
|
|
41
|
+
* @returns {Promise<boolean>}
|
|
42
|
+
*/
|
|
43
|
+
function waitUntilReady(host, port, timeoutMs = 30000, intervalMs = 1000) {
|
|
44
|
+
return new Promise((resolve) => {
|
|
45
|
+
const start = Date.now();
|
|
46
|
+
const attempt = async () => {
|
|
47
|
+
const result = await check(host, port, 2000);
|
|
48
|
+
if (result.ok) {
|
|
49
|
+
resolve(true);
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
if (Date.now() - start >= timeoutMs) {
|
|
53
|
+
resolve(false);
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
setTimeout(attempt, intervalMs);
|
|
57
|
+
};
|
|
58
|
+
attempt();
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
module.exports = { check, waitUntilReady };
|
package/locales/de.json
CHANGED
|
@@ -86,6 +86,8 @@
|
|
|
86
86
|
"cancelled": "Abgebrochen",
|
|
87
87
|
"deleteColumn": "Spalte löschen",
|
|
88
88
|
"addColumn": "Spalte hinzufügen",
|
|
89
|
+
"columns": "Spalten",
|
|
90
|
+
"toggleColumns": "Spalten anzeigen",
|
|
89
91
|
"noTasks": "Keine Aufgaben",
|
|
90
92
|
"deleteAllTasks": "Alle {count} Aufgabe(n) in \"{status}\" löschen?",
|
|
91
93
|
"newTaskShortcut": "Neue Aufgabe (Strg/⌘ + Leertaste)",
|
package/locales/en.json
CHANGED
|
@@ -86,6 +86,8 @@
|
|
|
86
86
|
"cancelled": "Cancelled",
|
|
87
87
|
"deleteColumn": "Delete Column",
|
|
88
88
|
"addColumn": "Add Column",
|
|
89
|
+
"columns": "Columns",
|
|
90
|
+
"toggleColumns": "Toggle Columns",
|
|
89
91
|
"noTasks": "No tasks",
|
|
90
92
|
"deleteAllTasks": "Delete all {count} task(s) in \"{status}\"?",
|
|
91
93
|
"newTaskShortcut": "New Task (Ctrl/⌘ + Space)",
|
package/locales/es.json
CHANGED
|
@@ -86,6 +86,8 @@
|
|
|
86
86
|
"cancelled": "Cancelado",
|
|
87
87
|
"deleteColumn": "Eliminar Columna",
|
|
88
88
|
"addColumn": "Añadir Columna",
|
|
89
|
+
"columns": "Columnas",
|
|
90
|
+
"toggleColumns": "Mostrar columnas",
|
|
89
91
|
"noTasks": "Sin tareas",
|
|
90
92
|
"deleteAllTasks": "¿Eliminar {count} tarea(s) en \"{status}\"?",
|
|
91
93
|
"newTaskShortcut": "Nueva Tarea (Ctrl/⌘ + Espacio)",
|
package/locales/fr.json
CHANGED
|
@@ -86,6 +86,8 @@
|
|
|
86
86
|
"cancelled": "Annulé",
|
|
87
87
|
"deleteColumn": "Supprimer la colonne",
|
|
88
88
|
"addColumn": "Ajouter une colonne",
|
|
89
|
+
"columns": "Colonnes",
|
|
90
|
+
"toggleColumns": "Afficher les colonnes",
|
|
89
91
|
"noTasks": "Aucune tâche",
|
|
90
92
|
"deleteAllTasks": "Supprimer les {count} tâche(s) dans \"{status}\" ?",
|
|
91
93
|
"newTaskShortcut": "Nouvelle tâche (Ctrl/⌘ + Espace)",
|
package/locales/ja.json
CHANGED
|
@@ -86,6 +86,8 @@
|
|
|
86
86
|
"cancelled": "キャンセル済み",
|
|
87
87
|
"deleteColumn": "列を削除",
|
|
88
88
|
"addColumn": "列を追加",
|
|
89
|
+
"columns": "列",
|
|
90
|
+
"toggleColumns": "列の表示切替",
|
|
89
91
|
"noTasks": "タスクなし",
|
|
90
92
|
"deleteAllTasks": "「{status}」のすべての {count} 個のタスクを削除しますか?",
|
|
91
93
|
"newTaskShortcut": "新しいタスク (Ctrl/⌘ + Space)",
|
package/locales/ko.json
CHANGED
package/locales/vi.json
CHANGED
|
@@ -86,6 +86,8 @@
|
|
|
86
86
|
"cancelled": "Đã hủy",
|
|
87
87
|
"deleteColumn": "Xóa cột",
|
|
88
88
|
"addColumn": "Thêm cột",
|
|
89
|
+
"columns": "Cột",
|
|
90
|
+
"toggleColumns": "Hiển thị cột",
|
|
89
91
|
"noTasks": "Không có nhiệm vụ",
|
|
90
92
|
"deleteAllTasks": "Xóa tất cả {count} nhiệm vụ trong \"{status}\"?",
|
|
91
93
|
"newTaskShortcut": "Nhiệm vụ mới (Ctrl/⌘ + Space)",
|
package/locales/zh.json
CHANGED
package/package.json
CHANGED
|
@@ -8,46 +8,48 @@ import type { TaskStatus } from '@/types';
|
|
|
8
8
|
// Query params:
|
|
9
9
|
// ?projectId=xxx - Single project (backward compat)
|
|
10
10
|
// ?projectIds=id1,id2,id3 - Multiple projects
|
|
11
|
+
// ?status=in_review - Filter by status (single or comma-separated)
|
|
11
12
|
// No params - All tasks
|
|
12
13
|
export async function GET(request: NextRequest) {
|
|
13
14
|
try {
|
|
14
15
|
const searchParams = request.nextUrl.searchParams;
|
|
15
16
|
const projectId = searchParams.get('projectId');
|
|
16
17
|
const projectIds = searchParams.get('projectIds');
|
|
18
|
+
const statusParam = searchParams.get('status');
|
|
17
19
|
|
|
18
|
-
|
|
20
|
+
// Build status filter conditions
|
|
21
|
+
const validStatuses: TaskStatus[] = ['todo', 'in_progress', 'in_review', 'done', 'cancelled'];
|
|
22
|
+
let statusFilter: ReturnType<typeof inArray> | undefined;
|
|
23
|
+
if (statusParam) {
|
|
24
|
+
const statuses = statusParam.split(',').filter((s): s is TaskStatus => validStatuses.includes(s as TaskStatus));
|
|
25
|
+
if (statuses.length > 0) {
|
|
26
|
+
statusFilter = inArray(schema.tasks.status, statuses);
|
|
27
|
+
}
|
|
28
|
+
}
|
|
19
29
|
|
|
30
|
+
// Build project filter conditions
|
|
31
|
+
let projectFilter: ReturnType<typeof eq> | ReturnType<typeof inArray> | undefined;
|
|
20
32
|
if (projectIds) {
|
|
21
|
-
// Multi-project mode
|
|
22
33
|
const ids = projectIds.split(',').filter(Boolean);
|
|
23
34
|
if (ids.length > 0) {
|
|
24
|
-
|
|
25
|
-
.select()
|
|
26
|
-
.from(schema.tasks)
|
|
27
|
-
.where(inArray(schema.tasks.projectId, ids))
|
|
28
|
-
.orderBy(schema.tasks.status, schema.tasks.position);
|
|
29
|
-
} else {
|
|
30
|
-
// Empty filter = all tasks
|
|
31
|
-
tasks = await db
|
|
32
|
-
.select()
|
|
33
|
-
.from(schema.tasks)
|
|
34
|
-
.orderBy(schema.tasks.status, schema.tasks.position);
|
|
35
|
+
projectFilter = inArray(schema.tasks.projectId, ids);
|
|
35
36
|
}
|
|
36
37
|
} else if (projectId) {
|
|
37
|
-
|
|
38
|
-
tasks = await db
|
|
39
|
-
.select()
|
|
40
|
-
.from(schema.tasks)
|
|
41
|
-
.where(eq(schema.tasks.projectId, projectId))
|
|
42
|
-
.orderBy(schema.tasks.status, schema.tasks.position);
|
|
43
|
-
} else {
|
|
44
|
-
// No filter - return all tasks
|
|
45
|
-
tasks = await db
|
|
46
|
-
.select()
|
|
47
|
-
.from(schema.tasks)
|
|
48
|
-
.orderBy(schema.tasks.status, schema.tasks.position);
|
|
38
|
+
projectFilter = eq(schema.tasks.projectId, projectId);
|
|
49
39
|
}
|
|
50
40
|
|
|
41
|
+
// Combine filters
|
|
42
|
+
const conditions = [projectFilter, statusFilter].filter(Boolean);
|
|
43
|
+
const whereClause = conditions.length > 0
|
|
44
|
+
? conditions.length === 1 ? conditions[0] : and(...conditions)
|
|
45
|
+
: undefined;
|
|
46
|
+
|
|
47
|
+
const tasks = await db
|
|
48
|
+
.select()
|
|
49
|
+
.from(schema.tasks)
|
|
50
|
+
.where(whereClause)
|
|
51
|
+
.orderBy(schema.tasks.status, schema.tasks.position);
|
|
52
|
+
|
|
51
53
|
return NextResponse.json(tasks);
|
|
52
54
|
} catch (error) {
|
|
53
55
|
console.error('Failed to fetch tasks:', error);
|
|
@@ -18,15 +18,24 @@ import {
|
|
|
18
18
|
} from '@dnd-kit/core';
|
|
19
19
|
import { arrayMove } from '@dnd-kit/sortable';
|
|
20
20
|
import { useTranslations } from 'next-intl';
|
|
21
|
-
import { Plus, Trash2, ArrowDown } from 'lucide-react';
|
|
21
|
+
import { Plus, Trash2, ArrowDown, Columns3 } from 'lucide-react';
|
|
22
22
|
import { Task, TaskStatus, KANBAN_COLUMNS } from '@/types';
|
|
23
23
|
import { Column } from './column';
|
|
24
24
|
import { TaskCard } from './task-card';
|
|
25
25
|
import { useTaskStore } from '@/stores/task-store';
|
|
26
|
+
import { usePanelLayoutStore } from '@/stores/panel-layout-store';
|
|
26
27
|
import { useTouchDetection } from '@/hooks/use-touch-detection';
|
|
27
28
|
import { useIsMobileViewport } from '@/hooks/use-mobile-viewport';
|
|
28
29
|
import { useChatHistorySearch } from '@/hooks/use-chat-history-search';
|
|
29
30
|
import { cn } from '@/lib/utils';
|
|
31
|
+
import {
|
|
32
|
+
DropdownMenu,
|
|
33
|
+
DropdownMenuTrigger,
|
|
34
|
+
DropdownMenuContent,
|
|
35
|
+
DropdownMenuCheckboxItem,
|
|
36
|
+
DropdownMenuLabel,
|
|
37
|
+
DropdownMenuSeparator,
|
|
38
|
+
} from '@/components/ui/dropdown-menu';
|
|
30
39
|
|
|
31
40
|
/**
|
|
32
41
|
* Custom collision detector for mobile status tabs.
|
|
@@ -153,6 +162,20 @@ export function Board({ attempts = [], onCreateTask, searchQuery = '' }: BoardPr
|
|
|
153
162
|
const isMobile = useTouchDetection(); // Single global touch detection
|
|
154
163
|
const isMobileViewport = useIsMobileViewport();
|
|
155
164
|
|
|
165
|
+
const { hiddenColumns, toggleColumn } = usePanelLayoutStore();
|
|
166
|
+
|
|
167
|
+
const visibleColumns = useMemo(
|
|
168
|
+
() => KANBAN_COLUMNS.filter(col => !hiddenColumns.includes(col.id)),
|
|
169
|
+
[hiddenColumns]
|
|
170
|
+
);
|
|
171
|
+
|
|
172
|
+
// If mobile active column is hidden, reset to first visible column
|
|
173
|
+
useEffect(() => {
|
|
174
|
+
if (visibleColumns.length > 0 && !visibleColumns.some(c => c.id === mobileActiveColumn)) {
|
|
175
|
+
setMobileActiveColumn(visibleColumns[0].id);
|
|
176
|
+
}
|
|
177
|
+
}, [visibleColumns, mobileActiveColumn]);
|
|
178
|
+
|
|
156
179
|
// Search chat history for matches
|
|
157
180
|
const { matches: chatHistoryMatches } = useChatHistorySearch(searchQuery);
|
|
158
181
|
|
|
@@ -420,7 +443,7 @@ export function Board({ attempts = [], onCreateTask, searchQuery = '' }: BoardPr
|
|
|
420
443
|
touchStartRef.current = null;
|
|
421
444
|
setIsDragging(false);
|
|
422
445
|
|
|
423
|
-
const columnIds =
|
|
446
|
+
const columnIds = visibleColumns.map(c => c.id);
|
|
424
447
|
const currentIndex = columnIds.indexOf(mobileActiveColumn);
|
|
425
448
|
const threshold = window.innerWidth * 0.2; // 20% of screen width to trigger column change
|
|
426
449
|
|
|
@@ -470,7 +493,7 @@ export function Board({ attempts = [], onCreateTask, searchQuery = '' }: BoardPr
|
|
|
470
493
|
// Mobile: single column view with tab bar
|
|
471
494
|
if (isMobileViewport) {
|
|
472
495
|
const activeColumnTasks = tasksByStatus.get(mobileActiveColumn) || [];
|
|
473
|
-
const columnIds =
|
|
496
|
+
const columnIds = visibleColumns.map(c => c.id);
|
|
474
497
|
const currentIndex = columnIds.indexOf(mobileActiveColumn);
|
|
475
498
|
|
|
476
499
|
// Determine which adjacent column to show based on swipe direction
|
|
@@ -501,7 +524,7 @@ export function Board({ attempts = [], onCreateTask, searchQuery = '' }: BoardPr
|
|
|
501
524
|
{/* Column tab bar */}
|
|
502
525
|
<div className="flex-shrink-0 border-b overflow-x-auto">
|
|
503
526
|
<div className="flex min-w-min">
|
|
504
|
-
{
|
|
527
|
+
{visibleColumns.map((column) => {
|
|
505
528
|
const count = (tasksByStatus.get(column.id) || []).length;
|
|
506
529
|
const isActive = column.id === mobileActiveColumn;
|
|
507
530
|
const isOver = hoveredStatusTab === column.id;
|
|
@@ -673,20 +696,45 @@ export function Board({ attempts = [], onCreateTask, searchQuery = '' }: BoardPr
|
|
|
673
696
|
onDragEnd={handleDragEnd}
|
|
674
697
|
onDragCancel={handleDragCancel}
|
|
675
698
|
>
|
|
676
|
-
<div className="flex
|
|
677
|
-
|
|
678
|
-
<
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
699
|
+
<div className="flex flex-col h-full">
|
|
700
|
+
<div className="flex justify-end px-4 pt-2 pb-1">
|
|
701
|
+
<DropdownMenu>
|
|
702
|
+
<DropdownMenuTrigger asChild>
|
|
703
|
+
<button className="inline-flex items-center gap-1.5 px-2 py-1 text-xs text-muted-foreground hover:text-foreground rounded-md hover:bg-accent transition-colors">
|
|
704
|
+
<Columns3 className="h-3.5 w-3.5" />
|
|
705
|
+
<span>{t('columns')}</span>
|
|
706
|
+
</button>
|
|
707
|
+
</DropdownMenuTrigger>
|
|
708
|
+
<DropdownMenuContent align="end">
|
|
709
|
+
<DropdownMenuLabel>{t('toggleColumns')}</DropdownMenuLabel>
|
|
710
|
+
<DropdownMenuSeparator />
|
|
711
|
+
{KANBAN_COLUMNS.map((column) => (
|
|
712
|
+
<DropdownMenuCheckboxItem
|
|
713
|
+
key={column.id}
|
|
714
|
+
checked={!hiddenColumns.includes(column.id)}
|
|
715
|
+
onCheckedChange={() => toggleColumn(column.id)}
|
|
716
|
+
>
|
|
717
|
+
{t(column.titleKey)}
|
|
718
|
+
</DropdownMenuCheckboxItem>
|
|
719
|
+
))}
|
|
720
|
+
</DropdownMenuContent>
|
|
721
|
+
</DropdownMenu>
|
|
722
|
+
</div>
|
|
723
|
+
<div className="flex gap-4 flex-1 min-h-0 overflow-x-auto pb-4 pl-4">
|
|
724
|
+
{visibleColumns.map((column) => (
|
|
725
|
+
<Column
|
|
726
|
+
key={column.id}
|
|
727
|
+
status={column.id}
|
|
728
|
+
title={t(column.titleKey)}
|
|
729
|
+
tasks={tasksByStatus.get(column.id) || []}
|
|
730
|
+
attemptCounts={attemptCounts}
|
|
731
|
+
onCreateTask={onCreateTask}
|
|
732
|
+
searchQuery={searchQuery}
|
|
733
|
+
isMobile={isMobile}
|
|
734
|
+
chatHistoryMatches={chatHistoryMatches}
|
|
735
|
+
/>
|
|
736
|
+
))}
|
|
737
|
+
</div>
|
|
690
738
|
</div>
|
|
691
739
|
|
|
692
740
|
<DragOverlay>
|
|
@@ -44,7 +44,7 @@ const STATUSES: TaskStatus[] = ['todo', 'in_progress', 'in_review', 'done', 'can
|
|
|
44
44
|
export function TaskDetailPanel({ className }: TaskDetailPanelProps) {
|
|
45
45
|
const t = useTranslations('chat');
|
|
46
46
|
const tk = useTranslations('kanban');
|
|
47
|
-
const { selectedTask, setSelectedTask, updateTaskStatus, setTaskChatInit, pendingAutoStartTask, pendingAutoStartPrompt, pendingAutoStartFileIds, setPendingAutoStartTask, moveTaskToInProgress, renameTask } = useTaskStore();
|
|
47
|
+
const { selectedTask, setSelectedTask, updateTaskStatus, setTaskChatInit, pendingAutoStartTask, pendingAutoStartPrompt, pendingAutoStartFileIds, setPendingAutoStartTask, moveTaskToInProgress, renameTask, updateTaskDescription } = useTaskStore();
|
|
48
48
|
const { activeProjectId, selectedProjectIds, projects } = useProjectStore();
|
|
49
49
|
const { widths, setWidth: setPanelWidth } = usePanelLayoutStore();
|
|
50
50
|
const { getPendingFiles, clearFiles } = useAttachmentStore();
|
|
@@ -60,10 +60,13 @@ export function TaskDetailPanel({ className }: TaskDetailPanelProps) {
|
|
|
60
60
|
const [showQuestionPrompt, setShowQuestionPrompt] = useState(false);
|
|
61
61
|
const [isEditingTitle, setIsEditingTitle] = useState(false);
|
|
62
62
|
const [editTitleValue, setEditTitleValue] = useState('');
|
|
63
|
+
const [isEditingDescription, setIsEditingDescription] = useState(false);
|
|
64
|
+
const [editDescriptionValue, setEditDescriptionValue] = useState('');
|
|
63
65
|
|
|
64
66
|
const panelRef = useRef<HTMLDivElement>(null);
|
|
65
67
|
const promptInputRef = useRef<PromptInputRef>(null);
|
|
66
68
|
const titleInputRef = useRef<HTMLInputElement>(null);
|
|
69
|
+
const descriptionTextareaRef = useRef<HTMLTextAreaElement>(null);
|
|
67
70
|
const { shells } = useShellStore();
|
|
68
71
|
const hasAutoStartedRef = useRef(false);
|
|
69
72
|
const lastCompletedTaskRef = useRef<string | null>(null);
|
|
@@ -167,6 +170,8 @@ export function TaskDetailPanel({ className }: TaskDetailPanelProps) {
|
|
|
167
170
|
setShowQuestionPrompt(false);
|
|
168
171
|
setIsEditingTitle(false);
|
|
169
172
|
setEditTitleValue('');
|
|
173
|
+
setIsEditingDescription(false);
|
|
174
|
+
setEditDescriptionValue('');
|
|
170
175
|
lastCompletedTaskRef.current = null;
|
|
171
176
|
hasAutoStartedRef.current = false;
|
|
172
177
|
|
|
@@ -268,6 +273,30 @@ export function TaskDetailPanel({ className }: TaskDetailPanelProps) {
|
|
|
268
273
|
setEditTitleValue('');
|
|
269
274
|
};
|
|
270
275
|
|
|
276
|
+
const handleStartEditDescription = () => {
|
|
277
|
+
setEditDescriptionValue(selectedTask.description || '');
|
|
278
|
+
setIsEditingDescription(true);
|
|
279
|
+
setTimeout(() => descriptionTextareaRef.current?.focus(), 0);
|
|
280
|
+
};
|
|
281
|
+
|
|
282
|
+
const handleSaveDescription = async () => {
|
|
283
|
+
const trimmed = editDescriptionValue.trim();
|
|
284
|
+
const newValue = trimmed || null;
|
|
285
|
+
if (newValue !== (selectedTask.description || null)) {
|
|
286
|
+
try {
|
|
287
|
+
await updateTaskDescription(selectedTask.id, newValue);
|
|
288
|
+
} catch {
|
|
289
|
+
// Store reverts on failure
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
setIsEditingDescription(false);
|
|
293
|
+
};
|
|
294
|
+
|
|
295
|
+
const handleCancelEditDescription = () => {
|
|
296
|
+
setIsEditingDescription(false);
|
|
297
|
+
setEditDescriptionValue('');
|
|
298
|
+
};
|
|
299
|
+
|
|
271
300
|
const handlePromptSubmit = (prompt: string, displayPrompt?: string, fileIds?: string[]) => {
|
|
272
301
|
if (selectedTask?.status !== 'in_progress') {
|
|
273
302
|
moveTaskToInProgress(selectedTask.id);
|
|
@@ -493,6 +522,32 @@ export function TaskDetailPanel({ className }: TaskDetailPanelProps) {
|
|
|
493
522
|
{selectedTask.title}
|
|
494
523
|
</h2>
|
|
495
524
|
)}
|
|
525
|
+
{isEditingDescription ? (
|
|
526
|
+
<textarea
|
|
527
|
+
ref={descriptionTextareaRef}
|
|
528
|
+
value={editDescriptionValue}
|
|
529
|
+
onChange={(e) => setEditDescriptionValue(e.target.value)}
|
|
530
|
+
onBlur={handleSaveDescription}
|
|
531
|
+
onKeyDown={(e) => {
|
|
532
|
+
if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) {
|
|
533
|
+
e.preventDefault();
|
|
534
|
+
handleSaveDescription();
|
|
535
|
+
} else if (e.key === 'Escape') {
|
|
536
|
+
handleCancelEditDescription();
|
|
537
|
+
}
|
|
538
|
+
}}
|
|
539
|
+
rows={3}
|
|
540
|
+
className="mt-1 text-sm text-muted-foreground w-full bg-transparent border border-border rounded-md p-2 outline-none resize-y"
|
|
541
|
+
placeholder="Add description..."
|
|
542
|
+
/>
|
|
543
|
+
) : (
|
|
544
|
+
<p
|
|
545
|
+
className="mt-1 text-sm text-muted-foreground line-clamp-3 cursor-text min-h-[1.25rem]"
|
|
546
|
+
onClick={handleStartEditDescription}
|
|
547
|
+
>
|
|
548
|
+
{selectedTask.description || 'Add description...'}
|
|
549
|
+
</p>
|
|
550
|
+
)}
|
|
496
551
|
</div>
|
|
497
552
|
|
|
498
553
|
{renderContent()}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { create } from 'zustand';
|
|
2
2
|
import { persist } from 'zustand/middleware';
|
|
3
|
+
import { TaskStatus } from '@/types';
|
|
3
4
|
|
|
4
5
|
export interface PanelWidths {
|
|
5
6
|
leftSidebar: number;
|
|
@@ -23,11 +24,13 @@ export const PANEL_CONFIGS: Record<keyof PanelWidths, PanelConfig> = {
|
|
|
23
24
|
|
|
24
25
|
interface PanelLayoutState {
|
|
25
26
|
widths: PanelWidths;
|
|
27
|
+
hiddenColumns: TaskStatus[];
|
|
26
28
|
}
|
|
27
29
|
|
|
28
30
|
interface PanelLayoutActions {
|
|
29
31
|
setWidth: (panel: keyof PanelWidths, width: number) => void;
|
|
30
32
|
resetWidths: () => void;
|
|
33
|
+
toggleColumn: (status: TaskStatus) => void;
|
|
31
34
|
}
|
|
32
35
|
|
|
33
36
|
type PanelLayoutStore = PanelLayoutState & PanelLayoutActions;
|
|
@@ -43,6 +46,7 @@ export const usePanelLayoutStore = create<PanelLayoutStore>()(
|
|
|
43
46
|
persist(
|
|
44
47
|
(set) => ({
|
|
45
48
|
widths: getDefaultWidths(),
|
|
49
|
+
hiddenColumns: [] as TaskStatus[],
|
|
46
50
|
|
|
47
51
|
setWidth: (panel, width) =>
|
|
48
52
|
set((state) => {
|
|
@@ -57,10 +61,17 @@ export const usePanelLayoutStore = create<PanelLayoutStore>()(
|
|
|
57
61
|
}),
|
|
58
62
|
|
|
59
63
|
resetWidths: () => set({ widths: getDefaultWidths() }),
|
|
64
|
+
|
|
65
|
+
toggleColumn: (status) =>
|
|
66
|
+
set((state) => ({
|
|
67
|
+
hiddenColumns: state.hiddenColumns.includes(status)
|
|
68
|
+
? state.hiddenColumns.filter((s) => s !== status)
|
|
69
|
+
: [...state.hiddenColumns, status],
|
|
70
|
+
})),
|
|
60
71
|
}),
|
|
61
72
|
{
|
|
62
73
|
name: 'panel-layout-store',
|
|
63
|
-
partialize: (state) => ({ widths: state.widths }),
|
|
74
|
+
partialize: (state) => ({ widths: state.widths, hiddenColumns: state.hiddenColumns }),
|
|
64
75
|
}
|
|
65
76
|
)
|
|
66
77
|
);
|
package/src/stores/task-store.ts
CHANGED
|
@@ -35,6 +35,7 @@ interface TaskStore {
|
|
|
35
35
|
reorderTasks: (taskId: string, newStatus: TaskStatus, newPosition: number) => Promise<void>;
|
|
36
36
|
updateTaskStatus: (taskId: string, status: TaskStatus) => Promise<void>;
|
|
37
37
|
renameTask: (taskId: string, title: string) => Promise<void>;
|
|
38
|
+
updateTaskDescription: (taskId: string, description: string | null) => Promise<void>;
|
|
38
39
|
}
|
|
39
40
|
|
|
40
41
|
export const useTaskStore = create<TaskStore>((set, get) => ({
|
|
@@ -331,6 +332,39 @@ export const useTaskStore = create<TaskStore>((set, get) => ({
|
|
|
331
332
|
}
|
|
332
333
|
},
|
|
333
334
|
|
|
335
|
+
updateTaskDescription: async (taskId: string, description: string | null) => {
|
|
336
|
+
const oldTasks = get().tasks;
|
|
337
|
+
const task = oldTasks.find((t) => t.id === taskId);
|
|
338
|
+
if (!task) return;
|
|
339
|
+
|
|
340
|
+
// Optimistic update
|
|
341
|
+
get().updateTask(taskId, { description });
|
|
342
|
+
|
|
343
|
+
// Update selectedTask if it's the same task
|
|
344
|
+
const selected = get().selectedTask;
|
|
345
|
+
if (selected?.id === taskId) {
|
|
346
|
+
set({ selectedTask: { ...selected, description } });
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
try {
|
|
350
|
+
const res = await fetch(`/api/tasks/${taskId}`, {
|
|
351
|
+
method: 'PATCH',
|
|
352
|
+
headers: { 'Content-Type': 'application/json' },
|
|
353
|
+
body: JSON.stringify({ description }),
|
|
354
|
+
});
|
|
355
|
+
if (!res.ok) throw new Error('Failed to update task description');
|
|
356
|
+
} catch (error) {
|
|
357
|
+
// Revert on failure
|
|
358
|
+
get().updateTask(taskId, { description: task.description });
|
|
359
|
+
const selected = get().selectedTask;
|
|
360
|
+
if (selected?.id === taskId) {
|
|
361
|
+
set({ selectedTask: { ...selected, description: task.description } });
|
|
362
|
+
}
|
|
363
|
+
log.error({ error, taskId }, 'Error updating task description');
|
|
364
|
+
throw error;
|
|
365
|
+
}
|
|
366
|
+
},
|
|
367
|
+
|
|
334
368
|
setTaskChatInit: async (taskId: string, chatInit: boolean) => {
|
|
335
369
|
// Optimistic update
|
|
336
370
|
get().updateTask(taskId, { chatInit });
|