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 +5 -2
- package/lib/live-sessions.js +16 -1
- package/lib/retention.js +10 -1
- package/lib/terminal-client.js +2 -0
- package/lib/terminal-host.js +1 -0
- package/lib/terminal.js +37 -13
- package/package.json +1 -1
- package/public/app.js +230 -51
- package/public/index.html +1 -1
- package/public/style.css +29 -4
- package/server.js +9 -2
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
|
|
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/lib/live-sessions.js
CHANGED
|
@@ -36,4 +36,19 @@ function isSessionLive(sessions, sessionId, exceptPid = null) {
|
|
|
36
36
|
});
|
|
37
37
|
}
|
|
38
38
|
|
|
39
|
-
|
|
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.
|
package/lib/terminal-client.js
CHANGED
|
@@ -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);
|
package/lib/terminal-host.js
CHANGED
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
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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
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
|
|
8128
|
-
|
|
8129
|
-
|
|
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)
|
|
8593
|
-
|
|
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
|
-
|
|
8598
|
-
|
|
8599
|
-
|
|
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',
|
|
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
|
|
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
|
|
8680
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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({
|
|
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
|
|
9062
|
-
if (!currentPreviewPath || !
|
|
9234
|
+
function isPreviewLinkedToSession() {
|
|
9235
|
+
if (!currentPreviewPath || !previewSessionId) return false;
|
|
9063
9236
|
const key = canonicalPath(currentPreviewPath);
|
|
9064
|
-
return getSessionPreviewPaths(
|
|
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 =
|
|
9242
|
+
if (paneBtn) paneBtn.style.display = previewSessionId ? '' : 'none';
|
|
9070
9243
|
const btn = document.getElementById('preview-link-btn');
|
|
9071
9244
|
if (!btn) return;
|
|
9072
|
-
if (!
|
|
9245
|
+
if (!previewSessionId) {
|
|
9073
9246
|
btn.style.display = 'none';
|
|
9074
9247
|
return;
|
|
9075
9248
|
}
|
|
9076
9249
|
btn.style.display = '';
|
|
9077
|
-
const linked =
|
|
9078
|
-
btn.title = linked ? 'Unlink from
|
|
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 || !
|
|
9257
|
+
if (!currentPreviewPath || !previewSessionId) {
|
|
9085
9258
|
showToast('Select a session first');
|
|
9086
9259
|
return;
|
|
9087
9260
|
}
|
|
9088
|
-
const unlink =
|
|
9089
|
-
setSessionDocLink(
|
|
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
|
-
|
|
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(() =>
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
|
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
|
|
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.
|