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.
Files changed (50) hide show
  1. package/README.md +23 -7
  2. package/package.json +3 -3
  3. package/template/CLAUDE.md +14 -1
  4. package/template/README.md +123 -43
  5. package/template/claude-task-manager/bin/restart-ctm.sh +3 -2
  6. package/template/claude-task-manager/db.js +40 -2
  7. package/template/claude-task-manager/public/css/walle.css +123 -0
  8. package/template/claude-task-manager/public/index.html +1003 -75
  9. package/template/claude-task-manager/public/js/walle.js +562 -131
  10. package/template/claude-task-manager/public/prompts.html +84 -26
  11. package/template/claude-task-manager/public/walle-icon.svg +45 -0
  12. package/template/claude-task-manager/server.js +69 -4
  13. package/template/docs/openclaw-vs-walle-comparison.md +103 -0
  14. package/template/package.json +1 -1
  15. package/template/wall-e/agent.js +63 -3
  16. package/template/wall-e/api-walle.js +158 -0
  17. package/template/wall-e/brain.js +182 -5
  18. package/template/wall-e/channels/imessage-channel.js +4 -1
  19. package/template/wall-e/channels/slack-channel.js +3 -1
  20. package/template/wall-e/chat.js +115 -213
  21. package/template/wall-e/context/compactor.js +163 -0
  22. package/template/wall-e/context/context-builder.js +355 -0
  23. package/template/wall-e/context/state-snapshot.js +209 -0
  24. package/template/wall-e/context/token-counter.js +55 -0
  25. package/template/wall-e/context/topic-matcher.js +79 -0
  26. package/template/wall-e/core-tasks.js +25 -1
  27. package/template/wall-e/events/event-bus.js +23 -0
  28. package/template/wall-e/loops/ingest.js +4 -0
  29. package/template/wall-e/loops/initiative.js +316 -0
  30. package/template/wall-e/loops/tasks.js +55 -5
  31. package/template/wall-e/skills/_bundled/email-sync/run.js +3 -1
  32. package/template/wall-e/skills/_bundled/mcp-scan/SKILL.md +14 -0
  33. package/template/wall-e/skills/_bundled/mcp-scan/run.js +86 -0
  34. package/template/wall-e/skills/_bundled/morning-briefing/run.js +41 -0
  35. package/template/wall-e/skills/_bundled/proactive-alerts/SKILL.md +20 -0
  36. package/template/wall-e/skills/_bundled/proactive-alerts/run.js +144 -0
  37. package/template/wall-e/skills/_bundled/slack-mentions/.watched-threads.json +18 -0
  38. package/template/wall-e/skills/_bundled/slack-mentions/.watermark.json +4 -0
  39. package/template/wall-e/skills/_bundled/slack-mentions/SKILL.md +52 -0
  40. package/template/wall-e/skills/_bundled/slack-mentions/run.js +470 -0
  41. package/template/wall-e/skills/_bundled/weekly-reflection/SKILL.md +69 -0
  42. package/template/wall-e/skills/mcp-client.js +241 -13
  43. package/template/wall-e/tests/brain.test.js +4 -4
  44. package/template/wall-e/tests/compactor.test.js +323 -0
  45. package/template/wall-e/tests/context-builder.test.js +215 -0
  46. package/template/wall-e/tests/event-bus.test.js +74 -0
  47. package/template/wall-e/tests/initiative.test.js +354 -0
  48. package/template/wall-e/tests/proactive-alerts.test.js +140 -0
  49. package/template/wall-e/tests/session-persistence.test.js +335 -0
  50. 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(--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,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;">&#9662;</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>&#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);
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 so they see the response
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
- // Auto-scroll is handled in onOutput using xterm.js buffer API (viewportY vs baseY).
3322
- // No manual tracking needed xterm.js buffer is the source of truth.
3323
- //
3324
- // Safety: clicking the terminal forces a fit() recalculation, fixing any scroll desync
3325
- // that may occur after Claude Code's TUI exits or terminal state changes.
3326
- container.addEventListener('click', () => {
3327
- const s = state.sessions.get(id);
3328
- 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
+ }
3329
3466
  });
3330
3467
 
3331
- 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 });
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
- // Auto-scroll using xterm.js buffer API: if viewport is at the bottom of the
3831
- // scrollback, keep it there. If user has scrolled up, don't interrupt.
3832
- // buffer.active.viewportY === buffer.active.baseY means "at bottom".
3833
- const buf = s.term.buffer.active;
3834
- const wasAtBottom = buf.viewportY >= buf.baseY;
3835
- if (state.activeTab === msg.id && wasAtBottom) {
3836
- s.term.write(data, () => {
3837
- 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
+ }
3838
4281
  });
3839
- } else {
3840
- s.term.write(data);
3841
4282
  }
3842
- // After output stops for 500ms, force xterm to recalculate viewport dimensions.
3843
- // This fixes a desync between xterm's internal buffer and the DOM viewport that
3844
- // can occur when Claude Code's TUI exits (Ink unmount, alt screen buffer exit).
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
- 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
+ }
3850
4316
  }
3851
- }, 500);
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="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)">
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}')">&times;</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
- // Send rename via WebSocket
4014
- if (state.ws && state.ws.readyState === 1) {
4015
- 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;
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}')">&times;</button>
4161
4745
  `;
4162
4746
  s.container.prepend(banner);
4163
- // Refit terminal since banner takes space
4164
- 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
+ });
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(() => 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
+ });
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.innerHTML = `<span class="tab-icon">▸</span><span>${escHtml(label)}</span><span class="close-tab" onclick="event.stopPropagation();killSession('${id}')">&times;</span>`;
4241
- 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
+ };
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-cwd').focus();
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(() => 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);
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
- sessions = sessions.filter(s =>
4784
- (s.firstMessage || '').toLowerCase().includes(q) ||
4785
- (s.aiTitle || '').toLowerCase().includes(q) ||
4786
- s.project.toLowerCase().includes(q) ||
4787
- s.sessionId.toLowerCase().includes(q) ||
4788
- (s.gitBranch || '').toLowerCase().includes(q)
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="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}')">
4881
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>
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) 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 };
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">&#8635;</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">&#9998;</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)'">&times;</button>