claude-code-kanban 7.8.0 → 7.9.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/cli.js CHANGED
@@ -637,11 +637,14 @@ async function runPreviewCli(args) {
637
637
  if (url) return previewUrlCli(url, args);
638
638
  // No $CLAUDE_CODE_SESSION_ID fallback: a session here switches the board's focus, and the
639
639
  // agent must not move the board unless asked.
640
- const sessionId = getArgValue(args, 'session') || process.env.PREVIEW_SESSION || null;
640
+ const sessionArg = getArgValue(args, 'session') || process.env.PREVIEW_SESSION || null;
641
+ const resolved = sessionArg && await resolveSessionByIdOrPrefix(sessionArg);
642
+ if (sessionArg && !resolved) return 1;
643
+ const sessionId = resolved?.id || null;
641
644
  const abs = cliPath(filePathArg);
642
645
  try {
643
646
  if (!await cliPostJson('/api/preview', { path: abs, sessionId }, 'Preview')) return 1;
644
- console.log(`Preview opened: ${abs}${sessionId ? ` (session ${sessionId})` : ''}`);
647
+ console.log(`Preview opened: ${abs}${sessionId ? ` (session ${sessionId.slice(0, 8)})` : ''}`);
645
648
  return 0;
646
649
  } catch (e) { reportCliError(e); return 1; }
647
650
  }
@@ -36,4 +36,19 @@ function isSessionLive(sessions, sessionId, exceptPid = null) {
36
36
  });
37
37
  }
38
38
 
39
- module.exports = { readLiveSessions, isPidAlive, isSessionLive };
39
+ // /clear and /resume keep claude's process and registry file and swap its sessionId, so the id
40
+ // that pid held before is finished even though its transcript was written moments ago. `replaced`
41
+ // maps that id to when the swap was seen; an id that shows up live again leaves it.
42
+ function noteReplacedSessions(prev, next, replaced, now, ttlMs) {
43
+ for (const [id, at] of replaced) if (now - at > ttlMs) replaced.delete(id);
44
+ if (prev) {
45
+ const byPid = new Map(next.filter((s) => s.pid).map((s) => [s.pid, s.sessionId]));
46
+ for (const s of prev) {
47
+ const id = s.pid && byPid.get(s.pid);
48
+ if (id && id !== s.sessionId) replaced.set(s.sessionId, now);
49
+ }
50
+ }
51
+ for (const s of next) replaced.delete(s.sessionId);
52
+ }
53
+
54
+ module.exports = { readLiveSessions, isPidAlive, isSessionLive, noteReplacedSessions };
package/lib/retention.js CHANGED
@@ -78,6 +78,15 @@ function createDispatchedStore({ load, save, now = Date.now }) {
78
78
  persist();
79
79
  }
80
80
 
81
+ function move(from, to) {
82
+ const e = entries.get(from);
83
+ if (!e || entries.has(to)) return false;
84
+ entries.delete(from);
85
+ entries.set(to, e);
86
+ persist();
87
+ return true;
88
+ }
89
+
81
90
  // Returns how many entries went; writes only when one did.
82
91
  function prune({ known, maxAgeMs }) {
83
92
  const removed = pruneMap(entries, (e) => e.at, { known, maxAgeMs, now: now() });
@@ -95,7 +104,7 @@ function createDispatchedStore({ load, save, now = Date.now }) {
95
104
  return out;
96
105
  }
97
106
 
98
- return { record, prune, running, get: (id) => entries.get(id) || null, entries: () => entries.entries() };
107
+ return { record, move, prune, running, get: (id) => entries.get(id) || null, entries: () => entries.entries() };
99
108
  }
100
109
 
101
110
  // Names only, no stat or parse: the sweep needs which transcripts exist, not what they hold.
@@ -33,6 +33,7 @@ function hostError(message) {
33
33
  * @param {(data: {sessions: string[]}) => void} [o.save]
34
34
  * @param {() => void} [o.onChange]
35
35
  * @param {(id: string) => void} [o.onExit]
36
+ * @param {(from: string, to: string) => void} [o.onRekey]
36
37
  */
37
38
  function createTerminalClient(o) {
38
39
  const { config, net } = o;
@@ -83,6 +84,7 @@ function createTerminalClient(o) {
83
84
  settle(hostReason);
84
85
  o.onChange?.();
85
86
  } else if (msg.t === 'exit') o.onExit?.(msg.id);
87
+ else if (msg.t === 'rekey') o.onRekey?.(msg.from, msg.to);
86
88
  else if (msg.t === 'save') o.save?.(msg.data);
87
89
  else if (msg.t === 'reply') {
88
90
  const pending = calls.get(msg.id);
@@ -61,6 +61,7 @@ function init(msg) {
61
61
  save: (data) => send({ t: 'save', data }),
62
62
  onChange: pushState,
63
63
  onExit: (id) => send({ t: 'exit', id }),
64
+ onRekey: (from, to) => send({ t: 'rekey', from, to }),
64
65
  });
65
66
  pushState();
66
67
  }
package/lib/terminal.js CHANGED
@@ -351,6 +351,7 @@ function clientConfigFor(config, available) {
351
351
  * @param {(cmd: string) => string|null} o.which
352
352
  * @param {string} [o.token]
353
353
  * @param {() => void} [o.onChange] a terminal started, ended or moved to another session id
354
+ * @param {(from: string, to: string) => void} [o.onRekey] a terminal moved to another session id
354
355
  * @param {() => {sessions?: string[], taskLists?: Object<string, string>}|null} [o.load] the saved list, read when config.restore is on
355
356
  * @param {(data: {sessions: string[], taskLists?: Object<string, string>}) => void} [o.save] the session ids to resume on the next start, and the shared task list of each that has one
356
357
  * @param {object} [o.pty] stands in for @lydell/node-pty in tests
@@ -596,7 +597,12 @@ function createTerminalService(o) {
596
597
  const pid = inputPid(s, live);
597
598
  // An edit terminal starts claude whenever the user presses Enter, after syncBoost stopped looking.
598
599
  if (pid && pid !== had) syncBoost(s);
599
- if (!pid || (live.some((l) => l.pid === pid) && pidAlive(pid))) continue;
600
+ if (!pid) continue;
601
+ const entry = live.find((l) => l.pid === pid);
602
+ if (entry && pidAlive(pid)) {
603
+ if (entry.sessionId !== s.id && s.mode !== 'pick' && !s.resumeOf) followSession(s, entry.sessionId);
604
+ continue;
605
+ }
600
606
  s.claudeExitedAt = Date.now();
601
607
  s.claudePid = null;
602
608
  moved = true;
@@ -696,7 +702,7 @@ function createTerminalService(o) {
696
702
  // is found by folder and start time, and the pick shows as an id with a transcript behind it.
697
703
  function watchPick(s) {
698
704
  s.pickTimer = setInterval(async () => {
699
- if (s.pickChecking) return;
705
+ if (s.resolving) return;
700
706
  const live = o.liveSessions();
701
707
  const entry = s.claudePid && live.find((l) => l.pid === s.claudePid);
702
708
  if (!entry) {
@@ -708,10 +714,7 @@ function createTerminalService(o) {
708
714
  }
709
715
  const picked = entry.sessionId;
710
716
  if (picked === s.id) return;
711
- s.pickChecking = true;
712
- const known = await o.resolveCwd(picked);
713
- s.pickChecking = false;
714
- if (known === null || s.exited || s.mode !== 'pick') return;
717
+ if (!(await hasTranscript(s, picked)) || s.mode !== 'pick') return;
715
718
  clearInterval(s.pickTimer);
716
719
  // Two claude processes on one session write the same transcript. When cck already runs it,
717
720
  // the pick is ended and its viewers move to that terminal; the sockets leave first so no
@@ -723,16 +726,37 @@ function createTerminalService(o) {
723
726
  for (const ws of sockets) send(ws, { t: 'rekey', id: picked, duplicate: true });
724
727
  return;
725
728
  }
726
- const elsewhere = o.isLiveElsewhere(picked, s.claudePid);
727
- sessions.delete(s.id);
728
- s.id = picked;
729
- s.mode = 'resume';
730
- sessions.set(picked, s);
731
- changed();
732
- for (const ws of s.sockets) send(ws, { t: 'rekey', id: picked, elsewhere });
729
+ rekey(s, picked);
733
730
  }, PICK_POLL_MS);
734
731
  }
735
732
 
733
+ // /clear and /resume swap the session id under the same claude process. The terminal follows
734
+ // once the new id has a transcript, so the new session's card holds it and the old one can end.
735
+ async function followSession(s, id) {
736
+ if (s.resolving || sessions.has(id)) return;
737
+ if (!(await hasTranscript(s, id)) || s.ended || sessions.has(id)) return;
738
+ rekey(s, id);
739
+ }
740
+
741
+ async function hasTranscript(s, id) {
742
+ s.resolving = true;
743
+ const known = await o.resolveCwd(id);
744
+ s.resolving = false;
745
+ return known !== null && !s.exited;
746
+ }
747
+
748
+ function rekey(s, id) {
749
+ const elsewhere = o.isLiveElsewhere(id, s.claudePid);
750
+ const from = s.id;
751
+ sessions.delete(from);
752
+ s.id = id;
753
+ s.mode = 'resume';
754
+ sessions.set(id, s);
755
+ o.onRekey?.(from, id);
756
+ changed();
757
+ for (const ws of s.sockets) send(ws, { t: 'rekey', id, elsewhere });
758
+ }
759
+
736
760
  function attach(ws, s, attached) {
737
761
  ws.unacked = 0;
738
762
  s.sockets.add(ws);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-code-kanban",
3
- "version": "7.8.0",
3
+ "version": "7.9.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
@@ -8068,6 +8068,9 @@ document.addEventListener('keydown', (e) => {
8068
8068
  // the frame keeps its own ranges and marks them by id. A page whose CSP forbids inline
8069
8069
  // scripts gets no comments.
8070
8070
  const reviewDrafts = new Map();
8071
+ // Text typed but not added, kept across a teardown the user did not ask for (the content
8072
+ // refreshed, a modal opened on top) until its review is unmounted.
8073
+ const reviewUnsent = new Map();
8071
8074
  const reviewHighlight = typeof Highlight === 'function' && CSS.highlights ? new Highlight() : null;
8072
8075
  const reviewHotHighlight = reviewHighlight ? new Highlight() : null;
8073
8076
  const reviewPendingHighlight = reviewHighlight ? new Highlight() : null;
@@ -8124,9 +8127,13 @@ function dropReviewEntry(panelEl) {
8124
8127
 
8125
8128
  function showTopReview() {
8126
8129
  const opts = reviewStack.at(-1) || reviewBase;
8127
- if (activeReview && activeReview.opts === opts) return;
8128
- stopReview();
8129
- if (opts) startReview(opts);
8130
+ if (!activeReview || activeReview.opts !== opts) {
8131
+ stopReview();
8132
+ if (opts) startReview(opts);
8133
+ }
8134
+ if (!reviewUnsent.size) return;
8135
+ const mounted = new Set([...reviewStack, reviewBase].filter(Boolean).map((o) => reviewKey(o.sessionId, o.source)));
8136
+ for (const key of reviewUnsent.keys()) if (!mounted.has(key)) reviewUnsent.delete(key);
8130
8137
  }
8131
8138
 
8132
8139
  function startReview(opts) {
@@ -8160,11 +8167,17 @@ function startReview(opts) {
8160
8167
  document.addEventListener('scroll', closeReviewOffer, { capture: true, signal });
8161
8168
  panelEl.addEventListener('click', onReviewPanelClick, { signal });
8162
8169
  panelEl.addEventListener('mouseover', onReviewPanelHover, { signal });
8170
+ panelEl.addEventListener('dblclick', onReviewPanelDblClick, { signal });
8171
+ panelEl.addEventListener('keydown', onReviewPanelKey, { signal });
8172
+ panelEl.addEventListener('input', onReviewPanelInput, { signal });
8163
8173
  panelEl.addEventListener('mouseleave', () => setReviewHot(null), { signal });
8174
+ if (!frame) anchorReviewItems(contentEl);
8164
8175
  renderReviewPanel();
8176
+ restoreUnsentReview();
8165
8177
  }
8166
8178
 
8167
8179
  function stopReview() {
8180
+ if (activeReview) keepUnsentReview();
8168
8181
  closeReviewPop();
8169
8182
  if (!activeReview) return;
8170
8183
  const { ctl, panelEl, hostEl } = activeReview;
@@ -8250,6 +8263,100 @@ function reviewSelection() {
8250
8263
  return { quote, range: range.cloneRange(), rect: range.getBoundingClientRect() };
8251
8264
  }
8252
8265
 
8266
+ // A re-render replaces the nodes a range points at, so a comment keeps its text and the
8267
+ // 32 chars around it, and is found again after a refresh. Port of scratchpad's cmtFindAnchor.
8268
+ function reviewAnchorOf(root, range) {
8269
+ const before = reviewTextBefore(root, range.startContainer, range.startOffset);
8270
+ const after = root.ownerDocument.createRange();
8271
+ after.selectNodeContents(root);
8272
+ after.setStart(range.endContainer, range.endOffset);
8273
+ return {
8274
+ exact: range.toString(),
8275
+ pos: before.length,
8276
+ prefix: before.slice(-32),
8277
+ suffix: after.toString().slice(0, 32),
8278
+ };
8279
+ }
8280
+
8281
+ // The best match scores the most prefix and suffix chars that match outward from the quote;
8282
+ // a tie goes to the one nearest the old offset. -1 when the quote is gone.
8283
+ function reviewQuoteAt(text, { exact, pos, prefix, suffix }) {
8284
+ let best = -1;
8285
+ let bestScore = -1;
8286
+ if (!exact) return best;
8287
+ for (let at = text.indexOf(exact); at !== -1; at = text.indexOf(exact, at + 1)) {
8288
+ let score = 0;
8289
+ for (let k = 1; k <= prefix.length && text[at - k] === prefix[prefix.length - k]; k++) score++;
8290
+ for (let k = 0; k < suffix.length && text[at + exact.length + k] === suffix[k]; k++) score++;
8291
+ if (score > bestScore || (score === bestScore && Math.abs(at - pos) < Math.abs(best - pos))) {
8292
+ best = at;
8293
+ bestScore = score;
8294
+ }
8295
+ }
8296
+ return best;
8297
+ }
8298
+
8299
+ function reviewTextIndex(root) {
8300
+ const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT);
8301
+ const nodes = [];
8302
+ let text = '';
8303
+ for (let n = walker.nextNode(); n; n = walker.nextNode()) {
8304
+ nodes.push({ n, start: text.length });
8305
+ text += n.nodeValue;
8306
+ }
8307
+ return { nodes, text };
8308
+ }
8309
+
8310
+ function reviewFindAnchor({ nodes, text }, anchor) {
8311
+ const at = reviewQuoteAt(text, anchor);
8312
+ if (at < 0) return null;
8313
+ const end = at + anchor.exact.length;
8314
+ const first = nodes.findLast((x) => x.start <= at);
8315
+ const last = nodes.find((x) => end <= x.start + x.n.nodeValue.length);
8316
+ const range = document.createRange();
8317
+ range.setStart(first.n, at - first.start);
8318
+ range.setEnd(last.n, end - last.start);
8319
+ return { range, at };
8320
+ }
8321
+
8322
+ function anchorReviewItems(root) {
8323
+ const items = reviewItems().filter((c) => c.anchor);
8324
+ if (!items.length) return;
8325
+ const index = reviewTextIndex(root);
8326
+ for (const c of items) {
8327
+ const found = reviewFindAnchor(index, c.anchor);
8328
+ c.range = found?.range || null;
8329
+ if (!found) continue;
8330
+ c.pos = c.anchor.pos = found.at;
8331
+ reviewHighlight?.add(c.range);
8332
+ }
8333
+ }
8334
+
8335
+ function keepUnsentReview() {
8336
+ const { key, picked, editing } = activeReview;
8337
+ const text = reviewPopMode === 'editor' ? reviewPopEl().querySelector('textarea').value : '';
8338
+ if (text.trim()) reviewUnsent.set(key, { picked, text });
8339
+ else if (editing) reviewUnsent.set(key, { editing });
8340
+ }
8341
+
8342
+ function restoreUnsentReview() {
8343
+ const u = reviewUnsent.get(activeReview.key);
8344
+ if (!u) return;
8345
+ reviewUnsent.delete(activeReview.key);
8346
+ if (u.editing) {
8347
+ if (reviewItems().includes(u.editing.c)) startReviewEdit(u.editing.c, u.editing.draft);
8348
+ return;
8349
+ }
8350
+ const { anchor } = u.picked;
8351
+ const range = anchor && reviewFindAnchor(reviewTextIndex(activeReview.contentEl), anchor)?.range;
8352
+ if (range) return openReviewEditor({ ...u.picked, range, rect: range.getBoundingClientRect() }, u.text);
8353
+ const why = anchor ? 'The text you commented on changed' : 'The page reloaded';
8354
+ showToast(`${why}, so the comment was not added`, 'info', {
8355
+ label: 'Copy',
8356
+ onClick: () => copyText(u.text, 'the comment'),
8357
+ });
8358
+ }
8359
+
8253
8360
  function onReviewFrameMessage(e) {
8254
8361
  const frameWin = activeReview?.frame?.contentWindow;
8255
8362
  const type =
@@ -8585,31 +8692,45 @@ function isReviewSendKey(e) {
8585
8692
  return !e.target.closest?.(TEXT_FIELD_SELECTOR);
8586
8693
  }
8587
8694
 
8588
- function openReviewEditor(picked) {
8695
+ function openReviewEditor(picked, text = '') {
8589
8696
  const el = reviewPopEl();
8590
8697
  reviewPopMode = 'editor';
8591
8698
  el.className = 'visible editor';
8592
- if (picked.range) reviewPendingHighlight?.add(picked.range);
8593
- else if (picked.inFrame) postToReviewFrame('pend');
8699
+ if (picked.range) {
8700
+ picked.anchor = reviewAnchorOf(activeReview.contentEl, picked.range);
8701
+ reviewPendingHighlight?.add(picked.range);
8702
+ } else if (picked.inFrame) postToReviewFrame('pend');
8703
+ activeReview.picked = picked;
8594
8704
  el.innerHTML = `<textarea rows="3" placeholder="Comment (Ctrl+Enter to add)"></textarea>
8595
8705
  <div class="review-pop-actions"><button type="button" data-act="cancel">Cancel</button><button type="button" data-act="add" class="primary">Add</button></div>`;
8596
8706
  const ta = el.querySelector('textarea');
8597
- const add = () => {
8598
- const comment = ta.value.trim();
8599
- if (!comment) return ta.focus();
8600
- addReviewComment(picked, comment);
8601
- };
8602
- ta.addEventListener('keydown', (e) => {
8603
- e.stopPropagation();
8604
- if (e.key === 'Escape') closeReviewPop();
8605
- else if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) add();
8606
- });
8707
+ ta.value = text;
8708
+ const add = (comment) => addReviewComment(picked, comment);
8709
+ ta.addEventListener('keydown', (e) => onReviewEditorKey(e, add, closeReviewPop));
8607
8710
  el.querySelector('[data-act="cancel"]').addEventListener('click', closeReviewPop);
8608
- el.querySelector('[data-act="add"]').addEventListener('click', add);
8711
+ el.querySelector('[data-act="add"]').addEventListener('click', () => {
8712
+ const comment = reviewEditorText(ta);
8713
+ if (comment) add(comment);
8714
+ });
8609
8715
  placeReviewPop(el, picked.rect);
8610
8716
  ta.focus();
8611
8717
  }
8612
8718
 
8719
+ function reviewEditorText(ta) {
8720
+ const text = ta.value.trim();
8721
+ if (!text) ta.focus();
8722
+ return text;
8723
+ }
8724
+
8725
+ function onReviewEditorKey(e, save, cancel) {
8726
+ e.stopPropagation();
8727
+ if (e.key === 'Escape') cancel();
8728
+ else if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) {
8729
+ const text = reviewEditorText(e.target);
8730
+ if (text) save(text);
8731
+ }
8732
+ }
8733
+
8613
8734
  function addReviewComment(picked, comment) {
8614
8735
  if (!activeReview) return;
8615
8736
  const { key, contentEl } = activeReview;
@@ -8630,8 +8751,8 @@ function addReviewComment(picked, comment) {
8630
8751
  heading: reviewHeadingBefore(contentEl, range.startContainer),
8631
8752
  ...activeReview.context?.(range),
8632
8753
  };
8633
- const pos = reviewTextBefore(contentEl, range.startContainer, range.startOffset).length;
8634
- item = { quote: picked.quote, comment, context, pos, range, frameId: 0 };
8754
+ const { anchor } = picked;
8755
+ item = { quote: picked.quote, comment, context, pos: anchor.pos, anchor, range, frameId: 0 };
8635
8756
  reviewHighlight?.add(range);
8636
8757
  window.getSelection()?.removeAllRanges();
8637
8758
  }
@@ -8661,6 +8782,7 @@ function removeReviewComment(c) {
8661
8782
  items.splice(i, 1);
8662
8783
  unmarkReviewItem(c);
8663
8784
  if (activeReview.hot === c) setReviewHot(null);
8785
+ if (activeReview.editing?.c === c) activeReview.editing = null;
8664
8786
  if (!items.length) reviewDrafts.delete(activeReview.key);
8665
8787
  renderReviewPanel();
8666
8788
  }
@@ -8668,6 +8790,7 @@ function removeReviewComment(c) {
8668
8790
  function clearReviewDrafts(key = activeReview?.key) {
8669
8791
  for (const c of reviewDrafts.get(key) || []) unmarkReviewItem(c);
8670
8792
  reviewDrafts.delete(key);
8793
+ if (activeReview) activeReview.editing = null;
8671
8794
  setReviewHot(null);
8672
8795
  renderReviewPanel();
8673
8796
  }
@@ -8676,16 +8799,52 @@ function onReviewPanelClick(e) {
8676
8799
  const act = e.target.closest('[data-act]')?.dataset.act;
8677
8800
  if (act === 'send') return sendReview();
8678
8801
  if (act === 'discard') return clearReviewDrafts();
8679
- const card = e.target.closest('.review-card');
8680
- const c = card && reviewItems()[Number(card.dataset.i)];
8681
- if (!c) return;
8802
+ const c = reviewCardItem(e.target);
8803
+ if (!c || e.target.closest('.review-card-edit')) return;
8682
8804
  if (e.target.closest('.review-remove')) removeReviewComment(c);
8805
+ else if (e.target.closest('.review-edit')) startReviewEdit(c);
8683
8806
  else revealReviewItem(c);
8684
8807
  }
8685
8808
 
8809
+ function reviewCardItem(el) {
8810
+ const card = el.closest('.review-card');
8811
+ return (card && reviewItems()[Number(card.dataset.i)]) || null;
8812
+ }
8813
+
8814
+ function onReviewPanelDblClick(e) {
8815
+ const c = reviewCardItem(e.target);
8816
+ if (c && e.target.closest('.review-card-comment')) startReviewEdit(c);
8817
+ }
8818
+
8819
+ // `draft` keeps the typed text when the panel renders again during the edit.
8820
+ function startReviewEdit(c, draft = c.comment) {
8821
+ activeReview.editing = { c, draft };
8822
+ renderReviewPanel();
8823
+ const ta = activeReview.panelEl.querySelector('.review-card-edit');
8824
+ ta.focus();
8825
+ ta.setSelectionRange(ta.value.length, ta.value.length);
8826
+ }
8827
+
8828
+ function endReviewEdit() {
8829
+ activeReview.editing = null;
8830
+ renderReviewPanel();
8831
+ }
8832
+
8833
+ function saveReviewEdit(comment) {
8834
+ activeReview.editing.c.comment = comment;
8835
+ endReviewEdit();
8836
+ }
8837
+
8838
+ function onReviewPanelKey(e) {
8839
+ if (e.target.matches('.review-card-edit')) onReviewEditorKey(e, saveReviewEdit, endReviewEdit);
8840
+ }
8841
+
8842
+ function onReviewPanelInput(e) {
8843
+ if (e.target.matches('.review-card-edit')) activeReview.editing.draft = e.target.value;
8844
+ }
8845
+
8686
8846
  function onReviewPanelHover(e) {
8687
- const card = e.target.closest('.review-card');
8688
- setReviewHot((card && reviewItems()[Number(card.dataset.i)]) || null);
8847
+ setReviewHot(reviewCardItem(e.target));
8689
8848
  }
8690
8849
 
8691
8850
  let reviewSendClaimed = false;
@@ -8712,10 +8871,15 @@ function renderReviewPanel() {
8712
8871
  </div>
8713
8872
  <ol class="review-panel-list">${items
8714
8873
  .map((c, i) => {
8874
+ const body =
8875
+ c === activeReview.editing?.c
8876
+ ? `<textarea class="review-card-edit" rows="3" aria-label="Edit comment" placeholder="Ctrl+Enter to save, Esc to cancel">${escapeHtml(activeReview.editing.draft)}</textarea>`
8877
+ : `<div class="review-card-comment" title="Double-click to edit">${escapeHtml(c.comment)}</div>`;
8715
8878
  return `<li class="review-card${c === activeReview.hot ? ' hot' : ''}" data-i="${i}" title="Show in the document">
8879
+ <button type="button" class="review-edit" title="Edit" aria-label="Edit">${DETAIL_EDIT_SVG}</button>
8716
8880
  <button type="button" class="review-remove" title="Remove" aria-label="Remove">×</button>
8717
- <div class="review-card-quote">${escapeHtml(c.quote.slice(0, 300))}</div>
8718
- <div class="review-card-comment">${escapeHtml(c.comment)}</div></li>`;
8881
+ <div class="review-card-quote">${c.anchor && !c.range ? '<span class="review-orphan" title="The file changed and this text is no longer in it">not found</span>' : ''}${escapeHtml(c.quote.slice(0, 300))}</div>
8882
+ ${body}</li>`;
8719
8883
  })
8720
8884
  .join('')}</ol>`;
8721
8885
  }
@@ -8798,6 +8962,8 @@ async function sendPreviewAction(frame, msg) {
8798
8962
  //#region MARKDOWN_PREVIEW
8799
8963
  const PREVIEW_STORAGE_PREFIX = 'preview-paths-';
8800
8964
  let currentPreviewPath = null;
8965
+ // The session a CLI preview was sent for, which need not be the one on screen.
8966
+ let previewSessionId = null;
8801
8967
  let previewFromTerminal = false;
8802
8968
 
8803
8969
  function readStoredList(key) {
@@ -9019,8 +9185,9 @@ function localImageUrl(src, base) {
9019
9185
  return `/api/preview/image?${new URLSearchParams({ path: p, base })}`;
9020
9186
  }
9021
9187
 
9022
- function openPreviewModal(filePath, content, kind) {
9188
+ function openPreviewModal(filePath, content, kind, sessionId) {
9023
9189
  currentPreviewPath = filePath;
9190
+ previewSessionId = sessionId;
9024
9191
  const fileName = filePath.split(/[\\/]/).pop();
9025
9192
  document.getElementById('preview-modal-title').textContent = fileName;
9026
9193
  const bodyEl = document.getElementById('preview-modal-body');
@@ -9038,7 +9205,13 @@ function openPreviewModal(filePath, content, kind) {
9038
9205
  const panelEl = document.getElementById('preview-review-panel');
9039
9206
  const opts = fileReviewOpts(filePath, kind, bodyEl, isHtml ? bodyEl.querySelector('iframe') : null);
9040
9207
  if (!opts) return unmountReview(panelEl);
9041
- mountReview({ ...opts, panelEl, hostEl: document.querySelector('#preview-modal .modal'), onSent: closePreviewModal });
9208
+ mountReview({
9209
+ ...opts,
9210
+ sessionId,
9211
+ panelEl,
9212
+ hostEl: document.querySelector('#preview-modal .modal'),
9213
+ onSent: closePreviewModal,
9214
+ });
9042
9215
  }
9043
9216
 
9044
9217
  // Null for an image, which takes no comments.
@@ -9058,35 +9231,35 @@ function sourceLineOf(bodyEl, range) {
9058
9231
  return reviewTextBefore(code, range.startContainer, range.startOffset).split('\n').length;
9059
9232
  }
9060
9233
 
9061
- function isPreviewLinkedToCurrentSession() {
9062
- if (!currentPreviewPath || !currentSessionId) return false;
9234
+ function isPreviewLinkedToSession() {
9235
+ if (!currentPreviewPath || !previewSessionId) return false;
9063
9236
  const key = canonicalPath(currentPreviewPath);
9064
- return getSessionPreviewPaths(currentSessionId).some((p) => canonicalPath(p) === key);
9237
+ return getSessionPreviewPaths(previewSessionId).some((p) => canonicalPath(p) === key);
9065
9238
  }
9066
9239
 
9067
9240
  function updatePreviewLinkBtn() {
9068
9241
  const paneBtn = document.getElementById('preview-pane-btn');
9069
- if (paneBtn) paneBtn.style.display = paneSessionId() ? '' : 'none';
9242
+ if (paneBtn) paneBtn.style.display = previewSessionId ? '' : 'none';
9070
9243
  const btn = document.getElementById('preview-link-btn');
9071
9244
  if (!btn) return;
9072
- if (!currentSessionId) {
9245
+ if (!previewSessionId) {
9073
9246
  btn.style.display = 'none';
9074
9247
  return;
9075
9248
  }
9076
9249
  btn.style.display = '';
9077
- const linked = isPreviewLinkedToCurrentSession();
9078
- btn.title = linked ? 'Unlink from current session' : 'Link to current session';
9250
+ const linked = isPreviewLinkedToSession();
9251
+ btn.title = linked ? 'Unlink from the session' : 'Link to the session';
9079
9252
  btn.style.color = linked ? 'var(--accent)' : '';
9080
9253
  }
9081
9254
 
9082
9255
  // biome-ignore lint/correctness/noUnusedVariables: used in HTML
9083
9256
  function togglePreviewSessionLink() {
9084
- if (!currentPreviewPath || !currentSessionId) {
9257
+ if (!currentPreviewPath || !previewSessionId) {
9085
9258
  showToast('Select a session first');
9086
9259
  return;
9087
9260
  }
9088
- const unlink = isPreviewLinkedToCurrentSession();
9089
- setSessionDocLink(currentSessionId, currentPreviewPath, unlink);
9261
+ const unlink = isPreviewLinkedToSession();
9262
+ setSessionDocLink(previewSessionId, currentPreviewPath, unlink);
9090
9263
  showToast(unlink ? 'Unlinked from session' : 'Linked to session');
9091
9264
  }
9092
9265
 
@@ -9113,6 +9286,7 @@ function closePreviewModal() {
9113
9286
  // Empty the body so an iframe preview is destroyed and its scripts/timers stop.
9114
9287
  document.getElementById('preview-modal-body').innerHTML = '';
9115
9288
  currentPreviewPath = null;
9289
+ previewSessionId = null;
9116
9290
  if (previewFromTerminal) focusTerminalPane();
9117
9291
  previewFromTerminal = false;
9118
9292
  }
@@ -9130,7 +9304,8 @@ function openPreviewInEditor() {
9130
9304
  // The server is the only place that knows what it can render, so a null `kind` is its
9131
9305
  // answer to "not previewable" rather than a failure: callers that have a fallback
9132
9306
  // destination pass `onUnsupported` instead of keeping a copy of the extension list here.
9133
- async function openPreviewByPath(filePath, base, onUnsupported) {
9307
+ // A link followed inside an open preview keeps the preview's session.
9308
+ async function openPreviewByPath(filePath, base, onUnsupported, sessionId = previewSessionId || currentSessionId) {
9134
9309
  if (!filePath) return;
9135
9310
  try {
9136
9311
  const r = await api('/api/preview', { query: { path: filePath, base: base || null } });
@@ -9148,7 +9323,7 @@ async function openPreviewByPath(filePath, base, onUnsupported) {
9148
9323
  else showToast('Nothing to preview in this file');
9149
9324
  return;
9150
9325
  }
9151
- openPreviewModal(data.path, data.content, data.kind);
9326
+ openPreviewModal(data.path, data.content, data.kind, sessionId);
9152
9327
  } catch {
9153
9328
  showToast('Failed to load preview', 'error');
9154
9329
  }
@@ -9187,7 +9362,7 @@ async function handlePreviewOpenEvent(data) {
9187
9362
  }
9188
9363
  }
9189
9364
  // The broadcast carries the path only — each tab fetches the document itself.
9190
- openPreviewByPath(filePath);
9365
+ openPreviewByPath(filePath, undefined, undefined, currentSessionId);
9191
9366
  }
9192
9367
 
9193
9368
  // Linked docs live in localStorage, so the CLI can only reach them through a tab:
@@ -13515,7 +13690,9 @@ let paneUpdatedToastTimer = 0;
13515
13690
  async function autoReloadPaneView(sid, pane, view) {
13516
13691
  if (!(await reloadPaneView(sid, pane, view))) return;
13517
13692
  clearTimeout(paneUpdatedToastTimer);
13518
- paneUpdatedToastTimer = setTimeout(() => showToast('Pane updated', 'info'), 1000);
13693
+ paneUpdatedToastTimer = setTimeout(() => {
13694
+ if (!document.getElementById('toast').classList.contains('has-action')) showToast('Pane updated', 'info');
13695
+ }, 1000);
13519
13696
  }
13520
13697
 
13521
13698
  // Builds the new view hidden over the old one and swaps them once it has loaded and scrolled,
@@ -14025,15 +14202,13 @@ function markSourceLine(bodyEl, line) {
14025
14202
  band.scrollIntoView({ block: 'center', inline: 'nearest' });
14026
14203
  }
14027
14204
 
14028
- function openFileInPane(filePath, closeModal) {
14029
- if (!filePath || !paneSessionId()) return;
14030
- closeModal();
14031
- addPane(filePath);
14032
- }
14033
-
14034
14205
  // biome-ignore lint/correctness/noUnusedVariables: used in HTML
14035
14206
  function openPreviewInPane() {
14036
- openFileInPane(currentPreviewPath, closePreviewModal);
14207
+ if (!currentPreviewPath || !previewSessionId) return;
14208
+ const sid = previewSessionId;
14209
+ const target = currentPreviewPath;
14210
+ closePreviewModal();
14211
+ addPaneToSession(sid, target, pathBasename(target));
14037
14212
  }
14038
14213
 
14039
14214
  // The plan modal also opens other sessions' plans; the pane goes to the plan's session, wherever it is.
@@ -14044,15 +14219,19 @@ function syncPlanPaneBtn() {
14044
14219
  }
14045
14220
 
14046
14221
  // biome-ignore lint/correctness/noUnusedVariables: used in HTML
14047
- async function openPlanInPane() {
14222
+ function openPlanInPane() {
14048
14223
  if (!planPaneAllowed()) return;
14049
14224
  const sid = _planSessionId;
14050
14225
  const target = _pendingPlanPath;
14051
14226
  closePlanModal();
14227
+ addPaneToSession(sid, target, 'Plan');
14228
+ }
14229
+
14230
+ async function addPaneToSession(sid, target, label) {
14052
14231
  if (sid === paneSessionId()) return addPane(target);
14053
14232
  if ((await postPane(sid, { target, base: getSessionBaseDir(sid) || undefined })) !== false) return;
14054
14233
  const s = sessions.find((x) => x.id === sid);
14055
- showToast(`Plan added to ${s ? sessionDisplayName(s) : 'its session'}`, 'success', {
14234
+ showToast(`${label} added to ${s ? sessionDisplayName(s) : 'its session'}`, 'success', {
14056
14235
  label: 'Open',
14057
14236
  onClick: () => openSession(sid),
14058
14237
  });
package/public/index.html CHANGED
@@ -477,7 +477,7 @@
477
477
  <button id="preview-pane-btn" class="icon-btn" aria-label="Open in a pane" title="Open in a pane" onclick="openPreviewInPane()">
478
478
  <svg fill="none" stroke="currentColor" stroke-width="2" width="16" height="16"><use href="#icon-pane"/></svg>
479
479
  </button>
480
- <button id="preview-link-btn" class="icon-btn" aria-label="Link to current session" title="Link to current session" onclick="togglePreviewSessionLink()">
480
+ <button id="preview-link-btn" class="icon-btn" aria-label="Link to the session" title="Link to the session" onclick="togglePreviewSessionLink()">
481
481
  <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="16" height="16"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/></svg>
482
482
  </button>
483
483
  <button class="icon-btn" aria-label="Open in editor" title="Open in editor" onclick="openPreviewInEditor()">
package/public/style.css CHANGED
@@ -8446,7 +8446,8 @@ select.form-input option:checked {
8446
8446
  box-shadow: 0 6px 20px rgba(0, 0, 0, 0.3);
8447
8447
  }
8448
8448
 
8449
- #review-pop textarea {
8449
+ #review-pop textarea,
8450
+ .review-card-edit {
8450
8451
  width: 100%;
8451
8452
  box-sizing: border-box;
8452
8453
  margin: 0 0 6px;
@@ -8575,7 +8576,8 @@ select.form-input option:checked {
8575
8576
  }
8576
8577
 
8577
8578
  .review-panel button.review-discard,
8578
- .review-panel button.review-remove {
8579
+ .review-panel button.review-remove,
8580
+ .review-panel button.review-edit {
8579
8581
  padding: 3px 4px;
8580
8582
  color: var(--text-muted);
8581
8583
  background: none;
@@ -8590,7 +8592,7 @@ select.form-input option:checked {
8590
8592
 
8591
8593
  .review-card {
8592
8594
  position: relative;
8593
- padding: 8px 24px 10px 0;
8595
+ padding: 8px 44px 10px 0;
8594
8596
  cursor: pointer;
8595
8597
  }
8596
8598
 
@@ -8617,12 +8619,35 @@ select.form-input option:checked {
8617
8619
  word-break: break-word;
8618
8620
  }
8619
8621
 
8620
- .review-panel button.review-remove {
8622
+ .review-panel button.review-remove,
8623
+ .review-panel button.review-edit {
8621
8624
  position: absolute;
8622
8625
  top: 4px;
8623
8626
  right: 4px;
8624
8627
  padding: 0 6px;
8625
8628
  }
8629
+
8630
+ .review-panel button.review-edit {
8631
+ right: 24px;
8632
+ }
8633
+
8634
+ .review-edit svg {
8635
+ width: 11px;
8636
+ height: 11px;
8637
+ }
8638
+
8639
+ .review-card-edit {
8640
+ border-color: var(--accent);
8641
+ }
8642
+
8643
+ .review-orphan {
8644
+ margin-right: 6px;
8645
+ padding: 0 4px;
8646
+ font-size: 10px;
8647
+ color: var(--warning);
8648
+ border: 1px solid var(--warning-dim);
8649
+ border-radius: 3px;
8650
+ }
8626
8651
  /* #endregion */
8627
8652
 
8628
8653
  /* #region SESSION_PICKER */
package/server.js CHANGED
@@ -50,7 +50,7 @@ const { readTerminalConfig } = require('./lib/terminal');
50
50
  const { createTerminalClient } = require('./lib/terminal-client');
51
51
  const { kanbotOn, kanbotModel, createKanbot } = require('./lib/kanbot');
52
52
  const { createShowStore, mountShowRoutes, showBodyParser, SHOW_PATH } = require('./lib/show');
53
- const { readLiveSessions, isPidAlive } = require('./lib/live-sessions');
53
+ const { readLiveSessions, isPidAlive, noteReplacedSessions } = require('./lib/live-sessions');
54
54
  const { createProcStats } = require('./lib/proc-stats');
55
55
  const { createUserGroupStore } = require('./lib/user-groups');
56
56
  const { createSessionPinStore } = require('./lib/session-pins');
@@ -546,12 +546,15 @@ const SELF_TEAM_BOOT_WINDOW_MS = 60 * 1000;
546
546
  let liveSessionsCache = null;
547
547
  let lastLiveSessionsScan = 0;
548
548
  const LIVE_SESSIONS_TTL = 5000;
549
+ const replacedSessions = new Map();
549
550
 
550
551
  function loadLiveSessions() {
551
552
  const now = Date.now();
552
553
  if (liveSessionsCache && now - lastLiveSessionsScan < LIVE_SESSIONS_TTL) return liveSessionsCache;
554
+ const prev = liveSessionsCache;
553
555
  liveSessionsCache = readLiveSessions(SESSIONS_DIR);
554
556
  lastLiveSessionsScan = now;
557
+ noteReplacedSessions(prev, liveSessionsCache, replacedSessions, now, SESSION_STALE_MS);
555
558
  return liveSessionsCache;
556
559
  }
557
560
 
@@ -572,13 +575,14 @@ function getPeerName(sessionId) {
572
575
  }
573
576
 
574
577
  function hasRecentLogActivity(sessionId, logAge) {
575
- return logAge <= SESSION_STALE_MS && !isRegistryIdle(sessionId);
578
+ return logAge <= SESSION_STALE_MS && !isRegistryIdle(sessionId) && !replacedSessions.has(sessionId);
576
579
  }
577
580
 
578
581
  // Visibility-only recency: hasRecentLogActivity widened by the post-turn grace
579
582
  // window (ungated by registry-idle). Drives whether a session appears in the
580
583
  // active list — never the "active" status badges, which stay on hasRecentLog.
581
584
  function hasVisibleLogActivity(sessionId, logAge) {
585
+ if (replacedSessions.has(sessionId)) return false;
582
586
  return logAge <= SESSION_GRACE_MS || hasRecentLogActivity(sessionId, logAge);
583
587
  }
584
588
 
@@ -3348,6 +3352,9 @@ const terminal = createTerminalClient({
3348
3352
  onExit: (id) => {
3349
3353
  if (dispatched.get(id)) broadcast({ type: 'dispatch-update' });
3350
3354
  },
3355
+ onRekey: (from, to) => {
3356
+ if (dispatched.move(from, to)) broadcast({ type: 'dispatch-update' });
3357
+ },
3351
3358
  });
3352
3359
 
3353
3360
  // A call to the terminal host fails with 503 while the host restarts.