create-walle 0.9.21 → 0.9.22

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 (52) hide show
  1. package/README.md +5 -5
  2. package/package.json +2 -2
  3. package/template/claude-task-manager/api-prompts.js +13 -0
  4. package/template/claude-task-manager/api-reviews.js +5 -2
  5. package/template/claude-task-manager/db.js +348 -15
  6. package/template/claude-task-manager/docs/app-update-refresh-protocol.md +69 -0
  7. package/template/claude-task-manager/docs/image-paste-ux.md +3 -0
  8. package/template/claude-task-manager/docs/ipad-web-preview.md +88 -0
  9. package/template/claude-task-manager/git-utils.js +146 -17
  10. package/template/claude-task-manager/lib/auth-rate-limit.js +23 -3
  11. package/template/claude-task-manager/lib/auth-rules.js +3 -0
  12. package/template/claude-task-manager/lib/document-review.js +33 -2
  13. package/template/claude-task-manager/lib/microsoft-dev-tunnel-setup.js +83 -0
  14. package/template/claude-task-manager/lib/mobile-auth-api.js +14 -0
  15. package/template/claude-task-manager/lib/restart-guard.js +68 -0
  16. package/template/claude-task-manager/lib/session-standup.js +36 -13
  17. package/template/claude-task-manager/lib/session-stream.js +11 -4
  18. package/template/claude-task-manager/lib/transport-security.js +50 -0
  19. package/template/claude-task-manager/lib/walle-transcript.js +16 -0
  20. package/template/claude-task-manager/lib/worktree-active-sync.js +6 -3
  21. package/template/claude-task-manager/public/css/reviews.css +10 -0
  22. package/template/claude-task-manager/public/css/setup.css +13 -0
  23. package/template/claude-task-manager/public/css/walle.css +145 -0
  24. package/template/claude-task-manager/public/index.html +539 -44
  25. package/template/claude-task-manager/public/ipad.html +363 -0
  26. package/template/claude-task-manager/public/js/document-review-links.js +196 -0
  27. package/template/claude-task-manager/public/js/message-renderer.js +14 -3
  28. package/template/claude-task-manager/public/js/reviews.js +30 -6
  29. package/template/claude-task-manager/public/js/setup.js +42 -2
  30. package/template/claude-task-manager/public/js/stream-view.js +20 -1
  31. package/template/claude-task-manager/public/js/walle.js +314 -18
  32. package/template/claude-task-manager/public/m/app.css +789 -11
  33. package/template/claude-task-manager/public/m/app.js +1070 -67
  34. package/template/claude-task-manager/public/m/claim.html +9 -2
  35. package/template/claude-task-manager/public/m/index.html +17 -10
  36. package/template/claude-task-manager/public/m/sw.js +1 -1
  37. package/template/claude-task-manager/server.js +365 -95
  38. package/template/claude-task-manager/session-integrity.js +4 -0
  39. package/template/docs/designs/2026-05-17-portkey-gateway-provider-ux.md +86 -35
  40. package/template/package.json +1 -1
  41. package/template/wall-e/api-walle.js +19 -1
  42. package/template/wall-e/brain.js +152 -6
  43. package/template/wall-e/chat.js +85 -0
  44. package/template/wall-e/coding-orchestrator.js +106 -12
  45. package/template/wall-e/http/model-admin.js +131 -0
  46. package/template/wall-e/lib/service-health.js +194 -0
  47. package/template/wall-e/llm/anthropic.js +7 -0
  48. package/template/wall-e/llm/client.js +46 -12
  49. package/template/wall-e/llm/openai.js +17 -2
  50. package/template/wall-e/llm/portkey-sync.js +201 -0
  51. package/template/wall-e/server.js +13 -0
  52. package/template/website/index.html +10 -10
@@ -4310,6 +4310,38 @@
4310
4310
  .update-wizard-actions { flex-wrap: wrap; }
4311
4311
  .update-wizard-actions .btn { flex: 1 1 auto; }
4312
4312
  }
4313
+ .app-reload-banner {
4314
+ display: none;
4315
+ align-items: center;
4316
+ gap: 10px;
4317
+ padding: 8px 16px;
4318
+ border-bottom: 1px solid var(--border);
4319
+ background: color-mix(in srgb, var(--warning, #e5b567) 12%, var(--bg-elevated, #1f2335));
4320
+ color: var(--fg);
4321
+ font-size: 12px;
4322
+ line-height: 1.4;
4323
+ }
4324
+ .app-reload-banner.active { display: flex; }
4325
+ .app-reload-banner strong {
4326
+ color: var(--fg);
4327
+ font-weight: 700;
4328
+ }
4329
+ .app-reload-banner span {
4330
+ color: var(--fg-dim);
4331
+ }
4332
+ .app-reload-banner .btn {
4333
+ min-height: 28px;
4334
+ padding: 4px 10px;
4335
+ font-size: 12px;
4336
+ }
4337
+ .app-reload-banner .reload-copy {
4338
+ display: flex;
4339
+ flex-wrap: wrap;
4340
+ gap: 4px 8px;
4341
+ align-items: baseline;
4342
+ min-width: 0;
4343
+ flex: 1 1 auto;
4344
+ }
4313
4345
 
4314
4346
  /* Agent Picker Grid */
4315
4347
  .ns-agent-grid {
@@ -5072,6 +5104,13 @@
5072
5104
  <button id="update-apply-btn" onclick="applyUpdate('banner')" style="background:#7aa2f7;color:#1a1b26;border:none;padding:3px 10px;border-radius:4px;font-size:11px;cursor:pointer;font-weight:600;">Update Now</button>
5073
5105
  <button onclick="dismissUpdate('banner')" style="background:none;border:none;color:var(--fg-dim);cursor:pointer;margin-left:auto;opacity:0.6;font-size:14px;">&times;</button>
5074
5106
  </div>
5107
+ <div id="app-reload-banner" class="app-reload-banner" role="status" aria-live="polite" aria-atomic="true">
5108
+ <div class="reload-copy">
5109
+ <strong>Update installed.</strong>
5110
+ <span id="app-reload-msg">Reload this page to use the latest CTM / Wall-E UI.</span>
5111
+ </div>
5112
+ <button class="btn primary" id="app-reload-now-btn" onclick="reloadForInstalledUpdate('banner')">Reload now</button>
5113
+ </div>
5075
5114
  <div class="modal-overlay update-wizard-overlay hidden" id="update-wizard" role="dialog" aria-modal="true" aria-labelledby="update-wizard-heading">
5076
5115
  <div class="modal update-wizard-modal">
5077
5116
  <div class="update-wizard-head">
@@ -5859,7 +5898,10 @@ url = "http://localhost:<span id="setup-mcp-port-display-toml">3457</span>/mcp"<
5859
5898
  <div id="setup-ms-summary">Checking Microsoft tunnel...</div>
5860
5899
  <div id="setup-ms-account" class="setup-tunnel-account"></div>
5861
5900
  </div>
5862
- <button class="setup-btn setup-btn-primary setup-tunnel-primary" id="setup-ms-setup" onclick="SETUP.setupMicrosoftTunnel()">Set Up</button>
5901
+ <div class="setup-tunnel-actions">
5902
+ <button class="setup-btn setup-btn-primary setup-tunnel-primary" id="setup-ms-setup" onclick="SETUP.setupMicrosoftTunnel()">Set Up</button>
5903
+ <button class="setup-btn setup-btn-secondary setup-tunnel-secondary" id="setup-ms-switch-login" onclick="SETUP.switchMicrosoftTunnelLogin()" style="display:none;">Use Different Account</button>
5904
+ </div>
5863
5905
  </div>
5864
5906
  <ol class="setup-tunnel-steps" aria-label="Microsoft tunnel setup progress">
5865
5907
  <li id="setup-ms-step-install"><span>1</span><strong>Install</strong></li>
@@ -6351,6 +6393,7 @@ url = "http://localhost:<span id="setup-mcp-port-display-toml">3457</span>/mcp"<
6351
6393
  <div class="models-panel-scroll models-scroll">
6352
6394
  <div class="models-panel-content models-content">
6353
6395
  <div id="models-header"></div>
6396
+ <div id="models-gateways-section"></div>
6354
6397
  <div id="models-providers-grid"></div>
6355
6398
  <div id="models-shadow-config" style="margin-top:20px;"></div>
6356
6399
  <div id="models-tier-matrix" style="margin-top:20px;"></div>
@@ -7083,6 +7126,7 @@ url = "http://localhost:<span id="setup-mcp-port-display-toml">3457</span>/mcp"<
7083
7126
  <script src="js/terminal-restore-state.js"></script>
7084
7127
  <script src="js/image-normalize.js"></script>
7085
7128
  <script src="js/walle-session.js"></script>
7129
+ <script src="js/document-review-links.js"></script>
7086
7130
  <script src="js/message-renderer.js"></script>
7087
7131
  <script src="js/stream-view.js"></script>
7088
7132
  <script>
@@ -7560,6 +7604,12 @@ const state = window._ctmState = {
7560
7604
  rulesFiles: [],
7561
7605
  currentRule: null,
7562
7606
  queuePanelOpen: false,
7607
+ appRuntime: {
7608
+ loaded: null,
7609
+ latest: null,
7610
+ reloadRequired: false,
7611
+ reloadReason: '',
7612
+ },
7563
7613
  splitRoot: null, // Binary tree of panes (null = single-pane mode)
7564
7614
  savedSplitLayout: null, // Suspended split tree (saved when switching to non-split tab)
7565
7615
  focusedPaneId: null, // ID of focused leaf pane
@@ -7569,6 +7619,134 @@ const TERMINAL_HOT_CACHE_LIMIT = 3;
7569
7619
  const DEFAULT_SNAPSHOT_SCROLLBACK_ROWS = 300;
7570
7620
  const MAX_SNAPSHOT_SCROLLBACK_ROWS = 10000;
7571
7621
  const SNAPSHOT_SCROLLBACK_ROW_OPTIONS = Object.freeze([0, 100, 300, 1000, 2000, 10000]);
7622
+ const APP_RELOAD_CHANNEL_NAME = 'ctm-app-update';
7623
+ let _appReloadChannel = null;
7624
+
7625
+ function normalizeAppVersionInfo(info) {
7626
+ if (!info || typeof info !== 'object') return null;
7627
+ const version = String(info.version || '').trim();
7628
+ const buildId = String(info.buildId || '').trim();
7629
+ const product = String(info.product || 'create-walle').trim();
7630
+ const components = info.components && typeof info.components === 'object' ? {
7631
+ ctm: String(info.components.ctm || '').trim(),
7632
+ wallE: String(info.components.wallE || '').trim(),
7633
+ } : {};
7634
+ if (!version && !buildId) return null;
7635
+ return { version, buildId, product, components };
7636
+ }
7637
+
7638
+ function appVersionIdentity(info) {
7639
+ const normalized = normalizeAppVersionInfo(info);
7640
+ if (!normalized) return '';
7641
+ return normalized.buildId || `${normalized.product}:${normalized.version}:${normalized.components.ctm || ''}:${normalized.components.wallE || ''}`;
7642
+ }
7643
+
7644
+ function sameAppVersionIdentity(a, b) {
7645
+ const aid = appVersionIdentity(a);
7646
+ const bid = appVersionIdentity(b);
7647
+ return !!aid && !!bid && aid === bid;
7648
+ }
7649
+
7650
+ function initAppReloadChannel() {
7651
+ if (_appReloadChannel || typeof BroadcastChannel === 'undefined') return _appReloadChannel;
7652
+ try {
7653
+ _appReloadChannel = new BroadcastChannel(APP_RELOAD_CHANNEL_NAME);
7654
+ _appReloadChannel.onmessage = (event) => {
7655
+ const data = event && event.data;
7656
+ if (!data || data.type !== 'reload-required') return;
7657
+ requestInstalledUpdateReload({
7658
+ server: data.server,
7659
+ reason: data.reason || 'peer-detected',
7660
+ source: 'broadcast',
7661
+ rebroadcast: false,
7662
+ });
7663
+ };
7664
+ } catch (_) {
7665
+ _appReloadChannel = null;
7666
+ }
7667
+ return _appReloadChannel;
7668
+ }
7669
+
7670
+ function broadcastInstalledUpdateReload(server, reason) {
7671
+ const channel = initAppReloadChannel();
7672
+ if (!channel) return;
7673
+ try {
7674
+ channel.postMessage({ type: 'reload-required', server, reason, at: Date.now() });
7675
+ } catch (_) {}
7676
+ }
7677
+
7678
+ function handleServerHello(msg) {
7679
+ if (msg && msg.clientId) state._clientId = msg.clientId;
7680
+ const server = normalizeAppVersionInfo(msg && msg.appVersion);
7681
+ if (!server) return;
7682
+ if (!state.appRuntime.loaded) {
7683
+ state.appRuntime.loaded = server;
7684
+ state.appRuntime.latest = server;
7685
+ setAppVersion(server.version, { ...server, latestVersion: server.version });
7686
+ return;
7687
+ }
7688
+ state.appRuntime.latest = server;
7689
+ if (!sameAppVersionIdentity(state.appRuntime.loaded, server)) {
7690
+ requestInstalledUpdateReload({ server, reason: 'server-version-changed', source: 'hello' });
7691
+ }
7692
+ }
7693
+
7694
+ function isAppReloadUnsafe() {
7695
+ if (shouldPreserveFocusForUpdatePrompt()) return true;
7696
+ if (state.queuePanelOpen) return true;
7697
+ const screenshotEditor = document.getElementById('screenshot-editor');
7698
+ if (screenshotEditor && screenshotEditor.classList.contains('active')) return true;
7699
+ const openModal = document.querySelector('.modal-overlay:not(.hidden)');
7700
+ if (openModal) return true;
7701
+ return false;
7702
+ }
7703
+
7704
+ function showInstalledUpdateReloadBanner(server) {
7705
+ const banner = document.getElementById('app-reload-banner');
7706
+ const msg = document.getElementById('app-reload-msg');
7707
+ if (!banner) return;
7708
+ const loaded = state.appRuntime.loaded || {};
7709
+ const latest = normalizeAppVersionInfo(server) || state.appRuntime.latest || {};
7710
+ if (msg) {
7711
+ const from = loaded.version ? `v${loaded.version}` : 'old UI';
7712
+ const to = latest.version ? `v${latest.version}` : 'the installed update';
7713
+ msg.textContent = `Reload this page to use ${to}. Current tab is still running ${from}.`;
7714
+ }
7715
+ banner.classList.add('active');
7716
+ }
7717
+
7718
+ function requestInstalledUpdateReload(opts = {}) {
7719
+ const server = normalizeAppVersionInfo(opts.server) || state.appRuntime.latest;
7720
+ state.appRuntime.latest = server || state.appRuntime.latest;
7721
+ state.appRuntime.reloadRequired = true;
7722
+ state.appRuntime.reloadReason = opts.reason || 'app-updated';
7723
+ if (opts.rebroadcast !== false) broadcastInstalledUpdateReload(server, state.appRuntime.reloadReason);
7724
+ if (!isAppReloadUnsafe()) {
7725
+ reloadForInstalledUpdate(opts.source || 'auto');
7726
+ return;
7727
+ }
7728
+ showInstalledUpdateReloadBanner(server);
7729
+ }
7730
+
7731
+ function reloadForInstalledUpdate(source = 'button') {
7732
+ const detail = {
7733
+ source,
7734
+ reason: state.appRuntime.reloadReason || 'app-updated',
7735
+ loaded: state.appRuntime.loaded,
7736
+ latest: state.appRuntime.latest,
7737
+ };
7738
+ try { sessionStorage.setItem('ctm_app_update_reload', JSON.stringify({ ...detail, at: Date.now() })); } catch (_) {}
7739
+ if (typeof window.__ctmAppReload === 'function') {
7740
+ window.__ctmAppReload(detail);
7741
+ return;
7742
+ }
7743
+ location.reload();
7744
+ }
7745
+
7746
+ window.__ctmHandleServerHello = handleServerHello;
7747
+ window.__ctmRequestInstalledUpdateReload = requestInstalledUpdateReload;
7748
+ window.__ctmIsAppReloadUnsafe = isAppReloadUnsafe;
7749
+ initAppReloadChannel();
7572
7750
 
7573
7751
  function normalizeSnapshotScrollbackRows(value, fallback = DEFAULT_SNAPSHOT_SCROLLBACK_ROWS) {
7574
7752
  if (value == null || value === '') return normalizeSnapshotScrollbackRows(fallback, DEFAULT_SNAPSHOT_SCROLLBACK_ROWS);
@@ -8010,7 +8188,7 @@ function connect() {
8010
8188
  ws.onmessage = (e) => {
8011
8189
  const msg = JSON.parse(e.data);
8012
8190
  switch (msg.type) {
8013
- case 'hello': state._clientId = msg.clientId; break;
8191
+ case 'hello': handleServerHello(msg); break;
8014
8192
  case 'ui-prefs-changed': onUiPrefsChanged(msg); break;
8015
8193
  case 'created': onCreated(msg); break;
8016
8194
  case 'output': onOutput(msg); break;
@@ -11356,6 +11534,10 @@ function _disposeTerminalRenderer(s) {
11356
11534
  try { s._helperAlignDisposer.dispose(); } catch {}
11357
11535
  s._helperAlignDisposer = null;
11358
11536
  }
11537
+ if (s._docReviewLinkProvider) {
11538
+ try { s._docReviewLinkProvider.dispose(); } catch {}
11539
+ s._docReviewLinkProvider = null;
11540
+ }
11359
11541
  if (s._webglAddon) {
11360
11542
  try { s._webglAddon.dispose(); } catch {}
11361
11543
  s._webglAddon = null;
@@ -12510,6 +12692,78 @@ function createTerminal(id, opts) {
12510
12692
  return ' [' + label + ': ' + paths.map(p => '"' + p.replace(/"/g, '\\"') + '"').join(', ') + '] ';
12511
12693
  }
12512
12694
 
12695
+ function _terminalImageSubmitKey(att) {
12696
+ return String(att && (att.path || att.url || att.filename) || '').trim();
12697
+ }
12698
+
12699
+ function _terminalPendingImageSubmitAttachments(id) {
12700
+ if (!_terminalProviderAcceptsImagePathPaste(id)) return [];
12701
+ const list = _terminalImageAttachmentState(id) || [];
12702
+ const seen = new Set();
12703
+ const pending = [];
12704
+ for (const att of list) {
12705
+ const key = _terminalImageSubmitKey(att);
12706
+ if (!key || seen.has(key)) continue;
12707
+ seen.add(key);
12708
+ if (att._terminalSubmitAcknowledgedAt || att._terminalSubmitDiscardedAt) continue;
12709
+ pending.push(att);
12710
+ }
12711
+ return pending;
12712
+ }
12713
+
12714
+ function _terminalMarkImageSubmitState(attachments, field) {
12715
+ const now = Date.now();
12716
+ for (const att of attachments || []) {
12717
+ if (!att) continue;
12718
+ att[field] = now;
12719
+ }
12720
+ }
12721
+
12722
+ function _terminalSubmittedPromptText(id, s, attachments) {
12723
+ const labels = (attachments || []).map(att => att && att.label).filter(Boolean).join(' ');
12724
+ const draft = String(_terminalInputDraftPreview(s, '') || '').trim();
12725
+ return [labels, draft].filter(Boolean).join(' ').trim();
12726
+ }
12727
+
12728
+ function _terminalRecordSessionImageRefs(id, attachments, promptText) {
12729
+ const list = (attachments || []).filter(Boolean).map(att => ({
12730
+ id: att.id || null,
12731
+ label: att.label || '',
12732
+ filename: att.filename || '',
12733
+ path: att.path || '',
12734
+ url: att.url || '',
12735
+ }));
12736
+ if (!list.length) return;
12737
+ fetch('/api/session/image-refs?token=' + encodeURIComponent(state.token || ''), {
12738
+ method: 'POST',
12739
+ headers: { 'Content-Type': 'application/json' },
12740
+ keepalive: true,
12741
+ body: JSON.stringify({
12742
+ sessionId: id,
12743
+ submittedAt: Date.now(),
12744
+ promptText: String(promptText || '').slice(0, 4096),
12745
+ images: list,
12746
+ }),
12747
+ }).catch(err => {
12748
+ console.warn('[terminal] session image ref recording failed:', err && err.message ? err.message : err);
12749
+ });
12750
+ }
12751
+
12752
+ function _terminalAcknowledgePendingImageSubmit(id, promptText) {
12753
+ const pending = _terminalPendingImageSubmitAttachments(id);
12754
+ if (!pending.length) return false;
12755
+ _terminalRecordSessionImageRefs(id, pending, promptText);
12756
+ _terminalMarkImageSubmitState(pending, '_terminalSubmitAcknowledgedAt');
12757
+ return true;
12758
+ }
12759
+
12760
+ function _terminalDiscardPendingImageSubmit(id) {
12761
+ const pending = _terminalPendingImageSubmitAttachments(id);
12762
+ if (!pending.length) return false;
12763
+ _terminalMarkImageSubmitState(pending, '_terminalSubmitDiscardedAt');
12764
+ return true;
12765
+ }
12766
+
12513
12767
  function _terminalProviderAcceptsImagePathPaste(id) {
12514
12768
  const s = state.sessions.get(id);
12515
12769
  const agentType = _clientAgentTypeForSession(s);
@@ -12784,6 +13038,12 @@ function createTerminal(id, opts) {
12784
13038
  if (!isSinglePrintableChar && data === _lastSentData && now - _lastSentTs < 10) return;
12785
13039
  _lastSentData = data;
12786
13040
  _lastSentTs = now;
13041
+ if (_terminalInputSubmitsComposer(data)) {
13042
+ const submitSession = state.sessions.get(id);
13043
+ const pending = _terminalPendingImageSubmitAttachments(id);
13044
+ _terminalAcknowledgePendingImageSubmit(id, _terminalSubmittedPromptText(id, submitSession, pending));
13045
+ }
13046
+ else if (String(data || '').includes('\x03') || String(data || '').includes('\x15')) _terminalDiscardPendingImageSubmit(id);
12787
13047
  _forwardTerminalInput(id, data);
12788
13048
  _terminalInputDraftObserve(id, data);
12789
13049
  _terminalSkillObserveInput(id, data);
@@ -12918,6 +13178,9 @@ function createTerminal(id, opts) {
12918
13178
  _sessionViewState(sessionEntry, id);
12919
13179
  _markTerminalRendererChanged(sessionEntry, 'terminal-created');
12920
13180
  state.sessions.set(id, sessionEntry);
13181
+ if (window.CTMDocLinks && typeof window.CTMDocLinks.registerTerminalLinkProvider === 'function') {
13182
+ try { sessionEntry._docReviewLinkProvider = window.CTMDocLinks.registerTerminalLinkProvider(term, id); } catch {}
13183
+ }
12921
13184
  if (typeof term.onWriteParsed === 'function') {
12922
13185
  sessionEntry._helperAlignDisposer = term.onWriteParsed(() => {
12923
13186
  const current = state.sessions.get(id);
@@ -14053,7 +14316,7 @@ function activateTab(id) {
14053
14316
 
14054
14317
  // Hide all
14055
14318
  for (const [sid, s] of state.sessions) {
14056
- s.container.classList.remove('active');
14319
+ if (s && s.container && s.container.classList) s.container.classList.remove('active');
14057
14320
  }
14058
14321
  document.getElementById('welcome').style.display = 'none';
14059
14322
  document.getElementById('rules-panel').classList.remove('active');
@@ -15392,7 +15655,7 @@ function navTo(target, opts) {
15392
15655
  } else if (target === 'permissions') {
15393
15656
  showPermissionsPanel();
15394
15657
  } else if (target === 'codereview') {
15395
- showCodeReviewPanel();
15658
+ showCodeReviewPanel(opts || {});
15396
15659
  } else if (target === 'walle') {
15397
15660
  showWallePanel();
15398
15661
  } else if (target === 'models') {
@@ -15406,14 +15669,16 @@ function navTo(target, opts) {
15406
15669
  }
15407
15670
  }
15408
15671
 
15409
- function showCodeReviewPanel() {
15672
+ function showCodeReviewPanel(opts) {
15673
+ opts = opts || {};
15410
15674
  if (!state.tabOrder.includes('codereview')) {
15411
15675
  state.tabOrder.push('codereview');
15412
15676
  }
15413
15677
  activateTab('codereview');
15414
15678
  renderTabs();
15415
15679
  // Always show/refresh project list (unless in an active diff review)
15416
- if (window.CR && (!window.crState || !window.crState.reviewId)) {
15680
+ const reviewIsBusy = window.crState && (window.crState._view === 'review' || window.crState.reviewType === 'doc');
15681
+ if (!opts.suppressProjectList && window.CR && (!window.crState || (!window.crState.reviewId && !reviewIsBusy))) {
15417
15682
  CR.showProjectList();
15418
15683
  }
15419
15684
  }
@@ -15429,6 +15694,7 @@ function showWallePanel() {
15429
15694
  // === Models Tab ===
15430
15695
  var _modelsRegistryFilter = '';
15431
15696
  var _modelsExpandedProviders = new Set();
15697
+ var _modelsCatalogExpanded = false;
15432
15698
  var _modelsRetryTimer = null;
15433
15699
  var _modelsRetryCount = 0;
15434
15700
  var _modelsHasRenderedCatalog = false;
@@ -15530,6 +15796,7 @@ function _scheduleModelsRetry(delayMs) {
15530
15796
 
15531
15797
  function renderModelsLoadState(kind, message, detail, retryDelayMs) {
15532
15798
  var header = document.getElementById('models-header');
15799
+ var gateways = document.getElementById('models-gateways-section');
15533
15800
  var grid = document.getElementById('models-providers-grid');
15534
15801
  if (!header || !grid) return;
15535
15802
  var isError = kind === 'error';
@@ -15551,6 +15818,7 @@ function renderModelsLoadState(kind, message, detail, retryDelayMs) {
15551
15818
  '<button id="models-retry-btn" style="padding:6px 12px;border-radius:6px;border:1px solid var(--border,#414868);background:transparent;color:var(--fg,#c0caf5);cursor:pointer;font-size:12px;">Retry now</button>' +
15552
15819
  '</div>';
15553
15820
  header.innerHTML = DOMPurify.sanitize(h, { ADD_ATTR: ['style'] });
15821
+ if (gateways) gateways.innerHTML = '';
15554
15822
  grid.innerHTML = DOMPurify.sanitize(body, { ADD_ATTR: ['style'] });
15555
15823
  var addBtn = document.getElementById('models-add-provider-btn');
15556
15824
  if (addBtn) addBtn.addEventListener('click', function() { showAddProviderDialog(); });
@@ -15570,9 +15838,10 @@ async function showModelsPanel(options) {
15570
15838
  renderModelsLoadState('loading', 'Loading model catalog...', 'CTM is reading model providers and registry data from Wall-E.', 0);
15571
15839
  }
15572
15840
  try {
15573
- const [provRes, regRes, scRes, healthRes, tierRes, promoRes, reviewRes, shadowRes] = await Promise.all([
15841
+ const [provRes, regRes, gatewayRes, scRes, healthRes, tierRes, promoRes, reviewRes, shadowRes] = await Promise.all([
15574
15842
  _fetchModelsJson('/api/models/providers', { listKey: 'providers' }),
15575
15843
  _fetchModelsJson('/api/models/registry', { listKey: 'models' }),
15844
+ _fetchModelsJson('/api/models/gateways', { listKey: 'gateways' }).catch(function() { return { gateways: [] }; }),
15576
15845
  _fetchModelsJson('/api/models/scorecard?days=' + encodeURIComponent(_scorecardDaysParam())).catch(function() { return { scorecard: [], benchmarks: [], benchmarkCoverage: null }; }),
15577
15846
  _fetchModelsJson('/api/models/health').catch(function() { return {}; }),
15578
15847
  _fetchModelsJson('/api/models/tier-defaults').catch(function() { return {}; }),
@@ -15582,9 +15851,11 @@ async function showModelsPanel(options) {
15582
15851
  ]);
15583
15852
  var providers = _modelsPayloadList(provRes, 'providers') || [];
15584
15853
  var models = _modelsPayloadList(regRes, 'models') || [];
15854
+ var gateways = _modelsPayloadList(gatewayRes, 'gateways') || [];
15585
15855
  _modelsRetryCount = 0;
15586
15856
  _modelsHasRenderedCatalog = true;
15587
15857
  renderModelsHeader(providers, models);
15858
+ renderModelGateways(gateways, providers, models);
15588
15859
  renderModelProviders(providers, models, healthRes);
15589
15860
  renderShadowConfig(shadowRes, models);
15590
15861
  renderTierMatrix(tierRes, providers);
@@ -15689,6 +15960,124 @@ function _portkeyDefaultBaseUrl(type) {
15689
15960
  return type === 'anthropic' ? 'https://api.portkey.ai' : 'https://api.portkey.ai/v1';
15690
15961
  }
15691
15962
 
15963
+ function _providerRoutePolicyLabel(policy) {
15964
+ if (policy === 'portkey') return 'Prefer Portkey';
15965
+ if (policy === 'direct') return 'Prefer Direct';
15966
+ return 'Auto';
15967
+ }
15968
+
15969
+ function _formatModelSyncTime(value) {
15970
+ if (!value) return 'Not synced yet';
15971
+ var t = Date.parse(value);
15972
+ if (!Number.isFinite(t)) return value;
15973
+ var seconds = Math.max(0, Math.round((Date.now() - t) / 1000));
15974
+ if (seconds < 60) return seconds + 's ago';
15975
+ var minutes = Math.round(seconds / 60);
15976
+ if (minutes < 60) return minutes + 'm ago';
15977
+ var hours = Math.round(minutes / 60);
15978
+ if (hours < 48) return hours + 'h ago';
15979
+ return new Date(t).toLocaleString();
15980
+ }
15981
+
15982
+ function _modelProviderSupportsPortkey(type) {
15983
+ return ['anthropic', 'openai', 'google', 'deepseek', 'moonshot'].indexOf(type) >= 0;
15984
+ }
15985
+
15986
+ function _modelProviderRoutePolicy(providers, type) {
15987
+ var row = (providers || []).find(function(p) { return p.type === type && p.route_policy; });
15988
+ return row ? row.route_policy : 'auto';
15989
+ }
15990
+
15991
+ async function setProviderRoutePolicy(type, policy) {
15992
+ await fetch('/api/models/provider-route-policy?token=' + state.token, {
15993
+ method: 'POST',
15994
+ headers: { 'Content-Type': 'application/json' },
15995
+ body: JSON.stringify({ type: type, policy: policy }),
15996
+ });
15997
+ showModelsPanel();
15998
+ }
15999
+
16000
+ async function syncPortkeyModels(providerId) {
16001
+ await fetch('/api/models/portkey/sync?token=' + state.token, {
16002
+ method: 'POST',
16003
+ headers: { 'Content-Type': 'application/json' },
16004
+ body: JSON.stringify(providerId ? { provider_id: providerId } : {}),
16005
+ });
16006
+ showModelsPanel();
16007
+ }
16008
+
16009
+ async function applyPortkeyToAllProviders() {
16010
+ await fetch('/api/models/portkey/apply-all?token=' + state.token, {
16011
+ method: 'POST',
16012
+ headers: { 'Content-Type': 'application/json' },
16013
+ body: JSON.stringify({}),
16014
+ });
16015
+ showModelsPanel();
16016
+ }
16017
+
16018
+ async function disablePortkeyDefault() {
16019
+ await fetch('/api/models/portkey/disable-default?token=' + state.token, {
16020
+ method: 'POST',
16021
+ headers: { 'Content-Type': 'application/json' },
16022
+ body: JSON.stringify({}),
16023
+ });
16024
+ showModelsPanel();
16025
+ }
16026
+
16027
+ async function removePortkeyGateway() {
16028
+ if (!confirm('Remove all Portkey gateway connections and their imported gateway models? Direct provider API access will remain.')) return;
16029
+ await fetch('/api/models/gateways/portkey?token=' + state.token, { method: 'DELETE' });
16030
+ showModelsPanel();
16031
+ }
16032
+
16033
+ function renderModelGateways(gateways, providers, models) {
16034
+ var el = document.getElementById('models-gateways-section');
16035
+ if (!el) return;
16036
+ var portkey = (gateways || []).find(function(g) { return g.type === 'portkey'; }) || { type: 'portkey', name: 'Portkey Gateway', routes: [], route_count: 0, provider_count: 0, model_count: 0, provider_types: [] };
16037
+ var hasRoutes = (portkey.routes || []).length > 0;
16038
+ var routeSummary = hasRoutes
16039
+ ? portkey.provider_count + ' provider' + (portkey.provider_count !== 1 ? 's' : '') + ' · ' + portkey.model_count + ' imported model' + (portkey.model_count !== 1 ? 's' : '')
16040
+ : 'No Portkey gateway configured';
16041
+ var lastSync = portkey.last_success_at || portkey.last_run_at || '';
16042
+ var h = '<section style="margin-bottom:14px;border:1px solid var(--border,#414868);background:var(--bg-card,#24283b);border-radius:8px;padding:14px;">' +
16043
+ '<div style="display:flex;justify-content:space-between;gap:12px;align-items:flex-start;flex-wrap:wrap;">' +
16044
+ '<div>' +
16045
+ '<div style="font-size:11px;color:var(--fg-dim,#565f89);text-transform:uppercase;letter-spacing:0.5px;margin-bottom:4px;">Gateways</div>' +
16046
+ '<div style="font-size:15px;color:var(--fg,#c0caf5);font-weight:600;">Portkey Gateway</div>' +
16047
+ '<div style="font-size:12px;color:var(--fg-dim,#565f89);margin-top:3px;">' + escHtml(routeSummary) + '</div>' +
16048
+ '<div style="font-size:11px;color:' + (portkey.last_error ? '#f7768e' : '#7dcfff') + ';margin-top:5px;">' +
16049
+ (portkey.last_error ? 'Last sync error: ' + escHtml(String(portkey.last_error).slice(0, 120)) : 'Last sync: ' + escHtml(_formatModelSyncTime(lastSync))) +
16050
+ '</div>' +
16051
+ '</div>' +
16052
+ '<div style="display:flex;gap:6px;flex-wrap:wrap;justify-content:flex-end;">' +
16053
+ '<button data-connect-portkey style="padding:5px 10px;border-radius:5px;border:1px solid var(--border,#414868);background:' + (hasRoutes ? 'transparent' : 'var(--accent,#7aa2f7)') + ';color:' + (hasRoutes ? 'var(--fg,#c0caf5)' : '#1a1b26') + ';cursor:pointer;font-size:11px;font-weight:600;">' + (hasRoutes ? 'Add Route' : 'Connect Portkey') + '</button>' +
16054
+ '<button data-sync-portkey style="padding:5px 10px;border-radius:5px;border:1px solid var(--border,#414868);background:transparent;color:var(--fg,#c0caf5);cursor:pointer;font-size:11px;">Sync now</button>' +
16055
+ '<button data-apply-portkey-all style="padding:5px 10px;border-radius:5px;border:1px solid var(--border,#414868);background:transparent;color:#7dcfff;cursor:pointer;font-size:11px;">Apply to all providers</button>' +
16056
+ '<button data-disable-portkey-default style="padding:5px 10px;border-radius:5px;border:1px solid var(--border,#414868);background:transparent;color:var(--fg-dim,#565f89);cursor:pointer;font-size:11px;">Disable as default</button>' +
16057
+ (hasRoutes ? '<button data-remove-portkey style="padding:5px 10px;border-radius:5px;border:none;background:transparent;color:#f7768e;cursor:pointer;font-size:11px;">Remove</button>' : '') +
16058
+ '</div>' +
16059
+ '</div>';
16060
+ if (hasRoutes) {
16061
+ h += '<div style="display:flex;gap:6px;flex-wrap:wrap;margin-top:10px;">' + (portkey.routes || []).map(function(route) {
16062
+ return '<span style="font-size:10px;color:var(--fg-dim,#565f89);background:var(--bg,#1a1b26);border:1px solid var(--border,#414868);border-radius:4px;padding:3px 7px;">' +
16063
+ escHtml(_providerTypeName(route.type)) + ' · ' + escHtml(_providerRoutePolicyLabel(route.route_policy || 'auto')) + ' · ' + route.model_count + ' model' + (route.model_count !== 1 ? 's' : '') +
16064
+ '</span>';
16065
+ }).join('') + '</div>';
16066
+ }
16067
+ h += '</section>';
16068
+ el.innerHTML = DOMPurify.sanitize(h, { ADD_ATTR: ['style', 'data-connect-portkey', 'data-sync-portkey', 'data-apply-portkey-all', 'data-disable-portkey-default', 'data-remove-portkey'] });
16069
+ var connect = el.querySelector('[data-connect-portkey]');
16070
+ if (connect) connect.addEventListener('click', function() { showAddProviderDialog('', 'portkey'); });
16071
+ var sync = el.querySelector('[data-sync-portkey]');
16072
+ if (sync) sync.addEventListener('click', function() { sync.textContent = 'Syncing...'; syncPortkeyModels(); });
16073
+ var applyAll = el.querySelector('[data-apply-portkey-all]');
16074
+ if (applyAll) applyAll.addEventListener('click', applyPortkeyToAllProviders);
16075
+ var disableDefault = el.querySelector('[data-disable-portkey-default]');
16076
+ if (disableDefault) disableDefault.addEventListener('click', disablePortkeyDefault);
16077
+ var remove = el.querySelector('[data-remove-portkey]');
16078
+ if (remove) remove.addEventListener('click', removePortkeyGateway);
16079
+ }
16080
+
15692
16081
  function renderModelsHeader(providers, models) {
15693
16082
  var el = document.getElementById('models-header');
15694
16083
  var activeProviders = providers.filter(function(p) { return p.enabled; }).length;
@@ -15769,6 +16158,9 @@ function renderModelProviders(providers, models, healthData) {
15769
16158
 
15770
16159
  var anyEnabled = instances.some(function(p) { return p.enabled; });
15771
16160
  var instanceCountLabel = instances.length > 1 ? ' (' + instances.length + ' routes)' : '';
16161
+ var portkeyInstances = instances.filter(function(p) { return _providerConnectionKind(p) === 'portkey'; });
16162
+ var directInstances = instances.filter(function(p) { return _providerConnectionKind(p) !== 'portkey'; });
16163
+ var routePolicy = _modelProviderRoutePolicy(instances, type);
15772
16164
 
15773
16165
  // Brand mark next to the provider name. Falls back to a tinted dot when
15774
16166
  // brandIconSvg() doesn't recognise the type (custom / unknown).
@@ -15800,6 +16192,27 @@ function renderModelProviders(providers, models, healthData) {
15800
16192
  '</div>' +
15801
16193
  '</div>';
15802
16194
 
16195
+ if (!isLocal && _modelProviderSupportsPortkey(type)) {
16196
+ h += '<div style="margin-top:10px;background:var(--bg,#1a1b26);border-radius:6px;padding:9px 10px;">' +
16197
+ '<div style="display:flex;justify-content:space-between;gap:8px;align-items:center;flex-wrap:wrap;">' +
16198
+ '<div>' +
16199
+ '<div style="font-size:10px;color:var(--fg-dim,#565f89);text-transform:uppercase;letter-spacing:0.5px;">Access Route</div>' +
16200
+ '<div style="font-size:12px;color:var(--fg,#c0caf5);margin-top:2px;">' + escHtml(_providerRoutePolicyLabel(routePolicy)) + '</div>' +
16201
+ '<div style="font-size:10px;color:var(--fg-dim,#565f89);margin-top:2px;">' +
16202
+ directInstances.length + ' direct · ' + portkeyInstances.length + ' Portkey' +
16203
+ '</div>' +
16204
+ '</div>' +
16205
+ '<div style="display:flex;gap:5px;flex-wrap:wrap;justify-content:flex-end;">' +
16206
+ (portkeyInstances.length
16207
+ ? '<button data-route-policy-type="' + escHtml(type) + '" data-route-policy="portkey" style="padding:4px 8px;border-radius:4px;border:1px solid ' + (routePolicy === 'portkey' ? '#7dcfff' : 'var(--border,#414868)') + ';background:' + (routePolicy === 'portkey' ? '#7dcfff22' : 'transparent') + ';color:#7dcfff;cursor:pointer;font-size:10px;">Use Portkey</button>'
16208
+ : '<button data-enable-portkey-type="' + escHtml(type) + '" style="padding:4px 8px;border-radius:4px;border:1px solid var(--border,#414868);background:transparent;color:#7dcfff;cursor:pointer;font-size:10px;">Enable Portkey</button>') +
16209
+ '<button data-route-policy-type="' + escHtml(type) + '" data-route-policy="direct" style="padding:4px 8px;border-radius:4px;border:1px solid ' + (routePolicy === 'direct' ? '#9ece6a' : 'var(--border,#414868)') + ';background:' + (routePolicy === 'direct' ? '#9ece6a22' : 'transparent') + ';color:#9ece6a;cursor:pointer;font-size:10px;">Prefer Direct</button>' +
16210
+ '<button data-route-policy-type="' + escHtml(type) + '" data-route-policy="auto" style="padding:4px 8px;border-radius:4px;border:1px solid ' + (routePolicy === 'auto' ? '#7aa2f7' : 'var(--border,#414868)') + ';background:' + (routePolicy === 'auto' ? '#7aa2f722' : 'transparent') + ';color:#7aa2f7;cursor:pointer;font-size:10px;">Auto</button>' +
16211
+ '</div>' +
16212
+ '</div>' +
16213
+ '</div>';
16214
+ }
16215
+
15803
16216
  // Instance sub-rows (shown when multiple keys exist OR always for clarity)
15804
16217
  if (instances.length > 0) {
15805
16218
  h += '<div style="margin-top:12px;border-top:1px solid var(--border,#414868);padding-top:10px;">';
@@ -15843,9 +16256,17 @@ function renderModelProviders(providers, models, healthData) {
15843
16256
  h += '</div>';
15844
16257
  });
15845
16258
  h += '</div>';
15846
- el.innerHTML = DOMPurify.sanitize(h, { ADD_ATTR: ['style', 'data-rescan-provider', 'data-delete-provider', 'data-type', 'data-test-instance', 'data-add-key-type', 'title'] });
16259
+ el.innerHTML = DOMPurify.sanitize(h, { ADD_ATTR: ['style', 'data-rescan-provider', 'data-delete-provider', 'data-type', 'data-test-instance', 'data-add-key-type', 'data-route-policy-type', 'data-route-policy', 'data-enable-portkey-type', 'title'] });
15847
16260
 
15848
16261
  // Wire up event handlers
16262
+ el.querySelectorAll('[data-route-policy-type]').forEach(function(btn) {
16263
+ btn.addEventListener('click', function() {
16264
+ setProviderRoutePolicy(btn.dataset.routePolicyType, btn.dataset.routePolicy || 'auto');
16265
+ });
16266
+ });
16267
+ el.querySelectorAll('[data-enable-portkey-type]').forEach(function(btn) {
16268
+ btn.addEventListener('click', function() { showAddProviderDialog(btn.dataset.enablePortkeyType, 'portkey'); });
16269
+ });
15849
16270
  el.querySelectorAll('[data-delete-provider]').forEach(function(btn) {
15850
16271
  btn.addEventListener('click', function() { deleteModelProvider(btn.dataset.deleteProvider); });
15851
16272
  });
@@ -15963,10 +16384,17 @@ function renderModelRegistry(models) {
15963
16384
  groups[key].push(m);
15964
16385
  });
15965
16386
 
15966
- var h = '<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:8px;">' +
15967
- '<span style="font-size:13px;font-weight:600;color:var(--fg,#c0caf5);">Model Registry</span>' +
15968
- '<span style="font-size:11px;color:var(--fg-dim,#565f89);">' + models.length + ' models</span>' +
15969
- '</div>';
16387
+ if (_modelsRegistryFilter) _modelsCatalogExpanded = true;
16388
+ var h = '<div style="border:1px solid var(--border,#414868);border-radius:8px;overflow:hidden;background:var(--bg-card,#24283b);">' +
16389
+ '<div data-toggle-model-catalog style="display:flex;justify-content:space-between;align-items:center;padding:10px 14px;cursor:pointer;user-select:none;">' +
16390
+ '<div style="display:flex;align-items:center;gap:8px;">' +
16391
+ '<span style="color:#7dcfff;font-size:11px;transition:transform 0.2s;display:inline-block;' + (_modelsCatalogExpanded ? 'transform:rotate(90deg)' : '') + ';">\u25B6</span>' +
16392
+ '<span style="font-size:13px;font-weight:600;color:var(--fg,#c0caf5);">Model Catalog</span>' +
16393
+ '<span style="font-size:11px;color:var(--fg-dim,#565f89);">' + models.length + ' models</span>' +
16394
+ '</div>' +
16395
+ '<span style="font-size:11px;color:var(--fg-dim,#565f89);">' + (_modelsCatalogExpanded ? 'Collapse' : 'Expand') + '</span>' +
16396
+ '</div>' +
16397
+ '<div class="models-catalog-body" style="display:' + (_modelsCatalogExpanded ? 'block' : 'none') + ';border-top:1px solid var(--border,#414868);padding:8px;">';
15970
16398
 
15971
16399
  groupOrder.forEach(function(provName) {
15972
16400
  var pModels = groups[provName];
@@ -16019,7 +16447,17 @@ function renderModelRegistry(models) {
16019
16447
  h += '</div>';
16020
16448
  });
16021
16449
 
16022
- el.innerHTML = DOMPurify.sanitize(h, { ADD_ATTR: ['style', 'data-toggle-provider', 'data-provider', 'data-name'] });
16450
+ h += '</div></div>';
16451
+
16452
+ el.innerHTML = DOMPurify.sanitize(h, { ADD_ATTR: ['style', 'data-toggle-model-catalog', 'data-toggle-provider', 'data-provider', 'data-name'] });
16453
+ var catalogToggle = el.querySelector('[data-toggle-model-catalog]');
16454
+ if (catalogToggle) {
16455
+ catalogToggle.addEventListener('click', function() {
16456
+ _modelsCatalogExpanded = !_modelsCatalogExpanded;
16457
+ renderModelRegistry(models);
16458
+ _filterModelCards();
16459
+ });
16460
+ }
16023
16461
  // Bind toggle
16024
16462
  el.querySelectorAll('[data-toggle-provider]').forEach(function(hdr) {
16025
16463
  hdr.addEventListener('click', function() {
@@ -16041,6 +16479,11 @@ function renderModelRegistry(models) {
16041
16479
 
16042
16480
  function _filterModelCards() {
16043
16481
  var q = (_modelsRegistryFilter || '').toLowerCase();
16482
+ var catalogBody = document.querySelector('#models-registry-table .models-catalog-body');
16483
+ if (catalogBody && q) {
16484
+ catalogBody.style.display = 'block';
16485
+ _modelsCatalogExpanded = true;
16486
+ }
16044
16487
  document.querySelectorAll('.models-provider-group').forEach(function(group) {
16045
16488
  var rows = group.querySelectorAll('.model-row');
16046
16489
  var visibleCount = 0;
@@ -17610,8 +18053,9 @@ async function deleteModelProvider(id) {
17610
18053
  showModelsPanel();
17611
18054
  }
17612
18055
 
17613
- async function showAddProviderDialog(preselectedType) {
18056
+ async function showAddProviderDialog(preselectedType, preferredConnection) {
17614
18057
  if (typeof preselectedType !== 'string') preselectedType = '';
18058
+ if (typeof preferredConnection !== 'string') preferredConnection = '';
17615
18059
  var existing = document.getElementById('add-provider-dialog');
17616
18060
  if (existing) existing.remove();
17617
18061
 
@@ -17733,6 +18177,10 @@ async function showAddProviderDialog(preselectedType) {
17733
18177
  }
17734
18178
  document.getElementById('new-provider-type').addEventListener('change', updateProviderPlaceholders);
17735
18179
  document.getElementById('new-provider-connection').addEventListener('change', updateProviderPlaceholders);
18180
+ if (preferredConnection === 'portkey') {
18181
+ var connectionEl = document.getElementById('new-provider-connection');
18182
+ if (connectionEl) connectionEl.value = 'portkey';
18183
+ }
17736
18184
  updateProviderPlaceholders();
17737
18185
 
17738
18186
  // Only scan for env keys when adding a new provider type, not when adding a key to existing type
@@ -17984,12 +18432,25 @@ async function saveNewProvider() {
17984
18432
  body: JSON.stringify({
17985
18433
  id: id + ':' + m.id, providerId: id, modelId: m.id,
17986
18434
  displayName: m.name, capabilities: m.capabilities || ['code'],
18435
+ source: payload.connection === 'portkey' ? 'portkey' : 'catalog',
18436
+ gatewayType: payload.connection === 'portkey' ? 'portkey' : null,
18437
+ verificationStatus: payload.connection === 'portkey' ? 'unverified' : 'verified',
18438
+ lastSeenAt: payload.connection === 'portkey' ? new Date().toISOString() : null,
17987
18439
  speedTier: 3, enabled: 1,
17988
18440
  })
17989
18441
  });
17990
18442
  }
17991
18443
  }
17992
18444
  } catch (e) { /* ignore auto-detect failures */ }
18445
+ if (payload.connection === 'portkey') {
18446
+ try {
18447
+ await fetch('/api/models/provider-route-policy?token=' + state.token, {
18448
+ method: 'POST',
18449
+ headers: { 'Content-Type': 'application/json' },
18450
+ body: JSON.stringify({ type: type, policy: 'portkey' }),
18451
+ });
18452
+ } catch (e) { /* route policy is optional; provider was saved */ }
18453
+ }
17993
18454
  var dlg = document.getElementById('add-provider-dialog');
17994
18455
  if (dlg) dlg.remove();
17995
18456
  showModelsPanel();
@@ -18302,12 +18763,22 @@ function _wtRecommendedButton(wt) {
18302
18763
  return btn;
18303
18764
  }
18304
18765
 
18766
+ function _wtTrackedDirty(wt) {
18767
+ if (!wt) return 0;
18768
+ if (wt.trackedDirtyFiles != null) return Number(wt.trackedDirtyFiles || 0);
18769
+ return Math.max(0, Number(wt.dirtyFiles || 0) - Number(wt.untrackedFiles || 0));
18770
+ }
18771
+
18772
+ function _wtUntrackedOnly(wt) {
18773
+ return !!wt && Number(wt.dirtyFiles || 0) > 0 && _wtTrackedDirty(wt) === 0;
18774
+ }
18775
+
18305
18776
  function _wtSyncBlockReason(wt) {
18306
18777
  if (!wt || wt.isMain) return '';
18307
18778
  if (wt.sessionId && !wt.activeSyncEligible) {
18308
18779
  return wt.activeSyncBlockReason || 'Close the active session before syncing from main.';
18309
18780
  }
18310
- if ((wt.dirtyFiles || 0) > 0) return 'Commit or stash dirty files before syncing from main.';
18781
+ if (_wtTrackedDirty(wt) > 0) return 'Commit or stash tracked dirty files before syncing from main.';
18311
18782
  if (!wt.branch || wt.branch === 'HEAD' || wt.state === 'detached') return 'Recover this worktree onto a branch before syncing from main.';
18312
18783
  if (wt.isGhost || wt.state === 'ghost') return 'Prune or recover this ghost worktree before syncing from main.';
18313
18784
  return '';
@@ -18346,7 +18817,7 @@ function _wtSyncAllEligible(wts) {
18346
18817
  && ['behind', 'diverged'].indexOf(wt.state) !== -1
18347
18818
  && !wt.isGhost
18348
18819
  && (inactiveEligible || activeIdleEligible)
18349
- && (wt.dirtyFiles || 0) === 0
18820
+ && _wtTrackedDirty(wt) === 0
18350
18821
  && (wt.behind || 0) > 0;
18351
18822
  });
18352
18823
  }
@@ -18430,8 +18901,8 @@ async function loadWorktreesPanel(opts) {
18430
18901
  syncAllBtn.textContent = '↓ Sync all' + (syncTargets.length > 0 ? ' (' + syncTargets.length + ')' : '');
18431
18902
  syncAllBtn.disabled = false;
18432
18903
  syncAllBtn.title = syncTargets.length > 0
18433
- ? 'Sync ' + syncTargets.length + ' clean inactive or idle active branch(es) that are behind main'
18434
- : 'No clean inactive or idle active branches are behind main';
18904
+ ? 'Sync ' + syncTargets.length + ' tracked-clean inactive or idle active branch(es) that are behind main'
18905
+ : 'No tracked-clean inactive or idle active branches are behind main';
18435
18906
  }
18436
18907
 
18437
18908
  _wtRenderFilterChips(d.counts || {}, wts);
@@ -18711,9 +19182,9 @@ function _wtRenderCard(frag, wt) {
18711
19182
  prBtn.className = 'btn';
18712
19183
  prBtn.style.cssText = 'font-size:11px;padding:4px 10px;background:rgba(122,162,247,0.12);color:#7aa2f7;border:1px solid rgba(122,162,247,0.3);';
18713
19184
  prBtn.textContent = 'PR ▶';
18714
- prBtn.disabled = !!wt.sessionId || wt.dirtyFiles > 0;
19185
+ prBtn.disabled = !!wt.sessionId || _wtTrackedDirty(wt) > 0;
18715
19186
  if (wt.sessionId) prBtn.title = 'Close the active session before pushing';
18716
- else if (wt.dirtyFiles > 0) prBtn.title = 'Commit or stash dirty files before pushing';
19187
+ else if (_wtTrackedDirty(wt) > 0) prBtn.title = 'Commit or stash tracked dirty files before pushing';
18717
19188
  prBtn.onclick = function() { openCreatePRModal(wt); };
18718
19189
  actions.appendChild(prBtn);
18719
19190
  }
@@ -18763,7 +19234,8 @@ function _wtRenderCard(frag, wt) {
18763
19234
  metrics.style.cssText = 'font-size:11px;color:var(--fg-dim,#565f89);display:flex;flex-wrap:wrap;gap:6px;margin:0 0 8px;';
18764
19235
  if (!wt.isMain && wt.state !== 'ghost') {
18765
19236
  metrics.appendChild(_wtMetric('vs main', '+' + (wt.ahead || 0) + ' / -' + (wt.behind || 0), (wt.ahead || wt.behind) ? 'warn' : 'good'));
18766
- metrics.appendChild(_wtMetric('dirty', String(wt.dirtyFiles || 0), wt.dirtyFiles ? 'bad' : 'good'));
19237
+ metrics.appendChild(_wtMetric('dirty', String(_wtTrackedDirty(wt)), _wtTrackedDirty(wt) ? 'bad' : 'good'));
19238
+ if ((wt.untrackedFiles || 0) > 0) metrics.appendChild(_wtMetric('untracked', String(wt.untrackedFiles || 0), 'neutral'));
18767
19239
  metrics.appendChild(_wtMetric('unmerged', String(wt.unmergedCommits || 0), wt.unmergedCommits ? 'warn' : 'good'));
18768
19240
  metrics.appendChild(_wtMetric('cleanup', wt.safeCleanup ? 'safe' : 'blocked', wt.safeCleanup ? 'good' : 'warn'));
18769
19241
  }
@@ -18815,9 +19287,12 @@ function openSyncModal(wt) {
18815
19287
  };
18816
19288
  var sum = document.getElementById('wt-sync-summary');
18817
19289
  if (sum) {
18818
- sum.textContent = activeSync
19290
+ var untrackedNote = (wt.untrackedFiles || 0) > 0 && _wtTrackedDirty(wt) === 0
19291
+ ? ' Untracked files will be kept local; CTM will stop if main would overwrite one.'
19292
+ : '';
19293
+ sum.textContent = (activeSync
18819
19294
  ? 'Pull main into idle active session "' + (wt.sessionLabel || wt.branch) + '" (' + wt.behind + ' commits behind).'
18820
- : 'Pull main into "' + wt.branch + '" (' + wt.behind + ' commits behind).';
19295
+ : 'Pull main into "' + wt.branch + '" (' + wt.behind + ' commits behind).') + untrackedNote;
18821
19296
  }
18822
19297
  var strategy = document.getElementById('wt-sync-strategy');
18823
19298
  if (strategy) {
@@ -18882,11 +19357,11 @@ async function submitSyncWorktree() {
18882
19357
  async function submitSyncAllWorktrees() {
18883
19358
  var targets = _wtSyncAllEligible((_wtCache && _wtCache.items) || []);
18884
19359
  if (targets.length === 0) {
18885
- toast('No clean inactive or idle active branches are behind main', { type: 'info' });
19360
+ toast('No tracked-clean inactive or idle active branches are behind main', { type: 'info' });
18886
19361
  return;
18887
19362
  }
18888
19363
  var activeIdleCount = targets.filter(function(wt) { return !!wt.sessionId; }).length;
18889
- var confirmMsg = 'Sync ' + targets.length + ' clean branch(es) from main? Dirty, running/waiting active-session, detached, and ghost worktrees will be skipped.';
19364
+ var confirmMsg = 'Sync ' + targets.length + ' tracked-clean branch(es) from main? Tracked-dirty, running/waiting active-session, detached, and ghost worktrees will be skipped.';
18890
19365
  if (activeIdleCount) confirmMsg += ' ' + activeIdleCount + ' idle active session(s) will use merge with a pre-sync checkpoint.';
18891
19366
  if (!confirm(confirmMsg)) return;
18892
19367
  var btn = document.getElementById('wt-sync-all-btn');
@@ -19076,6 +19551,8 @@ function _wtGateWorktree(msg, wt) {
19076
19551
  if (msg.ahead != null) merged.ahead = msg.ahead || 0;
19077
19552
  if (msg.behind != null) merged.behind = msg.behind || 0;
19078
19553
  if (msg.dirtyFiles != null) merged.dirtyFiles = msg.dirtyFiles || 0;
19554
+ if (msg.trackedDirtyFiles != null) merged.trackedDirtyFiles = msg.trackedDirtyFiles || 0;
19555
+ if (msg.untrackedFiles != null) merged.untrackedFiles = msg.untrackedFiles || 0;
19079
19556
  if (msg.unmergedCommits != null) merged.unmergedCommits = msg.unmergedCommits || 0;
19080
19557
  if (msg.summary) merged.summary = msg.summary;
19081
19558
  if (msg.worktreePath) merged.path = msg.worktreePath;
@@ -19091,6 +19568,8 @@ function _wtGateWorktree(msg, wt) {
19091
19568
  ahead: msg.ahead || msg.unmergedCommits || 0,
19092
19569
  behind: msg.behind || 0,
19093
19570
  dirtyFiles: msg.dirtyFiles || 0,
19571
+ trackedDirtyFiles: msg.trackedDirtyFiles || 0,
19572
+ untrackedFiles: msg.untrackedFiles || 0,
19094
19573
  unmergedCommits: msg.unmergedCommits || 0,
19095
19574
  summary: msg.summary || msg.message || '',
19096
19575
  };
@@ -19101,8 +19580,8 @@ function _wtGateLabel(wt) {
19101
19580
  }
19102
19581
 
19103
19582
  function _wtGateHint(wt) {
19104
- if ((wt.dirtyFiles || 0) > 0) {
19105
- return 'This worktree has uncommitted files. Commit, stash, or inspect them before merging or opening a PR.';
19583
+ if (_wtTrackedDirty(wt) > 0) {
19584
+ return 'This worktree has tracked dirty files. Commit, stash, or inspect them before merging or opening a PR.';
19106
19585
  }
19107
19586
  if ((wt.behind || 0) > 0 && (wt.ahead || 0) > 0) {
19108
19587
  return 'This branch has work and is behind main. Sync from main first if you want the cleanest finish path.';
@@ -19126,14 +19605,15 @@ function openWorktreeFinishGate(msg, wt) {
19126
19605
  _wtClearChildren(metrics);
19127
19606
  metrics.appendChild(_wtMetric('state', gateWt.state || 'unknown', 'neutral'));
19128
19607
  metrics.appendChild(_wtMetric('vs main', '+' + (gateWt.ahead || 0) + ' / -' + (gateWt.behind || 0), (gateWt.ahead || gateWt.behind) ? 'warn' : 'good'));
19129
- metrics.appendChild(_wtMetric('dirty', String(gateWt.dirtyFiles || 0), gateWt.dirtyFiles ? 'bad' : 'good'));
19608
+ metrics.appendChild(_wtMetric('dirty', String(_wtTrackedDirty(gateWt)), _wtTrackedDirty(gateWt) ? 'bad' : 'good'));
19609
+ if ((gateWt.untrackedFiles || 0) > 0) metrics.appendChild(_wtMetric('untracked', String(gateWt.untrackedFiles || 0), 'neutral'));
19130
19610
  metrics.appendChild(_wtMetric('unmerged', String(gateWt.unmergedCommits || 0), gateWt.unmergedCommits ? 'warn' : 'good'));
19131
19611
  }
19132
19612
 
19133
19613
  var hint = document.getElementById('wt-finish-hint');
19134
19614
  if (hint) hint.textContent = _wtGateHint(gateWt);
19135
19615
 
19136
- var hasDirty = (gateWt.dirtyFiles || 0) > 0;
19616
+ var hasDirty = _wtTrackedDirty(gateWt) > 0;
19137
19617
  var hasCommittedWork = (gateWt.unmergedCommits || 0) > 0 || (gateWt.ahead || 0) > 0;
19138
19618
  var canFinish = !hasDirty && hasCommittedWork && gateWt.branch && gateWt.branch !== 'HEAD';
19139
19619
  var mergeBtn = document.getElementById('wt-finish-merge-btn');
@@ -19274,7 +19754,7 @@ function _wtMergeBlockReason(wt) {
19274
19754
  wt = wt || {};
19275
19755
  if (!wt.branch || wt.branch === 'HEAD') return 'Recover this worktree onto a branch before merging.';
19276
19756
  if (wt.sessionId) return 'Close the active session before merging: ' + (wt.sessionLabel || wt.branch) + '.';
19277
- if ((wt.dirtyFiles || 0) > 0) return 'Commit or stash dirty files before merging.';
19757
+ if (_wtTrackedDirty(wt) > 0) return 'Commit or stash tracked dirty files before merging.';
19278
19758
  if ((wt.behind || 0) > 0) return 'Sync from main before merging.';
19279
19759
  if ((wt.unmergedCommits || 0) === 0 && (wt.ahead || 0) === 0) return 'Nothing to merge — this branch is already integrated with main.';
19280
19760
  return '';
@@ -22043,7 +22523,7 @@ async function onSessionsList(msg) {
22043
22523
  if (s._outputIdleTimer) clearTimeout(s._outputIdleTimer);
22044
22524
  if (s._snapshotRetryTimer) clearTimeout(s._snapshotRetryTimer);
22045
22525
  if (s._blankCheckTimer) clearTimeout(s._blankCheckTimer);
22046
- _detachZerolag(s);
22526
+ _disposeTerminalRenderer(s);
22047
22527
  try { if (s.term) s.term.dispose(); } catch {}
22048
22528
  try { s.container.remove(); } catch {}
22049
22529
  state.sessions.delete(id);
@@ -22414,14 +22894,17 @@ function worktreeAttentionBadge(s) {
22414
22894
  const branch = wt.branch || s?.meta?.branch || '';
22415
22895
  if (!branch || branch === 'main' || branch === 'master') return '';
22416
22896
  const dirtyFiles = Number(wt.dirtyFiles || 0);
22897
+ const trackedDirtyFiles = wt.trackedDirtyFiles != null
22898
+ ? Number(wt.trackedDirtyFiles || 0)
22899
+ : Math.max(0, dirtyFiles - Number(wt.untrackedFiles || 0));
22417
22900
  const unmergedCommits = Number(wt.unmergedCommits || 0);
22418
- if (dirtyFiles <= 0 && unmergedCommits <= 0) return '';
22901
+ if (trackedDirtyFiles <= 0 && unmergedCommits <= 0) return '';
22419
22902
 
22420
22903
  const parts = [];
22421
22904
  const titleParts = [];
22422
- if (dirtyFiles > 0) {
22423
- parts.push(`<span class="wt-part" aria-hidden="true">&#9998;${dirtyFiles}</span>`);
22424
- titleParts.push(`${dirtyFiles} uncommitted file${dirtyFiles === 1 ? '' : 's'}`);
22905
+ if (trackedDirtyFiles > 0) {
22906
+ parts.push(`<span class="wt-part" aria-hidden="true">&#9998;${trackedDirtyFiles}</span>`);
22907
+ titleParts.push(`${trackedDirtyFiles} tracked dirty file${trackedDirtyFiles === 1 ? '' : 's'}`);
22425
22908
  }
22426
22909
  if (unmergedCommits > 0) {
22427
22910
  parts.push(`<span class="wt-part" aria-hidden="true">&#8593;${unmergedCommits}</span>`);
@@ -24235,8 +24718,8 @@ async function _maybeRecommendWorktreeForNewSession(force) {
24235
24718
  var repoRoot = _nsNormalizeCwdForCompare(d.cwd || '');
24236
24719
  var mainWt = (d.worktrees || []).find(function(wt) { return wt.isMain; });
24237
24720
  var isPrimaryRepo = repoRoot && cwd === repoRoot;
24238
- if (!reason && isPrimaryRepo && mainWt && (mainWt.dirtyFiles || 0) > 0) {
24239
- reason = 'Recommended: main has uncommitted files';
24721
+ if (!reason && isPrimaryRepo && mainWt && _wtTrackedDirty(mainWt) > 0) {
24722
+ reason = 'Recommended: main has tracked uncommitted files';
24240
24723
  }
24241
24724
  if (!reason && isPrimaryRepo) {
24242
24725
  reason = 'Recommended: code agent in the primary checkout';
@@ -24455,7 +24938,7 @@ function killSession(id, opts) {
24455
24938
  if (s._outputIdleTimer) clearTimeout(s._outputIdleTimer);
24456
24939
  if (s._snapshotRetryTimer) clearTimeout(s._snapshotRetryTimer);
24457
24940
  if (s._blankCheckTimer) clearTimeout(s._blankCheckTimer);
24458
- _detachZerolag(s);
24941
+ _disposeTerminalRenderer(s);
24459
24942
  if (s.term) s.term.dispose();
24460
24943
  s.container.remove();
24461
24944
  state.sessions.delete(id);
@@ -24497,7 +24980,7 @@ function _killTabSilent(id) {
24497
24980
  if (s._outputIdleTimer) clearTimeout(s._outputIdleTimer);
24498
24981
  if (s._snapshotRetryTimer) clearTimeout(s._snapshotRetryTimer);
24499
24982
  if (s._blankCheckTimer) clearTimeout(s._blankCheckTimer);
24500
- _detachZerolag(s);
24983
+ _disposeTerminalRenderer(s);
24501
24984
  if (s.term) s.term.dispose();
24502
24985
  s.container.remove();
24503
24986
  state.sessions.delete(id);
@@ -27345,6 +27828,15 @@ function openFolder(folderPath) {
27345
27828
  });
27346
27829
  }
27347
27830
 
27831
+ function linkReviewDocumentReferences(root, sessionId) {
27832
+ if (!root || !window.CTMDocLinks || typeof window.CTMDocLinks.linkifyElement !== 'function') return;
27833
+ try {
27834
+ window.CTMDocLinks.linkifyElement(root, window.CTMDocLinks.contextForSession(sessionId));
27835
+ } catch (e) {
27836
+ console.warn('[review] document linkification failed:', e && e.message ? e.message : e);
27837
+ }
27838
+ }
27839
+
27348
27840
  async function reviewSession(sessionId, projectEntry, title, sessionData, opts) {
27349
27841
  const explicitAgentType = _clientNormalizeAgentType(sessionData?.agent || sessionData?.agentType);
27350
27842
  if (explicitAgentType && !_clientAgentCaps(explicitAgentType).structuredTranscript) {
@@ -27456,6 +27948,7 @@ async function reviewSession(sessionId, projectEntry, title, sessionData, opts)
27456
27948
  // internally via formatMsgText); sanitize the wrapper before insertion.
27457
27949
  const firstHtml = turns.slice(0, FIRST_BATCH).map(renderReviewTurn).join('');
27458
27950
  container.innerHTML = DOMPurify.sanitize(firstHtml, { ADD_ATTR: ['style', 'data-idx', 'data-role', 'data-turn-id', 'data-msg-idx', 'data-parent-uuid', 'role', 'tabindex', 'aria-expanded'] });
27951
+ linkReviewDocumentReferences(container, sessionId);
27459
27952
  _updateReviewLoadOlderBar(container, fetchId, projectEntry, page);
27460
27953
 
27461
27954
  // Show partial load indicator if backend couldn't load all files
@@ -27485,6 +27978,7 @@ async function reviewSession(sessionId, projectEntry, title, sessionData, opts)
27485
27978
  const end = Math.min(offset + CHUNK_SIZE, turns.length);
27486
27979
  const chunkHtml = turns.slice(offset, end).map(renderReviewTurn).join('');
27487
27980
  container.insertAdjacentHTML('beforeend', DOMPurify.sanitize(chunkHtml, { ADD_ATTR: ['style', 'data-idx', 'data-role', 'data-turn-id', 'data-msg-idx', 'data-parent-uuid', 'role', 'tabindex', 'aria-expanded'] }));
27981
+ linkReviewDocumentReferences(container, sessionId);
27488
27982
  offset = end;
27489
27983
  requestAnimationFrame(renderNextChunk);
27490
27984
  };
@@ -31098,7 +31592,7 @@ function _parseHashRoute() {
31098
31592
  const params = {};
31099
31593
  for (const part of (isNav ? parts.slice(1) : parts)) {
31100
31594
  const eq = part.indexOf('=');
31101
- if (eq > 0) params[part.slice(0, eq)] = decodeURIComponent(part.slice(eq + 1));
31595
+ if (eq > 0) params[part.slice(0, eq)] = decodeURIComponent(part.slice(eq + 1).replace(/\+/g, '%20'));
31102
31596
  }
31103
31597
  return { hash, firstPart, isNav, params };
31104
31598
  }
@@ -31189,13 +31683,14 @@ function handleHashRoute() {
31189
31683
  return;
31190
31684
  }
31191
31685
 
31192
- // #review&type=doc&path=<absolute-path>&line=<line> — open document review workspace.
31686
+ // #review&type=doc&path=<path>&line=<line>&cwd=<session cwd> — open document review workspace.
31193
31687
  if (firstPart === 'review' && params.type === 'doc' && params.path) {
31194
- navTo('codereview', { skipHash: true });
31688
+ navTo('codereview', { skipHash: true, suppressProjectList: true });
31689
+ const docOpts = { cwd: params.cwd || '', sessionId: params.session || '' };
31195
31690
  if (window.CR && typeof CR.openDocumentReview === 'function') {
31196
- setTimeout(() => CR.openDocumentReview(params.path, params.line || 1), 100);
31691
+ setTimeout(() => CR.openDocumentReview(params.path, params.line || 1, docOpts), 100);
31197
31692
  } else {
31198
- state.pendingDocumentReview = { path: params.path, line: params.line || 1 };
31693
+ state.pendingDocumentReview = { path: params.path, line: params.line || 1, cwd: docOpts.cwd, sessionId: docOpts.sessionId };
31199
31694
  }
31200
31695
  return;
31201
31696
  }