claude-simple 0.2.4 → 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.4",
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
@@ -725,7 +725,7 @@ export function sendMessage() {
725
725
  isProcessing.value = true;
726
726
  syncWakeLock();
727
727
  const now = Date.now();
728
- if (text.startsWith('!')) {
728
+ if (isShellInput(text)) {
729
729
  appendMsg('shell', '$ Shell', text.slice(1).trim(), { ts: now });
730
730
  // Typed control plane (legacy shell-command still accepted server-side)
731
731
  const ok = sendWs({
@@ -778,7 +778,7 @@ export function newSession() {
778
778
 
779
779
  // ── Input helpers ─────────────────────────────────────────────────────────────
780
780
  function updateInputMode(value) {
781
- const isShell = value.startsWith('!');
781
+ const isShell = isShellInput(value);
782
782
  const input = $('chat-input'), btn = $('send-btn');
783
783
  if (!input || !btn) return;
784
784
  input.classList.toggle('textarea-warning', isShell);
@@ -787,15 +787,26 @@ function updateInputMode(value) {
787
787
  btn.classList.toggle('btn-primary', !isShell);
788
788
  btn.textContent = isShell ? 'Run' : 'Send';
789
789
  const label = AGENT_LABELS[currentAgent.peek()] || 'agent';
790
- 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);
791
802
  }
792
803
 
793
804
  function chatInputMaxHeight() {
794
805
  // Keep in sync with #chat-input max-height in style.css (mobile vs desktop).
795
806
  if (typeof window !== 'undefined' && window.matchMedia('(max-width: 640px)').matches) {
796
- return 96;
807
+ return 72;
797
808
  }
798
- return 140;
809
+ return 72;
799
810
  }
800
811
 
801
812
  function autoResize(el) {
@@ -908,16 +919,17 @@ let chatDropBound = false;
908
919
 
909
920
  async function uploadChatBlob(blob, filename) {
910
921
  const name = filename || `upload-${Date.now()}`;
922
+ const uploadBlob = await normalizeImageBlob(blob, name);
911
923
  const headers = authHeaders({ 'x-filename': name });
912
924
  delete headers['Content-Type'];
913
925
  // Prefer file MIME so hub base64-encodes binary uploads correctly.
914
- if (blob?.type && !headers['Content-Type'] && !headers['content-type']) {
915
- headers['Content-Type'] = blob.type;
926
+ if (uploadBlob?.type && !headers['Content-Type'] && !headers['content-type']) {
927
+ headers['Content-Type'] = uploadBlob.type;
916
928
  }
917
929
  const res = await fetch('/api/upload-image', {
918
930
  method: 'POST',
919
931
  headers,
920
- body: blob,
932
+ body: uploadBlob,
921
933
  });
922
934
  if (!res.ok) {
923
935
  const err = await res.json().catch(() => ({}));
@@ -928,6 +940,46 @@ async function uploadChatBlob(blob, filename) {
928
940
  return data.path;
929
941
  }
930
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
+ }
981
+ }
982
+
931
983
  function insertAtCursor(ta, text) {
932
984
  if (!ta) return;
933
985
  const s = ta.selectionStart ?? ta.value.length;
@@ -989,11 +1041,12 @@ function renderAttachBar() {
989
1041
  async function addChatAttachment(file) {
990
1042
  if (!file) return;
991
1043
  const name = file.name || `upload-${Date.now()}`;
992
- const path = await uploadChatBlob(file, name);
1044
+ const normalized = await normalizeImageBlob(file, name);
1045
+ const path = await uploadChatBlob(normalized, name);
993
1046
  const { ref, label, kind } = makeAttachRef(file, path);
994
1047
  let previewUrl = '';
995
1048
  if (kind === 'image') {
996
- try { previewUrl = URL.createObjectURL(file); } catch { /* ignore */ }
1049
+ try { previewUrl = URL.createObjectURL(normalized); } catch { /* ignore */ }
997
1050
  }
998
1051
  pendingAttachments.push({ path, ref, label, kind, name, previewUrl });
999
1052
  renderAttachBar();
@@ -1098,8 +1151,8 @@ function appendTokenBar(text) {
1098
1151
  const msgs = $('messages');
1099
1152
  if (!msgs) return;
1100
1153
  msgs.insertAdjacentHTML('beforeend',
1101
- `<div class="flex items-center gap-1.5 px-1 py-0.5 text-[10px] font-mono text-base-content/30 select-none">
1102
- <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>
1103
1156
  </div>`);
1104
1157
  stickBottom(msgs);
1105
1158
  }
@@ -1431,10 +1484,16 @@ export function initChat() {
1431
1484
 
1432
1485
  watch(chatDensity, () => applyChatDensity());
1433
1486
 
1434
- // Chat input: Enter = send, auto-resize, shell mode styling
1487
+ // Chat input: ⌘/Ctrl+Enter = send. On phones, Enter sends (Shift+Enter newline).
1435
1488
  delegate.on('keydown', '#chat-input', e => {
1436
- const send = e.key === 'Enter' && (e.metaKey || e.ctrlKey);
1437
- 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();
1438
1497
  });
1439
1498
  delegate.on('input', '#chat-input', (e, el) => { autoResize(el); updateInputMode(el.value); });
1440
1499
 
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);
@@ -14,12 +14,14 @@ import {
14
14
  refreshMachinesList, hideMachinePicker, showMachinePicker,
15
15
  syncTopbarMachine, startMachinePolling,
16
16
  } from './hub.js';
17
+ import { syncFocusScope } from '../mobile.js';
17
18
 
18
19
  export async function showApp(opts = {}) {
19
20
  $('auth-screen').style.display = 'none';
20
21
  const app = $('app');
21
22
  app.classList.remove('hidden');
22
23
  app.style.display = 'flex';
24
+ queueMicrotask(syncFocusScope);
23
25
 
24
26
  // Use probe result from boot when provided (avoid second /api/hub round-trip)
25
27
  let hub = opts.hub;
@@ -8,6 +8,7 @@ import { api, probeHub } from '../api.js';
8
8
  import { loadAllSessions, loadWorkspaces, loadAgentsMeta } from './session-list.js';
9
9
  import { loadSessionMetaMap } from './notes.js';
10
10
  import { loadMetaAgentBoot } from '../agent-panel.js';
11
+ import { syncFocusScope } from '../mobile.js';
11
12
  // goHome imported dynamically in enterMachine to avoid circular deps
12
13
 
13
14
  export async function refreshMachinesList() {
@@ -54,6 +55,9 @@ export function showMachinePicker() {
54
55
  const el = $('machine-picker');
55
56
  if (!el) return;
56
57
  el.classList.remove('hidden');
58
+ // On phones hidden panels are inert so Safari's form assistant cannot tab
59
+ // into them. Re-enable this picker before its machine buttons are shown.
60
+ syncFocusScope();
57
61
  hubMachineReady.value = false;
58
62
  renderMachinePickerList();
59
63
  const status = $('machine-picker-status');
@@ -65,6 +69,7 @@ export function showMachinePicker() {
65
69
 
66
70
  export function hideMachinePicker() {
67
71
  $('machine-picker')?.classList.add('hidden');
72
+ syncFocusScope();
68
73
  hubMachineReady.value = true;
69
74
  }
70
75
 
@@ -191,4 +196,3 @@ export async function enterMachine(machineId, { forceReload = false } = {}) {
191
196
  await Promise.all([loadAllSessions(), loadWorkspaces(), loadSessionMetaMap(), loadAgentsMeta().catch(() => {})]);
192
197
  hubMachineReady.value = true;
193
198
  }
194
-
@@ -12,10 +12,19 @@ import { connectWS, clearMessages, appendSystemMsg, applyChatDensity } from '../
12
12
  import { refreshModelSelect } from './session-list.js';
13
13
  import { switchTab } from './session-nav.js';
14
14
  import { showMachinePicker } from './hub.js';
15
+ import { syncFocusScope } from '../mobile.js';
15
16
 
16
17
  // ── Sidebar ───────────────────────────────────────────────────────────────────
17
- export function openSidebar() { $('sidebar').classList.add('open'); $('sidebar-overlay').classList.remove('hidden'); }
18
- export function closeSidebar() { $('sidebar').classList.remove('open'); $('sidebar-overlay').classList.add('hidden'); }
18
+ export function openSidebar() {
19
+ $('sidebar').classList.add('open');
20
+ $('sidebar-overlay').classList.remove('hidden');
21
+ syncFocusScope();
22
+ }
23
+ export function closeSidebar() {
24
+ $('sidebar').classList.remove('open');
25
+ $('sidebar-overlay').classList.add('hidden');
26
+ syncFocusScope();
27
+ }
19
28
  export function closeSidebarOnMobile() { if (window.innerWidth <= 640) closeSidebar(); }
20
29
 
21
30
  export function initSidebarToggle() {
@@ -7,6 +7,7 @@ export const AuthScreen = () => `
7
7
  <div class="card-body gap-3 p-8">
8
8
  <h1 class="text-xl font-bold">🤖 Agent UI</h1>
9
9
  <p id="auth-subtitle" class="text-sm text-base-content/60 mb-1">Claude · Codex · Grok</p>
10
+ <form id="auth-form" class="flex flex-col gap-3" autocomplete="on" action="#">
10
11
  <label class="form-control">
11
12
  <div class="label py-1"><span class="label-text text-xs uppercase tracking-wide">Username</span></div>
12
13
  <input id="auth-username" type="text" class="input input-bordered input-sm" placeholder="username" autocomplete="username">
@@ -15,7 +16,8 @@ export const AuthScreen = () => `
15
16
  <div class="label py-1"><span class="label-text text-xs uppercase tracking-wide">Password</span></div>
16
17
  <input id="auth-password" type="password" class="input input-bordered input-sm" placeholder="••••••" autocomplete="current-password">
17
18
  </label>
18
- <button id="auth-btn" class="btn btn-primary btn-sm mt-1" data-mode="login">Sign In</button>
19
+ <button type="submit" id="auth-btn" class="btn btn-primary btn-sm mt-1" data-mode="login">Sign In</button>
20
+ </form>
19
21
  <div id="auth-error" class="alert alert-error text-xs py-2 hidden"></div>
20
22
  </div>
21
23
  </div>
@@ -72,8 +74,8 @@ export const AppShell = (opts = {}) => {
72
74
  <div class="flex gap-2 topbar-actions">
73
75
  <button id="btn-refresh-conn" class="btn btn-ghost btn-xs border border-base-300" title="重新连接并同步">↻</button>
74
76
  <button id="btn-pwa-install" class="btn btn-ghost btn-xs border border-base-300 hidden" title="安装到主屏幕">安装</button>
75
- <button id="btn-meta-agent" class="btn btn-ghost btn-xs border border-primary/40 text-primary" title="Meta Agent — 报告 / 问答 / VLM">✦ Agent</button>
76
- <button id="btn-new-session" class="btn btn-ghost btn-xs border border-base-300">+ New</button>
77
+ <button id="btn-meta-agent" class="btn btn-ghost btn-xs border border-primary/40 text-primary" title="Meta Agent — 报告 / 问答 / VLM"><span class="hidden sm:inline">✦ Agent</span><span class="sm:hidden">✦</span></button>
78
+ <button id="btn-new-session" class="btn btn-ghost btn-xs border border-base-300" title="New session"><span class="hidden sm:inline">+ New</span><span class="sm:hidden">+</span></button>
77
79
  <button id="btn-theme" class="btn btn-ghost btn-xs border border-base-300">🌙</button>
78
80
  <button id="btn-settings" class="btn btn-ghost btn-xs border border-base-300">⚙️</button>
79
81
  <button id="btn-logout" class="btn btn-ghost btn-xs border border-base-300">Sign out</button>
@@ -238,9 +240,10 @@ export const AppShell = (opts = {}) => {
238
240
  <button id="scroll-bottom-btn" class="scroll-fab hidden" title="Jump to bottom">↓</button>
239
241
  </div>
240
242
  <div id="permission-requests"></div>
243
+ <form id="chat-form" autocomplete="off" action="#" class="flex flex-col flex-shrink-0">
241
244
  <div id="chat-composer" class="flex flex-col border-t border-base-300 bg-base-200 flex-shrink-0">
242
245
  <div id="chat-attach-bar" class="hidden flex flex-wrap gap-1.5 px-3 pt-2"></div>
243
- <div id="chat-opts" class="hidden flex items-center gap-1.5 px-3 pt-2 flex-wrap">
246
+ <div id="chat-opts" class="hidden flex items-center gap-1.5 px-3 pt-2 flex-wrap" inert>
244
247
  <select id="sel-agent" class="select select-xs select-bordered text-xs font-semibold" title="Agent">
245
248
  <option value="claude">Claude</option>
246
249
  <option value="codex">Codex</option>
@@ -283,25 +286,37 @@ export const AppShell = (opts = {}) => {
283
286
  <option value="full">density: full</option>
284
287
  </select>
285
288
  </div>
286
- <div id="composer-row" class="flex gap-2 items-end px-3 pt-2 pb-2">
287
- <button type="button" id="btn-opts" class="btn btn-ghost btn-sm px-2 text-base-content/30 hover:text-base-content flex-shrink-0" title="Options">⚙</button>
288
- <button type="button" id="btn-attach" class="btn btn-ghost btn-sm px-2 text-base-content/40 hover:text-base-content flex-shrink-0" title="添加图片 / 文件">📎</button>
289
- <input type="file" id="chat-file-input" class="hidden" multiple accept="image/*,*/*">
290
- <textarea id="chat-input" rows="1" placeholder="Ask… (📎 图片/文件 · !cmd · ⌘↵)"
291
- class="chat-input flex-1 text-sm resize-none leading-relaxed"
292
- name="agent-chat-message"
293
- autocomplete="off"
294
- autocorrect="off"
295
- autocapitalize="off"
296
- spellcheck="false"
297
- data-1p-ignore="true"
298
- data-lpignore="true"
299
- data-form-type="other"
300
- data-bwignore="true"></textarea>
301
- <button type="button" id="send-btn" class="btn btn-primary btn-sm flex-shrink-0">Send</button>
302
- <button type="button" id="stop-btn" class="btn btn-error btn-sm hidden flex-shrink-0">■</button>
289
+ <div id="composer-row" class="px-3 pt-2 pb-2">
290
+ <div class="composer-surface">
291
+ <textarea id="chat-input" rows="1" placeholder="问点什么…"
292
+ class="chat-input w-full text-sm resize-none leading-relaxed"
293
+ name="q"
294
+ enterkeyhint="send"
295
+ inputmode="text"
296
+ autocomplete="off"
297
+ autocorrect="off"
298
+ autocapitalize="sentences"
299
+ spellcheck="false"
300
+ data-1p-ignore="true"
301
+ data-lpignore="true"
302
+ data-form-type="other"
303
+ data-bwignore="true"></textarea>
304
+ <div class="composer-actions">
305
+ <div class="composer-tools">
306
+ <button type="button" id="btn-opts" class="composer-icon-btn" title="Options" aria-label="Options">⚙</button>
307
+ <button type="button" id="btn-attach" class="composer-icon-btn" title="添加图片 / 文件" aria-label="添加图片 / 文件">📎</button>
308
+ <input type="file" id="chat-file-input" class="hidden" multiple accept="image/*,*/*" tabindex="-1">
309
+ </div>
310
+ <div class="composer-submit">
311
+ <span class="composer-hint">Enter 发送</span>
312
+ <button type="submit" id="send-btn" class="composer-send-btn">Send</button>
313
+ <button type="button" id="stop-btn" class="composer-stop-btn hidden" aria-label="停止生成">■</button>
314
+ </div>
315
+ </div>
316
+ </div>
303
317
  </div>
304
318
  </div>
319
+ </form>
305
320
  </div>
306
321
  <div id="tab-files" class="hidden flex-col flex-1 overflow-hidden">
307
322
  <div class="flex items-center gap-2 px-3 py-1.5 border-b border-base-300 flex-shrink-0 bg-base-200">