claude-code-kanban 7.16.0 → 7.17.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-code-kanban",
3
- "version": "7.16.0",
3
+ "version": "7.17.0",
4
4
  "description": "A web-based Kanban board for viewing Claude Code tasks with agent teams support",
5
5
  "main": "server.js",
6
6
  "type": "commonjs",
@@ -39,6 +39,7 @@
39
39
  "@xterm/addon-fit": "0.11.0",
40
40
  "@xterm/addon-serialize": "0.14.0",
41
41
  "@xterm/addon-unicode11": "0.9.0",
42
+ "@xterm/addon-web-links": "0.12.0",
42
43
  "@xterm/addon-webgl": "0.19.0",
43
44
  "@xterm/headless": "6.0.0",
44
45
  "@xterm/xterm": "6.0.0",
package/public/app.js CHANGED
@@ -7503,7 +7503,13 @@ function _findOrphanedKeys(known) {
7503
7503
  const orphaned = [];
7504
7504
  for (const id of pinnedSessionIds) if (!known.has(id)) orphaned.push(`__pinned__${id}`);
7505
7505
  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];
7506
+ const sessionKeyPrefixes = [
7507
+ PINNED_MESSAGES_PREFIX,
7508
+ PAD_EMOJI_PREFIX,
7509
+ PREVIEW_STORAGE_PREFIX,
7510
+ PAD_LINKED_PREFIX,
7511
+ DROPPED_ARTIFACTS_PREFIX,
7512
+ ];
7507
7513
  for (const key of store.keys()) {
7508
7514
  const pad = _parsePadKey(key);
7509
7515
  if (pad?.kind === 'group') {
@@ -9173,7 +9179,7 @@ function bindPreviewRelativeLinks(bodyEl, baseOf = () => currentPreviewPath) {
9173
9179
  if (isUrlRef(href)) return;
9174
9180
  const cleanHref = href.replace(/#.*$/, '');
9175
9181
  e.preventDefault();
9176
- openPreviewByPath(cleanHref, isAbsolutePath(href) ? undefined : baseOf(a), openFileInEditor);
9182
+ openPreviewByPath(cleanHref, baseOf(a), openFileInEditor);
9177
9183
  });
9178
9184
  bodyEl.dataset.relLinkBound = '1';
9179
9185
  }
@@ -9675,8 +9681,34 @@ function artifactLabel(a) {
9675
9681
  return a.path ? pathBasename(a.path).replace(/\.html?$/i, '') : 'Artifact';
9676
9682
  }
9677
9683
 
9684
+ // The list comes from the transcript on every read, so a dropped artifact is kept out by URL.
9685
+ const DROPPED_ARTIFACTS_PREFIX = 'artifacts-dropped-';
9686
+
9687
+ function droppedArtifacts(sessionId) {
9688
+ return new Set(readStoredList(DROPPED_ARTIFACTS_PREFIX + sessionId));
9689
+ }
9690
+
9691
+ function setArtifactDropped(sessionId, url, dropped) {
9692
+ const urls = droppedArtifacts(sessionId);
9693
+ if (dropped) urls.add(url);
9694
+ else urls.delete(url);
9695
+ if (urls.size) store.writeJson(DROPPED_ARTIFACTS_PREFIX + sessionId, [...urls]);
9696
+ else store.removeItem(DROPPED_ARTIFACTS_PREFIX + sessionId);
9697
+ artifactsSection.repaint(sessionId);
9698
+ }
9699
+
9700
+ // biome-ignore lint/correctness/noUnusedVariables: used in HTML
9701
+ function dropArtifact(sessionId, url) {
9702
+ setArtifactDropped(sessionId, url, true);
9703
+ showToast('Artifact dropped from the list', 'info', {
9704
+ label: 'Undo',
9705
+ onClick: () => setArtifactDropped(sessionId, url, false),
9706
+ });
9707
+ }
9708
+
9678
9709
  function artifactsInnerHtml(sessionId) {
9679
- const list = artifactsSection.get(sessionId);
9710
+ const dropped = droppedArtifacts(sessionId);
9711
+ const list = artifactsSection.get(sessionId).filter((a) => !dropped.has(a.url));
9680
9712
  if (!list.length) return '';
9681
9713
  const items = list
9682
9714
  .map(
@@ -9684,6 +9716,7 @@ function artifactsInnerHtml(sessionId) {
9684
9716
  <a class="artifact-link" href="${escapeHtml(a.url)}" target="_blank" rel="noopener" title="${escapeHtml(a.url)}">${escapeHtml(artifactLabel(a))}</a>
9685
9717
  <span class="row-actions artifact-actions">
9686
9718
  <button type="button" onclick="copyWithFeedback('${escAttrJs(a.url)}', this)" title="Copy link" aria-label="Copy artifact link">${ICON_COPY}</button>
9719
+ <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
9720
  </span>
9688
9721
  </li>`,
9689
9722
  )
@@ -11055,9 +11088,14 @@ function toRelativeIfUnder(filePath, baseDir) {
11055
11088
  return fp.slice(bd.length + 1);
11056
11089
  }
11057
11090
 
11091
+ // DOMPurify's default list plus `file:` and drive paths (`C:/x` reads as a scheme), so a link to a
11092
+ // local file survives and opens in the preview.
11093
+ const MARKDOWN_URI =
11094
+ /^(?:(?:(?:f|ht)tps?|mailto|tel|callto|sms|cid|xmpp|matrix|file):|[a-z]:[\\/]|[^a-z]|[a-z+.-]+(?:[^a-z+.\-:]|$))/i;
11095
+
11058
11096
  function renderMarkdown(text) {
11059
11097
  if (typeof DOMPurify !== 'undefined' && typeof marked !== 'undefined') {
11060
- return DOMPurify.sanitize(marked.parse(text));
11098
+ return DOMPurify.sanitize(marked.parse(text), { ALLOWED_URI_REGEXP: MARKDOWN_URI });
11061
11099
  }
11062
11100
  return `<pre style="white-space:pre-wrap;margin:0;">${escapeHtml(text)}</pre>`;
11063
11101
  }
@@ -15377,6 +15415,12 @@ function terminalInitOptions(themeOptions) {
15377
15415
  };
15378
15416
  }
15379
15417
 
15418
+ function openTerminalLink(href) {
15419
+ if (typeof href === 'string' && isLocalHref(href)) return openLocalHref(href);
15420
+ const url = linkUrl(href);
15421
+ if (url) hub.openExternal(url);
15422
+ }
15423
+
15380
15424
  function onTerminalFrameMessage(t, m) {
15381
15425
  const socket = termState.socket;
15382
15426
  const current = socket && m.socketId === socket.id;
@@ -15396,6 +15440,7 @@ function onTerminalFrameMessage(t, m) {
15396
15440
  reconnectTerminal();
15397
15441
  }
15398
15442
  } else if (t === 'key') replayKey(m, document.getElementById('terminal-key-proxy'));
15443
+ else if (t === 'link') openTerminalLink(m.href);
15399
15444
  // Chrome does not always fire focusin on the iframe element when its content takes focus.
15400
15445
  else if (t === 'focused') terminalHadFocus = terminalPaneFocused();
15401
15446
  else if (t === 'opening') {
@@ -18092,13 +18137,6 @@ function publishSession() {
18092
18137
  );
18093
18138
  }
18094
18139
 
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
18140
  function webUrl(href) {
18103
18141
  let url;
18104
18142
  try {
@@ -18109,17 +18147,38 @@ function webUrl(href) {
18109
18147
  return url.protocol === 'http:' || url.protocol === 'https:' ? url : null;
18110
18148
  }
18111
18149
 
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
- });
18150
+ function isLocalHref(href) {
18151
+ return /^file:/i.test(href) || (isAbsolutePath(href) && !href.startsWith('//'));
18152
+ }
18153
+
18154
+ function openLocalHref(href) {
18155
+ openPreviewByPath(href.replace(/#.*$/, ''), undefined, openFileInEditor);
18156
+ }
18157
+
18158
+ // Capture, because the modals stop a click's propagation, and stopped here so a link in a message
18159
+ // preview does not also open the row. Under the hub the SDK has already opened a web link
18160
+ // outside (defaultPrevented), and leaves a link to a hub app alone.
18161
+ document.addEventListener(
18162
+ 'click',
18163
+ (e) => {
18164
+ if (e.button !== 0) return;
18165
+ const href = e.target.closest?.('a[href]')?.getAttribute('href');
18166
+ if (!href) return;
18167
+ const url = webUrl(href);
18168
+ const external = url && url.origin !== window.location.origin;
18169
+ if (e.defaultPrevented) {
18170
+ if (external) e.stopPropagation();
18171
+ return;
18172
+ }
18173
+ const local = isLocalHref(href);
18174
+ if (!local && (!external || hub.inHub)) return;
18175
+ e.preventDefault();
18176
+ e.stopPropagation();
18177
+ if (local) openLocalHref(href);
18178
+ else hub.openExternal(url.href);
18179
+ },
18180
+ true,
18181
+ );
18123
18182
 
18124
18183
  (function initHubTheme() {
18125
18184
  const getTheme = () => (document.body.classList.contains('light') ? 'light' : 'dark');
package/public/style.css CHANGED
@@ -7030,13 +7030,15 @@ body.show-resizing iframe {
7030
7030
  }
7031
7031
 
7032
7032
  /* The × is a glyph, not an svg, so .row-actions button svg does not size it. */
7033
- .linked-doc-remove {
7033
+ .linked-doc-remove,
7034
+ .artifact-remove {
7034
7035
  font-family: var(--mono);
7035
7036
  font-size: 15px;
7036
7037
  line-height: 1;
7037
7038
  }
7038
7039
 
7039
- .linked-doc-actions .linked-doc-remove:hover {
7040
+ .linked-doc-actions .linked-doc-remove:hover,
7041
+ .artifact-actions .artifact-remove:hover {
7040
7042
  background: var(--bg-elevated);
7041
7043
  border-color: var(--border);
7042
7044
  color: var(--text-primary);
@@ -8134,6 +8136,13 @@ body.light .msg-assistant .msg-text {
8134
8136
  padding: 8px 16px;
8135
8137
  white-space: nowrap;
8136
8138
  }
8139
+
8140
+ /* The modal focuses a button itself, and after a mouse click Chrome does not match
8141
+ :focus-visible for that, so Enter would act on a button with no ring. */
8142
+ .confirm-buttons .btn:focus {
8143
+ outline: 2px solid var(--accent);
8144
+ outline-offset: 2px;
8145
+ }
8137
8146
  /* #endregion */
8138
8147
 
8139
8148
  /* Reference card, not reading material: it stays out of the modal zoom, uses the app's
@@ -48,6 +48,39 @@
48
48
  return lines.join('\n');
49
49
  }
50
50
 
51
+ const PATH_ROOT = String.raw`(?:[A-Za-z]:[\\/]|\/(?=[\w.@+-]+\/)|\\\\(?=[\w.$-]+\\))`;
52
+ // A quoted path may hold spaces; a bare one ends at the first.
53
+ const PATH_RE = new RegExp(
54
+ [
55
+ String.raw`"(${PATH_ROOT}[^"<>|*?]*[^\s"<>|*?])"`,
56
+ String.raw`'(${PATH_ROOT}[^'<>|*?]*[^\s'<>|*?])'`,
57
+ String.raw`(?<![\w/\\:.~-])${PATH_ROOT}[^\s"'<>|*?${'`'}]*[^\s"'<>|*?${'`'}.,;:!)\]}]`,
58
+ ].join('|'),
59
+ 'g',
60
+ );
61
+
62
+ function pathsIn(text) {
63
+ return Array.from(text.matchAll(PATH_RE), (m) => {
64
+ const quoted = m[1] ?? m[2];
65
+ const found = quoted ?? m[0];
66
+ return { index: m.index + (quoted ? 1 : 0), text: found, path: found.replace(/(?::\d+){1,2}$/, '') };
67
+ });
68
+ }
69
+
70
+ // Cell x of each character, because a wide character spans two cells and translateToString counts it once.
71
+ function lineCells(line, cols, reuse) {
72
+ let text = '';
73
+ const xs = [];
74
+ for (let x = 0; x < cols; x++) {
75
+ const cell = line.getCell(x, reuse);
76
+ if (!cell || cell.getWidth() === 0) continue;
77
+ const chars = cell.getChars() || ' ';
78
+ for (let i = 0; i < chars.length; i++) xs.push(x);
79
+ text += chars;
80
+ }
81
+ return { text, xs };
82
+ }
83
+
51
84
  function oscColor(hex) {
52
85
  const m = /^#([0-9a-f]{6})$/i.exec(hex || '');
53
86
  if (!m) return null;
@@ -147,6 +180,8 @@
147
180
  scrollback: o.scrollback,
148
181
  cursorBlink: true,
149
182
  allowProposedApi: true,
183
+ // xterm's default asks with confirm() and opens a window, which the installed hub window blocks.
184
+ linkHandler: { activate: (_e, uri) => up('link', { href: uri }), allowNonHttpProtocols: true },
150
185
  ...o.themeOptions,
151
186
  });
152
187
  state.term = term;
@@ -154,6 +189,22 @@
154
189
  term.loadAddon(state.fit);
155
190
  term.loadAddon(new win.Unicode11Addon.Unicode11Addon());
156
191
  term.unicode.activeVersion = '11';
192
+ // Link providers scan a line only when the pointer moves onto it, never on output.
193
+ term.loadAddon(new win.WebLinksAddon.WebLinksAddon((_e, uri) => up('link', { href: uri })));
194
+ const cellReuse = term.buffer.active.getNullCell();
195
+ term.registerLinkProvider({
196
+ provideLinks(y, done) {
197
+ const line = term.buffer.active.getLine(y - 1);
198
+ if (!line || !/[\\/]/.test(line.translateToString(true))) return done(undefined);
199
+ const { text, xs } = lineCells(line, term.cols, cellReuse);
200
+ const links = pathsIn(text).map((p) => ({
201
+ text: p.text,
202
+ range: { start: { x: xs[p.index] + 1, y }, end: { x: xs[p.index + p.text.length - 1] + 1, y } },
203
+ activate: () => up('link', { href: p.path }),
204
+ }));
205
+ done(links.length ? links : undefined);
206
+ },
207
+ });
157
208
  term.open(host);
158
209
  try {
159
210
  const gl = new win.WebglAddon.WebglAddon();
@@ -346,7 +397,7 @@
346
397
  }
347
398
 
348
399
  if (typeof module === 'object' && module.exports) {
349
- module.exports = { createTerminalFrame, claimSig, isClaimed, handsBack, parentOriginOf, tailOf };
400
+ module.exports = { createTerminalFrame, claimSig, isClaimed, handsBack, parentOriginOf, tailOf, pathsIn };
350
401
  } else {
351
402
  createTerminalFrame(root);
352
403
  }
@@ -22,6 +22,7 @@
22
22
  <script src="/vendor/xterm/addon-fit.js"></script>
23
23
  <script src="/vendor/xterm/addon-webgl.js"></script>
24
24
  <script src="/vendor/xterm/addon-unicode11.js"></script>
25
+ <script src="/vendor/xterm/addon-web-links.js"></script>
25
26
  <script src="/terminal-frame.js"></script>
26
27
  </body>
27
28
  </html>
@@ -1,4 +1,4 @@
1
- // claude-hub-sdk 1.5.0 (sha256 1505d3d5f8ba). Copied by npm run sdk:sync in claude-code-hub. Do not edit.
1
+ // claude-hub-sdk 1.6.0 (sha256 71a58022516d). Copied by npm run sdk:sync in claude-code-hub. Do not edit.
2
2
  // Claude Code Hub SDK: the one rule that names a key press, shared by the hub page, client.js and
3
3
  // stub.js. The hub serves it to its page as /sdk/keys.js; mount() and sdk:sync put it in front of
4
4
  // client.js and stub.js, so an app still loads one file.
package/server.js CHANGED
@@ -3468,6 +3468,7 @@ const XTERM_FILES = {
3468
3468
  'addon-fit.js': '@xterm/addon-fit/lib/addon-fit.js',
3469
3469
  'addon-webgl.js': '@xterm/addon-webgl/lib/addon-webgl.js',
3470
3470
  'addon-unicode11.js': '@xterm/addon-unicode11/lib/addon-unicode11.js',
3471
+ 'addon-web-links.js': '@xterm/addon-web-links/lib/addon-web-links.js',
3471
3472
  };
3472
3473
  app.get('/vendor/xterm/:file', (req, res) => {
3473
3474
  const rel = Object.hasOwn(XTERM_FILES, req.params.file) ? XTERM_FILES[req.params.file] : null;