claude-code-kanban 7.15.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.15.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
@@ -77,7 +77,8 @@ let sessions = [];
77
77
  let currentSessionId = null;
78
78
  // Kept in the store because a config-dir switch in the hub reloads this page.
79
79
  const SWAP_KEY = 'swap-sessions';
80
- let [lastSessionId, previousSessionId] = readSwapPair();
80
+ const SWAP_HISTORY_MAX = 20;
81
+ let swapHistory = readStoredList(SWAP_KEY).filter(Boolean);
81
82
  let currentTasks = [];
82
83
  let viewMode = 'session';
83
84
  let sessionFilter = FILTER_DEFAULTS.session;
@@ -309,12 +310,13 @@ function apiPath(strings, ...values) {
309
310
 
310
311
  function api(path, { method, body, query, headers, cache } = {}) {
311
312
  const qs = query ? new URLSearchParams(Object.entries(query).filter(([, v]) => v != null)).toString() : '';
312
- const json = body != null;
313
+ // A Blob goes as-is, and fetch takes its Content-Type from the Blob's type.
314
+ const json = body != null && !(body instanceof Blob);
313
315
  return fetch(qs ? `${path}?${qs}` : path, {
314
316
  method,
315
317
  cache,
316
318
  headers: json ? { 'Content-Type': 'application/json', ...headers } : headers,
317
- body: json ? JSON.stringify(body) : undefined,
319
+ body: json ? JSON.stringify(body) : (body ?? undefined),
318
320
  });
319
321
  }
320
322
 
@@ -782,7 +784,7 @@ async function fetchTasks(sessionId) {
782
784
  revealedStorageSessionId = null;
783
785
  }
784
786
  if (currentSessionId && currentSessionId !== sessionId) deferredPinPlacement.delete(currentSessionId);
785
- if (lastSessionId !== sessionId) setSwapPair(sessionId, lastSessionId);
787
+ pushSwapHistory(sessionId);
786
788
  const switched = sessionId !== currentSessionId;
787
789
  currentSessionId = sessionId;
788
790
  markOpenSessionRead();
@@ -7501,7 +7503,13 @@ function _findOrphanedKeys(known) {
7501
7503
  const orphaned = [];
7502
7504
  for (const id of pinnedSessionIds) if (!known.has(id)) orphaned.push(`__pinned__${id}`);
7503
7505
  for (const id of stickySessionIds) if (!known.has(id)) orphaned.push(`__sticky__${id}`);
7504
- 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
+ ];
7505
7513
  for (const key of store.keys()) {
7506
7514
  const pad = _parsePadKey(key);
7507
7515
  if (pad?.kind === 'group') {
@@ -7591,9 +7599,8 @@ function dismissSessionFromList(sid) {
7591
7599
  return;
7592
7600
  }
7593
7601
  // Ctrl+Alt+S would reveal the dismissed session, and revealing restores it.
7594
- const forget = (x) => (x === sid ? null : x);
7595
- const fallback = currentSessionId === sid && lastSessionId === sid ? previousSessionId : null;
7596
- setSwapPair(forget(lastSessionId), forget(previousSessionId));
7602
+ const fallback = currentSessionId === sid && swapHistory[0] === sid ? swapHistory[1] : null;
7603
+ forgetSwapHistory(sid);
7597
7604
  const targetIdx = newItems.length > 0 ? Math.max(0, prevIdx - 1) : -1;
7598
7605
  // If the dismissed session is currently open, navigate to the previous one
7599
7606
  if (currentSessionId === sid || selectedSessionId === sid) {
@@ -9172,7 +9179,7 @@ function bindPreviewRelativeLinks(bodyEl, baseOf = () => currentPreviewPath) {
9172
9179
  if (isUrlRef(href)) return;
9173
9180
  const cleanHref = href.replace(/#.*$/, '');
9174
9181
  e.preventDefault();
9175
- openPreviewByPath(cleanHref, isAbsolutePath(href) ? undefined : baseOf(a), openFileInEditor);
9182
+ openPreviewByPath(cleanHref, baseOf(a), openFileInEditor);
9176
9183
  });
9177
9184
  bodyEl.dataset.relLinkBound = '1';
9178
9185
  }
@@ -9674,8 +9681,34 @@ function artifactLabel(a) {
9674
9681
  return a.path ? pathBasename(a.path).replace(/\.html?$/i, '') : 'Artifact';
9675
9682
  }
9676
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
+
9677
9709
  function artifactsInnerHtml(sessionId) {
9678
- const list = artifactsSection.get(sessionId);
9710
+ const dropped = droppedArtifacts(sessionId);
9711
+ const list = artifactsSection.get(sessionId).filter((a) => !dropped.has(a.url));
9679
9712
  if (!list.length) return '';
9680
9713
  const items = list
9681
9714
  .map(
@@ -9683,6 +9716,7 @@ function artifactsInnerHtml(sessionId) {
9683
9716
  <a class="artifact-link" href="${escapeHtml(a.url)}" target="_blank" rel="noopener" title="${escapeHtml(a.url)}">${escapeHtml(artifactLabel(a))}</a>
9684
9717
  <span class="row-actions artifact-actions">
9685
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>
9686
9720
  </span>
9687
9721
  </li>`,
9688
9722
  )
@@ -11054,9 +11088,14 @@ function toRelativeIfUnder(filePath, baseDir) {
11054
11088
  return fp.slice(bd.length + 1);
11055
11089
  }
11056
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
+
11057
11096
  function renderMarkdown(text) {
11058
11097
  if (typeof DOMPurify !== 'undefined' && typeof marked !== 'undefined') {
11059
- return DOMPurify.sanitize(marked.parse(text));
11098
+ return DOMPurify.sanitize(marked.parse(text), { ALLOWED_URI_REGEXP: MARKDOWN_URI });
11060
11099
  }
11061
11100
  return `<pre style="white-space:pre-wrap;margin:0;">${escapeHtml(text)}</pre>`;
11062
11101
  }
@@ -15131,20 +15170,22 @@ function terminalShortcut(e, probe = false) {
15131
15170
  return null;
15132
15171
  }
15133
15172
 
15134
- function readSwapPair() {
15135
- const pair = readStoredList(SWAP_KEY);
15136
- return pair.length === 2 ? pair : [null, null];
15173
+ function setSwapHistory(ids) {
15174
+ swapHistory = [...new Set(ids.filter(Boolean))].slice(0, SWAP_HISTORY_MAX);
15175
+ store.writeJson(SWAP_KEY, swapHistory);
15137
15176
  }
15138
15177
 
15139
- function setSwapPair(last, previous) {
15140
- lastSessionId = last;
15141
- previousSessionId = previous;
15142
- store.writeJson(SWAP_KEY, [last, previous]);
15178
+ function pushSwapHistory(id) {
15179
+ if (swapHistory[0] !== id) setSwapHistory([id, ...swapHistory]);
15180
+ }
15181
+
15182
+ function forgetSwapHistory(id) {
15183
+ if (swapHistory.includes(id)) setSwapHistory(swapHistory.filter((x) => x !== id));
15143
15184
  }
15144
15185
 
15145
15186
  async function swapToPreviousSession() {
15146
- const target = currentSessionId === lastSessionId ? previousSessionId : lastSessionId;
15147
- if (!target || target === currentSessionId) return;
15187
+ const target = swapHistory.find((id) => id !== currentSessionId);
15188
+ if (!target) return;
15148
15189
  if (terminalPaneFocused() || isShowCardFocused()) termState.focusNext = true;
15149
15190
  await revealSession(target);
15150
15191
  }
@@ -15374,6 +15415,12 @@ function terminalInitOptions(themeOptions) {
15374
15415
  };
15375
15416
  }
15376
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
+
15377
15424
  function onTerminalFrameMessage(t, m) {
15378
15425
  const socket = termState.socket;
15379
15426
  const current = socket && m.socketId === socket.id;
@@ -15393,6 +15440,7 @@ function onTerminalFrameMessage(t, m) {
15393
15440
  reconnectTerminal();
15394
15441
  }
15395
15442
  } else if (t === 'key') replayKey(m, document.getElementById('terminal-key-proxy'));
15443
+ else if (t === 'link') openTerminalLink(m.href);
15396
15444
  // Chrome does not always fire focusin on the iframe element when its content takes focus.
15397
15445
  else if (t === 'focused') terminalHadFocus = terminalPaneFocused();
15398
15446
  else if (t === 'opening') {
@@ -15805,8 +15853,7 @@ function terminalOpenMode(sessionId) {
15805
15853
  // replays the same screen, and the placeholder gives way to the real card.
15806
15854
  function adoptPickedSession(oldId, id) {
15807
15855
  const focused = terminalPaneFocused();
15808
- const adopt = (x) => (x === oldId ? id : x);
15809
- setSwapPair(adopt(lastSessionId), adopt(previousSessionId));
15856
+ setSwapHistory(swapHistory.map((x) => (x === oldId ? id : x)));
15810
15857
  setTerminalMode(id, true);
15811
15858
  forgetPlaceholder(oldId);
15812
15859
  if (currentSessionId !== oldId || viewMode !== 'session') return renderSessions();
@@ -17025,7 +17072,10 @@ const newSpecs = new Map();
17025
17072
  // Sessions another session started (`dispatch start`), shown where they will stay until
17026
17073
  // their transcript appears. Kept apart from newSpecs: their prompt is already sent.
17027
17074
  const dispatchSpecs = new Map();
17028
- const ns = { projects: [], picked: [], matches: [], idx: -1, folder: '', busy: false, resume: false };
17075
+ const ns = { projects: [], picked: [], matches: [], idx: -1, folder: '', busy: false, resume: false, images: [] };
17076
+ // Must match ATTACHMENT_TYPES and the upload limit in server.js.
17077
+ const NS_IMAGE_TYPES = ['image/png', 'image/jpeg', 'image/gif', 'image/webp'];
17078
+ const NS_IMAGE_MAX_BYTES = 10 * 1024 * 1024;
17029
17079
  const NEW_SESSION_STAY_KEY = 'new-session-stay';
17030
17080
  const NEW_SESSION_EDIT_KEY = 'new-session-edit';
17031
17081
 
@@ -17127,8 +17177,7 @@ function forgetPlaceholder(id) {
17127
17177
  // Before the socket's close handler runs, or it would reconnect and start the session again.
17128
17178
  if (termState.sessionId === id) detachTerminal();
17129
17179
  sessions = sessions.filter((s) => s.id !== id);
17130
- const forget = (x) => (x === id ? null : x);
17131
- setSwapPair(forget(lastSessionId), forget(previousSessionId));
17180
+ forgetSwapHistory(id);
17132
17181
  return true;
17133
17182
  }
17134
17183
 
@@ -17229,6 +17278,7 @@ function openNewSession(folder, resume = false) {
17229
17278
  document.getElementById('ns-wt').checked = false;
17230
17279
  document.getElementById('ns-model').value = '';
17231
17280
  document.getElementById('ns-effort').value = '';
17281
+ clearNewSessionImages();
17232
17282
  document.getElementById('ns-stay').checked = store.getItem(NEW_SESSION_STAY_KEY) === 'true';
17233
17283
  document.getElementById('ns-edit').checked = store.getItem(NEW_SESSION_EDIT_KEY) === 'true';
17234
17284
  setNewSessionError('');
@@ -17252,9 +17302,77 @@ function openNewSession(folder, resume = false) {
17252
17302
  function closeNewSession() {
17253
17303
  hideModalOverlay('new-session-modal');
17254
17304
  closeFolderList();
17305
+ closeImagePreview();
17306
+ clearNewSessionImages();
17255
17307
  promptVoice.rec?.abort();
17256
17308
  }
17257
17309
 
17310
+ function addNewSessionImages(files) {
17311
+ for (const file of files) {
17312
+ if (!NS_IMAGE_TYPES.includes(file.type)) {
17313
+ setNewSessionError(`${file.name || 'File'}: use a PNG, JPEG, GIF or WebP image.`);
17314
+ continue;
17315
+ }
17316
+ if (file.size > NS_IMAGE_MAX_BYTES) {
17317
+ setNewSessionError(`${file.name || 'Image'}: larger than ${NS_IMAGE_MAX_BYTES / 1024 / 1024} MB.`);
17318
+ continue;
17319
+ }
17320
+ ns.images.push({ file, url: URL.createObjectURL(file) });
17321
+ }
17322
+ renderNewSessionImages();
17323
+ }
17324
+
17325
+ function removeNewSessionImage(i) {
17326
+ const [img] = ns.images.splice(i, 1);
17327
+ if (img) URL.revokeObjectURL(img.url);
17328
+ renderNewSessionImages();
17329
+ }
17330
+
17331
+ function clearNewSessionImages() {
17332
+ for (const img of ns.images) URL.revokeObjectURL(img.url);
17333
+ ns.images = [];
17334
+ renderNewSessionImages();
17335
+ }
17336
+
17337
+ function renderNewSessionImages() {
17338
+ const strip = document.getElementById('ns-images');
17339
+ strip.hidden = !ns.images.length;
17340
+ strip.innerHTML = ns.images
17341
+ .map(
17342
+ (img, i) =>
17343
+ `<button type="button" class="ns-thumb" data-preview="${i}" title="${escapeHtml(img.file.name || 'Pasted image')}"><img src="${escapeHtml(img.url)}" alt=""><span class="ns-thumb-remove" data-remove="${i}" title="Remove" aria-label="Remove image">×</span></button>`,
17344
+ )
17345
+ .join('');
17346
+ }
17347
+
17348
+ function openImagePreview(i) {
17349
+ const img = ns.images[i];
17350
+ if (!img) return;
17351
+ const box = document.getElementById('ns-lightbox');
17352
+ box.querySelector('img').src = img.url;
17353
+ box.hidden = false;
17354
+ }
17355
+
17356
+ function closeImagePreview() {
17357
+ const box = document.getElementById('ns-lightbox');
17358
+ if (box.hidden) return false;
17359
+ box.hidden = true;
17360
+ box.querySelector('img').removeAttribute('src');
17361
+ return true;
17362
+ }
17363
+
17364
+ async function uploadNewSessionImage(sessionId, file) {
17365
+ const r = await newSessionPost(`/api/terminal/attachments/${encodeURIComponent(sessionId)}`, file, 'Upload failed');
17366
+ if (r.error) throw new Error(r.error);
17367
+ return r.path;
17368
+ }
17369
+
17370
+ async function withImagePaths(id, prompt) {
17371
+ const paths = await Promise.all(ns.images.map((img) => uploadNewSessionImage(id, img.file)));
17372
+ const list = `Attached images:\n${paths.map((p) => `- ${p}`).join('\n')}`;
17373
+ return prompt ? `${prompt}\n\n${list}` : list;
17374
+ }
17375
+
17258
17376
  function knownFolders() {
17259
17377
  return [...new Set([...ns.picked, ...ns.projects])];
17260
17378
  }
@@ -17414,10 +17532,17 @@ function fieldAfterFolder() {
17414
17532
  return ns.resume ? 'ns-start' : 'ns-name';
17415
17533
  }
17416
17534
 
17417
- function startNewSession() {
17535
+ async function startNewSession() {
17418
17536
  const v = newSessionValues();
17419
17537
  if (!v.cwd || newSessionProblem(v) || ns.busy) return;
17420
17538
  const id = crypto.randomUUID();
17539
+ if (!ns.resume && ns.images.length) {
17540
+ setNewSessionBusy(true);
17541
+ const prompt = await withImagePaths(id, v.prompt).catch((e) => e);
17542
+ setNewSessionBusy(false);
17543
+ if (prompt instanceof Error) return setNewSessionError(prompt.message);
17544
+ v.prompt = prompt;
17545
+ }
17421
17546
  newSpecs.set(id, { ...(ns.resume ? { cwd: v.cwd } : v), mode: ns.resume ? 'pick' : 'new', startedAt: Date.now() });
17422
17547
  setTerminalMode(id, true);
17423
17548
  if (v.stay) return startNewSessionInBackground(id, v);
@@ -17556,11 +17681,35 @@ function initNewSession() {
17556
17681
  mic.addEventListener('mousedown', (e) => e.preventDefault());
17557
17682
  mic.addEventListener('click', togglePromptVoice);
17558
17683
  }
17684
+ const prompt = document.getElementById('ns-prompt');
17685
+ const promptField = prompt.closest('.ns-prompt-field');
17686
+ prompt.addEventListener('paste', (e) => {
17687
+ const files = [...(e.clipboardData?.files || [])].filter((f) => f.type.startsWith('image/'));
17688
+ if (!files.length) return;
17689
+ e.preventDefault();
17690
+ addNewSessionImages(files);
17691
+ });
17692
+ promptField.addEventListener('dragover', (e) => {
17693
+ if (e.dataTransfer?.types.includes('Files')) e.preventDefault();
17694
+ });
17695
+ promptField.addEventListener('drop', (e) => {
17696
+ if (!e.dataTransfer?.files.length) return;
17697
+ e.preventDefault();
17698
+ addNewSessionImages(e.dataTransfer.files);
17699
+ });
17700
+ document.getElementById('ns-images').addEventListener('click', (e) => {
17701
+ const remove = e.target.closest('[data-remove]');
17702
+ if (remove) return removeNewSessionImage(Number(remove.dataset.remove));
17703
+ const thumb = e.target.closest('[data-preview]');
17704
+ if (thumb) openImagePreview(Number(thumb.dataset.preview));
17705
+ });
17706
+ document.getElementById('ns-lightbox').addEventListener('click', closeImagePreview);
17559
17707
  // Bound on the dialog: the global handler returns early on INPUT and TEXTAREA targets.
17560
17708
  modal.addEventListener('keydown', (e) => {
17561
17709
  const inFolder = e.target === input;
17562
17710
  if (e.key === 'Escape') {
17563
- if (folderListOpen()) closeFolderList();
17711
+ if (closeImagePreview()) prompt.focus();
17712
+ else if (folderListOpen()) closeFolderList();
17564
17713
  else closeNewSession();
17565
17714
  } else if (inFolder && matchKey(e, 'ArrowDown')) {
17566
17715
  if (!folderListOpen()) renderFolderList();
@@ -17569,6 +17718,8 @@ function initNewSession() {
17569
17718
  moveFolderHighlight(-1);
17570
17719
  } else if (inFolder && e.key === 'Enter' && folderListOpen() && ns.idx >= 0 && ns.matches[ns.idx] !== ns.folder) {
17571
17720
  pickFolderOption(ns.idx);
17721
+ } else if (e.target === prompt && e.key === 'Backspace' && !prompt.value && ns.images.length) {
17722
+ removeNewSessionImage(ns.images.length - 1);
17572
17723
  } else if (e.key === 'Enter' && (e.target.tagName !== 'TEXTAREA' || e.ctrlKey || e.metaKey)) {
17573
17724
  if (e.target.tagName === 'BUTTON') return;
17574
17725
  startNewSession();
@@ -17986,13 +18137,6 @@ function publishSession() {
17986
18137
  );
17987
18138
  }
17988
18139
 
17989
- // The help modal stops click propagation, so the document handler below never sees this link.
17990
- // The service worker can pair a cached page that lacks the link with this script.
17991
- document.getElementById('help-docs')?.addEventListener('click', (e) => {
17992
- e.preventDefault();
17993
- hub.openExternal(e.currentTarget.href);
17994
- });
17995
-
17996
18140
  function webUrl(href) {
17997
18141
  let url;
17998
18142
  try {
@@ -18003,17 +18147,38 @@ function webUrl(href) {
18003
18147
  return url.protocol === 'http:' || url.protocol === 'https:' ? url : null;
18004
18148
  }
18005
18149
 
18006
- document.addEventListener('click', (e) => {
18007
- const a = e.target.closest?.('a[href]');
18008
- if (!a) return;
18009
- const href = a.getAttribute('href');
18010
- if (!href) return;
18011
- const url = webUrl(href);
18012
- if (!url || url.origin === window.location.origin) return;
18013
- e.preventDefault();
18014
- e.stopPropagation();
18015
- hub.openExternal(url.href);
18016
- });
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
+ );
18017
18182
 
18018
18183
  (function initHubTheme() {
18019
18184
  const getTheme = () => (document.body.classList.contains('light') ? 'light' : 'dark');
package/public/index.html CHANGED
@@ -812,10 +812,11 @@
812
812
  <div class="ns-row ns-extra">
813
813
  <label class="ns-label" for="ns-prompt">Prompt</label>
814
814
  <div class="ns-field ns-prompt-field">
815
- <textarea id="ns-prompt" class="form-input" rows="4" placeholder="Optional. Sent as the first message."></textarea>
815
+ <textarea id="ns-prompt" class="form-input" rows="4" placeholder="Optional. Sent as the first message. Paste or drop images to attach."></textarea>
816
816
  <button type="button" id="ns-mic" class="ns-mic" title="Dictate" aria-label="Dictate" aria-pressed="false" hidden>
817
817
  <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="2" width="6" height="12" rx="3"/><path d="M5 10a7 7 0 0 0 14 0M12 17v5"/></svg>
818
818
  </button>
819
+ <div id="ns-images" class="ns-images" hidden></div>
819
820
  </div>
820
821
  </div>
821
822
  <div class="ns-row ns-extra">
@@ -864,6 +865,7 @@
864
865
  <button type="button" class="btn btn-secondary" onclick="closeNewSession()">Cancel</button>
865
866
  <button type="button" id="ns-start" class="btn btn-primary" onclick="startNewSession()" disabled>Start</button>
866
867
  </div>
868
+ <div id="ns-lightbox" class="ns-lightbox" hidden><img alt=""></div>
867
869
  </div>
868
870
  </div>
869
871
 
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
@@ -9276,6 +9285,81 @@ select.form-input option:checked {
9276
9285
  }
9277
9286
  }
9278
9287
 
9288
+ .ns-images {
9289
+ display: flex;
9290
+ flex-wrap: wrap;
9291
+ gap: 8px;
9292
+ margin-top: 8px;
9293
+ }
9294
+
9295
+ .ns-images[hidden] {
9296
+ display: none;
9297
+ }
9298
+
9299
+ .ns-thumb {
9300
+ position: relative;
9301
+ width: 64px;
9302
+ height: 64px;
9303
+ padding: 0;
9304
+ overflow: hidden;
9305
+ background: var(--bg-hover);
9306
+ border: 1px solid var(--border);
9307
+ border-radius: 6px;
9308
+ cursor: zoom-in;
9309
+ }
9310
+
9311
+ .ns-thumb img {
9312
+ width: 100%;
9313
+ height: 100%;
9314
+ object-fit: cover;
9315
+ display: block;
9316
+ }
9317
+
9318
+ .ns-thumb-remove {
9319
+ position: absolute;
9320
+ top: 2px;
9321
+ right: 2px;
9322
+ display: flex;
9323
+ align-items: center;
9324
+ justify-content: center;
9325
+ width: 18px;
9326
+ height: 18px;
9327
+ font-size: 13px;
9328
+ line-height: 1;
9329
+ color: #fff;
9330
+ background: rgba(0, 0, 0, 0.6);
9331
+ border-radius: 50%;
9332
+ cursor: pointer;
9333
+ }
9334
+
9335
+ .ns-thumb-remove:hover {
9336
+ background: var(--danger);
9337
+ }
9338
+
9339
+ .ns-lightbox {
9340
+ position: fixed;
9341
+ inset: 0;
9342
+ z-index: 10000;
9343
+ display: flex;
9344
+ align-items: center;
9345
+ justify-content: center;
9346
+ padding: 32px;
9347
+ background: rgba(0, 0, 0, 0.75);
9348
+ cursor: zoom-out;
9349
+ }
9350
+
9351
+ .ns-lightbox[hidden] {
9352
+ display: none;
9353
+ }
9354
+
9355
+ .ns-lightbox img {
9356
+ max-width: 100%;
9357
+ max-height: 100%;
9358
+ object-fit: contain;
9359
+ border-radius: 4px;
9360
+ box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5);
9361
+ }
9362
+
9279
9363
  .ns-error {
9280
9364
  font-size: 12px;
9281
9365
  color: var(--danger);
@@ -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
@@ -115,6 +115,7 @@ const SESSIONS_DIR = path.join(CLAUDE_DIR, 'sessions');
115
115
  const CCK_DIR = path.join(CLAUDE_DIR, '.cck');
116
116
  const AGENT_ACTIVITY_DIR = path.join(CCK_DIR, 'agent-activity');
117
117
  const CONTEXT_STATUS_DIR = path.join(CCK_DIR, 'context-status');
118
+ const ATTACHMENTS_DIR = path.join(CCK_DIR, 'attachments');
118
119
  const PINS_FILE = path.join(CCK_DIR, 'pins.json');
119
120
  const USER_GROUPS_FILE = path.join(CCK_DIR, 'groups.json');
120
121
  const DISPATCHED_FILE = path.join(CCK_DIR, 'dispatched.json');
@@ -3386,6 +3387,30 @@ app.post('/api/terminal/start', terminalRoute(async (req, res) => {
3386
3387
  res.status(201).json({ session: started.id, cwd: started.cwd });
3387
3388
  }));
3388
3389
 
3390
+ // The New session dialog's images. The prompt names the saved path, and claude reads the file.
3391
+ // The token check runs before the body parser, so a rejected upload is never buffered.
3392
+ const ATTACHMENT_TYPES = { 'image/png': 'png', 'image/jpeg': 'jpg', 'image/gif': 'gif', 'image/webp': 'webp' };
3393
+ app.post('/api/terminal/attachments/:session', (req, res, next) => {
3394
+ const reason = terminal.unavailableReason();
3395
+ if (reason) return res.status(403).json({ error: reason });
3396
+ if (!terminal.authorized(req.get('x-terminal-token'))) return res.status(401).json({ error: 'invalid terminal token' });
3397
+ if (!isUUID(req.params.session)) return res.status(400).json({ error: 'invalid session id' });
3398
+ res.locals.ext = ATTACHMENT_TYPES[req.get('content-type')];
3399
+ if (!res.locals.ext) return res.status(415).json({ error: 'use a PNG, JPEG, GIF or WebP image' });
3400
+ next();
3401
+ }, express.raw({ type: () => true, limit: '10mb' }), async (req, res) => {
3402
+ if (!req.body?.length) return res.status(400).json({ error: 'empty image' });
3403
+ const dir = path.join(ATTACHMENTS_DIR, req.params.session);
3404
+ const file = path.join(dir, `${Date.now()}-${crypto.randomBytes(3).toString('hex')}.${res.locals.ext}`);
3405
+ try {
3406
+ await fs.mkdir(dir, { recursive: true });
3407
+ await fs.writeFile(file, req.body);
3408
+ res.status(201).json({ path: file });
3409
+ } catch (e) {
3410
+ res.status(500).json({ error: e.message });
3411
+ }
3412
+ });
3413
+
3389
3414
  // The hub's eviction check reads this to keep a pool with live PTYs alive.
3390
3415
  app.get('/api/terminals', terminalRoute(async (_req, res) => {
3391
3416
  res.setHeader('Cache-Control', 'no-store');
@@ -3443,6 +3468,7 @@ const XTERM_FILES = {
3443
3468
  'addon-fit.js': '@xterm/addon-fit/lib/addon-fit.js',
3444
3469
  'addon-webgl.js': '@xterm/addon-webgl/lib/addon-webgl.js',
3445
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',
3446
3472
  };
3447
3473
  app.get('/vendor/xterm/:file', (req, res) => {
3448
3474
  const rel = Object.hasOwn(XTERM_FILES, req.params.file) ? XTERM_FILES[req.params.file] : null;
@@ -4783,7 +4809,7 @@ async function cleanupAgentActivity() {
4783
4809
  } catch { /* agent-activity dir may not exist */ }
4784
4810
  }
4785
4811
 
4786
- // Dispatch markers, pane layouts, reviews, context status and worktrees: see docs/retention.md.
4812
+ // Dispatch markers, pane layouts, reviews, attachments, context status and worktrees: see docs/retention.md.
4787
4813
  async function runRetention() {
4788
4814
  try {
4789
4815
  const scan = await scanTranscripts(PROJECTS_DIR);
@@ -4792,12 +4818,13 @@ async function runRetention() {
4792
4818
  const layouts = panes.prune(opts);
4793
4819
  if (layouts) queuePaneFileWatch();
4794
4820
  const reviews = await pruneSessionDirs(REVIEW_DIR, opts);
4821
+ const attachments = await pruneSessionDirs(ATTACHMENTS_DIR, opts);
4795
4822
  const contexts = await pruneContextStatus(CONTEXT_STATUS_DIR, opts);
4796
4823
  const wts = worktrees.prune(scan?.dirs);
4797
4824
  const taskMaps = await pruneTaskMaps(TASK_MAPS_DIR, opts);
4798
4825
  const grouped = expireGroupMembers(scan?.ids);
4799
- if (markers || layouts || reviews || contexts || wts || taskMaps || grouped.members) {
4800
- console.log(`[retention] removed ${markers} dispatch markers, ${layouts} pane layouts, ${reviews} reviews, ${contexts} context status files, ${wts} worktrees, ${taskMaps} task-list mappings, ${grouped.members} group members, ${grouped.groups} groups`);
4826
+ if (markers || layouts || reviews || attachments || contexts || wts || taskMaps || grouped.members) {
4827
+ console.log(`[retention] removed ${markers} dispatch markers, ${layouts} pane layouts, ${reviews} reviews, ${attachments} attachments, ${contexts} context status files, ${wts} worktrees, ${taskMaps} task-list mappings, ${grouped.members} group members, ${grouped.groups} groups`);
4801
4828
  }
4802
4829
  } catch (e) {
4803
4830
  console.warn('[retention] failed:', e.message);