claude-code-kanban 7.16.0 → 7.18.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 +65 -40
- package/hub-app.json +23 -0
- package/package.json +2 -1
- package/plugin/plugins/claude-code-kanban/.claude-plugin/plugin.json +1 -1
- package/plugin/plugins/claude-code-kanban/hooks/activity.ts +45 -6
- package/plugin/plugins/claude-code-kanban/hooks/show.ts +93 -53
- package/plugin/plugins/claude-code-kanban/skills/visualize/SKILL.md +1 -0
- package/plugin/plugins/claude-code-kanban/tests/activity.test.ts +79 -13
- package/plugin/plugins/claude-code-kanban/tests/doorbell.test.ts +1 -0
- package/plugin/plugins/claude-code-kanban/tests/show.test.ts +63 -28
- package/public/app.js +178 -60
- package/public/style.css +116 -2
- package/public/terminal-frame.js +52 -1
- package/public/terminal.html +1 -0
- package/public/vendor/claude-hub-sdk.js +3 -1
- package/server.js +1 -0
|
@@ -1,26 +1,20 @@
|
|
|
1
1
|
import type { On, ToolCallArgs } from 'claude-code'
|
|
2
|
-
import { expect, test } from 'claude-code/testing'
|
|
3
|
-
import { boardPort,
|
|
2
|
+
import { expect, mock, test } from 'claude-code/testing'
|
|
3
|
+
import { boardPort, CLAIM_OFF_TERMINAL, paneText, shownText, TOOL } from '../hooks/show'
|
|
4
4
|
|
|
5
5
|
const CCK_URL = 'http://127.0.0.1:4100'
|
|
6
6
|
const TERMINAL_ID = 'term-1'
|
|
7
7
|
const TOKEN_FILE = 'C:/cfg/.cck/terminal-tokens/4100.json'
|
|
8
|
-
const
|
|
9
|
-
'The following skills are available for use with the Skill tool:',
|
|
10
|
-
'',
|
|
11
|
-
'- claude-code-kanban:dispatch',
|
|
12
|
-
'- claude-code-kanban:show',
|
|
13
|
-
'- claude-code-kanban:kanban: Kanban board CLI',
|
|
14
|
-
'- meta:show-me: Help the user understand the current topic visually',
|
|
15
|
-
].join('\n')
|
|
8
|
+
const SERVER_FILE = 'C:/cfg/.cck/server.json'
|
|
16
9
|
|
|
17
10
|
type Reply = { status: number; body: unknown } | Error
|
|
18
11
|
type Request = { url: string; method?: string; headers?: Record<string, string>; body?: unknown }
|
|
19
12
|
|
|
20
13
|
function engine(on: On, env: Record<string, string>, reply?: Reply) {
|
|
21
|
-
const registered: string[] = []
|
|
14
|
+
const registered: { name: string; required: unknown }[] = []
|
|
22
15
|
const requests: Request[] = []
|
|
23
|
-
const files: Record<string, string> = { [TOKEN_FILE]: '{"token":"tok-abc"}' }
|
|
16
|
+
const files: Record<string, string> = { [TOKEN_FILE]: '{"token":"tok-abc"}', [SERVER_FILE]: '{"pid":1,"port":4100}' }
|
|
17
|
+
mock.clock(on)
|
|
24
18
|
on('env.get', (_$, e) => ({ value: { CLAUDE_CONFIG_DIR: 'C:/cfg', ...env }[e.name] }))
|
|
25
19
|
on('fs.read', (_$, e) => {
|
|
26
20
|
const text = files[e.path.replaceAll('\\', '/')]
|
|
@@ -30,12 +24,11 @@ function engine(on: On, env: Record<string, string>, reply?: Reply) {
|
|
|
30
24
|
on('session.id', () => ({ value: 'sid-1' }))
|
|
31
25
|
on('session.start', (_$, e) => ({ cwd: e.cwd }))
|
|
32
26
|
on('tool.register', (_$, e) => {
|
|
33
|
-
registered.push(e.name)
|
|
27
|
+
registered.push({ name: e.name, required: (e.inputSchema as { required?: unknown }).required })
|
|
34
28
|
return { value: { tool: `mcp__claude-code-kanban__${e.name}` } }
|
|
35
29
|
})
|
|
36
|
-
on('prompt.attachment', (_$, e) => ({ text: e.text }))
|
|
37
30
|
on('http.fetch', (_$, e) => {
|
|
38
|
-
if (
|
|
31
|
+
if (!/\/show$|\/api\/panes\//.test(e.url)) return new Promise(() => {})
|
|
39
32
|
const { method, headers, body } = (e.init ?? {}) as Omit<Request, 'url'>
|
|
40
33
|
requests.push({ url: e.url, method, headers, body: typeof body === 'string' ? JSON.parse(body) : body })
|
|
41
34
|
if (!reply) throw new Error('no reply')
|
|
@@ -46,10 +39,9 @@ function engine(on: On, env: Record<string, string>, reply?: Reply) {
|
|
|
46
39
|
}
|
|
47
40
|
|
|
48
41
|
const GATE = { CCK_URL, CCK_TERMINAL_ID: TERMINAL_ID }
|
|
49
|
-
const listing = { type: 'skill_listing', text: LISTING, origin: { kind: 'engine' } } as const
|
|
50
42
|
const call = (input: Record<string, unknown>) => ({ tool: TOOL, ...input }) as ToolCallArgs
|
|
51
43
|
|
|
52
|
-
test('helpers: the port of CCK_URL, the answer
|
|
44
|
+
test('helpers: the port of CCK_URL, the answer lines', async () => {
|
|
53
45
|
expect(boardPort('http://127.0.0.1:4100')).toBe('4100')
|
|
54
46
|
expect(boardPort('http://localhost:3541/')).toBe('3541')
|
|
55
47
|
expect(boardPort('http://localhost')).toBe(undefined)
|
|
@@ -58,24 +50,67 @@ test('helpers: the port of CCK_URL, the answer line, the listing filter', async
|
|
|
58
50
|
expect(shownText({ title: 'Plan', key: 'plan', index: 1, count: 1, path: 'C:/s/p1.md' }, true)).toBe(
|
|
59
51
|
'Card "Plan" (plan, 1/1) is ready. Write it to C:/s/p1.md; the card refreshes on each save.',
|
|
60
52
|
)
|
|
61
|
-
expect(
|
|
62
|
-
expect(dropSkillLine('- claude-code-kanban:show: Show a diagram\n- x')).toBe('- x')
|
|
53
|
+
expect(paneText('Plan', false)).toBe('The board already has a tab for this file; it refreshes on each save.')
|
|
63
54
|
})
|
|
64
55
|
|
|
65
|
-
test('
|
|
56
|
+
test('in the terminal: the overlay tool, file optional', async ($, on) => {
|
|
57
|
+
const board = engine(on, GATE)
|
|
58
|
+
await $.session.start({ cwd: 'C:/proj', surface: 'terminal', isInteractive: true })
|
|
59
|
+
expect(board.registered).toEqual([{ name: 'show', required: ['title'] }])
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
test('outside the terminal: the pane tool, file required', async ($, on) => {
|
|
66
63
|
const board = engine(on, { CCK_URL })
|
|
67
64
|
await $.session.start({ cwd: 'C:/proj', surface: 'terminal', isInteractive: true })
|
|
68
|
-
expect(board.registered).toEqual([])
|
|
69
|
-
const { text } = await $.prompt.attachment(listing)
|
|
70
|
-
expect(text).toBe(LISTING.replace('- claude-code-kanban:show\n', ''))
|
|
65
|
+
expect(board.registered).toEqual([{ name: 'show', required: ['title', 'file'] }])
|
|
71
66
|
})
|
|
72
67
|
|
|
73
|
-
test('
|
|
74
|
-
const board = engine(on,
|
|
68
|
+
test('outside the terminal: a file post adds a show pane on the board of server.json', async ($, on) => {
|
|
69
|
+
const board = engine(on, {}, { status: 200, body: { pane: { id: 'p1' }, added: true } })
|
|
70
|
+
await $.session.start({ cwd: 'C:/proj', surface: 'terminal', isInteractive: true })
|
|
71
|
+
const res = await $.tool.call(call({ title: 'Plan', file: 'C:/s/plan.html' }))
|
|
72
|
+
expect(res.result).toBe(paneText('Plan', true))
|
|
73
|
+
expect(board.requests).toEqual([
|
|
74
|
+
{
|
|
75
|
+
url: 'http://127.0.0.1:4100/api/panes/sid-1',
|
|
76
|
+
method: 'POST',
|
|
77
|
+
headers: { 'content-type': 'application/json' },
|
|
78
|
+
body: { target: 'C:/s/plan.html', title: 'Plan', show: true },
|
|
79
|
+
},
|
|
80
|
+
])
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
test('outside the terminal: the same file again answers that the tab exists', async ($, on) => {
|
|
84
|
+
engine(on, {}, { status: 200, body: { pane: { id: 'p1' }, added: false } })
|
|
75
85
|
await $.session.start({ cwd: 'C:/proj', surface: 'terminal', isInteractive: true })
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
86
|
+
const res = await $.tool.call(call({ title: 'Plan', file: 'C:/s/plan.html' }))
|
|
87
|
+
expect(res.result).toBe(paneText('Plan', false))
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
test('outside the terminal: a claim sends nothing and asks for a file', async ($, on) => {
|
|
91
|
+
const board = engine(on, {})
|
|
92
|
+
await $.session.start({ cwd: 'C:/proj', surface: 'terminal', isInteractive: true })
|
|
93
|
+
const res = await $.tool.call(call({ title: 'Plan' }))
|
|
94
|
+
expect(res.result).toBe(CLAIM_OFF_TERMINAL)
|
|
95
|
+
expect(board.requests).toEqual([])
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
test('outside the terminal: no board, no request, an error that says so', async ($, on) => {
|
|
99
|
+
// CCK_URL keeps the doorbell on its open poll: without a board it sleeps, and the unload rejects that sleep.
|
|
100
|
+
const board = engine(on, { CCK_URL })
|
|
101
|
+
delete board.files[SERVER_FILE]
|
|
102
|
+
await $.session.start({ cwd: 'C:/proj', surface: 'terminal', isInteractive: true })
|
|
103
|
+
const res = await $.tool.call(call({ title: 'Plan', file: 'C:/s/plan.md' }))
|
|
104
|
+
expect(res.deny).toContain('not running')
|
|
105
|
+
expect(board.requests).toEqual([])
|
|
106
|
+
})
|
|
107
|
+
|
|
108
|
+
test('outside the terminal: a board refusal reaches the model with its text', async ($, on) => {
|
|
109
|
+
const error = 'Not a previewable text, markdown, HTML or image file'
|
|
110
|
+
engine(on, {}, { status: 400, body: { error } })
|
|
111
|
+
await $.session.start({ cwd: 'C:/proj', surface: 'terminal', isInteractive: true })
|
|
112
|
+
const res = await $.tool.call(call({ title: 'Zip', file: 'C:/s/a.zip' }))
|
|
113
|
+
expect(res.deny).toBe(error)
|
|
79
114
|
})
|
|
80
115
|
|
|
81
116
|
test('a -p run gets no tool', async ($, on) => {
|
package/public/app.js
CHANGED
|
@@ -571,6 +571,9 @@ function sessionBarClass(s) {
|
|
|
571
571
|
function isActiveSession(s) {
|
|
572
572
|
return !isWaitingSession(s) && (s.inProgress > 0 || s.hasRecentLog || s.hasRunningAgents);
|
|
573
573
|
}
|
|
574
|
+
function sessionState(s) {
|
|
575
|
+
return isWaitingSession(s) ? 'waiting' : isActiveSession(s) ? 'working' : 'idle';
|
|
576
|
+
}
|
|
574
577
|
// hasRecentLog carries the server's registry-idle correction: an open-but-idle
|
|
575
578
|
// terminal keeps touching its JSONL, so mtime alone is not proof of activity.
|
|
576
579
|
function isSessionLive(s) {
|
|
@@ -815,6 +818,7 @@ async function fetchTasks(sessionId) {
|
|
|
815
818
|
//#region TIMINGS
|
|
816
819
|
const WAITING_TTL_MS = 30 * 60 * 1000;
|
|
817
820
|
const AGENT_LOG_MAX = 8;
|
|
821
|
+
const ZEN_AGENTS_MAX = 5;
|
|
818
822
|
const LIVE_INDICATOR_MS = 10 * 1000;
|
|
819
823
|
const JUST_NOW_MS = 60 * 1000;
|
|
820
824
|
//#endregion
|
|
@@ -3397,17 +3401,63 @@ function formatDuration(ms) {
|
|
|
3397
3401
|
//#endregion
|
|
3398
3402
|
|
|
3399
3403
|
//#region AGENTS
|
|
3400
|
-
function
|
|
3401
|
-
const
|
|
3402
|
-
const
|
|
3403
|
-
|
|
3404
|
+
function agentTypeParts(a) {
|
|
3405
|
+
const raw = a.type || 'unknown';
|
|
3406
|
+
const colonIdx = raw.indexOf(':');
|
|
3407
|
+
return colonIdx > 0
|
|
3408
|
+
? { raw, ns: raw.substring(0, colonIdx + 1), name: raw.substring(colonIdx + 1) }
|
|
3409
|
+
: { raw, ns: '', name: raw };
|
|
3410
|
+
}
|
|
3411
|
+
|
|
3412
|
+
function agentTaskText(a) {
|
|
3413
|
+
if (a.description) return a.description;
|
|
3414
|
+
const prompt = stripAnsi(stripTeammateWrapper((a.prompt || '').trim())).replace(/[\r\n]+/g, ' ');
|
|
3415
|
+
return prompt || agentTypeParts(a).name;
|
|
3416
|
+
}
|
|
3417
|
+
|
|
3418
|
+
function agentElapsed(a, now) {
|
|
3419
|
+
return a.status === 'stopped' && a.stoppedAt
|
|
3420
|
+
? new Date(a.stoppedAt).getTime() - new Date(a.startedAt || a.stoppedAt).getTime()
|
|
3421
|
+
: now - new Date(a.startedAt || a.updatedAt).getTime();
|
|
3422
|
+
}
|
|
3423
|
+
|
|
3424
|
+
function agentIdentHtml(a) {
|
|
3425
|
+
const { raw, ns, name } = agentTypeParts(a);
|
|
3426
|
+
const fullModel = agentModel(a);
|
|
3427
|
+
const model = shortModelName(fullModel);
|
|
3428
|
+
const modelHtml = model
|
|
3429
|
+
? `<span class="agent-card-model" title="${escapeHtml(fullModel)}">${escapeHtml(model)}</span><span class="agent-ident-sep">·</span>`
|
|
3430
|
+
: '';
|
|
3431
|
+
return `${modelHtml}<span class="agent-type-name" title="${escapeHtml(raw)}">${ns ? `<span class="agent-type-ns">${escapeHtml(ns)}</span>` : ''}${escapeHtml(name)}</span>`;
|
|
3432
|
+
}
|
|
3433
|
+
|
|
3434
|
+
// The count names every running agent; the rows stop at ZEN_AGENTS_MAX, and the Agents Log
|
|
3435
|
+
// below the board lists the rest.
|
|
3436
|
+
function zenAgentsHtml(agents) {
|
|
3437
|
+
const active = agents.filter((a) => a.status === 'active');
|
|
3438
|
+
if (!active.length) return '';
|
|
3404
3439
|
const now = Date.now();
|
|
3440
|
+
const rows = active.slice(0, ZEN_AGENTS_MAX).map((a) => {
|
|
3441
|
+
const task = truncateAtWord(agentTaskText(a), 200);
|
|
3442
|
+
return `<button type="button" class="zen-agent" title="${escapeHtml(task)}" onclick="showAgentModal('${escAttrJs(a.agentId)}')">
|
|
3443
|
+
<span class="agent-dot active"></span>
|
|
3444
|
+
<span class="zen-agent-text"><span class="zen-agent-task">${escapeHtml(task)}</span><span class="agent-ident-row">${agentIdentHtml(a)}</span></span>
|
|
3445
|
+
<span class="zen-agent-time">${formatDuration(agentElapsed(a, now))}</span>
|
|
3446
|
+
</button>`;
|
|
3447
|
+
});
|
|
3448
|
+
return `<div class="panel-section-header"><span>Agents</span><span class="panel-section-count">${active.length}</span></div>${rows.join('')}`;
|
|
3449
|
+
}
|
|
3450
|
+
|
|
3451
|
+
function renderZenAgents(agents) {
|
|
3452
|
+
const el = zenMode && sessionsList.querySelector('.zen-agents');
|
|
3453
|
+
if (el) el.innerHTML = zenAgentsHtml(agents);
|
|
3454
|
+
}
|
|
3405
3455
|
|
|
3406
|
-
|
|
3407
|
-
|
|
3408
|
-
|
|
3456
|
+
// Filter shutdown ghosts: for same-type agents, keep if they overlapped (parallel)
|
|
3457
|
+
// or started >30s after previous stopped (legitimate re-spawn). Filter the rest.
|
|
3458
|
+
function visibleAgents() {
|
|
3409
3459
|
const byType = {};
|
|
3410
|
-
for (const a of
|
|
3460
|
+
for (const a of currentAgents) {
|
|
3411
3461
|
const groupKey = a.agentName || a.type;
|
|
3412
3462
|
if (!byType[groupKey]) byType[groupKey] = [];
|
|
3413
3463
|
byType[groupKey].push(a);
|
|
@@ -3431,13 +3481,21 @@ function renderAgentFooter() {
|
|
|
3431
3481
|
}
|
|
3432
3482
|
// Sort: active/idle first, then by updatedAt desc
|
|
3433
3483
|
const statusOrder = { active: 0, idle: 1, stopped: 2 };
|
|
3434
|
-
|
|
3435
|
-
|
|
3436
|
-
(a
|
|
3437
|
-
|
|
3438
|
-
|
|
3439
|
-
|
|
3440
|
-
|
|
3484
|
+
return filtered.sort(
|
|
3485
|
+
(a, b) =>
|
|
3486
|
+
(statusOrder[a.status] ?? 2) - (statusOrder[b.status] ?? 2) ||
|
|
3487
|
+
new Date(b.updatedAt || 0) - new Date(a.updatedAt || 0),
|
|
3488
|
+
);
|
|
3489
|
+
}
|
|
3490
|
+
|
|
3491
|
+
function renderAgentFooter() {
|
|
3492
|
+
const footer = document.getElementById('agent-footer');
|
|
3493
|
+
const content = document.getElementById('agent-footer-content');
|
|
3494
|
+
const label = document.getElementById('agent-footer-label');
|
|
3495
|
+
const now = Date.now();
|
|
3496
|
+
const agents = visibleAgents();
|
|
3497
|
+
renderZenAgents(agents);
|
|
3498
|
+
const visible = agents.slice(0, AGENT_LOG_MAX);
|
|
3441
3499
|
|
|
3442
3500
|
const permFresh = isWaitingFresh();
|
|
3443
3501
|
|
|
@@ -3461,23 +3519,14 @@ function renderAgentFooter() {
|
|
|
3461
3519
|
permHtml +
|
|
3462
3520
|
visible
|
|
3463
3521
|
.map((a) => {
|
|
3464
|
-
const elapsed =
|
|
3465
|
-
a.status === 'stopped' && a.stoppedAt
|
|
3466
|
-
? new Date(a.stoppedAt).getTime() - new Date(a.startedAt || a.stoppedAt).getTime()
|
|
3467
|
-
: now - new Date(a.startedAt || a.updatedAt).getTime();
|
|
3522
|
+
const elapsed = agentElapsed(a, now);
|
|
3468
3523
|
const statusText =
|
|
3469
3524
|
a.status === 'stopped'
|
|
3470
3525
|
? `stopped · ${formatDuration(elapsed)}`
|
|
3471
3526
|
: a.status === 'idle'
|
|
3472
3527
|
? `idle · ${formatDuration(elapsed)}`
|
|
3473
3528
|
: `active · ${formatDuration(elapsed)}`;
|
|
3474
|
-
const
|
|
3475
|
-
const promptTrimmed = stripAnsi(stripTeammateWrapper((a.prompt || '').trim())).replace(/[\r\n]+/g, ' ');
|
|
3476
|
-
const rawType = a.type || 'unknown';
|
|
3477
|
-
const colonIdx = rawType.indexOf(':');
|
|
3478
|
-
const typeNs = colonIdx > 0 ? rawType.substring(0, colonIdx + 1) : '';
|
|
3479
|
-
const typeName = colonIdx > 0 ? rawType.substring(colonIdx + 1) : rawType;
|
|
3480
|
-
const displayText = descText || promptTrimmed || typeName;
|
|
3529
|
+
const displayText = agentTaskText(a);
|
|
3481
3530
|
const taskHtml = `<div class="agent-task" title="${escapeHtml(displayText)}">${escapeHtml(displayText)}</div>`;
|
|
3482
3531
|
const agentNameVal = a.agentName || null;
|
|
3483
3532
|
const nameColor = agentNameVal ? getOwnerColor(agentNameVal) : null;
|
|
@@ -3487,15 +3536,10 @@ function renderAgentFooter() {
|
|
|
3487
3536
|
const agentColor = resolveNamedColor(a.color);
|
|
3488
3537
|
const colorStyle = agentColor ? ` style="border-left:3px solid ${agentColor.color}"` : '';
|
|
3489
3538
|
const selectedClass = a.agentId === currentAgentModalId ? ' selected' : '';
|
|
3490
|
-
const fullModel = agentModel(a);
|
|
3491
|
-
const cardModel = shortModelName(fullModel);
|
|
3492
|
-
const modelHtml = cardModel
|
|
3493
|
-
? `<span class="agent-card-model" title="${escapeHtml(fullModel)}">${escapeHtml(cardModel)}</span><span class="agent-ident-sep">·</span>`
|
|
3494
|
-
: '';
|
|
3495
3539
|
return `<div class="agent-card${selectedClass}" data-agent-id="${escapeHtml(a.agentId)}"${colorStyle} onclick="showAgentModal('${escAttrJs(a.agentId)}')">
|
|
3496
3540
|
${agentLogButton(a.agentId, 'agent-card-log-btn')}
|
|
3497
3541
|
${taskHtml}
|
|
3498
|
-
<div class="agent-ident-row">${
|
|
3542
|
+
<div class="agent-ident-row">${agentIdentHtml(a)}${nameBadgeHtml}</div>
|
|
3499
3543
|
<div class="agent-status-row"><span class="agent-dot ${a.status}"></span><span class="agent-status">${statusText}</span></div>
|
|
3500
3544
|
</div>`;
|
|
3501
3545
|
})
|
|
@@ -3889,6 +3933,8 @@ function renderSessions() {
|
|
|
3889
3933
|
// and still offers everything, or there would be no way to switch sessions without leaving zen.
|
|
3890
3934
|
// Group zen renders only its subtree's buckets, so it takes every session unfiltered.
|
|
3891
3935
|
const zenSession = zenMode && currentSessionId ? sessions.find((s) => s.id === currentSessionId) : null;
|
|
3936
|
+
sessionsList.classList.toggle('zen', !!zenSession);
|
|
3937
|
+
sessionsList.dataset.zenState = zenSession ? sessionState(zenSession) : '';
|
|
3892
3938
|
let filteredSessions;
|
|
3893
3939
|
if (zenGroup) filteredSessions = applyStableSessionOrder(sessions);
|
|
3894
3940
|
else if (zenMode) filteredSessions = zenSession ? [zenSession] : [];
|
|
@@ -4064,7 +4110,8 @@ function renderSessions() {
|
|
|
4064
4110
|
${renderArtifactsHtml(zenSession.id)}
|
|
4065
4111
|
${renderZenBookmarksHtml(zenSession.id)}
|
|
4066
4112
|
</div>
|
|
4067
|
-
${zenSession.hasWorkflow ? renderWorkflowLiveHtml(zenSession.id) : ''}
|
|
4113
|
+
${zenSession.hasWorkflow ? renderWorkflowLiveHtml(zenSession.id) : ''}
|
|
4114
|
+
<div class="zen-agents">${zenAgentsHtml(visibleAgents())}</div>`;
|
|
4068
4115
|
bindLinkedDocsHandlers(sessionsList.querySelector('.linked-docs-section'), zenSession.id);
|
|
4069
4116
|
artifactsSection.load(zenSession.id);
|
|
4070
4117
|
scratchFilesSection.load(zenSession.id);
|
|
@@ -7503,7 +7550,13 @@ function _findOrphanedKeys(known) {
|
|
|
7503
7550
|
const orphaned = [];
|
|
7504
7551
|
for (const id of pinnedSessionIds) if (!known.has(id)) orphaned.push(`__pinned__${id}`);
|
|
7505
7552
|
for (const id of stickySessionIds) if (!known.has(id)) orphaned.push(`__sticky__${id}`);
|
|
7506
|
-
const sessionKeyPrefixes = [
|
|
7553
|
+
const sessionKeyPrefixes = [
|
|
7554
|
+
PINNED_MESSAGES_PREFIX,
|
|
7555
|
+
PAD_EMOJI_PREFIX,
|
|
7556
|
+
PREVIEW_STORAGE_PREFIX,
|
|
7557
|
+
PAD_LINKED_PREFIX,
|
|
7558
|
+
DROPPED_ARTIFACTS_PREFIX,
|
|
7559
|
+
];
|
|
7507
7560
|
for (const key of store.keys()) {
|
|
7508
7561
|
const pad = _parsePadKey(key);
|
|
7509
7562
|
if (pad?.kind === 'group') {
|
|
@@ -9173,7 +9226,7 @@ function bindPreviewRelativeLinks(bodyEl, baseOf = () => currentPreviewPath) {
|
|
|
9173
9226
|
if (isUrlRef(href)) return;
|
|
9174
9227
|
const cleanHref = href.replace(/#.*$/, '');
|
|
9175
9228
|
e.preventDefault();
|
|
9176
|
-
openPreviewByPath(cleanHref,
|
|
9229
|
+
openPreviewByPath(cleanHref, baseOf(a), openFileInEditor);
|
|
9177
9230
|
});
|
|
9178
9231
|
bodyEl.dataset.relLinkBound = '1';
|
|
9179
9232
|
}
|
|
@@ -9675,8 +9728,34 @@ function artifactLabel(a) {
|
|
|
9675
9728
|
return a.path ? pathBasename(a.path).replace(/\.html?$/i, '') : 'Artifact';
|
|
9676
9729
|
}
|
|
9677
9730
|
|
|
9731
|
+
// The list comes from the transcript on every read, so a dropped artifact is kept out by URL.
|
|
9732
|
+
const DROPPED_ARTIFACTS_PREFIX = 'artifacts-dropped-';
|
|
9733
|
+
|
|
9734
|
+
function droppedArtifacts(sessionId) {
|
|
9735
|
+
return new Set(readStoredList(DROPPED_ARTIFACTS_PREFIX + sessionId));
|
|
9736
|
+
}
|
|
9737
|
+
|
|
9738
|
+
function setArtifactDropped(sessionId, url, dropped) {
|
|
9739
|
+
const urls = droppedArtifacts(sessionId);
|
|
9740
|
+
if (dropped) urls.add(url);
|
|
9741
|
+
else urls.delete(url);
|
|
9742
|
+
if (urls.size) store.writeJson(DROPPED_ARTIFACTS_PREFIX + sessionId, [...urls]);
|
|
9743
|
+
else store.removeItem(DROPPED_ARTIFACTS_PREFIX + sessionId);
|
|
9744
|
+
artifactsSection.repaint(sessionId);
|
|
9745
|
+
}
|
|
9746
|
+
|
|
9747
|
+
// biome-ignore lint/correctness/noUnusedVariables: used in HTML
|
|
9748
|
+
function dropArtifact(sessionId, url) {
|
|
9749
|
+
setArtifactDropped(sessionId, url, true);
|
|
9750
|
+
showToast('Artifact dropped from the list', 'info', {
|
|
9751
|
+
label: 'Undo',
|
|
9752
|
+
onClick: () => setArtifactDropped(sessionId, url, false),
|
|
9753
|
+
});
|
|
9754
|
+
}
|
|
9755
|
+
|
|
9678
9756
|
function artifactsInnerHtml(sessionId) {
|
|
9679
|
-
const
|
|
9757
|
+
const dropped = droppedArtifacts(sessionId);
|
|
9758
|
+
const list = artifactsSection.get(sessionId).filter((a) => !dropped.has(a.url));
|
|
9680
9759
|
if (!list.length) return '';
|
|
9681
9760
|
const items = list
|
|
9682
9761
|
.map(
|
|
@@ -9684,6 +9763,7 @@ function artifactsInnerHtml(sessionId) {
|
|
|
9684
9763
|
<a class="artifact-link" href="${escapeHtml(a.url)}" target="_blank" rel="noopener" title="${escapeHtml(a.url)}">${escapeHtml(artifactLabel(a))}</a>
|
|
9685
9764
|
<span class="row-actions artifact-actions">
|
|
9686
9765
|
<button type="button" onclick="copyWithFeedback('${escAttrJs(a.url)}', this)" title="Copy link" aria-label="Copy artifact link">${ICON_COPY}</button>
|
|
9766
|
+
<button type="button" class="artifact-remove" onclick="dropArtifact('${escAttrJs(sessionId)}','${escAttrJs(a.url)}')" title="Drop from the list" aria-label="Drop ${escapeHtml(artifactLabel(a))} from the list">×</button>
|
|
9687
9767
|
</span>
|
|
9688
9768
|
</li>`,
|
|
9689
9769
|
)
|
|
@@ -11055,9 +11135,14 @@ function toRelativeIfUnder(filePath, baseDir) {
|
|
|
11055
11135
|
return fp.slice(bd.length + 1);
|
|
11056
11136
|
}
|
|
11057
11137
|
|
|
11138
|
+
// DOMPurify's default list plus `file:` and drive paths (`C:/x` reads as a scheme), so a link to a
|
|
11139
|
+
// local file survives and opens in the preview.
|
|
11140
|
+
const MARKDOWN_URI =
|
|
11141
|
+
/^(?:(?:(?:f|ht)tps?|mailto|tel|callto|sms|cid|xmpp|matrix|file):|[a-z]:[\\/]|[^a-z]|[a-z+.-]+(?:[^a-z+.\-:]|$))/i;
|
|
11142
|
+
|
|
11058
11143
|
function renderMarkdown(text) {
|
|
11059
11144
|
if (typeof DOMPurify !== 'undefined' && typeof marked !== 'undefined') {
|
|
11060
|
-
return DOMPurify.sanitize(marked.parse(text));
|
|
11145
|
+
return DOMPurify.sanitize(marked.parse(text), { ALLOWED_URI_REGEXP: MARKDOWN_URI });
|
|
11061
11146
|
}
|
|
11062
11147
|
return `<pre style="white-space:pre-wrap;margin:0;">${escapeHtml(text)}</pre>`;
|
|
11063
11148
|
}
|
|
@@ -11531,6 +11616,12 @@ function toggleZenMode() {
|
|
|
11531
11616
|
if (!leavingSessionZen) fetchSessions(false);
|
|
11532
11617
|
}
|
|
11533
11618
|
|
|
11619
|
+
function applyHubSettings(s) {
|
|
11620
|
+
sessionsList.classList.toggle('zen-centered', s.zenCentered === true);
|
|
11621
|
+
if (s.zenPattern) sessionsList.dataset.zenPattern = s.zenPattern;
|
|
11622
|
+
else delete sessionsList.dataset.zenPattern;
|
|
11623
|
+
}
|
|
11624
|
+
|
|
11534
11625
|
function renderZenState() {
|
|
11535
11626
|
const btn = document.getElementById('zen-mode-btn');
|
|
11536
11627
|
if (!btn) return;
|
|
@@ -15377,6 +15468,12 @@ function terminalInitOptions(themeOptions) {
|
|
|
15377
15468
|
};
|
|
15378
15469
|
}
|
|
15379
15470
|
|
|
15471
|
+
function openTerminalLink(href) {
|
|
15472
|
+
if (typeof href === 'string' && isLocalHref(href)) return openLocalHref(href);
|
|
15473
|
+
const url = linkUrl(href);
|
|
15474
|
+
if (url) hub.openExternal(url);
|
|
15475
|
+
}
|
|
15476
|
+
|
|
15380
15477
|
function onTerminalFrameMessage(t, m) {
|
|
15381
15478
|
const socket = termState.socket;
|
|
15382
15479
|
const current = socket && m.socketId === socket.id;
|
|
@@ -15396,6 +15493,7 @@ function onTerminalFrameMessage(t, m) {
|
|
|
15396
15493
|
reconnectTerminal();
|
|
15397
15494
|
}
|
|
15398
15495
|
} else if (t === 'key') replayKey(m, document.getElementById('terminal-key-proxy'));
|
|
15496
|
+
else if (t === 'link') openTerminalLink(m.href);
|
|
15399
15497
|
// Chrome does not always fire focusin on the iframe element when its content takes focus.
|
|
15400
15498
|
else if (t === 'focused') terminalHadFocus = terminalPaneFocused();
|
|
15401
15499
|
else if (t === 'opening') {
|
|
@@ -16022,17 +16120,18 @@ async function terminalFetch(url, method, body) {
|
|
|
16022
16120
|
return res.status === 401 && (await refreshTerminalToken()) ? send() : res;
|
|
16023
16121
|
}
|
|
16024
16122
|
|
|
16025
|
-
const
|
|
16123
|
+
const TERMINAL_DOT_STATE = { working: 'busy', waiting: 'wait', idle: 'idle' };
|
|
16026
16124
|
|
|
16027
16125
|
// biome-ignore lint/correctness/noUnusedVariables: used in HTML
|
|
16028
16126
|
async function endAllTerminals() {
|
|
16029
16127
|
const items = [...document.querySelectorAll('#terminal-manager-body [data-end]')].map((b) => {
|
|
16030
16128
|
const id = b.dataset.end;
|
|
16031
16129
|
const session = sessions.find((s) => s.id === id);
|
|
16032
|
-
const
|
|
16130
|
+
const label = session ? sessionState(session) : 'idle';
|
|
16131
|
+
const state = TERMINAL_DOT_STATE[label];
|
|
16033
16132
|
const started = Number(b.dataset.started);
|
|
16034
16133
|
const meta = [
|
|
16035
|
-
|
|
16134
|
+
label,
|
|
16036
16135
|
started && `up ${formatDuration(Date.now() - started)}`,
|
|
16037
16136
|
formatTerminalProc(terminalProcStats[id]),
|
|
16038
16137
|
]
|
|
@@ -16730,9 +16829,13 @@ function withHeadFirst(content, head) {
|
|
|
16730
16829
|
return content.slice(0, cut) + inject + content.slice(cut);
|
|
16731
16830
|
}
|
|
16732
16831
|
|
|
16832
|
+
// The board's scrollbar rules do not reach into a frame, which would show the OS default.
|
|
16833
|
+
// Grey, not a board token, because the page paints its own background.
|
|
16834
|
+
const FRAME_SCROLLBAR_CSS = ':where(html){scrollbar-width:thin;scrollbar-color:rgba(128,128,128,.45) transparent}';
|
|
16835
|
+
|
|
16733
16836
|
// A whole document gets the tokens and base styles first in its head, so its own styles win.
|
|
16734
16837
|
function showSrcdoc(content, restoreY) {
|
|
16735
|
-
const head = `<meta http-equiv="Content-Security-Policy" content="${SHOW_CSP}"><style>:root{${showTokenCss()}}${SHOW_FRAME_BASE}</style>`;
|
|
16838
|
+
const head = `<meta http-equiv="Content-Security-Policy" content="${SHOW_CSP}"><style>:root{${showTokenCss()}}${SHOW_FRAME_BASE}${FRAME_SCROLLBAR_CSS}</style>`;
|
|
16736
16839
|
const bridge = `<script>(${showBridge})(${Math.max(0, Math.round(restoreY) || 0)}, ${modalZoom});</script>${REVIEW_BRIDGE_TAG}`;
|
|
16737
16840
|
if (!/^\s*(<!doctype|<html)/i.test(content)) {
|
|
16738
16841
|
return `<!doctype html><html><head><meta charset="utf-8">${head}</head><body>${content}${bridge}</body></html>`;
|
|
@@ -16743,7 +16846,7 @@ function showSrcdoc(content, restoreY) {
|
|
|
16743
16846
|
// An HTML file preview gets only the tokens, first in its head: its own :root rules win, and
|
|
16744
16847
|
// a page that declares its defaults in :where(:root) follows the board theme.
|
|
16745
16848
|
function themedSrcdoc(content) {
|
|
16746
|
-
const head = `<style id="cck-tokens">:root{${showTokenCss()}}</style>`;
|
|
16849
|
+
const head = `<style id="cck-tokens">:root{${showTokenCss()}}${FRAME_SCROLLBAR_CSS}</style>`;
|
|
16747
16850
|
return withHeadFirst(content, head) + THEME_BRIDGE_TAG + REVIEW_BRIDGE_TAG;
|
|
16748
16851
|
}
|
|
16749
16852
|
|
|
@@ -18060,6 +18163,7 @@ hub.onActive((active) => {
|
|
|
18060
18163
|
pushTerminalClaims();
|
|
18061
18164
|
});
|
|
18062
18165
|
hub.onStatus(pushTerminalClaims);
|
|
18166
|
+
hub.onSettings(applyHubSettings);
|
|
18063
18167
|
|
|
18064
18168
|
// session.changed goes out once the switch settles, so arrowing through the list sends one event.
|
|
18065
18169
|
let publishTimer = 0;
|
|
@@ -18092,13 +18196,6 @@ function publishSession() {
|
|
|
18092
18196
|
);
|
|
18093
18197
|
}
|
|
18094
18198
|
|
|
18095
|
-
// The help modal stops click propagation, so the document handler below never sees this link.
|
|
18096
|
-
// The service worker can pair a cached page that lacks the link with this script.
|
|
18097
|
-
document.getElementById('help-docs')?.addEventListener('click', (e) => {
|
|
18098
|
-
e.preventDefault();
|
|
18099
|
-
hub.openExternal(e.currentTarget.href);
|
|
18100
|
-
});
|
|
18101
|
-
|
|
18102
18199
|
function webUrl(href) {
|
|
18103
18200
|
let url;
|
|
18104
18201
|
try {
|
|
@@ -18109,17 +18206,38 @@ function webUrl(href) {
|
|
|
18109
18206
|
return url.protocol === 'http:' || url.protocol === 'https:' ? url : null;
|
|
18110
18207
|
}
|
|
18111
18208
|
|
|
18112
|
-
|
|
18113
|
-
|
|
18114
|
-
|
|
18115
|
-
|
|
18116
|
-
|
|
18117
|
-
|
|
18118
|
-
|
|
18119
|
-
|
|
18120
|
-
|
|
18121
|
-
|
|
18122
|
-
|
|
18209
|
+
function isLocalHref(href) {
|
|
18210
|
+
return /^file:/i.test(href) || (isAbsolutePath(href) && !href.startsWith('//'));
|
|
18211
|
+
}
|
|
18212
|
+
|
|
18213
|
+
function openLocalHref(href) {
|
|
18214
|
+
openPreviewByPath(href.replace(/#.*$/, ''), undefined, openFileInEditor);
|
|
18215
|
+
}
|
|
18216
|
+
|
|
18217
|
+
// Capture, because the modals stop a click's propagation, and stopped here so a link in a message
|
|
18218
|
+
// preview does not also open the row. Under the hub the SDK has already opened a web link
|
|
18219
|
+
// outside (defaultPrevented), and leaves a link to a hub app alone.
|
|
18220
|
+
document.addEventListener(
|
|
18221
|
+
'click',
|
|
18222
|
+
(e) => {
|
|
18223
|
+
if (e.button !== 0) return;
|
|
18224
|
+
const href = e.target.closest?.('a[href]')?.getAttribute('href');
|
|
18225
|
+
if (!href) return;
|
|
18226
|
+
const url = webUrl(href);
|
|
18227
|
+
const external = url && url.origin !== window.location.origin;
|
|
18228
|
+
if (e.defaultPrevented) {
|
|
18229
|
+
if (external) e.stopPropagation();
|
|
18230
|
+
return;
|
|
18231
|
+
}
|
|
18232
|
+
const local = isLocalHref(href);
|
|
18233
|
+
if (!local && (!external || hub.inHub)) return;
|
|
18234
|
+
e.preventDefault();
|
|
18235
|
+
e.stopPropagation();
|
|
18236
|
+
if (local) openLocalHref(href);
|
|
18237
|
+
else hub.openExternal(url.href);
|
|
18238
|
+
},
|
|
18239
|
+
true,
|
|
18240
|
+
);
|
|
18123
18241
|
|
|
18124
18242
|
(function initHubTheme() {
|
|
18125
18243
|
const getTheme = () => (document.body.classList.contains('light') ? 'light' : 'dark');
|