create-walle 0.8.0 → 0.9.1
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/README.md +23 -7
- package/package.json +3 -3
- package/template/CLAUDE.md +14 -1
- package/template/README.md +123 -43
- package/template/claude-task-manager/bin/restart-ctm.sh +3 -2
- package/template/claude-task-manager/db.js +40 -2
- package/template/claude-task-manager/public/css/walle.css +123 -0
- package/template/claude-task-manager/public/index.html +1003 -75
- package/template/claude-task-manager/public/js/walle.js +562 -131
- package/template/claude-task-manager/public/prompts.html +84 -26
- package/template/claude-task-manager/public/walle-icon.svg +45 -0
- package/template/claude-task-manager/server.js +69 -4
- package/template/docs/openclaw-vs-walle-comparison.md +103 -0
- package/template/package.json +1 -1
- package/template/wall-e/agent.js +63 -3
- package/template/wall-e/api-walle.js +158 -0
- package/template/wall-e/brain.js +182 -5
- package/template/wall-e/channels/imessage-channel.js +4 -1
- package/template/wall-e/channels/slack-channel.js +3 -1
- package/template/wall-e/chat.js +115 -213
- package/template/wall-e/context/compactor.js +163 -0
- package/template/wall-e/context/context-builder.js +355 -0
- package/template/wall-e/context/state-snapshot.js +209 -0
- package/template/wall-e/context/token-counter.js +55 -0
- package/template/wall-e/context/topic-matcher.js +79 -0
- package/template/wall-e/core-tasks.js +25 -1
- package/template/wall-e/events/event-bus.js +23 -0
- package/template/wall-e/loops/ingest.js +4 -0
- package/template/wall-e/loops/initiative.js +316 -0
- package/template/wall-e/loops/tasks.js +55 -5
- package/template/wall-e/skills/_bundled/email-sync/run.js +3 -1
- package/template/wall-e/skills/_bundled/mcp-scan/SKILL.md +14 -0
- package/template/wall-e/skills/_bundled/mcp-scan/run.js +86 -0
- package/template/wall-e/skills/_bundled/morning-briefing/run.js +41 -0
- package/template/wall-e/skills/_bundled/proactive-alerts/SKILL.md +20 -0
- package/template/wall-e/skills/_bundled/proactive-alerts/run.js +144 -0
- package/template/wall-e/skills/_bundled/slack-mentions/.watched-threads.json +18 -0
- package/template/wall-e/skills/_bundled/slack-mentions/.watermark.json +4 -0
- package/template/wall-e/skills/_bundled/slack-mentions/SKILL.md +52 -0
- package/template/wall-e/skills/_bundled/slack-mentions/run.js +470 -0
- package/template/wall-e/skills/_bundled/weekly-reflection/SKILL.md +69 -0
- package/template/wall-e/skills/mcp-client.js +241 -13
- package/template/wall-e/tests/brain.test.js +4 -4
- package/template/wall-e/tests/compactor.test.js +323 -0
- package/template/wall-e/tests/context-builder.test.js +215 -0
- package/template/wall-e/tests/event-bus.test.js +74 -0
- package/template/wall-e/tests/initiative.test.js +354 -0
- package/template/wall-e/tests/proactive-alerts.test.js +140 -0
- package/template/wall-e/tests/session-persistence.test.js +335 -0
- package/template/wall-e/tools/local-tools.js +65 -0
|
@@ -251,10 +251,12 @@
|
|
|
251
251
|
.session-item .dot {
|
|
252
252
|
width: 7px; height: 7px;
|
|
253
253
|
border-radius: 50%;
|
|
254
|
-
background: var(--
|
|
254
|
+
background: var(--fg-dim);
|
|
255
255
|
flex-shrink: 0;
|
|
256
|
+
opacity: 0.4;
|
|
256
257
|
}
|
|
257
|
-
.session-item.
|
|
258
|
+
.session-item.running .dot { background: var(--green); opacity: 1; }
|
|
259
|
+
.session-item.stale .dot { background: var(--yellow, #e0af68); opacity: 0.7; }
|
|
258
260
|
.session-item .idle-hint {
|
|
259
261
|
font-size: 9px;
|
|
260
262
|
color: var(--yellow, #e0af68);
|
|
@@ -269,6 +271,52 @@
|
|
|
269
271
|
text-overflow: ellipsis;
|
|
270
272
|
white-space: nowrap;
|
|
271
273
|
}
|
|
274
|
+
.session-item .status-tag {
|
|
275
|
+
font-size: 9px;
|
|
276
|
+
padding: 1px 5px;
|
|
277
|
+
border-radius: 3px;
|
|
278
|
+
flex-shrink: 0;
|
|
279
|
+
font-weight: 500;
|
|
280
|
+
letter-spacing: 0.02em;
|
|
281
|
+
}
|
|
282
|
+
.status-tag.running {
|
|
283
|
+
background: rgba(115, 218, 202, 0.15);
|
|
284
|
+
color: var(--green);
|
|
285
|
+
}
|
|
286
|
+
.session-item.active .status-tag.running {
|
|
287
|
+
background: rgba(26, 27, 38, 0.15);
|
|
288
|
+
color: #1a1b26;
|
|
289
|
+
}
|
|
290
|
+
.status-tag.running::before {
|
|
291
|
+
content: '';
|
|
292
|
+
display: inline-block;
|
|
293
|
+
width: 5px; height: 5px;
|
|
294
|
+
border-radius: 50%;
|
|
295
|
+
background: currentColor;
|
|
296
|
+
margin-right: 3px;
|
|
297
|
+
vertical-align: middle;
|
|
298
|
+
animation: pulse-dot 1.5s ease-in-out infinite;
|
|
299
|
+
}
|
|
300
|
+
@keyframes pulse-dot {
|
|
301
|
+
0%, 100% { opacity: 1; }
|
|
302
|
+
50% { opacity: 0.3; }
|
|
303
|
+
}
|
|
304
|
+
.status-tag.idle {
|
|
305
|
+
background: rgba(192, 202, 245, 0.08);
|
|
306
|
+
color: var(--fg-dim);
|
|
307
|
+
}
|
|
308
|
+
.session-item.active .status-tag.idle {
|
|
309
|
+
background: rgba(26, 27, 38, 0.1);
|
|
310
|
+
color: rgba(26, 27, 38, 0.5);
|
|
311
|
+
}
|
|
312
|
+
.status-tag.waiting {
|
|
313
|
+
background: rgba(224, 175, 104, 0.15);
|
|
314
|
+
color: var(--yellow);
|
|
315
|
+
}
|
|
316
|
+
.session-item.active .status-tag.waiting {
|
|
317
|
+
background: rgba(26, 27, 38, 0.15);
|
|
318
|
+
color: #1a1b26;
|
|
319
|
+
}
|
|
272
320
|
.session-item .close-btn {
|
|
273
321
|
opacity: 0;
|
|
274
322
|
background: none;
|
|
@@ -716,6 +764,37 @@
|
|
|
716
764
|
.scroll-bottom-btn:hover { transform: scale(1.1); }
|
|
717
765
|
.scroll-bottom-btn.visible { display: flex; }
|
|
718
766
|
|
|
767
|
+
/* Prompt navigation */
|
|
768
|
+
.prompt-nav {
|
|
769
|
+
display: inline-flex; align-items: center; gap: 2px; margin-left: auto;
|
|
770
|
+
}
|
|
771
|
+
.prompt-nav-btn {
|
|
772
|
+
background: none; border: 1px solid var(--border); color: var(--fg);
|
|
773
|
+
font-size: 10px; padding: 3px 6px; border-radius: 3px; cursor: pointer;
|
|
774
|
+
line-height: 1;
|
|
775
|
+
}
|
|
776
|
+
.prompt-nav-btn:hover { background: rgba(255,255,255,0.1); color: #fff; }
|
|
777
|
+
.prompt-nav-btn:disabled { opacity: 0.25; cursor: default; }
|
|
778
|
+
.prompt-nav-btn:disabled:hover { background: none; color: var(--fg); }
|
|
779
|
+
.prompt-nav-badge {
|
|
780
|
+
font-size: 11px; color: var(--fg); padding: 2px 6px; cursor: pointer;
|
|
781
|
+
border-radius: 3px; white-space: nowrap; user-select: none;
|
|
782
|
+
}
|
|
783
|
+
.prompt-nav-badge:hover { background: rgba(255,255,255,0.1); color: #fff; }
|
|
784
|
+
.prompt-nav-list {
|
|
785
|
+
position: absolute; top: 100%; right: 0; z-index: 50;
|
|
786
|
+
background: var(--bg-light); border: 1px solid var(--border); border-radius: 6px;
|
|
787
|
+
max-height: 300px; overflow-y: auto; min-width: 260px; max-width: 400px;
|
|
788
|
+
box-shadow: 0 4px 16px rgba(0,0,0,0.4); padding: 4px 0;
|
|
789
|
+
}
|
|
790
|
+
.prompt-nav-list-item {
|
|
791
|
+
padding: 5px 10px; font-size: 11px; color: var(--fg); cursor: pointer;
|
|
792
|
+
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
|
|
793
|
+
}
|
|
794
|
+
.prompt-nav-list-item:hover { background: rgba(255,255,255,0.06); }
|
|
795
|
+
.prompt-nav-list-item.current { color: var(--accent); font-weight: 600; }
|
|
796
|
+
.prompt-nav-list-item.not-in-buffer { color: var(--fg-dim); cursor: default; }
|
|
797
|
+
|
|
719
798
|
.review-msg {
|
|
720
799
|
margin-bottom: 12px;
|
|
721
800
|
border-radius: 8px;
|
|
@@ -1285,6 +1364,9 @@
|
|
|
1285
1364
|
0%, 100% { opacity: 1; }
|
|
1286
1365
|
50% { opacity: 0.3; }
|
|
1287
1366
|
}
|
|
1367
|
+
.tab[draggable="true"] { cursor: grab; }
|
|
1368
|
+
.tab[draggable="true"]:active { cursor: grabbing; }
|
|
1369
|
+
.tab.tab-drag-over { border-left: 2px solid var(--accent); }
|
|
1288
1370
|
.tab .tab-icon {
|
|
1289
1371
|
color: var(--green, #9ece6a);
|
|
1290
1372
|
font-size: 10px;
|
|
@@ -1327,7 +1409,7 @@
|
|
|
1327
1409
|
.term-container.active { display: flex; flex-direction: column; }
|
|
1328
1410
|
.term-container .xterm { flex: 1; height: 0; }
|
|
1329
1411
|
.session-toolbar {
|
|
1330
|
-
display: flex; gap: 6px; padding: 3px 8px;
|
|
1412
|
+
display: flex; align-items: center; gap: 6px; padding: 3px 8px;
|
|
1331
1413
|
background: rgba(255,255,255,0.03); border-bottom: 1px solid var(--border);
|
|
1332
1414
|
flex-shrink: 0;
|
|
1333
1415
|
}
|
|
@@ -2237,7 +2319,7 @@
|
|
|
2237
2319
|
<nav class="topbar-nav" id="topbar-nav">
|
|
2238
2320
|
<button class="nav-pill active" data-nav="sessions" onclick="navTo('sessions')" title="Terminal sessions">Sessions</button>
|
|
2239
2321
|
<button class="nav-pill" data-nav="prompts" onclick="navTo('prompts')" title="Prompt Editor">Prompts</button>
|
|
2240
|
-
<button class="nav-pill" data-nav="walle" onclick="navTo('walle')" title="WALL-E Agent">WALL-E</button>
|
|
2322
|
+
<button class="nav-pill" data-nav="walle" onclick="navTo('walle')" title="WALL-E Agent"><img src="/walle-icon.svg" width="14" height="14" style="vertical-align:middle;margin-right:3px;position:relative;top:-1px;">WALL-E</button>
|
|
2241
2323
|
<div style="position:relative;display:inline-block;" id="nav-more-wrap">
|
|
2242
2324
|
<button class="nav-pill" onclick="toggleNavMore()" title="More pages">More <span style="font-size:10px;">▾</span></button>
|
|
2243
2325
|
<div id="nav-more-dropdown" style="display:none;position:absolute;top:100%;left:0;z-index:999;background:var(--bg-light);border:1px solid var(--border);border-radius:6px;padding:4px 0;min-width:140px;box-shadow:0 4px 12px rgba(0,0,0,0.3);">
|
|
@@ -2317,7 +2399,7 @@
|
|
|
2317
2399
|
<div id="welcome">
|
|
2318
2400
|
<h2 style="font-size:24px;margin-bottom:4px;">Welcome to CTM</h2>
|
|
2319
2401
|
<p style="color:var(--fg-dim);margin-bottom:20px;">Manage Claude Code sessions, prompts, and your AI assistant Wall-E.</p>
|
|
2320
|
-
<button class="btn primary" onclick="
|
|
2402
|
+
<button class="btn primary" onclick="showNewSessionModal()" style="font-size:15px;padding:8px 24px;margin-bottom:28px;">New Claude Session</button>
|
|
2321
2403
|
<div style="display:flex;gap:16px;max-width:680px;">
|
|
2322
2404
|
<div onclick="navTo('sessions')" style="flex:1;padding:14px;background:rgba(255,255,255,0.03);border:1px solid var(--border);border-radius:8px;cursor:pointer;">
|
|
2323
2405
|
<div style="font-weight:600;margin-bottom:4px;color:var(--fg);">Sessions</div>
|
|
@@ -2328,7 +2410,7 @@
|
|
|
2328
2410
|
<div style="font-size:12px;color:var(--fg-dim);">Save, organize, and send prompts to Claude</div>
|
|
2329
2411
|
</div>
|
|
2330
2412
|
<div onclick="navTo('walle')" style="flex:1;padding:14px;background:rgba(255,255,255,0.03);border:1px solid var(--border);border-radius:8px;cursor:pointer;">
|
|
2331
|
-
<div style="font-weight:600;margin-bottom:4px;color:var(--fg);">WALL-E</div>
|
|
2413
|
+
<div style="font-weight:600;margin-bottom:4px;color:var(--fg);display:flex;align-items:center;gap:6px;"><img src="/walle-icon.svg" width="18" height="18"> WALL-E</div>
|
|
2332
2414
|
<div style="font-size:12px;color:var(--fg-dim);">Your personal AI assistant — chat, tasks, and insights</div>
|
|
2333
2415
|
</div>
|
|
2334
2416
|
</div>
|
|
@@ -2358,6 +2440,11 @@
|
|
|
2358
2440
|
<label style="font-size:11px;color:var(--fg-dim);display:flex;align-items:center;gap:4px;cursor:pointer;white-space:nowrap;user-select:none;">
|
|
2359
2441
|
<input type="checkbox" id="hide-tool-msgs" checked onchange="savePref('hide_tool_msgs', this.checked); toggleToolMsgs()"> Hide tool calls
|
|
2360
2442
|
</label>
|
|
2443
|
+
<div class="prompt-nav" id="review-prompt-nav" style="position:relative">
|
|
2444
|
+
<button class="prompt-nav-btn" onclick="reviewPromptNavGo(-1)" title="Previous prompt (Alt+↑)" disabled>▲</button>
|
|
2445
|
+
<span class="prompt-nav-badge" onclick="reviewPromptNavToggleList()" title="Click to see all prompts">0 prompts</span>
|
|
2446
|
+
<button class="prompt-nav-btn" onclick="reviewPromptNavGo(1)" title="Next prompt (Alt+↓)" disabled>▼</button>
|
|
2447
|
+
</div>
|
|
2361
2448
|
<div class="review-actions" id="review-actions"></div>
|
|
2362
2449
|
</div>
|
|
2363
2450
|
</div>
|
|
@@ -2391,11 +2478,13 @@
|
|
|
2391
2478
|
</div>
|
|
2392
2479
|
<div id="walle-panel">
|
|
2393
2480
|
<div class="walle-header">
|
|
2481
|
+
<img src="/walle-icon.svg" alt="WALL-E" width="22" height="22" style="vertical-align:middle;">
|
|
2394
2482
|
<span class="walle-header-title">WALL-E</span>
|
|
2395
2483
|
<div class="walle-subnav">
|
|
2396
2484
|
<button class="walle-subnav-btn active" data-view="chat" onclick="WE.showView('chat')">Chat</button>
|
|
2397
2485
|
<button class="walle-subnav-btn" data-view="tasks" onclick="WE.showView('tasks')">Tasks</button>
|
|
2398
2486
|
<button class="walle-subnav-btn" data-view="skills" onclick="WE.showView('skills')">Skills</button>
|
|
2487
|
+
<button class="walle-subnav-btn" data-view="mcp" onclick="WE.showView('mcp')">MCP</button>
|
|
2399
2488
|
<div style="position:relative;display:inline-block;" id="we-more-wrap">
|
|
2400
2489
|
<button class="walle-subnav-btn" onclick="WE.toggleMoreTabs()" id="we-more-btn">More <span style="font-size:10px;">▾</span></button>
|
|
2401
2490
|
<div id="we-more-dropdown" style="display:none;position:absolute;top:100%;left:0;z-index:999;background:var(--bg-light);border:1px solid var(--border);border-radius:6px;padding:4px 0;min-width:120px;box-shadow:0 4px 12px rgba(0,0,0,0.3);">
|
|
@@ -3109,8 +3198,11 @@ function connect() {
|
|
|
3109
3198
|
setTimeout(() => {
|
|
3110
3199
|
if (state.ws?.readyState === 1) {
|
|
3111
3200
|
overlay.classList.remove('active');
|
|
3201
|
+
const wasRestarting = state._restarting;
|
|
3112
3202
|
state._restarting = false;
|
|
3113
|
-
if (attempts > showOverlayAfter) toast('Server reconnected', { type: 'success' });
|
|
3203
|
+
if (attempts > showOverlayAfter || wasRestarting) toast('Server reconnected', { type: 'success' });
|
|
3204
|
+
// Post-reconnect reinitialization — refresh all state that may have gone stale
|
|
3205
|
+
onReconnected();
|
|
3114
3206
|
} else {
|
|
3115
3207
|
setTimeout(tryReconnect, state._restarting ? 1000 : 2000);
|
|
3116
3208
|
}
|
|
@@ -3120,6 +3212,30 @@ function connect() {
|
|
|
3120
3212
|
};
|
|
3121
3213
|
}
|
|
3122
3214
|
|
|
3215
|
+
function onReconnected() {
|
|
3216
|
+
// Refresh sidebar data (recent sessions, prompt links, etc.)
|
|
3217
|
+
loadRecentSessions();
|
|
3218
|
+
refreshSessionPrompts();
|
|
3219
|
+
// Clear prompt scan cache so it re-fetches from API
|
|
3220
|
+
for (const key of Object.keys(_promptScanCache)) delete _promptScanCache[key];
|
|
3221
|
+
// Re-attach ALL sessions — after a restart, the server has a fresh WS client map
|
|
3222
|
+
// so existing sessions need to re-register their WS connection for input to work.
|
|
3223
|
+
for (const [id] of state.sessions) {
|
|
3224
|
+
send({ type: 'attach', id });
|
|
3225
|
+
}
|
|
3226
|
+
// Resize active session's PTY to match current terminal dimensions
|
|
3227
|
+
if (state.activeTab && state.sessions.has(state.activeTab)) {
|
|
3228
|
+
const s = state.sessions.get(state.activeTab);
|
|
3229
|
+
if (s && s.term && s.fitAddon) {
|
|
3230
|
+
requestAnimationFrame(() => {
|
|
3231
|
+
s.fitAddon.fit();
|
|
3232
|
+
send({ type: 'resize', id: state.activeTab, cols: s.term.cols, rows: s.term.rows });
|
|
3233
|
+
s.term.focus();
|
|
3234
|
+
});
|
|
3235
|
+
}
|
|
3236
|
+
}
|
|
3237
|
+
}
|
|
3238
|
+
|
|
3123
3239
|
function send(msg) {
|
|
3124
3240
|
if (state.ws?.readyState === 1) {
|
|
3125
3241
|
state.ws.send(JSON.stringify(msg));
|
|
@@ -3211,7 +3327,7 @@ async function restartAll() {
|
|
|
3211
3327
|
await new Promise(r => setTimeout(r, 500));
|
|
3212
3328
|
state._restarting = true;
|
|
3213
3329
|
try {
|
|
3214
|
-
await fetch(`/api/restart/ctm?token=${state.token}`, { method: 'POST' });
|
|
3330
|
+
await fetch(`/api/restart/ctm?token=${state.token}&force=true`, { method: 'POST' });
|
|
3215
3331
|
} catch { /* expected — server dying */ }
|
|
3216
3332
|
}
|
|
3217
3333
|
|
|
@@ -3238,7 +3354,7 @@ async function svcAction(service, action) {
|
|
|
3238
3354
|
state._restarting = true;
|
|
3239
3355
|
hideAppMenu();
|
|
3240
3356
|
try {
|
|
3241
|
-
await fetch(`/api/restart/ctm?token=${state.token}`, { method: 'POST' });
|
|
3357
|
+
await fetch(`/api/restart/ctm?token=${state.token}&force=true`, { method: 'POST' });
|
|
3242
3358
|
} catch { /* expected — server dying */ }
|
|
3243
3359
|
}
|
|
3244
3360
|
}
|
|
@@ -3264,6 +3380,19 @@ function createTerminal(id) {
|
|
|
3264
3380
|
reviewBtn.textContent = '\u{1F50D} Review';
|
|
3265
3381
|
reviewBtn.onclick = function() { openSessionReview(id); };
|
|
3266
3382
|
toolbar.appendChild(reviewBtn);
|
|
3383
|
+
|
|
3384
|
+
// Prompt navigation — prev/next arrows + count badge (click for list)
|
|
3385
|
+
const promptNav = document.createElement('div');
|
|
3386
|
+
promptNav.className = 'prompt-nav';
|
|
3387
|
+
promptNav.style.position = 'relative';
|
|
3388
|
+
promptNav.innerHTML =
|
|
3389
|
+
'<button class="prompt-nav-btn" data-dir="prev" title="Previous prompt (Alt+\u2191)" disabled>▲</button>' +
|
|
3390
|
+
'<span class="prompt-nav-badge" title="Click to see all prompts">0/0</span>' +
|
|
3391
|
+
'<button class="prompt-nav-btn" data-dir="next" title="Next prompt (Alt+\u2193)" disabled>▼</button>';
|
|
3392
|
+
promptNav.querySelector('[data-dir="prev"]').onclick = function() { promptNavGo(id, -1); };
|
|
3393
|
+
promptNav.querySelector('[data-dir="next"]').onclick = function() { promptNavGo(id, 1); };
|
|
3394
|
+
promptNav.querySelector('.prompt-nav-badge').onclick = function() { promptNavToggleList(id); };
|
|
3395
|
+
toolbar.appendChild(promptNav);
|
|
3267
3396
|
container.appendChild(toolbar);
|
|
3268
3397
|
|
|
3269
3398
|
document.getElementById('terminal-area').appendChild(container);
|
|
@@ -3309,29 +3438,309 @@ function createTerminal(id) {
|
|
|
3309
3438
|
term.onData((data) => {
|
|
3310
3439
|
send({ type: 'input', id, data });
|
|
3311
3440
|
clearWaitingState(id);
|
|
3312
|
-
// User typed something — scroll to bottom
|
|
3441
|
+
// User typed something — scroll to bottom and re-enable follow mode
|
|
3313
3442
|
const s = state.sessions.get(id);
|
|
3314
|
-
if (s) s.term.scrollToBottom();
|
|
3443
|
+
if (s) { s.writer.followMode = true; s.term.scrollToBottom(); }
|
|
3315
3444
|
});
|
|
3316
3445
|
|
|
3317
3446
|
term.onResize(({ cols, rows }) => {
|
|
3447
|
+
const s = state.sessions.get(id);
|
|
3448
|
+
if (s && s._suppressResize) return; // Skip during font metric refresh
|
|
3318
3449
|
send({ type: 'resize', id, cols, rows });
|
|
3319
3450
|
});
|
|
3320
3451
|
|
|
3321
|
-
//
|
|
3322
|
-
//
|
|
3323
|
-
|
|
3324
|
-
|
|
3325
|
-
|
|
3326
|
-
|
|
3327
|
-
|
|
3328
|
-
|
|
3452
|
+
// Alt screen buffer switch listener — when Claude Code's TUI (Ink) exits,
|
|
3453
|
+
// force fit + scroll immediately instead of waiting for the idle timer.
|
|
3454
|
+
term.buffer.onBufferChange((activeBuffer) => {
|
|
3455
|
+
if (activeBuffer.type === 'normal') {
|
|
3456
|
+
requestAnimationFrame(() => {
|
|
3457
|
+
const s = state.sessions.get(id);
|
|
3458
|
+
if (s) {
|
|
3459
|
+
s.fitAddon.fit();
|
|
3460
|
+
// Returning from alt screen — scroll to bottom and re-enable follow
|
|
3461
|
+
s.writer.followMode = true;
|
|
3462
|
+
s.term.scrollToBottom();
|
|
3463
|
+
}
|
|
3464
|
+
});
|
|
3465
|
+
}
|
|
3329
3466
|
});
|
|
3330
3467
|
|
|
3331
|
-
|
|
3468
|
+
// Momentum scroll clamping — macOS trackpad momentum can cause "rocket scroll"
|
|
3469
|
+
// where the viewport flies past the content. Detect momentum events (rapid,
|
|
3470
|
+
// decaying deltas) and clamp them after a threshold.
|
|
3471
|
+
const viewportEl = container.querySelector('.xterm-viewport');
|
|
3472
|
+
if (viewportEl) {
|
|
3473
|
+
let lastWheelTime = 0;
|
|
3474
|
+
let lastDeltaY = 0;
|
|
3475
|
+
let momentumCount = 0;
|
|
3476
|
+
viewportEl.addEventListener('wheel', (e) => {
|
|
3477
|
+
const now = Date.now();
|
|
3478
|
+
const elapsed = now - lastWheelTime;
|
|
3479
|
+
lastWheelTime = now;
|
|
3480
|
+
if (elapsed < 80 && Math.abs(e.deltaY) < Math.abs(lastDeltaY) * 0.9) {
|
|
3481
|
+
momentumCount++;
|
|
3482
|
+
} else if (elapsed > 120) {
|
|
3483
|
+
momentumCount = 0;
|
|
3484
|
+
}
|
|
3485
|
+
lastDeltaY = e.deltaY;
|
|
3486
|
+
if (momentumCount >= 4) {
|
|
3487
|
+
e.preventDefault();
|
|
3488
|
+
e.stopPropagation();
|
|
3489
|
+
}
|
|
3490
|
+
}, { capture: true, passive: false });
|
|
3491
|
+
}
|
|
3492
|
+
|
|
3493
|
+
// RAF write batcher — coalesces rapid output into ~60fps frames to prevent
|
|
3494
|
+
// viewport desync during burst output (Claude Code thinking/streaming).
|
|
3495
|
+
const writer = {
|
|
3496
|
+
queue: '',
|
|
3497
|
+
scheduled: false,
|
|
3498
|
+
followMode: true, // tracks whether we should auto-scroll
|
|
3499
|
+
_suppressScroll: 0, // timestamp until which onScroll is suppressed
|
|
3500
|
+
};
|
|
3501
|
+
|
|
3502
|
+
// Track user scroll to toggle follow mode:
|
|
3503
|
+
// - Scrolling up disables auto-scroll so the user can read history
|
|
3504
|
+
// - Scrolling back to the bottom re-enables it
|
|
3505
|
+
// - Suppressed briefly after fit() to prevent reflow from flipping followMode
|
|
3506
|
+
term.onScroll(() => {
|
|
3507
|
+
const buf = term.buffer.active;
|
|
3508
|
+
const atBottom = buf.viewportY >= buf.baseY;
|
|
3509
|
+
// During suppression (after fit/write), ignore ALL scroll events.
|
|
3510
|
+
// The RAF batcher and fitActiveTerminal handle followMode explicitly;
|
|
3511
|
+
// allowing onScroll to re-enable followMode here causes a race where
|
|
3512
|
+
// term.write()'s internal scroll-to-bottom flips followMode back to true
|
|
3513
|
+
// even when the user has scrolled up.
|
|
3514
|
+
if (Date.now() < writer._suppressScroll) return;
|
|
3515
|
+
writer.followMode = atBottom;
|
|
3516
|
+
});
|
|
3517
|
+
|
|
3518
|
+
state.sessions.set(id, { term, fitAddon, container, needsFontRefresh: true, writer, promptLines: [], promptNavIdx: -1 });
|
|
3332
3519
|
return { term, fitAddon, container };
|
|
3333
3520
|
}
|
|
3334
3521
|
|
|
3522
|
+
// --- Prompt Navigation (active terminal) ---
|
|
3523
|
+
|
|
3524
|
+
// Scan for user prompts using the structured JSONL conversation data (same source as the Review page).
|
|
3525
|
+
// Falls back to terminal regex if the API is unavailable.
|
|
3526
|
+
// API results are cached and only re-fetched every 30s to avoid input lag.
|
|
3527
|
+
const _promptScanCache = {}; // { [sessionId]: { ts, previews } }
|
|
3528
|
+
|
|
3529
|
+
function scanPromptLines(id) {
|
|
3530
|
+
const s = state.sessions.get(id);
|
|
3531
|
+
if (!s) return;
|
|
3532
|
+
const recent = allRecentSessions.find(r => r.sessionId === id);
|
|
3533
|
+
if (recent && recent.projectEntry) {
|
|
3534
|
+
const cache = _promptScanCache[id];
|
|
3535
|
+
if (cache && Date.now() - cache.ts < 30000) {
|
|
3536
|
+
// Cache is fresh — just update badge from cached count (no buffer search)
|
|
3537
|
+
if (!s.promptPreviews || s.promptPreviews.length !== cache.previews.length) {
|
|
3538
|
+
s.promptLines = cache.previews.map(() => -1);
|
|
3539
|
+
s.promptPreviews = cache.previews;
|
|
3540
|
+
s.promptNavIdx = -1;
|
|
3541
|
+
}
|
|
3542
|
+
promptNavUpdateBadge(id);
|
|
3543
|
+
return;
|
|
3544
|
+
}
|
|
3545
|
+
_scanPromptLinesFromAPI(id, recent.projectEntry);
|
|
3546
|
+
} else {
|
|
3547
|
+
_scanPromptLinesFromTerminal(id);
|
|
3548
|
+
}
|
|
3549
|
+
}
|
|
3550
|
+
|
|
3551
|
+
async function _scanPromptLinesFromAPI(id, projectEntry) {
|
|
3552
|
+
const s = state.sessions.get(id);
|
|
3553
|
+
if (!s) return;
|
|
3554
|
+
try {
|
|
3555
|
+
const res = await fetch(`/api/session/messages?id=${id}&project=${encodeURIComponent(projectEntry)}&token=${state.token}`);
|
|
3556
|
+
const messages = await res.json();
|
|
3557
|
+
if (messages.error || !Array.isArray(messages)) {
|
|
3558
|
+
_scanPromptLinesFromTerminal(id);
|
|
3559
|
+
return;
|
|
3560
|
+
}
|
|
3561
|
+
// Match the review page: count all role:'user' messages
|
|
3562
|
+
const userMsgs = messages.filter(m => m.role === 'user');
|
|
3563
|
+
const previews = [];
|
|
3564
|
+
for (const msg of userMsgs) {
|
|
3565
|
+
const firstLine = msg.text.split('\n')[0].trim();
|
|
3566
|
+
if (!firstLine || firstLine.length < 3) continue;
|
|
3567
|
+
previews.push(firstLine.slice(0, 80));
|
|
3568
|
+
}
|
|
3569
|
+
_promptScanCache[id] = { ts: Date.now(), previews };
|
|
3570
|
+
if (s) s._promptLinesResolved = false;
|
|
3571
|
+
// Set prompt data without buffer search — positions resolved lazily on navigate/dropdown
|
|
3572
|
+
s.promptLines = previews.map(() => -1);
|
|
3573
|
+
s.promptPreviews = previews;
|
|
3574
|
+
s.promptNavIdx = -1;
|
|
3575
|
+
promptNavUpdateBadge(id);
|
|
3576
|
+
} catch {
|
|
3577
|
+
_scanPromptLinesFromTerminal(id);
|
|
3578
|
+
}
|
|
3579
|
+
}
|
|
3580
|
+
|
|
3581
|
+
function _applyPromptCache(id) {
|
|
3582
|
+
const s = state.sessions.get(id);
|
|
3583
|
+
const cache = _promptScanCache[id];
|
|
3584
|
+
if (!s || !cache) return;
|
|
3585
|
+
// Build buffer text index once, then match prompts against it
|
|
3586
|
+
const buf = s.term.buffer.active;
|
|
3587
|
+
const totalLines = buf.baseY + buf.cursorY;
|
|
3588
|
+
const bufTexts = new Array(totalLines + 1);
|
|
3589
|
+
for (let i = 0; i <= totalLines; i++) {
|
|
3590
|
+
const line = buf.getLine(i);
|
|
3591
|
+
bufTexts[i] = line ? line.translateToString(true) : '';
|
|
3592
|
+
}
|
|
3593
|
+
const lines = [];
|
|
3594
|
+
let searchFrom = 0;
|
|
3595
|
+
for (const preview of cache.previews) {
|
|
3596
|
+
let found = -1;
|
|
3597
|
+
if (preview.length >= 10) {
|
|
3598
|
+
const needle = preview.slice(0, 30);
|
|
3599
|
+
for (let i = searchFrom; i <= totalLines; i++) {
|
|
3600
|
+
if (bufTexts[i].includes(needle)) {
|
|
3601
|
+
found = i;
|
|
3602
|
+
searchFrom = i + 1;
|
|
3603
|
+
break;
|
|
3604
|
+
}
|
|
3605
|
+
}
|
|
3606
|
+
}
|
|
3607
|
+
lines.push(found);
|
|
3608
|
+
}
|
|
3609
|
+
s.promptLines = lines;
|
|
3610
|
+
s.promptPreviews = cache.previews;
|
|
3611
|
+
s.promptNavIdx = -1;
|
|
3612
|
+
promptNavUpdateBadge(id);
|
|
3613
|
+
}
|
|
3614
|
+
|
|
3615
|
+
function _scanPromptLinesFromTerminal(id) {
|
|
3616
|
+
const s = state.sessions.get(id);
|
|
3617
|
+
if (!s) return;
|
|
3618
|
+
const buf = s.term.buffer.active;
|
|
3619
|
+
const lines = [];
|
|
3620
|
+
let prevText = '';
|
|
3621
|
+
for (let i = 0; i <= buf.baseY + buf.cursorY; i++) {
|
|
3622
|
+
const line = buf.getLine(i);
|
|
3623
|
+
if (!line) continue;
|
|
3624
|
+
const text = line.translateToString(true);
|
|
3625
|
+
// Match Claude Code prompt: ❯ at column 0, followed by space + non-space.
|
|
3626
|
+
if (/^❯[ \u00a0]\S/.test(text)) {
|
|
3627
|
+
if (text !== prevText) {
|
|
3628
|
+
lines.push(i);
|
|
3629
|
+
prevText = text;
|
|
3630
|
+
}
|
|
3631
|
+
}
|
|
3632
|
+
}
|
|
3633
|
+
s.promptLines = lines;
|
|
3634
|
+
s.promptPreviews = null;
|
|
3635
|
+
s.promptNavIdx = -1;
|
|
3636
|
+
promptNavUpdateBadge(id);
|
|
3637
|
+
}
|
|
3638
|
+
|
|
3639
|
+
function _ensurePromptBufferPositions(id) {
|
|
3640
|
+
const s = state.sessions.get(id);
|
|
3641
|
+
if (!s || !s.promptPreviews) return;
|
|
3642
|
+
// Skip if already resolved (has at least one non-negative line)
|
|
3643
|
+
if (s._promptLinesResolved) return;
|
|
3644
|
+
s._promptLinesResolved = true;
|
|
3645
|
+
_applyPromptCache(id);
|
|
3646
|
+
}
|
|
3647
|
+
|
|
3648
|
+
function promptNavGo(id, dir) {
|
|
3649
|
+
const s = state.sessions.get(id);
|
|
3650
|
+
if (!s || s.promptLines.length === 0) return;
|
|
3651
|
+
_ensurePromptBufferPositions(id);
|
|
3652
|
+
|
|
3653
|
+
// Find the next navigable prompt (one with a valid buffer line)
|
|
3654
|
+
let newIdx = s.promptNavIdx;
|
|
3655
|
+
while (true) {
|
|
3656
|
+
newIdx += (s.promptNavIdx < 0 ? (dir < 0 ? 0 : 1) : dir);
|
|
3657
|
+
if (s.promptNavIdx < 0 && dir < 0) { newIdx = s.promptLines.length - 1; }
|
|
3658
|
+
if (newIdx < 0 || newIdx >= s.promptLines.length) return;
|
|
3659
|
+
if (s.promptLines[newIdx] >= 0) break; // Found a navigable prompt
|
|
3660
|
+
if (dir === 0) return;
|
|
3661
|
+
// Skip prompts not in the terminal buffer
|
|
3662
|
+
}
|
|
3663
|
+
|
|
3664
|
+
s.promptNavIdx = newIdx;
|
|
3665
|
+
const targetLine = s.promptLines[newIdx];
|
|
3666
|
+
s.writer.followMode = false;
|
|
3667
|
+
s.term.scrollToLine(Math.max(0, targetLine - 1));
|
|
3668
|
+
promptNavUpdateBadge(id);
|
|
3669
|
+
}
|
|
3670
|
+
|
|
3671
|
+
function promptNavUpdateBadge(id) {
|
|
3672
|
+
const s = state.sessions.get(id);
|
|
3673
|
+
if (!s) return;
|
|
3674
|
+
const nav = s.container.querySelector('.prompt-nav');
|
|
3675
|
+
if (!nav) return;
|
|
3676
|
+
const badge = nav.querySelector('.prompt-nav-badge');
|
|
3677
|
+
const prevBtn = nav.querySelector('[data-dir="prev"]');
|
|
3678
|
+
const nextBtn = nav.querySelector('[data-dir="next"]');
|
|
3679
|
+
const total = s.promptLines.length;
|
|
3680
|
+
const idx = s.promptNavIdx;
|
|
3681
|
+
badge.textContent = total === 0 ? '0 prompts' : (idx >= 0 ? (idx + 1) + '/' + total : total + (total === 1 ? ' prompt' : ' prompts'));
|
|
3682
|
+
prevBtn.disabled = total === 0 || idx <= 0;
|
|
3683
|
+
nextBtn.disabled = total === 0 || idx >= total - 1;
|
|
3684
|
+
}
|
|
3685
|
+
|
|
3686
|
+
function promptNavToggleList(id) {
|
|
3687
|
+
const s = state.sessions.get(id);
|
|
3688
|
+
if (!s) return;
|
|
3689
|
+
_ensurePromptBufferPositions(id);
|
|
3690
|
+
const nav = s.container.querySelector('.prompt-nav');
|
|
3691
|
+
if (!nav) return;
|
|
3692
|
+
// Close existing list
|
|
3693
|
+
const existing = nav.querySelector('.prompt-nav-list');
|
|
3694
|
+
if (existing) { existing.remove(); return; }
|
|
3695
|
+
if (s.promptLines.length === 0) return;
|
|
3696
|
+
|
|
3697
|
+
// Build list showing preview text for each prompt
|
|
3698
|
+
const list = document.createElement('div');
|
|
3699
|
+
list.className = 'prompt-nav-list';
|
|
3700
|
+
const buf = s.term.buffer.active;
|
|
3701
|
+
// Show most recent prompts first
|
|
3702
|
+
for (let i = s.promptLines.length - 1; i >= 0; i--) {
|
|
3703
|
+
let text;
|
|
3704
|
+
if (s.promptPreviews && s.promptPreviews[i]) {
|
|
3705
|
+
text = s.promptPreviews[i];
|
|
3706
|
+
} else if (s.promptLines[i] >= 0) {
|
|
3707
|
+
const line = buf.getLine(s.promptLines[i]);
|
|
3708
|
+
if (!line) continue;
|
|
3709
|
+
text = line.translateToString(true).trim();
|
|
3710
|
+
} else {
|
|
3711
|
+
continue; // No preview and not in buffer — skip
|
|
3712
|
+
}
|
|
3713
|
+
if (text.length > 80) text = text.slice(0, 80) + '\u2026';
|
|
3714
|
+
const inBuffer = s.promptLines[i] >= 0;
|
|
3715
|
+
const item = document.createElement('div');
|
|
3716
|
+
item.className = 'prompt-nav-list-item' + (i === s.promptNavIdx ? ' current' : '') + (inBuffer ? '' : ' not-in-buffer');
|
|
3717
|
+
item.textContent = text;
|
|
3718
|
+
if (inBuffer) {
|
|
3719
|
+
item.onclick = (function(idx) { return function() {
|
|
3720
|
+
s.promptNavIdx = idx;
|
|
3721
|
+
s.writer.followMode = false;
|
|
3722
|
+
s.term.scrollToLine(Math.max(0, s.promptLines[idx] - 1));
|
|
3723
|
+
promptNavUpdateBadge(id);
|
|
3724
|
+
list.remove();
|
|
3725
|
+
}; })(i);
|
|
3726
|
+
}
|
|
3727
|
+
list.appendChild(item);
|
|
3728
|
+
}
|
|
3729
|
+
nav.appendChild(list);
|
|
3730
|
+
// Close on outside click (mousedown catches terminal canvas clicks that don't bubble as click)
|
|
3731
|
+
setTimeout(() => {
|
|
3732
|
+
function close(e) {
|
|
3733
|
+
if (!list.contains(e.target)) {
|
|
3734
|
+
list.remove();
|
|
3735
|
+
document.removeEventListener('click', close);
|
|
3736
|
+
document.removeEventListener('mousedown', close);
|
|
3737
|
+
}
|
|
3738
|
+
}
|
|
3739
|
+
document.addEventListener('click', close);
|
|
3740
|
+
document.addEventListener('mousedown', close);
|
|
3741
|
+
}, 0);
|
|
3742
|
+
}
|
|
3743
|
+
|
|
3335
3744
|
function activateTab(id) {
|
|
3336
3745
|
const specialPanels = ['rules', 'insights', 'permissions', 'prompts', 'codereview', 'walle', 'backups'];
|
|
3337
3746
|
const isPanel = specialPanels.includes(id);
|
|
@@ -3387,20 +3796,24 @@ function activateTab(id) {
|
|
|
3387
3796
|
} else if (state.sessions.has(id)) {
|
|
3388
3797
|
const s = state.sessions.get(id);
|
|
3389
3798
|
s.container.classList.add('active');
|
|
3390
|
-
// Force xterm.js to re-measure font metrics if terminal was opened while hidden
|
|
3391
|
-
// (must happen synchronously after container becomes visible, before scrollback arrives)
|
|
3392
|
-
if (s.needsFontRefresh) {
|
|
3393
|
-
s.needsFontRefresh = false;
|
|
3394
|
-
const size = s.term.options.fontSize;
|
|
3395
|
-
s.term.options.fontSize = size + 1;
|
|
3396
|
-
s.term.options.fontSize = size;
|
|
3397
|
-
}
|
|
3398
3799
|
// Update hash so refresh re-attaches to this session
|
|
3399
3800
|
history.replaceState(null, '', location.pathname + location.search + '#session=' + id);
|
|
3400
3801
|
savePref('active_session', id);
|
|
3401
3802
|
requestAnimationFrame(() => {
|
|
3803
|
+
// Force xterm.js to re-measure font metrics if terminal was opened while hidden.
|
|
3804
|
+
// Must happen AFTER container is visible and in rAF so layout is computed.
|
|
3805
|
+
// Suppress onResize during the font toggle to avoid sending wrong PTY dimensions.
|
|
3806
|
+
if (s.needsFontRefresh) {
|
|
3807
|
+
s.needsFontRefresh = false;
|
|
3808
|
+
s._suppressResize = true;
|
|
3809
|
+
const size = s.term.options.fontSize;
|
|
3810
|
+
s.term.options.fontSize = size + 1;
|
|
3811
|
+
s.term.options.fontSize = size;
|
|
3812
|
+
s._suppressResize = false;
|
|
3813
|
+
}
|
|
3402
3814
|
s.fitAddon.fit();
|
|
3403
3815
|
send({ type: 'resize', id, cols: s.term.cols, rows: s.term.rows });
|
|
3816
|
+
s.writer.followMode = true;
|
|
3404
3817
|
s.term.scrollToBottom();
|
|
3405
3818
|
// If this session needs to attach (reconnect), do it after fit so PTY gets correct size
|
|
3406
3819
|
if (s.needsAttach) {
|
|
@@ -3799,6 +4212,7 @@ function onCreated(msg) {
|
|
|
3799
4212
|
|
|
3800
4213
|
if (!state.tabOrder.includes(id)) {
|
|
3801
4214
|
state.tabOrder.push(id);
|
|
4215
|
+
saveTabOrder();
|
|
3802
4216
|
}
|
|
3803
4217
|
activateTab(id);
|
|
3804
4218
|
|
|
@@ -3819,39 +4233,91 @@ function onCreated(msg) {
|
|
|
3819
4233
|
if (typeof onSkillSessionCreated === 'function') {
|
|
3820
4234
|
onSkillSessionCreated(msg);
|
|
3821
4235
|
}
|
|
4236
|
+
|
|
4237
|
+
// Refresh recent sessions so the new session appears in the sidebar list
|
|
4238
|
+
// (delay to let Claude Code write the .jsonl file)
|
|
4239
|
+
setTimeout(loadRecentSessions, 2000);
|
|
3822
4240
|
}
|
|
3823
4241
|
|
|
3824
4242
|
function onOutput(msg) {
|
|
3825
4243
|
const s = state.sessions.get(msg.id);
|
|
3826
4244
|
if (!s) return;
|
|
4245
|
+
s._lastOutputAt = Date.now();
|
|
4246
|
+
s._waitingForInput = false;
|
|
3827
4247
|
// Only strip \e[3J (Erase Scrollback) — preserves scroll history.
|
|
3828
4248
|
// Do NOT strip \e[2J or \e[?1049h/l — needed for Claude Code's TUI.
|
|
3829
4249
|
const data = msg.data.replace(/\x1b\[3J/g, '');
|
|
3830
|
-
|
|
3831
|
-
//
|
|
3832
|
-
|
|
3833
|
-
|
|
3834
|
-
|
|
3835
|
-
|
|
3836
|
-
|
|
3837
|
-
|
|
4250
|
+
|
|
4251
|
+
// followMode is maintained by the term.onScroll listener — no manual check needed here.
|
|
4252
|
+
|
|
4253
|
+
// RAF write batcher — coalesces rapid output chunks into ~60fps frames.
|
|
4254
|
+
// This prevents viewport desync caused by hundreds of tiny writes/sec
|
|
4255
|
+
// during Claude Code streaming output.
|
|
4256
|
+
const sid = msg.id;
|
|
4257
|
+
s.writer.queue += data;
|
|
4258
|
+
if (!s.writer.scheduled) {
|
|
4259
|
+
s.writer.scheduled = true;
|
|
4260
|
+
requestAnimationFrame(() => {
|
|
4261
|
+
const batch = s.writer.queue;
|
|
4262
|
+
const follow = s.writer.followMode;
|
|
4263
|
+
s.writer.queue = '';
|
|
4264
|
+
s.writer.scheduled = false;
|
|
4265
|
+
// Re-check active tab at render time (may have changed since queued)
|
|
4266
|
+
if (state.activeTab === sid && follow) {
|
|
4267
|
+
s.term.write(batch, () => {
|
|
4268
|
+
s.term.scrollToBottom();
|
|
4269
|
+
});
|
|
4270
|
+
} else {
|
|
4271
|
+
const savedLine = s.term.buffer.active.viewportY;
|
|
4272
|
+
// Suppress during write (large window covers slow writes), then
|
|
4273
|
+
// tighten to a short window in the callback to cover scrollToLine's
|
|
4274
|
+
// own scroll event before expiring naturally. No setTimeout needed.
|
|
4275
|
+
s.writer._suppressScroll = Date.now() + 2000;
|
|
4276
|
+
s.term.write(batch, () => {
|
|
4277
|
+
s.writer._suppressScroll = Date.now() + 100;
|
|
4278
|
+
s.term.scrollToLine(savedLine);
|
|
4279
|
+
});
|
|
4280
|
+
}
|
|
3838
4281
|
});
|
|
3839
|
-
} else {
|
|
3840
|
-
s.term.write(data);
|
|
3841
4282
|
}
|
|
3842
|
-
|
|
3843
|
-
//
|
|
3844
|
-
//
|
|
3845
|
-
// Without this, the viewport may become unscrollable after output finishes.
|
|
4283
|
+
|
|
4284
|
+
// After output stops for 300ms, ensure scroll position is correct
|
|
4285
|
+
// and auto-fix dimension drift (PTY size ≠ container size).
|
|
3846
4286
|
clearTimeout(s._outputIdleTimer);
|
|
3847
4287
|
s._outputIdleTimer = setTimeout(() => {
|
|
3848
4288
|
if (state.activeTab === msg.id) {
|
|
3849
|
-
|
|
4289
|
+
// Auto-heal dimension mismatch — if PTY was running at different
|
|
4290
|
+
// cols/rows than the container requires, fit + resize now.
|
|
4291
|
+
try {
|
|
4292
|
+
const dims = s.fitAddon.proposeDimensions();
|
|
4293
|
+
if (dims && (dims.cols !== s.term.cols || dims.rows !== s.term.rows)) {
|
|
4294
|
+
const buf = s.term.buffer.active;
|
|
4295
|
+
const wasAtBottom = buf.viewportY >= buf.baseY;
|
|
4296
|
+
const oldCols = s.term.cols;
|
|
4297
|
+
const savedLine = buf.viewportY;
|
|
4298
|
+
const savedAnchor = wasAtBottom ? null : _getScrollAnchor(s.term);
|
|
4299
|
+
s.writer._suppressScroll = Date.now() + 200;
|
|
4300
|
+
s.fitAddon.fit();
|
|
4301
|
+
send({ type: 'resize', id: msg.id, cols: s.term.cols, rows: s.term.rows });
|
|
4302
|
+
if (wasAtBottom) {
|
|
4303
|
+
s.term.scrollToBottom();
|
|
4304
|
+
} else if (s.term.cols !== oldCols) {
|
|
4305
|
+
_restoreScrollAnchor(s.term, savedAnchor);
|
|
4306
|
+
s._promptLinesResolved = false;
|
|
4307
|
+
} else {
|
|
4308
|
+
s.term.scrollToLine(savedLine);
|
|
4309
|
+
}
|
|
4310
|
+
return; // fit handles scroll position
|
|
4311
|
+
}
|
|
4312
|
+
} catch (_) { /* proposeDimensions may fail if container hidden */ }
|
|
4313
|
+
if (s.writer.followMode) {
|
|
4314
|
+
s.term.scrollToBottom();
|
|
4315
|
+
}
|
|
3850
4316
|
}
|
|
3851
|
-
},
|
|
4317
|
+
}, 300);
|
|
3852
4318
|
// Remove compact banner on new activity
|
|
3853
4319
|
const banner = s.container.querySelector('.compact-banner');
|
|
3854
|
-
if (banner) { banner.remove(); requestAnimationFrame(() => s.fitAddon.fit()); }
|
|
4320
|
+
if (banner) { banner.remove(); requestAnimationFrame(() => { const ln = s.term.buffer.active.viewportY; const ab = s.writer.followMode; s.writer._suppressScroll = Date.now() + 200; s.fitAddon.fit(); if (!ab) s.term.scrollToLine(ln); }); }
|
|
3855
4321
|
// Keep focus on the active session's terminal, but don't steal focus from
|
|
3856
4322
|
// input elements (e.g. queue panel textarea, search boxes)
|
|
3857
4323
|
if (state.activeTab === msg.id && document.activeElement !== s.term.textarea) {
|
|
@@ -3885,6 +4351,8 @@ function onScrollback(msg) {
|
|
|
3885
4351
|
s.term.clear();
|
|
3886
4352
|
s.term.write(msg.data, () => {
|
|
3887
4353
|
s.term.scrollToBottom();
|
|
4354
|
+
// Scan for prompt lines after scrollback is loaded
|
|
4355
|
+
scanPromptLines(msg.id);
|
|
3888
4356
|
});
|
|
3889
4357
|
|
|
3890
4358
|
// Send resize to ensure PTY matches this client's dimensions
|
|
@@ -3901,7 +4369,10 @@ function onExit(msg) {
|
|
|
3901
4369
|
}
|
|
3902
4370
|
}
|
|
3903
4371
|
|
|
3904
|
-
function onSessionsList(msg) {
|
|
4372
|
+
async function onSessionsList(msg) {
|
|
4373
|
+
// Wait for prefs to load before applying (avoids race with tab_order restore)
|
|
4374
|
+
if (state._prefsLoaded) await state._prefsLoaded;
|
|
4375
|
+
|
|
3905
4376
|
// Update sidebar - merge with existing data
|
|
3906
4377
|
const serverIds = new Set(msg.sessions.map(s => s.id));
|
|
3907
4378
|
|
|
@@ -3936,6 +4407,23 @@ function onSessionsList(msg) {
|
|
|
3936
4407
|
if (existing) existing.meta = sess;
|
|
3937
4408
|
}
|
|
3938
4409
|
|
|
4410
|
+
// Restore saved tab order (from prefs) on first session list after reconnect
|
|
4411
|
+
if (state._savedTabOrder) {
|
|
4412
|
+
const saved = state._savedTabOrder;
|
|
4413
|
+
delete state._savedTabOrder;
|
|
4414
|
+
// Reorder: put saved IDs first (if they still exist), then any new ones
|
|
4415
|
+
const sessionIds = state.tabOrder.filter(id => state.sessions.has(id));
|
|
4416
|
+
const nonSessionIds = state.tabOrder.filter(id => !state.sessions.has(id));
|
|
4417
|
+
const ordered = [];
|
|
4418
|
+
for (const id of saved) {
|
|
4419
|
+
if (sessionIds.includes(id)) ordered.push(id);
|
|
4420
|
+
}
|
|
4421
|
+
for (const id of sessionIds) {
|
|
4422
|
+
if (!ordered.includes(id)) ordered.push(id);
|
|
4423
|
+
}
|
|
4424
|
+
state.tabOrder = [...nonSessionIds, ...ordered];
|
|
4425
|
+
}
|
|
4426
|
+
|
|
3939
4427
|
renderSessionList();
|
|
3940
4428
|
renderTabs();
|
|
3941
4429
|
|
|
@@ -3970,6 +4458,8 @@ function onSessionsList(msg) {
|
|
|
3970
4458
|
// --- UI Rendering ---
|
|
3971
4459
|
function renderSessionList() {
|
|
3972
4460
|
const list = document.getElementById('session-list');
|
|
4461
|
+
// Skip re-render if user is actively renaming a session (input would be destroyed)
|
|
4462
|
+
if (list.querySelector('input')) return;
|
|
3973
4463
|
const sessions = Array.from(state.sessions.entries()).filter(([id, s]) => s.meta);
|
|
3974
4464
|
|
|
3975
4465
|
if (sessions.length === 0) {
|
|
@@ -3984,19 +4474,89 @@ function renderSessionList() {
|
|
|
3984
4474
|
const idleMs = Date.now() - lastAct;
|
|
3985
4475
|
const isStale = idleMs > 24 * 60 * 60 * 1000;
|
|
3986
4476
|
const idleHint = isStale ? `<span class="idle-hint">${formatIdleTime(idleMs)}</span>` : '';
|
|
4477
|
+
const sStatus = getSessionStatus(s);
|
|
4478
|
+
const statusTag = isStale ? '' : `<span class="status-tag ${sStatus.cls}">${sStatus.text}</span>`;
|
|
3987
4479
|
const promptBadges = (s.linkedPrompts || []).map(p =>
|
|
3988
4480
|
`<span class="prompt-badge" onclick="event.stopPropagation();openPromptInEditor(${p.prompt_id})" title="${escHtml(p.title || 'Prompt')}">${escHtml((p.title || 'Prompt').slice(0, 20))}</span>`
|
|
3989
4481
|
).join('');
|
|
3990
|
-
return `<div class="session-item ${isActive ? 'active' : ''} ${isStale ? 'stale' : ''}" onclick="
|
|
4482
|
+
return `<div class="session-item ${isActive ? 'active' : ''} ${isStale ? 'stale' : ''} ${sStatus.cls === 'running' ? 'running' : ''}" data-session-id="${id}" onclick="sessionItemClick('${id}', event)" ondblclick="sessionItemDblClick('${id}', event)">
|
|
3991
4483
|
<span class="dot"></span>
|
|
3992
4484
|
<span class="label" title="${escHtml(label)}">${escHtml(label)}</span>
|
|
4485
|
+
${statusTag}
|
|
3993
4486
|
${idleHint}
|
|
3994
4487
|
<span class="close-btn" onclick="event.stopPropagation();killSession('${id}')">×</span>
|
|
3995
4488
|
</div>${promptBadges ? `<div class="session-prompts">${promptBadges}</div>` : ''}`;
|
|
3996
4489
|
}).join('');
|
|
3997
4490
|
}
|
|
3998
4491
|
|
|
4492
|
+
// Determine session status: running (actively producing output), waiting (at prompt), idle (quiet)
|
|
4493
|
+
function getSessionStatus(s) {
|
|
4494
|
+
const now = Date.now();
|
|
4495
|
+
const recentOutput = s._lastOutputAt && (now - s._lastOutputAt) < 8000;
|
|
4496
|
+
const recentInput = s._lastInputAt && (now - s._lastInputAt) < 8000;
|
|
4497
|
+
if (s._waitingForInput) return { cls: 'waiting', text: 'Waiting' };
|
|
4498
|
+
if (recentOutput || recentInput) return { cls: 'running', text: 'Running' };
|
|
4499
|
+
return { cls: 'idle', text: 'Idle' };
|
|
4500
|
+
}
|
|
4501
|
+
|
|
4502
|
+
// Lightweight status tag updater — avoids full re-render to preserve rename inputs
|
|
4503
|
+
setInterval(function() {
|
|
4504
|
+
const list = document.getElementById('session-list');
|
|
4505
|
+
if (!list || list.querySelector('input')) return;
|
|
4506
|
+
list.querySelectorAll('.session-item[data-session-id]').forEach(el => {
|
|
4507
|
+
const id = el.dataset.sessionId;
|
|
4508
|
+
const s = state.sessions.get(id);
|
|
4509
|
+
if (!s) return;
|
|
4510
|
+
const tag = el.querySelector('.status-tag');
|
|
4511
|
+
if (!tag) return;
|
|
4512
|
+
const st = getSessionStatus(s);
|
|
4513
|
+
if (!tag.classList.contains(st.cls)) {
|
|
4514
|
+
tag.className = 'status-tag ' + st.cls;
|
|
4515
|
+
tag.textContent = st.text;
|
|
4516
|
+
el.classList.toggle('running', st.cls === 'running');
|
|
4517
|
+
}
|
|
4518
|
+
});
|
|
4519
|
+
}, 3000);
|
|
4520
|
+
|
|
4521
|
+
var _sessionClickTimer = null;
|
|
4522
|
+
function sessionItemClick(id, event) {
|
|
4523
|
+
if (_sessionClickTimer) clearTimeout(_sessionClickTimer);
|
|
4524
|
+
_sessionClickTimer = setTimeout(function() { _sessionClickTimer = null; activateTab(id); }, 250);
|
|
4525
|
+
}
|
|
4526
|
+
function sessionItemDblClick(id, event) {
|
|
4527
|
+
event.preventDefault();
|
|
4528
|
+
event.stopPropagation();
|
|
4529
|
+
if (_sessionClickTimer) { clearTimeout(_sessionClickTimer); _sessionClickTimer = null; }
|
|
4530
|
+
setTimeout(function() {
|
|
4531
|
+
var item = document.querySelector('#session-list .session-item[data-session-id="' + id + '"]');
|
|
4532
|
+
if (item) {
|
|
4533
|
+
var labelEl = item.querySelector('.label');
|
|
4534
|
+
if (labelEl) startRenameSession(id, labelEl);
|
|
4535
|
+
}
|
|
4536
|
+
}, 10);
|
|
4537
|
+
}
|
|
4538
|
+
|
|
4539
|
+
var _recentClickTimer = null;
|
|
4540
|
+
function recentItemClick(id, event) {
|
|
4541
|
+
if (_recentClickTimer) clearTimeout(_recentClickTimer);
|
|
4542
|
+
_recentClickTimer = setTimeout(function() { _recentClickTimer = null; openSessionReview(id); }, 250);
|
|
4543
|
+
}
|
|
4544
|
+
function recentItemDblClick(id, event) {
|
|
4545
|
+
event.preventDefault();
|
|
4546
|
+
event.stopPropagation();
|
|
4547
|
+
if (_recentClickTimer) { clearTimeout(_recentClickTimer); _recentClickTimer = null; }
|
|
4548
|
+
setTimeout(function() {
|
|
4549
|
+
var item = document.querySelector('.recent-item[data-session-id="' + id + '"]');
|
|
4550
|
+
if (item) {
|
|
4551
|
+
var labelEl = item.querySelector('.recent-msg-text');
|
|
4552
|
+
if (labelEl) startRenameRecentSession(id, labelEl);
|
|
4553
|
+
}
|
|
4554
|
+
}, 10);
|
|
4555
|
+
}
|
|
4556
|
+
|
|
3999
4557
|
function startRenameSession(sessionId, labelEl) {
|
|
4558
|
+
// Guard: already editing
|
|
4559
|
+
if (labelEl.querySelector('input')) return;
|
|
4000
4560
|
const currentText = labelEl.textContent.trim();
|
|
4001
4561
|
const input = document.createElement('input');
|
|
4002
4562
|
input.type = 'text';
|
|
@@ -4007,18 +4567,35 @@ function startRenameSession(sessionId, labelEl) {
|
|
|
4007
4567
|
input.focus();
|
|
4008
4568
|
input.select();
|
|
4009
4569
|
|
|
4570
|
+
let done = false;
|
|
4010
4571
|
function finish() {
|
|
4572
|
+
if (done) return;
|
|
4573
|
+
done = true;
|
|
4011
4574
|
const newName = input.value.trim();
|
|
4012
4575
|
if (newName && newName !== currentText) {
|
|
4013
|
-
//
|
|
4014
|
-
|
|
4015
|
-
|
|
4576
|
+
// Persist to DB via REST API
|
|
4577
|
+
fetch(`/api/sessions/rename?token=${state.token}`, {
|
|
4578
|
+
method: 'POST',
|
|
4579
|
+
headers: { 'Content-Type': 'application/json' },
|
|
4580
|
+
body: JSON.stringify({ sessionId, title: newName }),
|
|
4581
|
+
});
|
|
4582
|
+
// Update active session label
|
|
4583
|
+
const active = state.sessions.get(sessionId);
|
|
4584
|
+
if (active && active.meta) active.meta.label = newName;
|
|
4585
|
+
// Update recent session list
|
|
4586
|
+
const recent = allRecentSessions.find(x => x.sessionId === sessionId);
|
|
4587
|
+
if (recent) { recent.aiTitle = newName; recent.userRenamed = true; }
|
|
4588
|
+
// Update review title if this session is being reviewed
|
|
4589
|
+
if (state.reviewingSessionId === sessionId) {
|
|
4590
|
+
const reviewTitleEl = document.getElementById('review-title');
|
|
4591
|
+
if (reviewTitleEl) reviewTitleEl.textContent = newName;
|
|
4016
4592
|
}
|
|
4017
|
-
// Update local state immediately
|
|
4018
|
-
const s = state.sessions.get(sessionId);
|
|
4019
|
-
if (s && s.meta) s.meta.label = newName;
|
|
4020
4593
|
}
|
|
4594
|
+
// Remove input before re-rendering so the input-guard doesn't block
|
|
4595
|
+
input.remove();
|
|
4021
4596
|
renderSessionList();
|
|
4597
|
+
renderFilteredSessions();
|
|
4598
|
+
renderTabs();
|
|
4022
4599
|
}
|
|
4023
4600
|
|
|
4024
4601
|
input.addEventListener('blur', finish);
|
|
@@ -4099,11 +4676,18 @@ function startRenameRecentSession(sessionId, spanEl) {
|
|
|
4099
4676
|
});
|
|
4100
4677
|
// Update local data immediately
|
|
4101
4678
|
const s = allRecentSessions.find(x => x.sessionId === sessionId);
|
|
4102
|
-
if (s) s.aiTitle = newName;
|
|
4679
|
+
if (s) { s.aiTitle = newName; s.userRenamed = true; }
|
|
4103
4680
|
// Also update active session if applicable
|
|
4104
4681
|
const active = state.sessions.get(sessionId);
|
|
4105
4682
|
if (active && active.meta) active.meta.label = newName;
|
|
4683
|
+
// Update review title if this session is being reviewed
|
|
4684
|
+
if (state.reviewingSessionId === sessionId) {
|
|
4685
|
+
const reviewTitleEl = document.getElementById('review-title');
|
|
4686
|
+
if (reviewTitleEl) reviewTitleEl.textContent = newName;
|
|
4687
|
+
}
|
|
4106
4688
|
}
|
|
4689
|
+
// Remove input before re-rendering so the input-guard doesn't block
|
|
4690
|
+
input.remove();
|
|
4107
4691
|
renderFilteredSessions();
|
|
4108
4692
|
renderSessionList();
|
|
4109
4693
|
renderTabs();
|
|
@@ -4160,8 +4744,14 @@ function showCompactBannerIfStale(id, s) {
|
|
|
4160
4744
|
<button class="compact-dismiss" onclick="dismissCompactBanner('${id}')">×</button>
|
|
4161
4745
|
`;
|
|
4162
4746
|
s.container.prepend(banner);
|
|
4163
|
-
// Refit terminal since banner takes space
|
|
4164
|
-
requestAnimationFrame(() =>
|
|
4747
|
+
// Refit terminal since banner takes space — preserve scroll position
|
|
4748
|
+
requestAnimationFrame(() => {
|
|
4749
|
+
const ln = s.term.buffer.active.viewportY;
|
|
4750
|
+
const atBot = s.writer.followMode;
|
|
4751
|
+
s.writer._suppressScroll = Date.now() + 200;
|
|
4752
|
+
s.fitAddon.fit();
|
|
4753
|
+
if (!atBot) s.term.scrollToLine(ln);
|
|
4754
|
+
});
|
|
4165
4755
|
}
|
|
4166
4756
|
|
|
4167
4757
|
function compactSession(id) {
|
|
@@ -4176,13 +4766,21 @@ function dismissCompactBanner(id) {
|
|
|
4176
4766
|
const banner = s.container.querySelector('.compact-banner');
|
|
4177
4767
|
if (banner) {
|
|
4178
4768
|
banner.remove();
|
|
4179
|
-
requestAnimationFrame(() =>
|
|
4769
|
+
requestAnimationFrame(() => {
|
|
4770
|
+
const ln = s.term.buffer.active.viewportY;
|
|
4771
|
+
const atBot = s.writer.followMode;
|
|
4772
|
+
s.writer._suppressScroll = Date.now() + 200;
|
|
4773
|
+
s.fitAddon.fit();
|
|
4774
|
+
if (!atBot) s.term.scrollToLine(ln);
|
|
4775
|
+
});
|
|
4180
4776
|
}
|
|
4181
4777
|
}
|
|
4182
4778
|
}
|
|
4183
4779
|
|
|
4184
4780
|
function renderTabs() {
|
|
4185
4781
|
const tabbar = document.getElementById('tabbar');
|
|
4782
|
+
// Skip re-render if user is actively renaming a tab
|
|
4783
|
+
if (tabbar.querySelector('input')) return;
|
|
4186
4784
|
const addBtn = tabbar.querySelector('.tab-add');
|
|
4187
4785
|
|
|
4188
4786
|
// Remove old tabs
|
|
@@ -4237,20 +4835,70 @@ function renderTabs() {
|
|
|
4237
4835
|
|
|
4238
4836
|
const tab = document.createElement('div');
|
|
4239
4837
|
tab.className = `tab ${state.activeTab === id ? 'active' : ''}`;
|
|
4240
|
-
tab.
|
|
4241
|
-
tab.
|
|
4838
|
+
tab.dataset.sessionId = id;
|
|
4839
|
+
tab.draggable = true;
|
|
4840
|
+
tab.innerHTML = `<span class="tab-icon">▸</span><span class="tab-label">${escHtml(label)}</span><span class="close-tab" onclick="event.stopPropagation();killSession('${escHtml(id)}')">×</span>`;
|
|
4841
|
+
tab.onclick = function(e) { sessionItemClick(id, e); };
|
|
4842
|
+
tab.ondblclick = function(e) {
|
|
4843
|
+
e.preventDefault();
|
|
4844
|
+
e.stopPropagation();
|
|
4845
|
+
if (_sessionClickTimer) { clearTimeout(_sessionClickTimer); _sessionClickTimer = null; }
|
|
4846
|
+
setTimeout(function() {
|
|
4847
|
+
var t = document.querySelector('#tabbar .tab[data-session-id="' + id + '"] .tab-label');
|
|
4848
|
+
if (t) startRenameSession(id, t);
|
|
4849
|
+
}, 10);
|
|
4850
|
+
};
|
|
4851
|
+
tab.ondragstart = function(e) { _tabDragId = id; e.dataTransfer.effectAllowed = 'move'; e.dataTransfer.setData('text/plain', id); tab.style.opacity = '0.4'; };
|
|
4852
|
+
tab.ondragend = function() { tab.style.opacity = ''; };
|
|
4853
|
+
tab.ondragover = function(e) { e.preventDefault(); e.dataTransfer.dropEffect = 'move'; tab.classList.add('tab-drag-over'); };
|
|
4854
|
+
tab.ondragleave = function() { tab.classList.remove('tab-drag-over'); };
|
|
4855
|
+
tab.ondrop = function(e) {
|
|
4856
|
+
e.preventDefault();
|
|
4857
|
+
tab.classList.remove('tab-drag-over');
|
|
4858
|
+
if (!_tabDragId || _tabDragId === id) return;
|
|
4859
|
+
const from = state.tabOrder.indexOf(_tabDragId);
|
|
4860
|
+
const to = state.tabOrder.indexOf(id);
|
|
4861
|
+
if (from === -1 || to === -1) return;
|
|
4862
|
+
state.tabOrder.splice(from, 1);
|
|
4863
|
+
state.tabOrder.splice(to, 0, _tabDragId);
|
|
4864
|
+
_tabDragId = null;
|
|
4865
|
+
saveTabOrder();
|
|
4866
|
+
renderTabs();
|
|
4867
|
+
};
|
|
4242
4868
|
tabbar.insertBefore(tab, addBtn);
|
|
4243
4869
|
}
|
|
4244
4870
|
}
|
|
4245
4871
|
|
|
4872
|
+
// --- Tab drag-and-drop reorder ---
|
|
4873
|
+
let _tabDragId = null;
|
|
4874
|
+
function saveTabOrder() {
|
|
4875
|
+
// Save only session IDs (not panel IDs like 'rules', 'review', etc.)
|
|
4876
|
+
const sessionOrder = state.tabOrder.filter(id => state.sessions.has(id));
|
|
4877
|
+
savePref('tab_order', sessionOrder);
|
|
4878
|
+
}
|
|
4879
|
+
|
|
4246
4880
|
// --- Actions ---
|
|
4881
|
+
function getLastSessionCwd() {
|
|
4882
|
+
// Try most recently active session's cwd
|
|
4883
|
+
let latest = null;
|
|
4884
|
+
for (const [id, s] of state.sessions) {
|
|
4885
|
+
if (s.meta?.cwd && (!latest || (s.meta.lastActivity || 0) > (latest.lastActivity || 0))) {
|
|
4886
|
+
latest = s.meta;
|
|
4887
|
+
}
|
|
4888
|
+
}
|
|
4889
|
+
if (latest?.cwd) return latest.cwd;
|
|
4890
|
+
// Fall back to most recent session from filesystem
|
|
4891
|
+
if (allRecentSessions.length > 0) return allRecentSessions[0].cwd || allRecentSessions[0].project || '';
|
|
4892
|
+
return '';
|
|
4893
|
+
}
|
|
4894
|
+
|
|
4247
4895
|
function showNewSessionModal() {
|
|
4248
4896
|
document.getElementById('new-session-modal').classList.remove('hidden');
|
|
4249
|
-
document.getElementById('ns-cwd').value =
|
|
4897
|
+
document.getElementById('ns-cwd').value = getLastSessionCwd();
|
|
4250
4898
|
document.getElementById('ns-label').value = '';
|
|
4251
4899
|
document.getElementById('ns-type').value = 'claude';
|
|
4252
4900
|
onSessionTypeChange();
|
|
4253
|
-
document.getElementById('ns-
|
|
4901
|
+
document.getElementById('ns-label').focus();
|
|
4254
4902
|
}
|
|
4255
4903
|
|
|
4256
4904
|
function closeModal(id) {
|
|
@@ -4296,6 +4944,7 @@ function killSession(id) {
|
|
|
4296
4944
|
state.sessions.delete(id);
|
|
4297
4945
|
}
|
|
4298
4946
|
state.tabOrder = state.tabOrder.filter(t => t !== id);
|
|
4947
|
+
saveTabOrder();
|
|
4299
4948
|
|
|
4300
4949
|
if (state.activeTab === id) {
|
|
4301
4950
|
const next = state.tabOrder[state.tabOrder.length - 1];
|
|
@@ -4314,9 +4963,27 @@ function toggleSidebar() {
|
|
|
4314
4963
|
state.sidebarManuallyHidden = state.sidebarCollapsed;
|
|
4315
4964
|
document.getElementById('sidebar').classList.toggle('collapsed', state.sidebarCollapsed);
|
|
4316
4965
|
document.getElementById('sidebar-resize').style.display = state.sidebarCollapsed ? 'none' : '';
|
|
4317
|
-
// Refit active terminal
|
|
4966
|
+
// Refit active terminal — restore scroll position if user scrolled up
|
|
4318
4967
|
if (state.activeTab && state.sessions.has(state.activeTab)) {
|
|
4319
|
-
setTimeout(() =>
|
|
4968
|
+
setTimeout(() => {
|
|
4969
|
+
const s = state.sessions.get(state.activeTab);
|
|
4970
|
+
if (!s) return;
|
|
4971
|
+
const wasAtBottom = s.writer.followMode;
|
|
4972
|
+
const savedLine = s.term.buffer.active.viewportY;
|
|
4973
|
+
const oldCols = s.term.cols;
|
|
4974
|
+
const savedAnchor = wasAtBottom ? null : _getScrollAnchor(s.term);
|
|
4975
|
+
s.writer._suppressScroll = Date.now() + 200;
|
|
4976
|
+
s.fitAddon.fit();
|
|
4977
|
+
if (!wasAtBottom) {
|
|
4978
|
+
if (s.term.cols !== oldCols) {
|
|
4979
|
+
_restoreScrollAnchor(s.term, savedAnchor);
|
|
4980
|
+
// Invalidate prompt line positions — reflow shifts them
|
|
4981
|
+
s._promptLinesResolved = false;
|
|
4982
|
+
} else {
|
|
4983
|
+
s.term.scrollToLine(savedLine);
|
|
4984
|
+
}
|
|
4985
|
+
}
|
|
4986
|
+
}, 100);
|
|
4320
4987
|
}
|
|
4321
4988
|
}
|
|
4322
4989
|
|
|
@@ -4494,6 +5161,20 @@ document.addEventListener('keydown', (e) => {
|
|
|
4494
5161
|
swapNav();
|
|
4495
5162
|
}
|
|
4496
5163
|
}
|
|
5164
|
+
// Alt+Up/Down: navigate prompts in active terminal or review panel
|
|
5165
|
+
if (e.altKey && !e.ctrlKey && !e.metaKey && (e.key === 'ArrowUp' || e.key === 'ArrowDown')) {
|
|
5166
|
+
const dir = e.key === 'ArrowUp' ? -1 : 1;
|
|
5167
|
+
if (state.activeTab === 'review') {
|
|
5168
|
+
e.preventDefault();
|
|
5169
|
+
reviewPromptNavGo(dir);
|
|
5170
|
+
} else {
|
|
5171
|
+
const id = state.activeTab;
|
|
5172
|
+
if (id && state.sessions.has(id)) {
|
|
5173
|
+
e.preventDefault();
|
|
5174
|
+
promptNavGo(id, dir);
|
|
5175
|
+
}
|
|
5176
|
+
}
|
|
5177
|
+
}
|
|
4497
5178
|
// Escape: close modal
|
|
4498
5179
|
if (e.key === 'Escape') {
|
|
4499
5180
|
document.querySelectorAll('.modal-overlay:not(.hidden)').forEach(m => m.classList.add('hidden'));
|
|
@@ -4536,11 +5217,70 @@ function fitActiveTerminal() {
|
|
|
4536
5217
|
_fitDebounce = null;
|
|
4537
5218
|
if (state.activeTab && state.sessions.has(state.activeTab)) {
|
|
4538
5219
|
const s = state.sessions.get(state.activeTab);
|
|
5220
|
+
const buf = s.term.buffer.active;
|
|
5221
|
+
const wasAtBottom = buf.viewportY >= buf.baseY;
|
|
5222
|
+
const savedLine = buf.viewportY;
|
|
5223
|
+
const oldCols = s.term.cols;
|
|
5224
|
+
const savedAnchor = wasAtBottom ? null : _getScrollAnchor(s.term);
|
|
5225
|
+
// Suppress onScroll so reflow doesn't flip followMode
|
|
5226
|
+
s.writer._suppressScroll = Date.now() + 200;
|
|
4539
5227
|
s.fitAddon.fit();
|
|
4540
5228
|
send({ type: 'resize', id: state.activeTab, cols: s.term.cols, rows: s.term.rows });
|
|
5229
|
+
if (wasAtBottom) {
|
|
5230
|
+
s.term.scrollToBottom();
|
|
5231
|
+
} else if (s.term.cols !== oldCols) {
|
|
5232
|
+
// Cols changed — reflow shifted content, use anchor search
|
|
5233
|
+
_restoreScrollAnchor(s.term, savedAnchor);
|
|
5234
|
+
// Invalidate prompt line positions — reflow shifts them
|
|
5235
|
+
s._promptLinesResolved = false;
|
|
5236
|
+
} else {
|
|
5237
|
+
// Same cols — just restore line position
|
|
5238
|
+
s.term.scrollToLine(savedLine);
|
|
5239
|
+
}
|
|
4541
5240
|
}
|
|
4542
5241
|
});
|
|
4543
5242
|
}
|
|
5243
|
+
|
|
5244
|
+
// Content-based scroll anchor: saves text at viewport position so we can find
|
|
5245
|
+
// the same content after reflow (column change causes line wrapping shifts).
|
|
5246
|
+
function _getScrollAnchor(term) {
|
|
5247
|
+
const buf = term.buffer.active;
|
|
5248
|
+
const viewportY = buf.viewportY;
|
|
5249
|
+
// Grab a few non-empty lines near the viewport top as anchor text
|
|
5250
|
+
const anchors = [];
|
|
5251
|
+
for (let i = 0; i < 10 && anchors.length < 3; i++) {
|
|
5252
|
+
const line = buf.getLine(viewportY + i);
|
|
5253
|
+
if (line) {
|
|
5254
|
+
const text = line.translateToString(true).trim();
|
|
5255
|
+
if (text.length > 8) anchors.push({ offset: i, text });
|
|
5256
|
+
}
|
|
5257
|
+
}
|
|
5258
|
+
return { viewportY, baseY: buf.baseY, anchors };
|
|
5259
|
+
}
|
|
5260
|
+
|
|
5261
|
+
function _restoreScrollAnchor(term, anchor) {
|
|
5262
|
+
const buf = term.buffer.active;
|
|
5263
|
+
const newBaseY = buf.baseY;
|
|
5264
|
+
if (!anchor.anchors.length) {
|
|
5265
|
+
term.scrollToLine(Math.min(anchor.viewportY, newBaseY));
|
|
5266
|
+
return;
|
|
5267
|
+
}
|
|
5268
|
+
const searchText = anchor.anchors[0].text.substring(0, 30);
|
|
5269
|
+
// Full forward scan from line 0 — reflow can shift content anywhere when
|
|
5270
|
+
// the scrollback buffer is at its cap and line wrapping changes.
|
|
5271
|
+
for (let i = 0; i <= newBaseY; i++) {
|
|
5272
|
+
const line = buf.getLine(i);
|
|
5273
|
+
if (line) {
|
|
5274
|
+
const text = line.translateToString(true).trim();
|
|
5275
|
+
if (text.includes(searchText)) {
|
|
5276
|
+
term.scrollToLine(Math.max(0, i - anchor.anchors[0].offset));
|
|
5277
|
+
return;
|
|
5278
|
+
}
|
|
5279
|
+
}
|
|
5280
|
+
}
|
|
5281
|
+
// Fallback: keep same viewportY
|
|
5282
|
+
term.scrollToLine(Math.min(anchor.viewportY, newBaseY));
|
|
5283
|
+
}
|
|
4544
5284
|
new ResizeObserver(fitActiveTerminal).observe(document.getElementById('terminal-area'));
|
|
4545
5285
|
window.addEventListener('resize', fitActiveTerminal);
|
|
4546
5286
|
|
|
@@ -4552,6 +5292,7 @@ cwdInput.addEventListener('focus', () => {
|
|
|
4552
5292
|
|
|
4553
5293
|
// --- Recent Sessions ---
|
|
4554
5294
|
let allRecentSessions = [];
|
|
5295
|
+
let _convSearchPending = false;
|
|
4555
5296
|
let currentFilter = 'all';
|
|
4556
5297
|
let aiSearchMode = false;
|
|
4557
5298
|
let aiSearchDebounce = null;
|
|
@@ -4623,6 +5364,11 @@ async function loadPrefs() {
|
|
|
4623
5364
|
state._savedActiveSession = prefs.active_session;
|
|
4624
5365
|
}
|
|
4625
5366
|
|
|
5367
|
+
// Restore tab order
|
|
5368
|
+
if (prefs.tab_order && Array.isArray(prefs.tab_order)) {
|
|
5369
|
+
state._savedTabOrder = prefs.tab_order;
|
|
5370
|
+
}
|
|
5371
|
+
|
|
4626
5372
|
// Restore code review tree width
|
|
4627
5373
|
if (prefs.cr_tree_width) {
|
|
4628
5374
|
state._savedCrTreeWidth = prefs.cr_tree_width;
|
|
@@ -4734,6 +5480,11 @@ async function loadRecentSessions() {
|
|
|
4734
5480
|
reviewSession(s.sessionId, s.projectEntry, sessionDisplayText(s), s);
|
|
4735
5481
|
}
|
|
4736
5482
|
}
|
|
5483
|
+
|
|
5484
|
+
// Re-scan prompts for active sessions now that we have projectEntry data
|
|
5485
|
+
for (const [id, s] of state.sessions) {
|
|
5486
|
+
if (s.term) scanPromptLines(id);
|
|
5487
|
+
}
|
|
4737
5488
|
}
|
|
4738
5489
|
|
|
4739
5490
|
const CTM_SESSION_PATTERNS = [
|
|
@@ -4777,16 +5528,70 @@ function getFilteredSessions() {
|
|
|
4777
5528
|
let titleGenInProgress = false;
|
|
4778
5529
|
|
|
4779
5530
|
function renderFilteredSessions() {
|
|
5531
|
+
// Skip re-render if user is actively renaming a session in the recent list
|
|
5532
|
+
const recentList = document.getElementById('recent-list');
|
|
5533
|
+
if (recentList && recentList.querySelector('input[type="text"]')) return;
|
|
4780
5534
|
const q = document.getElementById('recent-search').value.toLowerCase();
|
|
4781
5535
|
let sessions = getFilteredSessions();
|
|
4782
5536
|
if (q && !aiSearchMode) {
|
|
4783
|
-
|
|
4784
|
-
|
|
4785
|
-
|
|
4786
|
-
|
|
4787
|
-
|
|
4788
|
-
(s.
|
|
4789
|
-
|
|
5537
|
+
// First filter local metadata
|
|
5538
|
+
const metaMatches = new Set();
|
|
5539
|
+
const recentIds = new Set(sessions.map(s => s.sessionId));
|
|
5540
|
+
sessions = sessions.filter(s => {
|
|
5541
|
+
// Also check active session label (tab name)
|
|
5542
|
+
const activeLabel = state.sessions.get(s.sessionId)?.meta?.label || '';
|
|
5543
|
+
const match = (s.firstMessage || '').toLowerCase().includes(q) ||
|
|
5544
|
+
(s.aiTitle || '').toLowerCase().includes(q) ||
|
|
5545
|
+
s.project.toLowerCase().includes(q) ||
|
|
5546
|
+
s.sessionId.toLowerCase().includes(q) ||
|
|
5547
|
+
(s.gitBranch || '').toLowerCase().includes(q) ||
|
|
5548
|
+
activeLabel.toLowerCase().includes(q);
|
|
5549
|
+
if (match) metaMatches.add(s.sessionId);
|
|
5550
|
+
return match;
|
|
5551
|
+
});
|
|
5552
|
+
// Include active sessions matching by label that aren't yet in allRecentSessions
|
|
5553
|
+
for (const [id, s] of state.sessions) {
|
|
5554
|
+
if (recentIds.has(id) || metaMatches.has(id)) continue;
|
|
5555
|
+
const label = s.meta?.label || '';
|
|
5556
|
+
if (label.toLowerCase().includes(q)) {
|
|
5557
|
+
sessions.push({
|
|
5558
|
+
sessionId: id,
|
|
5559
|
+
project: s.meta?.cwd || '',
|
|
5560
|
+
projectEntry: '',
|
|
5561
|
+
cwd: s.meta?.cwd || '',
|
|
5562
|
+
firstMessage: '',
|
|
5563
|
+
title: label,
|
|
5564
|
+
aiTitle: label,
|
|
5565
|
+
isEmpty: true,
|
|
5566
|
+
userMsgCount: 0,
|
|
5567
|
+
modifiedAt: new Date(s.meta?.createdAt || Date.now()).toISOString(),
|
|
5568
|
+
timestamp: new Date(s.meta?.createdAt || Date.now()).toISOString(),
|
|
5569
|
+
version: '',
|
|
5570
|
+
gitBranch: '',
|
|
5571
|
+
fileSize: 0,
|
|
5572
|
+
});
|
|
5573
|
+
metaMatches.add(id);
|
|
5574
|
+
}
|
|
5575
|
+
}
|
|
5576
|
+
// Also search imported conversations in DB (async, will re-render when results arrive)
|
|
5577
|
+
if (sessions.length === 0 && q.length >= 3 && !_convSearchPending) {
|
|
5578
|
+
_convSearchPending = true;
|
|
5579
|
+
fetch('/api/conversations?search=' + encodeURIComponent(q) + '&limit=20&all_devices=1&token=' + (state.token || ''))
|
|
5580
|
+
.then(r => r.json())
|
|
5581
|
+
.then(data => {
|
|
5582
|
+
_convSearchPending = false;
|
|
5583
|
+
const convResults = (data.backups ? [] : (Array.isArray(data) ? data : []));
|
|
5584
|
+
if (convResults.length === 0) return;
|
|
5585
|
+
// Merge conversation matches into session list
|
|
5586
|
+
for (const c of convResults) {
|
|
5587
|
+
if (metaMatches.has(c.session_id)) continue;
|
|
5588
|
+
const existing = allRecentSessions.find(s => s.sessionId === c.session_id);
|
|
5589
|
+
if (existing && !sessions.includes(existing)) sessions.push(existing);
|
|
5590
|
+
}
|
|
5591
|
+
if (sessions.length > 0) renderRecentSessions(sessions);
|
|
5592
|
+
})
|
|
5593
|
+
.catch(() => { _convSearchPending = false; });
|
|
5594
|
+
}
|
|
4790
5595
|
}
|
|
4791
5596
|
|
|
4792
5597
|
// Sort: pinned first (in user-defined order), then by modifiedAt
|
|
@@ -4877,7 +5682,7 @@ function sessionItemHtml(s) {
|
|
|
4877
5682
|
const isPinned = pinnedSessionIds.includes(s.sessionId);
|
|
4878
5683
|
const pinCls = isPinned ? ' pinned' : '';
|
|
4879
5684
|
const dragAttrs = isPinned ? ` draggable="true" ondragstart="pinDragStart(event)" ondragover="pinDragOver(event)" ondragleave="pinDragLeave(event)" ondrop="pinDrop(event)" ondragend="pinDragEnd(event)"` : '';
|
|
4880
|
-
return `<div class="recent-item${reviewing}${pinCls}" data-session-id="${s.sessionId}"${dragAttrs} onclick="
|
|
5685
|
+
return `<div class="recent-item${reviewing}${pinCls}" data-session-id="${s.sessionId}"${dragAttrs} onclick="recentItemClick('${s.sessionId}', event)" ondblclick="recentItemDblClick('${s.sessionId}', event)" oncontextmenu="event.preventDefault();togglePinSession('${s.sessionId}')">
|
|
4881
5686
|
<div class="recent-msg"><span class="pin-icon" style="${isPinned ? 'cursor:grab;' : ''}">★</span><span class="recent-msg-text">${escHtml(displayText)}</span>${emptyTag}${deviceTag}</div>
|
|
4882
5687
|
<div class="recent-meta">
|
|
4883
5688
|
<span class="project">${escHtml(project)}</span>
|
|
@@ -5201,6 +6006,9 @@ async function reviewSession(sessionId, projectEntry, title, sessionData) {
|
|
|
5201
6006
|
// Apply tool-only visibility
|
|
5202
6007
|
toggleToolMsgs();
|
|
5203
6008
|
|
|
6009
|
+
// Update review prompt navigation
|
|
6010
|
+
reviewPromptNavReset();
|
|
6011
|
+
|
|
5204
6012
|
renderTabs();
|
|
5205
6013
|
} catch (e) {
|
|
5206
6014
|
document.getElementById('review-messages').innerHTML = `<div style="padding:20px;color:var(--red)">Failed to load: ${escHtml(e.message)}</div>`;
|
|
@@ -5634,6 +6442,99 @@ function scrollReviewToBottom() {
|
|
|
5634
6442
|
if (el) el.scrollTop = el.scrollHeight;
|
|
5635
6443
|
}
|
|
5636
6444
|
|
|
6445
|
+
// --- Review Prompt Navigation ---
|
|
6446
|
+
let _reviewPromptEls = [];
|
|
6447
|
+
let _reviewPromptIdx = -1;
|
|
6448
|
+
|
|
6449
|
+
function reviewPromptNavReset() {
|
|
6450
|
+
const container = document.getElementById('review-messages');
|
|
6451
|
+
_reviewPromptEls = container ? Array.from(container.querySelectorAll('.review-msg.user')) : [];
|
|
6452
|
+
_reviewPromptIdx = -1;
|
|
6453
|
+
reviewPromptNavUpdateBadge();
|
|
6454
|
+
}
|
|
6455
|
+
|
|
6456
|
+
function reviewPromptNavGo(dir) {
|
|
6457
|
+
if (_reviewPromptEls.length === 0) return;
|
|
6458
|
+
const container = document.getElementById('review-messages');
|
|
6459
|
+
if (!container) return;
|
|
6460
|
+
|
|
6461
|
+
let newIdx;
|
|
6462
|
+
if (_reviewPromptIdx < 0) {
|
|
6463
|
+
// First navigation — find prompt in the direction the user wants
|
|
6464
|
+
const scrollMid = container.scrollTop + container.clientHeight / 2;
|
|
6465
|
+
if (dir < 0) {
|
|
6466
|
+
newIdx = 0;
|
|
6467
|
+
for (let i = _reviewPromptEls.length - 1; i >= 0; i--) {
|
|
6468
|
+
if (_reviewPromptEls[i].offsetTop <= scrollMid) { newIdx = i; break; }
|
|
6469
|
+
}
|
|
6470
|
+
} else {
|
|
6471
|
+
newIdx = _reviewPromptEls.length - 1;
|
|
6472
|
+
for (let i = 0; i < _reviewPromptEls.length; i++) {
|
|
6473
|
+
if (_reviewPromptEls[i].offsetTop >= scrollMid) { newIdx = i; break; }
|
|
6474
|
+
}
|
|
6475
|
+
}
|
|
6476
|
+
} else {
|
|
6477
|
+
newIdx = _reviewPromptIdx + dir;
|
|
6478
|
+
}
|
|
6479
|
+
if (newIdx < 0 || newIdx >= _reviewPromptEls.length) return;
|
|
6480
|
+
_reviewPromptIdx = newIdx;
|
|
6481
|
+
|
|
6482
|
+
// Scroll the message into view
|
|
6483
|
+
_reviewPromptEls[newIdx].scrollIntoView({ behavior: 'smooth', block: 'start' });
|
|
6484
|
+
|
|
6485
|
+
// Brief highlight
|
|
6486
|
+
_reviewPromptEls[newIdx].style.outline = '2px solid var(--accent)';
|
|
6487
|
+
setTimeout(() => { if (_reviewPromptEls[newIdx]) _reviewPromptEls[newIdx].style.outline = ''; }, 1200);
|
|
6488
|
+
|
|
6489
|
+
reviewPromptNavUpdateBadge();
|
|
6490
|
+
}
|
|
6491
|
+
|
|
6492
|
+
function reviewPromptNavUpdateBadge() {
|
|
6493
|
+
const nav = document.getElementById('review-prompt-nav');
|
|
6494
|
+
if (!nav) return;
|
|
6495
|
+
const badge = nav.querySelector('.prompt-nav-badge');
|
|
6496
|
+
const btns = nav.querySelectorAll('.prompt-nav-btn');
|
|
6497
|
+
const total = _reviewPromptEls.length;
|
|
6498
|
+
const idx = _reviewPromptIdx;
|
|
6499
|
+
badge.textContent = total === 0 ? '0 prompts' : (idx >= 0 ? (idx + 1) + '/' + total : total + (total === 1 ? ' prompt' : ' prompts'));
|
|
6500
|
+
btns[0].disabled = total === 0 || idx <= 0;
|
|
6501
|
+
btns[1].disabled = total === 0 || idx >= total - 1;
|
|
6502
|
+
}
|
|
6503
|
+
|
|
6504
|
+
function reviewPromptNavToggleList() {
|
|
6505
|
+
const nav = document.getElementById('review-prompt-nav');
|
|
6506
|
+
if (!nav) return;
|
|
6507
|
+
const existing = nav.querySelector('.prompt-nav-list');
|
|
6508
|
+
if (existing) { existing.remove(); return; }
|
|
6509
|
+
if (_reviewPromptEls.length === 0) return;
|
|
6510
|
+
|
|
6511
|
+
const list = document.createElement('div');
|
|
6512
|
+
list.className = 'prompt-nav-list';
|
|
6513
|
+
// Show most recent first
|
|
6514
|
+
for (let i = _reviewPromptEls.length - 1; i >= 0; i--) {
|
|
6515
|
+
const msgText = _reviewPromptEls[i].querySelector('.msg-text');
|
|
6516
|
+
let text = msgText ? msgText.textContent.trim() : '(empty)';
|
|
6517
|
+
if (text.length > 80) text = text.slice(0, 80) + '\u2026';
|
|
6518
|
+
const item = document.createElement('div');
|
|
6519
|
+
item.className = 'prompt-nav-list-item' + (i === _reviewPromptIdx ? ' current' : '');
|
|
6520
|
+
item.textContent = text;
|
|
6521
|
+
item.onclick = (function(idx) { return function() {
|
|
6522
|
+
_reviewPromptIdx = idx;
|
|
6523
|
+
_reviewPromptEls[idx].scrollIntoView({ behavior: 'smooth', block: 'start' });
|
|
6524
|
+
_reviewPromptEls[idx].style.outline = '2px solid var(--accent)';
|
|
6525
|
+
setTimeout(() => { if (_reviewPromptEls[idx]) _reviewPromptEls[idx].style.outline = ''; }, 1200);
|
|
6526
|
+
reviewPromptNavUpdateBadge();
|
|
6527
|
+
list.remove();
|
|
6528
|
+
}; })(i);
|
|
6529
|
+
list.appendChild(item);
|
|
6530
|
+
}
|
|
6531
|
+
nav.appendChild(list);
|
|
6532
|
+
setTimeout(() => {
|
|
6533
|
+
function close(e) { if (!list.contains(e.target)) { list.remove(); document.removeEventListener('click', close); } }
|
|
6534
|
+
document.addEventListener('click', close);
|
|
6535
|
+
}, 0);
|
|
6536
|
+
}
|
|
6537
|
+
|
|
5637
6538
|
(function() {
|
|
5638
6539
|
const el = document.getElementById('review-messages');
|
|
5639
6540
|
const btn = document.getElementById('review-scroll-bottom');
|
|
@@ -7194,6 +8095,8 @@ let _titleFlashStop = null;
|
|
|
7194
8095
|
|
|
7195
8096
|
// Clear waiting state for a session (tab indicator + title flash)
|
|
7196
8097
|
function clearWaitingState(sessionId) {
|
|
8098
|
+
const s = state.sessions.get(sessionId);
|
|
8099
|
+
if (s) { s._waitingForInput = false; s._lastInputAt = Date.now(); }
|
|
7197
8100
|
// Remove pulsing tab indicator
|
|
7198
8101
|
const tabs = document.querySelectorAll('#tabbar .tab');
|
|
7199
8102
|
const tabOrder = state.tabOrder;
|
|
@@ -7244,9 +8147,13 @@ function playNotificationSound(type) {
|
|
|
7244
8147
|
} catch (e) { /* Audio API may not be available */ }
|
|
7245
8148
|
}
|
|
7246
8149
|
|
|
8150
|
+
// Dedup tracking: { sessionId -> { reason, timestamp } }
|
|
8151
|
+
const _lastNotif = {};
|
|
8152
|
+
|
|
7247
8153
|
function onWaitingForInput(msg) {
|
|
7248
8154
|
const sessionId = msg.id;
|
|
7249
8155
|
const session = state.sessions.get(sessionId);
|
|
8156
|
+
if (session) session._waitingForInput = true;
|
|
7250
8157
|
const label = msg.label || session?.meta?.label || sessionId.slice(0, 8);
|
|
7251
8158
|
const reason = msg.reason || 'input';
|
|
7252
8159
|
const reasonText = reason === 'approval' ? 'Needs approval'
|
|
@@ -7269,7 +8176,17 @@ function onWaitingForInput(msg) {
|
|
|
7269
8176
|
}
|
|
7270
8177
|
}
|
|
7271
8178
|
|
|
7272
|
-
if (userIsViewing)
|
|
8179
|
+
if (userIsViewing) {
|
|
8180
|
+
// Clear dedup state when user views the session so next event fires fresh
|
|
8181
|
+
delete _lastNotif[sessionId];
|
|
8182
|
+
return;
|
|
8183
|
+
}
|
|
8184
|
+
|
|
8185
|
+
// Dedup: skip if same session+reason notified within 30s
|
|
8186
|
+
const now = Date.now();
|
|
8187
|
+
const prev = _lastNotif[sessionId];
|
|
8188
|
+
if (prev && prev.reason === reason && (now - prev.ts) < 30000) return;
|
|
8189
|
+
_lastNotif[sessionId] = { reason, ts: now };
|
|
7273
8190
|
|
|
7274
8191
|
// 2. Play notification sound
|
|
7275
8192
|
playNotificationSound(reason === 'approval' ? 'alert' : 'chime');
|
|
@@ -7387,6 +8304,7 @@ window.addEventListener('message', (e) => {
|
|
|
7387
8304
|
const activeSession = state.activeTab && state.sessions.get(state.activeTab);
|
|
7388
8305
|
if (activeSession) {
|
|
7389
8306
|
send({ type: 'input', id: state.activeTab, data: e.data.text + '\n' });
|
|
8307
|
+
clearWaitingState(state.activeTab);
|
|
7390
8308
|
}
|
|
7391
8309
|
}
|
|
7392
8310
|
});
|
|
@@ -7511,7 +8429,7 @@ window.addEventListener('hashchange', handleHashRoute);
|
|
|
7511
8429
|
|
|
7512
8430
|
// --- Init ---
|
|
7513
8431
|
connect();
|
|
7514
|
-
loadPrefs().then(() => {
|
|
8432
|
+
state._prefsLoaded = loadPrefs().then(() => {
|
|
7515
8433
|
loadRecentSessions();
|
|
7516
8434
|
// Restore hash from saved nav if no hash present
|
|
7517
8435
|
handleHashRoute();
|
|
@@ -7526,10 +8444,10 @@ function onQueueState(msg) {
|
|
|
7526
8444
|
// msg has: sessionId, mode, status, currentIndex, items, idleTimeoutMs
|
|
7527
8445
|
queueState = msg;
|
|
7528
8446
|
renderQueueBar();
|
|
7529
|
-
// Scroll terminal to bottom when queue is active
|
|
8447
|
+
// Scroll terminal to bottom when queue is active (only if user hasn't scrolled up)
|
|
7530
8448
|
if (msg.sessionId) {
|
|
7531
8449
|
const s = state.sessions.get(msg.sessionId);
|
|
7532
|
-
if (s) s.term.scrollToBottom();
|
|
8450
|
+
if (s && s.writer.followMode) s.term.scrollToBottom();
|
|
7533
8451
|
}
|
|
7534
8452
|
}
|
|
7535
8453
|
|
|
@@ -8059,6 +8977,15 @@ function removeQpItem(idx) {
|
|
|
8059
8977
|
renderQpItems();
|
|
8060
8978
|
}
|
|
8061
8979
|
|
|
8980
|
+
function resendQpItem(idx) {
|
|
8981
|
+
if (idx >= 0 && idx < qpItems.length) {
|
|
8982
|
+
qpItems[idx].status = 'pending';
|
|
8983
|
+
saveQpDraft();
|
|
8984
|
+
renderQpItems();
|
|
8985
|
+
toast('Item reset to pending — will be sent next', { type: 'info' });
|
|
8986
|
+
}
|
|
8987
|
+
}
|
|
8988
|
+
|
|
8062
8989
|
function toggleQpMode() {
|
|
8063
8990
|
qpMode = qpMode === 'auto' ? 'manual' : 'auto';
|
|
8064
8991
|
updateQpModeBtn();
|
|
@@ -8382,6 +9309,7 @@ function renderQpItems() {
|
|
|
8382
9309
|
</div>
|
|
8383
9310
|
<div style="display:flex;align-items:center;gap:2px;padding-top:2px;flex-shrink:0;">
|
|
8384
9311
|
<span style="font-size:9px;color:var(--fg-dim);background:var(--bg-lighter);padding:1px 5px;border-radius:8px;">${item.type === 'prompt' ? '#' + item.promptId : 'inline'}</span>
|
|
9312
|
+
${isSent && !isEditing ? `<button onclick="resendQpItem(${idx})" style="background:none;border:none;color:var(--fg-dim);cursor:pointer;font-size:10px;padding:0 2px;line-height:1;" onmouseover="this.style.color='var(--accent)'" onmouseout="this.style.color='var(--fg-dim)'" title="Re-send this item">↻</button>` : ''}
|
|
8385
9313
|
${!isEditing && item.type === 'inline' ? `<button onclick="saveQpItemAsPrompt(${idx})" style="background:none;border:none;color:var(--fg-dim);cursor:pointer;font-size:10px;padding:0 2px;line-height:1;" onmouseover="this.style.color='var(--green)'" onmouseout="this.style.color='var(--fg-dim)'" title="Save as prompt">💾</button>` : ''}
|
|
8386
9314
|
${!isEditing ? `<button onclick="startQpEdit(${idx})" style="background:none;border:none;color:var(--fg-dim);cursor:pointer;font-size:11px;padding:0 2px;line-height:1;" onmouseover="this.style.color='var(--accent)'" onmouseout="this.style.color='var(--fg-dim)'" title="Edit">✎</button>` : ''}
|
|
8387
9315
|
<button onclick="removeQpItem(${idx})" style="background:none;border:none;color:var(--fg-dim);cursor:pointer;font-size:14px;padding:0 2px;line-height:1;" onmouseover="this.style.color='var(--red)'" onmouseout="this.style.color='var(--fg-dim)'">×</button>
|