agenter-app 1.1.0 → 1.3.0
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 +168 -130
- package/cli.js +43 -0
- package/lib/memory.js +101 -0
- package/lib/terminal.js +127 -0
- package/lib/update.js +69 -0
- package/package.json +1 -1
- package/public/app.js +500 -21
- package/public/index.html +89 -10
- package/public/style.css +119 -10
- package/scripts/pick-folder.ps1 +85 -0
- package/server.js +205 -17
package/public/app.js
CHANGED
|
@@ -92,6 +92,7 @@ const ICONS = {
|
|
|
92
92
|
tool: '<path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z"/>',
|
|
93
93
|
code: '<polyline points="16 18 22 12 16 6"/><polyline points="8 6 2 12 8 18"/>',
|
|
94
94
|
send: '<path d="m22 2-7 20-4-9-9-4Z"/><path d="M22 2 11 13"/>',
|
|
95
|
+
image: '<rect width="18" height="18" x="3" y="3" rx="2"/><circle cx="9" cy="9" r="2"/><path d="m21 15-3.1-3.1a2 2 0 0 0-2.8 0L6 21"/>',
|
|
95
96
|
check: '<path d="M20 6 9 17l-5-5"/>',
|
|
96
97
|
shield: '<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10"/>',
|
|
97
98
|
reply: '<path d="M7.9 20A9 9 0 1 0 4 16.1L2 22Z"/>',
|
|
@@ -102,6 +103,7 @@ const ICONS = {
|
|
|
102
103
|
refresh: '<path d="M3 12a9 9 0 0 1 9-9 9.75 9.75 0 0 1 6.74 2.74L21 8"/><path d="M21 3v5h-5"/><path d="M21 12a9 9 0 0 1-9 9 9.75 9.75 0 0 1-6.74-2.74L3 16"/><path d="M8 16H3v5"/>',
|
|
103
104
|
gauge: '<path d="m12 14 4-4"/><path d="M3.34 19a10 10 0 1 1 17.32 0"/>',
|
|
104
105
|
preview: '<rect width="18" height="18" x="3" y="3" rx="2"/><path d="M15 3v18"/>',
|
|
106
|
+
dots: '<circle cx="12" cy="12" r="1"/><circle cx="19" cy="12" r="1"/><circle cx="5" cy="12" r="1"/>',
|
|
105
107
|
};
|
|
106
108
|
const icon = (name) => `<svg class="ic" viewBox="0 0 24 24" aria-hidden="true">${ICONS[name] || ICONS.tool}</svg>`;
|
|
107
109
|
|
|
@@ -211,6 +213,7 @@ function connect() {
|
|
|
211
213
|
const es = new EventSource(`/events?client=${CLIENT_ID}&focused=${hasAttention() ? 1 : 0}&token=${encodeURIComponent(TOKEN)}`);
|
|
212
214
|
es.addEventListener('state', (e) => apply(JSON.parse(e.data)));
|
|
213
215
|
es.addEventListener('quit', () => window.close());
|
|
216
|
+
es.addEventListener('terminal', (e) => onTerminal(JSON.parse(e.data)));
|
|
214
217
|
es.onopen = () => (setConn(true), reportFocus(true)); // after a reconnect the URL's focus flag may be stale
|
|
215
218
|
es.onerror = async () => {
|
|
216
219
|
setConn(false);
|
|
@@ -229,6 +232,11 @@ function setConn(ok) {
|
|
|
229
232
|
}
|
|
230
233
|
|
|
231
234
|
function apply(snap) {
|
|
235
|
+
// The server restarted into a new version, so this page is out of date.
|
|
236
|
+
if (state.version && snap.version !== state.version) return location.reload();
|
|
237
|
+
state.version = snap.version;
|
|
238
|
+
state.update = snap.update;
|
|
239
|
+
state.terminal = snap.terminal;
|
|
232
240
|
const prevEventAt = sessionById(state.selected)?.lastEventAt;
|
|
233
241
|
state.sessions = snap.sessions;
|
|
234
242
|
state.pending = snap.pending;
|
|
@@ -421,6 +429,8 @@ function render() {
|
|
|
421
429
|
else renderLiveRow();
|
|
422
430
|
renderDock();
|
|
423
431
|
renderPreview();
|
|
432
|
+
renderTerm();
|
|
433
|
+
renderUpdate();
|
|
424
434
|
markKeyTarget();
|
|
425
435
|
renderUsage();
|
|
426
436
|
refreshAges();
|
|
@@ -486,6 +496,129 @@ function fitTextarea(ta) {
|
|
|
486
496
|
ta.style.height = Math.min(ta.scrollHeight, 260) + 'px';
|
|
487
497
|
}
|
|
488
498
|
|
|
499
|
+
// ---------- image attachments ----------
|
|
500
|
+
// Images waiting to go with a message, by the box they belong to: a card's data-id, or
|
|
501
|
+
// 'new' for the New session dialog. Kept out of the DOM so re-renders don't drop them.
|
|
502
|
+
const attachments = new Map();
|
|
503
|
+
const MAX_ATTACH = 8;
|
|
504
|
+
const MAX_ATTACH_BYTES = 10 * 1024 * 1024;
|
|
505
|
+
const MAX_ATTACH_TOTAL = 22 * 1024 * 1024; // the request body stays under the server's limit
|
|
506
|
+
const IMAGE_TYPE = /^image\/(png|jpeg|gif|webp)$/;
|
|
507
|
+
|
|
508
|
+
function thumbsHtml(key) {
|
|
509
|
+
return (attachments.get(key) || [])
|
|
510
|
+
.map((src, i) => `<span class="thumb"><img src="${esc(src)}" alt="Attached image ${i + 1}"><button type="button" class="thumb-x" data-unattach="${i}" title="Remove image" aria-label="Remove image ${i + 1}">×</button></span>`)
|
|
511
|
+
.join('');
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
const attachRow = (key) => `<div class="attach-row" data-attach="${esc(key)}"${attachments.get(key)?.length ? '' : ' hidden'}>${thumbsHtml(key)}</div>`;
|
|
515
|
+
|
|
516
|
+
function renderThumbs(key) {
|
|
517
|
+
document.querySelectorAll(`[data-attach="${CSS.escape(key)}"]`).forEach((el) => {
|
|
518
|
+
el.innerHTML = thumbsHtml(key);
|
|
519
|
+
el.hidden = !attachments.get(key)?.length;
|
|
520
|
+
});
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
const readDataUrl = (file) =>
|
|
524
|
+
new Promise((resolve, reject) => {
|
|
525
|
+
const r = new FileReader();
|
|
526
|
+
r.onload = () => resolve(r.result);
|
|
527
|
+
r.onerror = () => reject(r.error);
|
|
528
|
+
r.readAsDataURL(file);
|
|
529
|
+
});
|
|
530
|
+
|
|
531
|
+
// Returns whether there were any images to take.
|
|
532
|
+
async function addImages(key, files) {
|
|
533
|
+
const imgs = [...(files || [])].filter((f) => IMAGE_TYPE.test(f.type));
|
|
534
|
+
if (!imgs.length) {
|
|
535
|
+
if ([...(files || [])].length) toast('Only PNG, JPEG, GIF and WebP images');
|
|
536
|
+
return false;
|
|
537
|
+
}
|
|
538
|
+
const list = [...(attachments.get(key) || [])];
|
|
539
|
+
let total = list.reduce((n, s) => n + s.length * 0.75, 0);
|
|
540
|
+
for (const f of imgs) {
|
|
541
|
+
if (list.length >= MAX_ATTACH) {
|
|
542
|
+
toast(`At most ${MAX_ATTACH} images per message`);
|
|
543
|
+
break;
|
|
544
|
+
}
|
|
545
|
+
if (f.size > MAX_ATTACH_BYTES) {
|
|
546
|
+
toast('Each image must be under 10 MB');
|
|
547
|
+
continue;
|
|
548
|
+
}
|
|
549
|
+
if (total + f.size > MAX_ATTACH_TOTAL) {
|
|
550
|
+
toast('That’s too much for one message. Send some images separately');
|
|
551
|
+
break;
|
|
552
|
+
}
|
|
553
|
+
list.push(await readDataUrl(f));
|
|
554
|
+
total += f.size;
|
|
555
|
+
}
|
|
556
|
+
attachments.set(key, list);
|
|
557
|
+
renderThumbs(key);
|
|
558
|
+
return true;
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
function takeAttachments(key) {
|
|
562
|
+
const list = attachments.get(key) || [];
|
|
563
|
+
attachments.delete(key);
|
|
564
|
+
renderThumbs(key);
|
|
565
|
+
return list;
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
function putBackAttachments(key, list) {
|
|
569
|
+
if (!list.length) return;
|
|
570
|
+
attachments.set(key, [...list, ...(attachments.get(key) || [])].slice(0, MAX_ATTACH));
|
|
571
|
+
renderThumbs(key);
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
// Which attachment list an element belongs to.
|
|
575
|
+
function attachKey(el) {
|
|
576
|
+
if (el.closest('#newDialog')) return 'new';
|
|
577
|
+
return el.closest('.card[data-id]')?.dataset.id || null;
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
let attachTarget = null;
|
|
581
|
+
$('#attachInput').addEventListener('change', async (e) => {
|
|
582
|
+
const files = [...e.target.files];
|
|
583
|
+
e.target.value = '';
|
|
584
|
+
if (attachTarget) await addImages(attachTarget, files);
|
|
585
|
+
document.querySelector(attachTarget === 'new' ? '#newMsg' : `.card[data-id="${CSS.escape(attachTarget || '')}"] textarea`)?.focus();
|
|
586
|
+
});
|
|
587
|
+
|
|
588
|
+
document.addEventListener('paste', (e) => {
|
|
589
|
+
const t = e.target instanceof Element ? e.target : null;
|
|
590
|
+
if (!t?.matches('.reply-box textarea, #newMsg')) return;
|
|
591
|
+
const files = [...(e.clipboardData?.files || [])].filter((f) => IMAGE_TYPE.test(f.type));
|
|
592
|
+
if (!files.length) return;
|
|
593
|
+
e.preventDefault();
|
|
594
|
+
addImages(attachKey(t), files);
|
|
595
|
+
});
|
|
596
|
+
|
|
597
|
+
const dropZone = (el) => (el instanceof Element ? el.closest('.reply-box, #newForm') : null);
|
|
598
|
+
const hasFiles = (e) => [...(e.dataTransfer?.types || [])].includes('Files');
|
|
599
|
+
document.addEventListener('dragover', (e) => {
|
|
600
|
+
if (!hasFiles(e)) return;
|
|
601
|
+
e.preventDefault(); // otherwise the browser opens a dropped file in place of the dashboard
|
|
602
|
+
const zone = dropZone(e.target);
|
|
603
|
+
e.dataTransfer.dropEffect = zone ? 'copy' : 'none';
|
|
604
|
+
document.querySelectorAll('.drop-over').forEach((z) => z !== zone && z.classList.remove('drop-over'));
|
|
605
|
+
zone?.classList.add('drop-over');
|
|
606
|
+
});
|
|
607
|
+
document.addEventListener('dragleave', (e) => {
|
|
608
|
+
const zone = dropZone(e.target);
|
|
609
|
+
if (zone && !zone.contains(e.relatedTarget)) zone.classList.remove('drop-over');
|
|
610
|
+
});
|
|
611
|
+
document.addEventListener('drop', (e) => {
|
|
612
|
+
if (!hasFiles(e)) return;
|
|
613
|
+
e.preventDefault();
|
|
614
|
+
document.querySelectorAll('.drop-over').forEach((z) => z.classList.remove('drop-over'));
|
|
615
|
+
const zone = dropZone(e.target);
|
|
616
|
+
const key = zone && attachKey(zone);
|
|
617
|
+
if (key) addImages(key, e.dataTransfer.files);
|
|
618
|
+
});
|
|
619
|
+
|
|
620
|
+
const attachButton = () => `<button class="btn ghost small attach-btn" type="button" data-attach-pick title="Attach images (or paste / drop them)" aria-label="Attach images">${icon('image')}</button>`;
|
|
621
|
+
|
|
489
622
|
function refreshAges() {
|
|
490
623
|
document.querySelectorAll('[data-t]').forEach((el) => (el.textContent = ago(Number(el.dataset.t))));
|
|
491
624
|
}
|
|
@@ -528,6 +661,9 @@ function renderSidebar() {
|
|
|
528
661
|
|
|
529
662
|
function renderHead() {
|
|
530
663
|
const menu = `<button class="btn ghost icon menu-btn" data-nav-toggle title="Sessions">${icon('menu')}</button>`;
|
|
664
|
+
const termBtn = state.terminal
|
|
665
|
+
? `<button class="btn small ${term.open ? 'on' : ''}" data-term-toggle title="PowerShell in this project's folder (\`)" aria-pressed="${term.open}">${icon('terminal')}<span class="label">Terminal</span></button>`
|
|
666
|
+
: '';
|
|
531
667
|
if (state.view === 'inbox') {
|
|
532
668
|
const count = (st) => state.sessions.filter((s) => s.status === st).length;
|
|
533
669
|
const parts = [
|
|
@@ -537,7 +673,8 @@ function renderHead() {
|
|
|
537
673
|
].filter(Boolean);
|
|
538
674
|
$('#mainHead').innerHTML = `${menu}<div class="head-text">
|
|
539
675
|
<div class="head-title"><span>Inbox</span></div>
|
|
540
|
-
<div class="head-sub">${esc(parts.join(' · ') || 'Nothing running right now')}</div></div
|
|
676
|
+
<div class="head-sub">${esc(parts.join(' · ') || 'Nothing running right now')}</div></div>
|
|
677
|
+
<div class="head-actions">${termBtn}</div>`;
|
|
541
678
|
return;
|
|
542
679
|
}
|
|
543
680
|
const s = sessionById(state.selected);
|
|
@@ -571,17 +708,20 @@ function renderHead() {
|
|
|
571
708
|
: ''
|
|
572
709
|
}
|
|
573
710
|
${s.cwd ? `<button class="btn small ${preview.open ? 'on' : ''}" data-preview-toggle title="Show the running app next to the conversation" aria-pressed="${preview.open}">${icon('preview')}<span class="label">Preview</span></button>` : ''}
|
|
574
|
-
${
|
|
711
|
+
${termBtn}
|
|
712
|
+
${s.cwd ? `<button class="btn small icon" data-more popovertarget="moreMenu" title="Memory, carry to a new chat, open in VS Code" aria-label="More actions">${icon('dots')}</button>` : ''}
|
|
575
713
|
</div>`;
|
|
576
714
|
}
|
|
577
715
|
|
|
578
716
|
// ---------- app preview ----------
|
|
579
717
|
// The project's running app, side by side with the conversation. An address you pick
|
|
580
|
-
// or type is remembered
|
|
718
|
+
// or type is remembered for that chat, and is the starting point for new chats in the same
|
|
719
|
+
// project. Otherwise the first page detected is shown.
|
|
581
720
|
|
|
582
|
-
const preview = { open: store.get('previewOpen', false), cwd: null, apps: [], scanning: false, detects: true, timer: null, shown: false };
|
|
721
|
+
const preview = { open: store.get('previewOpen', false), sid: null, cwd: null, apps: [], scanning: false, detects: true, timer: null, shown: false };
|
|
583
722
|
const previewKey = () => 'previewUrl:' + (preview.cwd || '').toLowerCase();
|
|
584
|
-
const
|
|
723
|
+
const previewChatKey = () => 'previewUrl:chat:' + preview.sid;
|
|
724
|
+
const chosenUrl = () => store.get(previewChatKey(), null) ?? (store.get(previewKey(), '') || preview.apps.find((a) => a.html)?.url || preview.apps[0]?.url || '');
|
|
585
725
|
|
|
586
726
|
function normalizeUrl(v) {
|
|
587
727
|
v = v.trim();
|
|
@@ -603,7 +743,8 @@ function renderPreview() {
|
|
|
603
743
|
preview.shown = false;
|
|
604
744
|
return clearTimeout(preview.timer);
|
|
605
745
|
}
|
|
606
|
-
if (s.cwd !== preview.cwd) {
|
|
746
|
+
if (s.id !== preview.sid || s.cwd !== preview.cwd) {
|
|
747
|
+
preview.sid = s.id;
|
|
607
748
|
preview.cwd = s.cwd;
|
|
608
749
|
preview.apps = [];
|
|
609
750
|
preview.shown = false;
|
|
@@ -673,6 +814,7 @@ async function scanApps() {
|
|
|
673
814
|
}
|
|
674
815
|
|
|
675
816
|
function pickUrl(url) {
|
|
817
|
+
store.set(previewChatKey(), url);
|
|
676
818
|
store.set(previewKey(), url);
|
|
677
819
|
renderApps();
|
|
678
820
|
loadFrame();
|
|
@@ -839,8 +981,10 @@ function renderSteer(s) {
|
|
|
839
981
|
${queued}
|
|
840
982
|
<div class="reply-box">
|
|
841
983
|
<textarea rows="1" placeholder="${busy ? `Steer ${isCodex(s) ? 'Codex' : 'Claude'} while it works…` : `Message ${isCodex(s) ? 'Codex' : 'Claude'}…`}"></textarea>
|
|
984
|
+
${attachRow(`steer-${s.id}`)}
|
|
842
985
|
<div class="reply-row">
|
|
843
986
|
<span class="hint">${when}</span>
|
|
987
|
+
${attachButton()}
|
|
844
988
|
${s.running ? `<button class="btn small" type="button" data-interrupt title="Stop the turn Agenter is running">Stop</button>` : ''}
|
|
845
989
|
<button class="btn primary small" type="button" data-steer-send>${icon('send')}Send</button>
|
|
846
990
|
</div>
|
|
@@ -851,21 +995,29 @@ const STEER_TOAST = { sent: 'Reply sent', queued: 'Queued', started: 'Running in
|
|
|
851
995
|
|
|
852
996
|
async function steer(card) {
|
|
853
997
|
const ta = $('textarea', card);
|
|
998
|
+
const key = card.dataset.id;
|
|
854
999
|
const message = ta.value.trim();
|
|
855
|
-
if (!message) return ta.focus();
|
|
1000
|
+
if (!message && !attachments.get(key)?.length) return ta.focus();
|
|
856
1001
|
// Clear first: the queued message re-renders the dock, which would otherwise put the text back.
|
|
857
1002
|
ta.value = '';
|
|
858
1003
|
fitTextarea(ta);
|
|
859
|
-
const
|
|
860
|
-
|
|
1004
|
+
const images = takeAttachments(key);
|
|
1005
|
+
const r = await post('/api/session/steer', { sessionId: state.selected, message, images }).catch(() => null);
|
|
1006
|
+
const putBack = () => {
|
|
861
1007
|
const box = $('#dock [data-steer] textarea');
|
|
862
1008
|
if (box && !box.value) box.value = message;
|
|
863
|
-
|
|
1009
|
+
putBackAttachments(key, images);
|
|
1010
|
+
};
|
|
1011
|
+
if (!r?.ok) {
|
|
1012
|
+
putBack();
|
|
1013
|
+
const error = r && (await r.json().catch(() => ({}))).error;
|
|
1014
|
+
return toast(error || "Couldn't send that");
|
|
864
1015
|
}
|
|
865
1016
|
const { result } = await r.json();
|
|
866
1017
|
// A turn Agenter just started: show your message now, ahead of the transcript.
|
|
867
1018
|
if (result === 'started') {
|
|
868
|
-
|
|
1019
|
+
const echo = [message, images.length ? `[${images.length === 1 ? 'Image' : `${images.length} images`}]` : ''].filter(Boolean).join('\n\n');
|
|
1020
|
+
sentEchoes.set(state.selected, [...(sentEchoes.get(state.selected) || []), echo]);
|
|
869
1021
|
renderLiveRow();
|
|
870
1022
|
}
|
|
871
1023
|
const FAILED = {
|
|
@@ -874,8 +1026,7 @@ async function steer(card) {
|
|
|
874
1026
|
error: "Codex didn't accept the message",
|
|
875
1027
|
};
|
|
876
1028
|
if (FAILED[result]) {
|
|
877
|
-
|
|
878
|
-
if (box && !box.value) box.value = message;
|
|
1029
|
+
putBack();
|
|
879
1030
|
return toast(FAILED[result]);
|
|
880
1031
|
}
|
|
881
1032
|
toast(STEER_TOAST[result] || 'Sent');
|
|
@@ -958,7 +1109,7 @@ function renderConvo(keepScroll = false) {
|
|
|
958
1109
|
continue;
|
|
959
1110
|
}
|
|
960
1111
|
flush();
|
|
961
|
-
if (m.role === 'user') chunks.push([`m${m.seq}`,
|
|
1112
|
+
if (m.role === 'user') chunks.push([`m${m.seq}`, userBubble(m.text)]);
|
|
962
1113
|
else chunks.push([`m${m.seq}`, `<div class="msg-claude"><span class="claude-mark">${icon('sparkle')}</span><div class="md">${md(m.text)}</div></div>`]);
|
|
963
1114
|
}
|
|
964
1115
|
flush();
|
|
@@ -966,6 +1117,13 @@ function renderConvo(keepScroll = false) {
|
|
|
966
1117
|
if (stick) scrollToBottom();
|
|
967
1118
|
}
|
|
968
1119
|
|
|
1120
|
+
// Context carried over from another chat follows the message, folded away.
|
|
1121
|
+
function userBubble(text) {
|
|
1122
|
+
const c = text.match(/\s*<carried-context from="([^"]*)">([\s\S]*)<\/carried-context>\s*$/);
|
|
1123
|
+
if (!c) return `<div class="msg-user">${esc(text)}</div>`;
|
|
1124
|
+
return `<div class="msg-user">${esc(text.slice(0, c.index))}<details class="carried"><summary>Context from ${esc(c[1] || 'an earlier chat')}</summary><pre>${esc(c[2].trim())}</pre></details></div>`;
|
|
1125
|
+
}
|
|
1126
|
+
|
|
969
1127
|
// Brings el's children in line with chunks, touching only the ones that changed, so
|
|
970
1128
|
// new messages don't reset text you've selected or re-render the whole conversation.
|
|
971
1129
|
function patchChildren(el, chunks) {
|
|
@@ -1125,8 +1283,10 @@ function renderCard(p, showSession) {
|
|
|
1125
1283
|
${p.message ? `<div class="last-msg md">${md(p.message)}</div>` : ''}
|
|
1126
1284
|
<div class="reply-box">
|
|
1127
1285
|
<textarea rows="2" placeholder="Tell Claude what to do next…"></textarea>
|
|
1286
|
+
${attachRow(p.id)}
|
|
1128
1287
|
<div class="reply-row">
|
|
1129
1288
|
<span class="hint"><kbd>Enter</kbd> send · <kbd>Shift</kbd>+<kbd>Enter</kbd> new line</span>
|
|
1289
|
+
${attachButton()}
|
|
1130
1290
|
<button class="btn ghost small" data-act="pass" title="Let the session stop normally so you can type in VS Code">Release to VS Code</button>
|
|
1131
1291
|
<button class="btn primary small" data-act="reply">${icon('send')}Send</button>
|
|
1132
1292
|
</div>
|
|
@@ -1143,7 +1303,8 @@ async function act(card, action) {
|
|
|
1143
1303
|
if (action === 'reply') {
|
|
1144
1304
|
const ta = $('textarea', card);
|
|
1145
1305
|
body.message = ta.value.trim();
|
|
1146
|
-
|
|
1306
|
+
body.images = attachments.get(p.id) || [];
|
|
1307
|
+
if (!body.message && !body.images.length) return ta.focus();
|
|
1147
1308
|
}
|
|
1148
1309
|
if (action === 'answer') {
|
|
1149
1310
|
body.answers = {};
|
|
@@ -1160,6 +1321,12 @@ async function act(card, action) {
|
|
|
1160
1321
|
const r = await post('/api/respond', body);
|
|
1161
1322
|
const s = sessionById(p.sessionId);
|
|
1162
1323
|
const what = p.kind === 'permission' && ['allow', 'always', 'deny'].includes(action) ? ` ${toolName(p.tool)}` : '';
|
|
1324
|
+
if (r.status === 400) {
|
|
1325
|
+
// Rejected before anything was sent (a bad image): let you fix it and try again.
|
|
1326
|
+
card.querySelectorAll('button, input, textarea').forEach((b) => (b.disabled = false));
|
|
1327
|
+
return toast((await r.json().catch(() => ({}))).error || "Couldn't send that");
|
|
1328
|
+
}
|
|
1329
|
+
if (action === 'reply' || action === 'pass') takeAttachments(p.id);
|
|
1163
1330
|
toast(r.ok ? `${ACTION_TOAST[action]}${what}${s ? ` · ${sessionLabel(s)}` : ''}` : 'That was already handled');
|
|
1164
1331
|
}
|
|
1165
1332
|
|
|
@@ -1248,7 +1415,33 @@ const closeNav = () => $('#app').classList.remove('nav-open');
|
|
|
1248
1415
|
|
|
1249
1416
|
document.addEventListener('click', (e) => {
|
|
1250
1417
|
const t = e.target;
|
|
1418
|
+
if (t.closest('#moreMenu button')) $('#moreMenu').hidePopover();
|
|
1251
1419
|
if (onPreviewClick(t)) return;
|
|
1420
|
+
if (t.closest('[data-term-toggle]')) return toggleTerm();
|
|
1421
|
+
if (t.closest('[data-term-stop]')) return post('/api/terminal/stop', { id: term.id });
|
|
1422
|
+
if (t.closest('[data-term-clear]')) return ($('#termOut').textContent = '');
|
|
1423
|
+
if (t.closest('[data-update-now]')) return startUpdate();
|
|
1424
|
+
if (t.closest('[data-update-restart]')) return restartApp();
|
|
1425
|
+
if (t.closest('[data-update-later]')) return ((update.dismissed = update.key), renderUpdate());
|
|
1426
|
+
if (t.closest('[data-nav-min]')) return toggleNavMin();
|
|
1427
|
+
if (t.closest('[data-memory]')) return openMemory();
|
|
1428
|
+
if (t.closest('[data-carry]')) return openNewSession({ carry: state.selected });
|
|
1429
|
+
if (t.closest('[data-carry-drop]')) return setCarry(null);
|
|
1430
|
+
if (t.closest('[data-mem-close]')) return closeMemory();
|
|
1431
|
+
const memFile = t.closest('[data-mem-file]');
|
|
1432
|
+
if (memFile) return showMemFile(memFile.dataset.memFile);
|
|
1433
|
+
if (t.closest('[data-attach-pick]')) {
|
|
1434
|
+
attachTarget = attachKey(t);
|
|
1435
|
+
return $('#attachInput').click();
|
|
1436
|
+
}
|
|
1437
|
+
const unattach = t.closest('[data-unattach]');
|
|
1438
|
+
if (unattach) {
|
|
1439
|
+
const key = attachKey(unattach);
|
|
1440
|
+
const list = [...(attachments.get(key) || [])];
|
|
1441
|
+
list.splice(Number(unattach.dataset.unattach), 1);
|
|
1442
|
+
attachments.set(key, list);
|
|
1443
|
+
return renderThumbs(key);
|
|
1444
|
+
}
|
|
1252
1445
|
if (t.closest('[data-steer-send]')) return steer(t.closest('.card'));
|
|
1253
1446
|
if (t.closest('[data-drive]')) return setDrive(t.closest('[data-drive]').dataset.drive);
|
|
1254
1447
|
if (t.closest('[data-interrupt]')) return post('/api/session/interrupt', { sessionId: state.selected });
|
|
@@ -1304,6 +1497,10 @@ document.addEventListener('input', (e) => {
|
|
|
1304
1497
|
|
|
1305
1498
|
document.addEventListener('keydown', (e) => {
|
|
1306
1499
|
const t = e.target instanceof Element ? e.target : document.body;
|
|
1500
|
+
if (t.id === 'termInput' && (e.key === 'ArrowUp' || e.key === 'ArrowDown')) {
|
|
1501
|
+
e.preventDefault();
|
|
1502
|
+
return termHistory(e.key === 'ArrowUp' ? 1 : -1);
|
|
1503
|
+
}
|
|
1307
1504
|
if (t.matches('.reply-box textarea') && e.key === 'Enter' && !e.shiftKey && !e.isComposing) {
|
|
1308
1505
|
e.preventDefault();
|
|
1309
1506
|
return t.closest('[data-steer]') ? steer(t.closest('.card')) : act(t.closest('.card'), 'reply');
|
|
@@ -1313,7 +1510,7 @@ document.addEventListener('keydown', (e) => {
|
|
|
1313
1510
|
if (e.key === 'Escape') t.blur();
|
|
1314
1511
|
return;
|
|
1315
1512
|
}
|
|
1316
|
-
if ($('
|
|
1513
|
+
if ($('dialog[open]') || e.ctrlKey || e.metaKey || e.altKey) return;
|
|
1317
1514
|
|
|
1318
1515
|
const perm = keyTarget();
|
|
1319
1516
|
const k = e.key;
|
|
@@ -1325,6 +1522,8 @@ document.addEventListener('keydown', (e) => {
|
|
|
1325
1522
|
chooseTheme(next);
|
|
1326
1523
|
toast(`Theme: ${next}`);
|
|
1327
1524
|
}
|
|
1525
|
+
else if (k === '`') (e.preventDefault(), toggleTerm());
|
|
1526
|
+
else if (k === 'b') toggleNavMin();
|
|
1328
1527
|
else if (k === 'j') step(1);
|
|
1329
1528
|
else if (k === 'k') step(-1);
|
|
1330
1529
|
else if (k === 'n') nextNeedingYou();
|
|
@@ -1356,22 +1555,64 @@ $('#newAgent').addEventListener('click', (e) => {
|
|
|
1356
1555
|
if (b) setNewAgent(b.dataset.newAgent);
|
|
1357
1556
|
});
|
|
1358
1557
|
|
|
1359
|
-
|
|
1558
|
+
let picking = false;
|
|
1559
|
+
// Opens the Windows folder window. { path } | { cancelled } | { busy } | { unsupported }
|
|
1560
|
+
async function chooseFolder(start) {
|
|
1561
|
+
if (picking) return { busy: true };
|
|
1562
|
+
picking = true;
|
|
1563
|
+
const label = $('#navNew span');
|
|
1564
|
+
const idle = label.textContent;
|
|
1565
|
+
label.textContent = 'Choosing folder…';
|
|
1566
|
+
$('#navNew').disabled = $('#newBrowse').disabled = true;
|
|
1567
|
+
const r = await post('/api/pick-folder', { start })
|
|
1568
|
+
.then((res) => res.json())
|
|
1569
|
+
.catch(() => ({ unsupported: true }));
|
|
1570
|
+
picking = false;
|
|
1571
|
+
label.textContent = idle;
|
|
1572
|
+
$('#navNew').disabled = $('#newBrowse').disabled = false;
|
|
1573
|
+
return r;
|
|
1574
|
+
}
|
|
1575
|
+
|
|
1576
|
+
// The chat a new session brings context from, shown in the dialog until removed.
|
|
1577
|
+
let carryFrom = null;
|
|
1578
|
+
function setCarry(s) {
|
|
1579
|
+
carryFrom = s?.id || null;
|
|
1580
|
+
$('#newCarry').hidden = !s;
|
|
1581
|
+
$('#newCarryText').textContent = s ? `Bringing context from: ${s.title || sessionLabel(s)}` : '';
|
|
1582
|
+
}
|
|
1583
|
+
|
|
1584
|
+
// Picks the folder first, in the Windows folder window; cancelling it cancels New session.
|
|
1585
|
+
// Carrying a chat over already has its folder. Where there's no folder window, you type it.
|
|
1586
|
+
async function openNewSession({ carry = null } = {}) {
|
|
1360
1587
|
closeNav();
|
|
1361
1588
|
setNewAgent(store.get('newAgent', 'claude'));
|
|
1362
1589
|
// Folders you've worked in, most recent first; the last one used here goes on top.
|
|
1363
1590
|
const last = store.get('newCwd', '');
|
|
1364
1591
|
const cwds = [...new Set([last, ...[...state.sessions].sort((a, b) => b.lastEventAt - a.lastEventAt).map((s) => s.cwd)].filter(Boolean))];
|
|
1365
1592
|
$('#newCwdList').innerHTML = cwds.map((c) => `<option value="${esc(c)}"></option>`).join('');
|
|
1366
|
-
const current = state.view === 'session' && sessionById(state.selected)?.cwd;
|
|
1367
|
-
|
|
1593
|
+
const current = (state.view === 'session' && sessionById(state.selected)?.cwd) || last || cwds[0] || '';
|
|
1594
|
+
const from = carry && sessionById(carry);
|
|
1595
|
+
let folder = from?.cwd;
|
|
1596
|
+
if (!from) {
|
|
1597
|
+
const picked = await chooseFolder(current);
|
|
1598
|
+
if (picked.cancelled) return;
|
|
1599
|
+
if (picked.busy) return toast('The folder window is already open');
|
|
1600
|
+
folder = picked.path || current;
|
|
1601
|
+
}
|
|
1602
|
+
setCarry(from);
|
|
1603
|
+
$('#newCwd').value = folder;
|
|
1368
1604
|
$('#newError').hidden = true;
|
|
1369
1605
|
$('#newStart').disabled = false;
|
|
1370
1606
|
$('#newDialog').showModal();
|
|
1371
1607
|
$('#newMsg').focus();
|
|
1372
1608
|
}
|
|
1373
1609
|
|
|
1374
|
-
$('#navNew').addEventListener('click', openNewSession);
|
|
1610
|
+
$('#navNew').addEventListener('click', () => openNewSession());
|
|
1611
|
+
$('#newBrowse').addEventListener('click', async () => {
|
|
1612
|
+
const picked = await chooseFolder($('#newCwd').value.trim());
|
|
1613
|
+
if (picked.path) $('#newCwd').value = picked.path;
|
|
1614
|
+
else if (picked.unsupported) toast('No folder window here. Type the full path');
|
|
1615
|
+
});
|
|
1375
1616
|
$('#newDialog [data-new-cancel]').addEventListener('click', () => $('#newDialog').close());
|
|
1376
1617
|
$('#newMsg').addEventListener('keydown', (e) => {
|
|
1377
1618
|
if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) (e.preventDefault(), $('#newForm').requestSubmit());
|
|
@@ -1385,10 +1626,12 @@ $('#newForm').addEventListener('submit', async (e) => {
|
|
|
1385
1626
|
$('#newError').hidden = false;
|
|
1386
1627
|
$('#newStart').disabled = false;
|
|
1387
1628
|
};
|
|
1629
|
+
const images = attachments.get('new') || [];
|
|
1630
|
+
if (!message && !images.length) return ($('#newMsg').focus(), showError('Type a first message, or attach an image.'));
|
|
1388
1631
|
$('#newStart').disabled = true;
|
|
1389
1632
|
let r;
|
|
1390
1633
|
try {
|
|
1391
|
-
r = await post('/api/session/new', { cwd, message, agent: store.get('newAgent', 'claude') });
|
|
1634
|
+
r = await post('/api/session/new', { cwd, message, images, agent: store.get('newAgent', 'claude'), fromSessionId: carryFrom });
|
|
1392
1635
|
} catch {
|
|
1393
1636
|
return showError("Couldn't reach the Agenter server.");
|
|
1394
1637
|
}
|
|
@@ -1396,12 +1639,248 @@ $('#newForm').addEventListener('submit', async (e) => {
|
|
|
1396
1639
|
if (!r.ok) return showError(data.error || "Couldn't start the session.");
|
|
1397
1640
|
store.set('newCwd', cwd);
|
|
1398
1641
|
$('#newMsg').value = '';
|
|
1642
|
+
takeAttachments('new');
|
|
1399
1643
|
$('#newDialog').close();
|
|
1400
1644
|
state.awaiting = data.sessionId;
|
|
1401
1645
|
select(data.sessionId);
|
|
1402
1646
|
toast('Session started');
|
|
1403
1647
|
});
|
|
1404
1648
|
$('#scrim').addEventListener('click', closeNav);
|
|
1649
|
+
|
|
1650
|
+
// ---------- collapsing the sidebar ----------
|
|
1651
|
+
|
|
1652
|
+
const drawerLayout = matchMedia('(max-width: 800px)');
|
|
1653
|
+
function setNavMin(min) {
|
|
1654
|
+
const btn = $('#navMinBtn');
|
|
1655
|
+
$('#app').classList.toggle('nav-min', min);
|
|
1656
|
+
btn.setAttribute('aria-expanded', String(!min));
|
|
1657
|
+
btn.setAttribute('aria-label', min ? 'Expand sidebar' : 'Collapse sidebar');
|
|
1658
|
+
btn.title = `${min ? 'Expand' : 'Collapse'} sidebar (B)`;
|
|
1659
|
+
}
|
|
1660
|
+
function toggleNavMin() {
|
|
1661
|
+
if (drawerLayout.matches) return $('#app').classList.toggle('nav-open');
|
|
1662
|
+
const min = !$('#app').classList.contains('nav-min');
|
|
1663
|
+
store.set('navMin', min);
|
|
1664
|
+
setNavMin(min);
|
|
1665
|
+
}
|
|
1666
|
+
setNavMin(store.get('navMin', false));
|
|
1667
|
+
|
|
1668
|
+
// ---------- updates ----------
|
|
1669
|
+
|
|
1670
|
+
const update = { key: '', dismissed: '', toasted: new Set() };
|
|
1671
|
+
|
|
1672
|
+
function renderUpdate() {
|
|
1673
|
+
const u = state.update;
|
|
1674
|
+
const mode = u?.restart ? 'restart' : u?.available ? 'update' : '';
|
|
1675
|
+
const key = mode && `${mode}:${mode === 'restart' ? u.installed : u.latest}`;
|
|
1676
|
+
const el = $('#updateBar');
|
|
1677
|
+
el.hidden = !key || key === update.dismissed;
|
|
1678
|
+
if (key === update.key) return;
|
|
1679
|
+
update.key = key;
|
|
1680
|
+
if (!key) return;
|
|
1681
|
+
if (mode === 'restart') {
|
|
1682
|
+
el.innerHTML = `<span class="update-text"><b>Agenter ${esc(u.installed)} is installed.</b> Restart to start using it. Running turns stop, and anything waiting goes back to VS Code.</span>
|
|
1683
|
+
<button class="btn primary small" type="button" data-update-restart>Restart</button><button class="btn ghost small" type="button" data-update-later>Later</button>`;
|
|
1684
|
+
} else {
|
|
1685
|
+
el.innerHTML = `<span class="update-text"><b>Agenter ${esc(u.latest)} is available.</b> You have ${esc(u.current)}.${u.canSelfUpdate ? ' Or run <code>agenter update</code>.' : ' Update with <code>git pull</code>.'}</span>
|
|
1686
|
+
${u.canSelfUpdate && state.terminal ? '<button class="btn primary small" type="button" data-update-now>Update now</button>' : ''}<button class="btn ghost small" type="button" data-update-later>Later</button>`;
|
|
1687
|
+
}
|
|
1688
|
+
// Every time the app opens, once per version.
|
|
1689
|
+
if (!update.toasted.has(key)) {
|
|
1690
|
+
update.toasted.add(key);
|
|
1691
|
+
toast(mode === 'restart' ? 'Restart Agenter to finish updating' : `Agenter ${u.latest} is available`);
|
|
1692
|
+
}
|
|
1693
|
+
}
|
|
1694
|
+
|
|
1695
|
+
async function startUpdate() {
|
|
1696
|
+
const r = await post('/api/update', {}).catch(() => null);
|
|
1697
|
+
const data = await r?.json().catch(() => ({}));
|
|
1698
|
+
if (!r?.ok) return toast(data?.error || "Couldn't start the update");
|
|
1699
|
+
term.open = true;
|
|
1700
|
+
store.set('termOpen', true);
|
|
1701
|
+
term.sid = currentTermSession();
|
|
1702
|
+
await loadTerm({});
|
|
1703
|
+
render();
|
|
1704
|
+
}
|
|
1705
|
+
|
|
1706
|
+
async function restartApp() {
|
|
1707
|
+
const r = await post('/api/restart', {}).catch(() => null);
|
|
1708
|
+
toast(r?.ok ? 'Restarting Agenter…' : "Couldn't restart");
|
|
1709
|
+
}
|
|
1710
|
+
|
|
1711
|
+
// ---------- terminal ----------
|
|
1712
|
+
// One PowerShell per folder, kept by the server. The panel follows the session you're on;
|
|
1713
|
+
// the inbox uses your home folder.
|
|
1714
|
+
|
|
1715
|
+
const TERM_KEEP = 200 * 1024;
|
|
1716
|
+
const term = { open: store.get('termOpen', false), id: null, sid: undefined, running: false, cwd: '', history: store.get('termHistory', []), back: -1 };
|
|
1717
|
+
const currentTermSession = () => (state.view === 'session' ? state.selected : null);
|
|
1718
|
+
// Colors and cursor moves mean nothing here; bare carriage returns are progress redraws.
|
|
1719
|
+
const termText = (s) => s.replace(/\x1b\[[0-9;?]*[ -/]*[@-~]|\x1b\][^\x07]*\x07/g, '').replace(/\r\n/g, '\n').replace(/\r/g, '\n');
|
|
1720
|
+
|
|
1721
|
+
function termStatus(running, cwd) {
|
|
1722
|
+
term.running = running;
|
|
1723
|
+
term.cwd = cwd;
|
|
1724
|
+
$('#termCwd').textContent = cwd;
|
|
1725
|
+
$('#termCwd').title = cwd;
|
|
1726
|
+
$('#termStop').hidden = !running;
|
|
1727
|
+
$('#termPrompt').textContent = running ? '…' : '>';
|
|
1728
|
+
$('#termInput').placeholder = running ? 'Running. Type a line to send it to the program' : 'Type a command';
|
|
1729
|
+
}
|
|
1730
|
+
|
|
1731
|
+
function termAppend(text) {
|
|
1732
|
+
const out = $('#termOut');
|
|
1733
|
+
const stick = out.scrollHeight - out.scrollTop - out.clientHeight < 40;
|
|
1734
|
+
out.append(text);
|
|
1735
|
+
if (out.textContent.length > TERM_KEEP * 1.5) out.textContent = out.textContent.slice(-TERM_KEEP);
|
|
1736
|
+
if (stick) out.scrollTop = out.scrollHeight;
|
|
1737
|
+
}
|
|
1738
|
+
|
|
1739
|
+
async function loadTerm(body) {
|
|
1740
|
+
const r = await post('/api/terminal/open', body).catch(() => null);
|
|
1741
|
+
if (!r?.ok) return termAppend("Couldn't open the terminal.\n");
|
|
1742
|
+
const t = await r.json();
|
|
1743
|
+
term.id = t.id;
|
|
1744
|
+
$('#termOut').textContent = termText(t.output);
|
|
1745
|
+
$('#termOut').scrollTop = $('#termOut').scrollHeight;
|
|
1746
|
+
termStatus(t.running, t.cwd);
|
|
1747
|
+
}
|
|
1748
|
+
|
|
1749
|
+
function renderTerm() {
|
|
1750
|
+
const show = term.open && !!state.terminal;
|
|
1751
|
+
$('#term').hidden = !show;
|
|
1752
|
+
if (!show) return;
|
|
1753
|
+
const sid = currentTermSession();
|
|
1754
|
+
if (sid === term.sid) return;
|
|
1755
|
+
term.sid = sid;
|
|
1756
|
+
loadTerm({ sessionId: sid });
|
|
1757
|
+
}
|
|
1758
|
+
|
|
1759
|
+
function toggleTerm() {
|
|
1760
|
+
term.open = !term.open;
|
|
1761
|
+
store.set('termOpen', term.open);
|
|
1762
|
+
render();
|
|
1763
|
+
if (term.open) $('#termInput').focus();
|
|
1764
|
+
}
|
|
1765
|
+
|
|
1766
|
+
function onTerminal(e) {
|
|
1767
|
+
if (e.id !== term.id) return;
|
|
1768
|
+
if (e.data) termAppend(termText(e.data));
|
|
1769
|
+
termStatus(e.running, e.cwd);
|
|
1770
|
+
}
|
|
1771
|
+
|
|
1772
|
+
function termHistory(dir) {
|
|
1773
|
+
const next = term.back + dir;
|
|
1774
|
+
if (next < -1 || next >= term.history.length) return;
|
|
1775
|
+
term.back = next;
|
|
1776
|
+
$('#termInput').value = next < 0 ? '' : term.history[next];
|
|
1777
|
+
}
|
|
1778
|
+
|
|
1779
|
+
$('#termForm').addEventListener('submit', async (e) => {
|
|
1780
|
+
e.preventDefault();
|
|
1781
|
+
const input = $('#termInput').value;
|
|
1782
|
+
if (!input.trim() && !term.running) return;
|
|
1783
|
+
$('#termInput').value = '';
|
|
1784
|
+
term.back = -1;
|
|
1785
|
+
if (input.trim() && input !== term.history[0] && !term.running) {
|
|
1786
|
+
term.history = [input, ...term.history].slice(0, 100);
|
|
1787
|
+
store.set('termHistory', term.history);
|
|
1788
|
+
}
|
|
1789
|
+
const r = await post('/api/terminal/run', { id: term.id, input }).catch(() => null);
|
|
1790
|
+
if (r?.ok) return;
|
|
1791
|
+
// The server restarted, so that shell is gone: open a new one and try again from here.
|
|
1792
|
+
$('#termInput').value = input;
|
|
1793
|
+
term.sid = undefined;
|
|
1794
|
+
renderTerm();
|
|
1795
|
+
});
|
|
1796
|
+
|
|
1797
|
+
// ---------- memory ----------
|
|
1798
|
+
// The files the agent reads and keeps for this project. Edits stay in drafts until saved, so
|
|
1799
|
+
// moving between files doesn't lose them.
|
|
1800
|
+
|
|
1801
|
+
const mem = { sid: null, files: [], path: null, drafts: new Map() };
|
|
1802
|
+
const memFile = () => mem.files.find((f) => f.path === mem.path);
|
|
1803
|
+
const memDirty = (f) => mem.drafts.has(f.path) && mem.drafts.get(f.path) !== f.text;
|
|
1804
|
+
|
|
1805
|
+
function renderMemList() {
|
|
1806
|
+
let group = '';
|
|
1807
|
+
$('#memList').innerHTML = mem.files
|
|
1808
|
+
.map((f) => {
|
|
1809
|
+
const head = f.group === group ? '' : `<div class="mem-group">${esc((group = f.group))}</div>`;
|
|
1810
|
+
const mark = memDirty(f) ? '<i class="dirty" title="Not saved"></i>' : f.create ? '<i class="new">new</i>' : '';
|
|
1811
|
+
return `${head}<button type="button" class="mem-file" data-mem-file="${esc(f.path)}" aria-current="${f.path === mem.path}" title="${esc(f.path)}"><span>${esc(f.label)}</span>${mark}</button>`;
|
|
1812
|
+
})
|
|
1813
|
+
.join('');
|
|
1814
|
+
}
|
|
1815
|
+
|
|
1816
|
+
function showMemFile(p) {
|
|
1817
|
+
mem.path = p;
|
|
1818
|
+
const f = memFile();
|
|
1819
|
+
const ta = $('#memText');
|
|
1820
|
+
$('#memPath').textContent = f.path;
|
|
1821
|
+
$('#memPath').title = f.path;
|
|
1822
|
+
ta.value = mem.drafts.get(f.path) ?? f.text;
|
|
1823
|
+
ta.readOnly = !!f.tooLarge;
|
|
1824
|
+
ta.placeholder = f.tooLarge ? 'This file is too large to edit here.' : f.create ? "This file doesn't exist yet. Type here and Save to create it." : '';
|
|
1825
|
+
$('#memError').hidden = true;
|
|
1826
|
+
$('#memSave').disabled = !memDirty(f);
|
|
1827
|
+
renderMemList();
|
|
1828
|
+
}
|
|
1829
|
+
|
|
1830
|
+
async function openMemory() {
|
|
1831
|
+
const s = sessionById(state.selected);
|
|
1832
|
+
if (!s) return;
|
|
1833
|
+
const r = await fetch(`/api/memory?session=${encodeURIComponent(s.id)}`, { headers: authHeaders }).catch(() => null);
|
|
1834
|
+
if (!r?.ok) return toast("Couldn't read this project's memory");
|
|
1835
|
+
mem.sid = s.id;
|
|
1836
|
+
mem.files = (await r.json()).files;
|
|
1837
|
+
mem.drafts.clear();
|
|
1838
|
+
if (!mem.files.length) return toast('This session has no project folder');
|
|
1839
|
+
$('#memSub').textContent = s.cwd;
|
|
1840
|
+
showMemFile((mem.files.find((f) => !f.create && f.text) || mem.files[0]).path);
|
|
1841
|
+
$('#memDialog').showModal();
|
|
1842
|
+
}
|
|
1843
|
+
|
|
1844
|
+
const memCanLeave = () => !mem.files.some(memDirty) || confirm('Discard your unsaved memory changes?');
|
|
1845
|
+
function closeMemory() {
|
|
1846
|
+
if (memCanLeave()) $('#memDialog').close();
|
|
1847
|
+
}
|
|
1848
|
+
$('#memDialog').addEventListener('cancel', (e) => {
|
|
1849
|
+
if (!memCanLeave()) e.preventDefault();
|
|
1850
|
+
});
|
|
1851
|
+
$('#memText').addEventListener('input', (e) => {
|
|
1852
|
+
const f = memFile();
|
|
1853
|
+
mem.drafts.set(f.path, e.target.value);
|
|
1854
|
+
$('#memSave').disabled = !memDirty(f);
|
|
1855
|
+
renderMemList();
|
|
1856
|
+
});
|
|
1857
|
+
$('#memSave').addEventListener('click', async () => {
|
|
1858
|
+
const f = memFile();
|
|
1859
|
+
const text = $('#memText').value;
|
|
1860
|
+
const r = await post('/api/memory/save', { sessionId: mem.sid, path: f.path, text, mtime: f.mtime }).catch(() => null);
|
|
1861
|
+
const data = await r?.json().catch(() => ({}));
|
|
1862
|
+
if (!r?.ok) {
|
|
1863
|
+
$('#memError').textContent = data?.error || "Couldn't save.";
|
|
1864
|
+
$('#memError').hidden = false;
|
|
1865
|
+
return;
|
|
1866
|
+
}
|
|
1867
|
+
Object.assign(f, { text, mtime: data.mtime, create: false });
|
|
1868
|
+
mem.drafts.delete(f.path);
|
|
1869
|
+
$('#memError').hidden = true;
|
|
1870
|
+
$('#memSave').disabled = true;
|
|
1871
|
+
renderMemList();
|
|
1872
|
+
toast(`Saved ${f.label}`);
|
|
1873
|
+
});
|
|
1874
|
+
|
|
1875
|
+
{
|
|
1876
|
+
const menu = $('#moreMenu');
|
|
1877
|
+
menu.addEventListener('beforetoggle', (e) => {
|
|
1878
|
+
if (e.newState !== 'open') return;
|
|
1879
|
+
const r = $('[data-more]').getBoundingClientRect();
|
|
1880
|
+
menu.style.top = `${r.bottom + 6}px`;
|
|
1881
|
+
menu.style.right = `${Math.max(8, innerWidth - r.right)}px`;
|
|
1882
|
+
});
|
|
1883
|
+
}
|
|
1405
1884
|
$('#helpBtn').addEventListener('click', () => $('#helpDialog').showModal());
|
|
1406
1885
|
$('#search').addEventListener('input', (e) => {
|
|
1407
1886
|
state.filter = e.target.value;
|