agenter-app 1.0.3 → 1.2.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/Agenter.vbs +1 -1
- package/README.md +166 -121
- package/cli.js +45 -2
- package/hook.js +7 -2
- package/lib/memory.js +101 -0
- package/lib/open-dashboard.js +4 -1
- package/lib/terminal.js +127 -0
- package/lib/token.js +47 -0
- package/lib/update.js +69 -0
- package/package.json +1 -1
- package/public/app.js +387 -22
- package/public/index.html +84 -20
- package/public/style.css +109 -10
- package/public/theme.js +9 -0
- package/scripts/install-shortcuts.ps1 +3 -2
- package/scripts/pick-folder.ps1 +85 -0
- package/scripts/tray.ps1 +40 -0
- package/server.js +247 -21
package/public/app.js
CHANGED
|
@@ -44,7 +44,25 @@ function ago(t) {
|
|
|
44
44
|
return `${Math.floor(s / 86400)}d`;
|
|
45
45
|
}
|
|
46
46
|
|
|
47
|
-
|
|
47
|
+
// The launchers open the dashboard with #token=…; it's kept here, out of the address bar.
|
|
48
|
+
const TOKEN = (() => {
|
|
49
|
+
const key = 'agenter.token';
|
|
50
|
+
const m = location.hash.match(/^#token=([0-9a-f]+)$/);
|
|
51
|
+
if (m) {
|
|
52
|
+
history.replaceState(null, '', location.pathname + location.search);
|
|
53
|
+
try {
|
|
54
|
+
localStorage.setItem(key, m[1]);
|
|
55
|
+
} catch {}
|
|
56
|
+
return m[1];
|
|
57
|
+
}
|
|
58
|
+
try {
|
|
59
|
+
return localStorage.getItem(key) || '';
|
|
60
|
+
} catch {
|
|
61
|
+
return '';
|
|
62
|
+
}
|
|
63
|
+
})();
|
|
64
|
+
const authHeaders = { 'x-agenter-token': TOKEN };
|
|
65
|
+
const post = (url, body) => fetch(url, { method: 'POST', headers: { 'content-type': 'application/json', ...authHeaders }, body: JSON.stringify(body) });
|
|
48
66
|
|
|
49
67
|
const STATUS_LABEL = { working: 'Working', waiting: 'Needs you', vscode: 'Waiting in VS Code', idle: 'Idle', dormant: 'Not connected', ended: 'Ended' };
|
|
50
68
|
const isLive = (s) => s.status !== 'dormant' && s.status !== 'ended';
|
|
@@ -84,6 +102,7 @@ const ICONS = {
|
|
|
84
102
|
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"/>',
|
|
85
103
|
gauge: '<path d="m12 14 4-4"/><path d="M3.34 19a10 10 0 1 1 17.32 0"/>',
|
|
86
104
|
preview: '<rect width="18" height="18" x="3" y="3" rx="2"/><path d="M15 3v18"/>',
|
|
105
|
+
dots: '<circle cx="12" cy="12" r="1"/><circle cx="19" cy="12" r="1"/><circle cx="5" cy="12" r="1"/>',
|
|
87
106
|
};
|
|
88
107
|
const icon = (name) => `<svg class="ic" viewBox="0 0 24 24" aria-hidden="true">${ICONS[name] || ICONS.tool}</svg>`;
|
|
89
108
|
|
|
@@ -190,10 +209,19 @@ window.addEventListener('blur', () => setTimeout(reportFocus));
|
|
|
190
209
|
document.addEventListener('visibilitychange', () => reportFocus());
|
|
191
210
|
|
|
192
211
|
function connect() {
|
|
193
|
-
const es = new EventSource(`/events?client=${CLIENT_ID}&focused=${hasAttention() ? 1 : 0}`);
|
|
212
|
+
const es = new EventSource(`/events?client=${CLIENT_ID}&focused=${hasAttention() ? 1 : 0}&token=${encodeURIComponent(TOKEN)}`);
|
|
194
213
|
es.addEventListener('state', (e) => apply(JSON.parse(e.data)));
|
|
214
|
+
es.addEventListener('quit', () => window.close());
|
|
215
|
+
es.addEventListener('terminal', (e) => onTerminal(JSON.parse(e.data)));
|
|
195
216
|
es.onopen = () => (setConn(true), reportFocus(true)); // after a reconnect the URL's focus flag may be stale
|
|
196
|
-
es.onerror = () =>
|
|
217
|
+
es.onerror = async () => {
|
|
218
|
+
setConn(false);
|
|
219
|
+
// EventSource doesn't say why it failed, and it gives up for good on a refused request.
|
|
220
|
+
const refused = await post('/api/focus', {}).then((r) => r.status === 401, () => false);
|
|
221
|
+
if (!refused) return;
|
|
222
|
+
es.close();
|
|
223
|
+
if (!$('#authLost')) document.body.insertAdjacentHTML('afterbegin', '<div class="auth-lost" id="authLost" role="alert">This window isn\'t signed in to Agenter. Open it from the Agenter shortcut, or run <code>agenter</code>.</div>');
|
|
224
|
+
};
|
|
197
225
|
}
|
|
198
226
|
|
|
199
227
|
function setConn(ok) {
|
|
@@ -203,6 +231,11 @@ function setConn(ok) {
|
|
|
203
231
|
}
|
|
204
232
|
|
|
205
233
|
function apply(snap) {
|
|
234
|
+
// The server restarted into a new version, so this page is out of date.
|
|
235
|
+
if (state.version && snap.version !== state.version) return location.reload();
|
|
236
|
+
state.version = snap.version;
|
|
237
|
+
state.update = snap.update;
|
|
238
|
+
state.terminal = snap.terminal;
|
|
206
239
|
const prevEventAt = sessionById(state.selected)?.lastEventAt;
|
|
207
240
|
state.sessions = snap.sessions;
|
|
208
241
|
state.pending = snap.pending;
|
|
@@ -395,6 +428,8 @@ function render() {
|
|
|
395
428
|
else renderLiveRow();
|
|
396
429
|
renderDock();
|
|
397
430
|
renderPreview();
|
|
431
|
+
renderTerm();
|
|
432
|
+
renderUpdate();
|
|
398
433
|
markKeyTarget();
|
|
399
434
|
renderUsage();
|
|
400
435
|
refreshAges();
|
|
@@ -502,6 +537,9 @@ function renderSidebar() {
|
|
|
502
537
|
|
|
503
538
|
function renderHead() {
|
|
504
539
|
const menu = `<button class="btn ghost icon menu-btn" data-nav-toggle title="Sessions">${icon('menu')}</button>`;
|
|
540
|
+
const termBtn = state.terminal
|
|
541
|
+
? `<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>`
|
|
542
|
+
: '';
|
|
505
543
|
if (state.view === 'inbox') {
|
|
506
544
|
const count = (st) => state.sessions.filter((s) => s.status === st).length;
|
|
507
545
|
const parts = [
|
|
@@ -511,7 +549,8 @@ function renderHead() {
|
|
|
511
549
|
].filter(Boolean);
|
|
512
550
|
$('#mainHead').innerHTML = `${menu}<div class="head-text">
|
|
513
551
|
<div class="head-title"><span>Inbox</span></div>
|
|
514
|
-
<div class="head-sub">${esc(parts.join(' · ') || 'Nothing running right now')}</div></div
|
|
552
|
+
<div class="head-sub">${esc(parts.join(' · ') || 'Nothing running right now')}</div></div>
|
|
553
|
+
<div class="head-actions">${termBtn}</div>`;
|
|
515
554
|
return;
|
|
516
555
|
}
|
|
517
556
|
const s = sessionById(state.selected);
|
|
@@ -545,17 +584,20 @@ function renderHead() {
|
|
|
545
584
|
: ''
|
|
546
585
|
}
|
|
547
586
|
${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>` : ''}
|
|
548
|
-
${
|
|
587
|
+
${termBtn}
|
|
588
|
+
${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>` : ''}
|
|
549
589
|
</div>`;
|
|
550
590
|
}
|
|
551
591
|
|
|
552
592
|
// ---------- app preview ----------
|
|
553
593
|
// The project's running app, side by side with the conversation. An address you pick
|
|
554
|
-
// or type is remembered
|
|
594
|
+
// or type is remembered for that chat, and is the starting point for new chats in the same
|
|
595
|
+
// project. Otherwise the first page detected is shown.
|
|
555
596
|
|
|
556
|
-
const preview = { open: store.get('previewOpen', false), cwd: null, apps: [], scanning: false, detects: true, timer: null, shown: false };
|
|
597
|
+
const preview = { open: store.get('previewOpen', false), sid: null, cwd: null, apps: [], scanning: false, detects: true, timer: null, shown: false };
|
|
557
598
|
const previewKey = () => 'previewUrl:' + (preview.cwd || '').toLowerCase();
|
|
558
|
-
const
|
|
599
|
+
const previewChatKey = () => 'previewUrl:chat:' + preview.sid;
|
|
600
|
+
const chosenUrl = () => store.get(previewChatKey(), null) ?? (store.get(previewKey(), '') || preview.apps.find((a) => a.html)?.url || preview.apps[0]?.url || '');
|
|
559
601
|
|
|
560
602
|
function normalizeUrl(v) {
|
|
561
603
|
v = v.trim();
|
|
@@ -577,7 +619,8 @@ function renderPreview() {
|
|
|
577
619
|
preview.shown = false;
|
|
578
620
|
return clearTimeout(preview.timer);
|
|
579
621
|
}
|
|
580
|
-
if (s.cwd !== preview.cwd) {
|
|
622
|
+
if (s.id !== preview.sid || s.cwd !== preview.cwd) {
|
|
623
|
+
preview.sid = s.id;
|
|
581
624
|
preview.cwd = s.cwd;
|
|
582
625
|
preview.apps = [];
|
|
583
626
|
preview.shown = false;
|
|
@@ -632,7 +675,7 @@ async function scanApps() {
|
|
|
632
675
|
loadFrame();
|
|
633
676
|
let r = null;
|
|
634
677
|
try {
|
|
635
|
-
r = await fetch(`/api/preview/apps?session=${encodeURIComponent(sid)}
|
|
678
|
+
r = await fetch(`/api/preview/apps?session=${encodeURIComponent(sid)}`, { headers: authHeaders }).then((res) => res.json());
|
|
636
679
|
} catch {}
|
|
637
680
|
if (cwd !== preview.cwd) return; // switched project meanwhile; that one has its own scan
|
|
638
681
|
preview.scanning = false;
|
|
@@ -647,6 +690,7 @@ async function scanApps() {
|
|
|
647
690
|
}
|
|
648
691
|
|
|
649
692
|
function pickUrl(url) {
|
|
693
|
+
store.set(previewChatKey(), url);
|
|
650
694
|
store.set(previewKey(), url);
|
|
651
695
|
renderApps();
|
|
652
696
|
loadFrame();
|
|
@@ -748,13 +792,23 @@ const sortPending = (list) => [...list].sort((a, b) => a.createdAt - b.createdAt
|
|
|
748
792
|
|
|
749
793
|
// ---------- session view ----------
|
|
750
794
|
|
|
795
|
+
// Messages you sent that the agent's transcript hasn't recorded yet, by session.
|
|
796
|
+
const sentEchoes = new Map();
|
|
797
|
+
|
|
798
|
+
function clearEchoes(sid, messages) {
|
|
799
|
+
const left = (sentEchoes.get(sid) || []).filter((text) => !messages.some((m) => m.role === 'user' && m.text.trim() === text));
|
|
800
|
+
if (left.length) sentEchoes.set(sid, left);
|
|
801
|
+
else sentEchoes.delete(sid);
|
|
802
|
+
}
|
|
803
|
+
|
|
751
804
|
function renderLiveRow() {
|
|
752
805
|
const s = sessionById(state.selected);
|
|
753
806
|
const el = $('#liveRow');
|
|
754
807
|
if (!s || !el) return;
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
808
|
+
if (!s.running) sentEchoes.delete(s.id); // The turn is over, so the transcript has the last word.
|
|
809
|
+
let html = (sentEchoes.get(s.id) || []).map((text) => `<div class="live-echo"><div class="msg-user">${esc(text)}</div></div>`).join('');
|
|
810
|
+
if (s.status === 'working') html += `<div class="live-row"><span class="dots"><i></i><i></i><i></i></span><span class="targ">${esc(s.lastAction || 'Working…')}</span></div>`;
|
|
811
|
+
else if (s.status === 'vscode') html += `<div class="live-row">${icon('info')}<span>Waiting for you in VS Code</span></div>`;
|
|
758
812
|
if (el.innerHTML !== html) {
|
|
759
813
|
const stick = nearBottom();
|
|
760
814
|
el.innerHTML = html;
|
|
@@ -793,7 +847,7 @@ function renderSteer(s) {
|
|
|
793
847
|
.join('');
|
|
794
848
|
let when;
|
|
795
849
|
if (isCodex(s)) {
|
|
796
|
-
if (agenter && busy) when = 'Codex gets this as its next turn, when the current one ends.';
|
|
850
|
+
if (s.running || (agenter && busy)) when = 'Codex gets this as its next turn, when the current one ends.';
|
|
797
851
|
else if (agenter) when = 'Runs here in Agenter, sandboxed to the project folder (no network).';
|
|
798
852
|
else when = `Queued in Codex: it runs as the next turn when this session is open in Codex (VS Code or the desktop app). Or <button class="link" type="button" data-drive="agenter">drive from Agenter</button>.`;
|
|
799
853
|
} else if (busy) when = 'Claude gets this after its current step.';
|
|
@@ -827,6 +881,11 @@ async function steer(card) {
|
|
|
827
881
|
return toast("Couldn't send that");
|
|
828
882
|
}
|
|
829
883
|
const { result } = await r.json();
|
|
884
|
+
// A turn Agenter just started: show your message now, ahead of the transcript.
|
|
885
|
+
if (result === 'started') {
|
|
886
|
+
sentEchoes.set(state.selected, [...(sentEchoes.get(state.selected) || []), message]);
|
|
887
|
+
renderLiveRow();
|
|
888
|
+
}
|
|
830
889
|
const FAILED = {
|
|
831
890
|
'no-claude': "Couldn't find Claude Code. Install the VS Code extension, or set AGENTER_CLAUDE to claude.exe",
|
|
832
891
|
'no-codex': "Couldn't find Codex. Install the Codex VS Code extension, or set AGENTER_CODEX_BIN to codex.exe",
|
|
@@ -917,7 +976,7 @@ function renderConvo(keepScroll = false) {
|
|
|
917
976
|
continue;
|
|
918
977
|
}
|
|
919
978
|
flush();
|
|
920
|
-
if (m.role === 'user') chunks.push([`m${m.seq}`,
|
|
979
|
+
if (m.role === 'user') chunks.push([`m${m.seq}`, userBubble(m.text)]);
|
|
921
980
|
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>`]);
|
|
922
981
|
}
|
|
923
982
|
flush();
|
|
@@ -925,6 +984,13 @@ function renderConvo(keepScroll = false) {
|
|
|
925
984
|
if (stick) scrollToBottom();
|
|
926
985
|
}
|
|
927
986
|
|
|
987
|
+
// Context carried over from another chat follows the message, folded away.
|
|
988
|
+
function userBubble(text) {
|
|
989
|
+
const c = text.match(/\s*<carried-context from="([^"]*)">([\s\S]*)<\/carried-context>\s*$/);
|
|
990
|
+
if (!c) return `<div class="msg-user">${esc(text)}</div>`;
|
|
991
|
+
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>`;
|
|
992
|
+
}
|
|
993
|
+
|
|
928
994
|
// Brings el's children in line with chunks, touching only the ones that changed, so
|
|
929
995
|
// new messages don't reset text you've selected or re-render the whole conversation.
|
|
930
996
|
function patchChildren(el, chunks) {
|
|
@@ -1134,7 +1200,7 @@ function loadTranscript(sid) {
|
|
|
1134
1200
|
const have = state.transcript.sessionId === sid && state.transcript.messages ? state.transcript : null;
|
|
1135
1201
|
const lastSeq = () => have?.messages.at(-1)?.seq ?? 0;
|
|
1136
1202
|
const since = have ? `&epoch=${encodeURIComponent(have.epoch)}&after=${lastSeq()}` : '';
|
|
1137
|
-
const r = await fetch(`/api/transcript?session=${encodeURIComponent(sid)}${since}
|
|
1203
|
+
const r = await fetch(`/api/transcript?session=${encodeURIComponent(sid)}${since}`, { headers: authHeaders });
|
|
1138
1204
|
if (!r.ok) return;
|
|
1139
1205
|
const { epoch, reset, messages } = await r.json();
|
|
1140
1206
|
if (sid !== state.selected) return;
|
|
@@ -1142,7 +1208,9 @@ function loadTranscript(sid) {
|
|
|
1142
1208
|
// A request that overlapped another may repeat messages; seq tells them apart.
|
|
1143
1209
|
const merged = reset || !have ? messages : [...have.messages, ...messages.filter((m) => m.seq > lastSeq())].slice(-KEEP_MESSAGES);
|
|
1144
1210
|
state.transcript = { sessionId: sid, epoch, messages: merged };
|
|
1211
|
+
clearEchoes(sid, messages);
|
|
1145
1212
|
renderConvo(same);
|
|
1213
|
+
renderLiveRow();
|
|
1146
1214
|
} catch {}
|
|
1147
1215
|
}, 150);
|
|
1148
1216
|
}
|
|
@@ -1205,7 +1273,21 @@ const closeNav = () => $('#app').classList.remove('nav-open');
|
|
|
1205
1273
|
|
|
1206
1274
|
document.addEventListener('click', (e) => {
|
|
1207
1275
|
const t = e.target;
|
|
1276
|
+
if (t.closest('#moreMenu button')) $('#moreMenu').hidePopover();
|
|
1208
1277
|
if (onPreviewClick(t)) return;
|
|
1278
|
+
if (t.closest('[data-term-toggle]')) return toggleTerm();
|
|
1279
|
+
if (t.closest('[data-term-stop]')) return post('/api/terminal/stop', { id: term.id });
|
|
1280
|
+
if (t.closest('[data-term-clear]')) return ($('#termOut').textContent = '');
|
|
1281
|
+
if (t.closest('[data-update-now]')) return startUpdate();
|
|
1282
|
+
if (t.closest('[data-update-restart]')) return restartApp();
|
|
1283
|
+
if (t.closest('[data-update-later]')) return ((update.dismissed = update.key), renderUpdate());
|
|
1284
|
+
if (t.closest('[data-nav-min]')) return toggleNavMin();
|
|
1285
|
+
if (t.closest('[data-memory]')) return openMemory();
|
|
1286
|
+
if (t.closest('[data-carry]')) return openNewSession({ carry: state.selected });
|
|
1287
|
+
if (t.closest('[data-carry-drop]')) return setCarry(null);
|
|
1288
|
+
if (t.closest('[data-mem-close]')) return closeMemory();
|
|
1289
|
+
const memFile = t.closest('[data-mem-file]');
|
|
1290
|
+
if (memFile) return showMemFile(memFile.dataset.memFile);
|
|
1209
1291
|
if (t.closest('[data-steer-send]')) return steer(t.closest('.card'));
|
|
1210
1292
|
if (t.closest('[data-drive]')) return setDrive(t.closest('[data-drive]').dataset.drive);
|
|
1211
1293
|
if (t.closest('[data-interrupt]')) return post('/api/session/interrupt', { sessionId: state.selected });
|
|
@@ -1261,6 +1343,10 @@ document.addEventListener('input', (e) => {
|
|
|
1261
1343
|
|
|
1262
1344
|
document.addEventListener('keydown', (e) => {
|
|
1263
1345
|
const t = e.target instanceof Element ? e.target : document.body;
|
|
1346
|
+
if (t.id === 'termInput' && (e.key === 'ArrowUp' || e.key === 'ArrowDown')) {
|
|
1347
|
+
e.preventDefault();
|
|
1348
|
+
return termHistory(e.key === 'ArrowUp' ? 1 : -1);
|
|
1349
|
+
}
|
|
1264
1350
|
if (t.matches('.reply-box textarea') && e.key === 'Enter' && !e.shiftKey && !e.isComposing) {
|
|
1265
1351
|
e.preventDefault();
|
|
1266
1352
|
return t.closest('[data-steer]') ? steer(t.closest('.card')) : act(t.closest('.card'), 'reply');
|
|
@@ -1270,7 +1356,7 @@ document.addEventListener('keydown', (e) => {
|
|
|
1270
1356
|
if (e.key === 'Escape') t.blur();
|
|
1271
1357
|
return;
|
|
1272
1358
|
}
|
|
1273
|
-
if ($('
|
|
1359
|
+
if ($('dialog[open]') || e.ctrlKey || e.metaKey || e.altKey) return;
|
|
1274
1360
|
|
|
1275
1361
|
const perm = keyTarget();
|
|
1276
1362
|
const k = e.key;
|
|
@@ -1282,6 +1368,8 @@ document.addEventListener('keydown', (e) => {
|
|
|
1282
1368
|
chooseTheme(next);
|
|
1283
1369
|
toast(`Theme: ${next}`);
|
|
1284
1370
|
}
|
|
1371
|
+
else if (k === '`') (e.preventDefault(), toggleTerm());
|
|
1372
|
+
else if (k === 'b') toggleNavMin();
|
|
1285
1373
|
else if (k === 'j') step(1);
|
|
1286
1374
|
else if (k === 'k') step(-1);
|
|
1287
1375
|
else if (k === 'n') nextNeedingYou();
|
|
@@ -1313,22 +1401,64 @@ $('#newAgent').addEventListener('click', (e) => {
|
|
|
1313
1401
|
if (b) setNewAgent(b.dataset.newAgent);
|
|
1314
1402
|
});
|
|
1315
1403
|
|
|
1316
|
-
|
|
1404
|
+
let picking = false;
|
|
1405
|
+
// Opens the Windows folder window. { path } | { cancelled } | { busy } | { unsupported }
|
|
1406
|
+
async function chooseFolder(start) {
|
|
1407
|
+
if (picking) return { busy: true };
|
|
1408
|
+
picking = true;
|
|
1409
|
+
const label = $('#navNew span');
|
|
1410
|
+
const idle = label.textContent;
|
|
1411
|
+
label.textContent = 'Choosing folder…';
|
|
1412
|
+
$('#navNew').disabled = $('#newBrowse').disabled = true;
|
|
1413
|
+
const r = await post('/api/pick-folder', { start })
|
|
1414
|
+
.then((res) => res.json())
|
|
1415
|
+
.catch(() => ({ unsupported: true }));
|
|
1416
|
+
picking = false;
|
|
1417
|
+
label.textContent = idle;
|
|
1418
|
+
$('#navNew').disabled = $('#newBrowse').disabled = false;
|
|
1419
|
+
return r;
|
|
1420
|
+
}
|
|
1421
|
+
|
|
1422
|
+
// The chat a new session brings context from, shown in the dialog until removed.
|
|
1423
|
+
let carryFrom = null;
|
|
1424
|
+
function setCarry(s) {
|
|
1425
|
+
carryFrom = s?.id || null;
|
|
1426
|
+
$('#newCarry').hidden = !s;
|
|
1427
|
+
$('#newCarryText').textContent = s ? `Bringing context from: ${s.title || sessionLabel(s)}` : '';
|
|
1428
|
+
}
|
|
1429
|
+
|
|
1430
|
+
// Picks the folder first, in the Windows folder window; cancelling it cancels New session.
|
|
1431
|
+
// Carrying a chat over already has its folder. Where there's no folder window, you type it.
|
|
1432
|
+
async function openNewSession({ carry = null } = {}) {
|
|
1317
1433
|
closeNav();
|
|
1318
1434
|
setNewAgent(store.get('newAgent', 'claude'));
|
|
1319
1435
|
// Folders you've worked in, most recent first; the last one used here goes on top.
|
|
1320
1436
|
const last = store.get('newCwd', '');
|
|
1321
1437
|
const cwds = [...new Set([last, ...[...state.sessions].sort((a, b) => b.lastEventAt - a.lastEventAt).map((s) => s.cwd)].filter(Boolean))];
|
|
1322
1438
|
$('#newCwdList').innerHTML = cwds.map((c) => `<option value="${esc(c)}"></option>`).join('');
|
|
1323
|
-
const current = state.view === 'session' && sessionById(state.selected)?.cwd;
|
|
1324
|
-
|
|
1439
|
+
const current = (state.view === 'session' && sessionById(state.selected)?.cwd) || last || cwds[0] || '';
|
|
1440
|
+
const from = carry && sessionById(carry);
|
|
1441
|
+
let folder = from?.cwd;
|
|
1442
|
+
if (!from) {
|
|
1443
|
+
const picked = await chooseFolder(current);
|
|
1444
|
+
if (picked.cancelled) return;
|
|
1445
|
+
if (picked.busy) return toast('The folder window is already open');
|
|
1446
|
+
folder = picked.path || current;
|
|
1447
|
+
}
|
|
1448
|
+
setCarry(from);
|
|
1449
|
+
$('#newCwd').value = folder;
|
|
1325
1450
|
$('#newError').hidden = true;
|
|
1326
1451
|
$('#newStart').disabled = false;
|
|
1327
1452
|
$('#newDialog').showModal();
|
|
1328
1453
|
$('#newMsg').focus();
|
|
1329
1454
|
}
|
|
1330
1455
|
|
|
1331
|
-
$('#navNew').addEventListener('click', openNewSession);
|
|
1456
|
+
$('#navNew').addEventListener('click', () => openNewSession());
|
|
1457
|
+
$('#newBrowse').addEventListener('click', async () => {
|
|
1458
|
+
const picked = await chooseFolder($('#newCwd').value.trim());
|
|
1459
|
+
if (picked.path) $('#newCwd').value = picked.path;
|
|
1460
|
+
else if (picked.unsupported) toast('No folder window here. Type the full path');
|
|
1461
|
+
});
|
|
1332
1462
|
$('#newDialog [data-new-cancel]').addEventListener('click', () => $('#newDialog').close());
|
|
1333
1463
|
$('#newMsg').addEventListener('keydown', (e) => {
|
|
1334
1464
|
if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) (e.preventDefault(), $('#newForm').requestSubmit());
|
|
@@ -1345,7 +1475,7 @@ $('#newForm').addEventListener('submit', async (e) => {
|
|
|
1345
1475
|
$('#newStart').disabled = true;
|
|
1346
1476
|
let r;
|
|
1347
1477
|
try {
|
|
1348
|
-
r = await post('/api/session/new', { cwd, message, agent: store.get('newAgent', 'claude') });
|
|
1478
|
+
r = await post('/api/session/new', { cwd, message, agent: store.get('newAgent', 'claude'), fromSessionId: carryFrom });
|
|
1349
1479
|
} catch {
|
|
1350
1480
|
return showError("Couldn't reach the Agenter server.");
|
|
1351
1481
|
}
|
|
@@ -1359,6 +1489,241 @@ $('#newForm').addEventListener('submit', async (e) => {
|
|
|
1359
1489
|
toast('Session started');
|
|
1360
1490
|
});
|
|
1361
1491
|
$('#scrim').addEventListener('click', closeNav);
|
|
1492
|
+
|
|
1493
|
+
// ---------- collapsing the sidebar ----------
|
|
1494
|
+
|
|
1495
|
+
const drawerLayout = matchMedia('(max-width: 800px)');
|
|
1496
|
+
function setNavMin(min) {
|
|
1497
|
+
const btn = $('#navMinBtn');
|
|
1498
|
+
$('#app').classList.toggle('nav-min', min);
|
|
1499
|
+
btn.setAttribute('aria-expanded', String(!min));
|
|
1500
|
+
btn.setAttribute('aria-label', min ? 'Expand sidebar' : 'Collapse sidebar');
|
|
1501
|
+
btn.title = `${min ? 'Expand' : 'Collapse'} sidebar (B)`;
|
|
1502
|
+
}
|
|
1503
|
+
function toggleNavMin() {
|
|
1504
|
+
if (drawerLayout.matches) return $('#app').classList.toggle('nav-open');
|
|
1505
|
+
const min = !$('#app').classList.contains('nav-min');
|
|
1506
|
+
store.set('navMin', min);
|
|
1507
|
+
setNavMin(min);
|
|
1508
|
+
}
|
|
1509
|
+
setNavMin(store.get('navMin', false));
|
|
1510
|
+
|
|
1511
|
+
// ---------- updates ----------
|
|
1512
|
+
|
|
1513
|
+
const update = { key: '', dismissed: '', toasted: new Set() };
|
|
1514
|
+
|
|
1515
|
+
function renderUpdate() {
|
|
1516
|
+
const u = state.update;
|
|
1517
|
+
const mode = u?.restart ? 'restart' : u?.available ? 'update' : '';
|
|
1518
|
+
const key = mode && `${mode}:${mode === 'restart' ? u.installed : u.latest}`;
|
|
1519
|
+
const el = $('#updateBar');
|
|
1520
|
+
el.hidden = !key || key === update.dismissed;
|
|
1521
|
+
if (key === update.key) return;
|
|
1522
|
+
update.key = key;
|
|
1523
|
+
if (!key) return;
|
|
1524
|
+
if (mode === 'restart') {
|
|
1525
|
+
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>
|
|
1526
|
+
<button class="btn primary small" type="button" data-update-restart>Restart</button><button class="btn ghost small" type="button" data-update-later>Later</button>`;
|
|
1527
|
+
} else {
|
|
1528
|
+
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>
|
|
1529
|
+
${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>`;
|
|
1530
|
+
}
|
|
1531
|
+
// Every time the app opens, once per version.
|
|
1532
|
+
if (!update.toasted.has(key)) {
|
|
1533
|
+
update.toasted.add(key);
|
|
1534
|
+
toast(mode === 'restart' ? 'Restart Agenter to finish updating' : `Agenter ${u.latest} is available`);
|
|
1535
|
+
}
|
|
1536
|
+
}
|
|
1537
|
+
|
|
1538
|
+
async function startUpdate() {
|
|
1539
|
+
const r = await post('/api/update', {}).catch(() => null);
|
|
1540
|
+
const data = await r?.json().catch(() => ({}));
|
|
1541
|
+
if (!r?.ok) return toast(data?.error || "Couldn't start the update");
|
|
1542
|
+
term.open = true;
|
|
1543
|
+
store.set('termOpen', true);
|
|
1544
|
+
term.sid = currentTermSession();
|
|
1545
|
+
await loadTerm({});
|
|
1546
|
+
render();
|
|
1547
|
+
}
|
|
1548
|
+
|
|
1549
|
+
async function restartApp() {
|
|
1550
|
+
const r = await post('/api/restart', {}).catch(() => null);
|
|
1551
|
+
toast(r?.ok ? 'Restarting Agenter…' : "Couldn't restart");
|
|
1552
|
+
}
|
|
1553
|
+
|
|
1554
|
+
// ---------- terminal ----------
|
|
1555
|
+
// One PowerShell per folder, kept by the server. The panel follows the session you're on;
|
|
1556
|
+
// the inbox uses your home folder.
|
|
1557
|
+
|
|
1558
|
+
const TERM_KEEP = 200 * 1024;
|
|
1559
|
+
const term = { open: store.get('termOpen', false), id: null, sid: undefined, running: false, cwd: '', history: store.get('termHistory', []), back: -1 };
|
|
1560
|
+
const currentTermSession = () => (state.view === 'session' ? state.selected : null);
|
|
1561
|
+
// Colors and cursor moves mean nothing here; bare carriage returns are progress redraws.
|
|
1562
|
+
const termText = (s) => s.replace(/\x1b\[[0-9;?]*[ -/]*[@-~]|\x1b\][^\x07]*\x07/g, '').replace(/\r\n/g, '\n').replace(/\r/g, '\n');
|
|
1563
|
+
|
|
1564
|
+
function termStatus(running, cwd) {
|
|
1565
|
+
term.running = running;
|
|
1566
|
+
term.cwd = cwd;
|
|
1567
|
+
$('#termCwd').textContent = cwd;
|
|
1568
|
+
$('#termCwd').title = cwd;
|
|
1569
|
+
$('#termStop').hidden = !running;
|
|
1570
|
+
$('#termPrompt').textContent = running ? '…' : '>';
|
|
1571
|
+
$('#termInput').placeholder = running ? 'Running. Type a line to send it to the program' : 'Type a command';
|
|
1572
|
+
}
|
|
1573
|
+
|
|
1574
|
+
function termAppend(text) {
|
|
1575
|
+
const out = $('#termOut');
|
|
1576
|
+
const stick = out.scrollHeight - out.scrollTop - out.clientHeight < 40;
|
|
1577
|
+
out.append(text);
|
|
1578
|
+
if (out.textContent.length > TERM_KEEP * 1.5) out.textContent = out.textContent.slice(-TERM_KEEP);
|
|
1579
|
+
if (stick) out.scrollTop = out.scrollHeight;
|
|
1580
|
+
}
|
|
1581
|
+
|
|
1582
|
+
async function loadTerm(body) {
|
|
1583
|
+
const r = await post('/api/terminal/open', body).catch(() => null);
|
|
1584
|
+
if (!r?.ok) return termAppend("Couldn't open the terminal.\n");
|
|
1585
|
+
const t = await r.json();
|
|
1586
|
+
term.id = t.id;
|
|
1587
|
+
$('#termOut').textContent = termText(t.output);
|
|
1588
|
+
$('#termOut').scrollTop = $('#termOut').scrollHeight;
|
|
1589
|
+
termStatus(t.running, t.cwd);
|
|
1590
|
+
}
|
|
1591
|
+
|
|
1592
|
+
function renderTerm() {
|
|
1593
|
+
const show = term.open && !!state.terminal;
|
|
1594
|
+
$('#term').hidden = !show;
|
|
1595
|
+
if (!show) return;
|
|
1596
|
+
const sid = currentTermSession();
|
|
1597
|
+
if (sid === term.sid) return;
|
|
1598
|
+
term.sid = sid;
|
|
1599
|
+
loadTerm({ sessionId: sid });
|
|
1600
|
+
}
|
|
1601
|
+
|
|
1602
|
+
function toggleTerm() {
|
|
1603
|
+
term.open = !term.open;
|
|
1604
|
+
store.set('termOpen', term.open);
|
|
1605
|
+
render();
|
|
1606
|
+
if (term.open) $('#termInput').focus();
|
|
1607
|
+
}
|
|
1608
|
+
|
|
1609
|
+
function onTerminal(e) {
|
|
1610
|
+
if (e.id !== term.id) return;
|
|
1611
|
+
if (e.data) termAppend(termText(e.data));
|
|
1612
|
+
termStatus(e.running, e.cwd);
|
|
1613
|
+
}
|
|
1614
|
+
|
|
1615
|
+
function termHistory(dir) {
|
|
1616
|
+
const next = term.back + dir;
|
|
1617
|
+
if (next < -1 || next >= term.history.length) return;
|
|
1618
|
+
term.back = next;
|
|
1619
|
+
$('#termInput').value = next < 0 ? '' : term.history[next];
|
|
1620
|
+
}
|
|
1621
|
+
|
|
1622
|
+
$('#termForm').addEventListener('submit', async (e) => {
|
|
1623
|
+
e.preventDefault();
|
|
1624
|
+
const input = $('#termInput').value;
|
|
1625
|
+
if (!input.trim() && !term.running) return;
|
|
1626
|
+
$('#termInput').value = '';
|
|
1627
|
+
term.back = -1;
|
|
1628
|
+
if (input.trim() && input !== term.history[0] && !term.running) {
|
|
1629
|
+
term.history = [input, ...term.history].slice(0, 100);
|
|
1630
|
+
store.set('termHistory', term.history);
|
|
1631
|
+
}
|
|
1632
|
+
const r = await post('/api/terminal/run', { id: term.id, input }).catch(() => null);
|
|
1633
|
+
if (r?.ok) return;
|
|
1634
|
+
// The server restarted, so that shell is gone: open a new one and try again from here.
|
|
1635
|
+
$('#termInput').value = input;
|
|
1636
|
+
term.sid = undefined;
|
|
1637
|
+
renderTerm();
|
|
1638
|
+
});
|
|
1639
|
+
|
|
1640
|
+
// ---------- memory ----------
|
|
1641
|
+
// The files the agent reads and keeps for this project. Edits stay in drafts until saved, so
|
|
1642
|
+
// moving between files doesn't lose them.
|
|
1643
|
+
|
|
1644
|
+
const mem = { sid: null, files: [], path: null, drafts: new Map() };
|
|
1645
|
+
const memFile = () => mem.files.find((f) => f.path === mem.path);
|
|
1646
|
+
const memDirty = (f) => mem.drafts.has(f.path) && mem.drafts.get(f.path) !== f.text;
|
|
1647
|
+
|
|
1648
|
+
function renderMemList() {
|
|
1649
|
+
let group = '';
|
|
1650
|
+
$('#memList').innerHTML = mem.files
|
|
1651
|
+
.map((f) => {
|
|
1652
|
+
const head = f.group === group ? '' : `<div class="mem-group">${esc((group = f.group))}</div>`;
|
|
1653
|
+
const mark = memDirty(f) ? '<i class="dirty" title="Not saved"></i>' : f.create ? '<i class="new">new</i>' : '';
|
|
1654
|
+
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>`;
|
|
1655
|
+
})
|
|
1656
|
+
.join('');
|
|
1657
|
+
}
|
|
1658
|
+
|
|
1659
|
+
function showMemFile(p) {
|
|
1660
|
+
mem.path = p;
|
|
1661
|
+
const f = memFile();
|
|
1662
|
+
const ta = $('#memText');
|
|
1663
|
+
$('#memPath').textContent = f.path;
|
|
1664
|
+
$('#memPath').title = f.path;
|
|
1665
|
+
ta.value = mem.drafts.get(f.path) ?? f.text;
|
|
1666
|
+
ta.readOnly = !!f.tooLarge;
|
|
1667
|
+
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." : '';
|
|
1668
|
+
$('#memError').hidden = true;
|
|
1669
|
+
$('#memSave').disabled = !memDirty(f);
|
|
1670
|
+
renderMemList();
|
|
1671
|
+
}
|
|
1672
|
+
|
|
1673
|
+
async function openMemory() {
|
|
1674
|
+
const s = sessionById(state.selected);
|
|
1675
|
+
if (!s) return;
|
|
1676
|
+
const r = await fetch(`/api/memory?session=${encodeURIComponent(s.id)}`, { headers: authHeaders }).catch(() => null);
|
|
1677
|
+
if (!r?.ok) return toast("Couldn't read this project's memory");
|
|
1678
|
+
mem.sid = s.id;
|
|
1679
|
+
mem.files = (await r.json()).files;
|
|
1680
|
+
mem.drafts.clear();
|
|
1681
|
+
if (!mem.files.length) return toast('This session has no project folder');
|
|
1682
|
+
$('#memSub').textContent = s.cwd;
|
|
1683
|
+
showMemFile((mem.files.find((f) => !f.create && f.text) || mem.files[0]).path);
|
|
1684
|
+
$('#memDialog').showModal();
|
|
1685
|
+
}
|
|
1686
|
+
|
|
1687
|
+
const memCanLeave = () => !mem.files.some(memDirty) || confirm('Discard your unsaved memory changes?');
|
|
1688
|
+
function closeMemory() {
|
|
1689
|
+
if (memCanLeave()) $('#memDialog').close();
|
|
1690
|
+
}
|
|
1691
|
+
$('#memDialog').addEventListener('cancel', (e) => {
|
|
1692
|
+
if (!memCanLeave()) e.preventDefault();
|
|
1693
|
+
});
|
|
1694
|
+
$('#memText').addEventListener('input', (e) => {
|
|
1695
|
+
const f = memFile();
|
|
1696
|
+
mem.drafts.set(f.path, e.target.value);
|
|
1697
|
+
$('#memSave').disabled = !memDirty(f);
|
|
1698
|
+
renderMemList();
|
|
1699
|
+
});
|
|
1700
|
+
$('#memSave').addEventListener('click', async () => {
|
|
1701
|
+
const f = memFile();
|
|
1702
|
+
const text = $('#memText').value;
|
|
1703
|
+
const r = await post('/api/memory/save', { sessionId: mem.sid, path: f.path, text, mtime: f.mtime }).catch(() => null);
|
|
1704
|
+
const data = await r?.json().catch(() => ({}));
|
|
1705
|
+
if (!r?.ok) {
|
|
1706
|
+
$('#memError').textContent = data?.error || "Couldn't save.";
|
|
1707
|
+
$('#memError').hidden = false;
|
|
1708
|
+
return;
|
|
1709
|
+
}
|
|
1710
|
+
Object.assign(f, { text, mtime: data.mtime, create: false });
|
|
1711
|
+
mem.drafts.delete(f.path);
|
|
1712
|
+
$('#memError').hidden = true;
|
|
1713
|
+
$('#memSave').disabled = true;
|
|
1714
|
+
renderMemList();
|
|
1715
|
+
toast(`Saved ${f.label}`);
|
|
1716
|
+
});
|
|
1717
|
+
|
|
1718
|
+
{
|
|
1719
|
+
const menu = $('#moreMenu');
|
|
1720
|
+
menu.addEventListener('beforetoggle', (e) => {
|
|
1721
|
+
if (e.newState !== 'open') return;
|
|
1722
|
+
const r = $('[data-more]').getBoundingClientRect();
|
|
1723
|
+
menu.style.top = `${r.bottom + 6}px`;
|
|
1724
|
+
menu.style.right = `${Math.max(8, innerWidth - r.right)}px`;
|
|
1725
|
+
});
|
|
1726
|
+
}
|
|
1362
1727
|
$('#helpBtn').addEventListener('click', () => $('#helpDialog').showModal());
|
|
1363
1728
|
$('#search').addEventListener('input', (e) => {
|
|
1364
1729
|
state.filter = e.target.value;
|