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/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
- ${s.cwd ? `<button class="btn small" data-open-vscode title="Open this project in VS Code">${icon('code')}<span class="label">Open in VS Code</span></button>` : ''}
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 per project; otherwise the first page detected is shown.
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 chosenUrl = () => store.get(previewKey(), '') || preview.apps.find((a) => a.html)?.url || preview.apps[0]?.url || '';
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 r = await post('/api/session/steer', { sessionId: state.selected, message }).catch(() => null);
860
- if (!r?.ok) {
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
- return toast("Couldn't send that");
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
- sentEchoes.set(state.selected, [...(sentEchoes.get(state.selected) || []), message]);
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
- const box = $('#dock [data-steer] textarea');
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}`, `<div class="msg-user">${esc(m.text)}</div>`]);
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
- if (!body.message) return ta.focus();
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 ($('#helpDialog').open || e.ctrlKey || e.metaKey || e.altKey) return;
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
- function openNewSession() {
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
- $('#newCwd').value = current || last || cwds[0] || '';
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;