create-walle 0.9.0 → 0.9.3

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.
Files changed (45) hide show
  1. package/README.md +35 -31
  2. package/package.json +3 -3
  3. package/template/CLAUDE.md +23 -1
  4. package/template/claude-task-manager/bin/restart-ctm.sh +3 -2
  5. package/template/claude-task-manager/db.js +38 -0
  6. package/template/claude-task-manager/public/css/walle.css +123 -0
  7. package/template/claude-task-manager/public/index.html +962 -69
  8. package/template/claude-task-manager/public/js/walle.js +374 -121
  9. package/template/claude-task-manager/public/prompts.html +84 -26
  10. package/template/claude-task-manager/public/walle-icon.svg +45 -0
  11. package/template/claude-task-manager/server.js +69 -4
  12. package/template/docs/openclaw-vs-walle-comparison.md +103 -0
  13. package/template/package.json +1 -1
  14. package/template/wall-e/agent.js +63 -3
  15. package/template/wall-e/api-walle.js +42 -0
  16. package/template/wall-e/brain.js +182 -5
  17. package/template/wall-e/channels/imessage-channel.js +4 -1
  18. package/template/wall-e/channels/slack-channel.js +3 -1
  19. package/template/wall-e/chat.js +106 -224
  20. package/template/wall-e/context/compactor.js +163 -0
  21. package/template/wall-e/context/context-builder.js +355 -0
  22. package/template/wall-e/context/state-snapshot.js +209 -0
  23. package/template/wall-e/context/token-counter.js +55 -0
  24. package/template/wall-e/context/topic-matcher.js +79 -0
  25. package/template/wall-e/core-tasks.js +24 -0
  26. package/template/wall-e/events/event-bus.js +23 -0
  27. package/template/wall-e/loops/ingest.js +4 -0
  28. package/template/wall-e/loops/initiative.js +316 -0
  29. package/template/wall-e/loops/tasks.js +55 -5
  30. package/template/wall-e/skills/_bundled/email-sync/run.js +3 -1
  31. package/template/wall-e/skills/_bundled/morning-briefing/run.js +41 -0
  32. package/template/wall-e/skills/_bundled/proactive-alerts/SKILL.md +20 -0
  33. package/template/wall-e/skills/_bundled/proactive-alerts/run.js +144 -0
  34. package/template/wall-e/skills/_bundled/slack-mentions/.watched-threads.json +18 -0
  35. package/template/wall-e/skills/_bundled/slack-mentions/.watermark.json +4 -0
  36. package/template/wall-e/skills/_bundled/slack-mentions/SKILL.md +52 -0
  37. package/template/wall-e/skills/_bundled/slack-mentions/run.js +470 -0
  38. package/template/wall-e/skills/_bundled/weekly-reflection/SKILL.md +69 -0
  39. package/template/wall-e/tests/brain.test.js +4 -4
  40. package/template/wall-e/tests/compactor.test.js +323 -0
  41. package/template/wall-e/tests/context-builder.test.js +215 -0
  42. package/template/wall-e/tests/event-bus.test.js +74 -0
  43. package/template/wall-e/tests/initiative.test.js +354 -0
  44. package/template/wall-e/tests/proactive-alerts.test.js +140 -0
  45. package/template/wall-e/tests/session-persistence.test.js +335 -0
@@ -251,10 +251,12 @@
251
251
  .session-item .dot {
252
252
  width: 7px; height: 7px;
253
253
  border-radius: 50%;
254
- background: var(--green);
254
+ background: var(--fg-dim);
255
255
  flex-shrink: 0;
256
+ opacity: 0.4;
256
257
  }
257
- .session-item.stale .dot { background: var(--yellow, #e0af68); }
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;">&#9662;</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="createSession()" style="font-size:15px;padding:8px 24px;margin-bottom:28px;">New Claude Session</button>
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+&#x2191;)" disabled>&#x25B2;</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+&#x2193;)" disabled>&#x25BC;</button>
2447
+ </div>
2361
2448
  <div class="review-actions" id="review-actions"></div>
2362
2449
  </div>
2363
2450
  </div>
@@ -2391,6 +2478,7 @@
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>
@@ -3110,8 +3198,11 @@ function connect() {
3110
3198
  setTimeout(() => {
3111
3199
  if (state.ws?.readyState === 1) {
3112
3200
  overlay.classList.remove('active');
3201
+ const wasRestarting = state._restarting;
3113
3202
  state._restarting = false;
3114
- 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();
3115
3206
  } else {
3116
3207
  setTimeout(tryReconnect, state._restarting ? 1000 : 2000);
3117
3208
  }
@@ -3121,6 +3212,30 @@ function connect() {
3121
3212
  };
3122
3213
  }
3123
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
+
3124
3239
  function send(msg) {
3125
3240
  if (state.ws?.readyState === 1) {
3126
3241
  state.ws.send(JSON.stringify(msg));
@@ -3212,7 +3327,7 @@ async function restartAll() {
3212
3327
  await new Promise(r => setTimeout(r, 500));
3213
3328
  state._restarting = true;
3214
3329
  try {
3215
- await fetch(`/api/restart/ctm?token=${state.token}`, { method: 'POST' });
3330
+ await fetch(`/api/restart/ctm?token=${state.token}&force=true`, { method: 'POST' });
3216
3331
  } catch { /* expected — server dying */ }
3217
3332
  }
3218
3333
 
@@ -3239,7 +3354,7 @@ async function svcAction(service, action) {
3239
3354
  state._restarting = true;
3240
3355
  hideAppMenu();
3241
3356
  try {
3242
- await fetch(`/api/restart/ctm?token=${state.token}`, { method: 'POST' });
3357
+ await fetch(`/api/restart/ctm?token=${state.token}&force=true`, { method: 'POST' });
3243
3358
  } catch { /* expected — server dying */ }
3244
3359
  }
3245
3360
  }
@@ -3265,6 +3380,19 @@ function createTerminal(id) {
3265
3380
  reviewBtn.textContent = '\u{1F50D} Review';
3266
3381
  reviewBtn.onclick = function() { openSessionReview(id); };
3267
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>&#x25B2;</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>&#x25BC;</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);
3268
3396
  container.appendChild(toolbar);
3269
3397
 
3270
3398
  document.getElementById('terminal-area').appendChild(container);
@@ -3310,29 +3438,309 @@ function createTerminal(id) {
3310
3438
  term.onData((data) => {
3311
3439
  send({ type: 'input', id, data });
3312
3440
  clearWaitingState(id);
3313
- // User typed something — scroll to bottom so they see the response
3441
+ // User typed something — scroll to bottom and re-enable follow mode
3314
3442
  const s = state.sessions.get(id);
3315
- if (s) s.term.scrollToBottom();
3443
+ if (s) { s.writer.followMode = true; s.term.scrollToBottom(); }
3316
3444
  });
3317
3445
 
3318
3446
  term.onResize(({ cols, rows }) => {
3447
+ const s = state.sessions.get(id);
3448
+ if (s && s._suppressResize) return; // Skip during font metric refresh
3319
3449
  send({ type: 'resize', id, cols, rows });
3320
3450
  });
3321
3451
 
3322
- // Auto-scroll is handled in onOutput using xterm.js buffer API (viewportY vs baseY).
3323
- // No manual tracking needed xterm.js buffer is the source of truth.
3324
- //
3325
- // Safety: clicking the terminal forces a fit() recalculation, fixing any scroll desync
3326
- // that may occur after Claude Code's TUI exits or terminal state changes.
3327
- container.addEventListener('click', () => {
3328
- const s = state.sessions.get(id);
3329
- if (s) s.fitAddon.fit();
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
+ }
3330
3466
  });
3331
3467
 
3332
- state.sessions.set(id, { term, fitAddon, container, needsFontRefresh: true });
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 });
3333
3519
  return { term, fitAddon, container };
3334
3520
  }
3335
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
+
3336
3744
  function activateTab(id) {
3337
3745
  const specialPanels = ['rules', 'insights', 'permissions', 'prompts', 'codereview', 'walle', 'backups'];
3338
3746
  const isPanel = specialPanels.includes(id);
@@ -3388,20 +3796,24 @@ function activateTab(id) {
3388
3796
  } else if (state.sessions.has(id)) {
3389
3797
  const s = state.sessions.get(id);
3390
3798
  s.container.classList.add('active');
3391
- // Force xterm.js to re-measure font metrics if terminal was opened while hidden
3392
- // (must happen synchronously after container becomes visible, before scrollback arrives)
3393
- if (s.needsFontRefresh) {
3394
- s.needsFontRefresh = false;
3395
- const size = s.term.options.fontSize;
3396
- s.term.options.fontSize = size + 1;
3397
- s.term.options.fontSize = size;
3398
- }
3399
3799
  // Update hash so refresh re-attaches to this session
3400
3800
  history.replaceState(null, '', location.pathname + location.search + '#session=' + id);
3401
3801
  savePref('active_session', id);
3402
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
+ }
3403
3814
  s.fitAddon.fit();
3404
3815
  send({ type: 'resize', id, cols: s.term.cols, rows: s.term.rows });
3816
+ s.writer.followMode = true;
3405
3817
  s.term.scrollToBottom();
3406
3818
  // If this session needs to attach (reconnect), do it after fit so PTY gets correct size
3407
3819
  if (s.needsAttach) {
@@ -3800,6 +4212,7 @@ function onCreated(msg) {
3800
4212
 
3801
4213
  if (!state.tabOrder.includes(id)) {
3802
4214
  state.tabOrder.push(id);
4215
+ saveTabOrder();
3803
4216
  }
3804
4217
  activateTab(id);
3805
4218
 
@@ -3820,39 +4233,91 @@ function onCreated(msg) {
3820
4233
  if (typeof onSkillSessionCreated === 'function') {
3821
4234
  onSkillSessionCreated(msg);
3822
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);
3823
4240
  }
3824
4241
 
3825
4242
  function onOutput(msg) {
3826
4243
  const s = state.sessions.get(msg.id);
3827
4244
  if (!s) return;
4245
+ s._lastOutputAt = Date.now();
4246
+ s._waitingForInput = false;
3828
4247
  // Only strip \e[3J (Erase Scrollback) — preserves scroll history.
3829
4248
  // Do NOT strip \e[2J or \e[?1049h/l — needed for Claude Code's TUI.
3830
4249
  const data = msg.data.replace(/\x1b\[3J/g, '');
3831
- // Auto-scroll using xterm.js buffer API: if viewport is at the bottom of the
3832
- // scrollback, keep it there. If user has scrolled up, don't interrupt.
3833
- // buffer.active.viewportY === buffer.active.baseY means "at bottom".
3834
- const buf = s.term.buffer.active;
3835
- const wasAtBottom = buf.viewportY >= buf.baseY;
3836
- if (state.activeTab === msg.id && wasAtBottom) {
3837
- s.term.write(data, () => {
3838
- s.term.scrollToBottom();
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
+ }
3839
4281
  });
3840
- } else {
3841
- s.term.write(data);
3842
4282
  }
3843
- // After output stops for 500ms, force xterm to recalculate viewport dimensions.
3844
- // This fixes a desync between xterm's internal buffer and the DOM viewport that
3845
- // can occur when Claude Code's TUI exits (Ink unmount, alt screen buffer exit).
3846
- // 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).
3847
4286
  clearTimeout(s._outputIdleTimer);
3848
4287
  s._outputIdleTimer = setTimeout(() => {
3849
4288
  if (state.activeTab === msg.id) {
3850
- s.fitAddon.fit();
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
+ }
3851
4316
  }
3852
- }, 500);
4317
+ }, 300);
3853
4318
  // Remove compact banner on new activity
3854
4319
  const banner = s.container.querySelector('.compact-banner');
3855
- 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); }); }
3856
4321
  // Keep focus on the active session's terminal, but don't steal focus from
3857
4322
  // input elements (e.g. queue panel textarea, search boxes)
3858
4323
  if (state.activeTab === msg.id && document.activeElement !== s.term.textarea) {
@@ -3886,6 +4351,8 @@ function onScrollback(msg) {
3886
4351
  s.term.clear();
3887
4352
  s.term.write(msg.data, () => {
3888
4353
  s.term.scrollToBottom();
4354
+ // Scan for prompt lines after scrollback is loaded
4355
+ scanPromptLines(msg.id);
3889
4356
  });
3890
4357
 
3891
4358
  // Send resize to ensure PTY matches this client's dimensions
@@ -3902,7 +4369,10 @@ function onExit(msg) {
3902
4369
  }
3903
4370
  }
3904
4371
 
3905
- 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
+
3906
4376
  // Update sidebar - merge with existing data
3907
4377
  const serverIds = new Set(msg.sessions.map(s => s.id));
3908
4378
 
@@ -3937,6 +4407,23 @@ function onSessionsList(msg) {
3937
4407
  if (existing) existing.meta = sess;
3938
4408
  }
3939
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
+
3940
4427
  renderSessionList();
3941
4428
  renderTabs();
3942
4429
 
@@ -3971,6 +4458,8 @@ function onSessionsList(msg) {
3971
4458
  // --- UI Rendering ---
3972
4459
  function renderSessionList() {
3973
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;
3974
4463
  const sessions = Array.from(state.sessions.entries()).filter(([id, s]) => s.meta);
3975
4464
 
3976
4465
  if (sessions.length === 0) {
@@ -3985,19 +4474,89 @@ function renderSessionList() {
3985
4474
  const idleMs = Date.now() - lastAct;
3986
4475
  const isStale = idleMs > 24 * 60 * 60 * 1000;
3987
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>`;
3988
4479
  const promptBadges = (s.linkedPrompts || []).map(p =>
3989
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>`
3990
4481
  ).join('');
3991
- return `<div class="session-item ${isActive ? 'active' : ''} ${isStale ? 'stale' : ''}" onclick="activateTab('${id}')">
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)">
3992
4483
  <span class="dot"></span>
3993
4484
  <span class="label" title="${escHtml(label)}">${escHtml(label)}</span>
4485
+ ${statusTag}
3994
4486
  ${idleHint}
3995
4487
  <span class="close-btn" onclick="event.stopPropagation();killSession('${id}')">&times;</span>
3996
4488
  </div>${promptBadges ? `<div class="session-prompts">${promptBadges}</div>` : ''}`;
3997
4489
  }).join('');
3998
4490
  }
3999
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
+
4000
4557
  function startRenameSession(sessionId, labelEl) {
4558
+ // Guard: already editing
4559
+ if (labelEl.querySelector('input')) return;
4001
4560
  const currentText = labelEl.textContent.trim();
4002
4561
  const input = document.createElement('input');
4003
4562
  input.type = 'text';
@@ -4008,18 +4567,35 @@ function startRenameSession(sessionId, labelEl) {
4008
4567
  input.focus();
4009
4568
  input.select();
4010
4569
 
4570
+ let done = false;
4011
4571
  function finish() {
4572
+ if (done) return;
4573
+ done = true;
4012
4574
  const newName = input.value.trim();
4013
4575
  if (newName && newName !== currentText) {
4014
- // Send rename via WebSocket
4015
- if (state.ws && state.ws.readyState === 1) {
4016
- state.ws.send(JSON.stringify({ type: 'rename', id: sessionId, label: newName }));
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;
4017
4592
  }
4018
- // Update local state immediately
4019
- const s = state.sessions.get(sessionId);
4020
- if (s && s.meta) s.meta.label = newName;
4021
4593
  }
4594
+ // Remove input before re-rendering so the input-guard doesn't block
4595
+ input.remove();
4022
4596
  renderSessionList();
4597
+ renderFilteredSessions();
4598
+ renderTabs();
4023
4599
  }
4024
4600
 
4025
4601
  input.addEventListener('blur', finish);
@@ -4100,11 +4676,18 @@ function startRenameRecentSession(sessionId, spanEl) {
4100
4676
  });
4101
4677
  // Update local data immediately
4102
4678
  const s = allRecentSessions.find(x => x.sessionId === sessionId);
4103
- if (s) s.aiTitle = newName;
4679
+ if (s) { s.aiTitle = newName; s.userRenamed = true; }
4104
4680
  // Also update active session if applicable
4105
4681
  const active = state.sessions.get(sessionId);
4106
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
+ }
4107
4688
  }
4689
+ // Remove input before re-rendering so the input-guard doesn't block
4690
+ input.remove();
4108
4691
  renderFilteredSessions();
4109
4692
  renderSessionList();
4110
4693
  renderTabs();
@@ -4161,8 +4744,14 @@ function showCompactBannerIfStale(id, s) {
4161
4744
  <button class="compact-dismiss" onclick="dismissCompactBanner('${id}')">&times;</button>
4162
4745
  `;
4163
4746
  s.container.prepend(banner);
4164
- // Refit terminal since banner takes space
4165
- requestAnimationFrame(() => s.fitAddon.fit());
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
+ });
4166
4755
  }
4167
4756
 
4168
4757
  function compactSession(id) {
@@ -4177,13 +4766,21 @@ function dismissCompactBanner(id) {
4177
4766
  const banner = s.container.querySelector('.compact-banner');
4178
4767
  if (banner) {
4179
4768
  banner.remove();
4180
- requestAnimationFrame(() => s.fitAddon.fit());
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
+ });
4181
4776
  }
4182
4777
  }
4183
4778
  }
4184
4779
 
4185
4780
  function renderTabs() {
4186
4781
  const tabbar = document.getElementById('tabbar');
4782
+ // Skip re-render if user is actively renaming a tab
4783
+ if (tabbar.querySelector('input')) return;
4187
4784
  const addBtn = tabbar.querySelector('.tab-add');
4188
4785
 
4189
4786
  // Remove old tabs
@@ -4238,20 +4835,70 @@ function renderTabs() {
4238
4835
 
4239
4836
  const tab = document.createElement('div');
4240
4837
  tab.className = `tab ${state.activeTab === id ? 'active' : ''}`;
4241
- tab.innerHTML = `<span class="tab-icon">▸</span><span>${escHtml(label)}</span><span class="close-tab" onclick="event.stopPropagation();killSession('${id}')">&times;</span>`;
4242
- tab.onclick = () => activateTab(id);
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)}')">&times;</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
+ };
4243
4868
  tabbar.insertBefore(tab, addBtn);
4244
4869
  }
4245
4870
  }
4246
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
+
4247
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
+
4248
4895
  function showNewSessionModal() {
4249
4896
  document.getElementById('new-session-modal').classList.remove('hidden');
4250
- document.getElementById('ns-cwd').value = '';
4897
+ document.getElementById('ns-cwd').value = getLastSessionCwd();
4251
4898
  document.getElementById('ns-label').value = '';
4252
4899
  document.getElementById('ns-type').value = 'claude';
4253
4900
  onSessionTypeChange();
4254
- document.getElementById('ns-cwd').focus();
4901
+ document.getElementById('ns-label').focus();
4255
4902
  }
4256
4903
 
4257
4904
  function closeModal(id) {
@@ -4297,6 +4944,7 @@ function killSession(id) {
4297
4944
  state.sessions.delete(id);
4298
4945
  }
4299
4946
  state.tabOrder = state.tabOrder.filter(t => t !== id);
4947
+ saveTabOrder();
4300
4948
 
4301
4949
  if (state.activeTab === id) {
4302
4950
  const next = state.tabOrder[state.tabOrder.length - 1];
@@ -4315,9 +4963,27 @@ function toggleSidebar() {
4315
4963
  state.sidebarManuallyHidden = state.sidebarCollapsed;
4316
4964
  document.getElementById('sidebar').classList.toggle('collapsed', state.sidebarCollapsed);
4317
4965
  document.getElementById('sidebar-resize').style.display = state.sidebarCollapsed ? 'none' : '';
4318
- // Refit active terminal
4966
+ // Refit active terminal — restore scroll position if user scrolled up
4319
4967
  if (state.activeTab && state.sessions.has(state.activeTab)) {
4320
- setTimeout(() => state.sessions.get(state.activeTab).fitAddon.fit(), 100);
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);
4321
4987
  }
4322
4988
  }
4323
4989
 
@@ -4495,6 +5161,20 @@ document.addEventListener('keydown', (e) => {
4495
5161
  swapNav();
4496
5162
  }
4497
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
+ }
4498
5178
  // Escape: close modal
4499
5179
  if (e.key === 'Escape') {
4500
5180
  document.querySelectorAll('.modal-overlay:not(.hidden)').forEach(m => m.classList.add('hidden'));
@@ -4537,11 +5217,70 @@ function fitActiveTerminal() {
4537
5217
  _fitDebounce = null;
4538
5218
  if (state.activeTab && state.sessions.has(state.activeTab)) {
4539
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;
4540
5227
  s.fitAddon.fit();
4541
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
+ }
4542
5240
  }
4543
5241
  });
4544
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
+ }
4545
5284
  new ResizeObserver(fitActiveTerminal).observe(document.getElementById('terminal-area'));
4546
5285
  window.addEventListener('resize', fitActiveTerminal);
4547
5286
 
@@ -4625,6 +5364,11 @@ async function loadPrefs() {
4625
5364
  state._savedActiveSession = prefs.active_session;
4626
5365
  }
4627
5366
 
5367
+ // Restore tab order
5368
+ if (prefs.tab_order && Array.isArray(prefs.tab_order)) {
5369
+ state._savedTabOrder = prefs.tab_order;
5370
+ }
5371
+
4628
5372
  // Restore code review tree width
4629
5373
  if (prefs.cr_tree_width) {
4630
5374
  state._savedCrTreeWidth = prefs.cr_tree_width;
@@ -4736,6 +5480,11 @@ async function loadRecentSessions() {
4736
5480
  reviewSession(s.sessionId, s.projectEntry, sessionDisplayText(s), s);
4737
5481
  }
4738
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
+ }
4739
5488
  }
4740
5489
 
4741
5490
  const CTM_SESSION_PATTERNS = [
@@ -4779,20 +5528,51 @@ function getFilteredSessions() {
4779
5528
  let titleGenInProgress = false;
4780
5529
 
4781
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;
4782
5534
  const q = document.getElementById('recent-search').value.toLowerCase();
4783
5535
  let sessions = getFilteredSessions();
4784
5536
  if (q && !aiSearchMode) {
4785
5537
  // First filter local metadata
4786
5538
  const metaMatches = new Set();
5539
+ const recentIds = new Set(sessions.map(s => s.sessionId));
4787
5540
  sessions = sessions.filter(s => {
5541
+ // Also check active session label (tab name)
5542
+ const activeLabel = state.sessions.get(s.sessionId)?.meta?.label || '';
4788
5543
  const match = (s.firstMessage || '').toLowerCase().includes(q) ||
4789
5544
  (s.aiTitle || '').toLowerCase().includes(q) ||
4790
5545
  s.project.toLowerCase().includes(q) ||
4791
5546
  s.sessionId.toLowerCase().includes(q) ||
4792
- (s.gitBranch || '').toLowerCase().includes(q);
5547
+ (s.gitBranch || '').toLowerCase().includes(q) ||
5548
+ activeLabel.toLowerCase().includes(q);
4793
5549
  if (match) metaMatches.add(s.sessionId);
4794
5550
  return match;
4795
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
+ }
4796
5576
  // Also search imported conversations in DB (async, will re-render when results arrive)
4797
5577
  if (sessions.length === 0 && q.length >= 3 && !_convSearchPending) {
4798
5578
  _convSearchPending = true;
@@ -4902,7 +5682,7 @@ function sessionItemHtml(s) {
4902
5682
  const isPinned = pinnedSessionIds.includes(s.sessionId);
4903
5683
  const pinCls = isPinned ? ' pinned' : '';
4904
5684
  const dragAttrs = isPinned ? ` draggable="true" ondragstart="pinDragStart(event)" ondragover="pinDragOver(event)" ondragleave="pinDragLeave(event)" ondrop="pinDrop(event)" ondragend="pinDragEnd(event)"` : '';
4905
- return `<div class="recent-item${reviewing}${pinCls}" data-session-id="${s.sessionId}"${dragAttrs} onclick="openSessionReview('${s.sessionId}')" oncontextmenu="event.preventDefault();togglePinSession('${s.sessionId}')">
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}')">
4906
5686
  <div class="recent-msg"><span class="pin-icon" style="${isPinned ? 'cursor:grab;' : ''}">&#9733;</span><span class="recent-msg-text">${escHtml(displayText)}</span>${emptyTag}${deviceTag}</div>
4907
5687
  <div class="recent-meta">
4908
5688
  <span class="project">${escHtml(project)}</span>
@@ -5226,6 +6006,9 @@ async function reviewSession(sessionId, projectEntry, title, sessionData) {
5226
6006
  // Apply tool-only visibility
5227
6007
  toggleToolMsgs();
5228
6008
 
6009
+ // Update review prompt navigation
6010
+ reviewPromptNavReset();
6011
+
5229
6012
  renderTabs();
5230
6013
  } catch (e) {
5231
6014
  document.getElementById('review-messages').innerHTML = `<div style="padding:20px;color:var(--red)">Failed to load: ${escHtml(e.message)}</div>`;
@@ -5659,6 +6442,99 @@ function scrollReviewToBottom() {
5659
6442
  if (el) el.scrollTop = el.scrollHeight;
5660
6443
  }
5661
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
+
5662
6538
  (function() {
5663
6539
  const el = document.getElementById('review-messages');
5664
6540
  const btn = document.getElementById('review-scroll-bottom');
@@ -7219,6 +8095,8 @@ let _titleFlashStop = null;
7219
8095
 
7220
8096
  // Clear waiting state for a session (tab indicator + title flash)
7221
8097
  function clearWaitingState(sessionId) {
8098
+ const s = state.sessions.get(sessionId);
8099
+ if (s) { s._waitingForInput = false; s._lastInputAt = Date.now(); }
7222
8100
  // Remove pulsing tab indicator
7223
8101
  const tabs = document.querySelectorAll('#tabbar .tab');
7224
8102
  const tabOrder = state.tabOrder;
@@ -7269,9 +8147,13 @@ function playNotificationSound(type) {
7269
8147
  } catch (e) { /* Audio API may not be available */ }
7270
8148
  }
7271
8149
 
8150
+ // Dedup tracking: { sessionId -> { reason, timestamp } }
8151
+ const _lastNotif = {};
8152
+
7272
8153
  function onWaitingForInput(msg) {
7273
8154
  const sessionId = msg.id;
7274
8155
  const session = state.sessions.get(sessionId);
8156
+ if (session) session._waitingForInput = true;
7275
8157
  const label = msg.label || session?.meta?.label || sessionId.slice(0, 8);
7276
8158
  const reason = msg.reason || 'input';
7277
8159
  const reasonText = reason === 'approval' ? 'Needs approval'
@@ -7294,7 +8176,17 @@ function onWaitingForInput(msg) {
7294
8176
  }
7295
8177
  }
7296
8178
 
7297
- if (userIsViewing) return; // User can see the terminal — no notification needed
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 };
7298
8190
 
7299
8191
  // 2. Play notification sound
7300
8192
  playNotificationSound(reason === 'approval' ? 'alert' : 'chime');
@@ -7412,6 +8304,7 @@ window.addEventListener('message', (e) => {
7412
8304
  const activeSession = state.activeTab && state.sessions.get(state.activeTab);
7413
8305
  if (activeSession) {
7414
8306
  send({ type: 'input', id: state.activeTab, data: e.data.text + '\n' });
8307
+ clearWaitingState(state.activeTab);
7415
8308
  }
7416
8309
  }
7417
8310
  });
@@ -7536,7 +8429,7 @@ window.addEventListener('hashchange', handleHashRoute);
7536
8429
 
7537
8430
  // --- Init ---
7538
8431
  connect();
7539
- loadPrefs().then(() => {
8432
+ state._prefsLoaded = loadPrefs().then(() => {
7540
8433
  loadRecentSessions();
7541
8434
  // Restore hash from saved nav if no hash present
7542
8435
  handleHashRoute();
@@ -7551,10 +8444,10 @@ function onQueueState(msg) {
7551
8444
  // msg has: sessionId, mode, status, currentIndex, items, idleTimeoutMs
7552
8445
  queueState = msg;
7553
8446
  renderQueueBar();
7554
- // Scroll terminal to bottom when queue is active
8447
+ // Scroll terminal to bottom when queue is active (only if user hasn't scrolled up)
7555
8448
  if (msg.sessionId) {
7556
8449
  const s = state.sessions.get(msg.sessionId);
7557
- if (s) s.term.scrollToBottom();
8450
+ if (s && s.writer.followMode) s.term.scrollToBottom();
7558
8451
  }
7559
8452
  }
7560
8453