claude-simple 0.2.3 → 0.2.5

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,15 +1,19 @@
1
1
  {
2
2
  "name": "claude-simple",
3
- "version": "0.2.3",
3
+ "version": "0.2.5",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "claude-simple": "server.js",
7
- "claude-simple-client": "client.js"
7
+ "claude-simple-client": "client.js",
8
+ "claude-simple-edge": "bin/edge-ctl.js",
9
+ "claude-edge": "bin/edge-ctl.js",
10
+ "edge-daemon": "bin/edge-ctl.js"
8
11
  },
9
12
  "files": [
10
13
  "server.js",
11
14
  "client.js",
12
15
  "gateway.js",
16
+ "bin",
13
17
  "src",
14
18
  "dist",
15
19
  "public"
package/public/app.js CHANGED
@@ -11,6 +11,7 @@ import { initImagePaste } from './chat.js';
11
11
  import { initMetaAgent } from './agent-panel.js';
12
12
  import { initRouter, applyInitialRoute, parseHashPath } from './router.js';
13
13
  import { initPwa } from './pwa.js';
14
+ import { initMobileChrome } from './mobile.js';
14
15
 
15
16
  function markBootReady() {
16
17
  const root = document.documentElement;
@@ -28,6 +29,7 @@ const restoreSession = shouldRestoreSessionOnBoot() || !!(ctx.token && deepLink?
28
29
 
29
30
  // 1. Build DOM (welcome already suppressed via html.boot-restore CSS when restoring)
30
31
  initShell({ restoreSession });
32
+ initMobileChrome();
31
33
 
32
34
  // 2. Feature panels
33
35
  initChat();
@@ -85,8 +87,16 @@ initRouter(resumeSession, switchTab);
85
87
  }
86
88
  } else {
87
89
  clearBootRestore();
88
- await initAuth();
89
- markBootReady();
90
+ try {
91
+ await initAuth();
92
+ } catch (e) {
93
+ // A failed status probe must never leave the installed app with its
94
+ // whole UI hidden behind boot-pending. initAuth normally handles
95
+ // connection failures itself; this is the last-resort guard.
96
+ console.error('[boot auth]', e);
97
+ } finally {
98
+ markBootReady();
99
+ }
90
100
  }
91
101
  })();
92
102
 
package/public/chat.js CHANGED
@@ -3,7 +3,7 @@ import { watch, delegate, esc, $ } from './lib.js';
3
3
  import { ctx, isProcessing, currentProject, currentTab, currentModel, currentEffort, currentPermission,
4
4
  currentAgent, setAgent, AGENT_LABELS, getDefaultModel, getModelsForAgent, getEffortsForModel,
5
5
  addCustomModel, chatDensity, wsStatus } from './state.js';
6
- import { sendWs, api, flushWsQueue, clearWsQueue } from './api.js';
6
+ import { sendWs, api, authHeaders, flushWsQueue, clearWsQueue } from './api.js';
7
7
  import { initWakeLock, setWakeLockDesired } from './wake-lock.js';
8
8
 
9
9
  function assistantLabel() {
@@ -706,6 +706,7 @@ export function sendMessage() {
706
706
  input.value = '';
707
707
  autoResize(input);
708
708
  updateInputMode('');
709
+ clearPendingAttachments();
709
710
  appendSystemMsg(`已排队下一条,当前回复结束后发送:${text.length > 80 ? text.slice(0, 80) + '…' : text}`);
710
711
  return;
711
712
  }
@@ -713,6 +714,7 @@ export function sendMessage() {
713
714
  input.value = '';
714
715
  autoResize(input);
715
716
  updateInputMode('');
717
+ clearPendingAttachments();
716
718
 
717
719
  if (text.startsWith('/')) {
718
720
  if (handleSlashCommand(text)) {
@@ -723,7 +725,7 @@ export function sendMessage() {
723
725
  isProcessing.value = true;
724
726
  syncWakeLock();
725
727
  const now = Date.now();
726
- if (text.startsWith('!')) {
728
+ if (isShellInput(text)) {
727
729
  appendMsg('shell', '$ Shell', text.slice(1).trim(), { ts: now });
728
730
  // Typed control plane (legacy shell-command still accepted server-side)
729
731
  const ok = sendWs({
@@ -776,7 +778,7 @@ export function newSession() {
776
778
 
777
779
  // ── Input helpers ─────────────────────────────────────────────────────────────
778
780
  function updateInputMode(value) {
779
- const isShell = value.startsWith('!');
781
+ const isShell = isShellInput(value);
780
782
  const input = $('chat-input'), btn = $('send-btn');
781
783
  if (!input || !btn) return;
782
784
  input.classList.toggle('textarea-warning', isShell);
@@ -785,15 +787,26 @@ function updateInputMode(value) {
785
787
  btn.classList.toggle('btn-primary', !isShell);
786
788
  btn.textContent = isShell ? 'Run' : 'Send';
787
789
  const label = AGENT_LABELS[currentAgent.peek()] || 'agent';
788
- input.placeholder = isShell ? 'Shell command (without !)…' : `Ask ${label}… or !cmd for shell`;
790
+ const compact = typeof window !== 'undefined' && window.matchMedia('(max-width: 640px)').matches;
791
+ if (isShell) {
792
+ input.placeholder = compact ? 'Shell 命令…' : 'Shell command (without !)…';
793
+ } else {
794
+ input.placeholder = compact ? `问 ${label}…` : `Ask ${label}… or !cmd for shell`;
795
+ }
796
+ }
797
+
798
+ /** `![img](path)` is an attachment reference, not a shell command. */
799
+ function isShellInput(value) {
800
+ const text = String(value || '').trimStart();
801
+ return text.startsWith('!') && !/^!\[[^\]]*\]\([^)]+\)/.test(text);
789
802
  }
790
803
 
791
804
  function chatInputMaxHeight() {
792
805
  // Keep in sync with #chat-input max-height in style.css (mobile vs desktop).
793
806
  if (typeof window !== 'undefined' && window.matchMedia('(max-width: 640px)').matches) {
794
- return 96;
807
+ return 72;
795
808
  }
796
- return 140;
809
+ return 72;
797
810
  }
798
811
 
799
812
  function autoResize(el) {
@@ -899,74 +912,237 @@ export function appendMsg(role, _label, text, opts = {}) {
899
912
 
900
913
  export function appendSystemMsg(text) { appendMsg('system', '', text); }
901
914
 
902
- async function uploadImageBlob(blob, filename) {
915
+ /** Pending chat attachments shown above the input (paths also inserted into text). */
916
+ const pendingAttachments = [];
917
+ let attachSeq = 0;
918
+ let chatDropBound = false;
919
+
920
+ async function uploadChatBlob(blob, filename) {
921
+ const name = filename || `upload-${Date.now()}`;
922
+ const uploadBlob = await normalizeImageBlob(blob, name);
923
+ const headers = authHeaders({ 'x-filename': name });
924
+ delete headers['Content-Type'];
925
+ // Prefer file MIME so hub base64-encodes binary uploads correctly.
926
+ if (uploadBlob?.type && !headers['Content-Type'] && !headers['content-type']) {
927
+ headers['Content-Type'] = uploadBlob.type;
928
+ }
903
929
  const res = await fetch('/api/upload-image', {
904
930
  method: 'POST',
905
- headers: { Authorization: `Bearer ${ctx.token}`, 'x-filename': filename },
906
- body: blob,
931
+ headers,
932
+ body: uploadBlob,
907
933
  });
908
- if (!res.ok) throw new Error(`Upload failed: ${res.status}`);
909
- return (await res.json()).path;
934
+ if (!res.ok) {
935
+ const err = await res.json().catch(() => ({}));
936
+ throw new Error(err.error || `Upload failed: ${res.status}`);
937
+ }
938
+ const data = await res.json();
939
+ if (!data?.path) throw new Error('upload returned no path');
940
+ return data.path;
941
+ }
942
+
943
+ function imageMimeFromBytes(bytes, fallback) {
944
+ if (bytes.length >= 8 && bytes[0] === 0x89 && bytes[1] === 0x50 && bytes[2] === 0x4e && bytes[3] === 0x47) return 'image/png';
945
+ if (bytes.length >= 3 && bytes[0] === 0xff && bytes[1] === 0xd8 && bytes[2] === 0xff) return 'image/jpeg';
946
+ if (bytes.length >= 6 && String.fromCharCode(...bytes.slice(0, 6)) === 'GIF87a') return 'image/gif';
947
+ if (bytes.length >= 12 && String.fromCharCode(...bytes.slice(8, 12)) === 'WEBP') return 'image/webp';
948
+ return fallback || 'image/png';
949
+ }
950
+
951
+ /** Some iOS share sheets provide a .png whose bytes are Base64 text. Decode it once
952
+ * before both upload and preview, while leaving ordinary image files untouched. */
953
+ async function normalizeImageBlob(blob, filename) {
954
+ if (!blob || !isImageFile({ type: blob.type, name: filename })) return blob;
955
+ let head;
956
+ try { head = (await blob.slice(0, 80).text()).trim(); } catch { return blob; }
957
+ // Avoid reading an entire multi-megabyte ordinary photo into a JS string.
958
+ if (!/^(?:data:image\/[a-z0-9.+-]+;base64,|iVBORw0KGgo|\/9j\/|R0lGOD|UklGR)/i.test(head)) return blob;
959
+ let text;
960
+ try { text = (await blob.text()).trim(); } catch { return blob; }
961
+ const dataUrl = /^data:image\/[a-z0-9.+-]+;base64,([a-z0-9+/=\s]+)$/i.exec(text);
962
+ const payload = dataUrl?.[1] || text;
963
+ if (!payload || !/^[a-z0-9+/=\s]+$/i.test(payload) || payload.length % 4 === 1) return blob;
964
+ try {
965
+ const binary = atob(payload.replace(/\s/g, ''));
966
+ const bytes = Uint8Array.from(binary, c => c.charCodeAt(0));
967
+ const mime = imageMimeFromBytes(bytes, blob.type);
968
+ // A valid Base64 string alone is not enough: only replace it when it decodes
969
+ // to an actual image signature.
970
+ if (mime === (blob.type || 'image/png') && !/^(image\/png|image\/jpeg|image\/gif|image\/webp)$/.test(mime)) return blob;
971
+ const isImage = bytes.length >= 3 && (
972
+ (bytes[0] === 0x89 && bytes[1] === 0x50 && bytes[2] === 0x4e)
973
+ || (bytes[0] === 0xff && bytes[1] === 0xd8 && bytes[2] === 0xff)
974
+ || String.fromCharCode(...bytes.slice(0, 6)) === 'GIF87a'
975
+ || String.fromCharCode(...bytes.slice(8, 12)) === 'WEBP'
976
+ );
977
+ return isImage ? new Blob([bytes], { type: mime }) : blob;
978
+ } catch {
979
+ return blob;
980
+ }
910
981
  }
911
982
 
912
983
  function insertAtCursor(ta, text) {
913
- const s = ta.selectionStart, e = ta.selectionEnd;
914
- ta.value = ta.value.slice(0, s) + text + ta.value.slice(e);
915
- ta.selectionStart = ta.selectionEnd = s + text.length;
984
+ if (!ta) return;
985
+ const s = ta.selectionStart ?? ta.value.length;
986
+ const e = ta.selectionEnd ?? ta.value.length;
987
+ const padBefore = s > 0 && ta.value[s - 1] !== '\n' ? '\n' : '';
988
+ const insert = padBefore + text + '\n';
989
+ ta.value = ta.value.slice(0, s) + insert + ta.value.slice(e);
990
+ ta.selectionStart = ta.selectionEnd = s + insert.length;
916
991
  ta.dispatchEvent(new Event('input'));
917
992
  }
918
993
 
994
+ function isImageFile(file) {
995
+ if (!file) return false;
996
+ if (file.type && file.type.startsWith('image/')) return true;
997
+ return /\.(png|jpe?g|gif|webp|bmp|svg|heic|heif|avif)$/i.test(file.name || '');
998
+ }
999
+
1000
+ function makeAttachRef(file, path) {
1001
+ const name = file?.name || path.split('/').pop() || 'file';
1002
+ if (isImageFile(file)) {
1003
+ attachSeq += 1;
1004
+ const label = `img${attachSeq}`;
1005
+ return { ref: `![${label}](${path})`, label, kind: 'image' };
1006
+ }
1007
+ attachSeq += 1;
1008
+ const label = `file${attachSeq}`;
1009
+ return { ref: `[${label}](${path})`, label, kind: 'file' };
1010
+ }
1011
+
1012
+ function clearPendingAttachments() {
1013
+ for (const item of pendingAttachments) {
1014
+ if (item.previewUrl) {
1015
+ try { URL.revokeObjectURL(item.previewUrl); } catch { /* ignore */ }
1016
+ }
1017
+ }
1018
+ pendingAttachments.length = 0;
1019
+ renderAttachBar();
1020
+ }
1021
+
1022
+ function renderAttachBar() {
1023
+ const bar = $('chat-attach-bar');
1024
+ if (!bar) return;
1025
+ if (!pendingAttachments.length) {
1026
+ bar.classList.add('hidden');
1027
+ bar.innerHTML = '';
1028
+ return;
1029
+ }
1030
+ bar.classList.remove('hidden');
1031
+ bar.innerHTML = pendingAttachments.map((item, i) => `
1032
+ <div class="chat-thumb relative" title="${esc(item.ref || item.path)}">
1033
+ ${item.previewUrl
1034
+ ? `<img src="${item.previewUrl}" alt="${esc(item.label || 'img')}">`
1035
+ : `<span class="chat-thumb-path">${esc(item.label || item.name || 'file')}</span>`}
1036
+ <span class="chat-thumb-label">${esc(item.label || item.kind || '')}</span>
1037
+ <button type="button" class="chat-thumb-x" data-rm-attach="${i}" aria-label="remove">✕</button>
1038
+ </div>`).join('');
1039
+ }
1040
+
1041
+ async function addChatAttachment(file) {
1042
+ if (!file) return;
1043
+ const name = file.name || `upload-${Date.now()}`;
1044
+ const normalized = await normalizeImageBlob(file, name);
1045
+ const path = await uploadChatBlob(normalized, name);
1046
+ const { ref, label, kind } = makeAttachRef(file, path);
1047
+ let previewUrl = '';
1048
+ if (kind === 'image') {
1049
+ try { previewUrl = URL.createObjectURL(normalized); } catch { /* ignore */ }
1050
+ }
1051
+ pendingAttachments.push({ path, ref, label, kind, name, previewUrl });
1052
+ renderAttachBar();
1053
+ insertAtCursor($('chat-input'), ref);
1054
+ return path;
1055
+ }
1056
+
1057
+ async function uploadFilesList(fileList) {
1058
+ const files = [...(fileList || [])].filter(Boolean);
1059
+ if (!files.length) return;
1060
+ const ta = $('chat-input');
1061
+ if (ta) ta.disabled = true;
1062
+ try {
1063
+ for (const file of files) {
1064
+ try {
1065
+ await addChatAttachment(file);
1066
+ } catch (err) {
1067
+ appendSystemMsg(`上传失败 ${file.name || 'file'}: ${err.message}`);
1068
+ }
1069
+ }
1070
+ } finally {
1071
+ if (ta) {
1072
+ ta.disabled = false;
1073
+ ta.focus();
1074
+ }
1075
+ }
1076
+ }
1077
+
1078
+ /** Open file picker (images + any files). */
1079
+ export function attachImage() {
1080
+ const input = $('chat-file-input');
1081
+ if (input) {
1082
+ input.value = '';
1083
+ input.click();
1084
+ return;
1085
+ }
1086
+ const fallback = document.createElement('input');
1087
+ fallback.type = 'file';
1088
+ fallback.accept = 'image/*,*/*';
1089
+ fallback.multiple = true;
1090
+ fallback.addEventListener('change', () => uploadFilesList(fallback.files));
1091
+ fallback.click();
1092
+ }
1093
+
919
1094
  export function initImagePaste() {
920
1095
  document.addEventListener('paste', async e => {
921
1096
  const ta = $('chat-input');
922
- if (!ta || document.activeElement !== ta) return;
1097
+ if (!ta) return;
1098
+ // Accept paste when focusing input, or when composer is active
1099
+ const composer = $('chat-composer');
1100
+ const inComposer = composer && (composer.contains(document.activeElement) || document.activeElement === ta);
1101
+ if (!inComposer && document.activeElement !== ta) return;
1102
+
923
1103
  const items = [...(e.clipboardData?.items || [])];
924
- const imageItem = items.find(i => i.type.startsWith('image/'));
925
- if (!imageItem) return;
926
- e.preventDefault();
927
- const blob = imageItem.getAsFile();
928
- const ext = imageItem.type.split('/')[1] || 'png';
929
- const name = `paste-${Date.now()}.${ext}`;
930
- ta.disabled = true;
931
- try {
932
- const path = await uploadImageBlob(blob, name);
933
- insertAtCursor(ta, `![${name}](${path})`);
934
- } catch (err) {
935
- appendSystemMsg(`Image paste failed: ${err.message}`);
936
- } finally {
937
- ta.disabled = false;
938
- ta.focus();
1104
+ const files = [];
1105
+ for (const it of items) {
1106
+ if (it.kind === 'file') {
1107
+ const f = it.getAsFile();
1108
+ if (f) files.push(f);
1109
+ }
939
1110
  }
1111
+ // Prefer image paste; also allow non-image files from clipboard when present
1112
+ const imageFiles = files.filter(f => isImageFile(f));
1113
+ const pick = imageFiles.length ? imageFiles : files;
1114
+ if (!pick.length) return;
1115
+ e.preventDefault();
1116
+ await uploadFilesList(pick);
940
1117
  });
941
- }
942
1118
 
943
- export function attachImage() {
944
- const input = document.createElement('input');
945
- input.type = 'file';
946
- input.accept = 'image/*';
947
- input.multiple = true;
948
- input.addEventListener('change', async () => {
949
- for (const file of input.files) {
950
- appendSystemMsg(`Uploading ${file.name}…`);
951
- try {
952
- const res = await fetch('/api/upload-image', {
953
- method: 'POST',
954
- headers: { Authorization: `Bearer ${ctx.token}`, 'x-filename': file.name },
955
- body: file,
956
- });
957
- const { path } = await res.json();
958
- // Insert path reference into chat input
959
- const ta = $('chat-input');
960
- if (ta) {
961
- ta.value += (ta.value ? '\n' : '') + path;
962
- ta.dispatchEvent(new Event('input'));
963
- ta.focus();
964
- }
965
- appendSystemMsg(`📎 ${path}`);
966
- } catch (e) { appendSystemMsg(`Upload failed: ${e.message}`); }
1119
+ if (chatDropBound) return;
1120
+ chatDropBound = true;
1121
+ document.addEventListener('dragover', e => {
1122
+ const composer = $('chat-composer');
1123
+ if (!composer || !e.dataTransfer?.types?.includes('Files')) return;
1124
+ if (!composer.contains(e.target) && e.target !== composer) return;
1125
+ e.preventDefault();
1126
+ composer.classList.add('chat-drop-active');
1127
+ });
1128
+ document.addEventListener('dragleave', e => {
1129
+ const composer = $('chat-composer');
1130
+ if (!composer) return;
1131
+ if (e.target === composer || composer.contains(e.target)) {
1132
+ // leaving to outside
1133
+ if (!composer.contains(e.relatedTarget)) composer.classList.remove('chat-drop-active');
967
1134
  }
968
1135
  });
969
- input.click();
1136
+ document.addEventListener('drop', async e => {
1137
+ const composer = $('chat-composer');
1138
+ if (!composer) return;
1139
+ composer.classList.remove('chat-drop-active');
1140
+ if (!composer.contains(e.target) && e.target !== composer) return;
1141
+ const files = [...(e.dataTransfer?.files || [])];
1142
+ if (!files.length) return;
1143
+ e.preventDefault();
1144
+ await uploadFilesList(files);
1145
+ });
970
1146
  }
971
1147
 
972
1148
  function fmtNum(n) { return n >= 1000 ? `${(n/1000).toFixed(1)}k` : String(n); }
@@ -975,8 +1151,8 @@ function appendTokenBar(text) {
975
1151
  const msgs = $('messages');
976
1152
  if (!msgs) return;
977
1153
  msgs.insertAdjacentHTML('beforeend',
978
- `<div class="flex items-center gap-1.5 px-1 py-0.5 text-[10px] font-mono text-base-content/30 select-none">
979
- <span class="text-base-content/20">◈</span>${esc(text)}
1154
+ `<div class="token-bar select-none">
1155
+ <span class="token-bar-mark">◈</span><span class="token-bar-text">${esc(text)}</span>
980
1156
  </div>`);
981
1157
  stickBottom(msgs);
982
1158
  }
@@ -1308,13 +1484,47 @@ export function initChat() {
1308
1484
 
1309
1485
  watch(chatDensity, () => applyChatDensity());
1310
1486
 
1311
- // Chat input: Enter = send, auto-resize, shell mode styling
1487
+ // Chat input: ⌘/Ctrl+Enter = send. On phones, Enter sends (Shift+Enter newline).
1312
1488
  delegate.on('keydown', '#chat-input', e => {
1313
- const send = e.key === 'Enter' && (e.metaKey || e.ctrlKey);
1314
- if (send) { e.preventDefault(); sendMessage(); }
1489
+ if (e.key !== 'Enter') return;
1490
+ const combo = e.metaKey || e.ctrlKey;
1491
+ const phone = window.matchMedia('(max-width: 640px)').matches && !e.shiftKey && !combo;
1492
+ if (combo || phone) { e.preventDefault(); sendMessage(); }
1493
+ });
1494
+ delegate.on('submit', '#chat-form', e => {
1495
+ e.preventDefault();
1496
+ sendMessage();
1315
1497
  });
1316
1498
  delegate.on('input', '#chat-input', (e, el) => { autoResize(el); updateInputMode(el.value); });
1317
1499
 
1500
+ // Attach images / files
1501
+ delegate.on('click', '#btn-attach', e => {
1502
+ e.preventDefault();
1503
+ attachImage();
1504
+ });
1505
+ delegate.on('change', '#chat-file-input', (e, el) => {
1506
+ const files = el?.files;
1507
+ if (files?.length) uploadFilesList(files);
1508
+ if (el) el.value = '';
1509
+ });
1510
+ delegate.on('click', '[data-rm-attach]', (e, el) => {
1511
+ e.preventDefault();
1512
+ e.stopPropagation();
1513
+ const i = parseInt(el.dataset.rmAttach, 10);
1514
+ if (Number.isNaN(i) || i < 0 || i >= pendingAttachments.length) return;
1515
+ const item = pendingAttachments[i];
1516
+ if (item?.previewUrl) {
1517
+ try { URL.revokeObjectURL(item.previewUrl); } catch { /* ignore */ }
1518
+ }
1519
+ const ta = $('chat-input');
1520
+ if (ta && item?.ref) {
1521
+ ta.value = ta.value.split(item.ref).join('').replace(/\n{3,}/g, '\n\n');
1522
+ ta.dispatchEvent(new Event('input'));
1523
+ }
1524
+ pendingAttachments.splice(i, 1);
1525
+ renderAttachBar();
1526
+ });
1527
+
1318
1528
  // Permission buttons → approval.respond (typed control plane)
1319
1529
  delegate.on('click', '[data-perm-id]', (e, el) => {
1320
1530
  sendWs({
package/public/index.html CHANGED
@@ -27,6 +27,24 @@
27
27
  html.boot-ready #root {
28
28
  visibility: visible !important;
29
29
  }
30
+ /* If a cached/incomplete module cannot start, do not leave a standalone
31
+ iOS/Android window as a featureless dark screen. */
32
+ #boot-fallback {
33
+ position: fixed;
34
+ inset: 0;
35
+ display: flex;
36
+ align-items: center;
37
+ justify-content: center;
38
+ padding: 1.5rem;
39
+ background: #0f172a;
40
+ color: #e2e8f0;
41
+ font: 15px/1.5 system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
42
+ text-align: center;
43
+ }
44
+ #boot-fallback-error { display: none; }
45
+ html.boot-ready #boot-fallback { display: none; }
46
+ html.boot-timeout #boot-fallback-loading { display: none; }
47
+ html.boot-timeout #boot-fallback-error { display: block; }
30
48
  /* Session deep-link: never show welcome calendar during boot */
31
49
  html.boot-restore #welcome {
32
50
  display: none !important;
@@ -45,7 +63,7 @@
45
63
  display: none !important;
46
64
  }
47
65
  </style>
48
- <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, viewport-fit=cover">
66
+ <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, viewport-fit=cover, interactive-widget=resizes-content">
49
67
  <meta name="theme-color" content="#0f172a" media="(prefers-color-scheme: dark)">
50
68
  <meta name="theme-color" content="#0f172a">
51
69
  <meta name="color-scheme" content="dark light">
@@ -65,10 +83,22 @@
65
83
  <script src="https://cdn.jsdelivr.net/npm/marked@9/marked.min.js"></script>
66
84
  <link id="hljs-theme" rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@highlightjs/cdn-assets@11/styles/github-dark.min.css">
67
85
  <script src="https://cdn.jsdelivr.net/npm/@highlightjs/cdn-assets@11/highlight.min.js"></script>
68
- <link rel="stylesheet" href="style.css?v=ui-9">
86
+ <link rel="stylesheet" href="style.css?v=ui-10">
69
87
  </head>
70
88
  <body class="h-dvh overflow-hidden flex flex-col bg-base-100 text-base-content">
71
89
  <div id="root" class="flex flex-col flex-1 overflow-hidden"></div>
72
- <script type="module" src="app.js?v=ui-9"></script>
90
+ <div id="boot-fallback" role="status" aria-live="polite">
91
+ <div id="boot-fallback-loading">正在打开 Agent UI…</div>
92
+ <div id="boot-fallback-error">应用未能完成加载。请确认网络和服务可用,然后刷新或从主屏幕重新打开。</div>
93
+ </div>
94
+ <script>
95
+ setTimeout(function () {
96
+ if (document.documentElement.classList.contains('boot-pending')
97
+ && !document.documentElement.classList.contains('boot-ready')) {
98
+ document.documentElement.classList.add('boot-timeout');
99
+ }
100
+ }, 10000);
101
+ </script>
102
+ <script type="module" src="app.js?v=ui-10"></script>
73
103
  </body>
74
104
  </html>
@@ -0,0 +1,72 @@
1
+ // mobile.js — visual viewport (iOS keyboard) + isolate hidden form fields
2
+ // Safari's accessory bar / "Not Secure" chrome cannot be painted over from the page.
3
+
4
+ function setInert(el, on) {
5
+ if (!el) return;
6
+ if (on) el.setAttribute('inert', '');
7
+ else el.removeAttribute('inert');
8
+ }
9
+
10
+ function isHidden(el) {
11
+ if (!el) return true;
12
+ if (el.classList.contains('hidden')) return true;
13
+ const d = el.style.display;
14
+ return d === 'none';
15
+ }
16
+
17
+ /** Keep iOS form-assistant from tabbing through off-screen / hidden inputs. */
18
+ export function syncFocusScope() {
19
+ const mobile = typeof window !== 'undefined'
20
+ && window.matchMedia('(max-width: 640px)').matches;
21
+
22
+ const sidebar = document.getElementById('sidebar');
23
+ if (sidebar) {
24
+ const open = !mobile || sidebar.classList.contains('open');
25
+ setInert(sidebar, !open);
26
+ }
27
+
28
+ setInert(document.getElementById('welcome'), isHidden(document.getElementById('welcome')));
29
+ setInert(document.getElementById('auth-screen'), isHidden(document.getElementById('auth-screen')));
30
+ setInert(document.getElementById('chat-opts'), isHidden(document.getElementById('chat-opts')));
31
+ setInert(document.getElementById('machine-picker'), isHidden(document.getElementById('machine-picker')));
32
+
33
+ for (const id of ['files', 'git', 'shell', 'memory']) {
34
+ const el = document.getElementById(`tab-${id}`);
35
+ setInert(el, isHidden(el));
36
+ }
37
+
38
+ const chat = document.getElementById('tab-chat');
39
+ if (chat) setInert(chat, isHidden(chat));
40
+ }
41
+
42
+ function applyVisualViewport() {
43
+ const vv = window.visualViewport;
44
+ const root = document.documentElement;
45
+ if (!vv) return;
46
+
47
+ const keyboardish = vv.height < window.innerHeight - 80;
48
+ root.classList.toggle('vv-keyboard', keyboardish);
49
+ root.style.setProperty('--vvh', `${Math.round(vv.height)}px`);
50
+ root.style.setProperty('--vv-top', `${Math.round(vv.offsetTop)}px`);
51
+ }
52
+
53
+ export function initMobileChrome() {
54
+ syncFocusScope();
55
+ applyVisualViewport();
56
+
57
+ const vv = window.visualViewport;
58
+ if (vv) {
59
+ vv.addEventListener('resize', applyVisualViewport);
60
+ vv.addEventListener('scroll', applyVisualViewport);
61
+ }
62
+ window.addEventListener('resize', () => {
63
+ applyVisualViewport();
64
+ syncFocusScope();
65
+ });
66
+ window.addEventListener('orientationchange', () => {
67
+ setTimeout(() => {
68
+ applyVisualViewport();
69
+ syncFocusScope();
70
+ }, 250);
71
+ });
72
+ }
package/public/pwa.js CHANGED
@@ -11,6 +11,24 @@ function isStandalone() {
11
11
  );
12
12
  }
13
13
 
14
+ function isIpHost(host) {
15
+ const h = String(host || '').replace(/^\[|\]$/g, '');
16
+ if (/^\d{1,3}(\.\d{1,3}){3}$/.test(h)) return true;
17
+ // Bare IPv6 (no zone id)
18
+ if (h.includes(':') && /^[0-9a-f:]+$/i.test(h)) return true;
19
+ return false;
20
+ }
21
+
22
+ /** SW requires a trusted TLS cert. IP + self-signed HTTPS always fails fetch(sw.js). */
23
+ function canRegisterSw() {
24
+ if (!('serviceWorker' in navigator)) return false;
25
+ if (!window.isSecureContext) return false;
26
+ const host = location.hostname;
27
+ if (host === 'localhost' || host === '127.0.0.1' || host === '::1') return true;
28
+ if (isIpHost(host)) return false;
29
+ return location.protocol === 'https:';
30
+ }
31
+
14
32
  function showInstallButton() {
15
33
  document.getElementById('btn-pwa-install')?.classList.remove('hidden');
16
34
  }
@@ -40,7 +58,14 @@ export function initPwa() {
40
58
  requestAnimationFrame(() => showInstallButton());
41
59
  }
42
60
 
43
- if (!('serviceWorker' in navigator)) return;
61
+ if (!canRegisterSw()) {
62
+ if ('serviceWorker' in navigator) {
63
+ navigator.serviceWorker.getRegistrations()
64
+ .then((regs) => regs.forEach((r) => r.unregister()))
65
+ .catch(() => {});
66
+ }
67
+ return;
68
+ }
44
69
 
45
70
  // Register after first paint so install doesn't compete with boot.
46
71
  const register = () => {
@@ -56,7 +81,12 @@ export function initPwa() {
56
81
  });
57
82
  });
58
83
  })
59
- .catch((err) => console.warn('[pwa] SW register failed', err));
84
+ .catch(() => {
85
+ // Typical on self-signed / corporate MITM certs. App works without SW.
86
+ navigator.serviceWorker.getRegistrations()
87
+ .then((regs) => regs.forEach((r) => r.unregister()))
88
+ .catch(() => {});
89
+ });
60
90
  };
61
91
 
62
92
  if (document.readyState === 'complete') setTimeout(register, 0);