agenter-app 0.0.0-stage → 1.0.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 +9 -0
- package/LICENSE +21 -0
- package/README.md +168 -2
- package/cli.js +71 -0
- package/hook.js +49 -0
- package/install.js +73 -0
- package/lib/open-dashboard.js +26 -0
- package/open.js +29 -0
- package/package.json +47 -4
- package/public/app.js +1414 -0
- package/public/index.html +188 -0
- package/public/style.css +551 -0
- package/scripts/install-shortcuts.ps1 +36 -0
- package/server.js +1434 -0
package/public/app.js
ADDED
|
@@ -0,0 +1,1414 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// ---------- state & small utils ----------
|
|
4
|
+
|
|
5
|
+
const state = {
|
|
6
|
+
sessions: [],
|
|
7
|
+
pending: [],
|
|
8
|
+
settings: {},
|
|
9
|
+
view: 'inbox', // 'inbox' | 'session'
|
|
10
|
+
selected: null,
|
|
11
|
+
filter: '',
|
|
12
|
+
transcript: { sessionId: null, epoch: '', messages: null },
|
|
13
|
+
openTools: new Set(),
|
|
14
|
+
seenPending: new Set(),
|
|
15
|
+
firstSnapshot: true,
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
const $ = (sel, root = document) => root.querySelector(sel);
|
|
19
|
+
const esc = (s) => String(s ?? '').replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[c]);
|
|
20
|
+
const clip = (s, n) => ((s = String(s ?? '')).length > n ? s.slice(0, n) + `\n… (${s.length - n} more chars)` : s);
|
|
21
|
+
|
|
22
|
+
const store = {
|
|
23
|
+
get(k, d) {
|
|
24
|
+
try {
|
|
25
|
+
const v = localStorage.getItem('agenter.' + k);
|
|
26
|
+
return v === null ? d : JSON.parse(v);
|
|
27
|
+
} catch {
|
|
28
|
+
return d;
|
|
29
|
+
}
|
|
30
|
+
},
|
|
31
|
+
set(k, v) {
|
|
32
|
+
try {
|
|
33
|
+
localStorage.setItem('agenter.' + k, JSON.stringify(v));
|
|
34
|
+
} catch {}
|
|
35
|
+
},
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
function ago(t) {
|
|
39
|
+
const s = Math.max(0, Math.round((Date.now() - (typeof t === 'number' ? t : Date.parse(t))) / 1000));
|
|
40
|
+
if (s < 10) return 'now';
|
|
41
|
+
if (s < 60) return `${s}s`;
|
|
42
|
+
if (s < 3600) return `${Math.floor(s / 60)}m`;
|
|
43
|
+
if (s < 86400) return `${Math.floor(s / 3600)}h`;
|
|
44
|
+
return `${Math.floor(s / 86400)}d`;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const post = (url, body) => fetch(url, { method: 'POST', headers: { 'content-type': 'application/json', 'x-agenter': '1' }, body: JSON.stringify(body) });
|
|
48
|
+
|
|
49
|
+
const STATUS_LABEL = { working: 'Working', waiting: 'Needs you', vscode: 'Waiting in VS Code', idle: 'Idle', dormant: 'Not connected', ended: 'Ended' };
|
|
50
|
+
const isLive = (s) => s.status !== 'dormant' && s.status !== 'ended';
|
|
51
|
+
const pendingFor = (sid) => state.pending.filter((p) => p.sessionId === sid);
|
|
52
|
+
const sessionById = (sid) => state.sessions.find((s) => s.id === sid);
|
|
53
|
+
const sessionLabel = (s) => s.project || (s.cwd ? s.cwd.split(/[\\/]/).pop() : '') || s.id.slice(0, 8);
|
|
54
|
+
const isCodex = (s) => s.agent === 'codex';
|
|
55
|
+
const agentTag = (s) => (isCodex(s) ? '<span class="agent-tag" title="Codex session">Codex</span>' : '');
|
|
56
|
+
|
|
57
|
+
function pendingSummary(p) {
|
|
58
|
+
if (p.kind === 'permission') return `Wants to use ${p.tool}`;
|
|
59
|
+
if (p.kind === 'question') return 'Has a question for you';
|
|
60
|
+
return 'Finished, waiting for your reply';
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// ---------- icons & avatars ----------
|
|
64
|
+
|
|
65
|
+
const ICONS = {
|
|
66
|
+
terminal: '<polyline points="4 17 10 11 4 5"/><line x1="12" x2="20" y1="19" y2="19"/>',
|
|
67
|
+
file: '<path d="M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z"/><path d="M14 2v4a2 2 0 0 0 2 2h4"/>',
|
|
68
|
+
edit: '<path d="M12 20h9"/><path d="M16.5 3.5a2.12 2.12 0 0 1 3 3L7 19l-4 1 1-4Z"/>',
|
|
69
|
+
search: '<circle cx="11" cy="11" r="8"/><path d="m21 21-4.3-4.3"/>',
|
|
70
|
+
globe: '<circle cx="12" cy="12" r="10"/><path d="M2 12h20"/><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/>',
|
|
71
|
+
list: '<path d="m3 17 2 2 4-4"/><path d="m3 7 2 2 4-4"/><path d="M13 6h8"/><path d="M13 12h8"/><path d="M13 18h8"/>',
|
|
72
|
+
bot: '<rect width="18" height="10" x="3" y="11" rx="2"/><circle cx="12" cy="5" r="2"/><path d="M12 7v4"/><path d="M8 16h.01"/><path d="M16 16h.01"/>',
|
|
73
|
+
help: '<circle cx="12" cy="12" r="10"/><path d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3"/><path d="M12 17h.01"/>',
|
|
74
|
+
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"/>',
|
|
75
|
+
code: '<polyline points="16 18 22 12 16 6"/><polyline points="8 6 2 12 8 18"/>',
|
|
76
|
+
send: '<path d="m22 2-7 20-4-9-9-4Z"/><path d="M22 2 11 13"/>',
|
|
77
|
+
check: '<path d="M20 6 9 17l-5-5"/>',
|
|
78
|
+
shield: '<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10"/>',
|
|
79
|
+
reply: '<path d="M7.9 20A9 9 0 1 0 4 16.1L2 22Z"/>',
|
|
80
|
+
sparkle: '<path d="M12 3l1.9 5.8L20 11l-6.1 2.2L12 19l-1.9-5.8L4 11l6.1-2.2z"/>',
|
|
81
|
+
info: '<circle cx="12" cy="12" r="10"/><path d="M12 16v-4"/><path d="M12 8h.01"/>',
|
|
82
|
+
menu: '<line x1="4" x2="20" y1="12" y2="12"/><line x1="4" x2="20" y1="6" y2="6"/><line x1="4" x2="20" y1="18" y2="18"/>',
|
|
83
|
+
plug: '<path d="M12 22v-5"/><path d="M9 8V2"/><path d="M15 8V2"/><path d="M18 8v5a4 4 0 0 1-4 4h-4a4 4 0 0 1-4-4V8Z"/>',
|
|
84
|
+
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
|
+
gauge: '<path d="m12 14 4-4"/><path d="M3.34 19a10 10 0 1 1 17.32 0"/>',
|
|
86
|
+
preview: '<rect width="18" height="18" x="3" y="3" rx="2"/><path d="M15 3v18"/>',
|
|
87
|
+
};
|
|
88
|
+
const icon = (name) => `<svg class="ic" viewBox="0 0 24 24" aria-hidden="true">${ICONS[name] || ICONS.tool}</svg>`;
|
|
89
|
+
|
|
90
|
+
function toolIcon(name = '') {
|
|
91
|
+
if (/^(Bash|PowerShell|BashOutput|KillShell|exec|wait|shell)$/.test(name)) return 'terminal';
|
|
92
|
+
if (/^(Read|NotebookRead)$/.test(name)) return 'file';
|
|
93
|
+
if (/^(Edit|MultiEdit|Write|NotebookEdit)$/.test(name)) return 'edit';
|
|
94
|
+
if (/^(Grep|Glob|LS)$/.test(name)) return 'search';
|
|
95
|
+
if (/^Web/.test(name)) return 'globe';
|
|
96
|
+
if (/^Todo/.test(name)) return 'list';
|
|
97
|
+
if (/^(Task|Agent)$/.test(name)) return 'bot';
|
|
98
|
+
if (name === 'AskUserQuestion') return 'help';
|
|
99
|
+
if (name === 'ExitPlanMode') return 'list';
|
|
100
|
+
return 'tool';
|
|
101
|
+
}
|
|
102
|
+
const toolName = (name = '') => (name.startsWith('mcp__') ? name.slice(5).replace('__', ' · ') : name);
|
|
103
|
+
|
|
104
|
+
function hue(str) {
|
|
105
|
+
let h = 0;
|
|
106
|
+
for (const c of str) h = (h * 31 + c.charCodeAt(0)) % 360;
|
|
107
|
+
return h;
|
|
108
|
+
}
|
|
109
|
+
function initials(label) {
|
|
110
|
+
const parts = label.split(/[-_.\s]+/).filter(Boolean);
|
|
111
|
+
return ((parts.length > 1 ? parts[0][0] + parts[1][0] : label.slice(0, 2)) || '?').toUpperCase();
|
|
112
|
+
}
|
|
113
|
+
function avatar(s, cls = '') {
|
|
114
|
+
const label = sessionLabel(s);
|
|
115
|
+
return `<span class="avatar ${cls}" style="--h:${hue(label)}">${esc(initials(label))}<span class="st ${esc(s.status)}"></span></span>`;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// ---------- tiny markdown ----------
|
|
119
|
+
|
|
120
|
+
function inline(text) {
|
|
121
|
+
let t = esc(text);
|
|
122
|
+
const codes = [];
|
|
123
|
+
t = t.replace(/`([^`\n]+)`/g, (_, c) => `\u0000${codes.push(c) - 1}\u0000`);
|
|
124
|
+
t = t.replace(/\*\*([^*\n]+)\*\*/g, '<strong>$1</strong>');
|
|
125
|
+
t = t.replace(/(^|[^*\w])\*([^*\n]+)\*(?=[^*\w]|$)/g, '$1<em>$2</em>');
|
|
126
|
+
t = t.replace(/\[([^\]\n]+)\]\((https?:\/\/[^)\s]+)\)/g, '<a href="$2" target="_blank" rel="noopener">$1</a>');
|
|
127
|
+
t = t.replace(/\[([^\]\n]+)\]\(([^)\s]+)\)/g, '<span class="file-ref" title="$2">$1</span>'); // file links only work inside VS Code
|
|
128
|
+
return t.replace(/\u0000(\d+)\u0000/g, (_, i) => `<code>${codes[i]}</code>`);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function blocks(text) {
|
|
132
|
+
const out = [];
|
|
133
|
+
let para = [];
|
|
134
|
+
let list = null; // { tag, items }
|
|
135
|
+
let table = [];
|
|
136
|
+
const flushPara = () => para.length && (out.push(`<p>${para.map(inline).join('<br>')}</p>`), (para = []));
|
|
137
|
+
const flushList = () => list && (out.push(`<${list.tag}>${list.items.map((i) => `<li>${inline(i)}</li>`).join('')}</${list.tag}>`), (list = null));
|
|
138
|
+
const flushTable = () => {
|
|
139
|
+
if (!table.length) return;
|
|
140
|
+
const rows = table.filter((r) => !/^\|[\s:|-]+\|$/.test(r)).map((r) => r.slice(1, -1).split('|').map((c) => c.trim()));
|
|
141
|
+
out.push(`<table>${rows.map((r, i) => `<tr>${r.map((c) => (i ? `<td>${inline(c)}</td>` : `<th>${inline(c)}</th>`)).join('')}</tr>`).join('')}</table>`);
|
|
142
|
+
table = [];
|
|
143
|
+
};
|
|
144
|
+
const flushAll = () => (flushPara(), flushList(), flushTable());
|
|
145
|
+
|
|
146
|
+
for (const raw of text.split('\n')) {
|
|
147
|
+
const line = raw.trimEnd();
|
|
148
|
+
let m;
|
|
149
|
+
if (!line.trim()) flushAll();
|
|
150
|
+
else if (/^\|.*\|$/.test(line.trim())) (flushPara(), flushList(), table.push(line.trim()));
|
|
151
|
+
else if ((m = line.match(/^(#{1,4})\s+(.*)/))) (flushAll(), out.push(`<h${m[1].length}>${inline(m[2])}</h${m[1].length}>`));
|
|
152
|
+
else if ((m = line.match(/^\s*[-*•]\s+(.*)/)) || (m = line.match(/^\s*\d+[.)]\s+(.*)/))) {
|
|
153
|
+
const tag = /^\s*\d/.test(line) ? 'ol' : 'ul';
|
|
154
|
+
flushPara();
|
|
155
|
+
flushTable();
|
|
156
|
+
if (list && list.tag !== tag) flushList();
|
|
157
|
+
(list = list || { tag, items: [] }).items.push(m[1]);
|
|
158
|
+
} else if ((m = line.match(/^>\s?(.*)/))) (flushAll(), out.push(`<blockquote>${inline(m[1])}</blockquote>`));
|
|
159
|
+
else (flushList(), flushTable(), para.push(line));
|
|
160
|
+
}
|
|
161
|
+
flushAll();
|
|
162
|
+
return out.join('');
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
const md = (src) =>
|
|
166
|
+
String(src ?? '')
|
|
167
|
+
.split('```')
|
|
168
|
+
.map((part, i) => {
|
|
169
|
+
if (i % 2 === 0) return blocks(part);
|
|
170
|
+
const nl = part.indexOf('\n');
|
|
171
|
+
return `<pre><code>${esc((nl >= 0 ? part.slice(nl + 1) : part).replace(/\n$/, ''))}</code></pre>`;
|
|
172
|
+
})
|
|
173
|
+
.join('');
|
|
174
|
+
|
|
175
|
+
// ---------- live connection ----------
|
|
176
|
+
|
|
177
|
+
// The server only holds sessions while this window has focus, so tell it whenever that changes.
|
|
178
|
+
const CLIENT_ID = crypto.randomUUID();
|
|
179
|
+
const hasAttention = () => document.visibilityState === 'visible' && document.hasFocus();
|
|
180
|
+
let reportedFocus = null;
|
|
181
|
+
function reportFocus(force = false) {
|
|
182
|
+
const focused = hasAttention();
|
|
183
|
+
if (focused === reportedFocus && !force) return;
|
|
184
|
+
reportedFocus = focused;
|
|
185
|
+
post('/api/focus', { client: CLIENT_ID, focused }).catch(() => {});
|
|
186
|
+
}
|
|
187
|
+
window.addEventListener('focus', () => reportFocus());
|
|
188
|
+
// Clicking into the preview iframe blurs the window first; check once focus has landed.
|
|
189
|
+
window.addEventListener('blur', () => setTimeout(reportFocus));
|
|
190
|
+
document.addEventListener('visibilitychange', () => reportFocus());
|
|
191
|
+
|
|
192
|
+
function connect() {
|
|
193
|
+
const es = new EventSource(`/events?client=${CLIENT_ID}&focused=${hasAttention() ? 1 : 0}`);
|
|
194
|
+
es.addEventListener('state', (e) => apply(JSON.parse(e.data)));
|
|
195
|
+
es.onopen = () => (setConn(true), reportFocus(true)); // after a reconnect the URL's focus flag may be stale
|
|
196
|
+
es.onerror = () => setConn(false);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function setConn(ok) {
|
|
200
|
+
const el = $('#conn');
|
|
201
|
+
el.className = `conn ${ok ? 'ok' : 'bad'}`;
|
|
202
|
+
el.title = ok ? 'Live' : 'Reconnecting to the Agenter server…';
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function apply(snap) {
|
|
206
|
+
const prevEventAt = sessionById(state.selected)?.lastEventAt;
|
|
207
|
+
state.sessions = snap.sessions;
|
|
208
|
+
state.pending = snap.pending;
|
|
209
|
+
state.settings = snap.settings;
|
|
210
|
+
state.usage = snap.usage;
|
|
211
|
+
state.codexUsage = snap.codexUsage;
|
|
212
|
+
checkUsageAlerts(state.usage, 'Claude');
|
|
213
|
+
checkUsageAlerts(state.codexUsage, 'Codex');
|
|
214
|
+
|
|
215
|
+
for (const p of state.pending) {
|
|
216
|
+
if (state.seenPending.has(p.id)) continue;
|
|
217
|
+
state.seenPending.add(p.id);
|
|
218
|
+
if (!state.firstSnapshot) alertUser(p);
|
|
219
|
+
}
|
|
220
|
+
state.firstSnapshot = false;
|
|
221
|
+
|
|
222
|
+
// A session just started from here may reach us a moment after we switched to it.
|
|
223
|
+
if (sessionById(state.awaiting)) state.awaiting = null;
|
|
224
|
+
if (state.view === 'session' && !sessionById(state.selected) && state.selected !== state.awaiting) state.view = 'inbox';
|
|
225
|
+
render();
|
|
226
|
+
const sel = state.view === 'session' && sessionById(state.selected);
|
|
227
|
+
if (sel && (sel.id !== state.transcript.sessionId || sel.lastEventAt !== prevEventAt)) loadTranscript(sel.id);
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
function sortedSessions() {
|
|
231
|
+
const rank = { waiting: 0, vscode: 1, working: 2, idle: 3 };
|
|
232
|
+
const q = state.filter.toLowerCase();
|
|
233
|
+
return state.sessions
|
|
234
|
+
.filter((s) => !q || [sessionLabel(s), s.title, s.cwd].some((v) => (v || '').toLowerCase().includes(q)))
|
|
235
|
+
.sort((a, b) => (rank[a.status] ?? 4) - (rank[b.status] ?? 4) || b.lastEventAt - a.lastEventAt);
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
// ---------- alerts ----------
|
|
239
|
+
|
|
240
|
+
let audioCtx;
|
|
241
|
+
function beep() {
|
|
242
|
+
if (!store.get('sound', true)) return;
|
|
243
|
+
try {
|
|
244
|
+
audioCtx = audioCtx || new AudioContext();
|
|
245
|
+
const now = audioCtx.currentTime;
|
|
246
|
+
[880, 1175].forEach((f, i) => {
|
|
247
|
+
const o = audioCtx.createOscillator();
|
|
248
|
+
const g = audioCtx.createGain();
|
|
249
|
+
o.frequency.value = f;
|
|
250
|
+
g.gain.setValueAtTime(0.0001, now + i * 0.12);
|
|
251
|
+
g.gain.exponentialRampToValueAtTime(0.12, now + i * 0.12 + 0.02);
|
|
252
|
+
g.gain.exponentialRampToValueAtTime(0.0001, now + i * 0.12 + 0.18);
|
|
253
|
+
o.connect(g).connect(audioCtx.destination);
|
|
254
|
+
o.start(now + i * 0.12);
|
|
255
|
+
o.stop(now + i * 0.12 + 0.2);
|
|
256
|
+
});
|
|
257
|
+
} catch {}
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
function alertUser(p) {
|
|
261
|
+
beep();
|
|
262
|
+
const s = sessionById(p.sessionId);
|
|
263
|
+
if (document.hidden && 'Notification' in window && Notification.permission === 'granted') {
|
|
264
|
+
const n = new Notification(`${s ? sessionLabel(s) : 'Claude'} needs you`, { body: pendingSummary(p), tag: p.id });
|
|
265
|
+
n.onclick = () => {
|
|
266
|
+
window.focus();
|
|
267
|
+
select(p.sessionId);
|
|
268
|
+
n.close();
|
|
269
|
+
};
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
// One alert per limit window when usage crosses 80% and again at 95%.
|
|
274
|
+
function checkUsageAlerts(u, agent) {
|
|
275
|
+
for (const l of u?.limits || []) {
|
|
276
|
+
const crossed = [95, 80].find((t) => l.percent >= t);
|
|
277
|
+
if (!crossed) continue;
|
|
278
|
+
// Claude's keys predate Codex support; keep them so old alerts don't fire again.
|
|
279
|
+
const key = `usage-alert:${agent === 'Claude' ? '' : 'codex:'}${l.kind}:${crossed}:${l.resetsAt}`;
|
|
280
|
+
if (store.get(key, false)) continue;
|
|
281
|
+
store.set(key, true);
|
|
282
|
+
const text = `${agent} ${l.label} at ${Math.round(l.percent)}%${l.resetsAt ? `, resets ${clockText(l.resetsAt)}` : ''}`;
|
|
283
|
+
beep();
|
|
284
|
+
toast(text);
|
|
285
|
+
if (document.hidden && 'Notification' in window && Notification.permission === 'granted') new Notification(`${agent} usage limit`, { body: text, tag: key });
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
// A window that reset since the numbers were recorded (Codex's come from its last turn) is back to 0.
|
|
290
|
+
const freshLimits = (u) => (u?.limits || []).map((l) => (l.resetsAt && Date.parse(l.resetsAt) < Date.now() ? { ...l, percent: 0, resetsAt: null } : l));
|
|
291
|
+
|
|
292
|
+
const usageLevel = (l) => (l.percent >= 90 || l.severity === 'critical' ? 'crit' : l.percent >= 75 || l.severity === 'warning' ? 'warn' : '');
|
|
293
|
+
|
|
294
|
+
function untilText(iso) {
|
|
295
|
+
const m = Math.round((Date.parse(iso) - Date.now()) / 60000);
|
|
296
|
+
if (!(m > 0)) return 'any moment';
|
|
297
|
+
if (m < 60) return `${m}m`;
|
|
298
|
+
const h = Math.floor(m / 60);
|
|
299
|
+
return h < 24 ? `${h}h ${m % 60}m` : `${Math.floor(h / 24)}d ${h % 24}h`;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
function clockText(iso) {
|
|
303
|
+
const d = new Date(iso);
|
|
304
|
+
const t = d.toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' });
|
|
305
|
+
return d.toDateString() === new Date().toDateString() ? t : `${d.toLocaleDateString([], { weekday: 'short' })} ${t}`;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
let usageRefreshing = false;
|
|
309
|
+
function renderUsage() {
|
|
310
|
+
const el = $('#usage');
|
|
311
|
+
const claude = state.usage;
|
|
312
|
+
const codex = state.codexUsage;
|
|
313
|
+
el.hidden = !claude && !codex;
|
|
314
|
+
const meters = (u) =>
|
|
315
|
+
freshLimits(u)
|
|
316
|
+
.map(
|
|
317
|
+
(l) => `<div class="meter ${usageLevel(l)}">
|
|
318
|
+
<div class="meter-top"><span>${esc(l.label)}</span><b>${Math.round(l.percent)}%</b></div>
|
|
319
|
+
<div class="bar" role="progressbar" aria-label="${esc(l.label)}" aria-valuenow="${Math.round(l.percent)}" aria-valuemin="0" aria-valuemax="100"><i style="width:${Math.min(100, Math.max(0, l.percent))}%"></i></div>
|
|
320
|
+
${l.resetsAt ? `<div class="meter-sub">Resets in ${untilText(l.resetsAt)} · ${clockText(l.resetsAt)}</div>` : ''}
|
|
321
|
+
</div>`
|
|
322
|
+
)
|
|
323
|
+
.join('');
|
|
324
|
+
// Folded by default to a one-line summary, so the session list keeps its room.
|
|
325
|
+
const SHORT = { session: '5h', weekly_all: 'Week', weekly_opus: 'Opus', weekly_sonnet: 'Sonnet' };
|
|
326
|
+
const summary = (u) =>
|
|
327
|
+
freshLimits(u)
|
|
328
|
+
.map((l) => `<span class="${usageLevel(l)}">${esc(SHORT[l.kind] || l.label)} ${Math.round(l.percent)}%</span>`)
|
|
329
|
+
.join('<i>·</i>');
|
|
330
|
+
const open = (agent) => store.get(`usageOpen:${agent}`, false);
|
|
331
|
+
const head = (agent, title, u, extra = '') =>
|
|
332
|
+
`<div class="usage-head"><button type="button" class="usage-toggle" data-usage-toggle="${agent}" aria-expanded="${open(agent)}" title="${open(agent) ? 'Hide details' : 'Show details'}">${icon('gauge')}<span>${title}</span>${u.plan ? `<span class="plan">${esc(u.plan)}</span>` : ''}${open(agent) ? '' : `<span class="usage-sum">${summary(u)}</span>`}</button>${extra}</div>`;
|
|
333
|
+
const updated = (t) => {
|
|
334
|
+
const age = ago(t);
|
|
335
|
+
return age === 'now' ? 'just now' : `${age} ago`;
|
|
336
|
+
};
|
|
337
|
+
let html = '';
|
|
338
|
+
if (claude) {
|
|
339
|
+
const note = claude.error ? `<div class="usage-note err">${esc(claude.error)}</div>` : open('claude') ? `<div class="usage-note">Updated ${updated(claude.fetchedAt)}</div>` : '';
|
|
340
|
+
html += `<div class="usage-block">${head('claude', codex ? 'Claude' : 'Plan usage', claude, `<button type="button" data-usage-refresh title="Check now" class="${usageRefreshing ? 'spin' : ''}">${icon('refresh')}</button>`)}
|
|
341
|
+
${open('claude') ? meters(claude) : ''}${note}</div>`;
|
|
342
|
+
}
|
|
343
|
+
if (codex) {
|
|
344
|
+
const credits = codex.credits?.unlimited ? ' · unlimited credits' : typeof codex.credits?.balance === 'number' ? ` · ${codex.credits.balance} credits` : '';
|
|
345
|
+
html += `<div class="usage-block">${head('codex', 'Codex', codex)}${
|
|
346
|
+
open('codex')
|
|
347
|
+
? `${meters(codex)}<div class="usage-note" title="Codex records your limits with each turn, so this is as of your last Codex turn">As of your last Codex turn, ${updated(codex.updatedAt)}${credits}</div>`
|
|
348
|
+
: ''
|
|
349
|
+
}</div>`;
|
|
350
|
+
}
|
|
351
|
+
el.innerHTML = html;
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
function usageBanner() {
|
|
355
|
+
return [
|
|
356
|
+
['Claude', state.usage],
|
|
357
|
+
['Codex', state.codexUsage],
|
|
358
|
+
]
|
|
359
|
+
.map(([agent, u]) => {
|
|
360
|
+
const l = freshLimits(u).find((x) => x.kind === 'session' && x.percent >= 80);
|
|
361
|
+
if (!l) return '';
|
|
362
|
+
const crit = l.percent >= 95;
|
|
363
|
+
return `<div class="usage-banner ${crit ? 'crit' : ''}">${icon('gauge')}<div>
|
|
364
|
+
<b>${crit ? 'Almost out of' : "You've used most of"} your ${agent} 5-hour limit (${Math.round(l.percent)}%)</b>
|
|
365
|
+
<span>It resets in ${untilText(l.resetsAt)} at ${clockText(l.resetsAt)}. ${agent} will pause when it runs out.</span></div></div>`;
|
|
366
|
+
})
|
|
367
|
+
.join('');
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
function toast(text) {
|
|
371
|
+
const el = document.createElement('div');
|
|
372
|
+
el.className = 'toast';
|
|
373
|
+
el.innerHTML = `${icon('check')}<span>${esc(text)}</span>`;
|
|
374
|
+
$('#toasts').append(el);
|
|
375
|
+
setTimeout(() => el.classList.add('out'), 2200);
|
|
376
|
+
setTimeout(() => el.remove(), 2600);
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
// ---------- rendering ----------
|
|
380
|
+
|
|
381
|
+
let built = ''; // which view the content area is currently built for
|
|
382
|
+
const keys = {};
|
|
383
|
+
|
|
384
|
+
function render() {
|
|
385
|
+
renderSidebar();
|
|
386
|
+
renderHead();
|
|
387
|
+
const want = state.view === 'inbox' ? 'inbox' : `session:${state.selected}`;
|
|
388
|
+
if (want !== built) {
|
|
389
|
+
built = want;
|
|
390
|
+
for (const k in keys) delete keys[k];
|
|
391
|
+
$('#content').innerHTML = state.view === 'inbox' ? '<div id="inboxBanner"></div><div id="inboxPending"></div><div id="inboxRest"></div>' : '<div class="convo" id="convo"></div><div id="liveRow"></div>';
|
|
392
|
+
if (state.view === 'session') renderConvo();
|
|
393
|
+
}
|
|
394
|
+
if (state.view === 'inbox') renderInbox();
|
|
395
|
+
else renderLiveRow();
|
|
396
|
+
renderDock();
|
|
397
|
+
renderPreview();
|
|
398
|
+
markKeyTarget();
|
|
399
|
+
renderUsage();
|
|
400
|
+
refreshAges();
|
|
401
|
+
const needs = state.pending.length;
|
|
402
|
+
document.title = needs ? `(${needs}) Agenter` : 'Agenter';
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
function keyed(id, key, html, after) {
|
|
406
|
+
if (keys[id] === key) return false;
|
|
407
|
+
keys[id] = key;
|
|
408
|
+
const el = document.getElementById(id);
|
|
409
|
+
if (el) {
|
|
410
|
+
const saved = saveCardInputs(el);
|
|
411
|
+
el.innerHTML = html();
|
|
412
|
+
restoreCardInputs(el, saved);
|
|
413
|
+
after?.(el);
|
|
414
|
+
}
|
|
415
|
+
return true;
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
// Rebuilding a list of cards must not lose a half-typed reply, a deny reason, picked
|
|
419
|
+
// answers or focus. A card's markup is fixed for its pending item, so its controls
|
|
420
|
+
// line up by position.
|
|
421
|
+
function saveCardInputs(root) {
|
|
422
|
+
const saved = new Map();
|
|
423
|
+
const active = document.activeElement;
|
|
424
|
+
root.querySelectorAll('.card[data-id]').forEach((card) => {
|
|
425
|
+
const controls = [...card.querySelectorAll('button, input, textarea')];
|
|
426
|
+
const focus = controls.indexOf(active);
|
|
427
|
+
let sel = null;
|
|
428
|
+
try {
|
|
429
|
+
if (focus >= 0 && active.selectionStart !== null) sel = [active.selectionStart, active.selectionEnd];
|
|
430
|
+
} catch {}
|
|
431
|
+
saved.set(card.dataset.id, { values: controls.map((c) => ({ value: c.value, checked: c.checked, disabled: c.disabled })), focus, sel });
|
|
432
|
+
});
|
|
433
|
+
return saved;
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
function restoreCardInputs(root, saved) {
|
|
437
|
+
root.querySelectorAll('.card[data-id]').forEach((card) => {
|
|
438
|
+
const prev = saved.get(card.dataset.id);
|
|
439
|
+
if (!prev) return;
|
|
440
|
+
card.classList.add('kept'); // already on screen: don't replay the entrance animation
|
|
441
|
+
const controls = [...card.querySelectorAll('button, input, textarea')];
|
|
442
|
+
prev.values.forEach((v, i) => {
|
|
443
|
+
const c = controls[i];
|
|
444
|
+
if (!c) return;
|
|
445
|
+
if (c.type === 'radio' || c.type === 'checkbox') c.checked = v.checked;
|
|
446
|
+
else if (c.matches('input, textarea')) c.value = v.value;
|
|
447
|
+
c.disabled = v.disabled;
|
|
448
|
+
if (c.matches('.reply-box textarea')) fitTextarea(c);
|
|
449
|
+
});
|
|
450
|
+
const f = controls[prev.focus];
|
|
451
|
+
if (f) {
|
|
452
|
+
f.focus({ preventScroll: true });
|
|
453
|
+
if (prev.sel) f.setSelectionRange(...prev.sel);
|
|
454
|
+
}
|
|
455
|
+
});
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
function fitTextarea(ta) {
|
|
459
|
+
ta.style.height = 'auto';
|
|
460
|
+
ta.style.height = Math.min(ta.scrollHeight, 260) + 'px';
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
function refreshAges() {
|
|
464
|
+
document.querySelectorAll('[data-t]').forEach((el) => (el.textContent = ago(Number(el.dataset.t))));
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
function countdown(releaseAt) {
|
|
468
|
+
const s = Math.max(0, Math.round((releaseAt - Date.now()) / 1000));
|
|
469
|
+
return `Back to VS Code in ${Math.floor(s / 60)}:${String(s % 60).padStart(2, '0')}`;
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
function renderSidebar() {
|
|
473
|
+
const needs = state.pending.length;
|
|
474
|
+
$('#inboxCount').hidden = !needs;
|
|
475
|
+
$('#inboxCount').textContent = needs;
|
|
476
|
+
$('#navInbox').classList.toggle('active', state.view === 'inbox');
|
|
477
|
+
$('#holdAll').checked = !!state.settings.holdOnStop;
|
|
478
|
+
if (document.activeElement !== $('#holdMinutes')) $('#holdMinutes').value = String(state.settings.holdMinutes ?? 3);
|
|
479
|
+
|
|
480
|
+
const item = (s) => {
|
|
481
|
+
const pend = pendingFor(s.id);
|
|
482
|
+
const sub = pend.length ? pendingSummary(pend[0]) + (pend.length > 1 ? ` (+${pend.length - 1})` : '') : (isLive(s) && s.lastAction) || s.title || STATUS_LABEL[s.status];
|
|
483
|
+
const selected = state.view === 'session' && s.id === state.selected;
|
|
484
|
+
return `<div class="s-item ${selected ? 'selected' : ''} ${isLive(s) ? '' : 'dim'}" data-sid="${esc(s.id)}" title="${esc(s.title ? `${s.title}\n${s.cwd}` : s.cwd)}">
|
|
485
|
+
${avatar(s)}
|
|
486
|
+
<div class="s-main">
|
|
487
|
+
<div class="s-name"><span class="s-label">${esc(sessionLabel(s))}</span>${agentTag(s)}</div>
|
|
488
|
+
<div class="s-sub ${pend.length ? 'needs' : ''}">${esc(sub)}</div>
|
|
489
|
+
</div>
|
|
490
|
+
<span class="s-when" data-t="${s.lastEventAt}">${ago(s.lastEventAt)}</span>
|
|
491
|
+
</div>`;
|
|
492
|
+
};
|
|
493
|
+
const all = sortedSessions();
|
|
494
|
+
const live = all.filter(isLive);
|
|
495
|
+
const earlier = all.filter((s) => !isLive(s));
|
|
496
|
+
let html = '';
|
|
497
|
+
if (live.length) html += `<div class="group-label"><span>Live</span><span>${live.length}</span></div>${live.map(item).join('')}`;
|
|
498
|
+
if (earlier.length) html += `<div class="group-label"><span>Earlier today</span><span>${earlier.length}</span></div>${earlier.map(item).join('')}`;
|
|
499
|
+
if (!html) html = `<div class="group-label">${state.filter ? 'No matches' : 'No sessions yet'}</div>`;
|
|
500
|
+
$('#sessionList').innerHTML = html;
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
function renderHead() {
|
|
504
|
+
const menu = `<button class="btn ghost icon menu-btn" data-nav-toggle title="Sessions">${icon('menu')}</button>`;
|
|
505
|
+
if (state.view === 'inbox') {
|
|
506
|
+
const count = (st) => state.sessions.filter((s) => s.status === st).length;
|
|
507
|
+
const parts = [
|
|
508
|
+
state.pending.length && `${state.pending.length} waiting for you`,
|
|
509
|
+
count('working') && `${count('working')} working`,
|
|
510
|
+
count('idle') + count('vscode') && `${count('idle') + count('vscode')} idle`,
|
|
511
|
+
].filter(Boolean);
|
|
512
|
+
$('#mainHead').innerHTML = `${menu}<div class="head-text">
|
|
513
|
+
<div class="head-title"><span>Inbox</span></div>
|
|
514
|
+
<div class="head-sub">${esc(parts.join(' · ') || 'Nothing running right now')}</div></div>`;
|
|
515
|
+
return;
|
|
516
|
+
}
|
|
517
|
+
const s = sessionById(state.selected);
|
|
518
|
+
if (!s) return;
|
|
519
|
+
$('#mainHead').innerHTML = `${menu}
|
|
520
|
+
${avatar(s, 'lg')}
|
|
521
|
+
<div class="head-text">
|
|
522
|
+
<div class="head-title"><span>${esc(sessionLabel(s))}</span>${agentTag(s)}
|
|
523
|
+
<span class="pill ${esc(s.status)}">${esc(isCodex(s) && s.status === 'dormant' ? 'Inactive' : STATUS_LABEL[s.status] || s.status)}</span>
|
|
524
|
+
${s.permissionMode && s.permissionMode !== 'default' ? `<span class="pill plain">${esc(s.permissionMode)}</span>` : ''}
|
|
525
|
+
</div>
|
|
526
|
+
<div class="head-sub">${s.title ? `${esc(s.title)} · ` : ''}<button class="path-chip mono" data-copy="${esc(s.cwd)}" title="Copy path">${esc(s.cwd || s.id)}</button></div>
|
|
527
|
+
</div>
|
|
528
|
+
<div class="head-actions">
|
|
529
|
+
${
|
|
530
|
+
isLive(s) && s.drive !== 'agenter' && !isCodex(s)
|
|
531
|
+
? `<label class="switch" title="Keep this session waiting for your reply here when it finishes a turn">
|
|
532
|
+
<input type="checkbox" data-hold ${s.hold ? 'checked' : ''}><span class="track"></span><span class="label">Hold for reply</span></label>`
|
|
533
|
+
: ''
|
|
534
|
+
}
|
|
535
|
+
${
|
|
536
|
+
s.cwd
|
|
537
|
+
? `<div class="drive" title="Who runs this session's turns"><span class="label">Drive from</span><div class="segmented" role="radiogroup" aria-label="Drive from">
|
|
538
|
+
${
|
|
539
|
+
isCodex(s)
|
|
540
|
+
? `<button type="button" role="radio" data-drive="vscode" aria-checked="${s.drive !== 'agenter'}" title="Codex runs in its app (VS Code or desktop); messages from here are queued there">Codex app</button>`
|
|
541
|
+
: `<button type="button" role="radio" data-drive="vscode" aria-checked="${s.drive !== 'agenter'}" title="Claude runs in the VS Code panel; Agenter answers inside its turns">VS Code</button>`
|
|
542
|
+
}
|
|
543
|
+
<button type="button" role="radio" data-drive="agenter" aria-checked="${s.drive === 'agenter'}" title="Agenter runs the turns itself, so nothing waits on ${isCodex(s) ? 'the Codex app' : 'VS Code'}">Agenter</button>
|
|
544
|
+
</div></div>`
|
|
545
|
+
: ''
|
|
546
|
+
}
|
|
547
|
+
${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
|
+
${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>` : ''}
|
|
549
|
+
</div>`;
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
// ---------- app preview ----------
|
|
553
|
+
// The project's running app, side by side with the conversation. An address you pick
|
|
554
|
+
// or type is remembered per project; otherwise the first page detected is shown.
|
|
555
|
+
|
|
556
|
+
const preview = { open: store.get('previewOpen', false), cwd: null, apps: [], scanning: false, detects: true, timer: null, shown: false };
|
|
557
|
+
const previewKey = () => 'previewUrl:' + (preview.cwd || '').toLowerCase();
|
|
558
|
+
const chosenUrl = () => store.get(previewKey(), '') || preview.apps.find((a) => a.html)?.url || preview.apps[0]?.url || '';
|
|
559
|
+
|
|
560
|
+
function normalizeUrl(v) {
|
|
561
|
+
v = v.trim();
|
|
562
|
+
if (!v) return '';
|
|
563
|
+
if (/^\d+$/.test(v)) return `http://localhost:${v}`;
|
|
564
|
+
if (!/^https?:\/\//i.test(v)) v = 'http://' + v;
|
|
565
|
+
try {
|
|
566
|
+
return new URL(v).href;
|
|
567
|
+
} catch {
|
|
568
|
+
return '';
|
|
569
|
+
}
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
function renderPreview() {
|
|
573
|
+
const s = state.view === 'session' && sessionById(state.selected);
|
|
574
|
+
const show = !!(preview.open && s?.cwd);
|
|
575
|
+
$('#preview').hidden = $('#splitter').hidden = !show;
|
|
576
|
+
if (!show) {
|
|
577
|
+
preview.shown = false;
|
|
578
|
+
return clearTimeout(preview.timer);
|
|
579
|
+
}
|
|
580
|
+
if (s.cwd !== preview.cwd) {
|
|
581
|
+
preview.cwd = s.cwd;
|
|
582
|
+
preview.apps = [];
|
|
583
|
+
preview.shown = false;
|
|
584
|
+
loadFrame();
|
|
585
|
+
}
|
|
586
|
+
if (!preview.shown) {
|
|
587
|
+
preview.shown = true;
|
|
588
|
+
scanApps();
|
|
589
|
+
}
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
function loadFrame() {
|
|
593
|
+
const url = chosenUrl();
|
|
594
|
+
if (document.activeElement !== $('#pvUrl')) $('#pvUrl').value = url;
|
|
595
|
+
const body = $('#pvBody');
|
|
596
|
+
const frame = $('iframe', body);
|
|
597
|
+
if (url && frame?.dataset.src === url) return;
|
|
598
|
+
if (!url) {
|
|
599
|
+
body.innerHTML = `<div class="pv-empty">${
|
|
600
|
+
preview.scanning
|
|
601
|
+
? `<b>Looking for your app…</b>Checking what's running from this project.`
|
|
602
|
+
: preview.detects
|
|
603
|
+
? `<b>No app running for this project</b>Start its dev server (for example <code>npm run dev</code>) and it shows up here. Or type its address above.`
|
|
604
|
+
: `<b>Which app?</b>Type its address above, for example <code>localhost:5173</code>.`
|
|
605
|
+
}</div>`;
|
|
606
|
+
return;
|
|
607
|
+
}
|
|
608
|
+
const app = preview.apps.find((a) => a.url === url);
|
|
609
|
+
body.innerHTML =
|
|
610
|
+
`<iframe title="App preview" data-src="${esc(url)}" src="${esc(url)}"></iframe>` +
|
|
611
|
+
(app && !app.embeddable
|
|
612
|
+
? `<div class="notice pv-warn">${icon('info')}<div><b>This app may refuse to show inside another page.</b> If it stays blank, open it in your browser.</div><button class="btn small" type="button" data-pv-pop>Open</button></div>`
|
|
613
|
+
: '');
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
function renderApps() {
|
|
617
|
+
const url = chosenUrl();
|
|
618
|
+
const chips = preview.apps
|
|
619
|
+
.map((a) => `<button class="pv-chip ${a.url === url ? 'on' : ''}" type="button" data-pv-app="${esc(a.url)}" title="${esc(a.url)}"><span class="mono">:${a.port}</span>${esc(a.label)}</button>`)
|
|
620
|
+
.join('');
|
|
621
|
+
$('#pvApps').innerHTML = preview.detects
|
|
622
|
+
? `${chips ? `<span>Running:</span>${chips}` : ''}<button class="btn ghost small scan" type="button" data-pv-scan ${preview.scanning ? 'disabled' : ''}>${icon('refresh')}${preview.scanning ? 'Looking…' : 'Find apps'}</button>`
|
|
623
|
+
: '';
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
async function scanApps() {
|
|
627
|
+
clearTimeout(preview.timer);
|
|
628
|
+
const cwd = preview.cwd;
|
|
629
|
+
const sid = state.selected;
|
|
630
|
+
preview.scanning = true;
|
|
631
|
+
renderApps();
|
|
632
|
+
loadFrame();
|
|
633
|
+
let r = null;
|
|
634
|
+
try {
|
|
635
|
+
r = await fetch(`/api/preview/apps?session=${encodeURIComponent(sid)}`).then((res) => res.json());
|
|
636
|
+
} catch {}
|
|
637
|
+
if (cwd !== preview.cwd) return; // switched project meanwhile; that one has its own scan
|
|
638
|
+
preview.scanning = false;
|
|
639
|
+
if (r) {
|
|
640
|
+
preview.apps = r.apps || [];
|
|
641
|
+
preview.detects = r.detects !== false;
|
|
642
|
+
}
|
|
643
|
+
renderApps();
|
|
644
|
+
loadFrame();
|
|
645
|
+
// A dev server started later should show up without clicking Find apps.
|
|
646
|
+
if (!preview.apps.length && preview.detects && preview.shown) preview.timer = setTimeout(scanApps, 10000);
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
function pickUrl(url) {
|
|
650
|
+
store.set(previewKey(), url);
|
|
651
|
+
renderApps();
|
|
652
|
+
loadFrame();
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
function setPreviewWidth(pct) {
|
|
656
|
+
pct = Math.min(80, Math.max(20, pct));
|
|
657
|
+
$('#split').style.setProperty('--preview-w', `${pct}%`);
|
|
658
|
+
return pct;
|
|
659
|
+
}
|
|
660
|
+
setPreviewWidth(store.get('previewWidth', 50));
|
|
661
|
+
|
|
662
|
+
$('#splitter').addEventListener('pointerdown', (e) => {
|
|
663
|
+
const split = $('#split');
|
|
664
|
+
const splitter = e.currentTarget;
|
|
665
|
+
const rect = split.getBoundingClientRect();
|
|
666
|
+
let pct = store.get('previewWidth', 50);
|
|
667
|
+
split.classList.add('resizing');
|
|
668
|
+
splitter.setPointerCapture(e.pointerId);
|
|
669
|
+
const move = (ev) => (pct = setPreviewWidth(((rect.right - ev.clientX) / rect.width) * 100));
|
|
670
|
+
const up = () => {
|
|
671
|
+
split.classList.remove('resizing');
|
|
672
|
+
splitter.removeEventListener('pointermove', move);
|
|
673
|
+
splitter.removeEventListener('pointerup', up);
|
|
674
|
+
store.set('previewWidth', pct);
|
|
675
|
+
};
|
|
676
|
+
splitter.addEventListener('pointermove', move);
|
|
677
|
+
splitter.addEventListener('pointerup', up);
|
|
678
|
+
});
|
|
679
|
+
$('#splitter').addEventListener('dblclick', () => store.set('previewWidth', setPreviewWidth(50)));
|
|
680
|
+
|
|
681
|
+
$('#pvForm').addEventListener('submit', (e) => {
|
|
682
|
+
e.preventDefault();
|
|
683
|
+
const url = normalizeUrl($('#pvUrl').value);
|
|
684
|
+
if ($('#pvUrl').value.trim() && !url) return toast("That doesn't look like an address");
|
|
685
|
+
$('#pvUrl').blur();
|
|
686
|
+
pickUrl(url); // empty goes back to the detected app
|
|
687
|
+
});
|
|
688
|
+
|
|
689
|
+
function onPreviewClick(t) {
|
|
690
|
+
if (t.closest('[data-preview-toggle], [data-pv-close]')) {
|
|
691
|
+
preview.open = !!t.closest('[data-preview-toggle]') && !preview.open;
|
|
692
|
+
store.set('previewOpen', preview.open);
|
|
693
|
+
render();
|
|
694
|
+
return true;
|
|
695
|
+
}
|
|
696
|
+
if (t.closest('[data-pv-reload]')) {
|
|
697
|
+
const frame = $('#pvBody iframe');
|
|
698
|
+
if (frame) frame.src = frame.dataset.src;
|
|
699
|
+
return true;
|
|
700
|
+
}
|
|
701
|
+
if (t.closest('[data-pv-pop]')) {
|
|
702
|
+
const url = chosenUrl();
|
|
703
|
+
if (url) window.open(url, '_blank', 'noopener');
|
|
704
|
+
return true;
|
|
705
|
+
}
|
|
706
|
+
if (t.closest('[data-pv-app]')) return pickUrl(t.closest('[data-pv-app]').dataset.pvApp), true;
|
|
707
|
+
if (t.closest('[data-pv-scan]')) return scanApps(), true;
|
|
708
|
+
return false;
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
// ---------- inbox ----------
|
|
712
|
+
|
|
713
|
+
function renderInbox() {
|
|
714
|
+
const banner = usageBanner();
|
|
715
|
+
if ($('#inboxBanner').innerHTML !== banner) $('#inboxBanner').innerHTML = banner;
|
|
716
|
+
keyed(
|
|
717
|
+
'inboxPending',
|
|
718
|
+
state.pending.map((p) => p.id).join(',') || 'clear',
|
|
719
|
+
() =>
|
|
720
|
+
`<div class="section-title">Needs you ${state.pending.length ? `<span>${state.pending.length}</span>` : ''}</div>` +
|
|
721
|
+
(state.pending.length
|
|
722
|
+
? sortPending(state.pending).map((p) => renderCard(p, true)).join('')
|
|
723
|
+
: `<div class="all-clear"><span class="big">${icon('check')}</span><div><b>All clear</b><span>Nothing is waiting on you. Permission prompts, questions and finished turns from every session show up here.</span></div></div>`),
|
|
724
|
+
focusFirst
|
|
725
|
+
);
|
|
726
|
+
|
|
727
|
+
const all = sortedSessions();
|
|
728
|
+
const live = all.filter(isLive);
|
|
729
|
+
const earlier = all.filter((s) => !isLive(s));
|
|
730
|
+
const tile = (s, compact) => {
|
|
731
|
+
const pend = pendingFor(s.id);
|
|
732
|
+
return `<button class="tile ${compact ? 'compact' : ''} ${pend.length ? 'needs' : ''}" data-sid="${esc(s.id)}" type="button">
|
|
733
|
+
<div class="tile-top">${avatar(s)}<div style="min-width:0;flex:1"><div class="tile-name">${esc(sessionLabel(s))}${agentTag(s)}</div>
|
|
734
|
+
${compact ? '' : `<div class="tile-when"><span class="pill ${esc(s.status)}">${esc(STATUS_LABEL[s.status])}</span> · <span data-t="${s.lastEventAt}">${ago(s.lastEventAt)}</span></div>`}</div></div>
|
|
735
|
+
<div class="tile-title">${esc(s.title || 'Untitled session')}</div>
|
|
736
|
+
${compact ? `<span class="tile-when" data-t="${s.lastEventAt}">${ago(s.lastEventAt)}</span>` : `<div class="tile-action">${esc(pend.length ? pendingSummary(pend[0]) : s.lastAction || '—')}</div>`}
|
|
737
|
+
</button>`;
|
|
738
|
+
};
|
|
739
|
+
$('#inboxRest').innerHTML =
|
|
740
|
+
(live.length ? `<div class="section-title">Live sessions <span>${live.length}</span></div><div class="tiles">${live.map((s) => tile(s)).join('')}</div>` : '') +
|
|
741
|
+
(earlier.length ? `<div class="section-title">Earlier today <span>${earlier.length}</span></div><div class="tiles">${earlier.map((s) => tile(s, true)).join('')}</div>` : '') +
|
|
742
|
+
(!state.sessions.length
|
|
743
|
+
? `<div class="empty"><h2>No Claude sessions yet</h2><p>Run <code>npm run install-hooks</code> once and reload your VS Code windows.<br>Sessions show up here as soon as they do something.</p></div>`
|
|
744
|
+
: '');
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
const sortPending = (list) => [...list].sort((a, b) => a.createdAt - b.createdAt);
|
|
748
|
+
|
|
749
|
+
// ---------- session view ----------
|
|
750
|
+
|
|
751
|
+
function renderLiveRow() {
|
|
752
|
+
const s = sessionById(state.selected);
|
|
753
|
+
const el = $('#liveRow');
|
|
754
|
+
if (!s || !el) return;
|
|
755
|
+
let html = '';
|
|
756
|
+
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>`;
|
|
757
|
+
else if (s.status === 'vscode') html = `<div class="live-row">${icon('info')}<span>Waiting for you in VS Code</span></div>`;
|
|
758
|
+
if (el.innerHTML !== html) {
|
|
759
|
+
const stick = nearBottom();
|
|
760
|
+
el.innerHTML = html;
|
|
761
|
+
if (stick) scrollToBottom();
|
|
762
|
+
}
|
|
763
|
+
}
|
|
764
|
+
|
|
765
|
+
function renderDock() {
|
|
766
|
+
if (state.view !== 'session') return keyed('dock', 'none', () => '');
|
|
767
|
+
const s = sessionById(state.selected);
|
|
768
|
+
if (!s) return;
|
|
769
|
+
const pend = sortPending(pendingFor(s.id));
|
|
770
|
+
let hintKey = '';
|
|
771
|
+
let hint = '';
|
|
772
|
+
if (!isCodex(s) && !pend.length && s.drive !== 'agenter') {
|
|
773
|
+
if (s.status === 'dormant') {
|
|
774
|
+
hintKey = 'dormant';
|
|
775
|
+
hint = `<div class="notice">${icon('plug')}<div><b>Not connected.</b> This session was closed, or it started before Agenter was running. To continue it, reopen it from the Claude panel's session history in VS Code. It moves to Live once you send a prompt.</div>${s.cwd ? `<button class="btn small" data-open-vscode>${icon('code')}Open in VS Code</button>` : ''}</div>`;
|
|
776
|
+
} else if (s.status === 'ended') {
|
|
777
|
+
hintKey = 'ended';
|
|
778
|
+
hint = `<div class="notice">${icon('info')}<div>This session has ended.</div></div>`;
|
|
779
|
+
}
|
|
780
|
+
}
|
|
781
|
+
// A finished turn has its own reply box; otherwise you can always steer from here.
|
|
782
|
+
// Codex always takes messages: its app queues them even for a session that isn't open.
|
|
783
|
+
const steerable = (isCodex(s) || isLive(s) || s.drive === 'agenter') && !pend.some((p) => p.kind === 'stop');
|
|
784
|
+
const steerKey = steerable ? `${s.status}|${s.drive}|${s.running}|${(s.queue || []).map((m) => m.id).join(',')}` : '';
|
|
785
|
+
keyed('dock', `${s.id}|${pend.map((p) => p.id).join(',')}|${hintKey}|${steerKey}`, () => pend.map((p) => renderCard(p, false)).join('') + hint + (steerable ? renderSteer(s) : ''), focusFirst);
|
|
786
|
+
}
|
|
787
|
+
|
|
788
|
+
function renderSteer(s) {
|
|
789
|
+
const agenter = s.drive === 'agenter';
|
|
790
|
+
const busy = s.running || s.status === 'working' || s.status === 'waiting' || s.status === 'vscode';
|
|
791
|
+
const queued = (s.queue || [])
|
|
792
|
+
.map((m) => `<div class="queued"><span class="pill plain">Queued</span><span class="q-text">${esc(m.text)}</span><button class="btn ghost small" type="button" data-unqueue="${esc(m.id)}">Cancel</button></div>`)
|
|
793
|
+
.join('');
|
|
794
|
+
let when;
|
|
795
|
+
if (isCodex(s)) {
|
|
796
|
+
if (agenter && busy) when = 'Codex gets this as its next turn, when the current one ends.';
|
|
797
|
+
else if (agenter) when = 'Runs here in Agenter, sandboxed to the project folder (no network).';
|
|
798
|
+
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
|
+
} else if (busy) when = 'Claude gets this after its current step.';
|
|
800
|
+
else if (agenter) when = "Runs here in Agenter. VS Code shows this turn once you reopen the session there.";
|
|
801
|
+
else when = `Claude is idle in VS Code and can't be woken from here. This goes with your next prompt there, or <button class="link" type="button" data-drive="agenter">drive from Agenter</button>.`;
|
|
802
|
+
return `<div class="card kind-steer" data-id="steer-${esc(s.id)}" data-steer><div class="card-body">
|
|
803
|
+
${queued}
|
|
804
|
+
<div class="reply-box">
|
|
805
|
+
<textarea rows="1" placeholder="${busy ? `Steer ${isCodex(s) ? 'Codex' : 'Claude'} while it works…` : `Message ${isCodex(s) ? 'Codex' : 'Claude'}…`}"></textarea>
|
|
806
|
+
<div class="reply-row">
|
|
807
|
+
<span class="hint">${when}</span>
|
|
808
|
+
${s.running ? `<button class="btn small" type="button" data-interrupt title="Stop the turn Agenter is running">Stop</button>` : ''}
|
|
809
|
+
<button class="btn primary small" type="button" data-steer-send>${icon('send')}Send</button>
|
|
810
|
+
</div>
|
|
811
|
+
</div></div></div>`;
|
|
812
|
+
}
|
|
813
|
+
|
|
814
|
+
const STEER_TOAST = { sent: 'Reply sent', queued: 'Queued', started: 'Running in Agenter', 'codex-queued': 'Queued in Codex' };
|
|
815
|
+
|
|
816
|
+
async function steer(card) {
|
|
817
|
+
const ta = $('textarea', card);
|
|
818
|
+
const message = ta.value.trim();
|
|
819
|
+
if (!message) return ta.focus();
|
|
820
|
+
// Clear first: the queued message re-renders the dock, which would otherwise put the text back.
|
|
821
|
+
ta.value = '';
|
|
822
|
+
fitTextarea(ta);
|
|
823
|
+
const r = await post('/api/session/steer', { sessionId: state.selected, message }).catch(() => null);
|
|
824
|
+
if (!r?.ok) {
|
|
825
|
+
const box = $('#dock [data-steer] textarea');
|
|
826
|
+
if (box && !box.value) box.value = message;
|
|
827
|
+
return toast("Couldn't send that");
|
|
828
|
+
}
|
|
829
|
+
const { result } = await r.json();
|
|
830
|
+
const FAILED = {
|
|
831
|
+
'no-claude': "Couldn't find Claude Code. Install the VS Code extension, or set AGENTER_CLAUDE to claude.exe",
|
|
832
|
+
'no-codex': "Couldn't find Codex. Install the Codex VS Code extension, or set AGENTER_CODEX_BIN to codex.exe",
|
|
833
|
+
error: "Codex didn't accept the message",
|
|
834
|
+
};
|
|
835
|
+
if (FAILED[result]) {
|
|
836
|
+
const box = $('#dock [data-steer] textarea');
|
|
837
|
+
if (box && !box.value) box.value = message;
|
|
838
|
+
return toast(FAILED[result]);
|
|
839
|
+
}
|
|
840
|
+
toast(STEER_TOAST[result] || 'Sent');
|
|
841
|
+
}
|
|
842
|
+
|
|
843
|
+
async function setDrive(drive) {
|
|
844
|
+
const s = sessionById(state.selected);
|
|
845
|
+
const app = s && isCodex(s) ? 'the Codex app' : "VS Code's panel";
|
|
846
|
+
const r = await post('/api/session/drive', { sessionId: state.selected, drive });
|
|
847
|
+
if (!r.ok) return toast("Couldn't switch");
|
|
848
|
+
const { cli } = await r.json();
|
|
849
|
+
if (drive === 'vscode') return toast(s && isCodex(s) ? 'Driving from the Codex app' : 'Driving from VS Code');
|
|
850
|
+
toast(cli ? `Driving from Agenter. Don't type in ${app} for this session meanwhile.` : `Couldn't find ${s && isCodex(s) ? 'Codex' : 'Claude Code'}. See the README for setting its path.`);
|
|
851
|
+
}
|
|
852
|
+
|
|
853
|
+
// Only the reply box gets auto-focus, and only when it's alone. Focusing buttons
|
|
854
|
+
// would let a stray Enter approve a command, and focus in a field disables shortcuts.
|
|
855
|
+
function focusFirst(root) {
|
|
856
|
+
// Never pull focus out of a text box or the app preview you're clicking around in.
|
|
857
|
+
if (root.id !== 'dock' || document.activeElement?.matches('input, textarea, iframe')) return;
|
|
858
|
+
if (root.querySelector('.kind-permission, .kind-question')) return;
|
|
859
|
+
root.querySelector('textarea')?.focus({ preventScroll: true });
|
|
860
|
+
}
|
|
861
|
+
|
|
862
|
+
const nearBottom = () => {
|
|
863
|
+
const sc = $('#scroll');
|
|
864
|
+
return sc.scrollHeight - sc.scrollTop - sc.clientHeight < 120;
|
|
865
|
+
};
|
|
866
|
+
const scrollToBottom = () => {
|
|
867
|
+
const sc = $('#scroll');
|
|
868
|
+
sc.scrollTop = sc.scrollHeight;
|
|
869
|
+
};
|
|
870
|
+
|
|
871
|
+
function renderConvo(keepScroll = false) {
|
|
872
|
+
const el = $('#convo');
|
|
873
|
+
if (!el) return;
|
|
874
|
+
const msgs = state.transcript.sessionId === state.selected ? state.transcript.messages : null;
|
|
875
|
+
if (msgs === null) {
|
|
876
|
+
el.innerHTML = '<div class="skeleton" style="width:60%"></div><div class="skeleton" style="width:85%"></div><div class="skeleton" style="width:40%"></div>';
|
|
877
|
+
return;
|
|
878
|
+
}
|
|
879
|
+
if (!msgs.length) {
|
|
880
|
+
el.innerHTML = `<div class="notice">${icon('info')}<div>No conversation recorded yet.</div></div>`;
|
|
881
|
+
return;
|
|
882
|
+
}
|
|
883
|
+
const stick = !keepScroll || nearBottom();
|
|
884
|
+
const s = sessionById(state.selected);
|
|
885
|
+
const rel = relativizer(s?.cwd);
|
|
886
|
+
const results = new Map(msgs.filter((m) => m.kind === 'result' && m.toolUseId).map((m) => [m.toolUseId, m]));
|
|
887
|
+
const lastToolId = [...msgs].reverse().find((m) => m.kind === 'tool')?.id;
|
|
888
|
+
|
|
889
|
+
const chunks = []; // [key, html]: one per message or run of tool calls
|
|
890
|
+
let tools = [];
|
|
891
|
+
let toolsKey = '';
|
|
892
|
+
const flush = () => {
|
|
893
|
+
if (tools.length) chunks.push([toolsKey, `<div class="tools">${tools.join('')}</div>`]);
|
|
894
|
+
tools = [];
|
|
895
|
+
};
|
|
896
|
+
for (const m of msgs) {
|
|
897
|
+
if (m.kind === 'result') continue; // shown inside its tool call
|
|
898
|
+
if (m.kind === 'tool') {
|
|
899
|
+
if (!tools.length) toolsKey = `t${m.seq}`;
|
|
900
|
+
const r = results.get(m.id);
|
|
901
|
+
const lines = r?.text ? r.text.split('\n').length : 0;
|
|
902
|
+
const stateText = r
|
|
903
|
+
? r.isError
|
|
904
|
+
? '<span class="tstate err">error</span>'
|
|
905
|
+
: lines > 1
|
|
906
|
+
? `<span class="tstate">${lines} lines</span>`
|
|
907
|
+
: ''
|
|
908
|
+
: m.id === lastToolId && s?.status === 'working'
|
|
909
|
+
? '<span class="tstate">running…</span>'
|
|
910
|
+
: '';
|
|
911
|
+
const firstLine = rel(m.text.split('\n')[0]);
|
|
912
|
+
tools.push(`<details class="tool ${r?.isError ? 'err' : ''}" data-tid="${esc(m.id)}" ${state.openTools.has(m.id) ? 'open' : ''}>
|
|
913
|
+
<summary>${icon(toolIcon(m.name))}<span class="tname">${esc(toolName(m.name))}</span><span class="targ">${esc(firstLine)}</span>${stateText}</summary>
|
|
914
|
+
${m.text.includes('\n') || firstLine.length > 90 ? `<div class="full-arg mono">${esc(m.text)}</div>` : ''}
|
|
915
|
+
${r ? `<pre>${esc(r.text || '(no output)')}</pre>` : ''}
|
|
916
|
+
</details>`);
|
|
917
|
+
continue;
|
|
918
|
+
}
|
|
919
|
+
flush();
|
|
920
|
+
if (m.role === 'user') chunks.push([`m${m.seq}`, `<div class="msg-user">${esc(m.text)}</div>`]);
|
|
921
|
+
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
|
+
}
|
|
923
|
+
flush();
|
|
924
|
+
patchChildren(el, chunks);
|
|
925
|
+
if (stick) scrollToBottom();
|
|
926
|
+
}
|
|
927
|
+
|
|
928
|
+
// Brings el's children in line with chunks, touching only the ones that changed, so
|
|
929
|
+
// new messages don't reset text you've selected or re-render the whole conversation.
|
|
930
|
+
function patchChildren(el, chunks) {
|
|
931
|
+
const old = new Map();
|
|
932
|
+
for (const c of [...el.children]) c.dataset.key ? old.set(c.dataset.key, c) : c.remove(); // skeletons, notices
|
|
933
|
+
const want = new Set(chunks.map(([k]) => k));
|
|
934
|
+
for (const [k, c] of old) if (!want.has(k)) c.remove();
|
|
935
|
+
chunks.forEach(([key, html], i) => {
|
|
936
|
+
let node = old.get(key);
|
|
937
|
+
if (!node || node._html !== html) {
|
|
938
|
+
const t = document.createElement('template');
|
|
939
|
+
t.innerHTML = html;
|
|
940
|
+
const fresh = t.content.firstElementChild;
|
|
941
|
+
fresh.dataset.key = key;
|
|
942
|
+
fresh._html = html;
|
|
943
|
+
if (node) node.replaceWith(fresh);
|
|
944
|
+
node = fresh;
|
|
945
|
+
}
|
|
946
|
+
if (el.children[i] !== node) el.insertBefore(node, el.children[i] || null);
|
|
947
|
+
});
|
|
948
|
+
}
|
|
949
|
+
|
|
950
|
+
// Shows paths inside the project relative to it: C:\proj\src\a.ts -> src\a.ts
|
|
951
|
+
function relativizer(cwd) {
|
|
952
|
+
if (!cwd) return (t) => t;
|
|
953
|
+
const base = cwd.replace(/[\\/]+$/, '');
|
|
954
|
+
const re = new RegExp(base.replace(/[.*+?^${}()|[\]\\]/g, '\\$&').replace(/\\\\|\//g, '[\\\\/]') + '[\\\\/]', 'gi');
|
|
955
|
+
return (t) => t.replace(re, '');
|
|
956
|
+
}
|
|
957
|
+
|
|
958
|
+
// ---------- action cards ----------
|
|
959
|
+
|
|
960
|
+
// Commands where one stray keypress shouldn't be enough: A / Shift+A won't approve these.
|
|
961
|
+
const DESTRUCTIVE =
|
|
962
|
+
/\brm\s+-[a-z]*[rf]|\bRemove-Item\b[^|;]*-Recurse|\b(del|erase|rd|rmdir)\s+\/[sq]|\bgit\s+(push\b[^|;]*\s(--force|-f)\b|reset\s+--hard|clean\s+-[a-z]*f|branch\s+-D)|\bdrop\s+(table|database|schema)\b|\btruncate\s+table\b|\bformat\s+[a-z]:|\bmkfs\b|\bdd\s+if=/i;
|
|
963
|
+
const isDestructive = (p) => p.kind === 'permission' && typeof p.input?.command === 'string' && DESTRUCTIVE.test(p.input.command);
|
|
964
|
+
|
|
965
|
+
// The permission card A / D act on: the one in this session's dock, or the oldest in the inbox.
|
|
966
|
+
const keyTarget = () => $('#dock .card.kind-permission, #inboxPending .card.kind-permission');
|
|
967
|
+
|
|
968
|
+
function markKeyTarget() {
|
|
969
|
+
const target = keyTarget();
|
|
970
|
+
document.querySelectorAll('.card.kb-target').forEach((c) => c !== target && c.classList.remove('kb-target'));
|
|
971
|
+
target?.classList.add('kb-target');
|
|
972
|
+
}
|
|
973
|
+
|
|
974
|
+
// True when the card's buttons are actually visible, not scrolled away or covered.
|
|
975
|
+
function onScreen(card) {
|
|
976
|
+
const r = $('.actions', card).getBoundingClientRect();
|
|
977
|
+
if (r.bottom <= 0 || r.top >= innerHeight) return false;
|
|
978
|
+
const hit = document.elementFromPoint(r.left + r.width / 2, Math.max(0, r.top) + Math.min(r.height, innerHeight - r.top) / 2);
|
|
979
|
+
return !!hit && card.contains(hit);
|
|
980
|
+
}
|
|
981
|
+
|
|
982
|
+
function keyAct(action) {
|
|
983
|
+
const card = keyTarget();
|
|
984
|
+
if (!card) return;
|
|
985
|
+
if (!onScreen(card)) {
|
|
986
|
+
card.scrollIntoView({ block: 'center', behavior: 'smooth' });
|
|
987
|
+
card.classList.remove('flash');
|
|
988
|
+
void card.offsetWidth; // restart the animation
|
|
989
|
+
card.classList.add('flash');
|
|
990
|
+
return toast('This is the request your key applies to. Press again to confirm');
|
|
991
|
+
}
|
|
992
|
+
const p = state.pending.find((x) => x.id === card.dataset.id);
|
|
993
|
+
if (action !== 'deny' && p && isDestructive(p)) return toast('This command looks destructive. Click Allow to approve it');
|
|
994
|
+
act(card, action);
|
|
995
|
+
}
|
|
996
|
+
|
|
997
|
+
function renderToolBody(tool, input = {}) {
|
|
998
|
+
input = input || {};
|
|
999
|
+
if (['Bash', 'PowerShell'].includes(tool) || typeof input.command === 'string') {
|
|
1000
|
+
return `${input.description ? `<div class="desc">${esc(input.description)}</div>` : ''}<pre class="cmd shell">${esc(input.command)}</pre>`;
|
|
1001
|
+
}
|
|
1002
|
+
if (tool === 'Edit' || tool === 'MultiEdit') {
|
|
1003
|
+
const edits = tool === 'MultiEdit' ? input.edits || [] : [input];
|
|
1004
|
+
const lines = (text, sign, cls) =>
|
|
1005
|
+
String(text ?? '')
|
|
1006
|
+
.split('\n')
|
|
1007
|
+
.map((l) => `<div class="${cls}">${sign} ${esc(l)}</div>`)
|
|
1008
|
+
.join('');
|
|
1009
|
+
return `<div class="path mono">${esc(input.file_path)}</div>` + edits.map((e) => `<div class="diff mono">${lines(e.old_string, '−', 'del')}${lines(e.new_string, '+', 'add')}</div>`).join('');
|
|
1010
|
+
}
|
|
1011
|
+
if (tool === 'Write') return `<div class="path mono">${esc(input.file_path)}</div><pre class="cmd">${esc(clip(input.content, 6000))}</pre>`;
|
|
1012
|
+
if (tool === 'ExitPlanMode') return `<div class="desc">Claude wants to leave plan mode and start on this plan:</div><div class="last-msg md">${md(input.plan)}</div>`;
|
|
1013
|
+
if (tool === 'WebFetch') return `<div class="path mono">${esc(input.url)}</div>${input.prompt ? `<div class="desc">${esc(input.prompt)}</div>` : ''}`;
|
|
1014
|
+
return `<pre class="cmd">${esc(JSON.stringify(input, null, 2))}</pre>`;
|
|
1015
|
+
}
|
|
1016
|
+
|
|
1017
|
+
function renderCard(p, showSession) {
|
|
1018
|
+
const s = sessionById(p.sessionId);
|
|
1019
|
+
const sessionBar =
|
|
1020
|
+
showSession && s
|
|
1021
|
+
? `<div class="card-session" data-sid="${esc(s.id)}">${avatar(s)}<span class="cs-name">${esc(sessionLabel(s))}</span><span class="cs-title">${esc(s.title || '')}</span><span class="open">Open session →</span></div>`
|
|
1022
|
+
: '';
|
|
1023
|
+
const age = p.releaseAt
|
|
1024
|
+
? `<span class="age countdown" data-release="${p.releaseAt}" title="VS Code shows a spinner until this is answered or handed back">${countdown(p.releaseAt)}</span>`
|
|
1025
|
+
: `<span class="age" data-t="${p.createdAt}">${ago(p.createdAt)}</span>`;
|
|
1026
|
+
const head = (ic, title, sub) => `<div class="card-head"><span class="k-ic">${icon(ic)}</span><div><div class="k-title">${title}</div>${sub ? `<div class="k-sub">${sub}</div>` : ''}</div>${age}</div>`;
|
|
1027
|
+
|
|
1028
|
+
if (p.kind === 'permission') {
|
|
1029
|
+
const risky = isDestructive(p);
|
|
1030
|
+
return `<div class="card kind-permission ${risky ? 'risky' : ''}" data-id="${p.id}">${sessionBar}<div class="card-body">
|
|
1031
|
+
${head(toolIcon(p.tool), `Allow <span class="mono">${esc(toolName(p.tool))}</span>?`, 'Claude is asking for permission')}
|
|
1032
|
+
<div class="tool-body">${renderToolBody(p.tool, p.input)}</div>
|
|
1033
|
+
${risky ? `<div class="risk-note">${icon('shield')}<span>This looks destructive, so the <kbd>A</kbd> shortcut is off. Read it, then click Allow if it's fine.</span></div>` : ''}
|
|
1034
|
+
<div class="actions">
|
|
1035
|
+
<button class="btn primary" data-act="allow">${icon('check')}Allow${risky ? '' : ' <kbd>A</kbd>'}</button>
|
|
1036
|
+
${p.canAlways ? `<button class="btn" data-act="always" title="Allow, and add the suggested rule so Claude won't ask again">Always allow</button>` : ''}
|
|
1037
|
+
<input type="text" class="field grow deny-msg" placeholder="Optional: why not / what to do instead">
|
|
1038
|
+
<button class="btn danger" data-act="deny">Deny <kbd>D</kbd></button>
|
|
1039
|
+
${p.headless ? '' : `<button class="btn ghost small" data-act="pass" title="Show the normal prompt in VS Code instead">Answer in VS Code</button>`}
|
|
1040
|
+
</div></div></div>`;
|
|
1041
|
+
}
|
|
1042
|
+
|
|
1043
|
+
if (p.kind === 'question') {
|
|
1044
|
+
const qs = (p.questions || [])
|
|
1045
|
+
.map(
|
|
1046
|
+
(q, qi) => `<div class="question" data-q="${qi}">
|
|
1047
|
+
<div class="q-title">${q.header ? `<span class="pill plain">${esc(q.header)}</span>` : ''}${esc(q.question)}</div>
|
|
1048
|
+
<div class="options">${(q.options || [])
|
|
1049
|
+
.map(
|
|
1050
|
+
(o) => `<label class="option"><input type="${q.multiSelect ? 'checkbox' : 'radio'}" name="q-${p.id}-${qi}" value="${esc(o.label)}">
|
|
1051
|
+
<span><span class="o-label">${esc(o.label)}</span>${o.description ? `<div class="o-desc">${esc(o.description)}</div>` : ''}</span></label>`
|
|
1052
|
+
)
|
|
1053
|
+
.join('')}</div>
|
|
1054
|
+
<input type="text" class="field q-other" placeholder="Something else? Type your own answer">
|
|
1055
|
+
</div>`
|
|
1056
|
+
)
|
|
1057
|
+
.join('');
|
|
1058
|
+
return `<div class="card kind-question" data-id="${p.id}">${sessionBar}<div class="card-body">
|
|
1059
|
+
${head('help', 'Claude has a question', p.questions?.length > 1 ? `${p.questions.length} questions` : '')}
|
|
1060
|
+
${qs}
|
|
1061
|
+
<div class="actions">
|
|
1062
|
+
<button class="btn primary" data-act="answer">${icon('send')}Send answers</button>
|
|
1063
|
+
${p.headless ? '' : `<button class="btn ghost small" data-act="pass">Answer in VS Code</button>`}
|
|
1064
|
+
</div></div></div>`;
|
|
1065
|
+
}
|
|
1066
|
+
|
|
1067
|
+
return `<div class="card kind-stop" data-id="${p.id}">${sessionBar}<div class="card-body">
|
|
1068
|
+
${head('reply', 'Your turn', 'Claude finished. Reply to keep it going')}
|
|
1069
|
+
${p.message ? `<div class="last-msg md">${md(p.message)}</div>` : ''}
|
|
1070
|
+
<div class="reply-box">
|
|
1071
|
+
<textarea rows="2" placeholder="Tell Claude what to do next…"></textarea>
|
|
1072
|
+
<div class="reply-row">
|
|
1073
|
+
<span class="hint"><kbd>Enter</kbd> send · <kbd>Shift</kbd>+<kbd>Enter</kbd> new line</span>
|
|
1074
|
+
<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>
|
|
1075
|
+
<button class="btn primary small" data-act="reply">${icon('send')}Send</button>
|
|
1076
|
+
</div>
|
|
1077
|
+
</div></div></div>`;
|
|
1078
|
+
}
|
|
1079
|
+
|
|
1080
|
+
const ACTION_TOAST = { allow: 'Allowed', always: 'Always allowing', deny: 'Denied', pass: 'Opening VS Code', reply: 'Reply sent', answer: 'Answers sent' };
|
|
1081
|
+
|
|
1082
|
+
async function act(card, action) {
|
|
1083
|
+
const p = state.pending.find((x) => x.id === card.dataset.id);
|
|
1084
|
+
if (!p) return;
|
|
1085
|
+
const body = { id: p.id, action };
|
|
1086
|
+
if (action === 'deny') body.message = $('.deny-msg', card)?.value || '';
|
|
1087
|
+
if (action === 'reply') {
|
|
1088
|
+
const ta = $('textarea', card);
|
|
1089
|
+
body.message = ta.value.trim();
|
|
1090
|
+
if (!body.message) return ta.focus();
|
|
1091
|
+
}
|
|
1092
|
+
if (action === 'answer') {
|
|
1093
|
+
body.answers = {};
|
|
1094
|
+
card.querySelectorAll('.question').forEach((qEl) => {
|
|
1095
|
+
const q = p.questions[Number(qEl.dataset.q)];
|
|
1096
|
+
const picked = [...qEl.querySelectorAll('input:checked')].map((i) => i.value);
|
|
1097
|
+
const other = $('.q-other', qEl).value.trim();
|
|
1098
|
+
if (other) picked.push(other);
|
|
1099
|
+
if (picked.length) body.answers[q.question] = picked.join(', ');
|
|
1100
|
+
});
|
|
1101
|
+
if (Object.keys(body.answers).length < p.questions.length) return toast('Answer every question first');
|
|
1102
|
+
}
|
|
1103
|
+
card.querySelectorAll('button, input, textarea').forEach((b) => (b.disabled = true));
|
|
1104
|
+
const r = await post('/api/respond', body);
|
|
1105
|
+
const s = sessionById(p.sessionId);
|
|
1106
|
+
const what = p.kind === 'permission' && ['allow', 'always', 'deny'].includes(action) ? ` ${toolName(p.tool)}` : '';
|
|
1107
|
+
toast(r.ok ? `${ACTION_TOAST[action]}${what}${s ? ` · ${sessionLabel(s)}` : ''}` : 'That was already handled');
|
|
1108
|
+
}
|
|
1109
|
+
|
|
1110
|
+
// ---------- transcript ----------
|
|
1111
|
+
|
|
1112
|
+
// After the first load, only messages newer than the last one we have are fetched.
|
|
1113
|
+
const KEEP_MESSAGES = 300;
|
|
1114
|
+
let transcriptTimer;
|
|
1115
|
+
function loadTranscript(sid) {
|
|
1116
|
+
clearTimeout(transcriptTimer);
|
|
1117
|
+
transcriptTimer = setTimeout(async () => {
|
|
1118
|
+
try {
|
|
1119
|
+
const have = state.transcript.sessionId === sid && state.transcript.messages ? state.transcript : null;
|
|
1120
|
+
const lastSeq = () => have?.messages.at(-1)?.seq ?? 0;
|
|
1121
|
+
const since = have ? `&epoch=${encodeURIComponent(have.epoch)}&after=${lastSeq()}` : '';
|
|
1122
|
+
const r = await fetch(`/api/transcript?session=${encodeURIComponent(sid)}${since}`);
|
|
1123
|
+
if (!r.ok) return;
|
|
1124
|
+
const { epoch, reset, messages } = await r.json();
|
|
1125
|
+
if (sid !== state.selected) return;
|
|
1126
|
+
const same = state.transcript.sessionId === sid;
|
|
1127
|
+
// A request that overlapped another may repeat messages; seq tells them apart.
|
|
1128
|
+
const merged = reset || !have ? messages : [...have.messages, ...messages.filter((m) => m.seq > lastSeq())].slice(-KEEP_MESSAGES);
|
|
1129
|
+
state.transcript = { sessionId: sid, epoch, messages: merged };
|
|
1130
|
+
renderConvo(same);
|
|
1131
|
+
} catch {}
|
|
1132
|
+
}, 150);
|
|
1133
|
+
}
|
|
1134
|
+
|
|
1135
|
+
// ---------- navigation ----------
|
|
1136
|
+
|
|
1137
|
+
function select(sid) {
|
|
1138
|
+
closeNav();
|
|
1139
|
+
if (state.view === 'session' && state.selected === sid) return;
|
|
1140
|
+
state.view = 'session';
|
|
1141
|
+
state.selected = sid;
|
|
1142
|
+
state.openTools.clear();
|
|
1143
|
+
setHash(`#/s/${sid}`);
|
|
1144
|
+
render();
|
|
1145
|
+
loadTranscript(sid);
|
|
1146
|
+
}
|
|
1147
|
+
|
|
1148
|
+
function showInbox() {
|
|
1149
|
+
closeNav();
|
|
1150
|
+
state.view = 'inbox';
|
|
1151
|
+
setHash('#/');
|
|
1152
|
+
render();
|
|
1153
|
+
$('#scroll').scrollTop = 0;
|
|
1154
|
+
}
|
|
1155
|
+
|
|
1156
|
+
function setHash(h) {
|
|
1157
|
+
if (location.hash !== h) history.pushState(null, '', h);
|
|
1158
|
+
}
|
|
1159
|
+
|
|
1160
|
+
function routeFromHash() {
|
|
1161
|
+
const m = location.hash.match(/^#\/s\/(.+)$/);
|
|
1162
|
+
if (m) {
|
|
1163
|
+
state.view = 'session';
|
|
1164
|
+
state.selected = decodeURIComponent(m[1]);
|
|
1165
|
+
} else state.view = 'inbox';
|
|
1166
|
+
}
|
|
1167
|
+
window.addEventListener('popstate', () => {
|
|
1168
|
+
routeFromHash();
|
|
1169
|
+
render();
|
|
1170
|
+
if (state.view === 'session') loadTranscript(state.selected);
|
|
1171
|
+
});
|
|
1172
|
+
|
|
1173
|
+
function step(dir) {
|
|
1174
|
+
const list = sortedSessions();
|
|
1175
|
+
if (!list.length) return;
|
|
1176
|
+
const i = state.view === 'session' ? list.findIndex((s) => s.id === state.selected) : -1;
|
|
1177
|
+
select(list[(i + dir + list.length) % list.length].id);
|
|
1178
|
+
}
|
|
1179
|
+
|
|
1180
|
+
function nextNeedingYou() {
|
|
1181
|
+
const ids = [...new Set(sortPending(state.pending).map((p) => p.sessionId))];
|
|
1182
|
+
if (!ids.length) return toast('Nothing is waiting on you');
|
|
1183
|
+
const i = ids.indexOf(state.selected);
|
|
1184
|
+
select(ids[(i + 1) % ids.length]);
|
|
1185
|
+
}
|
|
1186
|
+
|
|
1187
|
+
const closeNav = () => $('#app').classList.remove('nav-open');
|
|
1188
|
+
|
|
1189
|
+
// ---------- events ----------
|
|
1190
|
+
|
|
1191
|
+
document.addEventListener('click', (e) => {
|
|
1192
|
+
const t = e.target;
|
|
1193
|
+
if (onPreviewClick(t)) return;
|
|
1194
|
+
if (t.closest('[data-steer-send]')) return steer(t.closest('.card'));
|
|
1195
|
+
if (t.closest('[data-drive]')) return setDrive(t.closest('[data-drive]').dataset.drive);
|
|
1196
|
+
if (t.closest('[data-interrupt]')) return post('/api/session/interrupt', { sessionId: state.selected });
|
|
1197
|
+
if (t.closest('[data-unqueue]')) return post('/api/session/unqueue', { sessionId: state.selected, id: t.closest('[data-unqueue]').dataset.unqueue });
|
|
1198
|
+
const actBtn = t.closest('[data-act]');
|
|
1199
|
+
if (actBtn) return act(actBtn.closest('.card'), actBtn.dataset.act);
|
|
1200
|
+
const sidEl = t.closest('[data-sid]');
|
|
1201
|
+
if (sidEl) return select(sidEl.dataset.sid);
|
|
1202
|
+
if (t.closest('[data-copy]')) {
|
|
1203
|
+
navigator.clipboard?.writeText(t.closest('[data-copy]').dataset.copy).then(() => toast('Path copied'));
|
|
1204
|
+
return;
|
|
1205
|
+
}
|
|
1206
|
+
if (t.closest('[data-open-vscode]')) {
|
|
1207
|
+
post('/api/session/open', { sessionId: state.selected }).then((r) => toast(r.ok ? 'Opening in VS Code…' : "Couldn't open VS Code"));
|
|
1208
|
+
return;
|
|
1209
|
+
}
|
|
1210
|
+
if (t.closest('[data-usage-toggle]')) {
|
|
1211
|
+
const agent = t.closest('[data-usage-toggle]').dataset.usageToggle;
|
|
1212
|
+
store.set(`usageOpen:${agent}`, !store.get(`usageOpen:${agent}`, false));
|
|
1213
|
+
return renderUsage();
|
|
1214
|
+
}
|
|
1215
|
+
if (t.closest('[data-usage-refresh]')) {
|
|
1216
|
+
usageRefreshing = true;
|
|
1217
|
+
renderUsage();
|
|
1218
|
+
post('/api/usage/refresh', {}).finally(() => {
|
|
1219
|
+
usageRefreshing = false;
|
|
1220
|
+
renderUsage();
|
|
1221
|
+
});
|
|
1222
|
+
return;
|
|
1223
|
+
}
|
|
1224
|
+
if (t.closest('[data-hold-on]')) return post('/api/session/hold', { sessionId: state.selected, hold: true });
|
|
1225
|
+
if (t.closest('[data-nav-toggle]')) return $('#app').classList.toggle('nav-open');
|
|
1226
|
+
});
|
|
1227
|
+
|
|
1228
|
+
document.addEventListener('change', (e) => {
|
|
1229
|
+
if (e.target.matches('[data-hold]')) post('/api/session/hold', { sessionId: state.selected, hold: e.target.checked });
|
|
1230
|
+
});
|
|
1231
|
+
|
|
1232
|
+
document.addEventListener(
|
|
1233
|
+
'toggle',
|
|
1234
|
+
(e) => {
|
|
1235
|
+
const d = e.target;
|
|
1236
|
+
if (!d.matches?.('details.tool')) return;
|
|
1237
|
+
if (d.open) state.openTools.add(d.dataset.tid);
|
|
1238
|
+
else state.openTools.delete(d.dataset.tid);
|
|
1239
|
+
},
|
|
1240
|
+
true
|
|
1241
|
+
);
|
|
1242
|
+
|
|
1243
|
+
document.addEventListener('input', (e) => {
|
|
1244
|
+
if (e.target.matches('.reply-box textarea')) fitTextarea(e.target);
|
|
1245
|
+
});
|
|
1246
|
+
|
|
1247
|
+
document.addEventListener('keydown', (e) => {
|
|
1248
|
+
const t = e.target instanceof Element ? e.target : document.body;
|
|
1249
|
+
if (t.matches('.reply-box textarea') && e.key === 'Enter' && !e.shiftKey && !e.isComposing) {
|
|
1250
|
+
e.preventDefault();
|
|
1251
|
+
return t.closest('[data-steer]') ? steer(t.closest('.card')) : act(t.closest('.card'), 'reply');
|
|
1252
|
+
}
|
|
1253
|
+
if (t.matches('.deny-msg') && e.key === 'Enter') return act(t.closest('.card'), 'deny');
|
|
1254
|
+
if (t.matches('input, textarea, select')) {
|
|
1255
|
+
if (e.key === 'Escape') t.blur();
|
|
1256
|
+
return;
|
|
1257
|
+
}
|
|
1258
|
+
if ($('#helpDialog').open || e.ctrlKey || e.metaKey || e.altKey) return;
|
|
1259
|
+
|
|
1260
|
+
const perm = keyTarget();
|
|
1261
|
+
const k = e.key;
|
|
1262
|
+
if (k === '/') (e.preventDefault(), $('#search').focus());
|
|
1263
|
+
else if (k === '?') $('#helpDialog').showModal();
|
|
1264
|
+
else if (k === 'i') showInbox();
|
|
1265
|
+
else if (k === 't') {
|
|
1266
|
+
const next = THEMES[(THEMES.indexOf(store.get('theme', 'system')) + 1) % THEMES.length];
|
|
1267
|
+
chooseTheme(next);
|
|
1268
|
+
toast(`Theme: ${next}`);
|
|
1269
|
+
}
|
|
1270
|
+
else if (k === 'j') step(1);
|
|
1271
|
+
else if (k === 'k') step(-1);
|
|
1272
|
+
else if (k === 'n') nextNeedingYou();
|
|
1273
|
+
else if (k === 'a' && perm) keyAct('allow');
|
|
1274
|
+
else if (k === 'A' && perm) keyAct($('[data-act="always"]', perm) ? 'always' : 'allow');
|
|
1275
|
+
else if (k === 'd' && perm) keyAct('deny');
|
|
1276
|
+
else if (k === 'r') {
|
|
1277
|
+
const ta = $('#dock textarea, #inboxPending textarea');
|
|
1278
|
+
if (ta) (e.preventDefault(), ta.focus());
|
|
1279
|
+
} else if (k === 'Escape') closeNav();
|
|
1280
|
+
});
|
|
1281
|
+
|
|
1282
|
+
$('#navInbox').addEventListener('click', showInbox);
|
|
1283
|
+
|
|
1284
|
+
// ---------- new session ----------
|
|
1285
|
+
|
|
1286
|
+
const NEW_SUB = {
|
|
1287
|
+
claude: "Runs here in Agenter, no VS Code needed. You can open it in VS Code later from the Claude panel's session history.",
|
|
1288
|
+
codex: 'Runs here in Agenter, sandboxed to the project folder (no network). It also shows up in the Codex app.',
|
|
1289
|
+
};
|
|
1290
|
+
function setNewAgent(agent) {
|
|
1291
|
+
store.set('newAgent', agent);
|
|
1292
|
+
document.querySelectorAll('#newAgent [data-new-agent]').forEach((b) => b.setAttribute('aria-checked', String(b.dataset.newAgent === agent)));
|
|
1293
|
+
$('#newSub').textContent = NEW_SUB[agent];
|
|
1294
|
+
$('#newMsg').placeholder = `What should ${agent === 'codex' ? 'Codex' : 'Claude'} do?`;
|
|
1295
|
+
}
|
|
1296
|
+
$('#newAgent').addEventListener('click', (e) => {
|
|
1297
|
+
const b = e.target.closest('[data-new-agent]');
|
|
1298
|
+
if (b) setNewAgent(b.dataset.newAgent);
|
|
1299
|
+
});
|
|
1300
|
+
|
|
1301
|
+
function openNewSession() {
|
|
1302
|
+
closeNav();
|
|
1303
|
+
setNewAgent(store.get('newAgent', 'claude'));
|
|
1304
|
+
// Folders you've worked in, most recent first; the last one used here goes on top.
|
|
1305
|
+
const last = store.get('newCwd', '');
|
|
1306
|
+
const cwds = [...new Set([last, ...[...state.sessions].sort((a, b) => b.lastEventAt - a.lastEventAt).map((s) => s.cwd)].filter(Boolean))];
|
|
1307
|
+
$('#newCwdList').innerHTML = cwds.map((c) => `<option value="${esc(c)}"></option>`).join('');
|
|
1308
|
+
const current = state.view === 'session' && sessionById(state.selected)?.cwd;
|
|
1309
|
+
$('#newCwd').value = current || last || cwds[0] || '';
|
|
1310
|
+
$('#newError').hidden = true;
|
|
1311
|
+
$('#newStart').disabled = false;
|
|
1312
|
+
$('#newDialog').showModal();
|
|
1313
|
+
$('#newMsg').focus();
|
|
1314
|
+
}
|
|
1315
|
+
|
|
1316
|
+
$('#navNew').addEventListener('click', openNewSession);
|
|
1317
|
+
$('#newDialog [data-new-cancel]').addEventListener('click', () => $('#newDialog').close());
|
|
1318
|
+
$('#newMsg').addEventListener('keydown', (e) => {
|
|
1319
|
+
if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) (e.preventDefault(), $('#newForm').requestSubmit());
|
|
1320
|
+
});
|
|
1321
|
+
$('#newForm').addEventListener('submit', async (e) => {
|
|
1322
|
+
e.preventDefault();
|
|
1323
|
+
const cwd = $('#newCwd').value.trim();
|
|
1324
|
+
const message = $('#newMsg').value.trim();
|
|
1325
|
+
const showError = (text) => {
|
|
1326
|
+
$('#newError').textContent = text;
|
|
1327
|
+
$('#newError').hidden = false;
|
|
1328
|
+
$('#newStart').disabled = false;
|
|
1329
|
+
};
|
|
1330
|
+
$('#newStart').disabled = true;
|
|
1331
|
+
let r;
|
|
1332
|
+
try {
|
|
1333
|
+
r = await post('/api/session/new', { cwd, message, agent: store.get('newAgent', 'claude') });
|
|
1334
|
+
} catch {
|
|
1335
|
+
return showError("Couldn't reach the Agenter server.");
|
|
1336
|
+
}
|
|
1337
|
+
const data = await r.json().catch(() => ({}));
|
|
1338
|
+
if (!r.ok) return showError(data.error || "Couldn't start the session.");
|
|
1339
|
+
store.set('newCwd', cwd);
|
|
1340
|
+
$('#newMsg').value = '';
|
|
1341
|
+
$('#newDialog').close();
|
|
1342
|
+
state.awaiting = data.sessionId;
|
|
1343
|
+
select(data.sessionId);
|
|
1344
|
+
toast('Session started');
|
|
1345
|
+
});
|
|
1346
|
+
$('#scrim').addEventListener('click', closeNav);
|
|
1347
|
+
$('#helpBtn').addEventListener('click', () => $('#helpDialog').showModal());
|
|
1348
|
+
$('#search').addEventListener('input', (e) => {
|
|
1349
|
+
state.filter = e.target.value;
|
|
1350
|
+
renderSidebar();
|
|
1351
|
+
if (state.view === 'inbox') renderInbox();
|
|
1352
|
+
});
|
|
1353
|
+
$('#holdAll').addEventListener('change', (e) => post('/api/settings', { holdOnStop: e.target.checked }));
|
|
1354
|
+
$('#holdMinutes').addEventListener('change', (e) => {
|
|
1355
|
+
post('/api/settings', { holdMinutes: Number(e.target.value) });
|
|
1356
|
+
e.target.blur();
|
|
1357
|
+
toast(e.target.value === '0' ? 'Waiting items stay until you answer' : `New waiting items go back to VS Code after ${e.target.value} min`);
|
|
1358
|
+
});
|
|
1359
|
+
$('#soundToggle').checked = store.get('sound', true);
|
|
1360
|
+
$('#soundToggle').addEventListener('change', (e) => {
|
|
1361
|
+
store.set('sound', e.target.checked);
|
|
1362
|
+
if (e.target.checked) beep();
|
|
1363
|
+
});
|
|
1364
|
+
$('#settingsPanel').open = store.get('settingsOpen', false);
|
|
1365
|
+
$('#settingsPanel').addEventListener('toggle', (e) => store.set('settingsOpen', e.target.open));
|
|
1366
|
+
|
|
1367
|
+
const THEMES = ['system', 'light', 'dark'];
|
|
1368
|
+
const systemDark = matchMedia('(prefers-color-scheme: dark)');
|
|
1369
|
+
function setTheme(pref) {
|
|
1370
|
+
const dark = pref === 'dark' || (pref === 'system' && systemDark.matches);
|
|
1371
|
+
document.documentElement.dataset.theme = dark ? 'dark' : 'light';
|
|
1372
|
+
document.querySelectorAll('#themePicker [data-theme-pick]').forEach((b) => b.setAttribute('aria-checked', String(b.dataset.themePick === pref)));
|
|
1373
|
+
}
|
|
1374
|
+
function chooseTheme(pref) {
|
|
1375
|
+
store.set('theme', pref);
|
|
1376
|
+
setTheme(pref);
|
|
1377
|
+
}
|
|
1378
|
+
setTheme(store.get('theme', 'system'));
|
|
1379
|
+
systemDark.addEventListener('change', () => setTheme(store.get('theme', 'system')));
|
|
1380
|
+
$('#themePicker').addEventListener('click', (e) => {
|
|
1381
|
+
const b = e.target.closest('[data-theme-pick]');
|
|
1382
|
+
if (b) chooseTheme(b.dataset.themePick);
|
|
1383
|
+
});
|
|
1384
|
+
|
|
1385
|
+
function setAccent(name) {
|
|
1386
|
+
if (name === 'sage') delete document.documentElement.dataset.accent;
|
|
1387
|
+
else document.documentElement.dataset.accent = name;
|
|
1388
|
+
document.querySelectorAll('#accentPicker [data-accent]').forEach((b) => b.setAttribute('aria-checked', String(b.dataset.accent === name)));
|
|
1389
|
+
}
|
|
1390
|
+
setAccent(store.get('accent', 'sage'));
|
|
1391
|
+
$('#accentPicker').addEventListener('click', (e) => {
|
|
1392
|
+
const b = e.target.closest('[data-accent]');
|
|
1393
|
+
if (!b) return;
|
|
1394
|
+
store.set('accent', b.dataset.accent);
|
|
1395
|
+
setAccent(b.dataset.accent);
|
|
1396
|
+
});
|
|
1397
|
+
$('#clearEnded').addEventListener('click', () => post('/api/session/remove', { sessionIds: state.sessions.filter((s) => !isLive(s)).map((s) => s.id) }));
|
|
1398
|
+
document.addEventListener('pointerdown', () => audioCtx?.resume?.(), { once: true });
|
|
1399
|
+
|
|
1400
|
+
(function setupNotifyButton() {
|
|
1401
|
+
const btn = $('#notifyBtn');
|
|
1402
|
+
if (!('Notification' in window) || Notification.permission !== 'default') return;
|
|
1403
|
+
btn.hidden = false;
|
|
1404
|
+
btn.onclick = async () => {
|
|
1405
|
+
await Notification.requestPermission();
|
|
1406
|
+
btn.hidden = true;
|
|
1407
|
+
};
|
|
1408
|
+
})();
|
|
1409
|
+
|
|
1410
|
+
setInterval(() => (refreshAges(), renderUsage()), 10000);
|
|
1411
|
+
setInterval(() => document.querySelectorAll('[data-release]').forEach((el) => (el.textContent = countdown(Number(el.dataset.release)))), 1000);
|
|
1412
|
+
routeFromHash();
|
|
1413
|
+
render();
|
|
1414
|
+
connect();
|