ai-or-die 0.1.93 → 0.1.94

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.
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.94",
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) {
@@ -735,6 +796,10 @@ class ClaudeCodeWebInterface {
735
796
  if (termContainerEl && typeof ResizeObserver !== 'undefined') {
736
797
  let resizeTimeout;
737
798
  new ResizeObserver(() => {
799
+ // During a soft-keyboard transition the dedicated keyboard
800
+ // controller owns the (single, coalesced) fit — skip here so we
801
+ // don't fire a second competing fit mid-animation (flicker).
802
+ if (this._inKeyboardTransition) return;
738
803
  clearTimeout(resizeTimeout);
739
804
  resizeTimeout = setTimeout(() => {
740
805
  this.fitTerminal();
@@ -758,16 +823,7 @@ class ClaudeCodeWebInterface {
758
823
  this._imageHandler = window.imageHandler.attachImageHandler(
759
824
  this.terminal, termContainer, {
760
825
  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
- }
826
+ this._uploadImage(imageData);
771
827
  },
772
828
  // Non-image files pasted from a file manager (clipboardData
773
829
  // carries File objects) — route through the generic pipeline.
@@ -799,16 +855,7 @@ class ClaudeCodeWebInterface {
799
855
  files && files.length) {
800
856
  try {
801
857
  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
- }
858
+ this._uploadImage(imageData);
812
859
  });
813
860
  } catch (_) { /* ignore */ }
814
861
  }
@@ -847,8 +894,10 @@ class ClaudeCodeWebInterface {
847
894
  }
848
895
  this._ctrlModifierPending = false;
849
896
  if (this.extraKeys) {
850
- this.extraKeys.ctrlActive = false;
851
- this.extraKeys._updateCtrlVisual();
897
+ // Use the shared consume so the sticky-Ctrl 5s timeout is
898
+ // cleared too (a stale timeout could otherwise cancel a
899
+ // later Ctrl press).
900
+ this.extraKeys._consumeCtrl();
852
901
  }
853
902
  }
854
903
  if (this.socket && this.socket.readyState === WebSocket.OPEN) {
@@ -932,7 +981,13 @@ class ClaudeCodeWebInterface {
932
981
  _setupExtraKeys() {
933
982
  if (!this.isMobile || typeof ExtraKeys === 'undefined') return;
934
983
 
984
+ document.body.classList.add('is-mobile');
935
985
  this.extraKeys = new ExtraKeys({ app: this });
986
+ // Control-mode "all keys" panel (keyboard-down). Launcher FAB is shown
987
+ // via the .is-mobile body class. See keys-panel.js / ADR-0037.
988
+ if (typeof KeysPanel !== 'undefined') {
989
+ this.keysPanel = new KeysPanel({ app: this });
990
+ }
936
991
  this._keyboardOpen = false;
937
992
 
938
993
  // Browsers without visualViewport (Firefox Android, Samsung Internet):
@@ -970,6 +1025,11 @@ class ClaudeCodeWebInterface {
970
1025
  this._keyboardOpen = false;
971
1026
  document.body.classList.remove('keyboard-open');
972
1027
  this._restoreTerminalFromKeyboard();
1028
+ } else if (heightDiff > threshold && this._keyboardOpen) {
1029
+ // Keyboard already open but the viewport height changed (final
1030
+ // animation frame, or rotation while open) — re-apply the size so
1031
+ // the terminal isn't left fit against a stale intermediate height.
1032
+ this._updateKeyboardHeight(currentHeight);
973
1033
  }
974
1034
  };
975
1035
 
@@ -1045,26 +1105,65 @@ class ClaudeCodeWebInterface {
1045
1105
  }
1046
1106
 
1047
1107
  _adjustTerminalForKeyboard(availableHeight) {
1048
- if (this.extraKeys) this.extraKeys.show();
1108
+ if (this.extraKeys) {
1109
+ this.extraKeys.show();
1110
+ this.extraKeys._updateRow2Visibility();
1111
+ }
1112
+ this._updateKeyboardHeight(availableHeight);
1113
+ }
1114
+
1115
+ // Apply (or re-apply) the terminal size for the current keyboard-open
1116
+ // viewport height. Called on the first threshold crossing AND on every later
1117
+ // visualViewport frame while the keyboard stays open (final animation frame,
1118
+ // or rotation) so the terminal never fits against a stale intermediate
1119
+ // height and extends under the keyboard. Style writes are cheap; the xterm
1120
+ // fit is coalesced into one rAF below.
1121
+ _updateKeyboardHeight(availableHeight) {
1122
+ this._inKeyboardTransition = true;
1049
1123
  document.documentElement.style.setProperty('--visual-viewport-height', availableHeight + 'px');
1050
1124
  const termEl = document.getElementById('terminal');
1051
1125
  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();
1126
+ const barH = (this.extraKeys && this.extraKeys.container)
1127
+ ? (this.extraKeys.container.offsetHeight || 44) : 0;
1128
+ termEl.style.height = (availableHeight - barH) + 'px';
1057
1129
  }
1130
+ this._scheduleKeyboardFit();
1131
+ this._endKeyboardTransitionSoon();
1058
1132
  }
1059
1133
 
1060
1134
  _restoreTerminalFromKeyboard() {
1135
+ this._inKeyboardTransition = true;
1061
1136
  if (this.extraKeys) this.extraKeys.hide();
1062
1137
  document.documentElement.style.removeProperty('--visual-viewport-height');
1063
1138
  const termEl = document.getElementById('terminal');
1064
- if (termEl) {
1065
- termEl.style.height = '';
1066
- if (this.fitAddon) this.fitAddon.fit();
1067
- }
1139
+ if (termEl) termEl.style.height = '';
1140
+ this._scheduleKeyboardFit();
1141
+ this._endKeyboardTransitionSoon();
1142
+ }
1143
+
1144
+ // Coalesce fits during a keyboard transition into a single rAF. Uses
1145
+ // fitTerminal() (which applies the mobile row/column adjustments) and refits
1146
+ // split panes — NOT a raw fitAddon.fit() that would bypass both.
1147
+ _scheduleKeyboardFit() {
1148
+ if (this._kbFitRaf) cancelAnimationFrame(this._kbFitRaf);
1149
+ this._kbFitRaf = requestAnimationFrame(() => {
1150
+ this._kbFitRaf = null;
1151
+ this.fitTerminal();
1152
+ if (this.splitContainer && this.splitContainer.splits) {
1153
+ this.splitContainer.splits.forEach(s => { try { s.fit(); } catch (_) {} });
1154
+ }
1155
+ });
1156
+ }
1157
+
1158
+ // End the transition window after the CSS chrome-collapse would have settled,
1159
+ // then do one final measured fit to reconcile the resting layout.
1160
+ _endKeyboardTransitionSoon() {
1161
+ if (this._kbTransitionTimer) clearTimeout(this._kbTransitionTimer);
1162
+ this._kbTransitionTimer = setTimeout(() => {
1163
+ this._kbTransitionTimer = null;
1164
+ this._inKeyboardTransition = false;
1165
+ this._scheduleKeyboardFit();
1166
+ }, 320);
1068
1167
  }
1069
1168
 
1070
1169
  showSessionSelectionModal() {
@@ -1190,16 +1289,7 @@ class ClaudeCodeWebInterface {
1190
1289
  // Fallback: generic handler unavailable — keep the legacy image-only picker.
1191
1290
  attachBtn.addEventListener('click', () => {
1192
1291
  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
- }
1292
+ this._uploadImage(imageData);
1203
1293
  });
1204
1294
  });
1205
1295
  }
@@ -1300,6 +1390,22 @@ class ClaudeCodeWebInterface {
1300
1390
  this.closeMobileMenu();
1301
1391
  });
1302
1392
  }
1393
+
1394
+ // Mobile image attach — the desktop attach button is hidden on mobile,
1395
+ // so this is the mobile entry point. The file picker offers Photo Library
1396
+ // / Take Photo / Files on iOS; the picked image goes through the preview
1397
+ // modal and the HTTP upload path (_uploadImage).
1398
+ const attachImageBtnMobile = document.getElementById('attachImageBtnMobile');
1399
+ if (attachImageBtnMobile) {
1400
+ attachImageBtnMobile.addEventListener('click', () => {
1401
+ this.closeMobileMenu();
1402
+ if (window.imageHandler && typeof window.imageHandler.triggerFilePicker === 'function') {
1403
+ window.imageHandler.triggerFilePicker((imageData) => this._uploadImage(imageData));
1404
+ } else if (window.feedback) {
1405
+ window.feedback.warning('Image upload is unavailable');
1406
+ }
1407
+ });
1408
+ }
1303
1409
 
1304
1410
  // Mobile sessions button
1305
1411
  const sessionsBtnMobile = document.getElementById('sessionsBtnMobile');
@@ -3900,7 +4006,6 @@ class ClaudeCodeWebInterface {
3900
4006
  break;
3901
4007
  }
3902
4008
  case 'pasteImage': {
3903
- const pasteSocket = activeSocket;
3904
4009
  try {
3905
4010
  if (navigator.clipboard && typeof navigator.clipboard.read === 'function') {
3906
4011
  const items = await navigator.clipboard.read();
@@ -3911,17 +4016,7 @@ class ClaudeCodeWebInterface {
3911
4016
  if (imageType) {
3912
4017
  const blob = await item.getType(imageType);
3913
4018
  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
- }
4019
+ this._uploadImage(imageData);
3925
4020
  });
3926
4021
  return;
3927
4022
  }
@@ -3949,19 +4044,8 @@ class ClaudeCodeWebInterface {
3949
4044
  { multiple: true }
3950
4045
  );
3951
4046
  } else if (window.imageHandler) {
3952
- const attachSocket = activeSocket;
3953
4047
  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
- }
4048
+ this._uploadImage(imageData);
3965
4049
  });
3966
4050
  }
3967
4051
  break;
@@ -4207,10 +4291,6 @@ class ClaudeCodeWebInterface {
4207
4291
 
4208
4292
  // Capture the socket at attach-initiation time. The image preview modal
4209
4293
  // 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
4294
  // Images: reuse the existing single-preview flow. The modal handles one
4215
4295
  // image at a time, so if several images are selected we attach the first
4216
4296
  // and tell the user rather than silently dropping the rest.
@@ -4219,16 +4299,7 @@ class ClaudeCodeWebInterface {
4219
4299
  window.feedback.info('Only the first image is attached — attach images one at a time.');
4220
4300
  }
4221
4301
  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
- }
4302
+ this._uploadImage(imageData);
4232
4303
  });
4233
4304
  }
4234
4305
 
@@ -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; }
@@ -293,3 +293,42 @@
293
293
  .artifact-panel--maximized .artifact-panel__frame { width: 100%; }
294
294
  .artifact-panel--maximized .artifact-panel__chat { max-height: 30%; }
295
295
  }
296
+
297
+ @media (max-width: 640px) {
298
+ .artifact-panel:not(.artifact-panel--maximized) {
299
+ left: 0 !important;
300
+ top: auto !important;
301
+ right: 0 !important;
302
+ bottom: 0 !important;
303
+ width: 100% !important;
304
+ max-width: none;
305
+ height: 70vh !important;
306
+ height: 70dvh !important;
307
+ min-width: 0;
308
+ border-radius: 12px 12px 0 0;
309
+ box-sizing: border-box;
310
+ padding-bottom: var(--sa-bottom, 0px);
311
+ box-shadow: 0 -10px 32px rgba(0, 0, 0, 0.45);
312
+ }
313
+
314
+ .artifact-panel__resize {
315
+ display: none;
316
+ }
317
+
318
+ .artifact-panel__header {
319
+ min-height: 44px;
320
+ }
321
+
322
+ .artifact-panel__btn {
323
+ display: inline-flex;
324
+ align-items: center;
325
+ justify-content: center;
326
+ min-width: 44px;
327
+ min-height: 44px;
328
+ }
329
+
330
+ .artifact-panel__reopen {
331
+ min-width: 44px;
332
+ min-height: 44px;
333
+ }
334
+ }