claude-code-kanban 4.19.0 → 4.20.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 +9 -1
- package/lib/session-events.js +99 -0
- package/package.json +1 -1
- package/plugin/plugins/claude-code-kanban/.claude-plugin/plugin.json +5 -2
- package/plugin/plugins/claude-code-kanban/monitors.json +8 -0
- package/plugin/plugins/claude-code-kanban/scripts/postman.js +67 -0
- package/plugin/plugins/claude-code-kanban/skills/kanban/SKILL.md +20 -29
- package/plugin/plugins/claude-code-kanban/skills/kanban-follow/SKILL.md +39 -0
- package/public/app.js +228 -53
- package/public/index.html +1 -1
- package/public/style.css +110 -5
- package/server.js +99 -26
package/README.md
CHANGED
|
@@ -30,7 +30,15 @@ npx claude-code-kanban --open
|
|
|
30
30
|
|
|
31
31
|
### 3. Use Claude Code as usual
|
|
32
32
|
|
|
33
|
-
Tasks, agents, and messages appear on the board automatically — Claude Code writes task files and conversation logs to `~/.claude`, the dashboard watches them and streams updates to the browser via SSE.
|
|
33
|
+
Tasks, agents, and messages appear on the board automatically — Claude Code writes task files and conversation logs to `~/.claude`, the dashboard watches them and streams updates to the browser via SSE. Moving a card is the one thing that flows the other way: the board notifies the owning session with the card subject and description, so the agent can act on it.
|
|
34
|
+
|
|
35
|
+
> **Empty board?** Claude Code ships the task tools off by default on some models — currently Opus 5, Fable 5 — so nothing writes task files and the board stays empty. Turn them on in `~/.claude/settings.json`:
|
|
36
|
+
>
|
|
37
|
+
> ```json
|
|
38
|
+
> { "env": { "CLAUDE_CODE_ENABLE_TODO_TOOLS": "true" } }
|
|
39
|
+
> ```
|
|
40
|
+
>
|
|
41
|
+
> Then restart Claude Code. You can also add a task by hand from the board's Pending column.
|
|
34
42
|
|
|
35
43
|
## Features
|
|
36
44
|
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
// Session event doorbell: tells a live session that the board moved one of its tasks.
|
|
2
|
+
// Its own module so the behaviour is unit-testable without booting the server -- the
|
|
3
|
+
// bucket-map invariants here are the difference between a bounded queue and a map that
|
|
4
|
+
// grows one permanent entry per session id ever named in a request path.
|
|
5
|
+
|
|
6
|
+
// Tells a live session that the board moved one of its tasks. The postman monitor
|
|
7
|
+
// (plugin/plugins/claude-code-kanban/scripts/postman.js) drains this queue and prints
|
|
8
|
+
// each line, which Claude Code delivers into that session as a task notification.
|
|
9
|
+
//
|
|
10
|
+
// Deliberately in-memory and lossy. The task file is the durable command -- a dropped
|
|
11
|
+
// event only means the agent notices on its next turn instead of immediately -- so a
|
|
12
|
+
// disk queue would buy nothing. Reading consumes, so a restarted postman never replays
|
|
13
|
+
// a backlog and acts on the same move twice.
|
|
14
|
+
//
|
|
15
|
+
// A bucket exists only while it holds something: an undelivered line or a waiting
|
|
16
|
+
// poller. Without that, the map would grow one permanent entry per session id ever
|
|
17
|
+
// asked for -- and the id comes straight off the request path.
|
|
18
|
+
const sessionEventBuckets = new Map();
|
|
19
|
+
|
|
20
|
+
// The line reaches the model verbatim at hook trust level. The task id is caller-supplied
|
|
21
|
+
// and the subject and description are board-authored, so the length cap and the
|
|
22
|
+
// control-character scrub are what hold the one-line-per-event contract: a newline inside
|
|
23
|
+
// a description becomes a space rather than a second forged event.
|
|
24
|
+
//
|
|
25
|
+
// One cap for the whole line rather than one per field, because the description comes
|
|
26
|
+
// last: truncation eats its tail first and leaves the machine-readable head intact.
|
|
27
|
+
function sanitizeEventLine(line) {
|
|
28
|
+
return line.replace(/[\x00-\x1f\x7f]/g, ' ').trim().slice(0, 1500);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// Everything after `description=` is the description verbatim to end of line, so no amount
|
|
32
|
+
// of board text can pose as a further field. That leaves the subject as the only value
|
|
33
|
+
// that needs delimiting.
|
|
34
|
+
function formatTaskMoved(taskId, prevStatus, task) {
|
|
35
|
+
const subject = String(task.subject || '').replace(/(["\\])/g, '\\$1');
|
|
36
|
+
const head = `cck:1 task.moved ${taskId} ${prevStatus || 'none'}>${task.status} subject="${subject}"`;
|
|
37
|
+
return task.description ? `${head} description=${task.description}` : head;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function enqueueSessionEvent(sessionId, line) {
|
|
41
|
+
const text = sanitizeEventLine(line);
|
|
42
|
+
if (!sessionId || !text) return;
|
|
43
|
+
let bucket = sessionEventBuckets.get(sessionId);
|
|
44
|
+
if (!bucket) {
|
|
45
|
+
bucket = { queue: [], waiters: new Set() };
|
|
46
|
+
sessionEventBuckets.set(sessionId, bucket);
|
|
47
|
+
}
|
|
48
|
+
bucket.queue.push(text);
|
|
49
|
+
// A session with no postman attached must not grow without bound.
|
|
50
|
+
if (bucket.queue.length > 50) bucket.queue.splice(0, bucket.queue.length - 50);
|
|
51
|
+
for (const wake of [...bucket.waiters]) wake();
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Long-poll drained by the postman monitor. Routing stays in server.js; this is the handler.
|
|
55
|
+
function handleSessionEvents(req, res) {
|
|
56
|
+
const { sessionId } = req.params;
|
|
57
|
+
const bucket = sessionEventBuckets.get(sessionId);
|
|
58
|
+
const wait = Math.min(Math.max(Number(req.query.wait) || 0, 0), 120);
|
|
59
|
+
|
|
60
|
+
// A postman is armed by a skill invocation, so it can attach long after the board moved
|
|
61
|
+
// something. Those lines are read as instructions, and an hours-old instruction is worse
|
|
62
|
+
// than no instruction, so the grant starts the session's history rather than inheriting
|
|
63
|
+
// it: `first=1` drops the whole backlog. drain() (not a bare truncate) so an emptied
|
|
64
|
+
// bucket is still evicted from the map.
|
|
65
|
+
if (bucket && req.query.first === '1') drain(sessionId, bucket);
|
|
66
|
+
|
|
67
|
+
if (bucket && bucket.queue.length) return res.json({ events: drain(sessionId, bucket) });
|
|
68
|
+
if (!wait) return res.json({ events: [] });
|
|
69
|
+
|
|
70
|
+
const pending = bucket || { queue: [], waiters: new Set() };
|
|
71
|
+
sessionEventBuckets.set(sessionId, pending);
|
|
72
|
+
|
|
73
|
+
const send = () => {
|
|
74
|
+
// Set.delete is the whole idempotency story: whichever of enqueue, timeout, or
|
|
75
|
+
// client disconnect gets here first is the one that answers.
|
|
76
|
+
if (!pending.waiters.delete(send)) return;
|
|
77
|
+
clearTimeout(timer);
|
|
78
|
+
req.removeListener('close', send);
|
|
79
|
+
const events = drain(sessionId, pending);
|
|
80
|
+
if (!res.writableEnded) res.json({ events });
|
|
81
|
+
};
|
|
82
|
+
const timer = setTimeout(send, wait * 1000);
|
|
83
|
+
pending.waiters.add(send);
|
|
84
|
+
req.on('close', send);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function drain(sessionId, bucket) {
|
|
88
|
+
const events = bucket.queue.splice(0);
|
|
89
|
+
if (!bucket.queue.length && !bucket.waiters.size) sessionEventBuckets.delete(sessionId);
|
|
90
|
+
return events;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
module.exports = {
|
|
94
|
+
sessionEventBuckets,
|
|
95
|
+
sanitizeEventLine,
|
|
96
|
+
formatTaskMoved,
|
|
97
|
+
enqueueSessionEvent,
|
|
98
|
+
handleSessionEvents,
|
|
99
|
+
};
|
package/package.json
CHANGED
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "claude-code-kanban",
|
|
3
|
-
"version": "2.
|
|
4
|
-
"description": "claude-code-kanban dashboard integration: agent activity tracking, context statusline,
|
|
3
|
+
"version": "2.7.1",
|
|
4
|
+
"description": "claude-code-kanban dashboard integration: agent activity tracking, context statusline, skills to drive the board from a session and to follow it",
|
|
5
|
+
"experimental": {
|
|
6
|
+
"monitors": "./monitors.json"
|
|
7
|
+
}
|
|
5
8
|
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Kanban -> session doorbell.
|
|
3
|
+
//
|
|
4
|
+
// Claude Code delivers every line a monitor prints to the owning session as a task
|
|
5
|
+
// notification, so this process is the only way board activity can reach a session that
|
|
6
|
+
// is sitting idle. It long-polls the kanban server for events addressed to this session
|
|
7
|
+
// and prints them one per line.
|
|
8
|
+
//
|
|
9
|
+
// Pairing is free: CLAUDE_CODE_SESSION_ID is inherited from the session that spawned us,
|
|
10
|
+
// so the id we poll with is the same id the hooks report. No cwd or pid guessing.
|
|
11
|
+
//
|
|
12
|
+
// The lines we print carry board text (the card subject and description), which the
|
|
13
|
+
// session is told to read as the user's own brief. That is only safe because we are armed
|
|
14
|
+
// by an explicit `kanban-follow` invocation: the user asked to follow the board before anything the
|
|
15
|
+
// board says can reach the model.
|
|
16
|
+
|
|
17
|
+
const fs = require('fs');
|
|
18
|
+
const os = require('os');
|
|
19
|
+
const path = require('path');
|
|
20
|
+
|
|
21
|
+
const SESSION_ID = process.env.CLAUDE_CODE_SESSION_ID;
|
|
22
|
+
const SERVER_INFO = path.join(os.homedir(), '.claude', '.cck', 'server.json');
|
|
23
|
+
// The server caps its own wait at 120s. Sitting at the ceiling halves every recurring
|
|
24
|
+
// cost -- handshake, route walk, timer, empty response -- and costs no event latency,
|
|
25
|
+
// because an enqueue wakes the poll immediately.
|
|
26
|
+
const WAIT_SEC = 120;
|
|
27
|
+
const RETRY_MS = 15000;
|
|
28
|
+
|
|
29
|
+
if (!SESSION_ID) process.exit(0);
|
|
30
|
+
|
|
31
|
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
32
|
+
|
|
33
|
+
// Re-read every cycle rather than caching: it is how we follow the board across a
|
|
34
|
+
// restart onto a different port. A file left behind by a crashed server names a port
|
|
35
|
+
// something else may now hold, so trust it only while its pid is alive.
|
|
36
|
+
function serverUrl() {
|
|
37
|
+
const { port, pid } = JSON.parse(fs.readFileSync(SERVER_INFO, 'utf8'));
|
|
38
|
+
if (pid) process.kill(pid, 0);
|
|
39
|
+
return `http://127.0.0.1:${port}`;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Once per process, not once per poll: the grant means "follow the board from here on", so
|
|
43
|
+
// the first attach throws away whatever queued up before it. A later reconnect must not
|
|
44
|
+
// discard again -- by then the queue holds events the user is owed.
|
|
45
|
+
let firstAttach = true;
|
|
46
|
+
|
|
47
|
+
async function poll(base) {
|
|
48
|
+
const first = firstAttach ? '&first=1' : '';
|
|
49
|
+
const url = `${base}/api/sessions/${encodeURIComponent(SESSION_ID)}/events?wait=${WAIT_SEC}${first}`;
|
|
50
|
+
const res = await fetch(url, { signal: AbortSignal.timeout((WAIT_SEC + 15) * 1000) });
|
|
51
|
+
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
|
52
|
+
firstAttach = false;
|
|
53
|
+
const { events } = await res.json();
|
|
54
|
+
return Array.isArray(events) ? events : [];
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
(async () => {
|
|
58
|
+
for (;;) {
|
|
59
|
+
try {
|
|
60
|
+
for (const line of await poll(serverUrl())) console.log(line);
|
|
61
|
+
} catch (_) {
|
|
62
|
+
// No board yet, or it went away. It may come back later in the session, so keep
|
|
63
|
+
// waiting quietly -- a missing server is the normal case, not an error.
|
|
64
|
+
await sleep(RETRY_MS);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
})();
|
|
@@ -1,60 +1,53 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: kanban
|
|
3
|
-
description: Drive the
|
|
3
|
+
description: Drive the kanban board — open, pin, preview, link, inspect.
|
|
4
4
|
argument-hint: '[open|pin|unpin|preview|link] [target]'
|
|
5
|
+
disable-model-invocation: true
|
|
5
6
|
---
|
|
6
7
|
|
|
7
8
|
# Kanban Skill
|
|
8
9
|
|
|
9
|
-
|
|
10
|
+
This session id is `${CLAUDE_SESSION_ID}`, substituted when the skill loads.
|
|
10
11
|
|
|
11
|
-
|
|
12
|
+
An argument names the section below that handles it; with no argument, open the current session. Prefer the bare `claude-code-kanban` binary, falling back to `npx claude-code-kanban` when it is off PATH or the user asks for npx.
|
|
12
13
|
|
|
13
|
-
|
|
14
|
+
To be driven *by* the board instead — card moves arriving as instructions — the user types `/claude-code-kanban:kanban-follow`.
|
|
14
15
|
|
|
15
|
-
##
|
|
16
|
+
## `open` — the current session
|
|
16
17
|
|
|
17
|
-
|
|
18
|
+
Pins the session and switches the board to the Active tab.
|
|
18
19
|
|
|
19
20
|
```bash
|
|
20
21
|
claude-code-kanban session open ${CLAUDE_SESSION_ID}
|
|
21
22
|
```
|
|
22
23
|
|
|
23
|
-
##
|
|
24
|
-
|
|
25
|
-
Pins the session so it stays visible regardless of filters. Three states: `pinned` (default), `sticky` (always at the top), or cleared with `--unpin`.
|
|
24
|
+
## `pin` — keep the session visible
|
|
26
25
|
|
|
27
26
|
```bash
|
|
28
27
|
claude-code-kanban session pin ${CLAUDE_SESSION_ID} # pin
|
|
29
|
-
claude-code-kanban session pin ${CLAUDE_SESSION_ID} --sticky #
|
|
28
|
+
claude-code-kanban session pin ${CLAUDE_SESSION_ID} --sticky # always at the top
|
|
30
29
|
claude-code-kanban session pin ${CLAUDE_SESSION_ID} --unpin # clear
|
|
30
|
+
claude-code-kanban session pins # list pinned; --sticky narrows
|
|
31
31
|
```
|
|
32
32
|
|
|
33
|
-
##
|
|
34
|
-
|
|
35
|
-
```bash
|
|
36
|
-
claude-code-kanban session pins # all pinned/sticky
|
|
37
|
-
claude-code-kanban session pins --sticky # sticky only
|
|
38
|
-
```
|
|
39
|
-
|
|
40
|
-
## Preview a file in kanban
|
|
33
|
+
## `preview` — open a file in the modal
|
|
41
34
|
|
|
42
|
-
|
|
35
|
+
Markdown or standalone HTML. HTML renders in a sandboxed iframe, so sibling assets like `./style.css` do not load. Relative paths are fine — the server resolves them.
|
|
43
36
|
|
|
44
37
|
```bash
|
|
45
|
-
claude-code-kanban preview-doc <
|
|
38
|
+
claude-code-kanban preview-doc <file.md|.html> --session ${CLAUDE_SESSION_ID}
|
|
46
39
|
```
|
|
47
40
|
|
|
48
|
-
##
|
|
41
|
+
## `link` — attach a doc without the modal
|
|
49
42
|
|
|
50
|
-
|
|
43
|
+
Adds the file to the session's linked docs in the sidebar. Any extension, and nothing pops up, so it is the safe choice while the user is working.
|
|
51
44
|
|
|
52
45
|
```bash
|
|
53
|
-
claude-code-kanban link-doc <path
|
|
54
|
-
claude-code-kanban link-doc <path
|
|
46
|
+
claude-code-kanban link-doc <path> --session ${CLAUDE_SESSION_ID} # link
|
|
47
|
+
claude-code-kanban link-doc <path> --session ${CLAUDE_SESSION_ID} --unlink # remove
|
|
55
48
|
```
|
|
56
49
|
|
|
57
|
-
##
|
|
50
|
+
## `list` / `view` / `peek` — read-only
|
|
58
51
|
|
|
59
52
|
```bash
|
|
60
53
|
claude-code-kanban session list --active # recent active sessions
|
|
@@ -64,12 +57,10 @@ claude-code-kanban session view ${CLAUDE_SESSION_ID} # full stats
|
|
|
64
57
|
claude-code-kanban session peek ${CLAUDE_SESSION_ID} --limit 20 # last 20 messages (server caps at 50)
|
|
65
58
|
```
|
|
66
59
|
|
|
67
|
-
`session list` shows 10 rows
|
|
68
|
-
|
|
69
|
-
Add `--json` to any list-style verb for machine-readable output.
|
|
60
|
+
`session list` shows 10 rows and always includes pinned sessions, sticky first (`--no-pins` disables both). `--json` works on any list-style verb.
|
|
70
61
|
|
|
71
62
|
## Troubleshooting
|
|
72
63
|
|
|
73
|
-
`claude-code-kanban help <command>` prints the authoritative flags
|
|
64
|
+
`claude-code-kanban help <command>` prints the authoritative flags — read it instead of guessing.
|
|
74
65
|
|
|
75
66
|
- **"Cannot reach cck server…"** → the error names the port it tried. Ask the user to start the server with `claude-code-kanban`. If they run it elsewhere, set `PORT=<n>` when invoking the CLI.
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: kanban-follow
|
|
3
|
+
description: Let kanban card moves drive this session.
|
|
4
|
+
disable-model-invocation: true
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
# Follow the kanban board
|
|
8
|
+
|
|
9
|
+
The user typing this skill is the whole grant: it armed the doorbell — a monitor watching the board for moves of *this session's* tasks, live for the rest of the session. Confirm in one line and run no command. Moves made before it attached are discarded, so every line you get is current.
|
|
10
|
+
|
|
11
|
+
```
|
|
12
|
+
cck:1 task.moved <taskId> <from>><to> subject="<subject>" description=<description>
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
`description=` is omitted when the card has none. Everything after it is the description verbatim to end of line, truncated if long — read the task with `TaskGet` for the full text or for `activeForm` and `blockedBy`. Only moves are announced.
|
|
16
|
+
|
|
17
|
+
**A move is an instruction, not an FYI** — the user dragging a card is the user telling you something, and the subject and description are their brief for the work:
|
|
18
|
+
|
|
19
|
+
| Transition | What the user means |
|
|
20
|
+
|---|---|
|
|
21
|
+
| `pending>in_progress` / `todo>in_progress` | Start this task now. |
|
|
22
|
+
| `in_progress>pending` / `in_progress>todo` | Stop working on it and park it. |
|
|
23
|
+
| `*>completed` | The user considers it done — do not keep working on it. |
|
|
24
|
+
| `*>cancelled` | Abandon it. Undo nothing unless asked. |
|
|
25
|
+
|
|
26
|
+
Act on the newest line per task; a card dragged twice means only its final position. When a move contradicts your current work, the board wins.
|
|
27
|
+
|
|
28
|
+
Delivery is best-effort and the task file is the authority, so a missed line only delays you to your next turn.
|
|
29
|
+
|
|
30
|
+
## Finish the task on the board
|
|
31
|
+
|
|
32
|
+
A move hands you work; the task file is where you hand it back. When the work a `>in_progress` move asked for is done, set that task to `completed` with `TaskUpdate` before you reply — the card moves itself, and the board is where the user is watching. A card left in In Progress reads as work still running.
|
|
33
|
+
|
|
34
|
+
Your own `TaskUpdate` writes the task file rather than going through the board, so it rings no doorbell: there is no echo to guard against.
|
|
35
|
+
|
|
36
|
+
## Troubleshooting
|
|
37
|
+
|
|
38
|
+
- **No lines arrive** → the doorbell arms once per session, on the namespaced dispatch: `/claude-code-kanban:kanban-follow`. A session that never got it never hears the board.
|
|
39
|
+
- **"Cannot reach cck server…"** → the error names the port it tried. Ask the user to start the server with `claude-code-kanban`.
|
package/public/app.js
CHANGED
|
@@ -1425,6 +1425,7 @@ function toggleAgentPin(agentId) {
|
|
|
1425
1425
|
}
|
|
1426
1426
|
savePins(currentSessionId, currentPins);
|
|
1427
1427
|
renderMessages(currentMessages);
|
|
1428
|
+
renderSessions();
|
|
1428
1429
|
renderAgentFooter();
|
|
1429
1430
|
}
|
|
1430
1431
|
|
|
@@ -1476,6 +1477,7 @@ function togglePin(msgIndex) {
|
|
|
1476
1477
|
}
|
|
1477
1478
|
savePins(currentSessionId, currentPins);
|
|
1478
1479
|
renderMessages(currentMessages);
|
|
1480
|
+
renderSessions();
|
|
1479
1481
|
updateMsgDetailPinState();
|
|
1480
1482
|
}
|
|
1481
1483
|
|
|
@@ -1485,6 +1487,7 @@ function unpinById(pinIdx) {
|
|
|
1485
1487
|
currentPins.splice(pinIdx, 1);
|
|
1486
1488
|
savePins(currentSessionId, currentPins);
|
|
1487
1489
|
renderMessages(currentMessages);
|
|
1490
|
+
renderSessions();
|
|
1488
1491
|
if (wasAgent) renderAgentFooter();
|
|
1489
1492
|
updateMsgDetailPinState();
|
|
1490
1493
|
}
|
|
@@ -1900,8 +1903,22 @@ function _setModalWidth(modal, slot, on, maxWidth, width) {
|
|
|
1900
1903
|
}
|
|
1901
1904
|
}
|
|
1902
1905
|
|
|
1906
|
+
function _modalEl(modalId) {
|
|
1907
|
+
return document.querySelector(`#${modalId} .modal`);
|
|
1908
|
+
}
|
|
1909
|
+
|
|
1910
|
+
// The opt-in marker for fullscreen and drag-resize alike: the presence of a
|
|
1911
|
+
// `<modalId>-fullscreen-btn` button in the markup.
|
|
1912
|
+
function _forEachFullscreenModal(cb) {
|
|
1913
|
+
for (const btn of document.querySelectorAll('[id$="-fullscreen-btn"]')) {
|
|
1914
|
+
const modalId = btn.id.replace(/-fullscreen-btn$/, '');
|
|
1915
|
+
const modal = _modalEl(modalId);
|
|
1916
|
+
if (modal) cb(modalId, modal);
|
|
1917
|
+
}
|
|
1918
|
+
}
|
|
1919
|
+
|
|
1903
1920
|
function _applyModalFullscreen(modalId, on) {
|
|
1904
|
-
const modal =
|
|
1921
|
+
const modal = _modalEl(modalId);
|
|
1905
1922
|
modal.classList.toggle('fullscreen', on);
|
|
1906
1923
|
_setModalWidth(modal, 'Fs', on, '', '');
|
|
1907
1924
|
updateFullscreenBtnIcon(`${modalId}-fullscreen-btn`, on);
|
|
@@ -1909,16 +1926,15 @@ function _applyModalFullscreen(modalId, on) {
|
|
|
1909
1926
|
|
|
1910
1927
|
// biome-ignore lint/correctness/noUnusedVariables: used in HTML
|
|
1911
1928
|
function toggleModalFullscreen(modalId) {
|
|
1912
|
-
const on = !
|
|
1929
|
+
const on = !_modalEl(modalId).classList.contains('fullscreen');
|
|
1913
1930
|
_applyModalFullscreen(modalId, on);
|
|
1914
1931
|
localStorage.setItem(`modal-fullscreen-${modalId}`, String(on));
|
|
1915
1932
|
}
|
|
1916
1933
|
|
|
1917
1934
|
function loadModalFullscreen() {
|
|
1918
|
-
|
|
1919
|
-
const modalId = btn.id.replace(/-fullscreen-btn$/, '');
|
|
1935
|
+
_forEachFullscreenModal((modalId) => {
|
|
1920
1936
|
if (localStorage.getItem(`modal-fullscreen-${modalId}`) === 'true') _applyModalFullscreen(modalId, true);
|
|
1921
|
-
}
|
|
1937
|
+
});
|
|
1922
1938
|
}
|
|
1923
1939
|
|
|
1924
1940
|
// Hides the overlay only — the fullscreen state stays on the dialog, it is a
|
|
@@ -1937,6 +1953,72 @@ function updateFullscreenBtnIcon(btnId, isFullscreen) {
|
|
|
1937
1953
|
: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="16" height="16"><polyline points="15 3 21 3 21 9"/><polyline points="9 21 3 21 3 15"/><line x1="21" y1="3" x2="14" y2="10"/><line x1="3" y1="21" x2="10" y2="14"/></svg>';
|
|
1938
1954
|
}
|
|
1939
1955
|
|
|
1956
|
+
// Free-form modal resize: drag the bottom-right grip, double-click it to reset.
|
|
1957
|
+
// The size lives in CSS custom properties + `.user-sized` (see style.css) rather
|
|
1958
|
+
// than inline width/height, so it never collides with the inline-style stash
|
|
1959
|
+
// that fullscreen and "Show more" do via _setModalWidth. The viewport cap lives
|
|
1960
|
+
// only in the CSS min(); JS clamps just the minimums.
|
|
1961
|
+
const MODAL_MIN_W = 360;
|
|
1962
|
+
const MODAL_MIN_H = 240;
|
|
1963
|
+
|
|
1964
|
+
function _setModalUserSize(modal, w, h) {
|
|
1965
|
+
modal.style.setProperty('--user-modal-w', w);
|
|
1966
|
+
modal.style.setProperty('--user-modal-h', h);
|
|
1967
|
+
}
|
|
1968
|
+
|
|
1969
|
+
function initModalResize() {
|
|
1970
|
+
_forEachFullscreenModal((modalId, modal) => {
|
|
1971
|
+
const wKey = `modal-width-${modalId}`;
|
|
1972
|
+
const hKey = `modal-height-${modalId}`;
|
|
1973
|
+
const savedW = localStorage.getItem(wKey);
|
|
1974
|
+
const savedH = localStorage.getItem(hKey);
|
|
1975
|
+
if (savedW && savedH) {
|
|
1976
|
+
_setModalUserSize(modal, savedW, savedH);
|
|
1977
|
+
modal.classList.add('user-sized');
|
|
1978
|
+
}
|
|
1979
|
+
|
|
1980
|
+
const handle = document.createElement('div');
|
|
1981
|
+
handle.className = 'modal-resize-handle';
|
|
1982
|
+
handle.title = 'Drag to resize · double-click to reset';
|
|
1983
|
+
modal.appendChild(handle);
|
|
1984
|
+
|
|
1985
|
+
let startW, startH, w, h;
|
|
1986
|
+
_initDragResize(handle, {
|
|
1987
|
+
onStart() {
|
|
1988
|
+
startW = modal.offsetWidth;
|
|
1989
|
+
startH = modal.offsetHeight;
|
|
1990
|
+
// autoSizeModal / "Show more" may have widened the dialog inline
|
|
1991
|
+
// before the first drag; inline width beats the .user-sized CSS.
|
|
1992
|
+
modal.style.width = '';
|
|
1993
|
+
modal.style.maxWidth = '';
|
|
1994
|
+
// Vars and class land together, so `.user-sized` never reads an
|
|
1995
|
+
// undefined var.
|
|
1996
|
+
_setModalUserSize(modal, `${startW}px`, `${startH}px`);
|
|
1997
|
+
modal.classList.add('user-sized');
|
|
1998
|
+
},
|
|
1999
|
+
// The overlay centers the modal, so both edges move — double the delta
|
|
2000
|
+
// to keep the grip under the cursor.
|
|
2001
|
+
onMove(dx, dy) {
|
|
2002
|
+
w = Math.max(MODAL_MIN_W, startW + dx * 2);
|
|
2003
|
+
h = Math.max(MODAL_MIN_H, startH + dy * 2);
|
|
2004
|
+
_setModalUserSize(modal, `${w}px`, `${h}px`);
|
|
2005
|
+
},
|
|
2006
|
+
onEnd() {
|
|
2007
|
+
if (w && h) {
|
|
2008
|
+
localStorage.setItem(wKey, `${w}px`);
|
|
2009
|
+
localStorage.setItem(hKey, `${h}px`);
|
|
2010
|
+
}
|
|
2011
|
+
},
|
|
2012
|
+
});
|
|
2013
|
+
|
|
2014
|
+
handle.addEventListener('dblclick', () => {
|
|
2015
|
+
modal.classList.remove('user-sized');
|
|
2016
|
+
localStorage.removeItem(wKey);
|
|
2017
|
+
localStorage.removeItem(hKey);
|
|
2018
|
+
});
|
|
2019
|
+
});
|
|
2020
|
+
}
|
|
2021
|
+
|
|
1940
2022
|
const MODAL_ZOOM_KEY = 'modal-zoom';
|
|
1941
2023
|
const MODAL_ZOOM_MIN = 0.7;
|
|
1942
2024
|
const MODAL_ZOOM_MAX = 2.0;
|
|
@@ -2325,7 +2407,8 @@ function _applyExpandToggle(btn, fullEl) {
|
|
|
2325
2407
|
const panel = btn.closest('.message-panel');
|
|
2326
2408
|
if (panel) panel.classList.toggle('msg-expanded-wide', expand);
|
|
2327
2409
|
const modal = btn.closest('.modal');
|
|
2328
|
-
|
|
2410
|
+
// A user-dragged size is an explicit choice — don't widen over it.
|
|
2411
|
+
if (modal && !modal.classList.contains('user-sized')) _setModalWidth(modal, 'Expand', expand, '60vw', '60vw');
|
|
2329
2412
|
}
|
|
2330
2413
|
function _toggleExpand(btn) {
|
|
2331
2414
|
const f = document.getElementById(btn.dataset.expandId);
|
|
@@ -2343,7 +2426,7 @@ function makeExpandToggle(_truncatedHtml, fullHtml, opts = {}) {
|
|
|
2343
2426
|
}
|
|
2344
2427
|
|
|
2345
2428
|
function autoSizeModal(modal, body) {
|
|
2346
|
-
if (modal.classList.contains('fullscreen')) return;
|
|
2429
|
+
if (modal.classList.contains('fullscreen') || modal.classList.contains('user-sized')) return;
|
|
2347
2430
|
modal.style.maxWidth = '';
|
|
2348
2431
|
modal.classList.remove('has-mermaid');
|
|
2349
2432
|
const hasMermaid = body.querySelector('pre.mermaid') !== null;
|
|
@@ -3299,11 +3382,19 @@ function renderKanban() {
|
|
|
3299
3382
|
completedCount.textContent = completed.length;
|
|
3300
3383
|
|
|
3301
3384
|
const emptyIcon = `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2"/></svg>`;
|
|
3385
|
+
const plusIcon = `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M12 5v14M5 12h14"/></svg>`;
|
|
3302
3386
|
|
|
3303
|
-
|
|
3304
|
-
|
|
3305
|
-
|
|
3306
|
-
|
|
3387
|
+
// Adding is a live text input inside the column, so a background refresh would blow it
|
|
3388
|
+
// away mid-typing -- leave the column alone until the input is gone.
|
|
3389
|
+
if (!addingTask) {
|
|
3390
|
+
const addTile = canAddTask()
|
|
3391
|
+
? `<button type="button" class="column-add${pending.length ? '' : ' empty'}" onclick="startAddTask(this)">${plusIcon}<span>Add task</span></button>`
|
|
3392
|
+
: '';
|
|
3393
|
+
pendingTasks.innerHTML =
|
|
3394
|
+
pending.length > 0
|
|
3395
|
+
? pending.map(renderTaskCard).join('') + addTile
|
|
3396
|
+
: addTile || `<div class="column-empty">${emptyIcon}<div>No pending tasks</div></div>`;
|
|
3397
|
+
}
|
|
3307
3398
|
|
|
3308
3399
|
inProgressTasks.innerHTML =
|
|
3309
3400
|
inProgress.length > 0
|
|
@@ -3334,6 +3425,78 @@ function renderKanban() {
|
|
|
3334
3425
|
|
|
3335
3426
|
//#endregion
|
|
3336
3427
|
|
|
3428
|
+
//#region ADD_TASK
|
|
3429
|
+
let addingTask = false;
|
|
3430
|
+
|
|
3431
|
+
// A task the user types is theirs to place, and the only session it can belong to is the
|
|
3432
|
+
// one on screen -- the project and all-sessions views span many task dirs, so there is no
|
|
3433
|
+
// single target to write into.
|
|
3434
|
+
function canAddTask() {
|
|
3435
|
+
return viewMode === 'session' && !!currentSessionId;
|
|
3436
|
+
}
|
|
3437
|
+
|
|
3438
|
+
// biome-ignore lint/correctness/noUnusedVariables: used in HTML
|
|
3439
|
+
function startAddTask(tile) {
|
|
3440
|
+
if (addingTask) return;
|
|
3441
|
+
addingTask = true;
|
|
3442
|
+
|
|
3443
|
+
const input = document.createElement('input');
|
|
3444
|
+
input.type = 'text';
|
|
3445
|
+
input.className = 'form-input column-add-input';
|
|
3446
|
+
input.placeholder = 'Task subject, Enter to add';
|
|
3447
|
+
tile.replaceWith(input);
|
|
3448
|
+
input.focus();
|
|
3449
|
+
|
|
3450
|
+
const reset = () => {
|
|
3451
|
+
addingTask = false;
|
|
3452
|
+
renderKanban();
|
|
3453
|
+
};
|
|
3454
|
+
|
|
3455
|
+
const save = async () => {
|
|
3456
|
+
// Enter and blur both submit, and Enter's own save disables the input -- which blurs
|
|
3457
|
+
// it. Dropping both handlers first is what keeps that from posting the subject twice.
|
|
3458
|
+
input.onkeydown = null;
|
|
3459
|
+
input.onblur = null;
|
|
3460
|
+
|
|
3461
|
+
const subject = input.value.trim();
|
|
3462
|
+
if (!subject) return reset();
|
|
3463
|
+
input.disabled = true;
|
|
3464
|
+
const sessionId = currentSessionId;
|
|
3465
|
+
try {
|
|
3466
|
+
const res = await fetch(`/api/tasks/${sessionId}`, {
|
|
3467
|
+
method: 'POST',
|
|
3468
|
+
headers: { 'Content-Type': 'application/json' },
|
|
3469
|
+
body: JSON.stringify({ subject }),
|
|
3470
|
+
});
|
|
3471
|
+
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
|
3472
|
+
// The response carries the finished task, and the watcher will resend it within the
|
|
3473
|
+
// SSE debounce anyway -- so show it now rather than paying a session refetch for it.
|
|
3474
|
+
const { task } = await res.json();
|
|
3475
|
+
currentTasks.push({ ...task, sessionId });
|
|
3476
|
+
addingTask = false;
|
|
3477
|
+
renderKanban();
|
|
3478
|
+
} catch (error) {
|
|
3479
|
+
console.error('Failed to create task:', error);
|
|
3480
|
+
showToast('Failed to create task', 'error');
|
|
3481
|
+
reset();
|
|
3482
|
+
}
|
|
3483
|
+
};
|
|
3484
|
+
|
|
3485
|
+
input.onkeydown = (e) => {
|
|
3486
|
+
e.stopPropagation();
|
|
3487
|
+
if (e.key === 'Enter') {
|
|
3488
|
+
e.preventDefault();
|
|
3489
|
+
save();
|
|
3490
|
+
} else if (e.key === 'Escape') {
|
|
3491
|
+
e.preventDefault();
|
|
3492
|
+
input.onblur = null;
|
|
3493
|
+
reset();
|
|
3494
|
+
}
|
|
3495
|
+
};
|
|
3496
|
+
input.onblur = () => save();
|
|
3497
|
+
}
|
|
3498
|
+
//#endregion
|
|
3499
|
+
|
|
3337
3500
|
//#region DRAG_DROP
|
|
3338
3501
|
// biome-ignore lint/correctness/noUnusedVariables: used in HTML
|
|
3339
3502
|
function onCardDragStart(e) {
|
|
@@ -6313,68 +6476,79 @@ function loadSidebarState() {
|
|
|
6313
6476
|
}
|
|
6314
6477
|
}
|
|
6315
6478
|
|
|
6316
|
-
|
|
6317
|
-
|
|
6318
|
-
|
|
6319
|
-
|
|
6479
|
+
// Shared drag-session lifecycle for every resize grip (sidebar, panels, modal):
|
|
6480
|
+
// the `.dragging` class, text-selection suppression, and document-level
|
|
6481
|
+
// listener add/remove live here once. Callers keep only their geometry.
|
|
6482
|
+
// onStart may return false to veto the drag.
|
|
6483
|
+
function _initDragResize(handle, { onStart, onMove, onEnd }) {
|
|
6484
|
+
let startX, startY;
|
|
6320
6485
|
|
|
6321
6486
|
handle.addEventListener('mousedown', (e) => {
|
|
6322
|
-
if (
|
|
6487
|
+
if (onStart && onStart() === false) return;
|
|
6323
6488
|
startX = e.clientX;
|
|
6324
|
-
|
|
6325
|
-
sidebar.classList.add('resizing');
|
|
6489
|
+
startY = e.clientY;
|
|
6326
6490
|
handle.classList.add('dragging');
|
|
6327
6491
|
document.body.style.userSelect = 'none';
|
|
6328
|
-
document.addEventListener('mousemove',
|
|
6329
|
-
document.addEventListener('mouseup',
|
|
6492
|
+
document.addEventListener('mousemove', move);
|
|
6493
|
+
document.addEventListener('mouseup', up);
|
|
6330
6494
|
e.preventDefault();
|
|
6331
6495
|
});
|
|
6332
6496
|
|
|
6333
|
-
function
|
|
6334
|
-
|
|
6335
|
-
sidebar.style.setProperty('--sidebar-width', `${w}px`);
|
|
6336
|
-
sidebar.style.width = `${w}px`;
|
|
6497
|
+
function move(e) {
|
|
6498
|
+
onMove(e.clientX - startX, e.clientY - startY);
|
|
6337
6499
|
}
|
|
6338
6500
|
|
|
6339
|
-
function
|
|
6340
|
-
sidebar.classList.remove('resizing');
|
|
6501
|
+
function up() {
|
|
6341
6502
|
handle.classList.remove('dragging');
|
|
6342
6503
|
document.body.style.userSelect = '';
|
|
6343
|
-
document.removeEventListener('mousemove',
|
|
6344
|
-
document.removeEventListener('mouseup',
|
|
6345
|
-
|
|
6504
|
+
document.removeEventListener('mousemove', move);
|
|
6505
|
+
document.removeEventListener('mouseup', up);
|
|
6506
|
+
onEnd();
|
|
6346
6507
|
}
|
|
6347
6508
|
}
|
|
6348
6509
|
|
|
6510
|
+
function initSidebarResize() {
|
|
6511
|
+
const sidebar = document.querySelector('.sidebar');
|
|
6512
|
+
const handle = document.getElementById('sidebar-resize');
|
|
6513
|
+
let startWidth;
|
|
6514
|
+
|
|
6515
|
+
_initDragResize(handle, {
|
|
6516
|
+
onStart() {
|
|
6517
|
+
if (sidebar.classList.contains('collapsed')) return false;
|
|
6518
|
+
startWidth = sidebar.offsetWidth;
|
|
6519
|
+
sidebar.classList.add('resizing');
|
|
6520
|
+
},
|
|
6521
|
+
onMove(dx) {
|
|
6522
|
+
const w = Math.min(600, Math.max(200, startWidth + dx));
|
|
6523
|
+
sidebar.style.setProperty('--sidebar-width', `${w}px`);
|
|
6524
|
+
sidebar.style.width = `${w}px`;
|
|
6525
|
+
},
|
|
6526
|
+
onEnd() {
|
|
6527
|
+
sidebar.classList.remove('resizing');
|
|
6528
|
+
localStorage.setItem('sidebar-width', sidebar.style.getPropertyValue('--sidebar-width'));
|
|
6529
|
+
},
|
|
6530
|
+
});
|
|
6531
|
+
}
|
|
6532
|
+
|
|
6349
6533
|
function initPanelResize(panelId, handleId, cssVar, storageKey) {
|
|
6350
6534
|
const panel = document.getElementById(panelId);
|
|
6351
6535
|
const handle = document.getElementById(handleId);
|
|
6352
|
-
let
|
|
6536
|
+
let startWidth;
|
|
6353
6537
|
|
|
6354
|
-
handle
|
|
6355
|
-
|
|
6356
|
-
|
|
6357
|
-
|
|
6358
|
-
|
|
6359
|
-
|
|
6360
|
-
|
|
6361
|
-
|
|
6362
|
-
|
|
6538
|
+
_initDragResize(handle, {
|
|
6539
|
+
onStart() {
|
|
6540
|
+
startWidth = panel.offsetWidth;
|
|
6541
|
+
panel.classList.add('resizing');
|
|
6542
|
+
},
|
|
6543
|
+
onMove(dx) {
|
|
6544
|
+
const w = Math.max(200, startWidth - dx);
|
|
6545
|
+
panel.style.setProperty(cssVar, `${w}px`);
|
|
6546
|
+
},
|
|
6547
|
+
onEnd() {
|
|
6548
|
+
panel.classList.remove('resizing');
|
|
6549
|
+
localStorage.setItem(storageKey, panel.style.getPropertyValue(cssVar));
|
|
6550
|
+
},
|
|
6363
6551
|
});
|
|
6364
|
-
|
|
6365
|
-
function onMove(e) {
|
|
6366
|
-
const w = Math.max(200, startWidth - (e.clientX - startX));
|
|
6367
|
-
panel.style.setProperty(cssVar, `${w}px`);
|
|
6368
|
-
}
|
|
6369
|
-
|
|
6370
|
-
function onUp() {
|
|
6371
|
-
panel.classList.remove('resizing');
|
|
6372
|
-
handle.classList.remove('dragging');
|
|
6373
|
-
document.body.style.userSelect = '';
|
|
6374
|
-
document.removeEventListener('mousemove', onMove);
|
|
6375
|
-
document.removeEventListener('mouseup', onUp);
|
|
6376
|
-
localStorage.setItem(storageKey, panel.style.getPropertyValue(cssVar));
|
|
6377
|
-
}
|
|
6378
6552
|
}
|
|
6379
6553
|
|
|
6380
6554
|
function loadPanelWidths() {
|
|
@@ -7328,6 +7502,7 @@ try {
|
|
|
7328
7502
|
initSidebarResize();
|
|
7329
7503
|
applyModalZoom();
|
|
7330
7504
|
loadModalFullscreen();
|
|
7505
|
+
initModalResize();
|
|
7331
7506
|
loadPanelWidths();
|
|
7332
7507
|
initPanelResize('detail-panel', 'detail-panel-resize', '--detail-panel-width', 'detail-panel-width');
|
|
7333
7508
|
initPanelResize('message-panel', 'message-panel-resize', '--message-panel-width', 'message-panel-width');
|
package/public/index.html
CHANGED
|
@@ -285,7 +285,7 @@
|
|
|
285
285
|
|
|
286
286
|
<!-- Message Detail Modal -->
|
|
287
287
|
<div id="msg-detail-modal" class="modal-overlay" onclick="closeMsgDetailModal()">
|
|
288
|
-
<div class="modal" onclick="event.stopPropagation()"
|
|
288
|
+
<div class="modal msg-detail-dialog" onclick="event.stopPropagation()">
|
|
289
289
|
<div class="modal-header">
|
|
290
290
|
<h3 class="modal-title" id="msg-detail-title">Message</h3>
|
|
291
291
|
<div style="display:flex;gap:4px;align-items:center;">
|
package/public/style.css
CHANGED
|
@@ -1172,9 +1172,8 @@ body::before {
|
|
|
1172
1172
|
padding-right: 8px;
|
|
1173
1173
|
}
|
|
1174
1174
|
|
|
1175
|
-
.column-empty
|
|
1176
|
-
|
|
1177
|
-
padding: 32px 16px;
|
|
1175
|
+
.column-empty,
|
|
1176
|
+
.column-add {
|
|
1178
1177
|
color: var(--text-muted);
|
|
1179
1178
|
font-size: 12px;
|
|
1180
1179
|
border: 1px dashed var(--border);
|
|
@@ -1182,13 +1181,62 @@ body::before {
|
|
|
1182
1181
|
margin-top: 4px;
|
|
1183
1182
|
}
|
|
1184
1183
|
|
|
1185
|
-
.column-empty
|
|
1184
|
+
.column-empty {
|
|
1185
|
+
text-align: center;
|
|
1186
|
+
padding: 32px 16px;
|
|
1187
|
+
}
|
|
1188
|
+
|
|
1189
|
+
.column-add {
|
|
1190
|
+
display: flex;
|
|
1191
|
+
align-items: center;
|
|
1192
|
+
justify-content: center;
|
|
1193
|
+
gap: 6px;
|
|
1194
|
+
width: 100%;
|
|
1195
|
+
padding: 8px 16px;
|
|
1196
|
+
font-family: inherit;
|
|
1197
|
+
background: none;
|
|
1198
|
+
cursor: pointer;
|
|
1199
|
+
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
|
|
1200
|
+
}
|
|
1201
|
+
|
|
1202
|
+
.column-add.empty {
|
|
1203
|
+
flex-direction: column;
|
|
1204
|
+
padding: 32px 16px;
|
|
1205
|
+
}
|
|
1206
|
+
|
|
1207
|
+
.column-add:hover,
|
|
1208
|
+
.column-add:focus-visible {
|
|
1209
|
+
outline: none;
|
|
1210
|
+
color: var(--text-primary);
|
|
1211
|
+
border-color: var(--text-muted);
|
|
1212
|
+
background: var(--bg-hover);
|
|
1213
|
+
}
|
|
1214
|
+
|
|
1215
|
+
.column-empty svg,
|
|
1216
|
+
.column-add.empty svg {
|
|
1186
1217
|
width: 24px;
|
|
1187
1218
|
height: 24px;
|
|
1188
|
-
opacity: 0.5;
|
|
1189
1219
|
margin-bottom: 8px;
|
|
1190
1220
|
}
|
|
1191
1221
|
|
|
1222
|
+
.column-empty svg {
|
|
1223
|
+
opacity: 0.5;
|
|
1224
|
+
}
|
|
1225
|
+
|
|
1226
|
+
.column-add svg {
|
|
1227
|
+
width: 16px;
|
|
1228
|
+
height: 16px;
|
|
1229
|
+
opacity: 0.7;
|
|
1230
|
+
flex-shrink: 0;
|
|
1231
|
+
}
|
|
1232
|
+
|
|
1233
|
+
.column-add-input {
|
|
1234
|
+
margin-top: 4px;
|
|
1235
|
+
font-size: 12px;
|
|
1236
|
+
background: var(--bg-surface);
|
|
1237
|
+
border-color: var(--accent);
|
|
1238
|
+
}
|
|
1239
|
+
|
|
1192
1240
|
/* #endregion */
|
|
1193
1241
|
|
|
1194
1242
|
/* #region TASK_CARD */
|
|
@@ -3721,6 +3769,7 @@ body.light .msg-assistant .msg-text {
|
|
|
3721
3769
|
}
|
|
3722
3770
|
|
|
3723
3771
|
.modal {
|
|
3772
|
+
position: relative;
|
|
3724
3773
|
background: var(--bg-surface);
|
|
3725
3774
|
border: 1px solid var(--border);
|
|
3726
3775
|
border-radius: 12px;
|
|
@@ -3734,6 +3783,16 @@ body.light .msg-assistant .msg-text {
|
|
|
3734
3783
|
overflow-y: auto;
|
|
3735
3784
|
}
|
|
3736
3785
|
|
|
3786
|
+
/* Before .modal.fullscreen on purpose: at equal specificity the later
|
|
3787
|
+
fullscreen rule must win, same as for every other per-modal size. */
|
|
3788
|
+
.modal.msg-detail-dialog {
|
|
3789
|
+
width: 90vw;
|
|
3790
|
+
max-width: 860px;
|
|
3791
|
+
max-height: 90vh;
|
|
3792
|
+
display: flex;
|
|
3793
|
+
flex-direction: column;
|
|
3794
|
+
}
|
|
3795
|
+
|
|
3737
3796
|
/* Height is a cap, not a floor — a two-line message should not sit in a 92vh box.
|
|
3738
3797
|
The HTML preview keeps its explicit height below: its iframe is `height: 100%`
|
|
3739
3798
|
and needs a resolvable parent height. */
|
|
@@ -3743,6 +3802,39 @@ body.light .msg-assistant .msg-text {
|
|
|
3743
3802
|
max-height: 92vh;
|
|
3744
3803
|
}
|
|
3745
3804
|
|
|
3805
|
+
.modal-resize-handle {
|
|
3806
|
+
position: absolute;
|
|
3807
|
+
right: 0;
|
|
3808
|
+
bottom: 0;
|
|
3809
|
+
width: 18px;
|
|
3810
|
+
height: 18px;
|
|
3811
|
+
cursor: nwse-resize;
|
|
3812
|
+
z-index: 1;
|
|
3813
|
+
color: var(--text-muted);
|
|
3814
|
+
opacity: 0.55;
|
|
3815
|
+
border-bottom-right-radius: 11px;
|
|
3816
|
+
background: linear-gradient(
|
|
3817
|
+
135deg,
|
|
3818
|
+
transparent 0 50%,
|
|
3819
|
+
currentColor 50% 56%,
|
|
3820
|
+
transparent 56% 64%,
|
|
3821
|
+
currentColor 64% 70%,
|
|
3822
|
+
transparent 70% 78%,
|
|
3823
|
+
currentColor 78% 84%,
|
|
3824
|
+
transparent 84%
|
|
3825
|
+
);
|
|
3826
|
+
}
|
|
3827
|
+
|
|
3828
|
+
.modal-resize-handle:hover,
|
|
3829
|
+
.modal-resize-handle.dragging {
|
|
3830
|
+
opacity: 1;
|
|
3831
|
+
color: var(--accent);
|
|
3832
|
+
}
|
|
3833
|
+
|
|
3834
|
+
.modal.fullscreen .modal-resize-handle {
|
|
3835
|
+
display: none;
|
|
3836
|
+
}
|
|
3837
|
+
|
|
3746
3838
|
.modal.plan-modal {
|
|
3747
3839
|
width: 60vw;
|
|
3748
3840
|
max-width: 60vw;
|
|
@@ -3839,6 +3931,19 @@ body.light .msg-assistant .msg-text {
|
|
|
3839
3931
|
max-height: 98vh;
|
|
3840
3932
|
}
|
|
3841
3933
|
|
|
3934
|
+
/* User-dragged size (bottom-right grip, app.js initModalResize). Last of the
|
|
3935
|
+
modal sizing rules on purpose: at equal specificity, source order lets it
|
|
3936
|
+
beat every per-modal size above; fullscreen still wins via :not() while
|
|
3937
|
+
active, and JS keeps inline sizing off user-sized modals (autoSizeModal and
|
|
3938
|
+
"Show more" skip them, the drag clears leftovers). Height is a cap, not a
|
|
3939
|
+
floor (same rule as .fullscreen) — short content still collapses. min()
|
|
3940
|
+
keeps a size saved on a large screen inside the current viewport. */
|
|
3941
|
+
.modal.user-sized:not(.fullscreen) {
|
|
3942
|
+
width: min(var(--user-modal-w), 98vw);
|
|
3943
|
+
max-width: min(var(--user-modal-w), 98vw);
|
|
3944
|
+
max-height: min(var(--user-modal-h), 95vh);
|
|
3945
|
+
}
|
|
3946
|
+
|
|
3842
3947
|
.preview-html .modal-body,
|
|
3843
3948
|
.preview-html #preview-modal-body {
|
|
3844
3949
|
flex: 1 1 auto;
|
package/server.js
CHANGED
|
@@ -94,6 +94,7 @@ const CCK_DIR = path.join(CLAUDE_DIR, '.cck');
|
|
|
94
94
|
const AGENT_ACTIVITY_DIR = path.join(CCK_DIR, 'agent-activity');
|
|
95
95
|
const CONTEXT_STATUS_DIR = path.join(CCK_DIR, 'context-status');
|
|
96
96
|
const PINS_FILE = path.join(CCK_DIR, 'pins.json');
|
|
97
|
+
const SERVER_INFO_FILE = path.join(CCK_DIR, 'server.json');
|
|
97
98
|
// Harness-owned scratchpad root; the per-session dir under it is created lazily.
|
|
98
99
|
const SCRATCHPAD_ROOT = path.join(os.tmpdir(), 'claude');
|
|
99
100
|
|
|
@@ -106,17 +107,27 @@ function readPins() {
|
|
|
106
107
|
return {};
|
|
107
108
|
}
|
|
108
109
|
|
|
109
|
-
function
|
|
110
|
+
function writeJsonAtomic(file, obj) {
|
|
110
111
|
try {
|
|
111
112
|
mkdirSync(CCK_DIR, { recursive: true });
|
|
112
|
-
const tmp = `${
|
|
113
|
-
writeFileSync(tmp, JSON.stringify(
|
|
114
|
-
renameSync(tmp,
|
|
113
|
+
const tmp = `${file}.${process.pid}.${Date.now()}.tmp`;
|
|
114
|
+
writeFileSync(tmp, JSON.stringify(obj, null, 2), 'utf8');
|
|
115
|
+
renameSync(tmp, file);
|
|
115
116
|
} catch (e) {
|
|
116
|
-
console.error(
|
|
117
|
+
console.error(`Failed to write ${path.basename(file)}:`, e.message);
|
|
117
118
|
}
|
|
118
119
|
}
|
|
119
120
|
|
|
121
|
+
function writePins(pins) {
|
|
122
|
+
writeJsonAtomic(PINS_FILE, pins);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// Port discovery for out-of-process helpers (the postman monitor). The pid rides along
|
|
126
|
+
// so a reader can tell a live server from a file left behind by a crashed one.
|
|
127
|
+
function writeServerInfo(port) {
|
|
128
|
+
writeJsonAtomic(SERVER_INFO_FILE, { port, pid: process.pid });
|
|
129
|
+
}
|
|
130
|
+
|
|
120
131
|
// #region TIMINGS
|
|
121
132
|
const PERMISSION_TTL_MS = 30 * 60 * 1000;
|
|
122
133
|
const AGENT_TTL_MS = 60 * 60 * 1000;
|
|
@@ -562,6 +573,13 @@ function getCustomTaskDir(sessionId) {
|
|
|
562
573
|
return null;
|
|
563
574
|
}
|
|
564
575
|
|
|
576
|
+
// Where a session's task files live. The custom-list and team lookups can both miss, and
|
|
577
|
+
// the fallback is the plain per-session dir -- every route that touches a task file needs
|
|
578
|
+
// that same resolution, so it lives in one place.
|
|
579
|
+
function taskDirFor(sessionId) {
|
|
580
|
+
return getCustomTaskDir(sessionId) || path.join(TASKS_DIR, sessionId);
|
|
581
|
+
}
|
|
582
|
+
|
|
565
583
|
function getTaskCounts(sessionPath) {
|
|
566
584
|
const cached = taskCountsCache.get(sessionPath);
|
|
567
585
|
if (cached) return cached;
|
|
@@ -1409,8 +1427,7 @@ app.get('/api/projects', (req, res) => {
|
|
|
1409
1427
|
// API: Get tasks for a session
|
|
1410
1428
|
app.get('/api/sessions/:sessionId', async (req, res) => {
|
|
1411
1429
|
try {
|
|
1412
|
-
const
|
|
1413
|
-
const sessionPath = customDir || path.join(TASKS_DIR, req.params.sessionId);
|
|
1430
|
+
const sessionPath = taskDirFor(req.params.sessionId);
|
|
1414
1431
|
|
|
1415
1432
|
if (!existsSync(sessionPath)) {
|
|
1416
1433
|
return res.status(404).json({ error: 'Session not found' });
|
|
@@ -2636,13 +2653,54 @@ app.get('/api/tasks/all', async (req, res) => {
|
|
|
2636
2653
|
}
|
|
2637
2654
|
});
|
|
2638
2655
|
|
|
2656
|
+
const { enqueueSessionEvent, formatTaskMoved, handleSessionEvents } = require('./lib/session-events');
|
|
2657
|
+
app.get('/api/sessions/:sessionId/events', handleSessionEvents);
|
|
2658
|
+
|
|
2659
|
+
// API: Create a task
|
|
2660
|
+
app.post('/api/tasks/:sessionId', async (req, res) => {
|
|
2661
|
+
try {
|
|
2662
|
+
const { sessionId } = req.params;
|
|
2663
|
+
const subject = (req.body.subject || '').trim();
|
|
2664
|
+
if (!subject) return res.status(400).json({ error: 'Subject is required' });
|
|
2665
|
+
|
|
2666
|
+
const sessionDir = taskDirFor(sessionId);
|
|
2667
|
+
if (!existsSync(sessionDir)) mkdirSync(sessionDir, { recursive: true });
|
|
2668
|
+
|
|
2669
|
+
// Ids are the agent's own numbering scheme, so a hand-made task has to keep counting
|
|
2670
|
+
// from the highest one on disk -- reusing a number would overwrite that task's file.
|
|
2671
|
+
const ids = readdirSync(sessionDir)
|
|
2672
|
+
.filter((f) => f.endsWith('.json'))
|
|
2673
|
+
.map((f) => parseInt(path.basename(f, '.json'), 10))
|
|
2674
|
+
.filter((n) => Number.isInteger(n));
|
|
2675
|
+
const id = String(Math.max(0, ...ids) + 1);
|
|
2676
|
+
|
|
2677
|
+
const task = {
|
|
2678
|
+
id,
|
|
2679
|
+
subject,
|
|
2680
|
+
description: (req.body.description || '').trim(),
|
|
2681
|
+
activeForm: subject,
|
|
2682
|
+
status: 'pending',
|
|
2683
|
+
blocks: [],
|
|
2684
|
+
blockedBy: [],
|
|
2685
|
+
};
|
|
2686
|
+
|
|
2687
|
+
// No doorbell here, unlike a move: the user typed this task, so telling their session
|
|
2688
|
+
// about it would only repeat what they just said. Dragging it to In Progress rings.
|
|
2689
|
+
await fs.writeFile(path.join(sessionDir, `${id}.json`), JSON.stringify(task, null, 2));
|
|
2690
|
+
res.json({ success: true, task });
|
|
2691
|
+
} catch (error) {
|
|
2692
|
+
console.error('Error creating task:', error);
|
|
2693
|
+
res.status(500).json({ error: 'Failed to create task' });
|
|
2694
|
+
}
|
|
2695
|
+
});
|
|
2696
|
+
|
|
2639
2697
|
// API: Update task fields (subject, description)
|
|
2640
2698
|
app.put('/api/tasks/:sessionId/:taskId', async (req, res) => {
|
|
2641
2699
|
try {
|
|
2642
2700
|
const { sessionId, taskId } = req.params;
|
|
2643
2701
|
const { subject, description } = req.body;
|
|
2644
2702
|
|
|
2645
|
-
const sessionDir =
|
|
2703
|
+
const sessionDir = taskDirFor(sessionId);
|
|
2646
2704
|
const taskPath = path.join(sessionDir, `${taskId}.json`);
|
|
2647
2705
|
|
|
2648
2706
|
if (!existsSync(taskPath)) {
|
|
@@ -2650,6 +2708,7 @@ app.put('/api/tasks/:sessionId/:taskId', async (req, res) => {
|
|
|
2650
2708
|
}
|
|
2651
2709
|
|
|
2652
2710
|
const task = JSON.parse(await fs.readFile(taskPath, 'utf8'));
|
|
2711
|
+
const prevStatus = task.status;
|
|
2653
2712
|
|
|
2654
2713
|
if (subject !== undefined) task.subject = subject;
|
|
2655
2714
|
if (description !== undefined) task.description = description;
|
|
@@ -2657,6 +2716,17 @@ app.put('/api/tasks/:sessionId/:taskId', async (req, res) => {
|
|
|
2657
2716
|
|
|
2658
2717
|
await fs.writeFile(taskPath, JSON.stringify(task, null, 2));
|
|
2659
2718
|
|
|
2719
|
+
// Ring the session only for a move. The direction has to ride in the line because
|
|
2720
|
+
// the write above destroyed the old status -- nothing downstream can recover it, and
|
|
2721
|
+
// which way a task moved is what decides whether to start work or stop it.
|
|
2722
|
+
if (task.status !== prevStatus) {
|
|
2723
|
+
// The route param is a task *directory*, which for a shared list or team board is
|
|
2724
|
+
// not a session id -- and the postman polls with its own session id, so an unresolved
|
|
2725
|
+
// name would queue the line where nobody drains it.
|
|
2726
|
+
const line = formatTaskMoved(taskId, prevStatus, task);
|
|
2727
|
+
for (const sid of resolveSessionsForTaskDir(sessionId)) enqueueSessionEvent(sid, line);
|
|
2728
|
+
}
|
|
2729
|
+
|
|
2660
2730
|
res.json({ success: true, task });
|
|
2661
2731
|
} catch (error) {
|
|
2662
2732
|
console.error('Error updating task:', error);
|
|
@@ -2668,7 +2738,7 @@ app.put('/api/tasks/:sessionId/:taskId', async (req, res) => {
|
|
|
2668
2738
|
app.delete('/api/tasks/:sessionId/:taskId', async (req, res) => {
|
|
2669
2739
|
try {
|
|
2670
2740
|
const { sessionId, taskId } = req.params;
|
|
2671
|
-
const sessionPath =
|
|
2741
|
+
const sessionPath = taskDirFor(sessionId);
|
|
2672
2742
|
const taskPath = path.join(sessionPath, `${taskId}.json`);
|
|
2673
2743
|
|
|
2674
2744
|
if (!existsSync(taskPath)) {
|
|
@@ -2964,27 +3034,27 @@ watcher.on('all', (event, filePath) => {
|
|
|
2964
3034
|
|
|
2965
3035
|
taskCountsCache.delete(path.join(TASKS_DIR, dirName));
|
|
2966
3036
|
|
|
2967
|
-
|
|
2968
|
-
broadcast({ type: 'update', event, sessionId: dirName, file: path.basename(filePath) });
|
|
2969
|
-
} else {
|
|
2970
|
-
broadcastToMappedSessions(dirName, event, filePath);
|
|
2971
|
-
}
|
|
3037
|
+
broadcastToMappedSessions(dirName, event, filePath);
|
|
2972
3038
|
}
|
|
2973
3039
|
});
|
|
2974
3040
|
|
|
2975
|
-
|
|
3041
|
+
// Which sessions own a task directory. Usually the dir name IS the session id, but a
|
|
3042
|
+
// shared list or a team dir is one directory several sessions map onto -- so anything
|
|
3043
|
+
// addressing a session from a task path has to fan out the same way. One resolver for
|
|
3044
|
+
// both readers of that mapping: the SSE broadcast and the doorbell.
|
|
3045
|
+
function resolveSessionsForTaskDir(name) {
|
|
3046
|
+
if (isUUID(name)) return [name];
|
|
2976
3047
|
const { listToSessions } = loadAllTaskMaps();
|
|
2977
|
-
const map = listToSessions[
|
|
2978
|
-
if (map)
|
|
2979
|
-
|
|
2980
|
-
|
|
2981
|
-
|
|
2982
|
-
|
|
2983
|
-
|
|
2984
|
-
|
|
2985
|
-
const
|
|
2986
|
-
|
|
2987
|
-
broadcast({ type: 'update', event, sessionId: cfg.leadSessionId, file: path.basename(filePath) });
|
|
3048
|
+
const map = listToSessions[name];
|
|
3049
|
+
if (map) return Object.keys(map);
|
|
3050
|
+
// Fallback: check if name is a team name
|
|
3051
|
+
const cfg = loadTeamConfig(name);
|
|
3052
|
+
return cfg?.leadSessionId ? [cfg.leadSessionId] : [];
|
|
3053
|
+
}
|
|
3054
|
+
|
|
3055
|
+
function broadcastToMappedSessions(taskListName, event, filePath) {
|
|
3056
|
+
for (const sid of resolveSessionsForTaskDir(taskListName)) {
|
|
3057
|
+
broadcast({ type: 'update', event, sessionId: sid, file: path.basename(filePath) });
|
|
2988
3058
|
}
|
|
2989
3059
|
}
|
|
2990
3060
|
|
|
@@ -3213,6 +3283,9 @@ async function prewarmCaches() {
|
|
|
3213
3283
|
|
|
3214
3284
|
const onReady = (actualPort) => {
|
|
3215
3285
|
console.log(`Claude Task Kanban running at http://localhost:${actualPort}`);
|
|
3286
|
+
// The port is configurable and falls back to a random one when taken, so the postman
|
|
3287
|
+
// monitor cannot assume it -- publish the live one where it can read it.
|
|
3288
|
+
writeServerInfo(actualPort);
|
|
3216
3289
|
const warning = net.exposureWarning();
|
|
3217
3290
|
if (warning) console.log(warning);
|
|
3218
3291
|
|