create-walle 0.9.19 → 0.9.21

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 (31) hide show
  1. package/README.md +2 -2
  2. package/package.json +1 -1
  3. package/template/claude-task-manager/db.js +131 -0
  4. package/template/claude-task-manager/docs/microsoft-dev-tunnel-phone-access-design.md +58 -50
  5. package/template/claude-task-manager/docs/phone-access-design.md +23 -7
  6. package/template/claude-task-manager/docs/walle-session-model-preferences.md +119 -0
  7. package/template/claude-task-manager/lib/microsoft-dev-tunnel-setup.js +32 -48
  8. package/template/claude-task-manager/lib/remote-relay-protocol.js +5 -0
  9. package/template/claude-task-manager/lib/walle-external-actions.js +20 -3
  10. package/template/claude-task-manager/public/index.html +25 -0
  11. package/template/claude-task-manager/public/js/setup.js +16 -12
  12. package/template/claude-task-manager/public/js/walle-session.js +31 -3
  13. package/template/claude-task-manager/public/js/walle.js +93 -23
  14. package/template/claude-task-manager/public/m/app.css +417 -21
  15. package/template/claude-task-manager/public/m/app.js +831 -44
  16. package/template/claude-task-manager/public/m/claim.html +1 -1
  17. package/template/claude-task-manager/public/m/index.html +41 -7
  18. package/template/claude-task-manager/public/m/sw.js +1 -1
  19. package/template/claude-task-manager/server.js +377 -30
  20. package/template/claude-task-manager/workers/state-detectors/codex.js +18 -3
  21. package/template/package.json +1 -1
  22. package/template/wall-e/chat.js +32 -2
  23. package/template/wall-e/coding/stream-processor.js +36 -0
  24. package/template/wall-e/coding-orchestrator.js +45 -0
  25. package/template/wall-e/deploy.sh +1 -1
  26. package/template/wall-e/docs/external-action-controller.md +60 -2
  27. package/template/wall-e/external-action-controller.js +23 -1
  28. package/template/wall-e/external-action-gateway.js +163 -0
  29. package/template/wall-e/fly.toml +1 -0
  30. package/template/wall-e/tools/local-tools.js +122 -4
  31. package/template/website/index.html +2 -2
@@ -25,12 +25,14 @@
25
25
  remoteOutbox: [],
26
26
  remoteOutboxTimer: null,
27
27
  remoteOutboxDraining: false,
28
+ expandedTimelineTurnsBySession: new Map(),
28
29
  detail: {
29
30
  sessionId: null,
30
31
  messages: [],
31
32
  messageIndexByKey: new Map(),
32
33
  liveTail: null,
33
34
  walleActivity: null,
35
+ expandedMessageKeys: new Set(),
34
36
  viewMode: 'conversation',
35
37
  refreshTimer: null,
36
38
  refreshInFlight: null,
@@ -85,6 +87,16 @@
85
87
  touchStartX: null,
86
88
  touchStartY: null,
87
89
  },
90
+ modelPicker: {
91
+ open: false,
92
+ sessionId: '',
93
+ inputId: '',
94
+ loading: false,
95
+ error: '',
96
+ models: [],
97
+ loaded: false,
98
+ requestSeq: 0,
99
+ },
88
100
  push: {
89
101
  loaded: false,
90
102
  supported: false,
@@ -113,7 +125,7 @@
113
125
  };
114
126
 
115
127
  const $ = (id) => document.getElementById(id);
116
- const MOBILE_ASSET_VERSION = '20260519-copy-session-url';
128
+ const MOBILE_ASSET_VERSION = '20260519-walle-phone-model-picker';
117
129
  const THEME_STORAGE_KEY = 'ctm.theme.mode';
118
130
  const MOBILE_ATTACHMENT_MAX_BYTES = 10 * 1024 * 1024;
119
131
  const SEND_LONG_PRESS_MS = 520;
@@ -162,6 +174,7 @@
162
174
  attachments: 'walle-composer-attachments',
163
175
  clear: 'walle-clear',
164
176
  send: 'walle-send',
177
+ model: 'walle-model',
165
178
  picker: 'walle-skill-picker',
166
179
  }
167
180
  : {
@@ -171,6 +184,7 @@
171
184
  attachments: 'composer-attachments',
172
185
  clear: 'detail-clear',
173
186
  send: 'detail-send',
187
+ model: 'detail-model',
174
188
  picker: 'mobile-skill-picker',
175
189
  };
176
190
  return $(ids[kind]);
@@ -219,23 +233,60 @@
219
233
  return !!(input && input.isContentEditable);
220
234
  }
221
235
 
222
- function configureComposerInput(input = $('detail-input')) {
236
+ function composerCursorToken(text, cursor) {
237
+ const before = String(text || '').slice(0, Math.max(0, cursor || 0));
238
+ const match = before.match(/(?:^|\s)(\S*)$/);
239
+ return match ? match[1] : '';
240
+ }
241
+
242
+ function composerCurrentLine(text, cursor) {
243
+ const before = String(text || '').slice(0, Math.max(0, cursor || 0));
244
+ return before.slice(before.lastIndexOf('\n') + 1);
245
+ }
246
+
247
+ function composerShouldUseLiteralTextAssist(input) {
248
+ if (!input) return false;
249
+ const text = getComposerText(input);
250
+ const selection = getComposerSelection(input);
251
+ if (selection.start !== selection.end) return false;
252
+ const triggerInfo = findSkillTriggerInText(text, selection.start, selection.end);
253
+ if (triggerInfo) return true;
254
+ const token = composerCursorToken(text, selection.start);
255
+ const line = composerCurrentLine(text, selection.start);
256
+ if (/^[$/][^\s]*$/.test(token)) return true;
257
+ if (/^@[\w./-]*$/.test(token)) return true;
258
+ if (/^--?[\w-]*$/.test(token)) return true;
259
+ if (/^(https?:\/\/|[~./][\w./-]*|\w:[\\/])/.test(token)) return true;
260
+ if (/^[\w.-]+\.[A-Za-z0-9_-]{1,8}$/.test(token)) return true;
261
+ if (/`[^`]*$/.test(line) || /^\s*```/.test(line)) return true;
262
+ if (/^\s*(git|npm|npx|pnpm|yarn|node|uv|python3?|pip|cargo|go|docker|kubectl|ssh|scp|rsync|claude|codex|rg|grep|sed|awk|curl)\b/.test(line)) return true;
263
+ return false;
264
+ }
265
+
266
+ function applyComposerTextAssistMode(input = $('detail-input')) {
223
267
  const form = composerElement('form', input);
224
268
  if (form) {
225
269
  form.setAttribute('autocomplete', 'off');
226
- form.setAttribute('autocorrect', 'off');
270
+ form.setAttribute('autocorrect', 'on');
227
271
  form.setAttribute('autocapitalize', 'sentences');
228
- form.setAttribute('spellcheck', 'false');
229
- form.spellcheck = false;
272
+ form.setAttribute('spellcheck', 'true');
273
+ form.spellcheck = true;
230
274
  }
231
275
  if (!input) return;
276
+ const literal = composerShouldUseLiteralTextAssist(input);
232
277
  input.setAttribute('autocomplete', 'off');
233
- input.setAttribute('autocorrect', 'off');
234
- input.setAttribute('autocapitalize', 'sentences');
278
+ input.setAttribute('inputmode', 'text');
235
279
  input.setAttribute('enterkeyhint', 'send');
236
280
  input.setAttribute('data-form-type', 'other');
237
- input.setAttribute('spellcheck', 'false');
238
- input.spellcheck = false;
281
+ input.setAttribute('data-text-assist', literal ? 'literal' : 'natural');
282
+ input.setAttribute('autocorrect', literal ? 'off' : 'on');
283
+ input.setAttribute('autocapitalize', literal ? 'none' : 'sentences');
284
+ input.setAttribute('spellcheck', literal ? 'false' : 'true');
285
+ input.spellcheck = !literal;
286
+ }
287
+
288
+ function configureComposerInput(input = $('detail-input')) {
289
+ applyComposerTextAssistMode(input);
239
290
  }
240
291
 
241
292
  function composerEditorPlainText(input) {
@@ -335,6 +386,7 @@
335
386
  const cursor = Number.isFinite(opts.cursor) ? opts.cursor : value.length;
336
387
  setComposerSelection(input, cursor, cursor);
337
388
  }
389
+ applyComposerTextAssistMode(input);
338
390
  }
339
391
 
340
392
  function normalizeComposerEditor(input = $('detail-input')) {
@@ -367,6 +419,16 @@
367
419
  dispatchComposerInput(input, 'insertText', insertion);
368
420
  }
369
421
 
422
+ function updateMobileViewportInset() {
423
+ const vv = window.visualViewport;
424
+ let offset = 0;
425
+ if (vv && Number.isFinite(vv.height)) {
426
+ const layoutHeight = window.innerHeight || document.documentElement.clientHeight || vv.height;
427
+ offset = Math.max(0, Math.round(layoutHeight - vv.height - vv.offsetTop));
428
+ }
429
+ document.documentElement.style.setProperty('--mobile-keyboard-offset', `${offset}px`);
430
+ }
431
+
370
432
  function lockDetailBackgroundScroll() {
371
433
  if (state.detailScrollLock.locked) return;
372
434
  const scrollY = window.scrollY || document.documentElement.scrollTop || 0;
@@ -550,6 +612,40 @@
550
612
  return AI_PROVIDER_LABELS[key] || (key ? key.replace(/(^|-)([a-z])/g, (_, sep, ch) => (sep ? ' ' : '') + ch.toUpperCase()) : '');
551
613
  }
552
614
 
615
+ function modelDisplayLabel(model) {
616
+ return String(model || '')
617
+ .replace(/@\d{6,8}\b/g, '')
618
+ .replace(/^[a-z][a-z0-9_-]+[:/]/i, '')
619
+ .replace(/\s+/g, ' ')
620
+ .trim();
621
+ }
622
+
623
+ function modelButtonLabel(card) {
624
+ const provider = providerKey(card?.walle_model_provider || card?.model_provider || card?.modelProvider || card?.providerType || '');
625
+ const model = modelDisplayLabel(card?.walle_model_id || card?.model_id || card?.model || '');
626
+ const providerText = providerLabel(provider);
627
+ if (providerText && model) return `${providerText} · ${model}`;
628
+ if (model) return model;
629
+ return 'Auto model';
630
+ }
631
+
632
+ function modelButtonShortLabel(card) {
633
+ const provider = providerKey(card?.walle_model_provider || card?.model_provider || card?.modelProvider || card?.providerType || '');
634
+ if (provider === 'anthropic') return 'Claude';
635
+ if (provider === 'openai') return 'GPT';
636
+ if (provider === 'google') return 'Gemini';
637
+ if (provider === 'deepseek') return 'DeepSeek';
638
+ if (provider === 'moonshot') return 'Kimi';
639
+ if (provider === 'ollama') return 'Ollama';
640
+ if (provider === 'mlx') return 'MLX';
641
+ const model = modelDisplayLabel(card?.walle_model_id || card?.model_id || card?.model || '');
642
+ if (/claude|opus|sonnet|haiku/i.test(model)) return 'Claude';
643
+ if (/gpt|o[1-9]|codex/i.test(model)) return 'GPT';
644
+ if (/gemini/i.test(model)) return 'Gemini';
645
+ if (/deepseek/i.test(model)) return 'DeepSeek';
646
+ return 'Model';
647
+ }
648
+
553
649
  function providerBadge(card) {
554
650
  const key = sessionAiProviderKey(card);
555
651
  if (!key) return '';
@@ -1194,7 +1290,7 @@
1194
1290
  <section id="walle-messages" class="detail-messages walle-chat-messages" aria-live="polite">
1195
1291
  <div class="empty-state">Loading Wall-E conversation...</div>
1196
1292
  </section>
1197
- <form id="walle-chat-composer" class="composer walle-chat-composer" autocomplete="off" autocorrect="off" autocapitalize="sentences" spellcheck="false">
1293
+ <form id="walle-chat-composer" class="composer walle-chat-composer" autocomplete="off" autocorrect="on" autocapitalize="sentences" spellcheck="true">
1198
1294
  <div class="composer-field">
1199
1295
  <span id="walle-input-label" class="sr-only">Message Wall-E</span>
1200
1296
  <div id="walle-composer-attachments" class="composer-attachments" aria-label="Attachments" hidden></div>
@@ -1202,11 +1298,15 @@
1202
1298
  <button id="walle-attach-image" class="walle-tool-btn" type="button" aria-label="Attach image" title="Attach image" data-walle-attach="image"><span aria-hidden="true">▧</span></button>
1203
1299
  <button id="walle-attach-file" class="walle-tool-btn" type="button" aria-label="Attach file" title="Attach file" data-walle-attach="file"><span aria-hidden="true">▤</span></button>
1204
1300
  </div>
1205
- <div id="walle-input" class="composer-editor" contenteditable="true" role="textbox" aria-multiline="true" aria-labelledby="walle-input-label" aria-describedby="walle-composer-status" data-placeholder="Message Wall-E" data-skill-agent="walle" data-skill-mode="walle-mobile-composer" inputmode="text" enterkeyhint="send" autocomplete="off" autocorrect="off" autocapitalize="sentences" spellcheck="false" data-form-type="other"></div>
1301
+ <div id="walle-input" class="composer-editor" contenteditable="true" role="textbox" aria-multiline="true" aria-labelledby="walle-input-label" aria-describedby="walle-composer-status" data-placeholder="Message Wall-E" data-skill-agent="walle" data-skill-mode="walle-mobile-composer" inputmode="text" enterkeyhint="send" autocomplete="off" autocorrect="on" autocapitalize="sentences" spellcheck="true" data-form-type="other" data-text-assist="natural"></div>
1206
1302
  <button id="walle-clear" class="composer-clear" type="button" aria-label="Clear Wall-E message" title="Clear message" hidden><span aria-hidden="true">&times;</span></button>
1207
1303
  <div id="walle-skill-picker" class="mobile-skill-picker" role="listbox" aria-label="Skill suggestions" hidden></div>
1208
1304
  <div id="walle-composer-status" class="composer-status" aria-live="polite"></div>
1209
1305
  </div>
1306
+ <button id="walle-model" class="model-picker-btn" type="button" aria-label="Choose model for this Wall-E session" aria-haspopup="dialog" aria-expanded="false" hidden>
1307
+ <span class="model-picker-btn-kicker">Model</span>
1308
+ <span id="walle-model-label" class="model-picker-btn-label">Auto</span>
1309
+ </button>
1210
1310
  <button id="walle-send" class="send-btn" type="submit" aria-label="Send to Wall-E"><span aria-hidden="true">➤</span></button>
1211
1311
  </form>
1212
1312
  </div>
@@ -1541,7 +1641,8 @@
1541
1641
  if (!card || !card.id) return null;
1542
1642
  const copy = {};
1543
1643
  for (const key of [
1544
- 'id', 'sessionId', 'title', 'agent', 'provider', 'model', 'model_id',
1644
+ 'id', 'sessionId', 'title', 'aiTitle', 'displayTitle', 'userRenamed',
1645
+ 'agent', 'provider', 'model', 'model_id',
1545
1646
  'modelProvider', 'model_provider', 'providerType', 'llmProvider',
1546
1647
  'walle_model_id', 'walle_model_provider', 'runtime_model_id', 'runtime_model_provider',
1547
1648
  'project', 'cwd', 'projectPath', 'branch', 'lane', 'status',
@@ -1631,12 +1732,29 @@
1631
1732
  return false;
1632
1733
  }
1633
1734
 
1735
+ const mergeLocalUserRename = (incoming, existing = null) => {
1736
+ if (!incoming || typeof incoming !== 'object') return incoming;
1737
+ const local = existing || findSession(incoming.id || incoming.sessionId);
1738
+ if (!local?.userRenamed) return incoming;
1739
+ const localTitle = detailTitleText(local);
1740
+ if (!localTitle) return incoming;
1741
+ const incomingTitle = detailTitleText(incoming);
1742
+ if (incoming.userRenamed && incomingTitle && incomingTitle !== localTitle) return incoming;
1743
+ return {
1744
+ ...incoming,
1745
+ title: localTitle,
1746
+ aiTitle: localTitle,
1747
+ displayTitle: localTitle,
1748
+ userRenamed: true,
1749
+ };
1750
+ };
1751
+
1634
1752
  if (standup.incremental && Array.isArray(standup.sessionOrder) && Array.isArray(standup.sessions)) {
1635
1753
  const byId = new Map(state.sessions.map((session) => [String(session.id || ''), session]).filter(([id]) => id));
1636
1754
  for (const id of standup.removedSessionIds || []) byId.delete(String(id));
1637
1755
  for (const session of standup.sessions) {
1638
1756
  const id = String(session?.id || '');
1639
- if (id) byId.set(id, session);
1757
+ if (id) byId.set(id, mergeLocalUserRename(session, byId.get(id)));
1640
1758
  }
1641
1759
  state.sessions = standup.sessionOrder.map((id) => byId.get(String(id))).filter(Boolean);
1642
1760
  state.lanes = Array.isArray(standup.lanes) ? standup.lanes : state.lanes;
@@ -1644,7 +1762,9 @@
1644
1762
  return true;
1645
1763
  }
1646
1764
 
1647
- state.sessions = Array.isArray(standup.sessions) ? standup.sessions : [];
1765
+ state.sessions = Array.isArray(standup.sessions)
1766
+ ? standup.sessions.map((session) => mergeLocalUserRename(session))
1767
+ : [];
1648
1768
  state.lanes = Array.isArray(standup.lanes) ? standup.lanes : [];
1649
1769
  persistSessionSnapshot();
1650
1770
  return true;
@@ -1833,7 +1953,6 @@
1833
1953
  $('detail-status').className = `state-chip ${card.lane || card.status || 'idle'}`;
1834
1954
  renderDetailAlert(card);
1835
1955
  renderDetailControls(card);
1836
- if (!isTerminalControlSession(card)) hideSendMenu();
1837
1956
  updateComposerState();
1838
1957
  }
1839
1958
 
@@ -2474,7 +2593,23 @@
2474
2593
  const id = String(raw.id || remoteMessageId()).slice(0, 160);
2475
2594
  const now = Date.now();
2476
2595
  const normalizedBody = { session_id: sessionId, text };
2477
- if (type === 'wall_e.send_message' && cleanAttachments.length) normalizedBody.attachments = cleanAttachments;
2596
+ if (type === 'wall_e.send_message') {
2597
+ if (cleanAttachments.length) normalizedBody.attachments = cleanAttachments;
2598
+ const modelId = String(body.model_id || body.model || raw.model_id || raw.model || '').trim();
2599
+ const modelProvider = providerKey(body.model_provider || body.provider || raw.model_provider || raw.provider || '');
2600
+ const registryId = String(body.model_registry_id || body.modelRegistryId || raw.model_registry_id || raw.modelRegistryId || '').trim();
2601
+ const providerId = String(body.model_provider_id || body.modelProviderId || body.provider_id || raw.model_provider_id || raw.modelProviderId || raw.provider_id || '').trim();
2602
+ if (modelId) {
2603
+ normalizedBody.model_id = modelId;
2604
+ normalizedBody.model = modelId;
2605
+ normalizedBody.model_provider = modelProvider;
2606
+ normalizedBody.provider = modelProvider;
2607
+ normalizedBody.model_registry_id = registryId;
2608
+ normalizedBody.model_provider_id = providerId;
2609
+ normalizedBody.modelPinned = true;
2610
+ normalizedBody.allowProviderFallback = false;
2611
+ }
2612
+ }
2478
2613
  return {
2479
2614
  id,
2480
2615
  type,
@@ -2719,6 +2854,7 @@
2719
2854
  updateRemoteOutboxStatus();
2720
2855
  $('detail-view').classList.add('active');
2721
2856
  $('detail-view').setAttribute('aria-hidden', 'false');
2857
+ updateComposerState();
2722
2858
  $('detail-messages').innerHTML = card._placeholder
2723
2859
  ? '<div class="empty-state">Loading session context. You can still type and send a prompt.</div>'
2724
2860
  : '<div class="empty-state">Connecting to live stream...</div>';
@@ -2736,6 +2872,7 @@
2736
2872
  setComposerStatus('', '');
2737
2873
  hideMobileSkillPicker();
2738
2874
  hideSendMenu();
2875
+ closeModelPicker();
2739
2876
  clearComposerAttachments();
2740
2877
  state.composerSending = false;
2741
2878
  state.controlSending = false;
@@ -2770,6 +2907,13 @@
2770
2907
  return /walle|wall-e/i.test(kind);
2771
2908
  }
2772
2909
 
2910
+ function isWalleCodingSession(card) {
2911
+ if (!card || !isWalleSession(card)) return false;
2912
+ const caps = card.agentCapabilities || card.capabilities || {};
2913
+ if (caps.structuredTranscript === false && caps.terminalInput === false && caps.review === false) return false;
2914
+ return true;
2915
+ }
2916
+
2773
2917
  function terminalControlCapability(card) {
2774
2918
  const caps = card?.capabilities || card?.agentCapabilities || {};
2775
2919
  if (caps.terminalControl === true || caps.terminalInput === true || caps.canSendEscape === true) return true;
@@ -2860,6 +3004,7 @@
2860
3004
  messageIndexByKey: new Map(),
2861
3005
  liveTail: null,
2862
3006
  walleActivity: null,
3007
+ expandedMessageKeys: new Set(),
2863
3008
  viewMode: 'conversation',
2864
3009
  refreshTimer: null,
2865
3010
  refreshInFlight: null,
@@ -2867,6 +3012,31 @@
2867
3012
  updateDetailTimelineModeButton();
2868
3013
  }
2869
3014
 
3015
+ function activeTimelineExpansionSessionId() {
3016
+ return String(state.detail?.sessionId || state.activeSession?.id || '');
3017
+ }
3018
+
3019
+ function timelineTurnExpansionSet(sessionId = activeTimelineExpansionSessionId()) {
3020
+ if (!sessionId) return null;
3021
+ if (!state.expandedTimelineTurnsBySession.has(sessionId)) {
3022
+ state.expandedTimelineTurnsBySession.set(sessionId, new Set());
3023
+ }
3024
+ return state.expandedTimelineTurnsBySession.get(sessionId);
3025
+ }
3026
+
3027
+ function isTimelineTurnExpanded(key) {
3028
+ if (!key) return false;
3029
+ return !!timelineTurnExpansionSet()?.has(key);
3030
+ }
3031
+
3032
+ function rememberTimelineTurnExpanded(key, expanded) {
3033
+ if (!key) return;
3034
+ const set = timelineTurnExpansionSet();
3035
+ if (!set) return;
3036
+ if (expanded) set.add(key);
3037
+ else set.delete(key);
3038
+ }
3039
+
2870
3040
  function renderMessages(messages, opts = {}) {
2871
3041
  const pendingLiveMessages = state.detail.messages.slice();
2872
3042
  state.detail.messages = [];
@@ -2898,6 +3068,22 @@
2898
3068
  }
2899
3069
 
2900
3070
  function handleWalleSessionMessage(msg) {
3071
+ if (msg.type === 'walle-model') {
3072
+ const provider = providerKey(msg.model_provider || msg.provider || '');
3073
+ const item = msg.model_id
3074
+ ? {
3075
+ id: String(msg.model_registry_id || msg.modelRegistryId || '').trim(),
3076
+ modelId: String(msg.model_id || '').trim(),
3077
+ label: modelDisplayLabel(msg.model_id),
3078
+ provider,
3079
+ providerId: String(msg.model_provider_id || msg.modelProviderId || '').trim(),
3080
+ providerLabel: providerLabel(provider) || 'Provider',
3081
+ }
3082
+ : null;
3083
+ applyWalleModelLocally(msg.id || msg.sessionId, item);
3084
+ renderModelPickerSheet();
3085
+ return;
3086
+ }
2901
3087
  if (msg.type === 'walle-history') {
2902
3088
  state.detail.walleActivity = null;
2903
3089
  renderMessages(Array.isArray(msg.messages) ? msg.messages : []);
@@ -3127,7 +3313,9 @@
3127
3313
  }
3128
3314
 
3129
3315
  const TERMINAL_PROMPT_PLACEHOLDERS = [
3316
+ // Codex starter suggestions look like prompt rows in PTY snapshots, but are not submitted user text.
3130
3317
  /^explain this codebase$/i,
3318
+ /^find and fix a bug in @filename$/i,
3131
3319
  ];
3132
3320
 
3133
3321
  function isTerminalPromptPlaceholder(text) {
@@ -3159,6 +3347,24 @@
3159
3347
  return false;
3160
3348
  }
3161
3349
 
3350
+ function stripTerminalEdgeDecorations(line) {
3351
+ return stripAnsi(String(line || ''))
3352
+ .replace(/\u00a0/g, ' ')
3353
+ .replace(/^[\s|│┃┆┊╎╏·•●◦▪*─━═╌╍┄┅⎯—_=~-]+/g, '')
3354
+ .replace(/[\s|│┃┆┊╎╏·•●◦▪*─━═╌╍┄┅⎯—_=~-]+$/g, '')
3355
+ .trim();
3356
+ }
3357
+
3358
+ function isTerminalCompletionFooterLine(line) {
3359
+ const text = stripTerminalEdgeDecorations(line);
3360
+ if (!text) return false;
3361
+ return /^Worked for\s+\d+(?:\.\d+)?\s*(?:ms|msec|milliseconds?|s|sec|secs|seconds?|m|min|mins|minutes?|h|hr|hrs|hours?)(?:\s+\d+(?:\.\d+)?\s*(?:ms|msec|milliseconds?|s|sec|secs|seconds?|m|min|mins|minutes?|h|hr|hrs|hours?))*$/i.test(text);
3362
+ }
3363
+
3364
+ function isTerminalNonContentLine(line) {
3365
+ return isTerminalDividerLine(line) || isTerminalCompletionFooterLine(line);
3366
+ }
3367
+
3162
3368
  function isTerminalStatusLine(line) {
3163
3369
  const text = String(line || '').trim();
3164
3370
  if (!text) return false;
@@ -3203,7 +3409,7 @@
3203
3409
  }
3204
3410
 
3205
3411
  function cleanLiveTerminalRows(rows) {
3206
- return (rows || []).filter((row) => !isTerminalDividerLine(row?.text));
3412
+ return (rows || []).filter((row) => !isTerminalNonContentLine(row?.text));
3207
3413
  }
3208
3414
 
3209
3415
  function terminalRowsText(rows) {
@@ -3231,9 +3437,17 @@
3231
3437
  if (!text) return true;
3232
3438
  if (terminalPromptLineText(text) != null) return false;
3233
3439
  if (isTerminalChromeLine(text) || isTerminalStatusLine(text) || isTerminalDividerLine(text)) return false;
3440
+ if (isTerminalAgentOutputLine(text)) return false;
3234
3441
  return true;
3235
3442
  }
3236
3443
 
3444
+ function isTerminalAgentOutputLine(line) {
3445
+ const text = String(line || '').trim();
3446
+ if (!text) return false;
3447
+ if (/^[⚠✓✔✗✘]\s*/.test(text)) return true;
3448
+ return /^(?:Error|Warning|Falling back|stream disconnected|Failed|Retrying)\b/i.test(text);
3449
+ }
3450
+
3237
3451
  function promptContinuationFallback(promptText, rows, promptIndex) {
3238
3452
  const text = String(promptText || '').trim();
3239
3453
  const nextRow = rows[promptIndex + 1];
@@ -3242,6 +3456,7 @@
3242
3456
  if (!isPromptContinuationRow(nextRow)) return false;
3243
3457
  const statusIndex = findBusyStatusBeforeNextPrompt(rows, promptIndex);
3244
3458
  if (statusIndex > promptIndex + 1 && text.length >= 64) return true;
3459
+ if (/^\$[\w-]+\b/.test(text) && text.length >= 40) return true;
3245
3460
  if (text.length >= 82) return true;
3246
3461
  if (/[([{,;:]$/.test(text)) return true;
3247
3462
  return /\b(?:a|an|and|as|at|by|for|from|in|into|of|on|or|the|to|with|without)$/i.test(text);
@@ -3341,6 +3556,101 @@
3341
3556
  `;
3342
3557
  }
3343
3558
 
3559
+ function renderLiveTerminalResponse(turn, tail, index, total) {
3560
+ const agent = state.activeSession?.agent || state.activeSession?.provider || 'Agent';
3561
+ const time = index === total - 1 ? messageTime(tail) : '';
3562
+ const body = window.MR?.formatMsgText
3563
+ ? window.MR.formatMsgText(String(turn?.text || ''))
3564
+ : esc(turn?.text || '').replace(/\n/g, '<br>');
3565
+ return `
3566
+ <div class="review-msg assistant live-terminal-response">
3567
+ <div class="msg-header">
3568
+ <span class="msg-role">${esc(agent)} <span class="live-pill">Live</span></span>
3569
+ ${time ? `<span class="msg-time">${esc(time)}</span>` : ''}
3570
+ </div>
3571
+ <div class="msg-text live-tail-text">${body}</div>
3572
+ </div>
3573
+ `;
3574
+ }
3575
+
3576
+ function renderLiveTerminalPromptTurn(promptTurn, responseTurns, tail, index) {
3577
+ const key = mobileLiveTurnKey(tail, promptTurn?.text || '', index);
3578
+ const expanded = isTimelineTurnExpanded(key);
3579
+ 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>';
3583
+ return `
3584
+ <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
+ <div class="prompt-turn-header" role="button" tabindex="0" aria-expanded="${expanded ? 'true' : 'false'}">
3586
+ <span class="prompt-turn-chevron">▶</span>
3587
+ <div class="prompt-turn-head-main">
3588
+ <div class="review-msg user key-msg prompt-turn-prompt">
3589
+ <div class="msg-header">
3590
+ <span class="msg-role">You <span class="live-pill">Live</span></span>
3591
+ </div>
3592
+ <div class="msg-text live-terminal-prompt-text">${promptBody}</div>
3593
+ </div>
3594
+ </div>
3595
+ <div class="prompt-turn-meta">
3596
+ <span class="prompt-turn-badge">live</span>
3597
+ <span class="prompt-turn-badge">${responseTurns.length || 'no'} repl${responseTurns.length === 1 ? 'y' : 'ies'}</span>
3598
+ </div>
3599
+ </div>
3600
+ <div class="prompt-turn-response">
3601
+ ${responseHtml}
3602
+ </div>
3603
+ </section>
3604
+ `;
3605
+ }
3606
+
3607
+ function renderLiveTerminalStandaloneTurn(turn, tail, index) {
3608
+ const key = mobileLiveTurnKey(tail, turn?.text || '', index);
3609
+ const expanded = isTimelineTurnExpanded(key);
3610
+ const agent = state.activeSession?.agent || state.activeSession?.provider || 'Agent';
3611
+ const preview = compactPreview(turn?.text || '');
3612
+ return `
3613
+ <section class="prompt-turn live-prompt-turn live-standalone-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="response">
3614
+ <div class="prompt-turn-header" role="button" tabindex="0" aria-expanded="${expanded ? 'true' : 'false'}">
3615
+ <span class="prompt-turn-chevron">▶</span>
3616
+ <div class="prompt-turn-head-main">
3617
+ <div class="prompt-turn-live-title">
3618
+ <span>Live terminal</span>
3619
+ <span class="terminal-tail-preview">${esc(preview)}</span>
3620
+ </div>
3621
+ </div>
3622
+ <div class="prompt-turn-meta">
3623
+ <span class="prompt-turn-badge">${esc(agent)}</span>
3624
+ <span class="prompt-turn-badge">live</span>
3625
+ </div>
3626
+ </div>
3627
+ <div class="prompt-turn-response">
3628
+ ${renderLiveTerminalResponse(turn, tail, 0, 1)}
3629
+ </div>
3630
+ </section>
3631
+ `;
3632
+ }
3633
+
3634
+ function renderLiveTerminalConversationTurns(turns, tail) {
3635
+ const rows = [];
3636
+ for (let index = 0; index < turns.length; index += 1) {
3637
+ const turn = turns[index];
3638
+ if (turn?.role !== 'user') {
3639
+ rows.push(renderLiveTerminalStandaloneTurn(turn, tail, index));
3640
+ continue;
3641
+ }
3642
+ const responses = [];
3643
+ let cursor = index + 1;
3644
+ while (cursor < turns.length && turns[cursor]?.role !== 'user') {
3645
+ responses.push(turns[cursor]);
3646
+ cursor += 1;
3647
+ }
3648
+ rows.push(renderLiveTerminalPromptTurn(turn, responses, tail, index));
3649
+ index = cursor - 1;
3650
+ }
3651
+ return rows.join('');
3652
+ }
3653
+
3344
3654
  function hasTimelineContent(box) {
3345
3655
  return !!(box && box.querySelector('.prompt-turn, .review-msg, .thought-group, .message-row, .live-tail-row'));
3346
3656
  }
@@ -3358,16 +3668,16 @@
3358
3668
  const turns = mode === 'conversation' ? parseLiveTerminalTurns(cleanRows) : null;
3359
3669
  if (turns) {
3360
3670
  if (!turns.length) return '';
3361
- return turns.map((turn, index) => renderLiveTerminalTurn(turn, tail, index, turns.length)).join('');
3671
+ return renderLiveTerminalConversationTurns(turns, tail);
3362
3672
  }
3363
- const hasSemanticMessages = state.detail.messages.some((item) => messageText(item).trim());
3364
- const openAttr = mode === 'normal' || !hasSemanticMessages ? ' open' : '';
3673
+ const key = mobileLiveTurnKey(tail, cleanText, 0);
3674
+ const openAttr = mode === 'normal' || isTimelineTurnExpanded(key) ? ' open' : '';
3365
3675
  const preview = compactPreview(cleanText);
3366
3676
  const formatted = window.MR?.formatMsgText
3367
3677
  ? window.MR.formatMsgText(cleanText)
3368
3678
  : esc(cleanText).replace(/\n/g, '<br>');
3369
3679
  return `
3370
- <details class="terminal-tail-panel live-tail-row"${openAttr} data-live-tail="true">
3680
+ <details class="terminal-tail-panel live-tail-row"${openAttr} data-live-tail="true" data-mobile-turn-key="${esc(key)}">
3371
3681
  <summary class="terminal-tail-summary">
3372
3682
  <span class="terminal-tail-main">
3373
3683
  <span class="terminal-tail-kicker">Live terminal</span>
@@ -3439,14 +3749,34 @@
3439
3749
  };
3440
3750
  }
3441
3751
 
3752
+ function mobilePromptTurnKey(turn, idx) {
3753
+ if (!turn || typeof turn !== 'object') return `turn:${idx}`;
3754
+ if (turn.type === 'setup') return 'turn:setup';
3755
+ const prompt = turn.prompt?.m || turn.prompt || null;
3756
+ const stable = prompt && timelineMessageKey(prompt);
3757
+ if (stable) return `turn:${stable}`;
3758
+ const text = normalizeCompareText(prompt?.text || '').slice(0, 180);
3759
+ return `turn:${idx}:${text}`;
3760
+ }
3761
+
3762
+ function mobileLiveTurnKey(tail, text, index = 0) {
3763
+ const sessionId = activeTimelineExpansionSessionId();
3764
+ const normalized = normalizeCompareText(text).slice(0, 180);
3765
+ return `live:${sessionId}:${index}:${normalized || tail?.reason || 'terminal'}`;
3766
+ }
3767
+
3442
3768
  function renderMobileConversationTurns(messages) {
3443
3769
  const normalized = (messages || []).map(normalizeMobileReviewMessage).filter(Boolean);
3444
3770
  if (!normalized.length) return [];
3445
- if (window.MR?.renderReviewTurns) {
3446
- const hasPromptTurn = normalized.some((item) => item.role === 'user');
3447
- const html = window.MR.renderReviewTurns(normalized, {
3448
- expanded: (turn) => turn?.type !== 'setup' || !hasPromptTurn,
3449
- });
3771
+ if (window.MR?.groupMessagesIntoTurns && window.MR?.renderReviewTurn) {
3772
+ const html = window.MR.groupMessagesIntoTurns(normalized)
3773
+ .map((turn, idx) => window.MR.renderReviewTurn(turn, idx, {
3774
+ expanded: () => isTimelineTurnExpanded(mobilePromptTurnKey(turn, idx)),
3775
+ }))
3776
+ .join('');
3777
+ if (String(html || '').trim()) return [html];
3778
+ } else if (window.MR?.renderReviewTurns) {
3779
+ const html = window.MR.renderReviewTurns(normalized, { expanded: false });
3450
3780
  if (String(html || '').trim()) return [html];
3451
3781
  }
3452
3782
  return messages.map(renderMobileMessage).filter(Boolean);
@@ -3495,10 +3825,16 @@
3495
3825
  box.querySelectorAll('.prompt-turn-header').forEach((header) => {
3496
3826
  const turn = header.closest('.prompt-turn');
3497
3827
  if (!turn) return;
3828
+ const setExpanded = (next) => {
3829
+ window.MR.setPromptTurnExpanded(turn, next);
3830
+ syncMobilePromptTurnAction(turn, next);
3831
+ rememberTimelineTurnExpanded(turn.dataset.mobileTurnKey || turn.dataset.turnId || '', next);
3832
+ };
3498
3833
  const toggle = (ev) => {
3499
3834
  if (ev?.target?.closest?.('a,button,input,textarea,select')) return;
3500
3835
  if (window.MR?.shouldKeepPromptTextSelection?.(ev, header)) return;
3501
- window.MR.setPromptTurnExpanded(turn, !turn.classList.contains('expanded'));
3836
+ const next = !turn.classList.contains('expanded');
3837
+ setExpanded(next);
3502
3838
  };
3503
3839
  header.addEventListener('click', toggle);
3504
3840
  header.addEventListener('keydown', (ev) => {
@@ -3506,6 +3842,48 @@
3506
3842
  ev.preventDefault();
3507
3843
  toggle(ev);
3508
3844
  });
3845
+ const action = turn.querySelector('[data-prompt-turn-action="toggle"]');
3846
+ if (action) {
3847
+ action.addEventListener('click', (ev) => {
3848
+ ev.preventDefault();
3849
+ ev.stopPropagation();
3850
+ setExpanded(!turn.classList.contains('expanded'));
3851
+ });
3852
+ }
3853
+ });
3854
+ }
3855
+
3856
+ function syncMobilePromptTurnAction(turn, expanded = turn?.classList?.contains('expanded')) {
3857
+ const button = turn?.querySelector?.('[data-prompt-turn-action="toggle"]');
3858
+ if (!button) return;
3859
+ const isExpanded = !!expanded;
3860
+ button.textContent = isExpanded ? 'Hide details' : 'Full prompt';
3861
+ button.setAttribute('aria-expanded', isExpanded ? 'true' : 'false');
3862
+ button.setAttribute('aria-label', isExpanded ? 'Hide full prompt details' : 'Show full prompt');
3863
+ }
3864
+
3865
+ function ensureMobilePromptTurnAction(turn) {
3866
+ if (!turn || turn.classList?.contains('setup-turn')) return;
3867
+ const meta = turn.querySelector?.('.prompt-turn-meta');
3868
+ const prompt = turn.querySelector?.('.prompt-turn-prompt .msg-text');
3869
+ if (!meta || !prompt) return;
3870
+ let button = meta.querySelector('[data-prompt-turn-action="toggle"]');
3871
+ if (!button) {
3872
+ button = document.createElement('button');
3873
+ button.className = 'prompt-turn-action';
3874
+ button.type = 'button';
3875
+ button.dataset.promptTurnAction = 'toggle';
3876
+ meta.prepend(button);
3877
+ }
3878
+ syncMobilePromptTurnAction(turn);
3879
+ }
3880
+
3881
+ function wireMobileLiveDetails(box) {
3882
+ if (!box) return;
3883
+ box.querySelectorAll('details.terminal-tail-panel[data-mobile-turn-key]').forEach((details) => {
3884
+ details.addEventListener('toggle', () => {
3885
+ rememberTimelineTurnExpanded(details.dataset.mobileTurnKey || '', details.open);
3886
+ });
3509
3887
  });
3510
3888
  }
3511
3889
 
@@ -3516,15 +3894,56 @@
3516
3894
  textEl.classList.toggle('collapsed', !willExpand);
3517
3895
  button.textContent = willExpand ? 'Show less' : 'Show more';
3518
3896
  button.setAttribute('aria-expanded', willExpand ? 'true' : 'false');
3897
+ const key = mobileMessageKeyForText(textEl);
3898
+ if (key) {
3899
+ if (willExpand) state.detail.expandedMessageKeys.add(key);
3900
+ else state.detail.expandedMessageKeys.delete(key);
3901
+ }
3519
3902
  return true;
3520
3903
  }
3521
3904
 
3905
+ function mobileMessageKeyForText(textEl) {
3906
+ if (!textEl) return '';
3907
+ const direct = textEl.dataset?.mobileMessageKey || '';
3908
+ if (direct) return direct;
3909
+ const host = textEl.closest?.('[data-mobile-message-key]');
3910
+ return host?.dataset?.mobileMessageKey || '';
3911
+ }
3912
+
3913
+ function annotateMobileMessageKeys(box, messages, mode) {
3914
+ if (!box) return;
3915
+ const source = mode === 'conversation'
3916
+ ? (messages || []).map(normalizeMobileReviewMessage).filter(Boolean)
3917
+ : (messages || []);
3918
+ box.querySelectorAll('.msg-text[data-msg-idx]').forEach((textEl) => {
3919
+ const idx = Number(textEl.dataset.msgIdx);
3920
+ if (!Number.isInteger(idx) || idx < 0 || idx >= source.length) return;
3921
+ const key = timelineMessageKey(source[idx]);
3922
+ if (!key) return;
3923
+ textEl.dataset.mobileMessageKey = key;
3924
+ const row = textEl.closest('.review-msg, .message-row');
3925
+ if (row) row.dataset.mobileMessageKey = key;
3926
+ });
3927
+ box.querySelectorAll('[data-mobile-event-key]').forEach((row) => {
3928
+ const key = row.dataset.mobileEventKey || '';
3929
+ if (!key) return;
3930
+ row.dataset.mobileMessageKey = key;
3931
+ row.querySelectorAll('.msg-text, .message-body').forEach((textEl) => {
3932
+ textEl.dataset.mobileMessageKey = key;
3933
+ });
3934
+ });
3935
+ }
3936
+
3522
3937
  function wireMobileMsgExpandButtons(box) {
3523
3938
  if (!box) return;
3524
3939
  box.querySelectorAll('.msg-expand').forEach((button) => {
3525
3940
  button.removeAttribute('onclick');
3526
3941
  button.type = 'button';
3527
3942
  const textEl = button.previousElementSibling;
3943
+ const key = mobileMessageKeyForText(textEl);
3944
+ if (key && state.detail.expandedMessageKeys.has(key) && textEl?.classList) {
3945
+ textEl.classList.remove('collapsed');
3946
+ }
3528
3947
  const expanded = !!(textEl && textEl.classList && !textEl.classList.contains('collapsed'));
3529
3948
  button.textContent = expanded ? 'Show less' : 'Show more';
3530
3949
  button.setAttribute('aria-expanded', expanded ? 'true' : 'false');
@@ -3540,16 +3959,25 @@
3540
3959
  toggleMobileMsgExpand(button);
3541
3960
  }
3542
3961
 
3543
- function annotateMobilePromptTurns(box, mode) {
3962
+ function annotateMobilePromptTurns(box, mode, messages = []) {
3544
3963
  if (!box) return;
3545
3964
  if (mode === 'conversation') {
3546
3965
  box.setAttribute('role', 'feed');
3547
3966
  box.setAttribute('aria-label', 'Session conversation grouped by prompt');
3548
3967
  const turns = Array.from(box.querySelectorAll('.prompt-turn'));
3968
+ const sourceTurns = window.MR?.groupMessagesIntoTurns
3969
+ ? window.MR.groupMessagesIntoTurns((messages || []).map(normalizeMobileReviewMessage).filter(Boolean))
3970
+ : [];
3549
3971
  turns.forEach((turn, idx) => {
3550
3972
  turn.setAttribute('role', 'article');
3551
3973
  turn.setAttribute('aria-posinset', String(idx + 1));
3552
3974
  turn.setAttribute('aria-setsize', String(turns.length));
3975
+ const key = turn.dataset.mobileTurnKey || mobilePromptTurnKey(sourceTurns[idx], idx);
3976
+ if (key) turn.dataset.mobileTurnKey = key;
3977
+ if (window.MR?.setPromptTurnExpanded) {
3978
+ window.MR.setPromptTurnExpanded(turn, isTimelineTurnExpanded(key));
3979
+ }
3980
+ ensureMobilePromptTurnAction(turn);
3553
3981
  const label = turn.querySelector('.prompt-turn-prompt .msg-text, .prompt-turn-setup-title')?.textContent?.trim();
3554
3982
  if (label) turn.setAttribute('aria-label', label.slice(0, 120));
3555
3983
  });
@@ -3580,8 +4008,10 @@
3580
4008
  box.innerHTML = rows.length
3581
4009
  ? rows.join('')
3582
4010
  : '<div class="empty-state">No transcript is available yet.</div>';
3583
- annotateMobilePromptTurns(box, mode);
4011
+ annotateMobileMessageKeys(box, timelineMessages, mode);
4012
+ annotateMobilePromptTurns(box, mode, timelineMessages);
3584
4013
  if (mode === 'conversation') wireMobilePromptTurns(box);
4014
+ wireMobileLiveDetails(box);
3585
4015
  wireMobileMsgExpandButtons(box);
3586
4016
  updateDetailTimelineModeButton();
3587
4017
  if (wasNearBottom) box.scrollTop = box.scrollHeight;
@@ -3695,7 +4125,7 @@
3695
4125
  menu.hidden = false;
3696
4126
  button.setAttribute('aria-expanded', 'true');
3697
4127
  state.sendMenu.suppressNextClick = opts.suppressNextClick !== false;
3698
- ($('send-prev-prompt-option') || $('send-esc-option'))?.focus({ preventScroll: true });
4128
+ if (opts.focus !== false) ($('send-prev-prompt-option') || $('send-esc-option'))?.focus({ preventScroll: true });
3699
4129
  return true;
3700
4130
  }
3701
4131
 
@@ -3710,7 +4140,7 @@
3710
4140
  state.sendMenu.holdTimer = null;
3711
4141
  state.sendMenu.touchStartX = null;
3712
4142
  state.sendMenu.touchStartY = null;
3713
- showSendMenu();
4143
+ showSendMenu({ focus: false });
3714
4144
  }, SEND_LONG_PRESS_MS);
3715
4145
  }
3716
4146
 
@@ -3752,7 +4182,7 @@
3752
4182
  function handleSendButtonContextMenu(event) {
3753
4183
  if (!state.activeSession) return;
3754
4184
  event.preventDefault();
3755
- showSendMenu();
4185
+ showSendMenu({ focus: false });
3756
4186
  }
3757
4187
 
3758
4188
  function handleSendButtonKeydown(event) {
@@ -3762,7 +4192,7 @@
3762
4192
  }
3763
4193
  if ((event.key === 'ArrowUp' || event.key === 'ContextMenu') && state.activeSession) {
3764
4194
  event.preventDefault();
3765
- showSendMenu();
4195
+ showSendMenu({ focus: true });
3766
4196
  }
3767
4197
  }
3768
4198
 
@@ -3770,6 +4200,255 @@
3770
4200
  return !!target.closest?.('#send-menu, #detail-send');
3771
4201
  }
3772
4202
 
4203
+ function walleModelProviderSort(provider) {
4204
+ const order = { anthropic: 1, openai: 2, google: 3, deepseek: 4, moonshot: 5, ollama: 6, mlx: 7 };
4205
+ return order[providerKey(provider)] || 20;
4206
+ }
4207
+
4208
+ function normalizeWallePhoneModels(payload) {
4209
+ const rows = Array.isArray(payload) ? payload : (Array.isArray(payload?.models) ? payload.models : []);
4210
+ const out = [];
4211
+ for (const row of rows) {
4212
+ if (!row || row.enabled === false || row.enabled === 0 || row.available === false || row.coding_capable === false) continue;
4213
+ const provider = providerKey(row.provider_type || row.provider || '');
4214
+ const modelId = String(row.model_id || row.modelId || row.id || '').trim();
4215
+ const label = modelDisplayLabel(row.display_name || row.name || modelId);
4216
+ if (!modelId || !label) continue;
4217
+ out.push({
4218
+ id: String(row.id || `${provider}:${modelId}`).trim(),
4219
+ modelId,
4220
+ label,
4221
+ provider,
4222
+ providerId: String(row.provider_id || row.providerId || '').trim(),
4223
+ providerLabel: row.provider_name || providerLabel(provider) || 'Provider',
4224
+ reason: String(row.coding_reason || row.reason || '').trim(),
4225
+ });
4226
+ }
4227
+ out.sort((a, b) => {
4228
+ const providerCmp = walleModelProviderSort(a.provider) - walleModelProviderSort(b.provider);
4229
+ if (providerCmp) return providerCmp;
4230
+ return a.label.localeCompare(b.label);
4231
+ });
4232
+ return out;
4233
+ }
4234
+
4235
+ function selectedWalleModelIds(card) {
4236
+ return {
4237
+ modelId: String(card?.walle_model_id || card?.model_id || card?.model || '').trim(),
4238
+ provider: providerKey(card?.walle_model_provider || card?.model_provider || card?.modelProvider || card?.providerType || ''),
4239
+ registryId: String(card?.model_registry_id || card?.modelRegistryId || '').trim(),
4240
+ providerId: String(card?.model_provider_id || card?.modelProviderId || '').trim(),
4241
+ };
4242
+ }
4243
+
4244
+ function updateModelPickerButtons() {
4245
+ const visible = isWalleCodingSession(state.activeSession);
4246
+ const label = visible ? modelButtonShortLabel(state.activeSession) : 'Model';
4247
+ const controls = [
4248
+ { input: $('detail-input'), form: $('detail-composer'), button: $('detail-model'), surface: 'detail' },
4249
+ { input: $('walle-input'), form: $('walle-chat-composer'), button: $('walle-model'), surface: 'walle' },
4250
+ ];
4251
+ for (const control of controls) {
4252
+ const { input, form, button, surface } = control;
4253
+ if (!button || !form) continue;
4254
+ const show = visible && !!input && (surface === 'detail' ? isDetailOpen() : state.activeTab === 'walle');
4255
+ button.hidden = !show;
4256
+ button.disabled = !show || state.composerSending;
4257
+ button.setAttribute('aria-expanded', show && state.modelPicker.open ? 'true' : 'false');
4258
+ button.title = show ? modelButtonLabel(state.activeSession) : 'Choose model';
4259
+ const value = button.querySelector('.model-picker-btn-label');
4260
+ if (value) value.textContent = label;
4261
+ form.classList.toggle('has-model-picker', show);
4262
+ }
4263
+ }
4264
+
4265
+ function renderModelPickerSheet() {
4266
+ const sheet = $('model-picker-sheet');
4267
+ const list = $('model-picker-list');
4268
+ const current = $('model-picker-current');
4269
+ if (!sheet || !list || !current) return;
4270
+ sheet.hidden = !state.modelPicker.open;
4271
+ updateModelPickerButtons();
4272
+ if (!state.modelPicker.open) return;
4273
+
4274
+ const card = findSession(state.modelPicker.sessionId) || state.activeSession;
4275
+ current.textContent = card
4276
+ ? `Current: ${modelButtonLabel(card)}`
4277
+ : 'Use a specific coding model for this Wall-E session.';
4278
+
4279
+ if (state.modelPicker.loading) {
4280
+ list.innerHTML = '<div class="model-picker-empty">Loading coding models from Wall-E...</div>';
4281
+ return;
4282
+ }
4283
+ if (state.modelPicker.error) {
4284
+ list.innerHTML = `<div class="model-picker-empty">${esc(state.modelPicker.error)}</div>`;
4285
+ return;
4286
+ }
4287
+ const selected = selectedWalleModelIds(card);
4288
+ const autoSelected = !selected.modelId;
4289
+ const rows = [
4290
+ `<button class="model-picker-option" type="button" role="option" aria-selected="${autoSelected ? 'true' : 'false'}" data-model-choice="auto">
4291
+ <span class="model-picker-option-main">
4292
+ <span class="model-picker-option-title">Auto routing</span>
4293
+ <span class="model-picker-option-meta">Let Wall-E pick the configured coding default</span>
4294
+ </span>
4295
+ <span class="model-picker-option-check" aria-hidden="true">${autoSelected ? '✓' : ''}</span>
4296
+ </button>`,
4297
+ ];
4298
+ for (let i = 0; i < state.modelPicker.models.length; i += 1) {
4299
+ const item = state.modelPicker.models[i];
4300
+ const isSelected = (!!selected.registryId && item.id === selected.registryId)
4301
+ || (!!selected.modelId && item.modelId === selected.modelId && (!selected.provider || selected.provider === item.provider));
4302
+ const meta = [item.providerLabel, item.reason].filter(Boolean).join(' · ');
4303
+ rows.push(`
4304
+ <button class="model-picker-option" type="button" role="option" aria-selected="${isSelected ? 'true' : 'false'}" data-model-choice="${i}">
4305
+ <span class="model-picker-option-main">
4306
+ <span class="model-picker-option-title">${esc(item.label)}</span>
4307
+ <span class="model-picker-option-meta">${esc(meta || item.modelId)}</span>
4308
+ </span>
4309
+ <span class="model-picker-option-check" aria-hidden="true">${isSelected ? '✓' : ''}</span>
4310
+ </button>
4311
+ `);
4312
+ }
4313
+ list.innerHTML = rows.join('');
4314
+ }
4315
+
4316
+ async function loadWallePhoneModels(force = false) {
4317
+ if (state.modelPicker.loaded && !force) return state.modelPicker.models;
4318
+ const requestSeq = state.modelPicker.requestSeq + 1;
4319
+ state.modelPicker.requestSeq = requestSeq;
4320
+ state.modelPicker.loading = true;
4321
+ state.modelPicker.error = '';
4322
+ renderModelPickerSheet();
4323
+ try {
4324
+ const payload = await api('/api/models/coding-availability');
4325
+ if (requestSeq !== state.modelPicker.requestSeq) return state.modelPicker.models;
4326
+ state.modelPicker.models = normalizeWallePhoneModels(payload);
4327
+ state.modelPicker.loaded = true;
4328
+ if (!state.modelPicker.models.length) state.modelPicker.error = 'No coding-capable models are available.';
4329
+ } catch (err) {
4330
+ if (requestSeq === state.modelPicker.requestSeq) {
4331
+ state.modelPicker.error = err?.message || 'Could not load coding models.';
4332
+ }
4333
+ } finally {
4334
+ if (requestSeq === state.modelPicker.requestSeq) {
4335
+ state.modelPicker.loading = false;
4336
+ renderModelPickerSheet();
4337
+ }
4338
+ }
4339
+ return state.modelPicker.models;
4340
+ }
4341
+
4342
+ function openModelPickerForActiveSession(input = composerElement('input')) {
4343
+ const card = state.activeSession;
4344
+ if (!isWalleCodingSession(card)) return;
4345
+ hideMobileSkillPicker();
4346
+ hideSendMenu();
4347
+ state.modelPicker.open = true;
4348
+ state.modelPicker.sessionId = card.id;
4349
+ state.modelPicker.inputId = input?.id || '';
4350
+ state.modelPicker.error = '';
4351
+ renderModelPickerSheet();
4352
+ loadWallePhoneModels().catch(() => {});
4353
+ }
4354
+
4355
+ function closeModelPicker() {
4356
+ state.modelPicker.open = false;
4357
+ state.modelPicker.sessionId = '';
4358
+ state.modelPicker.inputId = '';
4359
+ renderModelPickerSheet();
4360
+ }
4361
+
4362
+ function applyWalleModelLocally(sessionId, item) {
4363
+ if (!sessionId) return;
4364
+ const patch = item
4365
+ ? {
4366
+ model: item.modelId,
4367
+ model_id: item.modelId,
4368
+ modelProvider: item.provider,
4369
+ model_provider: item.provider,
4370
+ model_registry_id: item.id || '',
4371
+ model_provider_id: item.providerId || '',
4372
+ walle_model_id: item.modelId,
4373
+ walle_model_provider: item.provider,
4374
+ model_pinned: true,
4375
+ }
4376
+ : {
4377
+ model: '',
4378
+ model_id: '',
4379
+ modelProvider: '',
4380
+ model_provider: '',
4381
+ model_registry_id: '',
4382
+ model_provider_id: '',
4383
+ walle_model_id: null,
4384
+ walle_model_provider: null,
4385
+ model_pinned: false,
4386
+ };
4387
+ const idx = findSessionIndex(sessionId);
4388
+ if (idx >= 0) state.sessions[idx] = { ...state.sessions[idx], ...patch };
4389
+ if (state.activeSession && (state.activeSession.id === sessionId || state.activeSession.sessionId === sessionId)) {
4390
+ state.activeSession = { ...state.activeSession, ...patch };
4391
+ }
4392
+ if (state.walle.sessionId === sessionId) {
4393
+ const active = findSession(sessionId) || state.activeSession;
4394
+ if (active && $('walle-context')) updateWalleContext(active, walleSessionCards());
4395
+ }
4396
+ persistSessionSnapshot();
4397
+ updateModelPickerButtons();
4398
+ }
4399
+
4400
+ function appendWalleModelSelection(body, card) {
4401
+ if (!body || !card) return body;
4402
+ const selected = selectedWalleModelIds(card);
4403
+ if (selected.modelId) {
4404
+ body.model_id = selected.modelId;
4405
+ body.model = selected.modelId;
4406
+ body.model_provider = selected.provider || '';
4407
+ body.provider = selected.provider || '';
4408
+ body.model_registry_id = selected.registryId || '';
4409
+ body.model_provider_id = selected.providerId || '';
4410
+ body.modelPinned = true;
4411
+ body.allowProviderFallback = false;
4412
+ }
4413
+ return body;
4414
+ }
4415
+
4416
+ async function chooseWallePhoneModel(choice) {
4417
+ const sessionId = state.modelPicker.sessionId || state.activeSession?.id || '';
4418
+ if (!sessionId) return;
4419
+ const item = choice === 'auto' ? null : state.modelPicker.models[Number(choice)];
4420
+ if (choice !== 'auto' && !item) return;
4421
+ const input = $(state.modelPicker.inputId) || composerElement('input');
4422
+ try {
4423
+ setComposerStatus('Saving model for this session...', '', input);
4424
+ const body = item
4425
+ ? {
4426
+ session_id: sessionId,
4427
+ model_id: item.modelId,
4428
+ model_provider: item.provider,
4429
+ model_registry_id: item.id || '',
4430
+ model_provider_id: item.providerId || '',
4431
+ scope: 'session',
4432
+ pinned: true,
4433
+ }
4434
+ : {
4435
+ session_id: sessionId,
4436
+ model_id: '',
4437
+ model_provider: '',
4438
+ scope: 'session',
4439
+ pinned: false,
4440
+ };
4441
+ await sendRemoteMessage('wall_e.set_model', body);
4442
+ applyWalleModelLocally(sessionId, item);
4443
+ closeModelPicker();
4444
+ setComposerStatus(item ? `Model set to ${item.label}.` : 'Model reset to auto routing.', 'ok', input);
4445
+ } catch (err) {
4446
+ state.modelPicker.error = remoteSendErrorMessage(err);
4447
+ renderModelPickerSheet();
4448
+ setComposerStatus(state.modelPicker.error, 'error', input);
4449
+ }
4450
+ }
4451
+
3773
4452
  function formatBytes(bytes) {
3774
4453
  const value = Number(bytes) || 0;
3775
4454
  if (value < 1024) return `${value} B`;
@@ -3874,6 +4553,7 @@
3874
4553
  setComposerText('', { input });
3875
4554
  state.promptHistory.index = -1;
3876
4555
  state.promptHistory.draft = '';
4556
+ updatePromptHistoryStrip({ hide: true });
3877
4557
  autoSizeComposer(input);
3878
4558
  setComposerStatus('', '');
3879
4559
  hideMobileSkillPicker();
@@ -3891,6 +4571,7 @@
3891
4571
  state.promptHistory.requestSeq += 1;
3892
4572
  state.promptHistory.applying = false;
3893
4573
  state.promptHistory.loadPromise = null;
4574
+ updatePromptHistoryStrip({ hide: true });
3894
4575
  }
3895
4576
 
3896
4577
  function normalizePromptHistoryEntries(items) {
@@ -3956,14 +4637,59 @@
3956
4637
  return promise;
3957
4638
  }
3958
4639
 
3959
- function setComposerTextFromHistory(text) {
4640
+ function promptHistoryMeta() {
4641
+ const prompts = state.promptHistory.prompts || [];
4642
+ const index = state.promptHistory.index;
4643
+ if (index < 0 || !prompts.length) return '';
4644
+ return `Prompt ${index + 1} of ${prompts.length}. Tap Edit to open the keyboard, or Send to reuse it.`;
4645
+ }
4646
+
4647
+ function updatePromptHistoryStrip(opts = {}) {
4648
+ const strip = $('prompt-history-strip');
4649
+ if (!strip) return;
4650
+ if (opts.hide || state.promptHistory.index < 0) {
4651
+ strip.hidden = true;
4652
+ return;
4653
+ }
4654
+ const prompts = state.promptHistory.prompts || [];
4655
+ const index = state.promptHistory.index;
4656
+ strip.hidden = false;
4657
+ const title = $('prompt-history-title');
4658
+ const meta = $('prompt-history-meta');
4659
+ if (title) title.textContent = 'Recalled prompt';
4660
+ if (meta) meta.textContent = promptHistoryMeta();
4661
+ const prev = $('prompt-history-prev');
4662
+ const next = $('prompt-history-next');
4663
+ if (prev) prev.disabled = index <= 0;
4664
+ if (next) next.disabled = index < 0 || index >= prompts.length;
4665
+ }
4666
+
4667
+ function finishPromptHistoryEditing() {
4668
+ const input = composerElement('input');
4669
+ if (!input) return;
4670
+ const text = getComposerText(input);
4671
+ setComposerText(text, { input, focus: true, cursor: text.length });
4672
+ setComposerStatus('Editing recalled prompt.', 'ok');
4673
+ }
4674
+
4675
+ function closePromptHistoryStrip() {
4676
+ const input = composerElement('input');
4677
+ state.promptHistory.index = -1;
4678
+ state.promptHistory.draft = input ? getComposerText(input) : '';
4679
+ updatePromptHistoryStrip({ hide: true });
4680
+ setComposerStatus('Prompt history controls hidden.', '');
4681
+ }
4682
+
4683
+ function setComposerTextFromHistory(text, opts = {}) {
3960
4684
  const input = composerElement('input');
3961
4685
  if (!input) return;
3962
4686
  state.promptHistory.applying = true;
3963
- setComposerText(text || '', { input });
4687
+ setComposerText(text || '', { input, focus: opts.focus === true, cursor: opts.focus === true ? undefined : false });
4688
+ if (opts.focus !== true && document.activeElement === input) input.blur();
3964
4689
  autoSizeComposer(input);
3965
4690
  hideMobileSkillPicker();
3966
4691
  updateComposerState(input);
4692
+ updatePromptHistoryStrip();
3967
4693
  state.promptHistory.applying = false;
3968
4694
  }
3969
4695
 
@@ -3986,6 +4712,7 @@
3986
4712
  if (state.promptHistory.index >= prompts.length) {
3987
4713
  state.promptHistory.index = -1;
3988
4714
  setComposerTextFromHistory(state.promptHistory.draft || '');
4715
+ updatePromptHistoryStrip({ hide: true });
3989
4716
  setComposerStatus(state.promptHistory.draft ? 'Draft restored.' : 'Back to current draft.', 'ok');
3990
4717
  return true;
3991
4718
  }
@@ -4184,6 +4911,7 @@
4184
4911
  const button = composerElement('send', input);
4185
4912
  if (!input || !button) return;
4186
4913
  const text = getComposerText(input);
4914
+ applyComposerTextAssistMode(input);
4187
4915
  const hasText = !!text.trim();
4188
4916
  const hasAttachments = state.composerAttachments.length > 0;
4189
4917
  const canMenu = !!state.activeSession;
@@ -4205,6 +4933,7 @@
4205
4933
  button.setAttribute('aria-label', state.composerSending ? 'Sending to Wall-E' : 'Send to Wall-E');
4206
4934
  button.title = 'Send to Wall-E';
4207
4935
  }
4936
+ updateModelPickerButtons();
4208
4937
  const clear = composerElement('clear', input);
4209
4938
  if (clear) {
4210
4939
  clear.hidden = text.length === 0;
@@ -4475,6 +5204,9 @@
4475
5204
  const payload = err?.payload || {};
4476
5205
  const code = payload.error || payload.result?.error || payload.result?.result?.error || err?.message || '';
4477
5206
  const message = payload.message || payload.result?.message || payload.result?.result?.message || '';
5207
+ if (/session_busy/.test(code)) return 'The agent is still working. This reply will stay queued and retry automatically.';
5208
+ if (/session_input_in_flight/.test(code)) return 'Another phone reply is already being delivered. This reply will retry automatically.';
5209
+ if (/session_waiting_for_approval/.test(code)) return 'This session is waiting for an approval or choice. Respond to that prompt before sending a new message.';
4478
5210
  if (/session_not_live/.test(code)) return 'This session is not live on the Mac. Open a live terminal session before sending.';
4479
5211
  if (/session_not_found/.test(code)) return 'This session no longer exists on the Mac. Refresh the session list.';
4480
5212
  if (/remote_action_not_enabled|approval.respond/.test(code)) return 'This action is not enabled from phone yet.';
@@ -4529,19 +5261,23 @@
4529
5261
  const card = state.activeSession;
4530
5262
  if (!card) return;
4531
5263
  if (!text && !attachments.length) {
4532
- showSendMenu({ suppressNextClick: false });
5264
+ showSendMenu({ suppressNextClick: false, focus: false });
4533
5265
  return;
4534
5266
  }
4535
5267
  const outboundText = buildComposerTransportText(text, attachments);
4536
5268
  try {
4537
5269
  const messageType = isWalleSession(card) ? 'wall_e.send_message' : 'session.send_message';
4538
5270
  const body = { session_id: card.id, text: outboundText };
4539
- if (messageType === 'wall_e.send_message' && attachments.length) body.attachments = attachments;
5271
+ if (messageType === 'wall_e.send_message') {
5272
+ if (attachments.length) body.attachments = attachments;
5273
+ appendWalleModelSelection(body, card);
5274
+ }
4540
5275
  const entry = enqueueRemoteReply(messageType, body);
4541
5276
  appendQueuedUserMessage(entry);
4542
5277
  setComposerText('', { input, focus: false, cursor: false });
4543
5278
  state.promptHistory.index = -1;
4544
5279
  state.promptHistory.draft = '';
5280
+ updatePromptHistoryStrip({ hide: true });
4545
5281
  autoSizeComposer(input);
4546
5282
  clearComposerAttachments();
4547
5283
  updateComposerState(input);
@@ -4566,12 +5302,14 @@
4566
5302
  try {
4567
5303
  const body = { session_id: card.id, text: outboundText };
4568
5304
  if (attachments.length) body.attachments = attachments;
5305
+ appendWalleModelSelection(body, card);
4569
5306
  const entry = enqueueRemoteReply('wall_e.send_message', body);
4570
5307
  appendQueuedUserMessage(entry);
4571
5308
  state.walle.draft = '';
4572
5309
  setComposerText('', { input, focus: false, cursor: false });
4573
5310
  state.promptHistory.index = -1;
4574
5311
  state.promptHistory.draft = '';
5312
+ updatePromptHistoryStrip({ hide: true });
4575
5313
  autoSizeComposer(input);
4576
5314
  clearComposerAttachments();
4577
5315
  updateComposerState(input);
@@ -4863,20 +5601,43 @@
4863
5601
  async function createSession(event) {
4864
5602
  event.preventDefault();
4865
5603
  const cwd = $('new-session-project').value || undefined;
4866
- const agent = $('new-session-agent').value;
5604
+ const agentSelect = $('new-session-agent');
5605
+ const agent = agentSelect.value;
5606
+ const agentLabel = agentSelect.selectedOptions?.[0]?.textContent?.trim() || agent;
4867
5607
  const prompt = $('new-session-prompt').value.trim();
4868
- const cmd = agent === 'codex' ? 'codex' : agent === 'gemini' ? 'gemini' : agent === 'shell' ? undefined : 'claude';
4869
- const args = prompt ? [prompt] : [];
5608
+ const isWalle = agent === 'walle';
5609
+ const cmd = agent === 'codex'
5610
+ ? 'codex'
5611
+ : agent === 'gemini'
5612
+ ? 'gemini'
5613
+ : agent === 'shell'
5614
+ ? undefined
5615
+ : isWalle
5616
+ ? 'walle'
5617
+ : 'claude';
5618
+ const args = prompt && !isWalle ? [prompt] : [];
4870
5619
  await withStepUp({
4871
5620
  title: 'Confirm new session',
4872
- copy: `Spawn ${agent} in ${compactPath(cwd || 'the selected project')}.`,
5621
+ copy: `Spawn ${agentLabel} in ${compactPath(cwd || 'the selected project')}.`,
4873
5622
  label: 'Face ID & Spawn',
4874
5623
  action: async () => {
4875
5624
  const id = randomId();
4876
- await sendWs({ type: 'create', id, cwd, cmd, args });
5625
+ const payload = { type: 'create', id, cwd, cmd, args };
5626
+ if (isWalle) {
5627
+ Object.assign(payload, {
5628
+ label: 'Wall-E Coding',
5629
+ agentType: 'walle',
5630
+ agentMode: 'coding',
5631
+ agentKind: 'walle-coding',
5632
+ taskType: 'coding',
5633
+ _skipProjectConfig: true,
5634
+ });
5635
+ if (prompt) payload.initialMessage = prompt;
5636
+ }
5637
+ await sendWs(payload);
4877
5638
  $('new-session-sheet').hidden = true;
4878
5639
  await refresh();
4879
- const card = { id, title: 'New session', cwd, agent, lane: 'running', status: 'running' };
5640
+ const card = { id, title: agentLabel, cwd, agent, lane: 'running', status: 'running' };
4880
5641
  state.sessions.unshift(card);
4881
5642
  openDetail(id);
4882
5643
  },
@@ -4915,9 +5676,23 @@
4915
5676
  $('detail-send').addEventListener('click', handleSendButtonClick);
4916
5677
  $('detail-send').addEventListener('contextmenu', handleSendButtonContextMenu);
4917
5678
  $('detail-send').addEventListener('keydown', handleSendButtonKeydown);
5679
+ $('detail-model')?.addEventListener('click', () => openModelPickerForActiveSession($('detail-input')));
5680
+ $('model-picker-close')?.addEventListener('click', closeModelPicker);
5681
+ $('model-picker-sheet')?.addEventListener('click', (event) => {
5682
+ if (event.target?.id === 'model-picker-sheet') {
5683
+ closeModelPicker();
5684
+ return;
5685
+ }
5686
+ const choice = event.target.closest?.('[data-model-choice]');
5687
+ if (choice) chooseWallePhoneModel(choice.dataset.modelChoice);
5688
+ });
4918
5689
  $('detail-clear')?.addEventListener('click', () => clearComposerText());
4919
5690
  $('send-prev-prompt-option')?.addEventListener('click', () => sendPromptHistoryToSession('previous'));
4920
5691
  $('send-next-prompt-option')?.addEventListener('click', () => sendPromptHistoryToSession('next'));
5692
+ $('prompt-history-prev')?.addEventListener('click', () => applyPromptHistoryNavigation('previous'));
5693
+ $('prompt-history-next')?.addEventListener('click', () => applyPromptHistoryNavigation('next'));
5694
+ $('prompt-history-edit')?.addEventListener('click', finishPromptHistoryEditing);
5695
+ $('prompt-history-close')?.addEventListener('click', closePromptHistoryStrip);
4921
5696
  $('send-copy-url-option')?.addEventListener('click', copySessionUrlFromMenu);
4922
5697
  $('send-esc-option')?.addEventListener('click', sendEscapeToSession);
4923
5698
  $('send-attach-image-option')?.addEventListener('click', () => triggerAttachmentInput('image'));
@@ -4935,6 +5710,7 @@
4935
5710
  if (!state.promptHistory.applying) {
4936
5711
  state.promptHistory.index = -1;
4937
5712
  state.promptHistory.draft = getComposerText(event.currentTarget);
5713
+ updatePromptHistoryStrip({ hide: true });
4938
5714
  }
4939
5715
  updateComposerState();
4940
5716
  updateMobileSkillPicker(event.currentTarget);
@@ -4974,6 +5750,10 @@
4974
5750
  $('walle-content')?.addEventListener('click', (event) => {
4975
5751
  const input = $('walle-input');
4976
5752
  if (event.target?.id === 'walle-input') updateMobileSkillPicker(event.target);
5753
+ if (event.target.closest?.('#walle-model')) {
5754
+ openModelPickerForActiveSession(input);
5755
+ return;
5756
+ }
4977
5757
  if (event.target.closest?.('#walle-clear')) clearComposerText(input);
4978
5758
  const attach = event.target.closest?.('[data-walle-attach]');
4979
5759
  if (attach) triggerAttachmentInput(attach.dataset.walleAttach === 'image' ? 'image' : 'file');
@@ -5019,7 +5799,10 @@
5019
5799
  if (!shouldKeepSendMenuOpen(event.target)) hideSendMenu();
5020
5800
  });
5021
5801
  document.addEventListener('keydown', (event) => {
5022
- if (event.key === 'Escape') hideSendMenu();
5802
+ if (event.key === 'Escape') {
5803
+ hideSendMenu();
5804
+ closeModelPicker();
5805
+ }
5023
5806
  });
5024
5807
  $('stepup-cancel').addEventListener('click', () => {
5025
5808
  $('stepup-sheet').hidden = true;
@@ -5045,6 +5828,9 @@
5045
5828
  applySearchSuggestion(chip.dataset.searchSuggestion || '');
5046
5829
  });
5047
5830
  window.addEventListener('hashchange', handleDeepLink);
5831
+ window.addEventListener('resize', updateMobileViewportInset);
5832
+ window.visualViewport?.addEventListener?.('resize', updateMobileViewportInset);
5833
+ window.visualViewport?.addEventListener?.('scroll', updateMobileViewportInset);
5048
5834
  window.addEventListener('online', () => {
5049
5835
  recoverMobileConnection('online').finally(() => scheduleRemoteOutboxDrain(0));
5050
5836
  });
@@ -5078,6 +5864,7 @@
5078
5864
 
5079
5865
  async function init() {
5080
5866
  loadThemePreference();
5867
+ updateMobileViewportInset();
5081
5868
  configureComposerInput();
5082
5869
  bindEvents();
5083
5870
  loadRemoteOutbox();