create-walle 0.9.3 → 0.9.4

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 (75) hide show
  1. package/README.md +2 -1
  2. package/package.json +1 -1
  3. package/template/claude-task-manager/db.js +5 -1
  4. package/template/claude-task-manager/public/css/walle.css +317 -0
  5. package/template/claude-task-manager/public/index.html +404 -101
  6. package/template/claude-task-manager/public/js/walle.js +1256 -86
  7. package/template/claude-task-manager/server.js +189 -14
  8. package/template/docs/site/api/README.md +146 -0
  9. package/template/docs/site/skills/README.md +99 -5
  10. package/template/package.json +1 -1
  11. package/template/wall-e/agent.js +54 -0
  12. package/template/wall-e/api-walle.js +452 -3
  13. package/template/wall-e/brain.js +45 -1
  14. package/template/wall-e/channels/telegram-channel.js +96 -0
  15. package/template/wall-e/chat.js +61 -2
  16. package/template/wall-e/coding-context.js +252 -0
  17. package/template/wall-e/coding-orchestrator.js +625 -0
  18. package/template/wall-e/coding-review.js +189 -0
  19. package/template/wall-e/core-tasks.js +12 -3
  20. package/template/wall-e/deploy.sh +4 -4
  21. package/template/wall-e/fly.toml +2 -2
  22. package/template/wall-e/package.json +4 -1
  23. package/template/wall-e/skills/_bundled/coding-agent/SKILL.md +17 -0
  24. package/template/wall-e/skills/_bundled/coding-agent/run.js +142 -0
  25. package/template/wall-e/skills/_bundled/email-sync/SKILL.md +12 -7
  26. package/template/wall-e/skills/_bundled/email-sync/mail-reader.jxa +76 -46
  27. package/template/wall-e/skills/_bundled/email-sync/run.js +42 -2
  28. package/template/wall-e/skills/_bundled/glean-team-sync/SKILL.md +57 -0
  29. package/template/wall-e/skills/_bundled/glean-team-sync/run.js +254 -0
  30. package/template/wall-e/skills/_bundled/slack-mentions/SKILL.md +1 -1
  31. package/template/wall-e/skills/_bundled/slack-mentions/run.js +268 -121
  32. package/template/wall-e/skills/_templates/data-fetcher.md +27 -0
  33. package/template/wall-e/skills/_templates/manual-action.md +19 -0
  34. package/template/wall-e/skills/_templates/periodic-checker.md +29 -0
  35. package/template/wall-e/skills/_templates/script-runner.md +21 -0
  36. package/template/wall-e/skills/claude-code-reader.js +16 -4
  37. package/template/wall-e/skills/skill-executor.js +23 -1
  38. package/template/wall-e/skills/skill-validator.js +73 -0
  39. package/template/wall-e/tests/brain.test.js +3 -3
  40. package/template/wall-e/tests/coding-agent-integration.test.js +240 -0
  41. package/template/wall-e/tests/coding-context.test.js +212 -0
  42. package/template/wall-e/tests/coding-orchestrator.test.js +303 -0
  43. package/template/wall-e/tests/coding-review.test.js +141 -0
  44. package/template/claude-task-manager/package-lock.json +0 -1607
  45. package/template/claude-task-manager/tests/test-ai-search.js +0 -61
  46. package/template/claude-task-manager/tests/test-editor-ux.js +0 -76
  47. package/template/claude-task-manager/tests/test-editor-ux2.js +0 -51
  48. package/template/claude-task-manager/tests/test-features-v2.js +0 -127
  49. package/template/claude-task-manager/tests/test-insights-cached.js +0 -78
  50. package/template/claude-task-manager/tests/test-insights.js +0 -124
  51. package/template/claude-task-manager/tests/test-permissions-v2.js +0 -127
  52. package/template/claude-task-manager/tests/test-permissions.js +0 -122
  53. package/template/claude-task-manager/tests/test-pin.js +0 -51
  54. package/template/claude-task-manager/tests/test-prompts.js +0 -164
  55. package/template/claude-task-manager/tests/test-recent-sessions.js +0 -96
  56. package/template/claude-task-manager/tests/test-review.js +0 -104
  57. package/template/claude-task-manager/tests/test-send-dropdown.js +0 -76
  58. package/template/claude-task-manager/tests/test-send-final.js +0 -30
  59. package/template/claude-task-manager/tests/test-send-fixes.js +0 -76
  60. package/template/claude-task-manager/tests/test-send-integration.js +0 -107
  61. package/template/claude-task-manager/tests/test-send-visual.js +0 -34
  62. package/template/claude-task-manager/tests/test-session-create.js +0 -147
  63. package/template/claude-task-manager/tests/test-sidebar-ux.js +0 -83
  64. package/template/claude-task-manager/tests/test-url-hash.js +0 -68
  65. package/template/claude-task-manager/tests/test-ux-crop.js +0 -34
  66. package/template/claude-task-manager/tests/test-ux-review.js +0 -130
  67. package/template/claude-task-manager/tests/test-zoom-card.js +0 -76
  68. package/template/claude-task-manager/tests/test-zoom.js +0 -92
  69. package/template/claude-task-manager/tests/test-zoom2.js +0 -67
  70. package/template/docs/openclaw-vs-walle-comparison.md +0 -103
  71. package/template/docs/ux-improvement-plan.md +0 -84
  72. package/template/wall-e/docs/specs/2026-04-01-publish-plan.md +0 -112
  73. package/template/wall-e/docs/specs/SKILL-FORMAT.md +0 -326
  74. package/template/wall-e/package-lock.json +0 -533
  75. package/template/wall-e/skills/_bundled/slack-mentions/.watermark.json +0 -4
@@ -1315,16 +1315,25 @@
1315
1315
  height: var(--tab-height);
1316
1316
  background: var(--bg);
1317
1317
  border-bottom: 1px solid var(--border);
1318
- overflow-x: auto;
1318
+ overflow: hidden;
1319
1319
  flex-shrink: 0;
1320
1320
  align-items: flex-end;
1321
1321
  }
1322
- #tabbar::-webkit-scrollbar { height: 0; }
1322
+ #tabbar-scroll {
1323
+ display: flex;
1324
+ flex: 1;
1325
+ min-width: 0;
1326
+ overflow-x: auto;
1327
+ align-items: flex-end;
1328
+ height: 100%;
1329
+ scroll-behavior: smooth;
1330
+ }
1331
+ #tabbar-scroll::-webkit-scrollbar { height: 0; }
1323
1332
  .tab {
1324
1333
  display: flex;
1325
1334
  align-items: center;
1326
1335
  gap: 6px;
1327
- padding: 0 14px;
1336
+ padding: 0 10px;
1328
1337
  height: 32px;
1329
1338
  font-size: 12px;
1330
1339
  color: var(--fg-dim);
@@ -1333,15 +1342,23 @@
1333
1342
  border-bottom: none;
1334
1343
  cursor: pointer;
1335
1344
  white-space: nowrap;
1336
- flex-shrink: 0;
1345
+ min-width: 60px;
1346
+ max-width: 240px;
1347
+ flex-shrink: 1;
1337
1348
  border-radius: 6px 6px 0 0;
1338
1349
  transition: all 0.1s;
1339
1350
  }
1351
+ .tab .tab-label {
1352
+ overflow: hidden;
1353
+ text-overflow: ellipsis;
1354
+ min-width: 0;
1355
+ }
1340
1356
  .tab:hover { color: var(--fg); background: var(--bg-light); }
1341
1357
  .tab.active {
1342
1358
  color: var(--fg);
1343
1359
  background: var(--bg-light);
1344
1360
  border-color: var(--border);
1361
+ flex-shrink: 0;
1345
1362
  }
1346
1363
  .tab.waiting {
1347
1364
  animation: tab-pulse 2s ease-in-out infinite;
@@ -1373,24 +1390,87 @@
1373
1390
  flex-shrink: 0;
1374
1391
  }
1375
1392
  .tab .close-tab {
1376
- font-size: 14px;
1393
+ font-size: 13px;
1394
+ line-height: 1;
1377
1395
  opacity: 0;
1378
1396
  cursor: pointer;
1379
- padding: 0 2px;
1397
+ padding: 3px 5px;
1398
+ border-radius: 4px;
1399
+ flex-shrink: 0;
1400
+ transition: background 0.1s, opacity 0.1s;
1380
1401
  }
1381
- .tab:hover .close-tab { opacity: 0.6; }
1382
- .tab .close-tab:hover { opacity: 1; }
1402
+ .tab:hover .close-tab { opacity: 0.5; }
1403
+ .tab .close-tab:hover { opacity: 1; background: rgba(255,255,255,0.1); }
1383
1404
  .tab-add {
1384
- padding: 0 10px;
1385
- height: 32px;
1405
+ width: 28px;
1406
+ height: 24px;
1407
+ margin-left: 10px;
1386
1408
  display: flex;
1387
1409
  align-items: center;
1410
+ justify-content: center;
1388
1411
  color: var(--fg-dim);
1389
1412
  cursor: pointer;
1390
1413
  font-size: 16px;
1391
1414
  flex-shrink: 0;
1415
+ border-radius: 6px;
1416
+ border: 1px solid var(--border);
1417
+ transition: all 0.15s;
1418
+ }
1419
+ .tab-add:hover { color: var(--fg); background: rgba(255,255,255,0.08); }
1420
+ .tab-overflow-btn {
1421
+ padding: 0 8px;
1422
+ height: 32px;
1423
+ display: none;
1424
+ align-items: center;
1425
+ color: var(--fg-dim);
1426
+ cursor: pointer;
1427
+ font-size: 11px;
1428
+ flex-shrink: 0;
1429
+ border-left: 1px solid var(--border);
1430
+ position: relative;
1431
+ }
1432
+ .tab-overflow-btn:hover { color: var(--fg); background: var(--bg-light); }
1433
+ .tab-overflow-btn.visible { display: flex; }
1434
+ .tab-overflow-menu {
1435
+ position: absolute;
1436
+ top: 100%;
1437
+ right: 0;
1438
+ z-index: 100;
1439
+ background: var(--bg-light);
1440
+ border: 1px solid var(--border);
1441
+ border-radius: 6px;
1442
+ box-shadow: 0 4px 16px rgba(0,0,0,0.4);
1443
+ min-width: 220px;
1444
+ max-width: 340px;
1445
+ max-height: 400px;
1446
+ overflow-y: auto;
1447
+ padding: 4px 0;
1448
+ }
1449
+ .tab-overflow-item {
1450
+ display: flex;
1451
+ align-items: center;
1452
+ gap: 8px;
1453
+ padding: 6px 12px;
1454
+ font-size: 12px;
1455
+ color: var(--fg-dim);
1456
+ cursor: pointer;
1457
+ white-space: nowrap;
1458
+ overflow: hidden;
1459
+ text-overflow: ellipsis;
1460
+ }
1461
+ .tab-overflow-item:hover { background: rgba(255,255,255,0.06); color: var(--fg); }
1462
+ .tab-overflow-item.active { color: var(--accent); font-weight: 600; }
1463
+ .tab-overflow-item .overflow-dot {
1464
+ width: 6px; height: 6px; border-radius: 50%; flex-shrink: 0;
1465
+ }
1466
+ .tab-overflow-item .overflow-label {
1467
+ overflow: hidden; text-overflow: ellipsis; flex: 1;
1392
1468
  }
1393
- .tab-add:hover { color: var(--fg); }
1469
+ .tab-overflow-item .overflow-close {
1470
+ opacity: 0; font-size: 14px; padding: 0 2px; flex-shrink: 0;
1471
+ }
1472
+ .tab-overflow-item:hover .overflow-close { opacity: 0.6; }
1473
+ .tab-overflow-item .overflow-close:hover { opacity: 1; }
1394
1474
 
1395
1475
  /* Terminal container */
1396
1476
  #terminal-area {
@@ -1405,13 +1485,14 @@
1405
1485
  flex: 1;
1406
1486
  display: none;
1407
1487
  overflow: hidden;
1488
+ min-height: 0;
1408
1489
  }
1409
1490
  .term-container.active { display: flex; flex-direction: column; }
1410
- .term-container .xterm { flex: 1; height: 0; }
1491
+ .term-container .xterm { flex: 1; height: 0; min-height: 0; overflow: hidden; }
1411
1492
  .session-toolbar {
1412
1493
  display: flex; align-items: center; gap: 6px; padding: 3px 8px;
1413
1494
  background: rgba(255,255,255,0.03); border-bottom: 1px solid var(--border);
1414
- flex-shrink: 0;
1495
+ flex-shrink: 0; position: relative; z-index: 1;
1415
1496
  }
1416
1497
  .session-toolbar-btn {
1417
1498
  background: none; border: 1px solid transparent; color: var(--fg-dim);
@@ -2375,7 +2456,10 @@
2375
2456
  <div id="sidebar-resize"></div>
2376
2457
  <div style="flex:1;display:flex;flex-direction:column;overflow:hidden;">
2377
2458
  <div id="tabbar">
2378
- <span class="tab-add" onclick="showNewSessionModal()" title="New Tab">+</span>
2459
+ <div id="tabbar-scroll">
2460
+ <span class="tab-add" onclick="showNewSessionModal()" title="New Tab">+</span>
2461
+ </div>
2462
+ <span class="tab-overflow-btn" id="tab-overflow-btn" onclick="toggleTabOverflow(event)" title="All tabs">&#9662; <span id="tab-overflow-count"></span></span>
2379
2463
  </div>
2380
2464
  <!-- Queue Status Bar -->
2381
2465
  <div id="queue-bar" style="display:none;">
@@ -3438,7 +3522,11 @@ function createTerminal(id) {
3438
3522
  term.onData((data) => {
3439
3523
  send({ type: 'input', id, data });
3440
3524
  clearWaitingState(id);
3441
- // User typed something — scroll to bottom and re-enable follow mode
3525
+ // User typed something — scroll to bottom and re-enable follow mode.
3526
+ // Skip mouse escape sequences (\x1b[M..., \x1b[<...) — these are wheel/click
3527
+ // events forwarded by xterm when mouse tracking is enabled, not real user input.
3528
+ const isMouse = data.length >= 3 && data[0] === '\x1b' && data[1] === '[' && (data[2] === 'M' || data[2] === '<');
3529
+ if (isMouse) return;
3442
3530
  const s = state.sessions.get(id);
3443
3531
  if (s) { s.writer.followMode = true; s.term.scrollToBottom(); }
3444
3532
  });
@@ -3468,6 +3556,7 @@ function createTerminal(id) {
3468
3556
  // Momentum scroll clamping — macOS trackpad momentum can cause "rocket scroll"
3469
3557
  // where the viewport flies past the content. Detect momentum events (rapid,
3470
3558
  // decaying deltas) and clamp them after a threshold.
3559
+ // Also tracks _lastWheelAt for scroll-during-output detection.
3471
3560
  const viewportEl = container.querySelector('.xterm-viewport');
3472
3561
  if (viewportEl) {
3473
3562
  let lastWheelTime = 0;
@@ -3477,6 +3566,11 @@ function createTerminal(id) {
3477
3566
  const now = Date.now();
3478
3567
  const elapsed = now - lastWheelTime;
3479
3568
  lastWheelTime = now;
3569
+ writer._lastWheelAt = now;
3570
+ // Immediately disable auto-scroll when user scrolls up — don't rely on
3571
+ // the onScroll handler which can be suppressed or arrive too late after
3572
+ // xterm's internal auto-scroll resets the viewport to bottom.
3573
+ if (e.deltaY < 0) writer.followMode = false;
3480
3574
  if (elapsed < 80 && Math.abs(e.deltaY) < Math.abs(lastDeltaY) * 0.9) {
3481
3575
  momentumCount++;
3482
3576
  } else if (elapsed > 120) {
@@ -3506,12 +3600,16 @@ function createTerminal(id) {
3506
3600
  term.onScroll(() => {
3507
3601
  const buf = term.buffer.active;
3508
3602
  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;
3603
+ // During suppression (after fit/write), ignore programmatic scroll events
3604
+ // to prevent write()'s internal auto-scroll from flipping followMode.
3605
+ // BUT: if the user actively scrolled (wheel event within last 150ms),
3606
+ // let their scroll intent through the wheel check in the write callback
3607
+ // will prevent scrollToBottom/scrollToLine from overriding them.
3608
+ const now = Date.now();
3609
+ if (now < writer._suppressScroll) {
3610
+ const recentWheel = writer._lastWheelAt && (now - writer._lastWheelAt < 150);
3611
+ if (!recentWheel) return;
3612
+ }
3515
3613
  writer.followMode = atBottom;
3516
3614
  });
3517
3615
 
@@ -3558,13 +3656,27 @@ async function _scanPromptLinesFromAPI(id, projectEntry) {
3558
3656
  _scanPromptLinesFromTerminal(id);
3559
3657
  return;
3560
3658
  }
3561
- // Match the review page: count all role:'user' messages
3659
+ // Match the review page: count all role:'user' messages, filtering out
3660
+ // injected system/skill context that isn't a real user prompt
3562
3661
  const userMsgs = messages.filter(m => m.role === 'user');
3563
3662
  const previews = [];
3564
3663
  for (const msg of userMsgs) {
3565
- const firstLine = msg.text.split('\n')[0].trim();
3664
+ const text = msg.text;
3665
+ // Skip injected context: skill metadata, built-in commands, continuation summaries
3666
+ const firstLine = text.split('\n')[0].trim();
3566
3667
  if (!firstLine || firstLine.length < 3) continue;
3567
- previews.push(firstLine.slice(0, 80));
3668
+ if (/^Base directory for this skill:/i.test(firstLine)) continue;
3669
+ if (/^<command-name>\/(model|exit|compact|clear|help|fast|config|memory|doctor|bug|init|review|cost|vim|resume|login|logout|permissions|status|mcp|diff|terminal-setup)<\/command-name>/.test(firstLine)) continue;
3670
+ if (/^This session is being continued from a previous conversation/i.test(firstLine)) continue;
3671
+ if (/^ARGUMENTS:\s/.test(firstLine)) continue;
3672
+ // Clean up command-message wrappers into readable format
3673
+ let cleaned = firstLine;
3674
+ if (/^<command-message>/.test(cleaned)) {
3675
+ cleaned = cleaned.replace(/<command-message>(.*?)<\/command-message>.*/, '/$1');
3676
+ }
3677
+ cleaned = cleaned.replace(/<[^>]+>/g, '').trim();
3678
+ if (!cleaned || cleaned.length < 2) continue;
3679
+ previews.push(cleaned.slice(0, 80));
3568
3680
  }
3569
3681
  _promptScanCache[id] = { ts: Date.now(), previews };
3570
3682
  if (s) s._promptLinesResolved = false;
@@ -3745,6 +3857,11 @@ function activateTab(id) {
3745
3857
  const specialPanels = ['rules', 'insights', 'permissions', 'prompts', 'codereview', 'walle', 'backups'];
3746
3858
  const isPanel = specialPanels.includes(id);
3747
3859
 
3860
+ // Clean up WALL-E background pollers when leaving WALL-E tab
3861
+ if (state.activeTab === 'walle' && id !== 'walle' && typeof WE !== 'undefined' && WE.pausePollers) {
3862
+ WE.pausePollers();
3863
+ }
3864
+
3748
3865
  // Hide all
3749
3866
  for (const [sid, s] of state.sessions) {
3750
3867
  s.container.classList.remove('active');
@@ -4246,7 +4363,9 @@ function onOutput(msg) {
4246
4363
  s._waitingForInput = false;
4247
4364
  // Only strip \e[3J (Erase Scrollback) — preserves scroll history.
4248
4365
  // Do NOT strip \e[2J or \e[?1049h/l — needed for Claude Code's TUI.
4249
- const data = msg.data.replace(/\x1b\[3J/g, '');
4366
+ // Skip regex if the escape sequence isn't present (common fast path).
4367
+ const raw = msg.data;
4368
+ const data = raw.indexOf('\x1b[3J') >= 0 ? raw.replace(/\x1b\[3J/g, '') : raw;
4250
4369
 
4251
4370
  // followMode is maintained by the term.onScroll listener — no manual check needed here.
4252
4371
 
@@ -4262,10 +4381,20 @@ function onOutput(msg) {
4262
4381
  const follow = s.writer.followMode;
4263
4382
  s.writer.queue = '';
4264
4383
  s.writer.scheduled = false;
4384
+ // Snapshot wheel timestamp before write — xterm processes writes async
4385
+ // (its own RAF), so the callback fires in a later frame. Between our
4386
+ // write() call and the callback, the user may scroll via wheel. If so,
4387
+ // we must NOT override their scroll with scrollToBottom/scrollToLine.
4388
+ const wheelBefore = s.writer._lastWheelAt || 0;
4265
4389
  // Re-check active tab at render time (may have changed since queued)
4266
4390
  if (state.activeTab === sid && follow) {
4391
+ s.writer._suppressScroll = Date.now() + 2000;
4267
4392
  s.term.write(batch, () => {
4268
- s.term.scrollToBottom();
4393
+ s.writer._suppressScroll = Date.now() + 100;
4394
+ // Only auto-scroll if user didn't wheel-scroll since write was queued
4395
+ if ((s.writer._lastWheelAt || 0) === wheelBefore) {
4396
+ s.term.scrollToBottom();
4397
+ }
4269
4398
  });
4270
4399
  } else {
4271
4400
  const savedLine = s.term.buffer.active.viewportY;
@@ -4275,57 +4404,72 @@ function onOutput(msg) {
4275
4404
  s.writer._suppressScroll = Date.now() + 2000;
4276
4405
  s.term.write(batch, () => {
4277
4406
  s.writer._suppressScroll = Date.now() + 100;
4278
- s.term.scrollToLine(savedLine);
4407
+ // Only restore position if user didn't wheel-scroll since write
4408
+ if ((s.writer._lastWheelAt || 0) === wheelBefore) {
4409
+ s.term.scrollToLine(savedLine);
4410
+ }
4279
4411
  });
4280
4412
  }
4281
- });
4282
- }
4283
4413
 
4284
- // After output stops for 300ms, ensure scroll position is correct
4285
- // and auto-fix dimension drift (PTY size ≠ container size).
4286
- clearTimeout(s._outputIdleTimer);
4287
- s._outputIdleTimer = setTimeout(() => {
4288
- if (state.activeTab === msg.id) {
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) {
4414
+ // --- Deferred housekeeping (once per frame, not per WS message) ---
4415
+
4416
+ // Idle timer: reset after output stops for 300ms (dimension drift auto-heal)
4417
+ clearTimeout(s._outputIdleTimer);
4418
+ s._outputIdleTimer = setTimeout(() => {
4419
+ if (state.activeTab === sid) {
4420
+ try {
4421
+ const dims = s.fitAddon.proposeDimensions();
4422
+ if (dims && (dims.cols !== s.term.cols || dims.rows !== s.term.rows)) {
4423
+ const buf = s.term.buffer.active;
4424
+ const wasAtBottom = buf.viewportY >= buf.baseY;
4425
+ const oldCols = s.term.cols;
4426
+ const savedLine2 = buf.viewportY;
4427
+ const savedAnchor = wasAtBottom ? null : _getScrollAnchor(s.term);
4428
+ s.writer._suppressScroll = Date.now() + 200;
4429
+ s.fitAddon.fit();
4430
+ send({ type: 'resize', id: sid, cols: s.term.cols, rows: s.term.rows });
4431
+ if (wasAtBottom) {
4432
+ s.term.scrollToBottom();
4433
+ } else if (s.term.cols !== oldCols) {
4434
+ _restoreScrollAnchor(s.term, savedAnchor);
4435
+ s._promptLinesResolved = false;
4436
+ } else {
4437
+ s.term.scrollToLine(savedLine2);
4438
+ }
4439
+ return;
4440
+ }
4441
+ } catch (_) { /* proposeDimensions may fail if container hidden */ }
4442
+ if (s.writer.followMode) {
4303
4443
  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
4444
  }
4310
- return; // fit handles scroll position
4311
4445
  }
4312
- } catch (_) { /* proposeDimensions may fail if container hidden */ }
4313
- if (s.writer.followMode) {
4314
- s.term.scrollToBottom();
4446
+ }, 300);
4447
+
4448
+ // Remove compact banner on new activity (once per frame, not per chunk)
4449
+ if (!s._bannerCleared) {
4450
+ const banner = s.container.querySelector('.compact-banner');
4451
+ if (banner) {
4452
+ banner.remove();
4453
+ s._bannerCleared = true;
4454
+ const ln = s.term.buffer.active.viewportY;
4455
+ const ab = s.writer.followMode;
4456
+ s.writer._suppressScroll = Date.now() + 200;
4457
+ s.fitAddon.fit();
4458
+ if (!ab) s.term.scrollToLine(ln);
4459
+ } else {
4460
+ s._bannerCleared = true; // no banner exists, skip future checks
4461
+ }
4315
4462
  }
4316
- }
4317
- }, 300);
4318
- // Remove compact banner on new activity
4319
- const banner = s.container.querySelector('.compact-banner');
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); }); }
4321
- // Keep focus on the active session's terminal, but don't steal focus from
4322
- // input elements (e.g. queue panel textarea, search boxes)
4323
- if (state.activeTab === msg.id && document.activeElement !== s.term.textarea) {
4324
- const ae = document.activeElement;
4325
- const isUserInput = ae && (ae.tagName === 'TEXTAREA' || ae.tagName === 'INPUT' || ae.tagName === 'SELECT' || ae.isContentEditable);
4326
- if (!isUserInput) {
4327
- s.term.focus();
4328
- }
4463
+
4464
+ // Keep focus on the active session's terminal
4465
+ if (state.activeTab === sid && document.activeElement !== s.term.textarea) {
4466
+ const ae = document.activeElement;
4467
+ const isUserInput = ae && (ae.tagName === 'TEXTAREA' || ae.tagName === 'INPUT' || ae.tagName === 'SELECT' || ae.isContentEditable);
4468
+ if (!isUserInput) {
4469
+ s.term.focus();
4470
+ }
4471
+ }
4472
+ });
4329
4473
  }
4330
4474
  }
4331
4475
 
@@ -4412,16 +4556,17 @@ async function onSessionsList(msg) {
4412
4556
  const saved = state._savedTabOrder;
4413
4557
  delete state._savedTabOrder;
4414
4558
  // 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));
4559
+ // Include 'review' as a restorable tab position
4560
+ const restorableIds = state.tabOrder.filter(id => state.sessions.has(id) || id === 'review');
4561
+ const nonRestorableIds = state.tabOrder.filter(id => !state.sessions.has(id) && id !== 'review');
4417
4562
  const ordered = [];
4418
4563
  for (const id of saved) {
4419
- if (sessionIds.includes(id)) ordered.push(id);
4564
+ if (restorableIds.includes(id)) ordered.push(id);
4420
4565
  }
4421
- for (const id of sessionIds) {
4566
+ for (const id of restorableIds) {
4422
4567
  if (!ordered.includes(id)) ordered.push(id);
4423
4568
  }
4424
- state.tabOrder = [...nonSessionIds, ...ordered];
4569
+ state.tabOrder = [...nonRestorableIds, ...ordered];
4425
4570
  }
4426
4571
 
4427
4572
  renderSessionList();
@@ -4554,6 +4699,19 @@ function recentItemDblClick(id, event) {
4554
4699
  }, 10);
4555
4700
  }
4556
4701
 
4702
+ // Shared helper: attach event listeners for inline rename inputs
4703
+ function setupRenameInput(input, currentText, finish) {
4704
+ // Stop events from bubbling to parent (e.g., activateTab → steal focus)
4705
+ for (const evt of ['click', 'mousedown', 'dblclick']) {
4706
+ input.addEventListener(evt, (e) => e.stopPropagation());
4707
+ }
4708
+ input.addEventListener('blur', finish);
4709
+ input.addEventListener('keydown', (e) => {
4710
+ if (e.key === 'Enter') { e.preventDefault(); input.blur(); }
4711
+ if (e.key === 'Escape') { input.value = currentText; input.blur(); }
4712
+ });
4713
+ }
4714
+
4557
4715
  function startRenameSession(sessionId, labelEl) {
4558
4716
  // Guard: already editing
4559
4717
  if (labelEl.querySelector('input')) return;
@@ -4590,6 +4748,8 @@ function startRenameSession(sessionId, labelEl) {
4590
4748
  const reviewTitleEl = document.getElementById('review-title');
4591
4749
  if (reviewTitleEl) reviewTitleEl.textContent = newName;
4592
4750
  }
4751
+ // Send rename via WebSocket too so the server updates in-memory session label
4752
+ send({ type: 'rename', id: sessionId, label: newName });
4593
4753
  }
4594
4754
  // Remove input before re-rendering so the input-guard doesn't block
4595
4755
  input.remove();
@@ -4598,11 +4758,7 @@ function startRenameSession(sessionId, labelEl) {
4598
4758
  renderTabs();
4599
4759
  }
4600
4760
 
4601
- input.addEventListener('blur', finish);
4602
- input.addEventListener('keydown', (e) => {
4603
- if (e.key === 'Enter') { e.preventDefault(); input.blur(); }
4604
- if (e.key === 'Escape') { input.value = currentText; input.blur(); }
4605
- });
4761
+ setupRenameInput(input, currentText, finish);
4606
4762
  }
4607
4763
 
4608
4764
  function startRenameReviewTitle(titleEl) {
@@ -4644,11 +4800,7 @@ function startRenameReviewTitle(titleEl) {
4644
4800
  }
4645
4801
  }
4646
4802
 
4647
- input.addEventListener('blur', finish);
4648
- input.addEventListener('keydown', (e) => {
4649
- if (e.key === 'Enter') { e.preventDefault(); input.blur(); }
4650
- if (e.key === 'Escape') { input.value = currentText; input.blur(); }
4651
- });
4803
+ setupRenameInput(input, currentText, finish);
4652
4804
  }
4653
4805
 
4654
4806
  function startRenameRecentSession(sessionId, spanEl) {
@@ -4693,11 +4845,7 @@ function startRenameRecentSession(sessionId, spanEl) {
4693
4845
  renderTabs();
4694
4846
  }
4695
4847
 
4696
- input.addEventListener('blur', finish);
4697
- input.addEventListener('keydown', (e) => {
4698
- if (e.key === 'Enter') { e.preventDefault(); input.blur(); }
4699
- if (e.key === 'Escape') { input.value = currentText; input.blur(); }
4700
- });
4848
+ setupRenameInput(input, currentText, finish);
4701
4849
  }
4702
4850
 
4703
4851
  async function loadSessionPrompts(sessionId) {
@@ -4728,6 +4876,7 @@ function showCompactBannerIfStale(id, s) {
4728
4876
  // Remove any existing banner first
4729
4877
  const existing = s.container.querySelector('.compact-banner');
4730
4878
  if (existing) existing.remove();
4879
+ s._bannerCleared = false; // reset so onOutput can detect new banners
4731
4880
 
4732
4881
  if (_compactDismissed.has(id)) return;
4733
4882
  const lastAct = s.meta?.lastActivity || s.meta?.createdAt || 0;
@@ -4778,13 +4927,13 @@ function dismissCompactBanner(id) {
4778
4927
  }
4779
4928
 
4780
4929
  function renderTabs() {
4781
- const tabbar = document.getElementById('tabbar');
4930
+ const scrollContainer = document.getElementById('tabbar-scroll');
4782
4931
  // Skip re-render if user is actively renaming a tab
4783
- if (tabbar.querySelector('input')) return;
4784
- const addBtn = tabbar.querySelector('.tab-add');
4932
+ if (scrollContainer.querySelector('input')) return;
4933
+ const addBtn = scrollContainer.querySelector('.tab-add');
4785
4934
 
4786
4935
  // Remove old tabs
4787
- tabbar.querySelectorAll('.tab').forEach(t => t.remove());
4936
+ scrollContainer.querySelectorAll('.tab').forEach(t => t.remove());
4788
4937
 
4789
4938
  for (const id of state.tabOrder) {
4790
4939
  if (id === 'rules') {
@@ -4792,16 +4941,35 @@ function renderTabs() {
4792
4941
  tab.className = `tab panel-tab ${state.activeTab === 'rules' ? 'active' : ''}`;
4793
4942
  tab.innerHTML = `<span>📝 Rules</span><span class="close-tab" onclick="event.stopPropagation();closeSpecialTab('rules')">&times;</span>`;
4794
4943
  tab.onclick = () => activateTab('rules');
4795
- tabbar.insertBefore(tab, addBtn);
4944
+ scrollContainer.insertBefore(tab, addBtn);
4796
4945
  continue;
4797
4946
  }
4798
4947
  if (id === 'review') {
4799
4948
  const tab = document.createElement('div');
4800
4949
  tab.className = `tab panel-tab ${state.activeTab === 'review' ? 'active' : ''}`;
4950
+ tab.dataset.sessionId = 'review';
4951
+ tab.draggable = true;
4801
4952
  const title = document.getElementById('review-title').textContent;
4802
4953
  tab.innerHTML = `<span>📋 ${escHtml(title)}</span><span class="close-tab" onclick="event.stopPropagation();closeSpecialTab('review')">&times;</span>`;
4803
4954
  tab.onclick = () => activateTab('review');
4804
- tabbar.insertBefore(tab, addBtn);
4955
+ tab.ondragstart = function(e) { _tabDragId = 'review'; e.dataTransfer.effectAllowed = 'move'; e.dataTransfer.setData('text/plain', 'review'); tab.style.opacity = '0.4'; };
4956
+ tab.ondragend = function() { tab.style.opacity = ''; };
4957
+ tab.ondragover = function(e) { e.preventDefault(); e.dataTransfer.dropEffect = 'move'; tab.classList.add('tab-drag-over'); };
4958
+ tab.ondragleave = function() { tab.classList.remove('tab-drag-over'); };
4959
+ tab.ondrop = function(e) {
4960
+ e.preventDefault();
4961
+ tab.classList.remove('tab-drag-over');
4962
+ if (!_tabDragId || _tabDragId === 'review') return;
4963
+ const from = state.tabOrder.indexOf(_tabDragId);
4964
+ const to = state.tabOrder.indexOf('review');
4965
+ if (from === -1 || to === -1) return;
4966
+ state.tabOrder.splice(from, 1);
4967
+ state.tabOrder.splice(to, 0, _tabDragId);
4968
+ _tabDragId = null;
4969
+ saveTabOrder();
4970
+ renderTabs();
4971
+ };
4972
+ scrollContainer.insertBefore(tab, addBtn);
4805
4973
  continue;
4806
4974
  }
4807
4975
  if (id === 'insights') {
@@ -4809,7 +4977,7 @@ function renderTabs() {
4809
4977
  tab.className = `tab panel-tab ${state.activeTab === 'insights' ? 'active' : ''}`;
4810
4978
  tab.innerHTML = `<span>📊 Insights</span><span class="close-tab" onclick="event.stopPropagation();closeSpecialTab('insights')">&times;</span>`;
4811
4979
  tab.onclick = () => activateTab('insights');
4812
- tabbar.insertBefore(tab, addBtn);
4980
+ scrollContainer.insertBefore(tab, addBtn);
4813
4981
  continue;
4814
4982
  }
4815
4983
  if (id === 'permissions') {
@@ -4817,7 +4985,7 @@ function renderTabs() {
4817
4985
  tab.className = `tab panel-tab ${state.activeTab === 'permissions' ? 'active' : ''}`;
4818
4986
  tab.innerHTML = `<span>🔒 Permissions</span><span class="close-tab" onclick="event.stopPropagation();closeSpecialTab('permissions')">&times;</span>`;
4819
4987
  tab.onclick = () => activateTab('permissions');
4820
- tabbar.insertBefore(tab, addBtn);
4988
+ scrollContainer.insertBefore(tab, addBtn);
4821
4989
  continue;
4822
4990
  }
4823
4991
  if (id === 'codereview') {
@@ -4865,15 +5033,109 @@ function renderTabs() {
4865
5033
  saveTabOrder();
4866
5034
  renderTabs();
4867
5035
  };
4868
- tabbar.insertBefore(tab, addBtn);
5036
+ scrollContainer.insertBefore(tab, addBtn);
5037
+ }
5038
+
5039
+ // Auto-scroll active tab into view + update overflow indicator
5040
+ requestAnimationFrame(() => {
5041
+ scrollActiveTabIntoView();
5042
+ updateTabOverflowBtn();
5043
+ });
5044
+ }
5045
+
5046
+ // Scroll the active tab into view within the tab bar
5047
+ function scrollActiveTabIntoView() {
5048
+ const scrollContainer = document.getElementById('tabbar-scroll');
5049
+ const activeTab = scrollContainer.querySelector('.tab.active');
5050
+ if (!activeTab) return;
5051
+ const cRect = scrollContainer.getBoundingClientRect();
5052
+ const tRect = activeTab.getBoundingClientRect();
5053
+ if (tRect.left < cRect.left) {
5054
+ scrollContainer.scrollLeft += tRect.left - cRect.left - 8;
5055
+ } else if (tRect.right > cRect.right) {
5056
+ scrollContainer.scrollLeft += tRect.right - cRect.right + 8;
4869
5057
  }
4870
5058
  }
4871
5059
 
5060
+ // Show/hide the overflow dropdown button + count badge
5061
+ function updateTabOverflowBtn() {
5062
+ const scrollContainer = document.getElementById('tabbar-scroll');
5063
+ const btn = document.getElementById('tab-overflow-btn');
5064
+ const countEl = document.getElementById('tab-overflow-count');
5065
+ const tabs = scrollContainer.querySelectorAll('.tab');
5066
+ const isOverflowing = scrollContainer.scrollWidth > scrollContainer.clientWidth + 2;
5067
+ btn.classList.toggle('visible', isOverflowing);
5068
+ if (isOverflowing && countEl) {
5069
+ countEl.textContent = tabs.length;
5070
+ }
5071
+ }
5072
+
5073
+ // Toggle the tab overflow dropdown menu
5074
+ function toggleTabOverflow(e) {
5075
+ e.stopPropagation();
5076
+ const btn = document.getElementById('tab-overflow-btn');
5077
+ let menu = btn.querySelector('.tab-overflow-menu');
5078
+ if (menu) { menu.remove(); return; }
5079
+
5080
+ menu = document.createElement('div');
5081
+ menu.className = 'tab-overflow-menu';
5082
+
5083
+ for (const id of state.tabOrder) {
5084
+ // Skip tabs that aren't rendered
5085
+ if (id === 'codereview' || id === 'walle') continue;
5086
+ let label, dotColor, isActive, closeAction;
5087
+ const specialLabels = { rules: '📝 Rules', insights: '📊 Insights', permissions: '🔒 Permissions' };
5088
+
5089
+ if (specialLabels[id]) {
5090
+ label = specialLabels[id];
5091
+ dotColor = 'var(--accent)';
5092
+ isActive = state.activeTab === id;
5093
+ closeAction = `closeSpecialTab('${id}')`;
5094
+ } else if (id === 'review') {
5095
+ const title = document.getElementById('review-title').textContent;
5096
+ label = '📋 ' + title;
5097
+ dotColor = 'var(--accent)';
5098
+ isActive = state.activeTab === 'review';
5099
+ closeAction = "closeSpecialTab('review')";
5100
+ } else {
5101
+ const s = state.sessions.get(id);
5102
+ if (!s) continue;
5103
+ label = s.meta?.label || id.slice(0, 8);
5104
+ const st = getSessionStatus(s);
5105
+ dotColor = st.cls === 'running' ? 'var(--green)' : st.cls === 'waiting' ? 'var(--yellow)' : 'var(--fg-dim)';
5106
+ isActive = state.activeTab === id;
5107
+ closeAction = `killSession('${escHtml(id)}')`;
5108
+ }
5109
+
5110
+ const item = document.createElement('div');
5111
+ item.className = 'tab-overflow-item' + (isActive ? ' active' : '');
5112
+ item.innerHTML = `<span class="overflow-dot" style="background:${dotColor}"></span><span class="overflow-label">${escHtml(label)}</span><span class="overflow-close" onclick="event.stopPropagation();${closeAction}">&times;</span>`;
5113
+ item.onclick = function() { menu.remove(); activateTab(id); };
5114
+ menu.appendChild(item);
5115
+ }
5116
+
5117
+ btn.appendChild(menu);
5118
+
5119
+ // Close on outside click
5120
+ function closeMenu(ev) {
5121
+ if (!btn.contains(ev.target)) { menu.remove(); document.removeEventListener('click', closeMenu); }
5122
+ }
5123
+ setTimeout(() => document.addEventListener('click', closeMenu), 0);
5124
+ }
5125
+
5126
+ // Wheel-to-scroll on tab bar (vertical wheel → horizontal scroll)
5127
+ document.getElementById('tabbar-scroll').addEventListener('wheel', function(e) {
5128
+ if (Math.abs(e.deltaY) > Math.abs(e.deltaX)) {
5129
+ e.preventDefault();
5130
+ this.scrollLeft += e.deltaY;
5131
+ }
5132
+ }, { passive: false });
5133
+
4872
5134
  // --- Tab drag-and-drop reorder ---
4873
5135
  let _tabDragId = null;
4874
5136
  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));
5137
+ // Save session IDs and review tab (for position restore after restart)
5138
+ const sessionOrder = state.tabOrder.filter(id => state.sessions.has(id) || id === 'review');
4877
5139
  savePref('tab_order', sessionOrder);
4878
5140
  }
4879
5141
 
@@ -4995,6 +5257,11 @@ function showRulesPanel() {
4995
5257
 
4996
5258
  function closeSpecialTab(tabId) {
4997
5259
  state.tabOrder = state.tabOrder.filter(t => t !== tabId);
5260
+ if (tabId === 'review') {
5261
+ state.reviewingSessionId = null;
5262
+ savePref('reviewing_session', null);
5263
+ document.querySelectorAll('.recent-item.reviewing').forEach(el => el.classList.remove('reviewing'));
5264
+ }
4998
5265
  if (state.activeTab === tabId) {
4999
5266
  // Prefer switching to an active session, then fall back to sessions nav
5000
5267
  const nextSession = state.tabOrder.filter(t => state.sessions.has(t)).pop();
@@ -5283,6 +5550,7 @@ function _restoreScrollAnchor(term, anchor) {
5283
5550
  }
5284
5551
  new ResizeObserver(fitActiveTerminal).observe(document.getElementById('terminal-area'));
5285
5552
  window.addEventListener('resize', fitActiveTerminal);
5553
+ window.addEventListener('resize', updateTabOverflowBtn);
5286
5554
 
5287
5555
  // Default cwd hint
5288
5556
  const cwdInput = document.getElementById('ns-cwd');
@@ -5369,6 +5637,11 @@ async function loadPrefs() {
5369
5637
  state._savedTabOrder = prefs.tab_order;
5370
5638
  }
5371
5639
 
5640
+ // Restore reviewing session (reopen review tab after CTM restart)
5641
+ if (prefs.reviewing_session) {
5642
+ state._savedReviewingSession = prefs.reviewing_session;
5643
+ }
5644
+
5372
5645
  // Restore code review tree width
5373
5646
  if (prefs.cr_tree_width) {
5374
5647
  state._savedCrTreeWidth = prefs.cr_tree_width;
@@ -5481,6 +5754,27 @@ async function loadRecentSessions() {
5481
5754
  }
5482
5755
  }
5483
5756
 
5757
+ // Restore reviewing session from prefs (reopen after CTM restart)
5758
+ // Only if no hash override and no review already open
5759
+ if (state._savedReviewingSession && !state.reviewingSessionId && !location.hash.includes('review=')) {
5760
+ const savedId = state._savedReviewingSession;
5761
+ delete state._savedReviewingSession;
5762
+ const s = allRecentSessions.find(x => x.sessionId === savedId);
5763
+ if (s) {
5764
+ state.reviewingSessionId = savedId;
5765
+ // Add review to tab order (matching saved position) but don't activate it
5766
+ if (!state.tabOrder.includes('review')) state.tabOrder.push('review');
5767
+ // Load the review content in the background
5768
+ reviewSession(s.sessionId, s.projectEntry, sessionDisplayText(s), s);
5769
+ // If the saved active session was 'review', activate it; otherwise stay on current tab
5770
+ if (state._savedActiveSession !== 'review') {
5771
+ // reviewSession calls activateTab('review'), so switch back to the saved session
5772
+ const target = state._savedActiveSession || state.tabOrder.find(t => state.sessions.has(t));
5773
+ if (target) setTimeout(() => activateTab(target), 50);
5774
+ }
5775
+ }
5776
+ }
5777
+
5484
5778
  // Re-scan prompts for active sessions now that we have projectEntry data
5485
5779
  for (const [id, s] of state.sessions) {
5486
5780
  if (s.term) scanPromptLines(id);
@@ -5827,6 +6121,12 @@ async function deleteSession(sessionId, projectEntry) {
5827
6121
  }
5828
6122
 
5829
6123
  function openSessionReview(sessionId) {
6124
+ // If already reviewing this session, just switch to the tab without reloading
6125
+ if (state.reviewingSessionId === sessionId && state.tabOrder.includes('review')) {
6126
+ activateTab('review');
6127
+ return;
6128
+ }
6129
+
5830
6130
  const s = allRecentSessions.find(x => x.sessionId === sessionId)
5831
6131
  || { sessionId, cwd: state.sessions.get(sessionId)?.cwd || '', projectEntry: '' };
5832
6132
  state.reviewingSessionId = sessionId;
@@ -5970,6 +6270,9 @@ async function reviewSession(sessionId, projectEntry, title, sessionData) {
5970
6270
  state.tabOrder.push('review');
5971
6271
  }
5972
6272
 
6273
+ // Persist for restore after CTM restart
6274
+ savePref('reviewing_session', sessionId);
6275
+
5973
6276
  // Update URL hash for deep linking (review= for session review)
5974
6277
  history.replaceState(null, '', location.pathname + location.search + '#review=' + sessionId);
5975
6278