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
@@ -40,6 +40,9 @@
40
40
  walle: {
41
41
  sessionId: null,
42
42
  draft: '',
43
+ alerts: [],
44
+ health: null,
45
+ healthTest: null,
43
46
  },
44
47
  detailScrollLock: {
45
48
  locked: false,
@@ -122,13 +125,22 @@
122
125
  lastTapX: 0,
123
126
  lastTapY: 0,
124
127
  },
128
+ appRuntime: {
129
+ loaded: null,
130
+ latest: null,
131
+ reloadRequired: false,
132
+ reloadReason: '',
133
+ },
125
134
  };
126
135
 
127
136
  const $ = (id) => document.getElementById(id);
128
- const MOBILE_ASSET_VERSION = '20260519-walle-phone-model-picker';
137
+ const MOBILE_ASSET_VERSION = '20260519-walle-default-chat';
138
+ const APP_RELOAD_CHANNEL_NAME = 'ctm-app-update';
129
139
  const THEME_STORAGE_KEY = 'ctm.theme.mode';
130
140
  const MOBILE_ATTACHMENT_MAX_BYTES = 10 * 1024 * 1024;
131
141
  const SEND_LONG_PRESS_MS = 520;
142
+ const DETAIL_SPLIT_QUERY = '(min-width: 900px) and (min-height: 650px)';
143
+ const WALLE_SINGLETON_CHAT_ID = 'default';
132
144
  const MOBILE_SESSION_CACHE_KEY = 'ctm.mobile.sessionSnapshot.v1';
133
145
  const MOBILE_SESSION_CACHE_MAX_AGE_MS = 12 * 60 * 60 * 1000;
134
146
  const MOBILE_SESSION_CACHE_MAX_SESSIONS = 500;
@@ -137,6 +149,15 @@
137
149
  const REMOTE_OUTBOX_RETRY_DELAYS_MS = [1200, 3000, 7000, 15000, 30000, 60000, 120000];
138
150
  const SW_RELOAD_SESSION_KEY = 'ctm.mobile.swReloaded';
139
151
  const SW_RELOAD_SESSION_VALUE = MOBILE_ASSET_VERSION;
152
+ const AUTH_RECOVERY_CODES = new Set([
153
+ 'ip_locked',
154
+ 'unauthorized',
155
+ 'invalid_token',
156
+ 'missing_token',
157
+ 'token_revoked',
158
+ 'token_expired',
159
+ 'device_token_lookup_failed',
160
+ ]);
140
161
  const PROVIDER_GENERATED_USER_CONTEXT_PATTERNS = [
141
162
  /^<environment_context>/i,
142
163
  /^<permissions instructions>/i,
@@ -146,11 +167,193 @@
146
167
  /^<skill>\s*<name>/i,
147
168
  /^# AGENTS\.md instructions for\b/i,
148
169
  ];
170
+ let appReloadChannel = null;
171
+
172
+ function normalizeAppVersionInfo(info) {
173
+ if (!info || typeof info !== 'object') return null;
174
+ const version = String(info.version || '').trim();
175
+ const buildId = String(info.buildId || '').trim();
176
+ const product = String(info.product || 'create-walle').trim();
177
+ const components = info.components && typeof info.components === 'object' ? {
178
+ ctm: String(info.components.ctm || '').trim(),
179
+ wallE: String(info.components.wallE || '').trim(),
180
+ } : {};
181
+ if (!version && !buildId) return null;
182
+ return { version, buildId, product, components };
183
+ }
184
+
185
+ function appVersionIdentity(info) {
186
+ const normalized = normalizeAppVersionInfo(info);
187
+ if (!normalized) return '';
188
+ return normalized.buildId || `${normalized.product}:${normalized.version}:${normalized.components.ctm || ''}:${normalized.components.wallE || ''}`;
189
+ }
190
+
191
+ function sameAppVersionIdentity(a, b) {
192
+ const aid = appVersionIdentity(a);
193
+ const bid = appVersionIdentity(b);
194
+ return !!aid && !!bid && aid === bid;
195
+ }
196
+
197
+ function initAppReloadChannel() {
198
+ if (appReloadChannel || typeof BroadcastChannel === 'undefined') return appReloadChannel;
199
+ try {
200
+ appReloadChannel = new BroadcastChannel(APP_RELOAD_CHANNEL_NAME);
201
+ appReloadChannel.onmessage = (event) => {
202
+ const data = event?.data;
203
+ if (!data || data.type !== 'reload-required') return;
204
+ requestInstalledUpdateReload({
205
+ server: data.server,
206
+ reason: data.reason || 'peer-detected',
207
+ source: 'broadcast',
208
+ rebroadcast: false,
209
+ });
210
+ };
211
+ } catch {
212
+ appReloadChannel = null;
213
+ }
214
+ return appReloadChannel;
215
+ }
216
+
217
+ function broadcastInstalledUpdateReload(server, reason) {
218
+ const channel = initAppReloadChannel();
219
+ if (!channel) return;
220
+ try {
221
+ channel.postMessage({ type: 'reload-required', server, reason, at: Date.now() });
222
+ } catch {}
223
+ }
224
+
225
+ function handleServerHello(msg) {
226
+ markHostReachable();
227
+ const server = normalizeAppVersionInfo(msg?.appVersion);
228
+ if (!server) return;
229
+ if (!state.appRuntime.loaded) {
230
+ state.appRuntime.loaded = server;
231
+ state.appRuntime.latest = server;
232
+ return;
233
+ }
234
+ state.appRuntime.latest = server;
235
+ if (!sameAppVersionIdentity(state.appRuntime.loaded, server)) {
236
+ requestInstalledUpdateReload({ server, reason: 'server-version-changed', source: 'hello' });
237
+ }
238
+ }
239
+
240
+ function isEditableElement(el) {
241
+ if (!el || el === document.body || el === document.documentElement) return false;
242
+ if (el.isContentEditable) return true;
243
+ const tag = String(el.tagName || '').toLowerCase();
244
+ if (tag === 'textarea' || tag === 'input' || tag === 'select') return true;
245
+ return el.getAttribute?.('role') === 'textbox';
246
+ }
247
+
248
+ function isMobileReloadUnsafe() {
249
+ if (isEditableElement(document.activeElement)) return true;
250
+ if (state.activeSession || state.activeTab === 'walle') return true;
251
+ if (!$('send-menu')?.hidden) return true;
252
+ for (const id of ['stepup-sheet', 'model-picker-sheet', 'new-session-sheet']) {
253
+ const sheet = $(id);
254
+ if (sheet && !sheet.hidden) return true;
255
+ }
256
+ return false;
257
+ }
258
+
259
+ function showInstalledUpdateReloadBanner(server) {
260
+ const banner = $('app-reload-banner');
261
+ const msg = $('app-reload-msg');
262
+ if (!banner) return;
263
+ const loaded = state.appRuntime.loaded || {};
264
+ const latest = normalizeAppVersionInfo(server) || state.appRuntime.latest || {};
265
+ if (msg) {
266
+ const from = loaded.version ? `v${loaded.version}` : 'old UI';
267
+ const to = latest.version ? `v${latest.version}` : 'the installed update';
268
+ msg.textContent = `Reload to use ${to}. This page is still running ${from}.`;
269
+ }
270
+ banner.hidden = false;
271
+ }
272
+
273
+ function requestInstalledUpdateReload(opts = {}) {
274
+ const server = normalizeAppVersionInfo(opts.server) || state.appRuntime.latest;
275
+ state.appRuntime.latest = server || state.appRuntime.latest;
276
+ state.appRuntime.reloadRequired = true;
277
+ state.appRuntime.reloadReason = opts.reason || 'app-updated';
278
+ if (opts.rebroadcast !== false) broadcastInstalledUpdateReload(server, state.appRuntime.reloadReason);
279
+ if (!isMobileReloadUnsafe()) {
280
+ reloadForInstalledUpdate(opts.source || 'auto');
281
+ return;
282
+ }
283
+ showInstalledUpdateReloadBanner(server);
284
+ }
285
+
286
+ function reloadForInstalledUpdate(source = 'button') {
287
+ const detail = {
288
+ source,
289
+ reason: state.appRuntime.reloadReason || 'app-updated',
290
+ loaded: state.appRuntime.loaded,
291
+ latest: state.appRuntime.latest,
292
+ };
293
+ try { sessionStorage.setItem('ctm_mobile_app_update_reload', JSON.stringify({ ...detail, at: Date.now() })); } catch {}
294
+ if (typeof window.__ctmAppReload === 'function') {
295
+ window.__ctmAppReload(detail);
296
+ return;
297
+ }
298
+ location.reload();
299
+ }
300
+
301
+ window.__ctmMobileHandleServerHello = handleServerHello;
302
+ window.__ctmMobileRequestInstalledUpdateReload = requestInstalledUpdateReload;
303
+ window.__ctmMobileSetLoadedAppForTest = (info) => {
304
+ state.appRuntime.loaded = normalizeAppVersionInfo(info);
305
+ };
306
+ window.__ctmMobileAppRuntime = state.appRuntime;
149
307
 
150
308
  function isDetailOpen() {
151
309
  return !!$('detail-view')?.classList.contains('active');
152
310
  }
153
311
 
312
+ function isDetailSplitLayout() {
313
+ try {
314
+ return !!(window.matchMedia && window.matchMedia(DETAIL_SPLIT_QUERY).matches);
315
+ } catch {
316
+ return false;
317
+ }
318
+ }
319
+
320
+ function setBackgroundInteractive(interactive) {
321
+ const main = $('app-main');
322
+ const nav = document.querySelector('.bottom-nav');
323
+ if (interactive) {
324
+ main?.removeAttribute('aria-hidden');
325
+ nav?.removeAttribute('aria-hidden');
326
+ if (main && 'inert' in main) main.inert = false;
327
+ if (nav && 'inert' in nav) nav.inert = false;
328
+ return;
329
+ }
330
+ main?.setAttribute('aria-hidden', 'true');
331
+ nav?.setAttribute('aria-hidden', 'true');
332
+ if (main && 'inert' in main) main.inert = true;
333
+ if (nav && 'inert' in nav) nav.inert = true;
334
+ }
335
+
336
+ function syncDetailLayoutMode() {
337
+ const split = isDetailSplitLayout();
338
+ const detail = $('detail-view');
339
+ document.documentElement.classList.toggle('detail-split-layout', split);
340
+ document.body.classList.toggle('detail-split-layout', split);
341
+ const open = isDetailOpen();
342
+ document.documentElement.classList.toggle('detail-open', open);
343
+ document.body.classList.toggle('detail-open', open);
344
+ if (detail) {
345
+ detail.setAttribute('role', split ? 'region' : 'dialog');
346
+ detail.setAttribute('aria-modal', split ? 'false' : 'true');
347
+ }
348
+ if (!open) return;
349
+ if (split) {
350
+ unlockDetailBackgroundScroll();
351
+ setBackgroundInteractive(true);
352
+ } else {
353
+ lockDetailBackgroundScroll();
354
+ }
355
+ }
356
+
154
357
  function activeTimelineBox() {
155
358
  if (isDetailOpen()) return $('detail-messages');
156
359
  if (state.activeTab === 'walle') return $('walle-messages') || $('detail-messages');
@@ -184,7 +387,6 @@
184
387
  attachments: 'composer-attachments',
185
388
  clear: 'detail-clear',
186
389
  send: 'detail-send',
187
- model: 'detail-model',
188
390
  picker: 'mobile-skill-picker',
189
391
  };
190
392
  return $(ids[kind]);
@@ -430,6 +632,10 @@
430
632
  }
431
633
 
432
634
  function lockDetailBackgroundScroll() {
635
+ if (isDetailSplitLayout()) {
636
+ setBackgroundInteractive(true);
637
+ return;
638
+ }
433
639
  if (state.detailScrollLock.locked) return;
434
640
  const scrollY = window.scrollY || document.documentElement.scrollTop || 0;
435
641
  state.detailScrollLock.locked = true;
@@ -441,12 +647,7 @@
441
647
  document.body.style.left = '0';
442
648
  document.body.style.right = '0';
443
649
  document.body.style.width = '100%';
444
- const main = $('app-main');
445
- const nav = document.querySelector('.bottom-nav');
446
- main?.setAttribute('aria-hidden', 'true');
447
- nav?.setAttribute('aria-hidden', 'true');
448
- if (main && 'inert' in main) main.inert = true;
449
- if (nav && 'inert' in nav) nav.inert = true;
650
+ setBackgroundInteractive(false);
450
651
  }
451
652
 
452
653
  function unlockDetailBackgroundScroll() {
@@ -461,12 +662,7 @@
461
662
  document.body.style.left = '';
462
663
  document.body.style.right = '';
463
664
  document.body.style.width = '';
464
- const main = $('app-main');
465
- const nav = document.querySelector('.bottom-nav');
466
- main?.removeAttribute('aria-hidden');
467
- nav?.removeAttribute('aria-hidden');
468
- if (main && 'inert' in main) main.inert = false;
469
- if (nav && 'inert' in nav) nav.inert = false;
665
+ setBackgroundInteractive(true);
470
666
  window.scrollTo(0, scrollY);
471
667
  }
472
668
 
@@ -506,11 +702,39 @@
506
702
  const err = new Error(json?.error || json?.message || `HTTP ${res.status}`);
507
703
  err.status = res.status;
508
704
  err.payload = json;
705
+ if (shouldRecoverMobileAuth(err)) beginMobileAuthRecovery(err);
509
706
  throw err;
510
707
  }
511
708
  return json;
512
709
  }
513
710
 
711
+ function shouldRecoverMobileAuth(err) {
712
+ const code = String(err?.payload?.error || err?.message || '');
713
+ return err?.status === 401 || AUTH_RECOVERY_CODES.has(code);
714
+ }
715
+
716
+ let mobileAuthRecoveryStarted = false;
717
+ function beginMobileAuthRecovery(err) {
718
+ if (mobileAuthRecoveryStarted) return;
719
+ mobileAuthRecoveryStarted = true;
720
+ const code = String(err?.payload?.error || err?.message || 'auth_required');
721
+ state.auth = null;
722
+ setConnection('offline', 'Sign in');
723
+ toast(code === 'ip_locked'
724
+ ? 'This phone session is locked after failed auth. Pair again from the Mac.'
725
+ : 'This phone session is signed out. Pair again from the Mac.');
726
+ fetch('/api/auth/logout-local', {
727
+ method: 'POST',
728
+ credentials: 'same-origin',
729
+ headers: { 'content-type': 'application/json' },
730
+ body: '{}',
731
+ }).catch(() => {}).finally(() => {
732
+ const target = new URL('/m/claim', window.location.origin);
733
+ target.searchParams.set('reason', code);
734
+ window.location.href = target.toString();
735
+ });
736
+ }
737
+
514
738
  function ageLabel(value) {
515
739
  const ms = value ? Date.parse(value) || Number(value) || 0 : 0;
516
740
  if (!ms) return '';
@@ -620,10 +844,17 @@
620
844
  .trim();
621
845
  }
622
846
 
847
+ function modelProviderLabel(provider) {
848
+ const key = providerKey(provider);
849
+ if (key === 'anthropic') return 'Claude';
850
+ if (key === 'openai') return 'GPT';
851
+ return providerLabel(key);
852
+ }
853
+
623
854
  function modelButtonLabel(card) {
624
855
  const provider = providerKey(card?.walle_model_provider || card?.model_provider || card?.modelProvider || card?.providerType || '');
625
856
  const model = modelDisplayLabel(card?.walle_model_id || card?.model_id || card?.model || '');
626
- const providerText = providerLabel(provider);
857
+ const providerText = modelProviderLabel(provider);
627
858
  if (providerText && model) return `${providerText} · ${model}`;
628
859
  if (model) return model;
629
860
  return 'Auto model';
@@ -666,15 +897,35 @@
666
897
  return `${count} ${singular}${count === 1 ? '' : 's'}`;
667
898
  }
668
899
 
900
+ function isMainBranch(branch, wt) {
901
+ const value = String(branch || '').trim();
902
+ return wt?.isMain === true || value === 'main' || value === 'master';
903
+ }
904
+
669
905
  function sessionWorktreeDiff(card) {
670
906
  const wt = card?.worktreeStatus || card?.worktree || card?.meta?.worktreeStatus || null;
671
- if (!wt || wt.needsAttention === false || wt.isMain) return null;
672
- const branch = String(wt.branch || card?.branch || card?.gitBranch || '').trim();
673
- if (!branch || branch === 'main' || branch === 'master') return null;
907
+ if (!wt) return null;
908
+ const branch = String(wt.branch || card?.branch || card?.gitBranch || (wt.isMain ? 'main' : '')).trim();
909
+ if (!branch) return null;
910
+ const isMain = isMainBranch(branch, wt);
674
911
  const dirtyFiles = positiveCount(wt.dirtyFiles);
675
- const unmergedCommits = positiveCount(wt.unmergedCommits);
912
+ const unmergedCommits = isMain
913
+ ? (
914
+ positiveCount(wt.unpushedCommits) ||
915
+ positiveCount(wt.mainRemote?.ahead) ||
916
+ positiveCount(wt.ahead) ||
917
+ positiveCount(wt.unmergedCommits)
918
+ )
919
+ : (positiveCount(wt.unmergedCommits) || positiveCount(wt.ahead));
676
920
  if (dirtyFiles <= 0 && unmergedCommits <= 0) return null;
677
- return { branch, dirtyFiles, unmergedCommits };
921
+ return {
922
+ branch,
923
+ dirtyFiles,
924
+ unmergedCommits,
925
+ isMain,
926
+ worktreeName: wt.worktreeName || '',
927
+ worktreePath: wt.worktreePath || card?.project || card?.cwd || '',
928
+ };
678
929
  }
679
930
 
680
931
  function sessionDiffTags(card) {
@@ -683,15 +934,20 @@
683
934
  const chips = [];
684
935
  if (diff.dirtyFiles > 0) {
685
936
  const label = pluralize(diff.dirtyFiles, 'file');
686
- const title = `${diff.branch}: ${pluralize(diff.dirtyFiles, 'uncommitted file')}`;
937
+ const title = diff.isMain
938
+ ? `${diff.branch}: ${pluralize(diff.dirtyFiles, 'uncommitted file')} on main`
939
+ : `${diff.branch}: ${pluralize(diff.dirtyFiles, 'uncommitted file')}`;
687
940
  chips.push(`<span class="session-diff-chip files" title="${esc(title)}" aria-label="${esc(title)}">${esc(label)}</span>`);
688
941
  }
689
942
  if (diff.unmergedCommits > 0) {
690
943
  const label = pluralize(diff.unmergedCommits, 'commit');
691
- const title = `${diff.branch}: ${pluralize(diff.unmergedCommits, 'commit')} not on main`;
944
+ const title = diff.isMain
945
+ ? `${diff.branch}: ${pluralize(diff.unmergedCommits, 'local commit')} on main`
946
+ : `${diff.branch}: ${pluralize(diff.unmergedCommits, 'commit')} not on main`;
692
947
  chips.push(`<span class="session-diff-chip commits" title="${esc(title)}" aria-label="${esc(title)}">${esc(label)}</span>`);
693
948
  }
694
- return `<span class="session-diff-tags" aria-label="${esc(`Branch diff for ${diff.branch}`)}">${chips.join('')}</span>`;
949
+ const label = diff.isMain ? `Main branch changes for ${diff.branch}` : `Branch diff for ${diff.branch}`;
950
+ return `<span class="session-diff-tags" aria-label="${esc(label)}">${chips.join('')}</span>`;
695
951
  }
696
952
 
697
953
  function randomId() {
@@ -713,6 +969,11 @@
713
969
  $('app-title').textContent = tab === 'walle' ? 'Wall-E' : tab.charAt(0).toUpperCase() + tab.slice(1);
714
970
  if (tab === 'search') setTimeout(() => $('search-input')?.focus(), 50);
715
971
  render();
972
+ if (tab === 'walle') {
973
+ refreshWalleHealth({ quiet: true }).then((payload) => {
974
+ if (payload && state.activeTab === 'walle') renderWalle();
975
+ }).catch(() => {});
976
+ }
716
977
  if (tab === 'more') loadPushState().then(() => renderMore()).catch(() => renderMore());
717
978
  }
718
979
 
@@ -785,6 +1046,10 @@
785
1046
  worktree.worktreePath,
786
1047
  worktree.state,
787
1048
  worktree.summary,
1049
+ worktree.isMain ? 'main branch local work' : '',
1050
+ worktree.dirtyFiles ? `${worktree.dirtyFiles} changed files` : '',
1051
+ worktree.unpushedCommits ? `${worktree.unpushedCommits} local commits` : '',
1052
+ worktree.unmergedCommits ? `${worktree.unmergedCommits} unmerged commits` : '',
788
1053
  ...(card.evidence || []),
789
1054
  ...snippets,
790
1055
  ].filter(Boolean).join(' '));
@@ -888,8 +1153,9 @@
888
1153
  const diffTags = sessionDiffTags(card);
889
1154
  const searchMatch = renderHomeSearchMatch(card, opts.searchTokens || []);
890
1155
  const action = opts.action || card.actionLabel || 'Open';
1156
+ const selected = state.activeSession && state.activeSession.id === card.id;
891
1157
  return `
892
- <button class="session-row" type="button" data-open-session="${esc(card.id)}">
1158
+ <button class="session-row ${selected ? 'selected' : ''}" type="button" data-open-session="${esc(card.id)}" ${selected ? 'aria-pressed="true"' : ''}>
893
1159
  <span class="rail ${esc(lane)}"></span>
894
1160
  <span class="session-main">
895
1161
  <span class="session-title-line">
@@ -909,6 +1175,72 @@
909
1175
  `;
910
1176
  }
911
1177
 
1178
+ function worktreeSummaryItems(sessions) {
1179
+ const seen = new Set();
1180
+ const items = [];
1181
+ for (const card of sessions || []) {
1182
+ const diff = sessionWorktreeDiff(card);
1183
+ if (!diff || !card?.id) continue;
1184
+ const key = `${diff.branch}\n${diff.worktreePath || card.project || card.cwd || card.id}`;
1185
+ if (seen.has(key)) continue;
1186
+ seen.add(key);
1187
+ const changedCount = diff.dirtyFiles + diff.unmergedCommits;
1188
+ const lastActivity = Date.parse(card.lastActivity || '') || 0;
1189
+ items.push({ card, diff, changedCount, lastActivity });
1190
+ }
1191
+ return items.sort((a, b) => {
1192
+ if (a.diff.isMain !== b.diff.isMain) return a.diff.isMain ? -1 : 1;
1193
+ if (b.changedCount !== a.changedCount) return b.changedCount - a.changedCount;
1194
+ return b.lastActivity - a.lastActivity;
1195
+ });
1196
+ }
1197
+
1198
+ function renderWorktreeSummary(view) {
1199
+ const items = worktreeSummaryItems(view.sessions);
1200
+ if (!items.length) return '';
1201
+ const totalFiles = items.reduce((sum, item) => sum + item.diff.dirtyFiles, 0);
1202
+ const totalCommits = items.reduce((sum, item) => sum + item.diff.unmergedCommits, 0);
1203
+ const stats = [
1204
+ totalFiles > 0 ? pluralize(totalFiles, 'file') : '',
1205
+ totalCommits > 0 ? pluralize(totalCommits, 'commit') : '',
1206
+ ].filter(Boolean).join(' · ');
1207
+ const rows = items.slice(0, 4).map(({ card, diff }) => {
1208
+ const title = diff.isMain ? 'Main branch' : diff.branch;
1209
+ const metaParts = [
1210
+ diff.isMain ? 'primary checkout' : (diff.worktreeName || 'worktree'),
1211
+ compactPath(diff.worktreePath || card.project || card.cwd || ''),
1212
+ ].filter(Boolean);
1213
+ return `
1214
+ <button class="worktree-summary-row ${diff.isMain ? 'main' : ''}" type="button" data-open-worktree-session="${esc(card.id)}" data-worktree-branch="${esc(diff.branch)}" aria-label="${esc(`Open ${title} changes`)}">
1215
+ <span class="worktree-summary-copy">
1216
+ <span class="worktree-summary-title">${esc(title)}</span>
1217
+ <span class="worktree-summary-meta">${esc(metaParts.join(' · '))}</span>
1218
+ </span>
1219
+ ${sessionDiffTags(card)}
1220
+ </button>
1221
+ `;
1222
+ }).join('');
1223
+ const remaining = items.length - 4;
1224
+ const more = remaining > 0
1225
+ ? `<div class="worktree-summary-more">${esc(`+${remaining} more changed ${remaining === 1 ? 'worktree' : 'worktrees'}`)}</div>`
1226
+ : '';
1227
+ return `
1228
+ <section class="worktree-summary" aria-label="Changed files and commits">
1229
+ <div class="worktree-summary-head">
1230
+ <span>
1231
+ <span class="worktree-summary-kicker">Local work</span>
1232
+ <strong>${view.searchActive ? 'Matching branch changes' : 'Branch changes'}</strong>
1233
+ </span>
1234
+ <span class="worktree-summary-stats">${esc(stats || pluralize(items.length, 'worktree'))}</span>
1235
+ </div>
1236
+ <div class="worktree-summary-list">
1237
+ ${rows}
1238
+ ${more}
1239
+ </div>
1240
+ </section>
1241
+ `;
1242
+ }
1243
+
912
1244
  function renderLoadingRows(label = 'Loading CTM sessions...') {
913
1245
  return `<div class="empty-state">${esc(label)}</div>`;
914
1246
  }
@@ -933,6 +1265,7 @@
933
1265
  const byLane = (lane) => view.sessions.filter((s) => s.lane === lane);
934
1266
  const empty = (text) => view.searchActive ? 'No matches.' : text;
935
1267
  $('status-content').innerHTML = [
1268
+ renderWorktreeSummary(view),
936
1269
  section('Needs you', counts.needs, byLane('needs_user').map((card) => sessionRow(card, { action: card.actionKind === 'approval_needed' ? 'Review' : 'Open', searchTokens: view.tokens })), empty('No agents are waiting on you.'), 'needs_user'),
937
1270
  section('Running', counts.running, byLane('running').map((card) => sessionRow(card, { action: 'Watch', searchTokens: view.tokens })), empty('No active agents right now.'), 'running'),
938
1271
  section('Ready review', counts.review, byLane('ready_review').map((card) => sessionRow(card, { action: 'Review', searchTokens: view.tokens })), empty('No finished work is waiting for review.'), 'ready_review'),
@@ -1241,17 +1574,253 @@
1241
1574
  $('agents-content').innerHTML = rows.length ? rows.join('') : '<div class="empty-state">No CTM sessions are available.</div>';
1242
1575
  }
1243
1576
 
1577
+ function isWalleSingletonChat(card) {
1578
+ if (!card) return false;
1579
+ if (card._singleton_walle_chat === true || card.singletonWalleChat === true) return true;
1580
+ const id = String(card.id || card.sessionId || '').trim();
1581
+ return id === WALLE_SINGLETON_CHAT_ID && hasWalleChatIdentity(card) && !hasWalleCodingIdentity(card);
1582
+ }
1583
+
1584
+ function singletonWalleChatCard() {
1585
+ return {
1586
+ id: WALLE_SINGLETON_CHAT_ID,
1587
+ title: 'Wall-E',
1588
+ label: 'Wall-E',
1589
+ agent: 'walle',
1590
+ provider: 'walle',
1591
+ type: 'walle',
1592
+ session_type: 'walle',
1593
+ runtime_type: 'walle',
1594
+ native_walle_session: true,
1595
+ nativeWalleSession: true,
1596
+ walle_chat_session: true,
1597
+ walleChatSession: true,
1598
+ agentMode: 'chat',
1599
+ agentKind: 'walle-chat',
1600
+ taskType: 'chat',
1601
+ project: '',
1602
+ cwd: '',
1603
+ lane: 'idle',
1604
+ status: 'idle',
1605
+ actionLabel: 'Open',
1606
+ goal: 'Wall-E chat',
1607
+ evidence: ['default chat'],
1608
+ lastActivity: state.standupGeneratedAt || new Date().toISOString(),
1609
+ singletonWalleChat: true,
1610
+ _singleton_walle_chat: true,
1611
+ agentCapabilities: {
1612
+ structuredTranscript: true,
1613
+ promptNavigation: 'none',
1614
+ review: false,
1615
+ resume: false,
1616
+ terminalInput: false,
1617
+ terminalControl: false,
1618
+ },
1619
+ };
1620
+ }
1621
+
1244
1622
  function walleSessionCards() {
1245
- return state.sessions.filter((s) => isWalleSession(s) || /walle|wall-e/i.test(`${s.title || ''}`));
1623
+ const cards = state.sessions.filter((s) => isWalleChatSession(s));
1624
+ return cards.length ? cards : [singletonWalleChatCard()];
1246
1625
  }
1247
1626
 
1248
1627
  function selectedWalleSession(cards = walleSessionCards()) {
1249
- const preferred = state.walle.sessionId || (isWalleSession(state.activeSession) ? state.activeSession.id : '');
1628
+ const preferred = state.walle.sessionId || (isWalleChatSession(state.activeSession) ? state.activeSession.id : '');
1250
1629
  return cards.find((s) => s.id === preferred) || cards[0] || null;
1251
1630
  }
1252
1631
 
1632
+ function defaultWalleChatCwd() {
1633
+ const project = state.projects.find((item) => item && (item.path || item.cwd));
1634
+ return project?.path || project?.cwd || state.activeSession?.project || state.activeSession?.cwd || '';
1635
+ }
1636
+
1637
+ function localWalleChatCard(id, cwd) {
1638
+ const now = new Date().toISOString();
1639
+ return {
1640
+ id,
1641
+ title: 'Wall-E Chat',
1642
+ label: 'Wall-E Chat',
1643
+ agent: 'walle',
1644
+ provider: 'walle',
1645
+ type: 'walle',
1646
+ session_type: 'walle',
1647
+ runtime_type: 'walle',
1648
+ native_walle_session: true,
1649
+ nativeWalleSession: true,
1650
+ walle_chat_session: true,
1651
+ walleChatSession: true,
1652
+ agentMode: 'chat',
1653
+ agentKind: 'walle-chat',
1654
+ taskType: 'chat',
1655
+ project: cwd || '',
1656
+ cwd: cwd || '',
1657
+ lane: 'running',
1658
+ status: 'starting',
1659
+ actionLabel: 'Open',
1660
+ goal: 'Wall-E chat is starting.',
1661
+ evidence: ['chat'],
1662
+ lastActivity: now,
1663
+ agentCapabilities: {
1664
+ structuredTranscript: true,
1665
+ promptNavigation: 'none',
1666
+ review: false,
1667
+ resume: false,
1668
+ terminalInput: false,
1669
+ terminalControl: false,
1670
+ },
1671
+ };
1672
+ }
1673
+
1674
+ async function startWalleChat() {
1675
+ if (!state.auth?.scopes?.includes('create') && !state.auth?.scopes?.includes('admin')) {
1676
+ toast('This phone token needs create scope. Upgrade it on the Mac.');
1677
+ return;
1678
+ }
1679
+ const cwd = defaultWalleChatCwd();
1680
+ await withStepUp({
1681
+ title: 'Start Wall-E chat',
1682
+ copy: `Start a Wall-E chat${cwd ? ` in ${compactPath(cwd)}` : ''}.`,
1683
+ label: 'Face ID & Start',
1684
+ action: async () => {
1685
+ const id = randomId();
1686
+ const payload = {
1687
+ type: 'create',
1688
+ id,
1689
+ cwd: cwd || undefined,
1690
+ cmd: 'walle',
1691
+ args: [],
1692
+ label: 'Wall-E Chat',
1693
+ agentType: 'walle',
1694
+ agentMode: 'chat',
1695
+ agentKind: 'walle-chat',
1696
+ taskType: 'chat',
1697
+ _skipProjectConfig: true,
1698
+ };
1699
+ await sendWs(payload);
1700
+ const card = localWalleChatCard(id, cwd);
1701
+ state.sessions = [card, ...state.sessions.filter((session) => session.id !== id)];
1702
+ state.walle.sessionId = id;
1703
+ renderWalle();
1704
+ },
1705
+ });
1706
+ }
1707
+
1708
+ function walleHealthCountMeta(health) {
1709
+ const counts = health?.counts || {};
1710
+ const parts = [];
1711
+ if (health?.current_issue) parts.push('current blocker');
1712
+ if (counts.disabled_skills) parts.push(`${counts.disabled_skills} skill${counts.disabled_skills === 1 ? '' : 's'}`);
1713
+ if (counts.integration) parts.push(`${counts.integration} integration${counts.integration === 1 ? '' : 's'}`);
1714
+ if (counts.provider_history) parts.push(`${counts.provider_history} older provider`);
1715
+ if (counts.system) parts.push(`${counts.system} system`);
1716
+ return parts.join(' · ');
1717
+ }
1718
+
1719
+ function walleHealthProviderLabel(provider) {
1720
+ const normalized = String(provider || '').trim();
1721
+ if (!normalized) return 'provider';
1722
+ if (normalized === 'deepseek') return 'DeepSeek';
1723
+ if (normalized === 'openai') return 'OpenAI';
1724
+ if (normalized === 'moonshot') return 'Moonshot / Kimi';
1725
+ return normalized.charAt(0).toUpperCase() + normalized.slice(1);
1726
+ }
1727
+
1728
+ function walleHealthSkillName(item) {
1729
+ if (!item) return '';
1730
+ if (item.skill) return String(item.skill);
1731
+ const service = String(item.service || '');
1732
+ if (service && service !== 'ai_provider' && service !== 'system') return service;
1733
+ const message = String(item.message || '');
1734
+ const quoted = message.match(/Skill\s+"([^"]+)"/i);
1735
+ if (quoted) return quoted[1];
1736
+ const named = message.match(/Skill\s+([A-Za-z0-9_.-]+)/i);
1737
+ return named ? named[1] : '';
1738
+ }
1739
+
1740
+ function walleHealthItemActionHtml(item) {
1741
+ const id = item?.id ? esc(item.id) : '';
1742
+ const type = String(item?.type || '').toLowerCase();
1743
+ const action = String(item?.action || '').toLowerCase();
1744
+ if (type === 'skill_disabled' && id) {
1745
+ return `<button type="button" class="mobile-health-row-action" data-walle-health-action="enable-skill" data-walle-health-alert-id="${id}">Re-enable</button>`;
1746
+ }
1747
+ if (action === 'repair_slack_owner' && id) {
1748
+ return `<button type="button" class="mobile-health-row-action" data-walle-health-action="repair-alert" data-walle-health-alert-id="${id}">Fix</button>`;
1749
+ }
1750
+ if (action === 'gws_reauth' || item?.action_url) {
1751
+ return '<a class="mobile-health-row-action" href="/setup.html">Setup</a>';
1752
+ }
1753
+ return '';
1754
+ }
1755
+
1756
+ function walleHealthGroupHtml(title, items, options = {}) {
1757
+ if (!Array.isArray(items) || !items.length) return '';
1758
+ const open = options.open ? ' open' : '';
1759
+ const rows = items.slice(0, options.limit || 8).map((item) => `
1760
+ <div class="mobile-health-row">
1761
+ <span class="mobile-health-row-dot" aria-hidden="true"></span>
1762
+ <span class="mobile-health-row-copy">${esc(item.message || item.title || item.skill || item.service || 'Service alert')}</span>
1763
+ ${walleHealthItemActionHtml(item)}
1764
+ </div>
1765
+ `).join('');
1766
+ const more = items.length > (options.limit || 8)
1767
+ ? `<div class="mobile-health-more">${items.length - (options.limit || 8)} more in desktop Wall-E</div>`
1768
+ : '';
1769
+ return `
1770
+ <details class="mobile-health-group"${open}>
1771
+ <summary><span>${esc(title)}</span><strong>${items.length}</strong></summary>
1772
+ <div class="mobile-health-group-body">${rows}${more}</div>
1773
+ </details>
1774
+ `;
1775
+ }
1776
+
1777
+ function walleHealthHtml() {
1778
+ const health = state.walle.health;
1779
+ const alerts = state.walle.alerts || [];
1780
+ if (!health || (!alerts.length && health.level === 'ok')) return '';
1781
+ const level = health.level || 'info';
1782
+ const issue = health.current_issue;
1783
+ const meta = walleHealthCountMeta(health);
1784
+ const provider = issue?.provider || health.active_provider?.provider || '';
1785
+ const model = issue?.model || health.active_provider?.model || '';
1786
+ const test = state.walle.healthTest;
1787
+ return `
1788
+ <section id="walle-health-card" class="mobile-health-card ${esc(level)}" aria-label="Wall-E service health">
1789
+ <div class="mobile-health-header">
1790
+ <span class="mobile-health-dot" aria-hidden="true"></span>
1791
+ <div class="mobile-health-copy">
1792
+ <div class="mobile-health-kicker">Service health</div>
1793
+ <h3>${esc(health.title || 'Wall-E services')}</h3>
1794
+ <p>${esc(health.message || '')}</p>
1795
+ ${meta ? `<div class="mobile-health-meta">${esc(meta)}</div>` : ''}
1796
+ </div>
1797
+ </div>
1798
+ ${issue ? `
1799
+ <div class="mobile-health-current">
1800
+ <div class="mobile-health-current-meta">${esc([provider, model].filter(Boolean).join(' / '))}</div>
1801
+ <div class="mobile-health-actions">
1802
+ <button type="button" class="mobile-health-btn primary" data-walle-health-action="test-provider">Test ${esc(walleHealthProviderLabel(provider))}</button>
1803
+ <a class="mobile-health-btn" href="/setup.html">Setup</a>
1804
+ <button type="button" class="mobile-health-icon-btn" data-walle-health-action="dismiss-current" aria-label="Dismiss current Wall-E service alert">&times;</button>
1805
+ </div>
1806
+ </div>
1807
+ ` : ''}
1808
+ ${test ? `<div class="mobile-health-test ${esc(test.ok ? 'ok' : 'error')}">${esc(test.message || '')}</div>` : ''}
1809
+ ${walleHealthGroupHtml('Integrations', health.integration_alerts, { open: !issue })}
1810
+ ${walleHealthGroupHtml('Disabled skills', health.disabled_skills, { open: !issue && !(health.integration_alerts || []).length })}
1811
+ ${walleHealthGroupHtml('Older provider history', health.provider_history)}
1812
+ ${walleHealthGroupHtml('System alerts', health.system_alerts)}
1813
+ <div class="mobile-health-footer">
1814
+ <a href="/#walle" class="mobile-health-link">Open full Wall-E panel</a>
1815
+ </div>
1816
+ </section>
1817
+ `;
1818
+ }
1819
+
1253
1820
  function walleContextHtml(active, cards) {
1254
- const meta = compactPath(active.project || active.cwd || active.projectPath || '');
1821
+ const meta = isWalleSingletonChat(active)
1822
+ ? 'Main Wall-E chat'
1823
+ : compactPath(active.project || active.cwd || active.projectPath || '');
1255
1824
  const status = active.lane || active.status || 'idle';
1256
1825
  const picker = cards.length > 1
1257
1826
  ? `
@@ -1262,6 +1831,9 @@
1262
1831
  </select>
1263
1832
  </label>`
1264
1833
  : '';
1834
+ const action = isWalleSingletonChat(active)
1835
+ ? '<a class="row-action" href="/#walle" aria-label="Open full Wall-E panel">Full panel</a>'
1836
+ : `<button class="row-action" type="button" data-open-session="${esc(active.id)}" aria-label="Open full Wall-E session">Open</button>`;
1265
1837
  return `
1266
1838
  <div class="walle-context-main">
1267
1839
  <span class="walle-avatar" aria-hidden="true">◆</span>
@@ -1276,7 +1848,7 @@
1276
1848
  <div class="walle-context-actions">
1277
1849
  <span class="state-chip ${esc(status)}">${esc(active.status || active.lane || 'idle')}</span>
1278
1850
  ${picker}
1279
- <button class="row-action" type="button" data-open-session="${esc(active.id)}" aria-label="Open full Wall-E session">Open</button>
1851
+ ${action}
1280
1852
  </div>
1281
1853
  `;
1282
1854
  }
@@ -1287,6 +1859,7 @@
1287
1859
  <section id="walle-context" class="walle-chat-context" aria-label="Wall-E session context">
1288
1860
  ${walleContextHtml(active, cards)}
1289
1861
  </section>
1862
+ ${walleHealthHtml()}
1290
1863
  <section id="walle-messages" class="detail-messages walle-chat-messages" aria-live="polite">
1291
1864
  <div class="empty-state">Loading Wall-E conversation...</div>
1292
1865
  </section>
@@ -1333,7 +1906,8 @@
1333
1906
  }
1334
1907
  if (state.activeStreamSessionId && state.activeStreamSessionId !== active.id) unsubscribeActiveStream(state.activeStreamSessionId);
1335
1908
  state.activeSession = active;
1336
- state.activeStreamSessionId = active.id;
1909
+ const singletonChat = isWalleSingletonChat(active);
1910
+ state.activeStreamSessionId = singletonChat ? null : active.id;
1337
1911
  resetDetailTimeline(active.id);
1338
1912
  resetPromptHistory(active.id);
1339
1913
  clearComposerAttachments();
@@ -1341,8 +1915,10 @@
1341
1915
  hideMobileSkillPicker();
1342
1916
  const box = $('walle-messages');
1343
1917
  if (box) box.innerHTML = '<div class="empty-state">Connecting to Wall-E conversation...</div>';
1344
- subscribeActiveStream(active.id).catch(() => {});
1345
- warmPromptHistory(active).catch(() => {});
1918
+ if (!singletonChat) {
1919
+ subscribeActiveStream(active.id).catch(() => {});
1920
+ warmPromptHistory(active).catch(() => {});
1921
+ }
1346
1922
  loadMessages(active).catch(() => {});
1347
1923
  return true;
1348
1924
  }
@@ -1351,10 +1927,17 @@
1351
1927
  const cards = walleSessionCards();
1352
1928
  const active = selectedWalleSession(cards);
1353
1929
  if (!active) {
1930
+ const hasCodingSessions = state.sessions.some((session) => isWalleOwnedSession(session) && !isWalleChatSession(session));
1354
1931
  $('walle-content').innerHTML = `
1355
1932
  <div class="walle-empty-chat">
1356
- <h3>No Wall-E chat is open</h3>
1357
- <p>Start or open a Wall-E session on the Mac, then this tab will show the live conversation.</p>
1933
+ <span class="walle-empty-icon" aria-hidden="true">◆</span>
1934
+ <h3>Wall-E chat</h3>
1935
+ <p>Chat with Wall-E here. Coding and skills sessions stay in Status and Agents so this tab stays focused.</p>
1936
+ <div class="walle-empty-actions">
1937
+ <button id="walle-start-chat" class="primary-action" type="button">Start chat</button>
1938
+ </div>
1939
+ ${walleHealthHtml()}
1940
+ ${hasCodingSessions ? '<p class="walle-empty-note">Existing Wall-E coding sessions are still available from Status and Agents.</p>' : ''}
1358
1941
  </div>
1359
1942
  `;
1360
1943
  return;
@@ -1366,6 +1949,11 @@
1366
1949
  renderWalleShell(active, cards);
1367
1950
  } else {
1368
1951
  updateWalleContext(active, cards);
1952
+ const health = $('walle-health-card');
1953
+ const nextHealth = walleHealthHtml();
1954
+ if (health && nextHealth) health.outerHTML = nextHealth;
1955
+ else if (health && !nextHealth) health.remove();
1956
+ else if (!health && nextHealth) $('walle-context')?.insertAdjacentHTML('afterend', nextHealth);
1369
1957
  }
1370
1958
  const activated = activateWalleChat(active);
1371
1959
  if (!activated) {
@@ -1541,11 +2129,25 @@
1541
2129
  }
1542
2130
  if (restart?.status === 'blocked') {
1543
2131
  const count = Number(restart.activeSessions || 0);
1544
- const noun = count === 1 ? 'active session' : 'active sessions';
2132
+ const noun = count === 1 ? 'session' : 'sessions';
2133
+ const blockers = Array.isArray(restart.blockingSessions) ? restart.blockingSessions : [];
2134
+ const restorable = Number(restart.restorableSessions || 0);
2135
+ const blockerNames = blockers
2136
+ .map((item) => String(item?.label || item?.id || '').trim())
2137
+ .filter(Boolean)
2138
+ .slice(0, 3);
2139
+ const blockerText = blockerNames.length
2140
+ ? `<span>Blocking: ${esc(blockerNames.join(', '))}${blockers.length > blockerNames.length ? '...' : ''}</span>`
2141
+ : '';
2142
+ const restorableText = restorable > 0
2143
+ ? `<span>${restorable} idle/review ${restorable === 1 ? 'session can' : 'sessions can'} reconnect after restart.</span>`
2144
+ : '';
1545
2145
  return `
1546
2146
  <div class="restart-notice blocked" role="alert">
1547
- <span class="restart-notice-title">${count || 'Some'} ${noun} running</span>
1548
- <span>Normal restart was blocked to avoid interrupting live terminals.</span>
2147
+ <span class="restart-notice-title">${count || 'Some'} ${noun} still active</span>
2148
+ <span>Normal restart was blocked to avoid interrupting running work or a waiting approval.</span>
2149
+ ${blockerText}
2150
+ ${restorableText}
1549
2151
  <button id="restart-force-btn" class="row-action warn" type="button">Force restart</button>
1550
2152
  </div>
1551
2153
  `;
@@ -1613,6 +2215,9 @@
1613
2215
  state.restart = {
1614
2216
  status: 'blocked',
1615
2217
  activeSessions: Number(err.payload?.active_sessions || 0),
2218
+ blockingSessions: Array.isArray(err.payload?.blocking_sessions) ? err.payload.blocking_sessions : [],
2219
+ restorableSessions: Number(err.payload?.restorable_sessions || 0),
2220
+ totalSessions: Number(err.payload?.total_sessions || 0),
1616
2221
  message: err.payload?.error || err.message || 'Restart blocked by active sessions.',
1617
2222
  };
1618
2223
  renderMore();
@@ -1642,6 +2247,10 @@
1642
2247
  const copy = {};
1643
2248
  for (const key of [
1644
2249
  'id', 'sessionId', 'title', 'aiTitle', 'displayTitle', 'userRenamed',
2250
+ 'type', 'session_type', 'sessionType', 'runtime_type', 'runtimeType',
2251
+ 'walle_owned', 'walleOwned', 'native_walle_session', 'nativeWalleSession',
2252
+ 'walle_chat_session', 'walleChatSession',
2253
+ 'agentMode', 'agent_mode', 'agentKind', 'agent_kind', 'taskType', 'task_type',
1645
2254
  'agent', 'provider', 'model', 'model_id',
1646
2255
  'modelProvider', 'model_provider', 'providerType', 'llmProvider',
1647
2256
  'walle_model_id', 'walle_model_provider', 'runtime_model_id', 'runtime_model_provider',
@@ -1656,7 +2265,8 @@
1656
2265
  copy.worktreeStatus = {};
1657
2266
  for (const key of [
1658
2267
  'branch', 'worktreeName', 'worktreePath', 'state', 'summary',
1659
- 'dirtyFiles', 'unmergedCommits', 'ahead', 'behind', 'needsAttention',
2268
+ 'isMain', 'dirtyFiles', 'unmergedCommits', 'unpushedCommits', 'ahead', 'behind',
2269
+ 'needsAttention', 'mainRemote',
1660
2270
  ]) {
1661
2271
  if (wt[key] != null) copy.worktreeStatus[key] = wt[key];
1662
2272
  }
@@ -2220,6 +2830,141 @@
2220
2830
  }, delayMs);
2221
2831
  }
2222
2832
 
2833
+ function applyWalleHealth(payload) {
2834
+ const alerts = Array.isArray(payload?.alerts) ? payload.alerts : [];
2835
+ state.walle.alerts = alerts;
2836
+ state.walle.health = payload?.summary || null;
2837
+ if (!state.walle.health && alerts.length) {
2838
+ state.walle.health = {
2839
+ level: 'warning',
2840
+ title: 'Wall-E service alerts',
2841
+ message: 'Review current provider, integration, and skill alerts.',
2842
+ counts: { total: alerts.length },
2843
+ current_issue: null,
2844
+ disabled_skills: alerts.filter((a) => String(a?.type || '') === 'skill_disabled'),
2845
+ integration_alerts: alerts.filter((a) => /(auth|owner|reauth|slack|gws|google)/i.test(`${a?.type || ''} ${a?.service || ''} ${a?.action || ''}`)),
2846
+ provider_history: alerts.filter((a) => String(a?.service || '') === 'ai_provider'),
2847
+ system_alerts: [],
2848
+ };
2849
+ }
2850
+ }
2851
+
2852
+ async function refreshWalleHealth(options = {}) {
2853
+ try {
2854
+ const payload = await api('/api/wall-e/alerts');
2855
+ applyWalleHealth(payload);
2856
+ return payload;
2857
+ } catch (err) {
2858
+ if (!options.quiet) toast(err?.message || 'Could not load Wall-E health.');
2859
+ return null;
2860
+ }
2861
+ }
2862
+
2863
+ async function testWalleHealthProvider() {
2864
+ const health = state.walle.health || {};
2865
+ const issue = health.current_issue || {};
2866
+ const provider = String(issue.provider || health.active_provider?.provider || '').trim();
2867
+ if (!provider) {
2868
+ state.walle.healthTest = { ok: false, message: 'No active provider to test.' };
2869
+ renderWalle();
2870
+ return;
2871
+ }
2872
+ state.walle.healthTest = { ok: true, message: `Testing ${walleHealthProviderLabel(provider)}...` };
2873
+ renderWalle();
2874
+ const params = new URLSearchParams({ provider });
2875
+ const result = await api(`/api/setup/test-key?${params.toString()}`).catch((err) => ({ ok: false, error: err?.message || 'Provider test failed.' }));
2876
+ if (result?.ok) {
2877
+ state.walle.healthTest = { ok: true, message: `${walleHealthProviderLabel(provider)} is reachable.` };
2878
+ if (issue.id) await api(`/api/wall-e/alerts/${encodeURIComponent(issue.id)}`, { method: 'DELETE' }).catch(() => null);
2879
+ await refreshWalleHealth({ quiet: true });
2880
+ } else {
2881
+ state.walle.healthTest = { ok: false, message: result?.error || 'Provider test failed.' };
2882
+ }
2883
+ renderWalle();
2884
+ }
2885
+
2886
+ async function dismissCurrentWalleHealthAlert() {
2887
+ const id = state.walle.health?.current_issue?.id;
2888
+ if (!id) return;
2889
+ await api(`/api/wall-e/alerts/${encodeURIComponent(id)}`, { method: 'DELETE' });
2890
+ state.walle.healthTest = null;
2891
+ await refreshWalleHealth({ quiet: true });
2892
+ renderWalle();
2893
+ }
2894
+
2895
+ function findWalleHealthAlert(alertId) {
2896
+ const id = String(alertId || '');
2897
+ if (!id) return null;
2898
+ const health = state.walle.health || {};
2899
+ const pools = [
2900
+ state.walle.alerts || [],
2901
+ health.current_issue ? [health.current_issue] : [],
2902
+ health.disabled_skills || [],
2903
+ health.integration_alerts || [],
2904
+ health.provider_history || [],
2905
+ health.system_alerts || [],
2906
+ ];
2907
+ for (const pool of pools) {
2908
+ const found = pool.find((item) => String(item?.id || '') === id);
2909
+ if (found) return found;
2910
+ }
2911
+ return null;
2912
+ }
2913
+
2914
+ async function enableWalleHealthSkill(alertId) {
2915
+ const alert = findWalleHealthAlert(alertId);
2916
+ const skillName = walleHealthSkillName(alert);
2917
+ if (!skillName) throw new Error('Could not identify the disabled skill.');
2918
+ state.walle.healthTest = { ok: true, message: `Re-enabling ${skillName}...` };
2919
+ renderWalle();
2920
+ const response = await api('/api/wall-e/skills?enabled=0');
2921
+ const skills = response?.data || response?.skills || [];
2922
+ const match = skills.find((skill) => String(skill?.name || '').toLowerCase() === skillName.toLowerCase());
2923
+ if (!match?.id) throw new Error(`${skillName} is not listed as disabled.`);
2924
+ await api(`/api/wall-e/skills/${encodeURIComponent(match.id)}`, {
2925
+ method: 'PUT',
2926
+ body: { enabled: 1 },
2927
+ });
2928
+ if (alertId) await api(`/api/wall-e/alerts/${encodeURIComponent(alertId)}`, { method: 'DELETE' }).catch(() => null);
2929
+ state.walle.healthTest = { ok: true, message: `${skillName} is enabled.` };
2930
+ await refreshWalleHealth({ quiet: true });
2931
+ renderWalle();
2932
+ }
2933
+
2934
+ async function repairWalleHealthAlert(alertId) {
2935
+ const alert = findWalleHealthAlert(alertId);
2936
+ const action = String(alert?.action || '').toLowerCase();
2937
+ if (action !== 'repair_slack_owner') throw new Error('Open the full Wall-E panel to fix this alert.');
2938
+ state.walle.healthTest = { ok: true, message: 'Fixing Slack owner identity...' };
2939
+ renderWalle();
2940
+ const result = await api('/api/wall-e/slack/repair-owner', { method: 'POST' });
2941
+ if (!result?.ok) throw new Error(result?.error || 'Could not fix Slack owner identity.');
2942
+ state.walle.healthTest = { ok: true, message: 'Slack owner identity is fixed.' };
2943
+ await refreshWalleHealth({ quiet: true });
2944
+ renderWalle();
2945
+ }
2946
+
2947
+ function handleWalleHealthAction(action, alertId) {
2948
+ if (action === 'test-provider') {
2949
+ testWalleHealthProvider().catch((err) => {
2950
+ state.walle.healthTest = { ok: false, message: err?.message || 'Provider test failed.' };
2951
+ renderWalle();
2952
+ });
2953
+ } else if (action === 'dismiss-current') {
2954
+ dismissCurrentWalleHealthAlert().catch((err) => toast(err?.message || 'Could not dismiss alert.'));
2955
+ } else if (action === 'enable-skill') {
2956
+ enableWalleHealthSkill(alertId).catch((err) => {
2957
+ state.walle.healthTest = { ok: false, message: err?.message || 'Could not re-enable skill.' };
2958
+ renderWalle();
2959
+ });
2960
+ } else if (action === 'repair-alert') {
2961
+ repairWalleHealthAlert(alertId).catch((err) => {
2962
+ state.walle.healthTest = { ok: false, message: err?.message || 'Could not fix alert.' };
2963
+ renderWalle();
2964
+ });
2965
+ }
2966
+ }
2967
+
2223
2968
  async function refresh() {
2224
2969
  if (state.refreshInFlight) return state.refreshInFlight;
2225
2970
  state.refreshInFlight = (async () => {
@@ -2227,11 +2972,14 @@
2227
2972
  if (!navigator.onLine) setConnection('offline');
2228
2973
  else if (!state.wsReady) setConnection('stale', 'Syncing');
2229
2974
  const activeBefore = state.activeSession;
2230
- const [auth, standup] = await Promise.all([
2975
+ const shouldLoadWalleHealth = state.activeTab === 'walle';
2976
+ const [auth, standup, walleHealth] = await Promise.all([
2231
2977
  api('/api/auth/me'),
2232
2978
  api(standupRequestPath()),
2979
+ shouldLoadWalleHealth ? refreshWalleHealth({ quiet: true }) : Promise.resolve(null),
2233
2980
  ]);
2234
2981
  state.auth = auth.auth || null;
2982
+ if (walleHealth) applyWalleHealth(walleHealth);
2235
2983
  applyStandupSnapshot(standup);
2236
2984
  state.sessionsLoaded = true;
2237
2985
  if (activeBefore && findSessionIndex(activeBefore.id) < 0) {
@@ -2469,7 +3217,7 @@
2469
3217
  let msg = null;
2470
3218
  try { msg = JSON.parse(event.data); } catch { return; }
2471
3219
  if (msg.type === 'hello') {
2472
- markHostReachable();
3220
+ handleServerHello(msg);
2473
3221
  return;
2474
3222
  }
2475
3223
  if (msg.type === 'server-restarting') {
@@ -2750,7 +3498,29 @@
2750
3498
  updateRemoteOutboxStatus();
2751
3499
  }
2752
3500
 
3501
+ function reconcileRemoteOutboxEntryTransport(entry) {
3502
+ if (!entry || entry.type !== 'wall_e.send_message') return true;
3503
+ const card = findSession(entry.sessionId);
3504
+ if ((!card || card._placeholder) && !state.sessionsLoaded) {
3505
+ entry.nextAttemptAt = Date.now() + 500;
3506
+ persistRemoteOutbox();
3507
+ return false;
3508
+ }
3509
+ if (card && isWalleOwnedSession(card) && !isNativeWalleSession(card)) {
3510
+ entry.type = 'session.send_message';
3511
+ entry.body = {
3512
+ session_id: entry.sessionId,
3513
+ text: entry.text,
3514
+ };
3515
+ entry.lastError = '';
3516
+ entry.status = 'queued';
3517
+ persistRemoteOutbox();
3518
+ }
3519
+ return true;
3520
+ }
3521
+
2753
3522
  async function deliverRemoteOutboxEntry(entry) {
3523
+ if (!reconcileRemoteOutboxEntryTransport(entry)) return false;
2754
3524
  entry.status = 'sending';
2755
3525
  entry.attempts = Math.max(0, Number(entry.attempts || 0) || 0) + 1;
2756
3526
  entry.lastError = '';
@@ -2772,6 +3542,9 @@
2772
3542
  });
2773
3543
  }
2774
3544
  if (!updateRemoteOutboxStatus()) setComposerStatus('Sent to Mac.', 'ok');
3545
+ if (isWalleSingletonChat(state.activeSession)) {
3546
+ loadMessages(state.activeSession, { background: true, fresh: true }).catch(() => {});
3547
+ }
2775
3548
  }
2776
3549
  return true;
2777
3550
  } catch (err) {
@@ -2841,6 +3614,7 @@
2841
3614
  if (state.activeStreamSessionId && state.activeStreamSessionId !== card.id) unsubscribeActiveStream(state.activeStreamSessionId);
2842
3615
  state.activeSession = card;
2843
3616
  state.activeStreamSessionId = card.id;
3617
+ render();
2844
3618
  resetDetailTimeline(card.id);
2845
3619
  resetPromptHistory(card.id);
2846
3620
  updateDetailChrome(card);
@@ -2850,10 +3624,11 @@
2850
3624
  setComposerText('', { focus: false, cursor: false });
2851
3625
  autoSizeComposer();
2852
3626
  updateComposerState();
2853
- lockDetailBackgroundScroll();
2854
3627
  updateRemoteOutboxStatus();
2855
3628
  $('detail-view').classList.add('active');
2856
3629
  $('detail-view').setAttribute('aria-hidden', 'false');
3630
+ syncDetailLayoutMode();
3631
+ lockDetailBackgroundScroll();
2857
3632
  updateComposerState();
2858
3633
  $('detail-messages').innerHTML = card._placeholder
2859
3634
  ? '<div class="empty-state">Loading session context. You can still type and send a prompt.</div>'
@@ -2884,7 +3659,9 @@
2884
3659
  updateComposerState();
2885
3660
  $('detail-view').classList.remove('active');
2886
3661
  $('detail-view').setAttribute('aria-hidden', 'true');
3662
+ syncDetailLayoutMode();
2887
3663
  unlockDetailBackgroundScroll();
3664
+ render();
2888
3665
  }
2889
3666
 
2890
3667
  function renderDetailAlert(card) {
@@ -2903,17 +3680,135 @@
2903
3680
  }
2904
3681
 
2905
3682
  function isWalleSession(card) {
2906
- const kind = `${card?.agent || ''} ${card?.provider || ''} ${card?.type || ''}`;
2907
- return /walle|wall-e/i.test(kind);
3683
+ return isWalleOwnedSession(card) || isNativeWalleSession(card);
3684
+ }
3685
+
3686
+ function normalizedCardType(card) {
3687
+ return String(card?.session_type || card?.sessionType || card?.runtime_type || card?.runtimeType || card?.type || '')
3688
+ .trim()
3689
+ .toLowerCase()
3690
+ .replace(/_/g, '-');
3691
+ }
3692
+
3693
+ function cardCapabilities(card) {
3694
+ return card?.agentCapabilities || card?.capabilities || {};
3695
+ }
3696
+
3697
+ function walleIdentityText(card) {
3698
+ return [
3699
+ card?.agent,
3700
+ card?.provider,
3701
+ card?.type,
3702
+ card?.session_type,
3703
+ card?.sessionType,
3704
+ card?.runtime_type,
3705
+ card?.runtimeType,
3706
+ card?.agentMode,
3707
+ card?.agent_mode,
3708
+ card?.agentKind,
3709
+ card?.agent_kind,
3710
+ card?.taskType,
3711
+ card?.task_type,
3712
+ card?.title,
3713
+ card?.label,
3714
+ card?.branch,
3715
+ ].filter(Boolean).join(' ').toLowerCase();
3716
+ }
3717
+
3718
+ function wallePathText(card) {
3719
+ return [
3720
+ card?.cwd,
3721
+ card?.project,
3722
+ card?.projectPath,
3723
+ card?.project_path,
3724
+ card?.worktree_path,
3725
+ card?.worktreeStatus?.worktreePath,
3726
+ ].filter(Boolean).join(' ').replace(/\\/g, '/').toLowerCase();
3727
+ }
3728
+
3729
+ function hasWalleCodingIdentity(card) {
3730
+ if (!card) return false;
3731
+ const type = normalizedCardType(card);
3732
+ const mode = String(card.agentMode || card.agent_mode || '').trim().toLowerCase();
3733
+ const taskType = String(card.taskType || card.task_type || '').trim().toLowerCase();
3734
+ const identity = walleIdentityText(card);
3735
+ const pathText = wallePathText(card);
3736
+ if (mode === 'coding' || taskType === 'coding') return true;
3737
+ if (type === 'walle-coding' || type === 'wall-e-coding') return true;
3738
+ if (/wall-?e[-\s]?coding|walle[-\s]?coding|\bcoding agent\b/.test(identity)) return true;
3739
+ if (/\/\.(?:walle|claude)\/worktrees\//.test(pathText)) return true;
3740
+ return false;
3741
+ }
3742
+
3743
+ function hasWalleChatIdentity(card) {
3744
+ if (!card) return false;
3745
+ const type = normalizedCardType(card);
3746
+ const mode = String(card.agentMode || card.agent_mode || '').trim().toLowerCase();
3747
+ const taskType = String(card.taskType || card.task_type || '').trim().toLowerCase();
3748
+ const identity = walleIdentityText(card);
3749
+ if (mode === 'coding' || taskType === 'coding') return false;
3750
+ if (card.walle_chat_session === true || card.walleChatSession === true) return true;
3751
+ if (mode === 'chat' || taskType === 'chat') return true;
3752
+ if (type === 'walle-chat' || type === 'wall-e-chat') return true;
3753
+ return /wall-?e[-\s]?chat|walle[-\s]?chat/.test(identity);
3754
+ }
3755
+
3756
+ function isNativeWalleSession(card) {
3757
+ if (!card) return false;
3758
+ if (card.native_walle_session === true || card.nativeWalleSession === true) return true;
3759
+ const type = normalizedCardType(card);
3760
+ if (type === 'walle' || type === 'wall-e' || type === 'walle-chat' || type === 'wall-e-chat') return true;
3761
+ const agentKind = String(card.agentKind || card.agent_kind || card.taskType || card.task_type || '').toLowerCase();
3762
+ if (/walle-chat|wall-e-chat/.test(agentKind)) return true;
3763
+ const agent = String(card.agent || '').toLowerCase();
3764
+ const provider = String(card.provider || '').toLowerCase();
3765
+ const caps = cardCapabilities(card);
3766
+ const hasTerminalCapability = caps.terminalControl === true || caps.terminalInput === true || caps.canSendEscape === true;
3767
+ const looksLikeCodingAgent = /codex|claude|gemini|opencode|terminal|pty|coding/.test(`${agent} ${card.title || ''} ${agentKind}`);
3768
+ return /^(walle|wall-e)$/.test(agent || provider) && !hasTerminalCapability && !looksLikeCodingAgent;
3769
+ }
3770
+
3771
+ function isWalleChatSession(card) {
3772
+ if (!card) return false;
3773
+ if (!isNativeWalleSession(card) && !isWalleOwnedSession(card)) return false;
3774
+ if (hasWalleChatIdentity(card)) return true;
3775
+ if (hasWalleCodingIdentity(card)) return false;
3776
+ if (card.native_walle_session === true || card.nativeWalleSession === true) return true;
3777
+ const type = normalizedCardType(card);
3778
+ if (type === 'walle' || type === 'wall-e') return true;
3779
+ const agent = String(card.agent || '').toLowerCase();
3780
+ const provider = String(card.provider || '').toLowerCase();
3781
+ const caps = cardCapabilities(card);
3782
+ const hasTerminalCapability = caps.terminalControl === true || caps.terminalInput === true || caps.canSendEscape === true;
3783
+ const looksLikeCodingAgent = /codex|claude|gemini|opencode|terminal|pty|coding/.test(walleIdentityText(card));
3784
+ return /^(walle|wall-e)$/.test(agent || provider) && !hasTerminalCapability && !looksLikeCodingAgent;
3785
+ }
3786
+
3787
+ function isWalleOwnedSession(card) {
3788
+ if (!card) return false;
3789
+ if (card.walle_owned === true || card.walleOwned === true) return true;
3790
+ if (isNativeWalleSession(card)) return true;
3791
+ const kind = `${card.agent || ''} ${card.provider || ''} ${card.type || ''} ${card.agentKind || ''} ${card.taskType || ''} ${card.title || ''} ${card.branch || ''} ${card.cwd || ''} ${card.project || ''}`;
3792
+ if (/walle|wall-e/i.test(`${card.provider || ''} ${card.agentKind || ''} ${card.taskType || ''}`)) return true;
3793
+ if (/walle[-\s]?coding|wall[-\s]?e[-\s]?coding/i.test(kind)) return true;
3794
+ if (card.walle_model_id || card.walle_model_provider) return true;
3795
+ return false;
2908
3796
  }
2909
3797
 
2910
3798
  function isWalleCodingSession(card) {
2911
- if (!card || !isWalleSession(card)) return false;
2912
- const caps = card.agentCapabilities || card.capabilities || {};
3799
+ if (!card || !isWalleOwnedSession(card)) return false;
3800
+ if (isWalleSingletonChat(card)) return false;
3801
+ const caps = cardCapabilities(card);
2913
3802
  if (caps.structuredTranscript === false && caps.terminalInput === false && caps.review === false) return false;
2914
3803
  return true;
2915
3804
  }
2916
3805
 
3806
+ function walleTransportSessionId(card) {
3807
+ return isWalleSingletonChat(card)
3808
+ ? WALLE_SINGLETON_CHAT_ID
3809
+ : String(card?.id || card?.sessionId || '').trim();
3810
+ }
3811
+
2917
3812
  function terminalControlCapability(card) {
2918
3813
  const caps = card?.capabilities || card?.agentCapabilities || {};
2919
3814
  if (caps.terminalControl === true || caps.terminalInput === true || caps.canSendEscape === true) return true;
@@ -2923,7 +3818,7 @@
2923
3818
 
2924
3819
  function isTerminalControlSession(card) {
2925
3820
  if (!card) return false;
2926
- if (isWalleSession(card)) return false;
3821
+ if (isNativeWalleSession(card)) return false;
2927
3822
  const explicit = terminalControlCapability(card);
2928
3823
  if (explicit !== null) return explicit;
2929
3824
  const kind = `${card.agent || ''} ${card.provider || ''} ${card.title || ''} ${card.type || ''}`;
@@ -2950,12 +3845,24 @@
2950
3845
  }
2951
3846
  try {
2952
3847
  const fresh = opts.fresh ? '&nocache=1' : '';
2953
- const data = await api(`/api/session/messages?id=${encodeURIComponent(card.id)}&offset=0&limit=80${fresh}`);
3848
+ const data = isWalleSingletonChat(card)
3849
+ ? await api(`/api/wall-e/chat/history?session_id=${encodeURIComponent(WALLE_SINGLETON_CHAT_ID)}&limit=200${fresh}`)
3850
+ : await api(`/api/session/messages?id=${encodeURIComponent(card.id)}&offset=0&limit=80${fresh}`);
2954
3851
  if (state.activeSession?.id !== card.id) return;
2955
- const messages = Array.isArray(data) ? data : (data.messages || data.items || []);
3852
+ const rawMessages = Array.isArray(data) ? data : (data.data || data.messages || data.items || []);
3853
+ const messages = isWalleSingletonChat(card)
3854
+ ? rawMessages.map((message) => ({
3855
+ role: message.role,
3856
+ text: message.content || message.text || '',
3857
+ timestamp: message.created_at || message.createdAt || message.timestamp || new Date().toISOString(),
3858
+ uuid: message.id || message.uuid || undefined,
3859
+ agentLabel: message.role === 'assistant' ? 'Wall-E' : undefined,
3860
+ attachments: message.attachments,
3861
+ }))
3862
+ : rawMessages;
2956
3863
  renderMessages(messages, { forceBottom: opts.forceBottom !== false });
2957
3864
  seedPromptHistoryFromTimeline();
2958
- if (!background) warmPromptHistory(card).catch(() => {});
3865
+ if (!background && !isWalleSingletonChat(card)) warmPromptHistory(card).catch(() => {});
2959
3866
  } catch (err) {
2960
3867
  if (state.activeSession?.id !== card.id) return;
2961
3868
  if (state.detail.messages.length || state.detail.liveTail || hasTimelineContent(box)) {
@@ -3210,6 +4117,38 @@
3210
4117
  return recent.includes(live);
3211
4118
  }
3212
4119
 
4120
+ function normalizedTimelineTextMatches(candidate, reference) {
4121
+ const left = normalizeCompareText(candidate);
4122
+ const right = normalizeCompareText(reference);
4123
+ if (!left || !right || Math.min(left.length, right.length) < 12) return false;
4124
+ if (left === right) return true;
4125
+ if (Math.min(left.length, right.length) < 24) return false;
4126
+ return left.includes(right) || right.includes(left);
4127
+ }
4128
+
4129
+ function timelineMessageMatchesText(item, text, roles = null) {
4130
+ if (Array.isArray(roles) && !roles.includes(mobileReviewRole(item))) return false;
4131
+ return normalizedTimelineTextMatches(messageText(item), text);
4132
+ }
4133
+
4134
+ function latestDurableUserMessageIndex() {
4135
+ for (let index = state.detail.messages.length - 1; index >= 0; index -= 1) {
4136
+ if (mobileReviewRole(state.detail.messages[index]) === 'user') return index;
4137
+ }
4138
+ return -1;
4139
+ }
4140
+
4141
+ function durableUserPromptIndex(text) {
4142
+ for (let index = state.detail.messages.length - 1; index >= 0; index -= 1) {
4143
+ if (timelineMessageMatchesText(state.detail.messages[index], text, ['user'])) return index;
4144
+ }
4145
+ return -1;
4146
+ }
4147
+
4148
+ function durableTimelineCoversMessageText(text, roles = null) {
4149
+ return state.detail.messages.some((item) => timelineMessageMatchesText(item, text, roles));
4150
+ }
4151
+
3213
4152
  function cleanTerminalTail(tail) {
3214
4153
  const rows = stripTerminalPromptPlaceholderRows(cleanLiveTerminalRows(normalizeLiveTerminalRows(tail?.rows, stripAnsi(tail?.text || ''))));
3215
4154
  return {
@@ -3449,10 +4388,14 @@
3449
4388
  }
3450
4389
 
3451
4390
  function promptContinuationFallback(promptText, rows, promptIndex) {
4391
+ // Codex can echo pasted or soft-wrapped prompts as several PTY rows where
4392
+ // only the first row has the prompt glyph. Treat likely continuation rows
4393
+ // as user text until a terminal boundary appears, so prompt fragments are
4394
+ // not rendered as assistant output.
3452
4395
  const text = String(promptText || '').trim();
3453
4396
  const nextRow = rows[promptIndex + 1];
3454
4397
  const next = String(nextRow?.text || '').trim();
3455
- if (!text || !next || nextRow?.wrapKnown) return false;
4398
+ if (!text || !next) return false;
3456
4399
  if (!isPromptContinuationRow(nextRow)) return false;
3457
4400
  const statusIndex = findBusyStatusBeforeNextPrompt(rows, promptIndex);
3458
4401
  if (statusIndex > promptIndex + 1 && text.length >= 64) return true;
@@ -3505,20 +4448,21 @@
3505
4448
  role: 'user',
3506
4449
  rows: promptText ? [{ text: promptText, wrapped: false, wrapKnown: row.wrapKnown }] : [],
3507
4450
  fallbackContinuation: promptContinuationFallback(promptText, rows, index),
3508
- fallbackCount: 0,
3509
4451
  };
3510
4452
  continue;
3511
4453
  }
3512
4454
  if (isTerminalDividerLine(line)) continue;
3513
- if (isTerminalChromeLine(line)) continue;
4455
+ if (isTerminalChromeLine(line) || isTerminalStatusLine(line)) {
4456
+ flush();
4457
+ continue;
4458
+ }
3514
4459
  if (current?.role === 'user') {
3515
- if (row.wrapped || (current.fallbackContinuation && current.fallbackCount < 6 && isPromptContinuationRow(row))) {
4460
+ if (row.wrapped || (current.fallbackContinuation && isPromptContinuationRow(row))) {
3516
4461
  current.rows.push({
3517
4462
  text: line,
3518
4463
  wrapped: true,
3519
4464
  wrapKnown: row.wrapKnown,
3520
4465
  });
3521
- if (!row.wrapped) current.fallbackCount += 1;
3522
4466
  continue;
3523
4467
  }
3524
4468
  flush();
@@ -3574,12 +4518,14 @@
3574
4518
  }
3575
4519
 
3576
4520
  function renderLiveTerminalPromptTurn(promptTurn, responseTurns, tail, index) {
4521
+ // A prompt echo without any agent output is only provisional terminal state. The
4522
+ // grouped conversation timeline should show durable turns or live answer content,
4523
+ // not a duplicate "You live / no replies" card for the active composer prompt.
4524
+ if (!responseTurns.length) return '';
3577
4525
  const key = mobileLiveTurnKey(tail, promptTurn?.text || '', index);
3578
4526
  const expanded = isTimelineTurnExpanded(key);
3579
4527
  const promptBody = esc(promptTurn?.text || '').replace(/\n/g, '<br>');
3580
- const responseHtml = responseTurns.length
3581
- ? responseTurns.map((turn, responseIndex) => renderLiveTerminalResponse(turn, tail, responseIndex, responseTurns.length)).join('')
3582
- : '<div class="prompt-turn-empty">Live response is still streaming.</div>';
4528
+ const responseHtml = responseTurns.map((turn, responseIndex) => renderLiveTerminalResponse(turn, tail, responseIndex, responseTurns.length)).join('');
3583
4529
  return `
3584
4530
  <section class="prompt-turn live-prompt-turn live-tail-row${expanded ? ' expanded' : ''}" data-mobile-turn-key="${esc(key)}" data-turn-id="${esc(key)}" data-live-tail="true" data-live-tail-kind="prompt">
3585
4531
  <div class="prompt-turn-header" role="button" tabindex="0" aria-expanded="${expanded ? 'true' : 'false'}">
@@ -3633,10 +4579,13 @@
3633
4579
 
3634
4580
  function renderLiveTerminalConversationTurns(turns, tail) {
3635
4581
  const rows = [];
4582
+ const latestUserIndex = latestDurableUserMessageIndex();
3636
4583
  for (let index = 0; index < turns.length; index += 1) {
3637
4584
  const turn = turns[index];
3638
4585
  if (turn?.role !== 'user') {
3639
- rows.push(renderLiveTerminalStandaloneTurn(turn, tail, index));
4586
+ if (!durableTimelineCoversMessageText(turn?.text || '', ['assistant', 'summary', 'system'])) {
4587
+ rows.push(renderLiveTerminalStandaloneTurn(turn, tail, index));
4588
+ }
3640
4589
  continue;
3641
4590
  }
3642
4591
  const responses = [];
@@ -3645,7 +4594,19 @@
3645
4594
  responses.push(turns[cursor]);
3646
4595
  cursor += 1;
3647
4596
  }
3648
- rows.push(renderLiveTerminalPromptTurn(turn, responses, tail, index));
4597
+ const visibleResponses = responses.filter((response) => (
4598
+ !durableTimelineCoversMessageText(response?.text || '', ['assistant', 'summary', 'system'])
4599
+ ));
4600
+ const durablePromptIndex = durableUserPromptIndex(turn?.text || '');
4601
+ if (durablePromptIndex >= 0) {
4602
+ if (durablePromptIndex >= latestUserIndex) {
4603
+ visibleResponses.forEach((response, responseIndex) => {
4604
+ rows.push(renderLiveTerminalStandaloneTurn(response, tail, `${index}-${responseIndex}`));
4605
+ });
4606
+ }
4607
+ } else {
4608
+ rows.push(renderLiveTerminalPromptTurn(turn, visibleResponses, tail, index));
4609
+ }
3649
4610
  index = cursor - 1;
3650
4611
  }
3651
4612
  return rows.join('');
@@ -3831,8 +4792,10 @@
3831
4792
  rememberTimelineTurnExpanded(turn.dataset.mobileTurnKey || turn.dataset.turnId || '', next);
3832
4793
  };
3833
4794
  const toggle = (ev) => {
4795
+ if (window.MR?.shouldIgnorePromptTurnHeaderToggle?.(ev, header)) return;
3834
4796
  if (ev?.target?.closest?.('a,button,input,textarea,select')) return;
3835
- if (window.MR?.shouldKeepPromptTextSelection?.(ev, header)) return;
4797
+ if (ev?.target?.closest?.('.prompt-turn-prompt .msg-text')) return;
4798
+ if (window.MR?.hasTextSelectionInside?.(header)) return;
3836
4799
  const next = !turn.classList.contains('expanded');
3837
4800
  setExpanded(next);
3838
4801
  };
@@ -4122,10 +5085,11 @@
4122
5085
  const button = $('detail-send');
4123
5086
  if (!menu || !button || button.disabled) return false;
4124
5087
  hideMobileSkillPicker();
5088
+ updateModelPickerButtons();
4125
5089
  menu.hidden = false;
4126
5090
  button.setAttribute('aria-expanded', 'true');
4127
5091
  state.sendMenu.suppressNextClick = opts.suppressNextClick !== false;
4128
- if (opts.focus !== false) ($('send-prev-prompt-option') || $('send-esc-option'))?.focus({ preventScroll: true });
5092
+ if (opts.focus !== false) menu.querySelector('.send-menu-item:not([hidden]):not(:disabled)')?.focus({ preventScroll: true });
4129
5093
  return true;
4130
5094
  }
4131
5095
 
@@ -4243,9 +5207,19 @@
4243
5207
 
4244
5208
  function updateModelPickerButtons() {
4245
5209
  const visible = isWalleCodingSession(state.activeSession);
5210
+ const detailVisible = visible && isDetailOpen();
4246
5211
  const label = visible ? modelButtonShortLabel(state.activeSession) : 'Model';
5212
+ const fullLabel = visible ? modelButtonLabel(state.activeSession) : 'Choose model';
5213
+ const sendModelOption = $('send-model-option');
5214
+ if (sendModelOption) {
5215
+ sendModelOption.hidden = !detailVisible;
5216
+ sendModelOption.disabled = !detailVisible || state.composerSending;
5217
+ sendModelOption.title = detailVisible ? `Choose model: ${fullLabel}` : 'Choose model';
5218
+ sendModelOption.setAttribute('aria-expanded', detailVisible && state.modelPicker.open ? 'true' : 'false');
5219
+ const value = $('send-model-label');
5220
+ if (value) value.textContent = detailVisible ? fullLabel : 'Auto model';
5221
+ }
4247
5222
  const controls = [
4248
- { input: $('detail-input'), form: $('detail-composer'), button: $('detail-model'), surface: 'detail' },
4249
5223
  { input: $('walle-input'), form: $('walle-chat-composer'), button: $('walle-model'), surface: 'walle' },
4250
5224
  ];
4251
5225
  for (const control of controls) {
@@ -4260,6 +5234,8 @@
4260
5234
  if (value) value.textContent = label;
4261
5235
  form.classList.toggle('has-model-picker', show);
4262
5236
  }
5237
+ const detailForm = $('detail-composer');
5238
+ if (detailForm) detailForm.classList.remove('has-model-picker');
4263
5239
  }
4264
5240
 
4265
5241
  function renderModelPickerSheet() {
@@ -5266,7 +6242,7 @@
5266
6242
  }
5267
6243
  const outboundText = buildComposerTransportText(text, attachments);
5268
6244
  try {
5269
- const messageType = isWalleSession(card) ? 'wall_e.send_message' : 'session.send_message';
6245
+ const messageType = isNativeWalleSession(card) ? 'wall_e.send_message' : 'session.send_message';
5270
6246
  const body = { session_id: card.id, text: outboundText };
5271
6247
  if (messageType === 'wall_e.send_message') {
5272
6248
  if (attachments.length) body.attachments = attachments;
@@ -5300,7 +6276,7 @@
5300
6276
  if (state.activeSession?.id !== card.id) activateWalleChat(card);
5301
6277
  const outboundText = buildComposerTransportText(text, attachments);
5302
6278
  try {
5303
- const body = { session_id: card.id, text: outboundText };
6279
+ const body = { session_id: walleTransportSessionId(card), text: outboundText };
5304
6280
  if (attachments.length) body.attachments = attachments;
5305
6281
  appendWalleModelSelection(body, card);
5306
6282
  const entry = enqueueRemoteReply('wall_e.send_message', body);
@@ -5654,10 +6630,11 @@
5654
6630
  btn.addEventListener('click', () => setTab(btn.dataset.tab));
5655
6631
  });
5656
6632
  document.body.addEventListener('click', (event) => {
5657
- const open = event.target.closest('[data-open-session]');
5658
- if (open) openDetail(open.dataset.openSession);
6633
+ const open = event.target.closest('[data-open-session], [data-open-worktree-session]');
6634
+ if (open) openDetail(open.dataset.openSession || open.dataset.openWorktreeSession);
5659
6635
  });
5660
6636
  $('refresh-btn').addEventListener('click', refresh);
6637
+ $('app-reload-now-btn')?.addEventListener('click', () => reloadForInstalledUpdate('banner'));
5661
6638
  $('detail-back').addEventListener('click', closeDetail);
5662
6639
  $('detail-title')?.addEventListener('dblclick', startDetailTitleEdit);
5663
6640
  $('detail-title')?.addEventListener('pointerup', handleDetailTitlePointerUp);
@@ -5676,7 +6653,6 @@
5676
6653
  $('detail-send').addEventListener('click', handleSendButtonClick);
5677
6654
  $('detail-send').addEventListener('contextmenu', handleSendButtonContextMenu);
5678
6655
  $('detail-send').addEventListener('keydown', handleSendButtonKeydown);
5679
- $('detail-model')?.addEventListener('click', () => openModelPickerForActiveSession($('detail-input')));
5680
6656
  $('model-picker-close')?.addEventListener('click', closeModelPicker);
5681
6657
  $('model-picker-sheet')?.addEventListener('click', (event) => {
5682
6658
  if (event.target?.id === 'model-picker-sheet') {
@@ -5693,6 +6669,7 @@
5693
6669
  $('prompt-history-next')?.addEventListener('click', () => applyPromptHistoryNavigation('next'));
5694
6670
  $('prompt-history-edit')?.addEventListener('click', finishPromptHistoryEditing);
5695
6671
  $('prompt-history-close')?.addEventListener('click', closePromptHistoryStrip);
6672
+ $('send-model-option')?.addEventListener('click', () => openModelPickerForActiveSession($('detail-input')));
5696
6673
  $('send-copy-url-option')?.addEventListener('click', copySessionUrlFromMenu);
5697
6674
  $('send-esc-option')?.addEventListener('click', sendEscapeToSession);
5698
6675
  $('send-attach-image-option')?.addEventListener('click', () => triggerAttachmentInput('image'));
@@ -5748,6 +6725,19 @@
5748
6725
  updateMobileSkillPicker(event.target);
5749
6726
  });
5750
6727
  $('walle-content')?.addEventListener('click', (event) => {
6728
+ const healthAction = event.target.closest?.('[data-walle-health-action]');
6729
+ if (healthAction) {
6730
+ event.preventDefault();
6731
+ handleWalleHealthAction(
6732
+ healthAction.dataset.walleHealthAction,
6733
+ healthAction.dataset.walleHealthAlertId,
6734
+ );
6735
+ return;
6736
+ }
6737
+ if (event.target.closest?.('#walle-start-chat')) {
6738
+ startWalleChat().catch((err) => toast(err?.message || 'Could not start Wall-E chat.'));
6739
+ return;
6740
+ }
5751
6741
  const input = $('walle-input');
5752
6742
  if (event.target?.id === 'walle-input') updateMobileSkillPicker(event.target);
5753
6743
  if (event.target.closest?.('#walle-model')) {
@@ -5828,7 +6818,15 @@
5828
6818
  applySearchSuggestion(chip.dataset.searchSuggestion || '');
5829
6819
  });
5830
6820
  window.addEventListener('hashchange', handleDeepLink);
5831
- window.addEventListener('resize', updateMobileViewportInset);
6821
+ window.addEventListener('resize', () => {
6822
+ updateMobileViewportInset();
6823
+ syncDetailLayoutMode();
6824
+ });
6825
+ try {
6826
+ const detailSplitMedia = window.matchMedia && window.matchMedia(DETAIL_SPLIT_QUERY);
6827
+ if (detailSplitMedia?.addEventListener) detailSplitMedia.addEventListener('change', syncDetailLayoutMode);
6828
+ else detailSplitMedia?.addListener?.(syncDetailLayoutMode);
6829
+ } catch {}
5832
6830
  window.visualViewport?.addEventListener?.('resize', updateMobileViewportInset);
5833
6831
  window.visualViewport?.addEventListener?.('scroll', updateMobileViewportInset);
5834
6832
  window.addEventListener('online', () => {
@@ -5865,12 +6863,14 @@
5865
6863
  async function init() {
5866
6864
  loadThemePreference();
5867
6865
  updateMobileViewportInset();
6866
+ syncDetailLayoutMode();
5868
6867
  configureComposerInput();
5869
6868
  bindEvents();
5870
6869
  loadRemoteOutbox();
5871
6870
  restoreSessionSnapshot();
5872
6871
  handleDeepLink();
5873
6872
  render();
6873
+ initAppReloadChannel();
5874
6874
  connectWs().catch(() => {});
5875
6875
  const firstRefresh = refresh().catch(() => {});
5876
6876
  loadProjects().catch(() => {});
@@ -5891,6 +6891,9 @@
5891
6891
  normalizeMobileSkillItems,
5892
6892
  getComposerText: () => getComposerText(),
5893
6893
  recoverMobileConnection,
6894
+ handleServerHello,
6895
+ requestInstalledUpdateReload,
6896
+ isMobileReloadUnsafe,
5894
6897
  handleWsMessage: (msg) => onWsMessage({ data: JSON.stringify(msg) }),
5895
6898
  sessionSnapshot: () => state.sessions.map((item) => ({
5896
6899
  id: item.id,