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 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 Start server using .env from current directory
74
- cd ~/myproject && claude-ws Use ~/myproject/.env and database
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
@@ -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/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
@@ -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/⌘ + 空格)",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-ws",
3
- "version": "0.3.99",
3
+ "version": "0.3.100",
4
4
  "private": false,
5
5
  "description": "A beautifully crafted workspace interface for Claude Code with real-time streaming and local SQLite database",
6
6
  "keywords": [
@@ -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
- let tasks;
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
- tasks = await db
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
- // Single project mode (backward compat)
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 = KANBAN_COLUMNS.map(c => c.id);
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 = KANBAN_COLUMNS.map(c => c.id);
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
- {KANBAN_COLUMNS.map((column) => {
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 gap-4 h-full overflow-x-auto pb-4 pl-4">
677
- {KANBAN_COLUMNS.map((column) => (
678
- <Column
679
- key={column.id}
680
- status={column.id}
681
- title={t(column.titleKey)}
682
- tasks={tasksByStatus.get(column.id) || []}
683
- attemptCounts={attemptCounts}
684
- onCreateTask={onCreateTask}
685
- searchQuery={searchQuery}
686
- isMobile={isMobile}
687
- chatHistoryMatches={chatHistoryMatches}
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
  );
@@ -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 });