claude-code-kanban 7.15.0 → 7.16.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.16.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",
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();
@@ -7591,9 +7593,8 @@ function dismissSessionFromList(sid) {
7591
7593
  return;
7592
7594
  }
7593
7595
  // 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));
7596
+ const fallback = currentSessionId === sid && swapHistory[0] === sid ? swapHistory[1] : null;
7597
+ forgetSwapHistory(sid);
7597
7598
  const targetIdx = newItems.length > 0 ? Math.max(0, prevIdx - 1) : -1;
7598
7599
  // If the dismissed session is currently open, navigate to the previous one
7599
7600
  if (currentSessionId === sid || selectedSessionId === sid) {
@@ -15131,20 +15132,22 @@ function terminalShortcut(e, probe = false) {
15131
15132
  return null;
15132
15133
  }
15133
15134
 
15134
- function readSwapPair() {
15135
- const pair = readStoredList(SWAP_KEY);
15136
- return pair.length === 2 ? pair : [null, null];
15135
+ function setSwapHistory(ids) {
15136
+ swapHistory = [...new Set(ids.filter(Boolean))].slice(0, SWAP_HISTORY_MAX);
15137
+ store.writeJson(SWAP_KEY, swapHistory);
15137
15138
  }
15138
15139
 
15139
- function setSwapPair(last, previous) {
15140
- lastSessionId = last;
15141
- previousSessionId = previous;
15142
- store.writeJson(SWAP_KEY, [last, previous]);
15140
+ function pushSwapHistory(id) {
15141
+ if (swapHistory[0] !== id) setSwapHistory([id, ...swapHistory]);
15142
+ }
15143
+
15144
+ function forgetSwapHistory(id) {
15145
+ if (swapHistory.includes(id)) setSwapHistory(swapHistory.filter((x) => x !== id));
15143
15146
  }
15144
15147
 
15145
15148
  async function swapToPreviousSession() {
15146
- const target = currentSessionId === lastSessionId ? previousSessionId : lastSessionId;
15147
- if (!target || target === currentSessionId) return;
15149
+ const target = swapHistory.find((id) => id !== currentSessionId);
15150
+ if (!target) return;
15148
15151
  if (terminalPaneFocused() || isShowCardFocused()) termState.focusNext = true;
15149
15152
  await revealSession(target);
15150
15153
  }
@@ -15805,8 +15808,7 @@ function terminalOpenMode(sessionId) {
15805
15808
  // replays the same screen, and the placeholder gives way to the real card.
15806
15809
  function adoptPickedSession(oldId, id) {
15807
15810
  const focused = terminalPaneFocused();
15808
- const adopt = (x) => (x === oldId ? id : x);
15809
- setSwapPair(adopt(lastSessionId), adopt(previousSessionId));
15811
+ setSwapHistory(swapHistory.map((x) => (x === oldId ? id : x)));
15810
15812
  setTerminalMode(id, true);
15811
15813
  forgetPlaceholder(oldId);
15812
15814
  if (currentSessionId !== oldId || viewMode !== 'session') return renderSessions();
@@ -17025,7 +17027,10 @@ const newSpecs = new Map();
17025
17027
  // Sessions another session started (`dispatch start`), shown where they will stay until
17026
17028
  // their transcript appears. Kept apart from newSpecs: their prompt is already sent.
17027
17029
  const dispatchSpecs = new Map();
17028
- const ns = { projects: [], picked: [], matches: [], idx: -1, folder: '', busy: false, resume: false };
17030
+ const ns = { projects: [], picked: [], matches: [], idx: -1, folder: '', busy: false, resume: false, images: [] };
17031
+ // Must match ATTACHMENT_TYPES and the upload limit in server.js.
17032
+ const NS_IMAGE_TYPES = ['image/png', 'image/jpeg', 'image/gif', 'image/webp'];
17033
+ const NS_IMAGE_MAX_BYTES = 10 * 1024 * 1024;
17029
17034
  const NEW_SESSION_STAY_KEY = 'new-session-stay';
17030
17035
  const NEW_SESSION_EDIT_KEY = 'new-session-edit';
17031
17036
 
@@ -17127,8 +17132,7 @@ function forgetPlaceholder(id) {
17127
17132
  // Before the socket's close handler runs, or it would reconnect and start the session again.
17128
17133
  if (termState.sessionId === id) detachTerminal();
17129
17134
  sessions = sessions.filter((s) => s.id !== id);
17130
- const forget = (x) => (x === id ? null : x);
17131
- setSwapPair(forget(lastSessionId), forget(previousSessionId));
17135
+ forgetSwapHistory(id);
17132
17136
  return true;
17133
17137
  }
17134
17138
 
@@ -17229,6 +17233,7 @@ function openNewSession(folder, resume = false) {
17229
17233
  document.getElementById('ns-wt').checked = false;
17230
17234
  document.getElementById('ns-model').value = '';
17231
17235
  document.getElementById('ns-effort').value = '';
17236
+ clearNewSessionImages();
17232
17237
  document.getElementById('ns-stay').checked = store.getItem(NEW_SESSION_STAY_KEY) === 'true';
17233
17238
  document.getElementById('ns-edit').checked = store.getItem(NEW_SESSION_EDIT_KEY) === 'true';
17234
17239
  setNewSessionError('');
@@ -17252,9 +17257,77 @@ function openNewSession(folder, resume = false) {
17252
17257
  function closeNewSession() {
17253
17258
  hideModalOverlay('new-session-modal');
17254
17259
  closeFolderList();
17260
+ closeImagePreview();
17261
+ clearNewSessionImages();
17255
17262
  promptVoice.rec?.abort();
17256
17263
  }
17257
17264
 
17265
+ function addNewSessionImages(files) {
17266
+ for (const file of files) {
17267
+ if (!NS_IMAGE_TYPES.includes(file.type)) {
17268
+ setNewSessionError(`${file.name || 'File'}: use a PNG, JPEG, GIF or WebP image.`);
17269
+ continue;
17270
+ }
17271
+ if (file.size > NS_IMAGE_MAX_BYTES) {
17272
+ setNewSessionError(`${file.name || 'Image'}: larger than ${NS_IMAGE_MAX_BYTES / 1024 / 1024} MB.`);
17273
+ continue;
17274
+ }
17275
+ ns.images.push({ file, url: URL.createObjectURL(file) });
17276
+ }
17277
+ renderNewSessionImages();
17278
+ }
17279
+
17280
+ function removeNewSessionImage(i) {
17281
+ const [img] = ns.images.splice(i, 1);
17282
+ if (img) URL.revokeObjectURL(img.url);
17283
+ renderNewSessionImages();
17284
+ }
17285
+
17286
+ function clearNewSessionImages() {
17287
+ for (const img of ns.images) URL.revokeObjectURL(img.url);
17288
+ ns.images = [];
17289
+ renderNewSessionImages();
17290
+ }
17291
+
17292
+ function renderNewSessionImages() {
17293
+ const strip = document.getElementById('ns-images');
17294
+ strip.hidden = !ns.images.length;
17295
+ strip.innerHTML = ns.images
17296
+ .map(
17297
+ (img, i) =>
17298
+ `<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>`,
17299
+ )
17300
+ .join('');
17301
+ }
17302
+
17303
+ function openImagePreview(i) {
17304
+ const img = ns.images[i];
17305
+ if (!img) return;
17306
+ const box = document.getElementById('ns-lightbox');
17307
+ box.querySelector('img').src = img.url;
17308
+ box.hidden = false;
17309
+ }
17310
+
17311
+ function closeImagePreview() {
17312
+ const box = document.getElementById('ns-lightbox');
17313
+ if (box.hidden) return false;
17314
+ box.hidden = true;
17315
+ box.querySelector('img').removeAttribute('src');
17316
+ return true;
17317
+ }
17318
+
17319
+ async function uploadNewSessionImage(sessionId, file) {
17320
+ const r = await newSessionPost(`/api/terminal/attachments/${encodeURIComponent(sessionId)}`, file, 'Upload failed');
17321
+ if (r.error) throw new Error(r.error);
17322
+ return r.path;
17323
+ }
17324
+
17325
+ async function withImagePaths(id, prompt) {
17326
+ const paths = await Promise.all(ns.images.map((img) => uploadNewSessionImage(id, img.file)));
17327
+ const list = `Attached images:\n${paths.map((p) => `- ${p}`).join('\n')}`;
17328
+ return prompt ? `${prompt}\n\n${list}` : list;
17329
+ }
17330
+
17258
17331
  function knownFolders() {
17259
17332
  return [...new Set([...ns.picked, ...ns.projects])];
17260
17333
  }
@@ -17414,10 +17487,17 @@ function fieldAfterFolder() {
17414
17487
  return ns.resume ? 'ns-start' : 'ns-name';
17415
17488
  }
17416
17489
 
17417
- function startNewSession() {
17490
+ async function startNewSession() {
17418
17491
  const v = newSessionValues();
17419
17492
  if (!v.cwd || newSessionProblem(v) || ns.busy) return;
17420
17493
  const id = crypto.randomUUID();
17494
+ if (!ns.resume && ns.images.length) {
17495
+ setNewSessionBusy(true);
17496
+ const prompt = await withImagePaths(id, v.prompt).catch((e) => e);
17497
+ setNewSessionBusy(false);
17498
+ if (prompt instanceof Error) return setNewSessionError(prompt.message);
17499
+ v.prompt = prompt;
17500
+ }
17421
17501
  newSpecs.set(id, { ...(ns.resume ? { cwd: v.cwd } : v), mode: ns.resume ? 'pick' : 'new', startedAt: Date.now() });
17422
17502
  setTerminalMode(id, true);
17423
17503
  if (v.stay) return startNewSessionInBackground(id, v);
@@ -17556,11 +17636,35 @@ function initNewSession() {
17556
17636
  mic.addEventListener('mousedown', (e) => e.preventDefault());
17557
17637
  mic.addEventListener('click', togglePromptVoice);
17558
17638
  }
17639
+ const prompt = document.getElementById('ns-prompt');
17640
+ const promptField = prompt.closest('.ns-prompt-field');
17641
+ prompt.addEventListener('paste', (e) => {
17642
+ const files = [...(e.clipboardData?.files || [])].filter((f) => f.type.startsWith('image/'));
17643
+ if (!files.length) return;
17644
+ e.preventDefault();
17645
+ addNewSessionImages(files);
17646
+ });
17647
+ promptField.addEventListener('dragover', (e) => {
17648
+ if (e.dataTransfer?.types.includes('Files')) e.preventDefault();
17649
+ });
17650
+ promptField.addEventListener('drop', (e) => {
17651
+ if (!e.dataTransfer?.files.length) return;
17652
+ e.preventDefault();
17653
+ addNewSessionImages(e.dataTransfer.files);
17654
+ });
17655
+ document.getElementById('ns-images').addEventListener('click', (e) => {
17656
+ const remove = e.target.closest('[data-remove]');
17657
+ if (remove) return removeNewSessionImage(Number(remove.dataset.remove));
17658
+ const thumb = e.target.closest('[data-preview]');
17659
+ if (thumb) openImagePreview(Number(thumb.dataset.preview));
17660
+ });
17661
+ document.getElementById('ns-lightbox').addEventListener('click', closeImagePreview);
17559
17662
  // Bound on the dialog: the global handler returns early on INPUT and TEXTAREA targets.
17560
17663
  modal.addEventListener('keydown', (e) => {
17561
17664
  const inFolder = e.target === input;
17562
17665
  if (e.key === 'Escape') {
17563
- if (folderListOpen()) closeFolderList();
17666
+ if (closeImagePreview()) prompt.focus();
17667
+ else if (folderListOpen()) closeFolderList();
17564
17668
  else closeNewSession();
17565
17669
  } else if (inFolder && matchKey(e, 'ArrowDown')) {
17566
17670
  if (!folderListOpen()) renderFolderList();
@@ -17569,6 +17673,8 @@ function initNewSession() {
17569
17673
  moveFolderHighlight(-1);
17570
17674
  } else if (inFolder && e.key === 'Enter' && folderListOpen() && ns.idx >= 0 && ns.matches[ns.idx] !== ns.folder) {
17571
17675
  pickFolderOption(ns.idx);
17676
+ } else if (e.target === prompt && e.key === 'Backspace' && !prompt.value && ns.images.length) {
17677
+ removeNewSessionImage(ns.images.length - 1);
17572
17678
  } else if (e.key === 'Enter' && (e.target.tagName !== 'TEXTAREA' || e.ctrlKey || e.metaKey)) {
17573
17679
  if (e.target.tagName === 'BUTTON') return;
17574
17680
  startNewSession();
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
@@ -9276,6 +9276,81 @@ select.form-input option:checked {
9276
9276
  }
9277
9277
  }
9278
9278
 
9279
+ .ns-images {
9280
+ display: flex;
9281
+ flex-wrap: wrap;
9282
+ gap: 8px;
9283
+ margin-top: 8px;
9284
+ }
9285
+
9286
+ .ns-images[hidden] {
9287
+ display: none;
9288
+ }
9289
+
9290
+ .ns-thumb {
9291
+ position: relative;
9292
+ width: 64px;
9293
+ height: 64px;
9294
+ padding: 0;
9295
+ overflow: hidden;
9296
+ background: var(--bg-hover);
9297
+ border: 1px solid var(--border);
9298
+ border-radius: 6px;
9299
+ cursor: zoom-in;
9300
+ }
9301
+
9302
+ .ns-thumb img {
9303
+ width: 100%;
9304
+ height: 100%;
9305
+ object-fit: cover;
9306
+ display: block;
9307
+ }
9308
+
9309
+ .ns-thumb-remove {
9310
+ position: absolute;
9311
+ top: 2px;
9312
+ right: 2px;
9313
+ display: flex;
9314
+ align-items: center;
9315
+ justify-content: center;
9316
+ width: 18px;
9317
+ height: 18px;
9318
+ font-size: 13px;
9319
+ line-height: 1;
9320
+ color: #fff;
9321
+ background: rgba(0, 0, 0, 0.6);
9322
+ border-radius: 50%;
9323
+ cursor: pointer;
9324
+ }
9325
+
9326
+ .ns-thumb-remove:hover {
9327
+ background: var(--danger);
9328
+ }
9329
+
9330
+ .ns-lightbox {
9331
+ position: fixed;
9332
+ inset: 0;
9333
+ z-index: 10000;
9334
+ display: flex;
9335
+ align-items: center;
9336
+ justify-content: center;
9337
+ padding: 32px;
9338
+ background: rgba(0, 0, 0, 0.75);
9339
+ cursor: zoom-out;
9340
+ }
9341
+
9342
+ .ns-lightbox[hidden] {
9343
+ display: none;
9344
+ }
9345
+
9346
+ .ns-lightbox img {
9347
+ max-width: 100%;
9348
+ max-height: 100%;
9349
+ object-fit: contain;
9350
+ border-radius: 4px;
9351
+ box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5);
9352
+ }
9353
+
9279
9354
  .ns-error {
9280
9355
  font-size: 12px;
9281
9356
  color: var(--danger);
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');
@@ -4783,7 +4808,7 @@ async function cleanupAgentActivity() {
4783
4808
  } catch { /* agent-activity dir may not exist */ }
4784
4809
  }
4785
4810
 
4786
- // Dispatch markers, pane layouts, reviews, context status and worktrees: see docs/retention.md.
4811
+ // Dispatch markers, pane layouts, reviews, attachments, context status and worktrees: see docs/retention.md.
4787
4812
  async function runRetention() {
4788
4813
  try {
4789
4814
  const scan = await scanTranscripts(PROJECTS_DIR);
@@ -4792,12 +4817,13 @@ async function runRetention() {
4792
4817
  const layouts = panes.prune(opts);
4793
4818
  if (layouts) queuePaneFileWatch();
4794
4819
  const reviews = await pruneSessionDirs(REVIEW_DIR, opts);
4820
+ const attachments = await pruneSessionDirs(ATTACHMENTS_DIR, opts);
4795
4821
  const contexts = await pruneContextStatus(CONTEXT_STATUS_DIR, opts);
4796
4822
  const wts = worktrees.prune(scan?.dirs);
4797
4823
  const taskMaps = await pruneTaskMaps(TASK_MAPS_DIR, opts);
4798
4824
  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`);
4825
+ if (markers || layouts || reviews || attachments || contexts || wts || taskMaps || grouped.members) {
4826
+ 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
4827
  }
4802
4828
  } catch (e) {
4803
4829
  console.warn('[retention] failed:', e.message);