ai-or-die 0.1.93 → 0.1.95

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 (35) hide show
  1. package/package.json +1 -1
  2. package/src/public/app.js +207 -106
  3. package/src/public/artifact-panel.js +62 -0
  4. package/src/public/components/artifact-panel.css +39 -0
  5. package/src/public/components/keys-panel.css +171 -0
  6. package/src/public/components/sticky-note.css +25 -4
  7. package/src/public/components/terminal.css +5 -0
  8. package/src/public/extra-keys.js +226 -102
  9. package/src/public/index.html +41 -9
  10. package/src/public/input-overlay.js +37 -12
  11. package/src/public/key-encoder.js +137 -0
  12. package/src/public/keys-panel.js +268 -0
  13. package/src/public/manifest.json +2 -2
  14. package/src/public/mobile.css +15 -15
  15. package/src/public/screenshot-narrow.png +0 -0
  16. package/src/public/screenshot-wide.png +0 -0
  17. package/src/public/service-worker.js +12 -2
  18. package/src/public/splash/ipad11-landscape.png +0 -0
  19. package/src/public/splash/ipad11-portrait.png +0 -0
  20. package/src/public/splash/iphone16-landscape.png +0 -0
  21. package/src/public/splash/iphone16-portrait.png +0 -0
  22. package/src/public/splits.js +82 -19
  23. package/src/public/terminal-copy.js +56 -0
  24. package/src/public/terminal-wheel.js +188 -0
  25. package/src/public/vendor/xterm/addon-fit.js +2 -0
  26. package/src/public/vendor/xterm/addon-search.js +2 -0
  27. package/src/public/vendor/xterm/addon-serialize.js +2 -0
  28. package/src/public/vendor/xterm/addon-unicode11.js +2 -0
  29. package/src/public/vendor/xterm/addon-web-links.js +2 -0
  30. package/src/public/vendor/xterm/addon-webgl.js +2 -0
  31. package/src/public/vendor/xterm/xterm.css +285 -0
  32. package/src/public/vendor/xterm/xterm.js +2 -0
  33. package/src/public/ws-reconnect.js +33 -0
  34. package/src/server.js +96 -68
  35. package/src/usage-reader.js +11 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ai-or-die",
3
- "version": "0.1.93",
3
+ "version": "0.1.95",
4
4
  "description": "Universal AI coding terminal — Claude, Copilot, Gemini & more in your browser",
5
5
  "main": "src/server.js",
6
6
  "bin": {
package/src/public/app.js CHANGED
@@ -402,6 +402,10 @@ class ClaudeCodeWebInterface {
402
402
  });
403
403
 
404
404
  window.addEventListener('resize', () => {
405
+ // Skip during a soft-keyboard transition: the keyboard controller
406
+ // owns the (coalesced) fit. On layout-viewport-resizing browsers a
407
+ // window.resize fires mid-animation and would race a competing fit.
408
+ if (this._inKeyboardTransition) return;
405
409
  this.fitTerminal();
406
410
  });
407
411
 
@@ -571,6 +575,63 @@ class ClaudeCodeWebInterface {
571
575
  });
572
576
  }
573
577
 
578
+ // Upload an image over HTTP (POST /api/images/upload) instead of the WS
579
+ // image_upload path. A real photo's base64 (~5.5 MB) exceeds the 1 MiB WS
580
+ // JSON guard, which force-closes the socket with 1009 — previously surfaced
581
+ // (wrongly) as "A voice message was rejected". HTTP has a 20 MB body limit.
582
+ // On success the returned temp path is injected into the terminal.
583
+ _uploadImage(imageData, sessionId) {
584
+ sessionId = sessionId || this.currentClaudeSessionId;
585
+ if (!sessionId) { this._imageError('No session joined'); return; }
586
+ const caption = imageData.caption || '';
587
+ const body = JSON.stringify({
588
+ sessionId: sessionId,
589
+ base64: imageData.base64,
590
+ mimeType: imageData.mimeType,
591
+ fileName: imageData.fileName || 'pasted-image.png',
592
+ caption: caption
593
+ });
594
+ this.authFetch('/api/images/upload', {
595
+ method: 'POST',
596
+ headers: { 'Content-Type': 'application/json' },
597
+ body: body
598
+ }).then(async (resp) => {
599
+ if (!resp.ok) {
600
+ let msg = 'HTTP ' + resp.status;
601
+ try { const j = await resp.json(); if (j && j.error) msg = j.error; } catch (_) {}
602
+ this._imageError(msg);
603
+ return;
604
+ }
605
+ const data = await resp.json();
606
+ if (!data || !data.filePath) {
607
+ this._imageError('Upload succeeded but no file path was returned');
608
+ return;
609
+ }
610
+ this._injectImagePath(data.filePath, caption);
611
+ }).catch((e) => this._imageError((e && e.message) || 'Upload failed'));
612
+ }
613
+
614
+ // Inject an uploaded image's temp path into the active terminal (caption +
615
+ // quoted path), matching the legacy image_upload_complete behavior.
616
+ _injectImagePath(filePath, caption) {
617
+ if (!filePath) return;
618
+ const normalizedPath = filePath.replace(/\\/g, '/');
619
+ const quotedPath = '"' + normalizedPath + '"';
620
+ const inputText = caption ? caption + ' ' + quotedPath : quotedPath;
621
+ let normalized = attachClipboardHandler.normalizeLineEndings(inputText);
622
+ if (this.terminal && this.terminal.modes && this.terminal.modes.bracketedPasteMode) {
623
+ normalized = attachClipboardHandler.wrapBracketedPaste(normalized);
624
+ }
625
+ this.send({ type: 'input', data: normalized });
626
+ }
627
+
628
+ _imageError(msg) {
629
+ if (window.feedback) window.feedback.warning('Image upload failed: ' + msg);
630
+ if (this.terminal) {
631
+ this.terminal.write('\r\n\x1b[31m[Image upload error] ' + msg + '\x1b[0m\r\n');
632
+ }
633
+ }
634
+
574
635
  sendEscape() {
575
636
  // Send ESC key to terminal
576
637
  if (this.socket && this.socket.readyState === WebSocket.OPEN) {
@@ -631,7 +692,7 @@ class ClaudeCodeWebInterface {
631
692
  foreground: '#f0f6fc',
632
693
  cursor: '#ff6b00',
633
694
  cursorAccent: '#0d1117',
634
- selection: 'rgba(255, 107, 0, 0.2)',
695
+ selectionBackground: 'rgba(255, 107, 0, 0.2)',
635
696
  black: '#484f58',
636
697
  red: '#ff7b72',
637
698
  green: '#7ee787',
@@ -680,6 +741,20 @@ class ClaudeCodeWebInterface {
680
741
 
681
742
  this.terminal.open(document.getElementById('terminal'));
682
743
 
744
+ // Trackpad/mouse-wheel policy: preempt xterm's alt-buffer wheel->arrow
745
+ // translation so scrolling doesn't hijack the Claude Code TUI. Reads the
746
+ // live setting via the cached `_wheelScrollMode` (default 'dontHijack').
747
+ if (typeof window.attachTerminalWheel === 'function') {
748
+ if (this._wheelHandler && this._wheelHandler.dispose) {
749
+ try { this._wheelHandler.dispose(); } catch (_) {}
750
+ }
751
+ this._wheelHandler = window.attachTerminalWheel(
752
+ this.terminal,
753
+ document.getElementById('terminal'),
754
+ () => this._wheelScrollMode || 'dontHijack'
755
+ );
756
+ }
757
+
683
758
  // Faithful per-tab snapshot cache for instant tab-switch repaint.
684
759
  // Guarded: if the serialize addon or the cache class failed to load
685
760
  // (e.g. CDN blocked), snapshotCache stays null and every call site is
@@ -735,6 +810,10 @@ class ClaudeCodeWebInterface {
735
810
  if (termContainerEl && typeof ResizeObserver !== 'undefined') {
736
811
  let resizeTimeout;
737
812
  new ResizeObserver(() => {
813
+ // During a soft-keyboard transition the dedicated keyboard
814
+ // controller owns the (single, coalesced) fit — skip here so we
815
+ // don't fire a second competing fit mid-animation (flicker).
816
+ if (this._inKeyboardTransition) return;
738
817
  clearTimeout(resizeTimeout);
739
818
  resizeTimeout = setTimeout(() => {
740
819
  this.fitTerminal();
@@ -758,16 +837,7 @@ class ClaudeCodeWebInterface {
758
837
  this._imageHandler = window.imageHandler.attachImageHandler(
759
838
  this.terminal, termContainer, {
760
839
  onImageReady: (imageData) => {
761
- this._pendingImageCaption = imageData.caption;
762
- if (this.socket && this.socket.readyState === WebSocket.OPEN) {
763
- this.send({
764
- type: 'image_upload',
765
- base64: imageData.base64,
766
- mimeType: imageData.mimeType,
767
- fileName: imageData.fileName || 'pasted-image.png',
768
- caption: imageData.caption || ''
769
- });
770
- }
840
+ this._uploadImage(imageData);
771
841
  },
772
842
  // Non-image files pasted from a file manager (clipboardData
773
843
  // carries File objects) — route through the generic pipeline.
@@ -799,16 +869,7 @@ class ClaudeCodeWebInterface {
799
869
  files && files.length) {
800
870
  try {
801
871
  window.imageHandler.showImagePreview(files[0], (imageData) => {
802
- this._pendingImageCaption = imageData.caption;
803
- if (this.socket && this.socket.readyState === WebSocket.OPEN) {
804
- this.send({
805
- type: 'image_upload',
806
- base64: imageData.base64,
807
- mimeType: imageData.mimeType,
808
- fileName: imageData.fileName || 'pasted-image.png',
809
- caption: imageData.caption || ''
810
- });
811
- }
872
+ this._uploadImage(imageData);
812
873
  });
813
874
  } catch (_) { /* ignore */ }
814
875
  }
@@ -847,8 +908,10 @@ class ClaudeCodeWebInterface {
847
908
  }
848
909
  this._ctrlModifierPending = false;
849
910
  if (this.extraKeys) {
850
- this.extraKeys.ctrlActive = false;
851
- this.extraKeys._updateCtrlVisual();
911
+ // Use the shared consume so the sticky-Ctrl 5s timeout is
912
+ // cleared too (a stale timeout could otherwise cancel a
913
+ // later Ctrl press).
914
+ this.extraKeys._consumeCtrl();
852
915
  }
853
916
  }
854
917
  if (this.socket && this.socket.readyState === WebSocket.OPEN) {
@@ -932,7 +995,13 @@ class ClaudeCodeWebInterface {
932
995
  _setupExtraKeys() {
933
996
  if (!this.isMobile || typeof ExtraKeys === 'undefined') return;
934
997
 
998
+ document.body.classList.add('is-mobile');
935
999
  this.extraKeys = new ExtraKeys({ app: this });
1000
+ // Control-mode "all keys" panel (keyboard-down). Launcher FAB is shown
1001
+ // via the .is-mobile body class. See keys-panel.js / ADR-0037.
1002
+ if (typeof KeysPanel !== 'undefined') {
1003
+ this.keysPanel = new KeysPanel({ app: this });
1004
+ }
936
1005
  this._keyboardOpen = false;
937
1006
 
938
1007
  // Browsers without visualViewport (Firefox Android, Samsung Internet):
@@ -970,6 +1039,11 @@ class ClaudeCodeWebInterface {
970
1039
  this._keyboardOpen = false;
971
1040
  document.body.classList.remove('keyboard-open');
972
1041
  this._restoreTerminalFromKeyboard();
1042
+ } else if (heightDiff > threshold && this._keyboardOpen) {
1043
+ // Keyboard already open but the viewport height changed (final
1044
+ // animation frame, or rotation while open) — re-apply the size so
1045
+ // the terminal isn't left fit against a stale intermediate height.
1046
+ this._updateKeyboardHeight(currentHeight);
973
1047
  }
974
1048
  };
975
1049
 
@@ -1045,26 +1119,65 @@ class ClaudeCodeWebInterface {
1045
1119
  }
1046
1120
 
1047
1121
  _adjustTerminalForKeyboard(availableHeight) {
1048
- if (this.extraKeys) this.extraKeys.show();
1122
+ if (this.extraKeys) {
1123
+ this.extraKeys.show();
1124
+ this.extraKeys._updateRow2Visibility();
1125
+ }
1126
+ this._updateKeyboardHeight(availableHeight);
1127
+ }
1128
+
1129
+ // Apply (or re-apply) the terminal size for the current keyboard-open
1130
+ // viewport height. Called on the first threshold crossing AND on every later
1131
+ // visualViewport frame while the keyboard stays open (final animation frame,
1132
+ // or rotation) so the terminal never fits against a stale intermediate
1133
+ // height and extends under the keyboard. Style writes are cheap; the xterm
1134
+ // fit is coalesced into one rAF below.
1135
+ _updateKeyboardHeight(availableHeight) {
1136
+ this._inKeyboardTransition = true;
1049
1137
  document.documentElement.style.setProperty('--visual-viewport-height', availableHeight + 'px');
1050
1138
  const termEl = document.getElementById('terminal');
1051
1139
  if (termEl) {
1052
- // Force reflow after show() so offsetHeight is accurate
1053
- const extraKeysHeight = this.extraKeys?.container?.offsetHeight || 44;
1054
- void extraKeysHeight; // ensure reflow read is not optimized away
1055
- termEl.style.height = (availableHeight - (this.extraKeys?.container?.offsetHeight || 44)) + 'px';
1056
- if (this.fitAddon) this.fitAddon.fit();
1140
+ const barH = (this.extraKeys && this.extraKeys.container)
1141
+ ? (this.extraKeys.container.offsetHeight || 44) : 0;
1142
+ termEl.style.height = (availableHeight - barH) + 'px';
1057
1143
  }
1144
+ this._scheduleKeyboardFit();
1145
+ this._endKeyboardTransitionSoon();
1058
1146
  }
1059
1147
 
1060
1148
  _restoreTerminalFromKeyboard() {
1149
+ this._inKeyboardTransition = true;
1061
1150
  if (this.extraKeys) this.extraKeys.hide();
1062
1151
  document.documentElement.style.removeProperty('--visual-viewport-height');
1063
1152
  const termEl = document.getElementById('terminal');
1064
- if (termEl) {
1065
- termEl.style.height = '';
1066
- if (this.fitAddon) this.fitAddon.fit();
1067
- }
1153
+ if (termEl) termEl.style.height = '';
1154
+ this._scheduleKeyboardFit();
1155
+ this._endKeyboardTransitionSoon();
1156
+ }
1157
+
1158
+ // Coalesce fits during a keyboard transition into a single rAF. Uses
1159
+ // fitTerminal() (which applies the mobile row/column adjustments) and refits
1160
+ // split panes — NOT a raw fitAddon.fit() that would bypass both.
1161
+ _scheduleKeyboardFit() {
1162
+ if (this._kbFitRaf) cancelAnimationFrame(this._kbFitRaf);
1163
+ this._kbFitRaf = requestAnimationFrame(() => {
1164
+ this._kbFitRaf = null;
1165
+ this.fitTerminal();
1166
+ if (this.splitContainer && this.splitContainer.splits) {
1167
+ this.splitContainer.splits.forEach(s => { try { s.fit(); } catch (_) {} });
1168
+ }
1169
+ });
1170
+ }
1171
+
1172
+ // End the transition window after the CSS chrome-collapse would have settled,
1173
+ // then do one final measured fit to reconcile the resting layout.
1174
+ _endKeyboardTransitionSoon() {
1175
+ if (this._kbTransitionTimer) clearTimeout(this._kbTransitionTimer);
1176
+ this._kbTransitionTimer = setTimeout(() => {
1177
+ this._kbTransitionTimer = null;
1178
+ this._inKeyboardTransition = false;
1179
+ this._scheduleKeyboardFit();
1180
+ }, 320);
1068
1181
  }
1069
1182
 
1070
1183
  showSessionSelectionModal() {
@@ -1190,16 +1303,7 @@ class ClaudeCodeWebInterface {
1190
1303
  // Fallback: generic handler unavailable — keep the legacy image-only picker.
1191
1304
  attachBtn.addEventListener('click', () => {
1192
1305
  window.imageHandler.triggerFilePicker((imageData) => {
1193
- this._pendingImageCaption = imageData.caption;
1194
- if (this.socket && this.socket.readyState === WebSocket.OPEN) {
1195
- this.send({
1196
- type: 'image_upload',
1197
- base64: imageData.base64,
1198
- mimeType: imageData.mimeType,
1199
- fileName: imageData.fileName || 'attached-image.png',
1200
- caption: imageData.caption || ''
1201
- });
1202
- }
1306
+ this._uploadImage(imageData);
1203
1307
  });
1204
1308
  });
1205
1309
  }
@@ -1300,6 +1404,22 @@ class ClaudeCodeWebInterface {
1300
1404
  this.closeMobileMenu();
1301
1405
  });
1302
1406
  }
1407
+
1408
+ // Mobile image attach — the desktop attach button is hidden on mobile,
1409
+ // so this is the mobile entry point. The file picker offers Photo Library
1410
+ // / Take Photo / Files on iOS; the picked image goes through the preview
1411
+ // modal and the HTTP upload path (_uploadImage).
1412
+ const attachImageBtnMobile = document.getElementById('attachImageBtnMobile');
1413
+ if (attachImageBtnMobile) {
1414
+ attachImageBtnMobile.addEventListener('click', () => {
1415
+ this.closeMobileMenu();
1416
+ if (window.imageHandler && typeof window.imageHandler.triggerFilePicker === 'function') {
1417
+ window.imageHandler.triggerFilePicker((imageData) => this._uploadImage(imageData));
1418
+ } else if (window.feedback) {
1419
+ window.feedback.warning('Image upload is unavailable');
1420
+ }
1421
+ });
1422
+ }
1303
1423
 
1304
1424
  // Mobile sessions button
1305
1425
  const sessionsBtnMobile = document.getElementById('sessionsBtnMobile');
@@ -2187,7 +2307,17 @@ class ClaudeCodeWebInterface {
2187
2307
  if (restartGen !== this._socketGeneration) return;
2188
2308
  this.reconnect();
2189
2309
  }, restartBackoff);
2190
- } else if ((!event.wasClean || voiceClose.rejected) && this.reconnectAttempts < this.maxReconnectAttempts) {
2310
+ // Reconnect decision extracted to WsReconnect.isReconnectableClose
2311
+ // (unit-tested): abnormal close, our own heartbeat pong-timeout
2312
+ // (code 4000 — a clean close that MUST still reconnect, or a single
2313
+ // transient pong-timeout strands the client on "Disconnected"), or a
2314
+ // server frame-rejection (1009/1003). Inline fallback keeps reconnect
2315
+ // working even if the tiny module failed to load.
2316
+ } else if (
2317
+ ((window.WsReconnect && window.WsReconnect.isReconnectableClose)
2318
+ ? window.WsReconnect.isReconnectableClose(event, voiceClose.rejected)
2319
+ : (!event.wasClean || event.code === 4000 || voiceClose.rejected))
2320
+ && this.reconnectAttempts < this.maxReconnectAttempts) {
2191
2321
  this.updateStatus('Reconnecting (' + (this.reconnectAttempts + 1) + '/' + this.maxReconnectAttempts + ')...');
2192
2322
  // First attempt is fast (250ms covers a server-process restart window);
2193
2323
  // subsequent attempts use exponential backoff with jitter.
@@ -2209,7 +2339,11 @@ class ClaudeCodeWebInterface {
2209
2339
  this.reconnectAttempts++;
2210
2340
  } else {
2211
2341
  this.updateStatus('Disconnected');
2212
- this.showError(`Connection lost after ${this.maxReconnectAttempts} attempts.\n\nYour session data is preserved on the server.\n\u2022 Check your network connection\n\u2022 The server may have restarted \u2014 try refreshing the page`);
2342
+ const n = this.reconnectAttempts;
2343
+ const lead = n > 0
2344
+ ? `Connection lost after ${n} reconnect attempt${n === 1 ? '' : 's'}.`
2345
+ : 'Disconnected from the server.';
2346
+ this.showError(`${lead}\n\nYour session data is preserved on the server.\n\u2022 Check your network connection\n\u2022 The server may have restarted \u2014 try refreshing the page`);
2213
2347
  }
2214
2348
  };
2215
2349
 
@@ -3467,40 +3601,30 @@ class ClaudeCodeWebInterface {
3467
3601
  }
3468
3602
 
3469
3603
  _loadGpuRenderer() {
3604
+ // The Canvas renderer was removed in xterm 6.0. Mobile keeps using the
3605
+ // reliable default DOM renderer (WebGL was historically avoided there for
3606
+ // context-loss/memory reasons); desktop uses WebGL and falls back to the
3607
+ // DOM renderer on failure or context loss.
3470
3608
  if (this.isMobile) {
3471
- console.log('[Renderer] Mobile detected, using Canvas renderer for reliability');
3472
- this._loadCanvasAddon();
3609
+ console.log('[Renderer] Mobile detected, using DOM renderer for reliability');
3473
3610
  return;
3474
3611
  }
3475
3612
  if (typeof WebglAddon !== 'undefined') {
3476
3613
  try {
3477
3614
  this.webglAddon = new WebglAddon.WebglAddon();
3478
3615
  this.webglAddon.onContextLoss(() => {
3479
- this.webglAddon.dispose();
3616
+ const addon = this.webglAddon;
3480
3617
  this.webglAddon = null;
3481
- this._loadCanvasAddon();
3618
+ try { if (addon) addon.dispose(); } catch (_) {}
3619
+ console.log('[Renderer] WebGL context lost, using DOM renderer');
3482
3620
  });
3483
3621
  this.terminal.loadAddon(this.webglAddon);
3484
3622
  } catch (e) {
3485
- console.log('[Renderer] WebGL unavailable, using Canvas renderer');
3486
- this._loadCanvasAddon();
3623
+ this.webglAddon = null;
3624
+ console.log('[Renderer] WebGL unavailable, using DOM renderer');
3487
3625
  }
3488
3626
  } else {
3489
- console.log('[Renderer] WebGL unavailable, using Canvas renderer');
3490
- this._loadCanvasAddon();
3491
- }
3492
- }
3493
-
3494
- _loadCanvasAddon() {
3495
- if (typeof CanvasAddon !== 'undefined') {
3496
- try {
3497
- this.canvasAddon = new CanvasAddon.CanvasAddon();
3498
- this.terminal.loadAddon(this.canvasAddon);
3499
- } catch (e) {
3500
- console.log('[Renderer] Canvas unavailable, using DOM renderer (slower)');
3501
- }
3502
- } else {
3503
- console.log('[Renderer] Canvas unavailable, using DOM renderer (slower)');
3627
+ console.log('[Renderer] WebGL unavailable, using DOM renderer');
3504
3628
  }
3505
3629
  }
3506
3630
 
@@ -3900,7 +4024,6 @@ class ClaudeCodeWebInterface {
3900
4024
  break;
3901
4025
  }
3902
4026
  case 'pasteImage': {
3903
- const pasteSocket = activeSocket;
3904
4027
  try {
3905
4028
  if (navigator.clipboard && typeof navigator.clipboard.read === 'function') {
3906
4029
  const items = await navigator.clipboard.read();
@@ -3911,17 +4034,7 @@ class ClaudeCodeWebInterface {
3911
4034
  if (imageType) {
3912
4035
  const blob = await item.getType(imageType);
3913
4036
  window.imageHandler.showImagePreview(blob, (imageData) => {
3914
- this._pendingImageCaption = imageData.caption;
3915
- const msg = JSON.stringify({
3916
- type: 'image_upload',
3917
- base64: imageData.base64,
3918
- mimeType: imageData.mimeType,
3919
- fileName: imageData.fileName || 'pasted-image.png',
3920
- caption: imageData.caption || ''
3921
- });
3922
- if (pasteSocket && pasteSocket.readyState === WebSocket.OPEN) {
3923
- pasteSocket.send(msg);
3924
- }
4037
+ this._uploadImage(imageData);
3925
4038
  });
3926
4039
  return;
3927
4040
  }
@@ -3949,19 +4062,8 @@ class ClaudeCodeWebInterface {
3949
4062
  { multiple: true }
3950
4063
  );
3951
4064
  } else if (window.imageHandler) {
3952
- const attachSocket = activeSocket;
3953
4065
  window.imageHandler.triggerFilePicker((imageData) => {
3954
- this._pendingImageCaption = imageData.caption;
3955
- const msg = JSON.stringify({
3956
- type: 'image_upload',
3957
- base64: imageData.base64,
3958
- mimeType: imageData.mimeType,
3959
- fileName: imageData.fileName || 'attached-image.png',
3960
- caption: imageData.caption || ''
3961
- });
3962
- if (attachSocket && attachSocket.readyState === WebSocket.OPEN) {
3963
- attachSocket.send(msg);
3964
- }
4066
+ this._uploadImage(imageData);
3965
4067
  });
3966
4068
  }
3967
4069
  break;
@@ -4175,6 +4277,9 @@ class ClaudeCodeWebInterface {
4175
4277
  const enableSessionStickyNotes = document.getElementById('enableSessionStickyNotes');
4176
4278
  if (enableSessionStickyNotes) enableSessionStickyNotes.checked = settings.enableSessionStickyNotes ?? true;
4177
4279
 
4280
+ const wheelScrollMode = document.getElementById('wheelScrollMode');
4281
+ if (wheelScrollMode) wheelScrollMode.value = settings.wheelScrollMode || 'dontHijack';
4282
+
4178
4283
  // Update install section
4179
4284
  this._updateInstallSection();
4180
4285
  }
@@ -4207,10 +4312,6 @@ class ClaudeCodeWebInterface {
4207
4312
 
4208
4313
  // Capture the socket at attach-initiation time. The image preview modal
4209
4314
  // is async (user-driven); the active session/socket can change while it
4210
- // is open. Sending on a captured target avoids the upload landing on a
4211
- // different session (mirrors the context-menu's existing capture).
4212
- const targetSocket = this.socket;
4213
-
4214
4315
  // Images: reuse the existing single-preview flow. The modal handles one
4215
4316
  // image at a time, so if several images are selected we attach the first
4216
4317
  // and tell the user rather than silently dropping the rest.
@@ -4219,16 +4320,7 @@ class ClaudeCodeWebInterface {
4219
4320
  window.feedback.info('Only the first image is attached — attach images one at a time.');
4220
4321
  }
4221
4322
  ih.showImagePreview(images[0], (imageData) => {
4222
- this._pendingImageCaption = imageData.caption;
4223
- if (targetSocket && targetSocket.readyState === WebSocket.OPEN) {
4224
- targetSocket.send(JSON.stringify({
4225
- type: 'image_upload',
4226
- base64: imageData.base64,
4227
- mimeType: imageData.mimeType,
4228
- fileName: imageData.fileName || 'attached-image.png',
4229
- caption: imageData.caption || ''
4230
- }));
4231
- }
4323
+ this._uploadImage(imageData);
4232
4324
  });
4233
4325
  }
4234
4326
 
@@ -4488,7 +4580,11 @@ class ClaudeCodeWebInterface {
4488
4580
  notifVolume: 30,
4489
4581
  notifDesktop: true,
4490
4582
  enableSessionStickyNotes: true,
4491
- tabSnapshotLines: 500
4583
+ tabSnapshotLines: 500,
4584
+ // Trackpad/mouse-wheel behaviour inside full-screen (alt-buffer) apps:
4585
+ // 'dontHijack' - wheel does nothing there (no menu hijack in the Claude Code TUI)
4586
+ // 'altScroll' - wheel sends Up/Down arrows (pagers like `less` scroll)
4587
+ wheelScrollMode: 'dontHijack'
4492
4588
  };
4493
4589
  }
4494
4590
 
@@ -4531,7 +4627,8 @@ class ClaudeCodeWebInterface {
4531
4627
  notifVolume: parseInt(document.getElementById('notifVolume')?.value || '30'),
4532
4628
  notifDesktop: document.getElementById('notifDesktop')?.checked ?? true,
4533
4629
  enableSessionStickyNotes: document.getElementById('enableSessionStickyNotes')?.checked ?? true,
4534
- tabSnapshotLines: parseInt(document.getElementById('tabSnapshotLines')?.value || '500', 10)
4630
+ tabSnapshotLines: parseInt(document.getElementById('tabSnapshotLines')?.value || '500', 10),
4631
+ wheelScrollMode: document.getElementById('wheelScrollMode')?.value || 'dontHijack'
4535
4632
  };
4536
4633
 
4537
4634
  try {
@@ -4580,6 +4677,10 @@ class ClaudeCodeWebInterface {
4580
4677
  this.terminal.options.cursorBlink = settings.cursorBlink ?? true;
4581
4678
  if (settings.scrollback) this.terminal.options.scrollback = settings.scrollback;
4582
4679
 
4680
+ // Cache the wheel-scroll policy for the capture-phase wheel handler
4681
+ // (read live per wheel event without touching localStorage each notch).
4682
+ this._wheelScrollMode = settings.wheelScrollMode || 'dontHijack';
4683
+
4583
4684
  // Push the instant-snapshot line cap to the cache (0 disables capture+paint).
4584
4685
  if (this.snapshotCache && settings.tabSnapshotLines !== undefined) {
4585
4686
  this.snapshotCache.setMaxLines(settings.tabSnapshotLines);
@@ -20,6 +20,7 @@
20
20
  const SDK_SOURCE_IN = 'ai-or-die-artifact-sdk'; // messages FROM the iframe
21
21
  const HOST_SOURCE_OUT = 'ai-or-die-artifact-host'; // messages TO the iframe
22
22
  const STORE_KEY = 'ai-or-die:artifact-panel:layout';
23
+ const PHONE_MQ = '(max-width:640px)';
23
24
 
24
25
  const MIN_W = 320;
25
26
  const MIN_H = 240;
@@ -60,6 +61,7 @@
60
61
 
61
62
  this._layout = this._loadLayout();
62
63
  this._buildDom();
64
+ this._wirePhoneMode();
63
65
  this._applyLayout();
64
66
  this._minimized = !!this._layout.minimized;
65
67
  this._applyMinimized();
@@ -82,6 +84,7 @@
82
84
  return {};
83
85
  }
84
86
  _saveLayout() {
87
+ if (this._isPhone()) return;
85
88
  try {
86
89
  window.localStorage.setItem(STORE_KEY, JSON.stringify({
87
90
  left: this._layout.left,
@@ -93,6 +96,39 @@
93
96
  } catch (_) { /* non-fatal */ }
94
97
  }
95
98
 
99
+ _isPhone() {
100
+ return !!(typeof window !== 'undefined' && typeof window.matchMedia === 'function' && window.matchMedia(PHONE_MQ).matches);
101
+ }
102
+
103
+ _wirePhoneMode() {
104
+ if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') return;
105
+ const mql = window.matchMedia(PHONE_MQ);
106
+ this._phoneMql = mql;
107
+ this._onPhoneChange = (e) => {
108
+ const matches = !!(e && typeof e.matches === 'boolean' ? e.matches : mql.matches);
109
+ if (matches) {
110
+ this._cancelPointerInteraction();
111
+ this._clearInlineGeometry();
112
+ } else {
113
+ this._applyLayout();
114
+ this._clampToBounds();
115
+ }
116
+ };
117
+ if (typeof mql.addEventListener === 'function') mql.addEventListener('change', this._onPhoneChange);
118
+ else if (typeof mql.addListener === 'function') mql.addListener(this._onPhoneChange);
119
+ if (mql.matches) this._clearInlineGeometry();
120
+ }
121
+
122
+ _clearInlineGeometry() {
123
+ if (!this.el) return;
124
+ this.el.style.left = '';
125
+ this.el.style.top = '';
126
+ this.el.style.right = '';
127
+ this.el.style.bottom = '';
128
+ this.el.style.width = '';
129
+ this.el.style.height = '';
130
+ }
131
+
96
132
  _buildDom() {
97
133
  this.el = el('div', { class: 'artifact-panel', id: 'artifactPanel', hidden: 'hidden', role: 'dialog', 'aria-label': 'Artifact review' });
98
134
 
@@ -167,6 +203,10 @@
167
203
 
168
204
  // ---- floating-window geometry ----------------------------------------
169
205
  _applyLayout() {
206
+ if (this._isPhone()) {
207
+ this._clearInlineGeometry();
208
+ return;
209
+ }
170
210
  const L = this._layout;
171
211
  if (typeof L.width === 'number') this.el.style.width = clampNumber(L.width, MIN_W, 4000) + 'px';
172
212
  if (typeof L.height === 'number') this.el.style.height = clampNumber(L.height, MIN_H, 4000) + 'px';
@@ -186,6 +226,7 @@
186
226
  // live panel size, then persists the corrected value. Safe to call when the
187
227
  // panel is visible (a hidden panel measures 0x0, so we skip then).
188
228
  _clampToBounds() {
229
+ if (this._isPhone()) return;
189
230
  const L = this._layout;
190
231
  if (typeof L.left !== 'number' || typeof L.top !== 'number') return;
191
232
  if (this.el.hidden) return;
@@ -211,10 +252,20 @@
211
252
  return { width: wr.width, height: wr.height };
212
253
  }
213
254
 
255
+ _cancelPointerInteraction() {
256
+ if (this._dragCleanup) { try { this._dragCleanup(); } catch (_) { /* ignore */ } this._dragCleanup = null; }
257
+ if (this._resizeCleanup) { try { this._resizeCleanup(); } catch (_) { /* ignore */ } this._resizeCleanup = null; }
258
+ }
259
+
214
260
  _wireDrag(handle) {
215
261
  let startX = 0, startY = 0, originLeft = 0, originTop = 0, dragging = false;
216
262
  const onMove = (e) => {
217
263
  if (!dragging) return;
264
+ if (this._isPhone()) {
265
+ dragging = false;
266
+ this._cancelPointerInteraction();
267
+ return;
268
+ }
218
269
  const p = this._point(e);
219
270
  const b = this._bounds();
220
271
  const rect = this.el.getBoundingClientRect();
@@ -236,6 +287,7 @@
236
287
  this._saveLayout();
237
288
  };
238
289
  handle.addEventListener('mousedown', (e) => {
290
+ if (this._isPhone()) return;
239
291
  // Ignore drags that start on a header button.
240
292
  if (e.target && e.target.closest && e.target.closest('.artifact-panel__btn')) return;
241
293
  e.preventDefault();
@@ -265,6 +317,11 @@
265
317
  let startX = 0, startY = 0, originW = 0, originH = 0, resizing = false;
266
318
  const onMove = (e) => {
267
319
  if (!resizing) return;
320
+ if (this._isPhone()) {
321
+ resizing = false;
322
+ this._cancelPointerInteraction();
323
+ return;
324
+ }
268
325
  const p = this._point(e);
269
326
  const b = this._bounds();
270
327
  const rect = this.el.getBoundingClientRect();
@@ -288,6 +345,7 @@
288
345
  this._saveLayout();
289
346
  };
290
347
  handle.addEventListener('mousedown', (e) => {
348
+ if (this._isPhone()) return;
291
349
  e.preventDefault();
292
350
  e.stopPropagation();
293
351
  const p = this._point(e);
@@ -886,6 +944,10 @@
886
944
  destroy() {
887
945
  window.removeEventListener('message', this._onWindowMessage);
888
946
  if (this._onWindowResize) window.removeEventListener('resize', this._onWindowResize);
947
+ if (this._phoneMql && this._onPhoneChange) {
948
+ if (typeof this._phoneMql.removeEventListener === 'function') this._phoneMql.removeEventListener('change', this._onPhoneChange);
949
+ else if (typeof this._phoneMql.removeListener === 'function') this._phoneMql.removeListener(this._onPhoneChange);
950
+ }
889
951
  this._teardownSse();
890
952
  if (this._dragCleanup) { try { this._dragCleanup(); } catch (_) { /* ignore */ } this._dragCleanup = null; }
891
953
  if (this._resizeCleanup) { try { this._resizeCleanup(); } catch (_) { /* ignore */ } this._resizeCleanup = null; }