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 +5 -2
- package/hub-app.json +1 -1
- 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 +264 -66
- 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/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",
|
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
|
@@ -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
|
-
|
|
2568
|
+
const opts = !agentLogMode && replyReviewOpts(m, body, currentSessionId);
|
|
2569
|
+
if (!opts) return unmountMsgReview();
|
|
2569
2570
|
mountReview({
|
|
2570
|
-
|
|
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
|
-
|
|
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 ===
|
|
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
|
|
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(
|
|
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
|
-
|
|
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
|
|
8128
|
-
|
|
8129
|
-
|
|
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)
|
|
8593
|
-
|
|
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
|
-
|
|
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
|
-
});
|
|
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',
|
|
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
|
|
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
|
|
8680
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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({
|
|
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
|
|
9062
|
-
if (!currentPreviewPath || !
|
|
9250
|
+
function isPreviewLinkedToSession() {
|
|
9251
|
+
if (!currentPreviewPath || !previewSessionId) return false;
|
|
9063
9252
|
const key = canonicalPath(currentPreviewPath);
|
|
9064
|
-
return getSessionPreviewPaths(
|
|
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 =
|
|
9258
|
+
if (paneBtn) paneBtn.style.display = previewSessionId ? '' : 'none';
|
|
9070
9259
|
const btn = document.getElementById('preview-link-btn');
|
|
9071
9260
|
if (!btn) return;
|
|
9072
|
-
if (!
|
|
9261
|
+
if (!previewSessionId) {
|
|
9073
9262
|
btn.style.display = 'none';
|
|
9074
9263
|
return;
|
|
9075
9264
|
}
|
|
9076
9265
|
btn.style.display = '';
|
|
9077
|
-
const linked =
|
|
9078
|
-
btn.title = linked ? 'Unlink from
|
|
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 || !
|
|
9273
|
+
if (!currentPreviewPath || !previewSessionId) {
|
|
9085
9274
|
showToast('Select a session first');
|
|
9086
9275
|
return;
|
|
9087
9276
|
}
|
|
9088
|
-
const unlink =
|
|
9089
|
-
setSessionDocLink(
|
|
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
|
-
|
|
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
|
-
|
|
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(() =>
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
|
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.
|