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/README.md +1 -0
- package/bin/edge-ctl.js +260 -0
- package/dist/{app.0.2.4.min.js → app.0.2.5.min.js} +131 -115
- package/dist/index.html +2 -2
- package/dist/{style.0.2.4.min.css → style.0.2.5.min.css} +194 -58
- package/package.json +6 -2
- package/public/app.js +12 -2
- package/public/chat.js +74 -15
- package/public/index.html +33 -3
- package/public/mobile.js +72 -0
- package/public/pwa.js +32 -2
- package/public/shell/boot.js +2 -0
- package/public/shell/hub.js +5 -1
- package/public/shell/layout.js +11 -2
- package/public/shell/templates.js +36 -21
- package/public/shell.js +34 -4
- package/public/state.js +8 -1
- package/public/style.css +193 -57
- package/public/sw.js +2 -2
- package/src/app.js +21 -7
package/package.json
CHANGED
|
@@ -1,15 +1,19 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "claude-simple",
|
|
3
|
-
"version": "0.2.
|
|
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
|
-
|
|
89
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
+
/** `` 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
|
|
807
|
+
return 72;
|
|
797
808
|
}
|
|
798
|
-
return
|
|
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 (
|
|
915
|
-
headers['Content-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:
|
|
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
|
|
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(
|
|
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="
|
|
1102
|
-
<span class="
|
|
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
|
|
1487
|
+
// Chat input: ⌘/Ctrl+Enter = send. On phones, Enter sends (Shift+Enter newline).
|
|
1435
1488
|
delegate.on('keydown', '#chat-input', e => {
|
|
1436
|
-
|
|
1437
|
-
|
|
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-
|
|
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
|
-
<
|
|
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>
|
package/public/mobile.js
ADDED
|
@@ -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 (!(
|
|
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((
|
|
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);
|
package/public/shell/boot.js
CHANGED
|
@@ -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;
|
package/public/shell/hub.js
CHANGED
|
@@ -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
|
-
|
package/public/shell/layout.js
CHANGED
|
@@ -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() {
|
|
18
|
-
|
|
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="
|
|
287
|
-
<
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
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">
|