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.
@@ -1,26 +1,20 @@
1
1
  import type { On, ToolCallArgs } from 'claude-code'
2
- import { expect, test } from 'claude-code/testing'
3
- import { boardPort, dropSkillLine, shownText, TOOL } from '../hooks/show'
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 LISTING = [
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 (!e.url.includes('/show')) return new Promise(() => {})
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 line, the listing filter', async () => {
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(dropSkillLine(LISTING)).toBe(LISTING.replace('- claude-code-kanban:show\n', ''))
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('gate off: no tool, and the skill line is dropped', async ($, on) => {
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('gate on: the tool is registered and the skill line stays', async ($, on) => {
74
- const board = engine(on, GATE)
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
- expect(board.registered).toEqual(['show'])
77
- const { text } = await $.prompt.attachment(listing)
78
- expect(text).toBe(LISTING)
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 renderAgentFooter() {
3401
- const footer = document.getElementById('agent-footer');
3402
- const content = document.getElementById('agent-footer-content');
3403
- const label = document.getElementById('agent-footer-label');
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
- const agents = currentAgents;
3407
- // Filter shutdown ghosts: for same-type agents, keep if they overlapped (parallel)
3408
- // or started >30s after previous stopped (legitimate re-spawn). Filter the rest.
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 agents) {
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
- const visible = filtered
3435
- .sort(
3436
- (a, b) =>
3437
- (statusOrder[a.status] ?? 2) - (statusOrder[b.status] ?? 2) ||
3438
- new Date(b.updatedAt || 0) - new Date(a.updatedAt || 0),
3439
- )
3440
- .slice(0, AGENT_LOG_MAX);
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 descText = a.description || '';
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">${modelHtml}<span class="agent-type-name" title="${escapeHtml(rawType)}">${typeNs ? `<span class="agent-type-ns">${escapeHtml(typeNs)}</span>` : ''}${escapeHtml(typeName)}</span>${nameBadgeHtml}</div>
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 = [PINNED_MESSAGES_PREFIX, PAD_EMOJI_PREFIX, PREVIEW_STORAGE_PREFIX, PAD_LINKED_PREFIX];
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, isAbsolutePath(href) ? undefined : baseOf(a), openFileInEditor);
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 list = artifactsSection.get(sessionId);
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">&times;</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 TERMINAL_STATE_LABEL = { busy: 'working', wait: 'waiting', idle: 'idle' };
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 state = !session ? 'idle' : isWaitingSession(session) ? 'wait' : isActiveSession(session) ? 'busy' : 'idle';
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
- TERMINAL_STATE_LABEL[state],
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
- document.addEventListener('click', (e) => {
18113
- const a = e.target.closest?.('a[href]');
18114
- if (!a) return;
18115
- const href = a.getAttribute('href');
18116
- if (!href) return;
18117
- const url = webUrl(href);
18118
- if (!url || url.origin === window.location.origin) return;
18119
- e.preventDefault();
18120
- e.stopPropagation();
18121
- hub.openExternal(url.href);
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');