claude-simple 0.2.2 → 0.2.4
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/client.js +7 -1
- package/dist/app.0.2.4.min.js +937 -0
- package/dist/index.html +2 -2
- package/dist/{style.0.2.2.min.css → style.0.2.4.min.css} +94 -7
- package/package.json +1 -1
- package/public/chat.js +201 -50
- package/public/index.html +2 -2
- package/public/shell/dashboard.js +64 -1
- package/public/shell/templates.js +22 -16
- package/public/shell.js +28 -11
- package/public/style.css +93 -6
- package/public/sw.js +2 -2
- package/dist/app.0.2.2.min.js +0 -921
package/package.json
CHANGED
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)) {
|
|
@@ -899,74 +901,195 @@ export function appendMsg(role, _label, text, opts = {}) {
|
|
|
899
901
|
|
|
900
902
|
export function appendSystemMsg(text) { appendMsg('system', '', text); }
|
|
901
903
|
|
|
902
|
-
|
|
904
|
+
/** Pending chat attachments shown above the input (paths also inserted into text). */
|
|
905
|
+
const pendingAttachments = [];
|
|
906
|
+
let attachSeq = 0;
|
|
907
|
+
let chatDropBound = false;
|
|
908
|
+
|
|
909
|
+
async function uploadChatBlob(blob, filename) {
|
|
910
|
+
const name = filename || `upload-${Date.now()}`;
|
|
911
|
+
const headers = authHeaders({ 'x-filename': name });
|
|
912
|
+
delete headers['Content-Type'];
|
|
913
|
+
// 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;
|
|
916
|
+
}
|
|
903
917
|
const res = await fetch('/api/upload-image', {
|
|
904
918
|
method: 'POST',
|
|
905
|
-
headers
|
|
919
|
+
headers,
|
|
906
920
|
body: blob,
|
|
907
921
|
});
|
|
908
|
-
if (!res.ok)
|
|
909
|
-
|
|
922
|
+
if (!res.ok) {
|
|
923
|
+
const err = await res.json().catch(() => ({}));
|
|
924
|
+
throw new Error(err.error || `Upload failed: ${res.status}`);
|
|
925
|
+
}
|
|
926
|
+
const data = await res.json();
|
|
927
|
+
if (!data?.path) throw new Error('upload returned no path');
|
|
928
|
+
return data.path;
|
|
910
929
|
}
|
|
911
930
|
|
|
912
931
|
function insertAtCursor(ta, text) {
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
932
|
+
if (!ta) return;
|
|
933
|
+
const s = ta.selectionStart ?? ta.value.length;
|
|
934
|
+
const e = ta.selectionEnd ?? ta.value.length;
|
|
935
|
+
const padBefore = s > 0 && ta.value[s - 1] !== '\n' ? '\n' : '';
|
|
936
|
+
const insert = padBefore + text + '\n';
|
|
937
|
+
ta.value = ta.value.slice(0, s) + insert + ta.value.slice(e);
|
|
938
|
+
ta.selectionStart = ta.selectionEnd = s + insert.length;
|
|
916
939
|
ta.dispatchEvent(new Event('input'));
|
|
917
940
|
}
|
|
918
941
|
|
|
942
|
+
function isImageFile(file) {
|
|
943
|
+
if (!file) return false;
|
|
944
|
+
if (file.type && file.type.startsWith('image/')) return true;
|
|
945
|
+
return /\.(png|jpe?g|gif|webp|bmp|svg|heic|heif|avif)$/i.test(file.name || '');
|
|
946
|
+
}
|
|
947
|
+
|
|
948
|
+
function makeAttachRef(file, path) {
|
|
949
|
+
const name = file?.name || path.split('/').pop() || 'file';
|
|
950
|
+
if (isImageFile(file)) {
|
|
951
|
+
attachSeq += 1;
|
|
952
|
+
const label = `img${attachSeq}`;
|
|
953
|
+
return { ref: ``, label, kind: 'image' };
|
|
954
|
+
}
|
|
955
|
+
attachSeq += 1;
|
|
956
|
+
const label = `file${attachSeq}`;
|
|
957
|
+
return { ref: `[${label}](${path})`, label, kind: 'file' };
|
|
958
|
+
}
|
|
959
|
+
|
|
960
|
+
function clearPendingAttachments() {
|
|
961
|
+
for (const item of pendingAttachments) {
|
|
962
|
+
if (item.previewUrl) {
|
|
963
|
+
try { URL.revokeObjectURL(item.previewUrl); } catch { /* ignore */ }
|
|
964
|
+
}
|
|
965
|
+
}
|
|
966
|
+
pendingAttachments.length = 0;
|
|
967
|
+
renderAttachBar();
|
|
968
|
+
}
|
|
969
|
+
|
|
970
|
+
function renderAttachBar() {
|
|
971
|
+
const bar = $('chat-attach-bar');
|
|
972
|
+
if (!bar) return;
|
|
973
|
+
if (!pendingAttachments.length) {
|
|
974
|
+
bar.classList.add('hidden');
|
|
975
|
+
bar.innerHTML = '';
|
|
976
|
+
return;
|
|
977
|
+
}
|
|
978
|
+
bar.classList.remove('hidden');
|
|
979
|
+
bar.innerHTML = pendingAttachments.map((item, i) => `
|
|
980
|
+
<div class="chat-thumb relative" title="${esc(item.ref || item.path)}">
|
|
981
|
+
${item.previewUrl
|
|
982
|
+
? `<img src="${item.previewUrl}" alt="${esc(item.label || 'img')}">`
|
|
983
|
+
: `<span class="chat-thumb-path">${esc(item.label || item.name || 'file')}</span>`}
|
|
984
|
+
<span class="chat-thumb-label">${esc(item.label || item.kind || '')}</span>
|
|
985
|
+
<button type="button" class="chat-thumb-x" data-rm-attach="${i}" aria-label="remove">✕</button>
|
|
986
|
+
</div>`).join('');
|
|
987
|
+
}
|
|
988
|
+
|
|
989
|
+
async function addChatAttachment(file) {
|
|
990
|
+
if (!file) return;
|
|
991
|
+
const name = file.name || `upload-${Date.now()}`;
|
|
992
|
+
const path = await uploadChatBlob(file, name);
|
|
993
|
+
const { ref, label, kind } = makeAttachRef(file, path);
|
|
994
|
+
let previewUrl = '';
|
|
995
|
+
if (kind === 'image') {
|
|
996
|
+
try { previewUrl = URL.createObjectURL(file); } catch { /* ignore */ }
|
|
997
|
+
}
|
|
998
|
+
pendingAttachments.push({ path, ref, label, kind, name, previewUrl });
|
|
999
|
+
renderAttachBar();
|
|
1000
|
+
insertAtCursor($('chat-input'), ref);
|
|
1001
|
+
return path;
|
|
1002
|
+
}
|
|
1003
|
+
|
|
1004
|
+
async function uploadFilesList(fileList) {
|
|
1005
|
+
const files = [...(fileList || [])].filter(Boolean);
|
|
1006
|
+
if (!files.length) return;
|
|
1007
|
+
const ta = $('chat-input');
|
|
1008
|
+
if (ta) ta.disabled = true;
|
|
1009
|
+
try {
|
|
1010
|
+
for (const file of files) {
|
|
1011
|
+
try {
|
|
1012
|
+
await addChatAttachment(file);
|
|
1013
|
+
} catch (err) {
|
|
1014
|
+
appendSystemMsg(`上传失败 ${file.name || 'file'}: ${err.message}`);
|
|
1015
|
+
}
|
|
1016
|
+
}
|
|
1017
|
+
} finally {
|
|
1018
|
+
if (ta) {
|
|
1019
|
+
ta.disabled = false;
|
|
1020
|
+
ta.focus();
|
|
1021
|
+
}
|
|
1022
|
+
}
|
|
1023
|
+
}
|
|
1024
|
+
|
|
1025
|
+
/** Open file picker (images + any files). */
|
|
1026
|
+
export function attachImage() {
|
|
1027
|
+
const input = $('chat-file-input');
|
|
1028
|
+
if (input) {
|
|
1029
|
+
input.value = '';
|
|
1030
|
+
input.click();
|
|
1031
|
+
return;
|
|
1032
|
+
}
|
|
1033
|
+
const fallback = document.createElement('input');
|
|
1034
|
+
fallback.type = 'file';
|
|
1035
|
+
fallback.accept = 'image/*,*/*';
|
|
1036
|
+
fallback.multiple = true;
|
|
1037
|
+
fallback.addEventListener('change', () => uploadFilesList(fallback.files));
|
|
1038
|
+
fallback.click();
|
|
1039
|
+
}
|
|
1040
|
+
|
|
919
1041
|
export function initImagePaste() {
|
|
920
1042
|
document.addEventListener('paste', async e => {
|
|
921
1043
|
const ta = $('chat-input');
|
|
922
|
-
if (!ta
|
|
1044
|
+
if (!ta) return;
|
|
1045
|
+
// Accept paste when focusing input, or when composer is active
|
|
1046
|
+
const composer = $('chat-composer');
|
|
1047
|
+
const inComposer = composer && (composer.contains(document.activeElement) || document.activeElement === ta);
|
|
1048
|
+
if (!inComposer && document.activeElement !== ta) return;
|
|
1049
|
+
|
|
923
1050
|
const items = [...(e.clipboardData?.items || [])];
|
|
924
|
-
const
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
ta.disabled = true;
|
|
931
|
-
try {
|
|
932
|
-
const path = await uploadImageBlob(blob, name);
|
|
933
|
-
insertAtCursor(ta, ``);
|
|
934
|
-
} catch (err) {
|
|
935
|
-
appendSystemMsg(`Image paste failed: ${err.message}`);
|
|
936
|
-
} finally {
|
|
937
|
-
ta.disabled = false;
|
|
938
|
-
ta.focus();
|
|
1051
|
+
const files = [];
|
|
1052
|
+
for (const it of items) {
|
|
1053
|
+
if (it.kind === 'file') {
|
|
1054
|
+
const f = it.getAsFile();
|
|
1055
|
+
if (f) files.push(f);
|
|
1056
|
+
}
|
|
939
1057
|
}
|
|
1058
|
+
// Prefer image paste; also allow non-image files from clipboard when present
|
|
1059
|
+
const imageFiles = files.filter(f => isImageFile(f));
|
|
1060
|
+
const pick = imageFiles.length ? imageFiles : files;
|
|
1061
|
+
if (!pick.length) return;
|
|
1062
|
+
e.preventDefault();
|
|
1063
|
+
await uploadFilesList(pick);
|
|
940
1064
|
});
|
|
941
|
-
}
|
|
942
1065
|
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
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}`); }
|
|
1066
|
+
if (chatDropBound) return;
|
|
1067
|
+
chatDropBound = true;
|
|
1068
|
+
document.addEventListener('dragover', e => {
|
|
1069
|
+
const composer = $('chat-composer');
|
|
1070
|
+
if (!composer || !e.dataTransfer?.types?.includes('Files')) return;
|
|
1071
|
+
if (!composer.contains(e.target) && e.target !== composer) return;
|
|
1072
|
+
e.preventDefault();
|
|
1073
|
+
composer.classList.add('chat-drop-active');
|
|
1074
|
+
});
|
|
1075
|
+
document.addEventListener('dragleave', e => {
|
|
1076
|
+
const composer = $('chat-composer');
|
|
1077
|
+
if (!composer) return;
|
|
1078
|
+
if (e.target === composer || composer.contains(e.target)) {
|
|
1079
|
+
// leaving to outside
|
|
1080
|
+
if (!composer.contains(e.relatedTarget)) composer.classList.remove('chat-drop-active');
|
|
967
1081
|
}
|
|
968
1082
|
});
|
|
969
|
-
|
|
1083
|
+
document.addEventListener('drop', async e => {
|
|
1084
|
+
const composer = $('chat-composer');
|
|
1085
|
+
if (!composer) return;
|
|
1086
|
+
composer.classList.remove('chat-drop-active');
|
|
1087
|
+
if (!composer.contains(e.target) && e.target !== composer) return;
|
|
1088
|
+
const files = [...(e.dataTransfer?.files || [])];
|
|
1089
|
+
if (!files.length) return;
|
|
1090
|
+
e.preventDefault();
|
|
1091
|
+
await uploadFilesList(files);
|
|
1092
|
+
});
|
|
970
1093
|
}
|
|
971
1094
|
|
|
972
1095
|
function fmtNum(n) { return n >= 1000 ? `${(n/1000).toFixed(1)}k` : String(n); }
|
|
@@ -1315,6 +1438,34 @@ export function initChat() {
|
|
|
1315
1438
|
});
|
|
1316
1439
|
delegate.on('input', '#chat-input', (e, el) => { autoResize(el); updateInputMode(el.value); });
|
|
1317
1440
|
|
|
1441
|
+
// Attach images / files
|
|
1442
|
+
delegate.on('click', '#btn-attach', e => {
|
|
1443
|
+
e.preventDefault();
|
|
1444
|
+
attachImage();
|
|
1445
|
+
});
|
|
1446
|
+
delegate.on('change', '#chat-file-input', (e, el) => {
|
|
1447
|
+
const files = el?.files;
|
|
1448
|
+
if (files?.length) uploadFilesList(files);
|
|
1449
|
+
if (el) el.value = '';
|
|
1450
|
+
});
|
|
1451
|
+
delegate.on('click', '[data-rm-attach]', (e, el) => {
|
|
1452
|
+
e.preventDefault();
|
|
1453
|
+
e.stopPropagation();
|
|
1454
|
+
const i = parseInt(el.dataset.rmAttach, 10);
|
|
1455
|
+
if (Number.isNaN(i) || i < 0 || i >= pendingAttachments.length) return;
|
|
1456
|
+
const item = pendingAttachments[i];
|
|
1457
|
+
if (item?.previewUrl) {
|
|
1458
|
+
try { URL.revokeObjectURL(item.previewUrl); } catch { /* ignore */ }
|
|
1459
|
+
}
|
|
1460
|
+
const ta = $('chat-input');
|
|
1461
|
+
if (ta && item?.ref) {
|
|
1462
|
+
ta.value = ta.value.split(item.ref).join('').replace(/\n{3,}/g, '\n\n');
|
|
1463
|
+
ta.dispatchEvent(new Event('input'));
|
|
1464
|
+
}
|
|
1465
|
+
pendingAttachments.splice(i, 1);
|
|
1466
|
+
renderAttachBar();
|
|
1467
|
+
});
|
|
1468
|
+
|
|
1318
1469
|
// Permission buttons → approval.respond (typed control plane)
|
|
1319
1470
|
delegate.on('click', '[data-perm-id]', (e, el) => {
|
|
1320
1471
|
sendWs({
|
package/public/index.html
CHANGED
|
@@ -65,10 +65,10 @@
|
|
|
65
65
|
<script src="https://cdn.jsdelivr.net/npm/marked@9/marked.min.js"></script>
|
|
66
66
|
<link id="hljs-theme" rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@highlightjs/cdn-assets@11/styles/github-dark.min.css">
|
|
67
67
|
<script src="https://cdn.jsdelivr.net/npm/@highlightjs/cdn-assets@11/highlight.min.js"></script>
|
|
68
|
-
<link rel="stylesheet" href="style.css?v=ui-
|
|
68
|
+
<link rel="stylesheet" href="style.css?v=ui-9">
|
|
69
69
|
</head>
|
|
70
70
|
<body class="h-dvh overflow-hidden flex flex-col bg-base-100 text-base-content">
|
|
71
71
|
<div id="root" class="flex flex-col flex-1 overflow-hidden"></div>
|
|
72
|
-
<script type="module" src="app.js?v=ui-
|
|
72
|
+
<script type="module" src="app.js?v=ui-9"></script>
|
|
73
73
|
</body>
|
|
74
74
|
</html>
|
|
@@ -266,7 +266,70 @@ export function updateRecentSessionsList(sessions) {
|
|
|
266
266
|
}
|
|
267
267
|
|
|
268
268
|
export function closeRecentMenu() {
|
|
269
|
-
$('recent-menu')
|
|
269
|
+
const menu = $('recent-menu');
|
|
270
|
+
if (menu) {
|
|
271
|
+
menu.classList.add('hidden');
|
|
272
|
+
menu.classList.remove('recent-menu-open');
|
|
273
|
+
// clear inline placement for next open
|
|
274
|
+
menu.style.top = '';
|
|
275
|
+
menu.style.left = '';
|
|
276
|
+
menu.style.right = '';
|
|
277
|
+
menu.style.width = '';
|
|
278
|
+
menu.style.maxHeight = '';
|
|
279
|
+
}
|
|
280
|
+
$('recent-menu-backdrop')?.classList.add('hidden');
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/**
|
|
284
|
+
* Place the panel under the topbar with position:fixed so it is never clipped
|
|
285
|
+
* by #app / #topbar overflow (the previous top-full + fixed combo put it
|
|
286
|
+
* below the viewport on phones — looked like “no response”).
|
|
287
|
+
*/
|
|
288
|
+
export function positionRecentMenu() {
|
|
289
|
+
const menu = $('recent-menu');
|
|
290
|
+
const topbar = $('topbar');
|
|
291
|
+
if (!menu || !topbar) return;
|
|
292
|
+
const rect = topbar.getBoundingClientRect();
|
|
293
|
+
const gap = 6;
|
|
294
|
+
const top = Math.round(rect.bottom + gap);
|
|
295
|
+
const side = window.matchMedia('(max-width: 640px)').matches ? 8 : null;
|
|
296
|
+
menu.style.top = `${top}px`;
|
|
297
|
+
if (side != null) {
|
|
298
|
+
menu.style.left = `${side}px`;
|
|
299
|
+
menu.style.right = `${side}px`;
|
|
300
|
+
} else {
|
|
301
|
+
// desktop: align under the “最近” button when possible
|
|
302
|
+
const btn = $('btn-recent-sessions');
|
|
303
|
+
const br = btn?.getBoundingClientRect();
|
|
304
|
+
if (br) {
|
|
305
|
+
const width = Math.min(320, window.innerWidth - 16);
|
|
306
|
+
let left = br.left;
|
|
307
|
+
if (left + width > window.innerWidth - 8) left = window.innerWidth - width - 8;
|
|
308
|
+
if (left < 8) left = 8;
|
|
309
|
+
menu.style.left = `${Math.round(left)}px`;
|
|
310
|
+
menu.style.right = 'auto';
|
|
311
|
+
menu.style.width = `${width}px`;
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
const maxH = Math.max(160, window.innerHeight - top - 12);
|
|
315
|
+
menu.style.maxHeight = `${Math.min(maxH, Math.round(window.innerHeight * 0.7))}px`;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
export function openRecentMenu(sessions) {
|
|
319
|
+
const menu = $('recent-menu');
|
|
320
|
+
if (!menu) return;
|
|
321
|
+
renderRecentSessionsMenu(sessions ?? getDashboardSessions());
|
|
322
|
+
menu.classList.remove('hidden');
|
|
323
|
+
menu.classList.add('recent-menu-open');
|
|
324
|
+
positionRecentMenu();
|
|
325
|
+
$('recent-menu-backdrop')?.classList.remove('hidden');
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
export function toggleRecentMenu(sessions) {
|
|
329
|
+
const menu = $('recent-menu');
|
|
330
|
+
if (!menu) return;
|
|
331
|
+
if (menu.classList.contains('hidden')) openRecentMenu(sessions);
|
|
332
|
+
else closeRecentMenu();
|
|
270
333
|
}
|
|
271
334
|
|
|
272
335
|
/** Topbar quick-jump: last N sessions by updatedAt. */
|
|
@@ -61,24 +61,15 @@ export const AppShell = (opts = {}) => {
|
|
|
61
61
|
</div>
|
|
62
62
|
</div>
|
|
63
63
|
<div id="recent-menu-wrap" class="relative flex-shrink-0">
|
|
64
|
-
<button type="button" id="btn-recent-sessions" class="btn btn-ghost btn-xs border border-base-300 gap-1"
|
|
65
|
-
title="最近会话 / 快速跳转">
|
|
64
|
+
<button type="button" id="btn-recent-sessions" class="btn btn-ghost btn-xs border border-base-300 gap-1 min-h-8 px-2"
|
|
65
|
+
title="最近会话 / 快速跳转" aria-haspopup="listbox" aria-expanded="false">
|
|
66
66
|
<span>最近</span>
|
|
67
67
|
<span class="text-[10px] opacity-50">▾</span>
|
|
68
68
|
</button>
|
|
69
|
-
<div id="recent-menu" class="hidden absolute left-0 top-full mt-1 z-50 w-80 max-h-[min(24rem,70vh)] overflow-y-auto
|
|
70
|
-
rounded-lg border border-base-300 bg-base-100 shadow-xl p-2">
|
|
71
|
-
<div class="flex items-center justify-between px-1 pb-1.5 mb-1 border-b border-base-300 sticky top-0 bg-base-100 z-[1]">
|
|
72
|
-
<span class="text-[10px] uppercase tracking-wide text-base-content/45 font-semibold">Recent sessions</span>
|
|
73
|
-
<span id="recent-menu-count" class="text-[10px] text-base-content/35 font-mono"></span>
|
|
74
|
-
</div>
|
|
75
|
-
<div id="recent-menu-list" class="flex flex-col gap-0.5"></div>
|
|
76
|
-
<p id="recent-menu-empty" class="text-xs text-base-content/40 px-1 py-2 hidden">暂无最近会话</p>
|
|
77
|
-
</div>
|
|
78
69
|
</div>
|
|
79
70
|
<span id="topbar-project" class="text-sm text-base-content/50 flex-1 truncate min-w-0">Select a session</span>
|
|
80
71
|
<span id="conn-dot" class="w-2 h-2 rounded-full bg-base-content/30 flex-shrink-0" title="连接状态"></span>
|
|
81
|
-
<div class="flex gap-2">
|
|
72
|
+
<div class="flex gap-2 topbar-actions">
|
|
82
73
|
<button id="btn-refresh-conn" class="btn btn-ghost btn-xs border border-base-300" title="重新连接并同步">↻</button>
|
|
83
74
|
<button id="btn-pwa-install" class="btn btn-ghost btn-xs border border-base-300 hidden" title="安装到主屏幕">安装</button>
|
|
84
75
|
<button id="btn-meta-agent" class="btn btn-ghost btn-xs border border-primary/40 text-primary" title="Meta Agent — 报告 / 问答 / VLM">✦ Agent</button>
|
|
@@ -88,6 +79,18 @@ export const AppShell = (opts = {}) => {
|
|
|
88
79
|
<button id="btn-logout" class="btn btn-ghost btn-xs border border-base-300">Sign out</button>
|
|
89
80
|
</div>
|
|
90
81
|
</div>
|
|
82
|
+
<!-- Fixed panel + backdrop: siblings of topbar so flex layout never clips them -->
|
|
83
|
+
<div id="recent-menu-backdrop" class="hidden fixed inset-0 z-[55] bg-black/40" aria-hidden="true"></div>
|
|
84
|
+
<div id="recent-menu" class="hidden fixed z-[60] w-80 max-h-[min(24rem,70vh)] overflow-y-auto
|
|
85
|
+
rounded-lg border border-base-300 bg-base-100 shadow-xl p-2"
|
|
86
|
+
role="listbox" aria-label="最近会话">
|
|
87
|
+
<div class="flex items-center justify-between px-1 pb-1.5 mb-1 border-b border-base-300 sticky top-0 bg-base-100 z-[1]">
|
|
88
|
+
<span class="text-[10px] uppercase tracking-wide text-base-content/45 font-semibold">最近会话</span>
|
|
89
|
+
<span id="recent-menu-count" class="text-[10px] text-base-content/35 font-mono"></span>
|
|
90
|
+
</div>
|
|
91
|
+
<div id="recent-menu-list" class="flex flex-col gap-0.5"></div>
|
|
92
|
+
<p id="recent-menu-empty" class="text-xs text-base-content/40 px-1 py-2 hidden">暂无最近会话</p>
|
|
93
|
+
</div>
|
|
91
94
|
<!-- Hub: only when no remembered machine (or last one offline) -->
|
|
92
95
|
<div id="machine-picker" class="hidden fixed inset-0 z-[60] bg-base-100 flex items-center justify-center p-4">
|
|
93
96
|
<div class="w-full max-w-md">
|
|
@@ -236,6 +239,7 @@ export const AppShell = (opts = {}) => {
|
|
|
236
239
|
</div>
|
|
237
240
|
<div id="permission-requests"></div>
|
|
238
241
|
<div id="chat-composer" class="flex flex-col border-t border-base-300 bg-base-200 flex-shrink-0">
|
|
242
|
+
<div id="chat-attach-bar" class="hidden flex flex-wrap gap-1.5 px-3 pt-2"></div>
|
|
239
243
|
<div id="chat-opts" class="hidden flex items-center gap-1.5 px-3 pt-2 flex-wrap">
|
|
240
244
|
<select id="sel-agent" class="select select-xs select-bordered text-xs font-semibold" title="Agent">
|
|
241
245
|
<option value="claude">Claude</option>
|
|
@@ -280,8 +284,10 @@ export const AppShell = (opts = {}) => {
|
|
|
280
284
|
</select>
|
|
281
285
|
</div>
|
|
282
286
|
<div id="composer-row" class="flex gap-2 items-end px-3 pt-2 pb-2">
|
|
283
|
-
<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>
|
|
284
|
-
<
|
|
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 · ⌘↵)"
|
|
285
291
|
class="chat-input flex-1 text-sm resize-none leading-relaxed"
|
|
286
292
|
name="agent-chat-message"
|
|
287
293
|
autocomplete="off"
|
|
@@ -292,8 +298,8 @@ export const AppShell = (opts = {}) => {
|
|
|
292
298
|
data-lpignore="true"
|
|
293
299
|
data-form-type="other"
|
|
294
300
|
data-bwignore="true"></textarea>
|
|
295
|
-
<button id="send-btn" class="btn btn-primary btn-sm flex-shrink-0">Send</button>
|
|
296
|
-
<button id="stop-btn" class="btn btn-error btn-sm hidden flex-shrink-0">■</button>
|
|
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>
|
|
297
303
|
</div>
|
|
298
304
|
</div>
|
|
299
305
|
</div>
|
package/public/shell.js
CHANGED
|
@@ -26,7 +26,7 @@ import { openMetaAgent, toggleMetaAgent } from './agent-panel.js';
|
|
|
26
26
|
import { AuthScreen, AppShell } from './shell/templates.js';
|
|
27
27
|
import {
|
|
28
28
|
updateDashboard, shiftCalMonth, setSelectedCalDate, getDashboardSessions,
|
|
29
|
-
|
|
29
|
+
openRecentMenu, toggleRecentMenu, closeRecentMenu, positionRecentMenu,
|
|
30
30
|
} from './shell/dashboard.js';
|
|
31
31
|
import {
|
|
32
32
|
loadAllSessions, loadWorkspaces, renderSessionList, syncSidebarChrome,
|
|
@@ -570,26 +570,43 @@ export function initShell(opts = {}) {
|
|
|
570
570
|
}
|
|
571
571
|
});
|
|
572
572
|
delegate.on('click', '#btn-recent-sessions', e => {
|
|
573
|
+
e.preventDefault();
|
|
573
574
|
e.stopPropagation();
|
|
574
575
|
closeMachineMenu();
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
576
|
+
toggleRecentMenu(getDashboardSessions());
|
|
577
|
+
const open = !$('recent-menu')?.classList.contains('hidden');
|
|
578
|
+
const btn = $('btn-recent-sessions');
|
|
579
|
+
if (btn) btn.setAttribute('aria-expanded', open ? 'true' : 'false');
|
|
580
|
+
});
|
|
581
|
+
delegate.on('click', '#recent-menu-backdrop', e => {
|
|
582
|
+
e.preventDefault();
|
|
583
|
+
e.stopPropagation();
|
|
584
|
+
closeRecentMenu();
|
|
585
|
+
$('btn-recent-sessions')?.setAttribute('aria-expanded', 'false');
|
|
583
586
|
});
|
|
584
587
|
document.addEventListener('click', e => {
|
|
585
588
|
const machineWrap = $('machine-menu-wrap');
|
|
586
589
|
if (machineWrap && !machineWrap.contains(e.target)) closeMachineMenu();
|
|
587
|
-
const
|
|
588
|
-
|
|
590
|
+
const menu = $('recent-menu');
|
|
591
|
+
const btn = $('btn-recent-sessions');
|
|
592
|
+
const t = e.target;
|
|
593
|
+
// Menu is portaled (fixed) outside the button wrap — check both
|
|
594
|
+
if (menu && !menu.classList.contains('hidden')) {
|
|
595
|
+
if (menu.contains(t) || btn?.contains(t)) return;
|
|
596
|
+
closeRecentMenu();
|
|
597
|
+
btn?.setAttribute('aria-expanded', 'false');
|
|
598
|
+
}
|
|
599
|
+
});
|
|
600
|
+
// Keep panel glued under topbar on rotate / URL bar show-hide
|
|
601
|
+
window.addEventListener('resize', () => {
|
|
602
|
+
if ($('recent-menu') && !$('recent-menu').classList.contains('hidden')) {
|
|
603
|
+
positionRecentMenu();
|
|
604
|
+
}
|
|
589
605
|
});
|
|
590
606
|
// After picking a session from the topbar menu, close it (row click also resumes).
|
|
591
607
|
delegate.on('click', '#recent-menu [data-session-id]', () => {
|
|
592
608
|
closeRecentMenu();
|
|
609
|
+
$('btn-recent-sessions')?.setAttribute('aria-expanded', 'false');
|
|
593
610
|
});
|
|
594
611
|
|
|
595
612
|
delegate.on('click', '#btn-edit-project-notes, #project-goal-text, #project-notes-text', () => {
|
package/public/style.css
CHANGED
|
@@ -28,6 +28,7 @@
|
|
|
28
28
|
line-height: 1.35 !important;
|
|
29
29
|
}
|
|
30
30
|
#btn-opts,
|
|
31
|
+
#btn-attach,
|
|
31
32
|
#send-btn,
|
|
32
33
|
#stop-btn {
|
|
33
34
|
height: 36px !important;
|
|
@@ -884,11 +885,71 @@ html.pwa-standalone .ma-panel {
|
|
|
884
885
|
/* ── Chat composer ───────────────────────────────────────────────────────── */
|
|
885
886
|
/* Single outer border only — no DaisyUI nested focus ring / inner outline. */
|
|
886
887
|
#composer-row #btn-opts,
|
|
888
|
+
#composer-row #btn-attach,
|
|
887
889
|
#composer-row #send-btn,
|
|
888
890
|
#composer-row #stop-btn {
|
|
889
891
|
height: 40px;
|
|
890
892
|
min-height: 40px;
|
|
891
893
|
}
|
|
894
|
+
#chat-composer.chat-drop-active {
|
|
895
|
+
outline: 2px dashed color-mix(in oklch, var(--color-primary) 70%, transparent);
|
|
896
|
+
outline-offset: -2px;
|
|
897
|
+
background: color-mix(in oklch, var(--color-primary) 8%, var(--color-base-200));
|
|
898
|
+
}
|
|
899
|
+
#chat-attach-bar:not(.hidden) {
|
|
900
|
+
display: flex;
|
|
901
|
+
}
|
|
902
|
+
.chat-thumb {
|
|
903
|
+
width: 56px;
|
|
904
|
+
height: 56px;
|
|
905
|
+
border-radius: 8px;
|
|
906
|
+
overflow: hidden;
|
|
907
|
+
border: 1px solid color-mix(in oklch, var(--color-base-content) 14%, transparent);
|
|
908
|
+
position: relative;
|
|
909
|
+
flex-shrink: 0;
|
|
910
|
+
background: var(--color-base-100);
|
|
911
|
+
}
|
|
912
|
+
.chat-thumb img { width: 100%; height: 100%; object-fit: cover; }
|
|
913
|
+
.chat-thumb-path {
|
|
914
|
+
display: flex;
|
|
915
|
+
align-items: center;
|
|
916
|
+
justify-content: center;
|
|
917
|
+
width: 100%;
|
|
918
|
+
height: 100%;
|
|
919
|
+
font-size: 10px;
|
|
920
|
+
padding: 4px;
|
|
921
|
+
text-align: center;
|
|
922
|
+
word-break: break-all;
|
|
923
|
+
line-height: 1.15;
|
|
924
|
+
color: color-mix(in oklch, var(--color-base-content) 70%, transparent);
|
|
925
|
+
background: color-mix(in oklch, var(--color-base-content) 6%, var(--color-base-100));
|
|
926
|
+
}
|
|
927
|
+
.chat-thumb-label {
|
|
928
|
+
position: absolute;
|
|
929
|
+
left: 0;
|
|
930
|
+
bottom: 0;
|
|
931
|
+
right: 0;
|
|
932
|
+
font-size: 9px;
|
|
933
|
+
text-align: center;
|
|
934
|
+
background: rgba(0,0,0,.55);
|
|
935
|
+
color: #fff;
|
|
936
|
+
line-height: 1.2;
|
|
937
|
+
padding: 1px 0;
|
|
938
|
+
}
|
|
939
|
+
.chat-thumb-x {
|
|
940
|
+
position: absolute;
|
|
941
|
+
top: 0;
|
|
942
|
+
right: 0;
|
|
943
|
+
background: rgba(0,0,0,.65);
|
|
944
|
+
color: #fff;
|
|
945
|
+
border: 0;
|
|
946
|
+
font-size: 10px;
|
|
947
|
+
width: 16px;
|
|
948
|
+
height: 16px;
|
|
949
|
+
cursor: pointer;
|
|
950
|
+
line-height: 1;
|
|
951
|
+
z-index: 1;
|
|
952
|
+
}
|
|
892
953
|
#chat-input.chat-input,
|
|
893
954
|
#chat-input {
|
|
894
955
|
width: 100%;
|
|
@@ -931,17 +992,43 @@ html.pwa-standalone .ma-panel {
|
|
|
931
992
|
transition: background-color 99999s ease-in-out 0s;
|
|
932
993
|
}
|
|
933
994
|
|
|
934
|
-
/* Recent sessions topbar
|
|
995
|
+
/* Recent sessions panel (position set in JS under topbar; never use top:100% with fixed) */
|
|
935
996
|
#recent-menu {
|
|
936
997
|
min-width: 16rem;
|
|
998
|
+
overscroll-behavior: contain;
|
|
999
|
+
-webkit-overflow-scrolling: touch;
|
|
1000
|
+
}
|
|
1001
|
+
#recent-menu.recent-menu-open {
|
|
1002
|
+
display: block; /* beat .hidden if both race */
|
|
937
1003
|
}
|
|
938
1004
|
@media (max-width: 640px) {
|
|
939
|
-
#recent-menu
|
|
940
|
-
|
|
941
|
-
left: 0.5rem;
|
|
942
|
-
right: 0.5rem;
|
|
1005
|
+
#recent-menu {
|
|
1006
|
+
min-width: 0;
|
|
943
1007
|
width: auto;
|
|
944
|
-
|
|
1008
|
+
}
|
|
1009
|
+
/* Keep 最近 + hamburger reachable; tuck secondary actions */
|
|
1010
|
+
#topbar {
|
|
1011
|
+
overflow: visible;
|
|
1012
|
+
gap: 0.35rem;
|
|
1013
|
+
}
|
|
1014
|
+
#topbar .topbar-actions {
|
|
1015
|
+
gap: 0.25rem;
|
|
1016
|
+
flex-shrink: 0;
|
|
1017
|
+
}
|
|
1018
|
+
#btn-theme,
|
|
1019
|
+
#btn-settings,
|
|
1020
|
+
#btn-logout,
|
|
1021
|
+
#btn-pwa-install {
|
|
1022
|
+
display: none !important;
|
|
1023
|
+
}
|
|
1024
|
+
#topbar-project {
|
|
1025
|
+
font-size: 11px;
|
|
1026
|
+
max-width: 30vw;
|
|
1027
|
+
}
|
|
1028
|
+
#recent-menu-list .recent-menu-item {
|
|
1029
|
+
min-height: 44px; /* comfortable touch targets */
|
|
1030
|
+
padding-top: 0.5rem;
|
|
1031
|
+
padding-bottom: 0.5rem;
|
|
945
1032
|
}
|
|
946
1033
|
}
|
|
947
1034
|
#chat-input:focus,
|