claude-code-kanban 7.8.0 → 7.10.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
  }
package/hub-app.json CHANGED
@@ -5,7 +5,7 @@
5
5
  "icon": "columns",
6
6
  "run": { "entry": "server.js", "defaultPort": 3541 },
7
7
  "publishes": ["session.changed"],
8
- "keys": { "keeps": ["ctrl+alt+n", "ctrl+alt+r", "ctrl+alt+s", "ctrl+shift+k", "ctrl+shift+p", "ctrl+shift+z"] },
8
+ "keys": { "keeps": ["ctrl+alt+n", "ctrl+alt+r", "ctrl+alt+s", "ctrl+shift+d", "ctrl+shift+k", "ctrl+shift+p", "ctrl+shift+z"] },
9
9
  "plugin": {
10
10
  "marketplace": "plugin",
11
11
  "name": "claude-code-kanban",
@@ -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.10.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
@@ -2565,20 +2565,28 @@ function unmountMsgReview() {
2565
2565
 
2566
2566
  // Only the main session's replies: the agent has them in context, so a quote is enough.
2567
2567
  function mountReplyReview(m, body, modal) {
2568
- if (m.type !== 'assistant' || agentLogMode || !m.timestamp) return unmountMsgReview();
2568
+ const opts = !agentLogMode && replyReviewOpts(m, body, currentSessionId);
2569
+ if (!opts) return unmountMsgReview();
2569
2570
  mountReview({
2570
- contentEl: body,
2571
+ ...opts,
2571
2572
  panelEl: document.getElementById('msg-review-panel'),
2572
2573
  hostEl: modal,
2574
+ onComment: () => setMsgDetailFollow(false),
2575
+ onSent: closeMsgDetailModal,
2576
+ });
2577
+ }
2578
+
2579
+ function replyReviewOpts(m, contentEl, sid) {
2580
+ if (m.type !== 'assistant' || !m.timestamp) return null;
2581
+ return {
2582
+ contentEl,
2573
2583
  source: {
2574
2584
  kind: 'reply',
2575
2585
  id: m.timestamp,
2576
2586
  label: replyReviewLabel(m.timestamp),
2577
- locate: replyLocateHint(m.timestamp),
2587
+ locate: replyLocateHint(m.timestamp, sid),
2578
2588
  },
2579
- onComment: () => setMsgDetailFollow(false),
2580
- onSent: closeMsgDetailModal,
2581
- });
2589
+ };
2582
2590
  }
2583
2591
 
2584
2592
  function replyReviewLabel(timestamp) {
@@ -2590,8 +2598,8 @@ function replyReviewLabel(timestamp) {
2590
2598
  return `your reply${day} at ${d.toLocaleTimeString()}`;
2591
2599
  }
2592
2600
 
2593
- function replyLocateHint(timestamp) {
2594
- const transcript = sessions.find((s) => s.id === currentSessionId)?.jsonlPath;
2601
+ function replyLocateHint(timestamp, sid) {
2602
+ const transcript = sessions.find((s) => s.id === sid)?.jsonlPath;
2595
2603
  if (!transcript) return undefined;
2596
2604
  return `If that reply is not in your context, it is in ${transcript}, the assistant entry with timestamp ${timestamp}.`;
2597
2605
  }
@@ -7610,6 +7618,17 @@ async function dismissSession(sid) {
7610
7618
  if (await confirmDismissBlockers(sid)) dismissSessionFromList(sid);
7611
7619
  }
7612
7620
 
7621
+ function contextSessionId() {
7622
+ if (terminalPaneFocused()) return termState.sessionId || currentSessionId;
7623
+ if (focusZone !== 'sidebar') return selectedSessionId || currentSessionId;
7624
+ return sessionsList.querySelector('.kb-selected')?.dataset.sessionId || currentSessionId;
7625
+ }
7626
+
7627
+ function dismissContextSession() {
7628
+ const sid = contextSessionId();
7629
+ if (sid) dismissSession(sid);
7630
+ }
7631
+
7613
7632
  const MODAL_ESC_PRIORITY = [
7614
7633
  'preview-modal',
7615
7634
  'msg-detail-modal',
@@ -7968,9 +7987,7 @@ document.addEventListener('keydown', (e) => {
7968
7987
  }
7969
7988
 
7970
7989
  // Shared actions — work in both sidebar and board
7971
- const cursorEl = focusZone === 'sidebar' ? sessionsList.querySelector('.kb-selected') : null;
7972
- const contextSid =
7973
- focusZone === 'sidebar' ? cursorEl?.dataset.sessionId || currentSessionId : selectedSessionId || currentSessionId;
7990
+ const contextSid = contextSessionId();
7974
7991
  if (matchKey(e, 'KeyP')) {
7975
7992
  e.preventDefault();
7976
7993
  if (contextSid) openPlanForSession(contextSid);
@@ -7983,7 +8000,7 @@ document.addEventListener('keydown', (e) => {
7983
8000
  }
7984
8001
  if (matchKey(e, 'KeyN')) {
7985
8002
  e.preventDefault();
7986
- const cursorKey = _scratchpadKeyOf(cursorEl);
8003
+ const cursorKey = _scratchpadKeyOf(focusZone === 'sidebar' ? sessionsList.querySelector('.kb-selected') : null);
7987
8004
  if (cursorKey) showScratchpad(cursorKey);
7988
8005
  else toggleScratchpad();
7989
8006
  return;
@@ -8007,8 +8024,7 @@ document.addEventListener('keydown', (e) => {
8007
8024
  }
8008
8025
  if (ClaudeHub.comboOf(e) === 'ctrl+d') {
8009
8026
  e.preventDefault();
8010
- if (!contextSid) return;
8011
- dismissSession(contextSid);
8027
+ dismissContextSession();
8012
8028
  return;
8013
8029
  }
8014
8030
  if (e.code === 'KeyC' && e.shiftKey && !e.altKey && !e.metaKey) {
@@ -8068,6 +8084,9 @@ document.addEventListener('keydown', (e) => {
8068
8084
  // the frame keeps its own ranges and marks them by id. A page whose CSP forbids inline
8069
8085
  // scripts gets no comments.
8070
8086
  const reviewDrafts = new Map();
8087
+ // Text typed but not added, kept across a teardown the user did not ask for (the content
8088
+ // refreshed, a modal opened on top) until its review is unmounted.
8089
+ const reviewUnsent = new Map();
8071
8090
  const reviewHighlight = typeof Highlight === 'function' && CSS.highlights ? new Highlight() : null;
8072
8091
  const reviewHotHighlight = reviewHighlight ? new Highlight() : null;
8073
8092
  const reviewPendingHighlight = reviewHighlight ? new Highlight() : null;
@@ -8124,9 +8143,13 @@ function dropReviewEntry(panelEl) {
8124
8143
 
8125
8144
  function showTopReview() {
8126
8145
  const opts = reviewStack.at(-1) || reviewBase;
8127
- if (activeReview && activeReview.opts === opts) return;
8128
- stopReview();
8129
- if (opts) startReview(opts);
8146
+ if (!activeReview || activeReview.opts !== opts) {
8147
+ stopReview();
8148
+ if (opts) startReview(opts);
8149
+ }
8150
+ if (!reviewUnsent.size) return;
8151
+ const mounted = new Set([...reviewStack, reviewBase].filter(Boolean).map((o) => reviewKey(o.sessionId, o.source)));
8152
+ for (const key of reviewUnsent.keys()) if (!mounted.has(key)) reviewUnsent.delete(key);
8130
8153
  }
8131
8154
 
8132
8155
  function startReview(opts) {
@@ -8160,11 +8183,17 @@ function startReview(opts) {
8160
8183
  document.addEventListener('scroll', closeReviewOffer, { capture: true, signal });
8161
8184
  panelEl.addEventListener('click', onReviewPanelClick, { signal });
8162
8185
  panelEl.addEventListener('mouseover', onReviewPanelHover, { signal });
8186
+ panelEl.addEventListener('dblclick', onReviewPanelDblClick, { signal });
8187
+ panelEl.addEventListener('keydown', onReviewPanelKey, { signal });
8188
+ panelEl.addEventListener('input', onReviewPanelInput, { signal });
8163
8189
  panelEl.addEventListener('mouseleave', () => setReviewHot(null), { signal });
8190
+ if (!frame) anchorReviewItems(contentEl);
8164
8191
  renderReviewPanel();
8192
+ restoreUnsentReview();
8165
8193
  }
8166
8194
 
8167
8195
  function stopReview() {
8196
+ if (activeReview) keepUnsentReview();
8168
8197
  closeReviewPop();
8169
8198
  if (!activeReview) return;
8170
8199
  const { ctl, panelEl, hostEl } = activeReview;
@@ -8250,6 +8279,100 @@ function reviewSelection() {
8250
8279
  return { quote, range: range.cloneRange(), rect: range.getBoundingClientRect() };
8251
8280
  }
8252
8281
 
8282
+ // A re-render replaces the nodes a range points at, so a comment keeps its text and the
8283
+ // 32 chars around it, and is found again after a refresh. Port of scratchpad's cmtFindAnchor.
8284
+ function reviewAnchorOf(root, range) {
8285
+ const before = reviewTextBefore(root, range.startContainer, range.startOffset);
8286
+ const after = root.ownerDocument.createRange();
8287
+ after.selectNodeContents(root);
8288
+ after.setStart(range.endContainer, range.endOffset);
8289
+ return {
8290
+ exact: range.toString(),
8291
+ pos: before.length,
8292
+ prefix: before.slice(-32),
8293
+ suffix: after.toString().slice(0, 32),
8294
+ };
8295
+ }
8296
+
8297
+ // The best match scores the most prefix and suffix chars that match outward from the quote;
8298
+ // a tie goes to the one nearest the old offset. -1 when the quote is gone.
8299
+ function reviewQuoteAt(text, { exact, pos, prefix, suffix }) {
8300
+ let best = -1;
8301
+ let bestScore = -1;
8302
+ if (!exact) return best;
8303
+ for (let at = text.indexOf(exact); at !== -1; at = text.indexOf(exact, at + 1)) {
8304
+ let score = 0;
8305
+ for (let k = 1; k <= prefix.length && text[at - k] === prefix[prefix.length - k]; k++) score++;
8306
+ for (let k = 0; k < suffix.length && text[at + exact.length + k] === suffix[k]; k++) score++;
8307
+ if (score > bestScore || (score === bestScore && Math.abs(at - pos) < Math.abs(best - pos))) {
8308
+ best = at;
8309
+ bestScore = score;
8310
+ }
8311
+ }
8312
+ return best;
8313
+ }
8314
+
8315
+ function reviewTextIndex(root) {
8316
+ const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT);
8317
+ const nodes = [];
8318
+ let text = '';
8319
+ for (let n = walker.nextNode(); n; n = walker.nextNode()) {
8320
+ nodes.push({ n, start: text.length });
8321
+ text += n.nodeValue;
8322
+ }
8323
+ return { nodes, text };
8324
+ }
8325
+
8326
+ function reviewFindAnchor({ nodes, text }, anchor) {
8327
+ const at = reviewQuoteAt(text, anchor);
8328
+ if (at < 0) return null;
8329
+ const end = at + anchor.exact.length;
8330
+ const first = nodes.findLast((x) => x.start <= at);
8331
+ const last = nodes.find((x) => end <= x.start + x.n.nodeValue.length);
8332
+ const range = document.createRange();
8333
+ range.setStart(first.n, at - first.start);
8334
+ range.setEnd(last.n, end - last.start);
8335
+ return { range, at };
8336
+ }
8337
+
8338
+ function anchorReviewItems(root) {
8339
+ const items = reviewItems().filter((c) => c.anchor);
8340
+ if (!items.length) return;
8341
+ const index = reviewTextIndex(root);
8342
+ for (const c of items) {
8343
+ const found = reviewFindAnchor(index, c.anchor);
8344
+ c.range = found?.range || null;
8345
+ if (!found) continue;
8346
+ c.pos = c.anchor.pos = found.at;
8347
+ reviewHighlight?.add(c.range);
8348
+ }
8349
+ }
8350
+
8351
+ function keepUnsentReview() {
8352
+ const { key, picked, editing } = activeReview;
8353
+ const text = reviewPopMode === 'editor' ? reviewPopEl().querySelector('textarea').value : '';
8354
+ if (text.trim()) reviewUnsent.set(key, { picked, text });
8355
+ else if (editing) reviewUnsent.set(key, { editing });
8356
+ }
8357
+
8358
+ function restoreUnsentReview() {
8359
+ const u = reviewUnsent.get(activeReview.key);
8360
+ if (!u) return;
8361
+ reviewUnsent.delete(activeReview.key);
8362
+ if (u.editing) {
8363
+ if (reviewItems().includes(u.editing.c)) startReviewEdit(u.editing.c, u.editing.draft);
8364
+ return;
8365
+ }
8366
+ const { anchor } = u.picked;
8367
+ const range = anchor && reviewFindAnchor(reviewTextIndex(activeReview.contentEl), anchor)?.range;
8368
+ if (range) return openReviewEditor({ ...u.picked, range, rect: range.getBoundingClientRect() }, u.text);
8369
+ const why = anchor ? 'The text you commented on changed' : 'The page reloaded';
8370
+ showToast(`${why}, so the comment was not added`, 'info', {
8371
+ label: 'Copy',
8372
+ onClick: () => copyText(u.text, 'the comment'),
8373
+ });
8374
+ }
8375
+
8253
8376
  function onReviewFrameMessage(e) {
8254
8377
  const frameWin = activeReview?.frame?.contentWindow;
8255
8378
  const type =
@@ -8585,31 +8708,45 @@ function isReviewSendKey(e) {
8585
8708
  return !e.target.closest?.(TEXT_FIELD_SELECTOR);
8586
8709
  }
8587
8710
 
8588
- function openReviewEditor(picked) {
8711
+ function openReviewEditor(picked, text = '') {
8589
8712
  const el = reviewPopEl();
8590
8713
  reviewPopMode = 'editor';
8591
8714
  el.className = 'visible editor';
8592
- if (picked.range) reviewPendingHighlight?.add(picked.range);
8593
- else if (picked.inFrame) postToReviewFrame('pend');
8715
+ if (picked.range) {
8716
+ picked.anchor = reviewAnchorOf(activeReview.contentEl, picked.range);
8717
+ reviewPendingHighlight?.add(picked.range);
8718
+ } else if (picked.inFrame) postToReviewFrame('pend');
8719
+ activeReview.picked = picked;
8594
8720
  el.innerHTML = `<textarea rows="3" placeholder="Comment (Ctrl+Enter to add)"></textarea>
8595
8721
  <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
8722
  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
- });
8723
+ ta.value = text;
8724
+ const add = (comment) => addReviewComment(picked, comment);
8725
+ ta.addEventListener('keydown', (e) => onReviewEditorKey(e, add, closeReviewPop));
8607
8726
  el.querySelector('[data-act="cancel"]').addEventListener('click', closeReviewPop);
8608
- el.querySelector('[data-act="add"]').addEventListener('click', add);
8727
+ el.querySelector('[data-act="add"]').addEventListener('click', () => {
8728
+ const comment = reviewEditorText(ta);
8729
+ if (comment) add(comment);
8730
+ });
8609
8731
  placeReviewPop(el, picked.rect);
8610
8732
  ta.focus();
8611
8733
  }
8612
8734
 
8735
+ function reviewEditorText(ta) {
8736
+ const text = ta.value.trim();
8737
+ if (!text) ta.focus();
8738
+ return text;
8739
+ }
8740
+
8741
+ function onReviewEditorKey(e, save, cancel) {
8742
+ e.stopPropagation();
8743
+ if (e.key === 'Escape') cancel();
8744
+ else if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) {
8745
+ const text = reviewEditorText(e.target);
8746
+ if (text) save(text);
8747
+ }
8748
+ }
8749
+
8613
8750
  function addReviewComment(picked, comment) {
8614
8751
  if (!activeReview) return;
8615
8752
  const { key, contentEl } = activeReview;
@@ -8630,8 +8767,8 @@ function addReviewComment(picked, comment) {
8630
8767
  heading: reviewHeadingBefore(contentEl, range.startContainer),
8631
8768
  ...activeReview.context?.(range),
8632
8769
  };
8633
- const pos = reviewTextBefore(contentEl, range.startContainer, range.startOffset).length;
8634
- item = { quote: picked.quote, comment, context, pos, range, frameId: 0 };
8770
+ const { anchor } = picked;
8771
+ item = { quote: picked.quote, comment, context, pos: anchor.pos, anchor, range, frameId: 0 };
8635
8772
  reviewHighlight?.add(range);
8636
8773
  window.getSelection()?.removeAllRanges();
8637
8774
  }
@@ -8661,6 +8798,7 @@ function removeReviewComment(c) {
8661
8798
  items.splice(i, 1);
8662
8799
  unmarkReviewItem(c);
8663
8800
  if (activeReview.hot === c) setReviewHot(null);
8801
+ if (activeReview.editing?.c === c) activeReview.editing = null;
8664
8802
  if (!items.length) reviewDrafts.delete(activeReview.key);
8665
8803
  renderReviewPanel();
8666
8804
  }
@@ -8668,6 +8806,7 @@ function removeReviewComment(c) {
8668
8806
  function clearReviewDrafts(key = activeReview?.key) {
8669
8807
  for (const c of reviewDrafts.get(key) || []) unmarkReviewItem(c);
8670
8808
  reviewDrafts.delete(key);
8809
+ if (activeReview) activeReview.editing = null;
8671
8810
  setReviewHot(null);
8672
8811
  renderReviewPanel();
8673
8812
  }
@@ -8676,16 +8815,52 @@ function onReviewPanelClick(e) {
8676
8815
  const act = e.target.closest('[data-act]')?.dataset.act;
8677
8816
  if (act === 'send') return sendReview();
8678
8817
  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;
8818
+ const c = reviewCardItem(e.target);
8819
+ if (!c || e.target.closest('.review-card-edit')) return;
8682
8820
  if (e.target.closest('.review-remove')) removeReviewComment(c);
8821
+ else if (e.target.closest('.review-edit')) startReviewEdit(c);
8683
8822
  else revealReviewItem(c);
8684
8823
  }
8685
8824
 
8825
+ function reviewCardItem(el) {
8826
+ const card = el.closest('.review-card');
8827
+ return (card && reviewItems()[Number(card.dataset.i)]) || null;
8828
+ }
8829
+
8830
+ function onReviewPanelDblClick(e) {
8831
+ const c = reviewCardItem(e.target);
8832
+ if (c && e.target.closest('.review-card-comment')) startReviewEdit(c);
8833
+ }
8834
+
8835
+ // `draft` keeps the typed text when the panel renders again during the edit.
8836
+ function startReviewEdit(c, draft = c.comment) {
8837
+ activeReview.editing = { c, draft };
8838
+ renderReviewPanel();
8839
+ const ta = activeReview.panelEl.querySelector('.review-card-edit');
8840
+ ta.focus();
8841
+ ta.setSelectionRange(ta.value.length, ta.value.length);
8842
+ }
8843
+
8844
+ function endReviewEdit() {
8845
+ activeReview.editing = null;
8846
+ renderReviewPanel();
8847
+ }
8848
+
8849
+ function saveReviewEdit(comment) {
8850
+ activeReview.editing.c.comment = comment;
8851
+ endReviewEdit();
8852
+ }
8853
+
8854
+ function onReviewPanelKey(e) {
8855
+ if (e.target.matches('.review-card-edit')) onReviewEditorKey(e, saveReviewEdit, endReviewEdit);
8856
+ }
8857
+
8858
+ function onReviewPanelInput(e) {
8859
+ if (e.target.matches('.review-card-edit')) activeReview.editing.draft = e.target.value;
8860
+ }
8861
+
8686
8862
  function onReviewPanelHover(e) {
8687
- const card = e.target.closest('.review-card');
8688
- setReviewHot((card && reviewItems()[Number(card.dataset.i)]) || null);
8863
+ setReviewHot(reviewCardItem(e.target));
8689
8864
  }
8690
8865
 
8691
8866
  let reviewSendClaimed = false;
@@ -8712,10 +8887,15 @@ function renderReviewPanel() {
8712
8887
  </div>
8713
8888
  <ol class="review-panel-list">${items
8714
8889
  .map((c, i) => {
8890
+ const body =
8891
+ c === activeReview.editing?.c
8892
+ ? `<textarea class="review-card-edit" rows="3" aria-label="Edit comment" placeholder="Ctrl+Enter to save, Esc to cancel">${escapeHtml(activeReview.editing.draft)}</textarea>`
8893
+ : `<div class="review-card-comment" title="Double-click to edit">${escapeHtml(c.comment)}</div>`;
8715
8894
  return `<li class="review-card${c === activeReview.hot ? ' hot' : ''}" data-i="${i}" title="Show in the document">
8895
+ <button type="button" class="review-edit" title="Edit" aria-label="Edit">${DETAIL_EDIT_SVG}</button>
8716
8896
  <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>`;
8897
+ <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>
8898
+ ${body}</li>`;
8719
8899
  })
8720
8900
  .join('')}</ol>`;
8721
8901
  }
@@ -8798,6 +8978,8 @@ async function sendPreviewAction(frame, msg) {
8798
8978
  //#region MARKDOWN_PREVIEW
8799
8979
  const PREVIEW_STORAGE_PREFIX = 'preview-paths-';
8800
8980
  let currentPreviewPath = null;
8981
+ // The session a CLI preview was sent for, which need not be the one on screen.
8982
+ let previewSessionId = null;
8801
8983
  let previewFromTerminal = false;
8802
8984
 
8803
8985
  function readStoredList(key) {
@@ -9019,8 +9201,9 @@ function localImageUrl(src, base) {
9019
9201
  return `/api/preview/image?${new URLSearchParams({ path: p, base })}`;
9020
9202
  }
9021
9203
 
9022
- function openPreviewModal(filePath, content, kind) {
9204
+ function openPreviewModal(filePath, content, kind, sessionId) {
9023
9205
  currentPreviewPath = filePath;
9206
+ previewSessionId = sessionId;
9024
9207
  const fileName = filePath.split(/[\\/]/).pop();
9025
9208
  document.getElementById('preview-modal-title').textContent = fileName;
9026
9209
  const bodyEl = document.getElementById('preview-modal-body');
@@ -9038,7 +9221,13 @@ function openPreviewModal(filePath, content, kind) {
9038
9221
  const panelEl = document.getElementById('preview-review-panel');
9039
9222
  const opts = fileReviewOpts(filePath, kind, bodyEl, isHtml ? bodyEl.querySelector('iframe') : null);
9040
9223
  if (!opts) return unmountReview(panelEl);
9041
- mountReview({ ...opts, panelEl, hostEl: document.querySelector('#preview-modal .modal'), onSent: closePreviewModal });
9224
+ mountReview({
9225
+ ...opts,
9226
+ sessionId,
9227
+ panelEl,
9228
+ hostEl: document.querySelector('#preview-modal .modal'),
9229
+ onSent: closePreviewModal,
9230
+ });
9042
9231
  }
9043
9232
 
9044
9233
  // Null for an image, which takes no comments.
@@ -9058,35 +9247,35 @@ function sourceLineOf(bodyEl, range) {
9058
9247
  return reviewTextBefore(code, range.startContainer, range.startOffset).split('\n').length;
9059
9248
  }
9060
9249
 
9061
- function isPreviewLinkedToCurrentSession() {
9062
- if (!currentPreviewPath || !currentSessionId) return false;
9250
+ function isPreviewLinkedToSession() {
9251
+ if (!currentPreviewPath || !previewSessionId) return false;
9063
9252
  const key = canonicalPath(currentPreviewPath);
9064
- return getSessionPreviewPaths(currentSessionId).some((p) => canonicalPath(p) === key);
9253
+ return getSessionPreviewPaths(previewSessionId).some((p) => canonicalPath(p) === key);
9065
9254
  }
9066
9255
 
9067
9256
  function updatePreviewLinkBtn() {
9068
9257
  const paneBtn = document.getElementById('preview-pane-btn');
9069
- if (paneBtn) paneBtn.style.display = paneSessionId() ? '' : 'none';
9258
+ if (paneBtn) paneBtn.style.display = previewSessionId ? '' : 'none';
9070
9259
  const btn = document.getElementById('preview-link-btn');
9071
9260
  if (!btn) return;
9072
- if (!currentSessionId) {
9261
+ if (!previewSessionId) {
9073
9262
  btn.style.display = 'none';
9074
9263
  return;
9075
9264
  }
9076
9265
  btn.style.display = '';
9077
- const linked = isPreviewLinkedToCurrentSession();
9078
- btn.title = linked ? 'Unlink from current session' : 'Link to current session';
9266
+ const linked = isPreviewLinkedToSession();
9267
+ btn.title = linked ? 'Unlink from the session' : 'Link to the session';
9079
9268
  btn.style.color = linked ? 'var(--accent)' : '';
9080
9269
  }
9081
9270
 
9082
9271
  // biome-ignore lint/correctness/noUnusedVariables: used in HTML
9083
9272
  function togglePreviewSessionLink() {
9084
- if (!currentPreviewPath || !currentSessionId) {
9273
+ if (!currentPreviewPath || !previewSessionId) {
9085
9274
  showToast('Select a session first');
9086
9275
  return;
9087
9276
  }
9088
- const unlink = isPreviewLinkedToCurrentSession();
9089
- setSessionDocLink(currentSessionId, currentPreviewPath, unlink);
9277
+ const unlink = isPreviewLinkedToSession();
9278
+ setSessionDocLink(previewSessionId, currentPreviewPath, unlink);
9090
9279
  showToast(unlink ? 'Unlinked from session' : 'Linked to session');
9091
9280
  }
9092
9281
 
@@ -9113,6 +9302,7 @@ function closePreviewModal() {
9113
9302
  // Empty the body so an iframe preview is destroyed and its scripts/timers stop.
9114
9303
  document.getElementById('preview-modal-body').innerHTML = '';
9115
9304
  currentPreviewPath = null;
9305
+ previewSessionId = null;
9116
9306
  if (previewFromTerminal) focusTerminalPane();
9117
9307
  previewFromTerminal = false;
9118
9308
  }
@@ -9130,7 +9320,8 @@ function openPreviewInEditor() {
9130
9320
  // The server is the only place that knows what it can render, so a null `kind` is its
9131
9321
  // answer to "not previewable" rather than a failure: callers that have a fallback
9132
9322
  // destination pass `onUnsupported` instead of keeping a copy of the extension list here.
9133
- async function openPreviewByPath(filePath, base, onUnsupported) {
9323
+ // A link followed inside an open preview keeps the preview's session.
9324
+ async function openPreviewByPath(filePath, base, onUnsupported, sessionId = previewSessionId || currentSessionId) {
9134
9325
  if (!filePath) return;
9135
9326
  try {
9136
9327
  const r = await api('/api/preview', { query: { path: filePath, base: base || null } });
@@ -9148,7 +9339,7 @@ async function openPreviewByPath(filePath, base, onUnsupported) {
9148
9339
  else showToast('Nothing to preview in this file');
9149
9340
  return;
9150
9341
  }
9151
- openPreviewModal(data.path, data.content, data.kind);
9342
+ openPreviewModal(data.path, data.content, data.kind, sessionId);
9152
9343
  } catch {
9153
9344
  showToast('Failed to load preview', 'error');
9154
9345
  }
@@ -9187,7 +9378,7 @@ async function handlePreviewOpenEvent(data) {
9187
9378
  }
9188
9379
  }
9189
9380
  // The broadcast carries the path only — each tab fetches the document itself.
9190
- openPreviewByPath(filePath);
9381
+ openPreviewByPath(filePath, undefined, undefined, currentSessionId);
9191
9382
  }
9192
9383
 
9193
9384
  // Linked docs live in localStorage, so the CLI can only reach them through a tab:
@@ -13496,7 +13687,9 @@ async function loadMessagePaneView(sid, pane, view) {
13496
13687
  return;
13497
13688
  }
13498
13689
  const meta = [msgDetailTitle(m), ...msgDetailMeta(m)].join(' · ');
13499
- appendPaneDoc(view).innerHTML = `<div class="pane-msg-meta">${escapeHtml(meta)}</div>${msgDetailContent(m)}`;
13690
+ const body = appendPaneDoc(view);
13691
+ body.innerHTML = `<div class="pane-msg-meta">${escapeHtml(meta)}</div>${msgDetailContent(m)}`;
13692
+ attachPaneReview(view, sid, replyReviewOpts(m, body, sid));
13500
13693
  }
13501
13694
 
13502
13695
  function onPaneFileChanged(filePath) {
@@ -13515,7 +13708,9 @@ let paneUpdatedToastTimer = 0;
13515
13708
  async function autoReloadPaneView(sid, pane, view) {
13516
13709
  if (!(await reloadPaneView(sid, pane, view))) return;
13517
13710
  clearTimeout(paneUpdatedToastTimer);
13518
- paneUpdatedToastTimer = setTimeout(() => showToast('Pane updated', 'info'), 1000);
13711
+ paneUpdatedToastTimer = setTimeout(() => {
13712
+ if (!document.getElementById('toast').classList.contains('has-action')) showToast('Pane updated', 'info');
13713
+ }, 1000);
13519
13714
  }
13520
13715
 
13521
13716
  // Builds the new view hidden over the old one and swaps them once it has loaded and scrolled,
@@ -14025,15 +14220,13 @@ function markSourceLine(bodyEl, line) {
14025
14220
  band.scrollIntoView({ block: 'center', inline: 'nearest' });
14026
14221
  }
14027
14222
 
14028
- function openFileInPane(filePath, closeModal) {
14029
- if (!filePath || !paneSessionId()) return;
14030
- closeModal();
14031
- addPane(filePath);
14032
- }
14033
-
14034
14223
  // biome-ignore lint/correctness/noUnusedVariables: used in HTML
14035
14224
  function openPreviewInPane() {
14036
- openFileInPane(currentPreviewPath, closePreviewModal);
14225
+ if (!currentPreviewPath || !previewSessionId) return;
14226
+ const sid = previewSessionId;
14227
+ const target = currentPreviewPath;
14228
+ closePreviewModal();
14229
+ addPaneToSession(sid, target, pathBasename(target));
14037
14230
  }
14038
14231
 
14039
14232
  // The plan modal also opens other sessions' plans; the pane goes to the plan's session, wherever it is.
@@ -14044,15 +14237,19 @@ function syncPlanPaneBtn() {
14044
14237
  }
14045
14238
 
14046
14239
  // biome-ignore lint/correctness/noUnusedVariables: used in HTML
14047
- async function openPlanInPane() {
14240
+ function openPlanInPane() {
14048
14241
  if (!planPaneAllowed()) return;
14049
14242
  const sid = _planSessionId;
14050
14243
  const target = _pendingPlanPath;
14051
14244
  closePlanModal();
14245
+ addPaneToSession(sid, target, 'Plan');
14246
+ }
14247
+
14248
+ async function addPaneToSession(sid, target, label) {
14052
14249
  if (sid === paneSessionId()) return addPane(target);
14053
14250
  if ((await postPane(sid, { target, base: getSessionBaseDir(sid) || undefined })) !== false) return;
14054
14251
  const s = sessions.find((x) => x.id === sid);
14055
- showToast(`Plan added to ${s ? sessionDisplayName(s) : 'its session'}`, 'success', {
14252
+ showToast(`${label} added to ${s ? sessionDisplayName(s) : 'its session'}`, 'success', {
14056
14253
  label: 'Open',
14057
14254
  onClick: () => openSession(sid),
14058
14255
  });
@@ -14559,6 +14756,7 @@ function terminalShortcut(e, probe = false) {
14559
14756
  // Ctrl+Shift+Z is redo in a text field, so only the terminal's own textarea gives it up.
14560
14757
  if (ctrlShift && e.code === 'KeyZ') return inPageField(e) ? null : toggleZenMode;
14561
14758
  if (ctrlShift && e.code === 'KeyK') return kanbotAvailable() ? () => toggleKanbot() : null;
14759
+ if (ctrlShift && e.code === 'KeyD') return dismissContextSession;
14562
14760
  if (ctrlAlt && (e.code === 'KeyN' || e.code === 'KeyR') && terminalAvailable()) {
14563
14761
  return () => openNewSession(null, e.code === 'KeyR');
14564
14762
  }
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.