wisp-agents 0.0.0-stage → 0.1.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/web/js/app.js ADDED
@@ -0,0 +1,1452 @@
1
+ import { renderPresentation, icon, el, fmtDuration, copy, countChanges } from './blocks.js';
2
+ import { esc, md } from './md.js';
3
+
4
+ // One page: the launcher on top, every session in a feed under it. Sessions
5
+ // expand in place; nothing navigates away.
6
+
7
+ // --- state -------------------------------------------------------------------
8
+
9
+ const S = {
10
+ sessions: new Map(), // id -> summary
11
+ full: new Map(), // id -> full session, for expanded items
12
+ open: new Set(load('wisp.open', [])), // expanded item ids
13
+ nodes: new Map(), // id -> { card, head, body, turnNodes, tray, showAll, unfolded, latestTurn }
14
+ focus: null, // id of the keyboard-focused item
15
+ config: { defaults: {}, projects: [] },
16
+ agents: [],
17
+ filter: load('wisp.filter', 'all'),
18
+ showDone: load('wisp.showDone', false),
19
+ drafts: new Map(),
20
+ };
21
+
22
+ const $ = (s, root = document) => root.querySelector(s);
23
+ const $$ = (s, root = document) => [...root.querySelectorAll(s)];
24
+ const stage = $('#stage');
25
+
26
+ function load(k, d) {
27
+ try {
28
+ return JSON.parse(localStorage.getItem(k)) ?? d;
29
+ } catch {
30
+ return d;
31
+ }
32
+ }
33
+ function save(k, v) {
34
+ try {
35
+ localStorage.setItem(k, JSON.stringify(v));
36
+ } catch {}
37
+ }
38
+ const saveOpen = () => save('wisp.open', [...S.open]);
39
+
40
+ async function api(path, { method = 'GET', body } = {}) {
41
+ const r = await fetch(`/api${path}`, {
42
+ method,
43
+ headers: body !== undefined ? { 'content-type': 'application/json' } : {},
44
+ body: body !== undefined ? JSON.stringify(body) : undefined,
45
+ });
46
+ const j = await r.json().catch(() => ({}));
47
+ if (!r.ok) throw new Error(j.error || r.statusText);
48
+ return j;
49
+ }
50
+
51
+ const MODES = [
52
+ { id: 'plan', label: 'Plan', hint: 'Read-only. The agent investigates and proposes.' },
53
+ { id: 'edits', label: 'Edits', hint: 'May edit files. Shell commands that need approval are denied.' },
54
+ { id: 'auto', label: 'Full auto', hint: 'No permission prompts at all. Trusted repos only.' },
55
+ ];
56
+ const KIND_ICON = { read: 'read', edit: 'pen', run: 'terminal', search: 'search', web: 'web', agent: 'agent', plan: 'plan', tool: 'tool', text: 'text', error: 'warn', ask: 'ask', answer: 'check', wait: 'ask' };
57
+ const STATUS_LABEL = { done: 'Done', partial: 'Partial', blocked: 'Blocked', info: 'Report' };
58
+ const agentName = (id) => S.agents.find((a) => a.id === id)?.name || (id === 'wisp' ? 'wisp' : id);
59
+ const projectById = (id) => S.config.projects.find((p) => p.id === id);
60
+
61
+ function ago(t) {
62
+ const s = (Date.now() - t) / 1000;
63
+ if (s < 45) return 'now';
64
+ if (s < 3600) return `${Math.round(s / 60)}m`;
65
+ if (s < 86400) return `${Math.round(s / 3600)}h`;
66
+ return `${Math.round(s / 86400)}d`;
67
+ }
68
+
69
+ // --- toasts & overlays -------------------------------------------------------
70
+
71
+ function toast(html, { tone = '', action, timeout = 4200 } = {}) {
72
+ const t = el(`<div class="toast ${tone}"><span>${html}</span>${action ? `<button class="link">${esc(action.label)}</button>` : ''}</div>`);
73
+ if (action) t.querySelector('button').onclick = () => (action.run(), t.remove());
74
+ $('#toasts').append(t);
75
+ setTimeout(() => t.classList.add('out'), timeout);
76
+ setTimeout(() => t.remove(), timeout + 400);
77
+ }
78
+
79
+ function overlay(node, { onClose } = {}) {
80
+ const o = $('#overlay');
81
+ o.innerHTML = '';
82
+ o.append(node);
83
+ o.hidden = false;
84
+ requestAnimationFrame(() => o.classList.add('on'));
85
+ const close = () => {
86
+ o.classList.remove('on');
87
+ setTimeout(() => ((o.hidden = true), (o.innerHTML = '')), 200);
88
+ onClose?.();
89
+ };
90
+ o.onclick = (e) => e.target === o && close();
91
+ o.close = close;
92
+ return close;
93
+ }
94
+ const closeOverlay = () => !$('#overlay').hidden && $('#overlay').close?.();
95
+
96
+ // --- feed ----------------------------------------------------------------------
97
+
98
+ function visible(list) {
99
+ return S.filter === 'all' ? list : list.filter((s) => s.projectId === S.filter);
100
+ }
101
+
102
+ function groups() {
103
+ const all = visible([...S.sessions.values()]);
104
+ const needs = all
105
+ .filter((s) => s.state === 'review' || s.state === 'waiting')
106
+ .sort((a, b) => (b.state === 'waiting') - (a.state === 'waiting') || b.updatedAt - a.updatedAt);
107
+ // Working items keep launch order so the feed doesn't reshuffle on every tool call.
108
+ const working = all.filter((s) => s.state === 'running').sort((a, b) => b.createdAt - a.createdAt);
109
+ const done = all.filter((s) => s.state === 'done').sort((a, b) => b.updatedAt - a.updatedAt);
110
+ return { needs, working, done };
111
+ }
112
+
113
+ function renderFeed() {
114
+ const { needs, working, done } = groups();
115
+ const place = (sec, list, show = true) => {
116
+ const box = $('.f-list', sec);
117
+ sec.hidden = !list.length;
118
+ $('h4 .n', sec).textContent = list.length || '';
119
+ const want = show ? list : [];
120
+ want.forEach((s, i) => {
121
+ const { card } = item(s);
122
+ // Only touch the DOM when the order actually changed: moving a node
123
+ // would blur a composer the user is typing in.
124
+ if (box.children[i] !== card) box.insertBefore(card, box.children[i] || null);
125
+ });
126
+ while (box.children.length > want.length) box.lastElementChild.remove();
127
+ };
128
+ place($('#secNeeds'), needs);
129
+ place($('#secWorking'), working);
130
+ place($('#secDone'), done.slice(0, 60), S.showDone);
131
+ $('#secDone').classList.toggle('collapsed', !S.showDone);
132
+
133
+ // Drop nodes for sessions that are gone or filtered out of the feed entirely.
134
+ const shown = new Set([...needs, ...working, ...done].map((s) => s.id));
135
+ for (const [id, n] of S.nodes) if (!shown.has(id)) (n.card.remove(), S.nodes.delete(id), S.full.delete(id));
136
+
137
+ const empty = !needs.length && !working.length && !done.length;
138
+ $('#feedEmpty').hidden = !empty;
139
+ if (empty)
140
+ $('#feedEmpty').innerHTML = `<div class="orb big"><i></i><i></i><i></i></div><p>${S.filter === 'all' ? 'Nothing yet. Describe a task above and send it off.' : 'Nothing in this project yet.'}</p>`;
141
+ renderFeedHead(needs, working);
142
+
143
+ const n = [...S.sessions.values()].filter((s) => (s.state === 'review' && s.unread) || s.state === 'waiting').length;
144
+ document.title = n ? `(${n}) wisp` : 'wisp';
145
+ renderProjects();
146
+ }
147
+
148
+ function renderFeedHead(needs, working) {
149
+ const parts = [];
150
+ if (working.length) parts.push(`<span class="hs working"><i></i>${working.length} working</span>`);
151
+ if (needs.length) parts.push(`<span class="hs needs"><i></i>${needs.length} need${needs.length === 1 ? 's' : ''} you</span>`);
152
+ // Group headings already carry the counts; only say something when the feed is calm.
153
+ $('#feedHead').innerHTML = parts.length ? '' : '<span class="fh-status"><span class="muted">Nothing needs you.</span></span>';
154
+ $('#feedHead').hidden = !!parts.length;
155
+ placeCollapseAll();
156
+ }
157
+
158
+ // "Collapse all" floats over the page instead of living in the flow: showing it
159
+ // never shifts the feed, and it stays reachable deep inside a long open card.
160
+ const collapseAllBtn = el(`<button class="collapse-all" title="Collapse all (Esc collapses the focused one)"></button>`);
161
+ document.body.append(collapseAllBtn);
162
+ collapseAllBtn.addEventListener('click', () => [...S.open].forEach((id) => collapse(id)));
163
+ function placeCollapseAll() {
164
+ const open = [...S.open].filter((id) => S.nodes.get(id)?.card.isConnected).length;
165
+ collapseAllBtn.classList.toggle('on', open > 0);
166
+ if (open) collapseAllBtn.textContent = open > 1 ? `Collapse all · ${open}` : 'Collapse';
167
+ }
168
+
169
+ // Get (or create) the card for a summary and refresh its header.
170
+ function item(s) {
171
+ let n = S.nodes.get(s.id);
172
+ if (!n) {
173
+ const card = el(`<article class="item" data-id="${s.id}"><div class="it-head" role="button" tabindex="-1"></div></article>`);
174
+ n = { card, head: $('.it-head', card), body: null, turnNodes: new Map(), tray: null, showAll: false, unfolded: new Set(), latestTurn: null };
175
+ S.nodes.set(s.id, n);
176
+ n.head.addEventListener('click', (e) => {
177
+ if (n.head.querySelector('[contenteditable="true"]')) return;
178
+ if (e.target.closest('[data-a="quick-done"]')) return toggleDone(s.id);
179
+ setFocus(s.id);
180
+ toggle(s.id);
181
+ });
182
+ card.addEventListener('mousedown', () => setFocus(s.id, { scroll: false }));
183
+ if (S.open.has(s.id)) expand(s.id, { scroll: false });
184
+ }
185
+ renderHead(n, s);
186
+ return n;
187
+ }
188
+
189
+ function renderHead(n, s) {
190
+ if (n.head.querySelector('[contenteditable="true"]')) return;
191
+ const open = S.open.has(s.id);
192
+ const live = s.state === 'running' || s.state === 'waiting';
193
+ const pill =
194
+ s.state === 'waiting'
195
+ ? `<span class="pill st-waiting"><i></i>Asking</span>`
196
+ : s.state === 'review'
197
+ ? `<span class="pill st-${s.status}"><i></i>${STATUS_LABEL[s.status] || 'Done'}</span>`
198
+ : '';
199
+ n.card.className = `item c-${s.color} st-${s.state} ${s.unread ? 'unread' : ''} ${open ? 'open' : ''} ${S.focus === s.id ? 'focused' : ''}`;
200
+ n.head.innerHTML = `
201
+ <span class="q-dot ${s.state === 'running' ? 'live' : ''} ${s.state === 'waiting' ? 'waiting' : ''}"></span>
202
+ <span class="it-main">
203
+ <span class="it-title">${esc(s.title)}</span>
204
+ ${live ? `<span class="q-live ${s.state}">${esc(s.live)}</span>` : open ? '' : `<span class="it-sub">${esc(s.preview || '')}</span>`}
205
+ </span>
206
+ <span class="it-side">
207
+ ${pill}
208
+ ${s.queued ? `<span class="q-queued" title="Follow-ups queued">+${s.queued}</span>` : ''}
209
+ <span class="q-proj">${esc(s.project)}</span>
210
+ <span class="q-time">${ago(s.updatedAt)}</span>
211
+ ${s.state === 'review' && !open ? `<button class="it-quick" data-a="quick-done" title="Mark done (E)">${icon('done')}</button>` : ''}
212
+ ${icon('chevron', 'chev')}
213
+ </span>`;
214
+ }
215
+
216
+ function setFocus(id, { scroll = true } = {}) {
217
+ if (S.focus === id) return;
218
+ S.nodes.get(S.focus)?.card.classList.remove('focused');
219
+ S.focus = id;
220
+ const n = S.nodes.get(id);
221
+ n?.card.classList.add('focused');
222
+ if (scroll) n?.card.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
223
+ }
224
+
225
+ function toggle(id) {
226
+ S.open.has(id) ? collapse(id) : expand(id);
227
+ }
228
+
229
+ async function expand(id, { scroll = true } = {}) {
230
+ const n = S.nodes.get(id);
231
+ if (!n) return;
232
+ S.open.add(id);
233
+ saveOpen();
234
+ n.card.classList.add('open');
235
+ renderHead(n, S.sessions.get(id));
236
+ if (!n.body) {
237
+ n.body = el(`<div class="it-body"><div class="it-loading"><span class="orb tiny"></span>Loading…</div></div>`);
238
+ n.card.append(n.body);
239
+ }
240
+ renderFeedHead(groups().needs, groups().working);
241
+ if (scroll) requestAnimationFrame(() => n.card.scrollIntoView({ block: 'start', behavior: 'smooth' }));
242
+ if (!S.full.has(id)) await fetchFull(id);
243
+ else renderBody(id, { fresh: true });
244
+ }
245
+
246
+ function collapse(id) {
247
+ const n = S.nodes.get(id);
248
+ S.open.delete(id);
249
+ saveOpen();
250
+ S.full.delete(id);
251
+ if (n) {
252
+ n.body?.remove();
253
+ n.body = null;
254
+ n.turnNodes.clear();
255
+ n.tray = null;
256
+ n.showAll = false;
257
+ n.card.classList.remove('open');
258
+ const s = S.sessions.get(id);
259
+ if (s) renderHead(n, s);
260
+ }
261
+ renderFeedHead(groups().needs, groups().working);
262
+ }
263
+
264
+ async function fetchFull(id) {
265
+ const s = await api(`/sessions/${id}`).catch(() => null);
266
+ if (!s) {
267
+ S.open.delete(id);
268
+ saveOpen();
269
+ return;
270
+ }
271
+ if (!S.open.has(id)) return; // collapsed while loading
272
+ const fresh = !S.full.has(id);
273
+ S.full.set(id, s);
274
+ renderBody(id, { fresh });
275
+ if (s.unread && s.state !== 'running' && document.hasFocus()) api(`/sessions/${id}`, { method: 'PATCH', body: { unread: false } }).catch(() => {});
276
+ }
277
+
278
+ const refetchTimers = new Map();
279
+ function scheduleRefetch(id) {
280
+ if (!S.open.has(id)) return;
281
+ clearTimeout(refetchTimers.get(id));
282
+ refetchTimers.set(id, setTimeout(() => (refetchTimers.delete(id), fetchFull(id)), 120));
283
+ }
284
+
285
+ // --- attachments ---------------------------------------------------------------
286
+
287
+ function attachTray() {
288
+ const items = [];
289
+ const node = el(`<div class="tray" hidden></div>`);
290
+ const fileInput = el(`<input type="file" multiple hidden>`);
291
+ document.body.append(fileInput);
292
+ const render = () => {
293
+ node.hidden = !items.length;
294
+ node.innerHTML = items
295
+ .map(
296
+ (it, i) => `<div class="att ${it.status}" data-i="${i}" title="${esc(it.name)}">
297
+ ${it.thumb ? `<img src="${it.thumb}" alt="">` : `<span class="att-ic">${icon(it.mime.startsWith('video') ? 'web' : 'file')}</span>`}
298
+ <span class="att-name">${esc(it.name)}</span><span class="att-size">${fmtSize(it.size)}</span>
299
+ <button class="att-x" title="Remove">${icon('x')}</button>
300
+ </div>`,
301
+ )
302
+ .join('');
303
+ };
304
+ node.addEventListener('click', (e) => {
305
+ const x = e.target.closest('.att-x');
306
+ if (x) {
307
+ items.splice(Number(x.closest('.att').dataset.i), 1);
308
+ render();
309
+ }
310
+ });
311
+ const add = (files) => {
312
+ for (const f of files) {
313
+ const it = { name: f.name || 'pasted', size: f.size, mime: f.type || '', status: 'uploading', thumb: f.type.startsWith('image/') ? URL.createObjectURL(f) : null };
314
+ items.push(it);
315
+ it.promise = fetch(`/api/uploads?name=${encodeURIComponent(it.name)}&type=${encodeURIComponent(it.mime)}`, {
316
+ method: 'POST',
317
+ headers: { 'content-type': 'application/octet-stream' },
318
+ body: f,
319
+ })
320
+ .then((r) => r.json().then((j) => (r.ok ? j : Promise.reject(new Error(j.error)))))
321
+ .then((j) => ((it.id = j.id), (it.status = 'ready'), render()))
322
+ .catch((err) => ((it.status = 'failed'), render(), toast(`Upload failed: ${esc(err.message)}`, { tone: 'bad' })));
323
+ }
324
+ render();
325
+ };
326
+ fileInput.onchange = () => (add(fileInput.files), (fileInput.value = ''));
327
+ return {
328
+ node,
329
+ add,
330
+ pick: () => fileInput.click(),
331
+ async ids() {
332
+ await Promise.allSettled(items.map((i) => i.promise));
333
+ return items.filter((i) => i.id).map((i) => i.id);
334
+ },
335
+ clear() {
336
+ items.length = 0;
337
+ render();
338
+ },
339
+ get busy() {
340
+ return items.some((i) => i.status === 'uploading');
341
+ },
342
+ };
343
+ }
344
+
345
+ function fmtSize(n) {
346
+ if (n < 1024) return `${n} B`;
347
+ if (n < 1024 * 1024) return `${Math.round(n / 1024)} KB`;
348
+ return `${(n / 1024 / 1024).toFixed(1)} MB`;
349
+ }
350
+
351
+ // Wire paste + drag/drop of files onto a container.
352
+ function acceptFiles(container, textarea, tray) {
353
+ textarea.addEventListener('paste', (e) => {
354
+ const files = [...(e.clipboardData?.files || [])];
355
+ if (files.length) {
356
+ e.preventDefault();
357
+ tray.add(files);
358
+ }
359
+ });
360
+ container.addEventListener('dragover', (e) => {
361
+ if ([...e.dataTransfer.types].includes('Files')) {
362
+ e.preventDefault();
363
+ e.stopPropagation();
364
+ container.classList.add('drop');
365
+ }
366
+ });
367
+ container.addEventListener('dragleave', (e) => !container.contains(e.relatedTarget) && container.classList.remove('drop'));
368
+ container.addEventListener('drop', (e) => {
369
+ container.classList.remove('drop');
370
+ if (e.dataTransfer.files.length) {
371
+ e.preventDefault();
372
+ e.stopPropagation();
373
+ tray.add(e.dataTransfer.files);
374
+ }
375
+ });
376
+ }
377
+
378
+ function autosize(ta, max = 340) {
379
+ const fit = () => {
380
+ ta.style.height = 'auto';
381
+ ta.style.height = `${Math.min(max, ta.scrollHeight)}px`;
382
+ };
383
+ ta.addEventListener('input', fit);
384
+ requestAnimationFrame(fit);
385
+ return fit;
386
+ }
387
+
388
+ // --- launcher ----------------------------------------------------------------------
389
+
390
+ let launchProject = load('wisp.launchProject', null);
391
+
392
+ function renderLauncher() {
393
+ const box = $('#launcher');
394
+ const mode = S.config.defaults.mode || 'edits';
395
+ const agent = S.agents.find((a) => a.id === (S.config.defaults.agent || 'claude')) || S.agents[0];
396
+ box.innerHTML = `
397
+ <div class="l-projects"></div>
398
+ <textarea id="prompt" rows="2" placeholder="What should I go do? Paste screenshots, drop logs or videos…" spellcheck="false"></textarea>
399
+ <div class="l-bar">
400
+ <button class="icon-btn" id="attach" title="Attach files">${icon('clip')}</button>
401
+ <div class="seg" id="mode">${MODES.map((m) => `<button data-mode="${m.id}" class="${m.id === mode ? 'on' : ''}" title="${esc(m.hint)}">${m.label}</button>`).join('')}</div>
402
+ <button class="model-btn" id="model" title="Model" aria-haspopup="menu"></button>
403
+ <span class="grow"></span>
404
+ <button class="send" id="launch" title="Launch (Enter · Shift+Enter for newline)">${icon('enter')}</button>
405
+ </div>`;
406
+ renderLaunchChips();
407
+
408
+ const ta = $('#prompt', box);
409
+ const tray = attachTray();
410
+ ta.before(tray.node);
411
+ acceptFiles(box, ta, tray);
412
+ autosize(ta);
413
+ ta.value = S.drafts.get('home') || '';
414
+ ta.addEventListener('input', () => S.drafts.set('home', ta.value));
415
+ $('#attach', box).onclick = tray.pick;
416
+
417
+ let curMode = mode;
418
+ $$('#mode button', box).forEach((b) =>
419
+ b.addEventListener('click', () => {
420
+ curMode = b.dataset.mode;
421
+ $$('#mode button', box).forEach((x) => x.classList.toggle('on', x === b));
422
+ api('/config', { method: 'PATCH', body: { defaults: { mode: curMode } } }).catch(() => {});
423
+ }),
424
+ );
425
+ const models = agent?.models || [{ id: '', label: 'Default' }];
426
+ let curModel = models.some((m) => m.id === S.config.defaults.model) ? S.config.defaults.model : models[0].id;
427
+ const modelBtn = $('#model', box);
428
+ const paintModel = () => {
429
+ const m = models.find((x) => x.id === curModel);
430
+ modelBtn.innerHTML = `${icon('spark')}<span>${esc(m.label)}</span>${icon('chevron', 'chev')}`;
431
+ modelBtn.classList.toggle('set', !!curModel);
432
+ };
433
+ paintModel();
434
+ modelBtn.onclick = () =>
435
+ modelMenu(modelBtn, models, curModel, (id) => {
436
+ curModel = id;
437
+ paintModel();
438
+ api('/config', { method: 'PATCH', body: { defaults: { model: id } } }).catch(() => {});
439
+ });
440
+
441
+ const launch = async () => {
442
+ const prompt = ta.value.trim();
443
+ if (!prompt) return ta.focus();
444
+ const project = projectById(launchProject);
445
+ if (!project) return openProjects({ focusAdd: true });
446
+ const btn = $('#launch', box);
447
+ btn.disabled = true;
448
+ try {
449
+ const attachments = await tray.ids();
450
+ const s = await api('/sessions', { method: 'POST', body: { prompt, cwd: project.path, agent: agent?.id || 'claude', mode: curMode, model: curModel, attachments } });
451
+ S.sessions.set(s.id, s);
452
+ ta.value = '';
453
+ S.drafts.delete('home');
454
+ tray.clear();
455
+ ta.dispatchEvent(new Event('input'));
456
+ if (S.filter !== 'all' && S.filter !== s.projectId) {
457
+ S.filter = 'all';
458
+ save('wisp.filter', S.filter);
459
+ }
460
+ renderFeed();
461
+ launchedFx(btn, S.nodes.get(s.id)?.card);
462
+ askNotify();
463
+ } catch (err) {
464
+ toast(esc(err.message), { tone: 'bad' });
465
+ } finally {
466
+ btn.disabled = false;
467
+ ta.focus();
468
+ }
469
+ };
470
+ $('#launch', box).onclick = launch;
471
+ ta.addEventListener('keydown', (e) => {
472
+ if (e.key === 'Enter' && !e.shiftKey && !e.isComposing) {
473
+ e.preventDefault();
474
+ launch();
475
+ }
476
+ });
477
+ }
478
+
479
+ function renderLaunchChips() {
480
+ const box = $('#launcher .l-projects');
481
+ if (!box) return;
482
+ const projects = S.config.projects.slice().sort((a, b) => (b.lastUsedAt || b.addedAt) - (a.lastUsedAt || a.addedAt));
483
+ if (S.filter !== 'all' && projectById(S.filter)) launchProject = S.filter;
484
+ if (!projectById(launchProject)) launchProject = projects[0]?.id || null;
485
+ box.innerHTML = `
486
+ <span class="l-in">in</span>
487
+ ${projects.map((p) => `<button class="lp-chip c-${p.color} ${p.id === launchProject ? 'on' : ''}" data-id="${p.id}" title="${esc(p.path)}"><i class="p-dot"></i>${esc(p.name)}</button>`).join('')}
488
+ <button class="lp-chip add" id="lpAdd">${icon('folder')}${projects.length ? 'Other folder…' : 'Choose a project folder…'}</button>`;
489
+ $$('.lp-chip[data-id]', box).forEach((b) =>
490
+ b.addEventListener('click', () => {
491
+ launchProject = b.dataset.id;
492
+ save('wisp.launchProject', launchProject);
493
+ $$('.lp-chip', box).forEach((x) => x.classList.toggle('on', x === b));
494
+ }),
495
+ );
496
+ $('#lpAdd', box).onclick = () => openProjects({ focusAdd: true });
497
+ }
498
+
499
+ // A little wisp flies from the launch button to the new item in the feed.
500
+ function launchedFx(from, to) {
501
+ to?.classList.add('arrived');
502
+ setTimeout(() => to?.classList.remove('arrived'), 1600);
503
+ if (!to || matchMedia('(prefers-reduced-motion: reduce)').matches) return;
504
+ const a = from.getBoundingClientRect();
505
+ const b = to.getBoundingClientRect();
506
+ const w = el(`<div class="flying-wisp"></div>`);
507
+ w.style.left = `${a.left + a.width / 2}px`;
508
+ w.style.top = `${a.top + a.height / 2}px`;
509
+ document.body.append(w);
510
+ const dx = b.left + 24 - (a.left + a.width / 2);
511
+ const dy = b.top + b.height / 2 - (a.top + a.height / 2);
512
+ w.animate(
513
+ [
514
+ { transform: 'translate(-50%,-50%) scale(.6)', opacity: 0 },
515
+ { transform: `translate(calc(-50% + ${dx * 0.4}px), calc(-50% + ${dy * 0.4 - 60}px)) scale(1.3)`, opacity: 1, offset: 0.35 },
516
+ { transform: `translate(calc(-50% + ${dx}px), calc(-50% + ${dy}px)) scale(.4)`, opacity: 0 },
517
+ ],
518
+ { duration: 800, easing: 'cubic-bezier(.5,0,.3,1)' },
519
+ ).onfinish = () => w.remove();
520
+ }
521
+
522
+ // --- project filter (top bar) ---------------------------------------------------
523
+
524
+ function renderProjects() {
525
+ const all = [...S.sessions.values()];
526
+ const stats = (pid) => {
527
+ const l = pid === 'all' ? all : all.filter((s) => s.projectId === pid);
528
+ return { run: l.filter((s) => s.state === 'running').length, need: l.filter((s) => s.state === 'waiting' || (s.state === 'review' && s.unread)).length };
529
+ };
530
+ const chip = (id, name, color) => {
531
+ const st = stats(id);
532
+ return `<button class="p-chip ${S.filter === id ? 'on' : ''} c-${color}" data-id="${id}" title="${esc(id === 'all' ? 'All projects' : projectById(id)?.path || '')}">
533
+ ${id === 'all' ? '' : '<i class="p-dot"></i>'}<span>${esc(name)}</span>${st.run ? `<span class="p-run" title="${st.run} working"></span>` : ''}${st.need ? `<span class="p-need">${st.need}</span>` : ''}
534
+ </button>`;
535
+ };
536
+ $('#projects').innerHTML = `${chip('all', 'All', 'slate')}${S.config.projects
537
+ .slice()
538
+ .sort((a, b) => (b.lastUsedAt || b.addedAt) - (a.lastUsedAt || a.addedAt))
539
+ .map((p) => chip(p.id, p.name, p.color))
540
+ .join('')}<button class="p-chip add" id="manageProjects" title="Manage projects">${icon('plus')}</button>`;
541
+ $$('.p-chip[data-id]').forEach((b) =>
542
+ b.addEventListener('click', () => {
543
+ S.filter = S.filter === b.dataset.id ? 'all' : b.dataset.id;
544
+ save('wisp.filter', S.filter);
545
+ renderFeed();
546
+ renderLaunchChips();
547
+ }),
548
+ );
549
+ $('#manageProjects').onclick = () => openProjects();
550
+ }
551
+
552
+ // --- expanded item body ------------------------------------------------------------
553
+
554
+ function ctxFor(s, turn, isLatest) {
555
+ return {
556
+ session: s,
557
+ turn,
558
+ isLatest,
559
+ reply: (text) => sendReply(s.id, text),
560
+ draft: (text) => {
561
+ const ta = $('.reply', S.nodes.get(s.id)?.card);
562
+ if (!ta) return;
563
+ ta.value = text;
564
+ ta.dispatchEvent(new Event('input'));
565
+ ta.focus();
566
+ ta.setSelectionRange(text.length, text.length);
567
+ },
568
+ openFile: (p, line) => openInEditor(s, p, line),
569
+ fileUrl: (p) => `/api/sessions/${s.id}/file?path=${encodeURIComponent(p)}`,
570
+ zoom: (src) => overlay(el(`<img class="lightbox" src="${esc(src)}">`)),
571
+ };
572
+ }
573
+
574
+ // Older turns render as one-line folded rows; past this many they hide behind "Show earlier".
575
+ const VISIBLE_TURNS = 8;
576
+
577
+ function renderBody(id, { fresh }) {
578
+ const s = S.full.get(id);
579
+ const n = S.nodes.get(id);
580
+ if (!s || !n) return;
581
+ const summary = S.sessions.get(id) || {};
582
+
583
+ if (fresh || !n.body?.querySelector('.turns')) {
584
+ n.turnNodes.clear();
585
+ const body = el(`
586
+ <div class="it-body">
587
+ <div class="it-bar">
588
+ <div class="it-meta"></div>
589
+ <div class="it-actions">
590
+ <button class="ghost xs" data-a="replay" title="Replay presentation (R)">${icon('replay')}</button>
591
+ <button class="ghost xs" data-a="takeover" title="Take over in your terminal (T)">${icon('terminal')}<span>Take over</span></button>
592
+ <button class="ghost xs" data-a="done" title="Mark done (E)">${icon('done')}<span>Done</span></button>
593
+ <button class="ghost xs" data-a="more" title="More">${icon('dots')}</button>
594
+ </div>
595
+ </div>
596
+ <button class="link xs earlier" hidden></button>
597
+ <div class="turns"></div>
598
+ <div class="it-dock"></div>
599
+ </div>`);
600
+ n.body ? n.body.replaceWith(body) : n.card.append(body);
601
+ n.body = body;
602
+ $('.it-bar', body).addEventListener('click', (e) => {
603
+ const a = e.target.closest('[data-a]')?.dataset.a;
604
+ if (a === 'replay') replay(id);
605
+ if (a === 'takeover') takeOver(id);
606
+ if (a === 'done') toggleDone(id);
607
+ if (a === 'more') moreMenu(id, e.target.closest('button'));
608
+ });
609
+ $('.earlier', body).onclick = () => ((n.showAll = true), renderBody(id, { fresh: false }));
610
+ renderDock(id);
611
+ }
612
+
613
+ const body = n.body;
614
+ const proj = projectById(summary.projectId);
615
+ $('.it-meta', body).innerHTML = `
616
+ <span class="m">${esc(agentName(s.agent))}</span>
617
+ <span class="m">${esc(MODES.find((m) => m.id === s.mode)?.label || s.mode)}</span>
618
+ ${s.model ? `<span class="m">${esc(s.model)}</span>` : ''}
619
+ <span class="m path" title="${esc(s.cwd)}">${esc((proj?.path || s.cwd).replace(S.config.home || '', '~'))}</span>`;
620
+ // Done lives both in the top bar and next to the composer, so it's reachable after reading.
621
+ $$('[data-a="done"]', body).forEach((b) => {
622
+ b.classList.toggle('on', s.state === 'done');
623
+ b.hidden = s.state === 'running' || s.state === 'waiting';
624
+ $('span', b).textContent = s.state === 'done' ? 'Reopen' : b.closest('.c-bar') ? 'Mark done' : 'Done';
625
+ });
626
+ $('[data-a="takeover"]', body).hidden = !s.agentSessionId;
627
+
628
+ // Turns: keep nodes whose content hasn't changed so animations don't replay.
629
+ const turnsEl = $('.turns', body);
630
+ const turns = n.showAll ? s.turns : s.turns.slice(-VISIBLE_TURNS);
631
+ const hiddenCount = s.turns.length - turns.length;
632
+ const earlier = $('.earlier', body);
633
+ earlier.hidden = !hiddenCount;
634
+ earlier.textContent = `Show ${hiddenCount} earlier turn${hiddenCount === 1 ? '' : 's'}`;
635
+ // A new turn re-folds everything older, so the latest exchange is what's open by default.
636
+ const latestId = s.turns.at(-1)?.id;
637
+ if (n.latestTurn !== latestId) (n.unfolded.clear(), (n.latestTurn = latestId));
638
+ const keep = new Set();
639
+ let firstNew = null;
640
+ turns.forEach((t) => {
641
+ const isLatest = t.id === latestId;
642
+ const folded = !isLatest && !n.unfolded.has(t.id);
643
+ const key = `${t.status}|${isLatest}|${folded}|${t.status === 'running' ? (s.ask?.id || '') + s.queue?.length : ''}`;
644
+ const prev = n.turnNodes.get(t.id);
645
+ let node = prev?.node;
646
+ if (!prev || prev.key !== key) {
647
+ const toggle = () => (n.unfolded.has(t.id) ? n.unfolded.delete(t.id) : n.unfolded.add(t.id), renderBody(id, { fresh: false }));
648
+ node = folded ? renderFoldedTurn(t, toggle) : renderTurn(s, t, isLatest, isLatest ? null : toggle);
649
+ if (prev) prev.node.replaceWith(node);
650
+ if (!prev && !fresh && !folded) firstNew ??= node;
651
+ if (prev && prev.status === 'running' && t.status !== 'running') firstNew ??= node;
652
+ n.turnNodes.set(t.id, { key, node, status: t.status });
653
+ }
654
+ keep.add(t.id);
655
+ turnsEl.append(node); // re-append keeps order
656
+ });
657
+ for (const [tid, v] of n.turnNodes) if (!keep.has(tid)) (v.node.remove(), n.turnNodes.delete(tid));
658
+
659
+ updateDock(id);
660
+ if (firstNew && isInView(n.card)) requestAnimationFrame(() => firstNew.scrollIntoView({ behavior: 'smooth', block: 'start' }));
661
+ }
662
+
663
+ function isInView(node) {
664
+ const r = node.getBoundingClientRect();
665
+ return r.bottom > 0 && r.top < innerHeight;
666
+ }
667
+
668
+ function renderFoldedTurn(t, unfold) {
669
+ const hero = t.presentation?.blocks?.find((b) => b.type === 'hero');
670
+ const node = el(`
671
+ <article class="turn folded" data-turn="${t.id}">
672
+ <button class="t-fold" title="Show this exchange">
673
+ ${icon('chevron', 'chev')}
674
+ <span class="t-prompt">${esc(t.prompt.replace(/\s+/g, ' '))}</span>
675
+ ${hero?.title ? `<span class="t-result">${esc(hero.title)}</span>` : ''}
676
+ <time>${new Date(t.startedAt).toLocaleString([], { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' })}</time>
677
+ </button>
678
+ </article>`);
679
+ $('.t-fold', node).onclick = unfold;
680
+ return node;
681
+ }
682
+
683
+ // `fold` is set for older turns the user unfolded, so they can tuck them away again.
684
+ function renderTurn(s, t, isLatest, fold) {
685
+ const node = el(`<article class="turn" data-turn="${t.id}"></article>`);
686
+ const long = t.prompt.length > 360 || t.prompt.split('\n').length > 5;
687
+ const ask = el(`
688
+ <div class="ask">
689
+ <div class="ask-text ${long ? 'clamped' : ''}">${esc(t.prompt)}</div>
690
+ ${long ? '<button class="link xs more-btn">Show all</button>' : ''}
691
+ ${t.attachments?.length ? `<div class="ask-atts">${t.attachments.map((a) => (a.mime?.startsWith('image/') ? `<img src="/api/uploads/${a.id}" alt="${esc(a.name)}" title="${esc(a.name)}">` : `<a class="att-pill" href="/api/uploads/${a.id}" target="_blank">${icon('file')}${esc(a.name)}</a>`)).join('')}</div>` : ''}
692
+ <time>${new Date(t.startedAt).toLocaleString([], { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' })}</time>
693
+ ${fold ? `<button class="ghost xs t-unfold" title="Collapse this exchange">${icon('chevron', 'chev')}</button>` : ''}
694
+ </div>`);
695
+ if (fold) ask.querySelector('.t-unfold').onclick = fold;
696
+ ask.querySelector('.more-btn')?.addEventListener('click', (e) => {
697
+ ask.querySelector('.ask-text').classList.toggle('clamped');
698
+ e.target.textContent = ask.querySelector('.ask-text').classList.contains('clamped') ? 'Show all' : 'Show less';
699
+ });
700
+ ask.querySelectorAll('.ask-atts img').forEach((img) => img.addEventListener('click', () => overlay(el(`<img class="lightbox" src="${img.src}">`))));
701
+ node.append(ask);
702
+
703
+ if (t.status === 'running') {
704
+ node.append(renderWorking(s, t));
705
+ } else {
706
+ node.append(renderPresentation(t.presentation, ctxFor(s, t, isLatest)));
707
+ node.append(renderTrail(s, t));
708
+ }
709
+ return node;
710
+ }
711
+
712
+ function renderWorking(s, t) {
713
+ const waiting = !!s.ask;
714
+ const node = el(`
715
+ <div class="w-panel ${waiting ? 'waiting' : ''}">
716
+ <div class="w-orb"><div class="orb live"><i></i><i></i><i></i></div></div>
717
+ <div class="w-body">
718
+ <div class="w-status">
719
+ <b>${waiting ? `${esc(agentName(s.agent))} is waiting for you` : `${esc(agentName(s.agent))} is working`}</b>
720
+ <span class="elapsed" data-start="${t.startedAt}">${fmtDuration(Date.now() - t.startedAt)}</span>
721
+ <span class="w-count">${t.activity.filter((a) => a.kind !== 'text').length} actions</span>
722
+ </div>
723
+ <div class="ask-slot"></div>
724
+ <ul class="ticker"></ul>
725
+ <div class="w-actions">
726
+ <button class="link xs peek">Show all activity</button>
727
+ ${s.queue?.length ? `<span class="w-queued">${s.queue.length} follow-up${s.queue.length > 1 ? 's' : ''} queued</span>` : ''}
728
+ <span class="grow"></span>
729
+ <button class="ghost sm danger stop">${icon('stop')}Stop</button>
730
+ </div>
731
+ <ul class="log" hidden></ul>
732
+ </div>
733
+ </div>`);
734
+ if (waiting) $('.ask-slot', node).append(renderAskCard(s));
735
+ const ticker = $('.ticker', node);
736
+ t.activity
737
+ .filter((a) => a.kind !== 'text')
738
+ .slice(-5)
739
+ .forEach((a) => ticker.prepend(actItem(a)));
740
+ $('.stop', node).onclick = async () => {
741
+ await api(`/sessions/${s.id}/stop`, { method: 'POST', body: {} }).catch((e) => toast(esc(e.message), { tone: 'bad' }));
742
+ };
743
+ $('.peek', node).onclick = (e) => {
744
+ const log = $('.log', node);
745
+ log.hidden = !log.hidden;
746
+ e.target.textContent = log.hidden ? 'Show all activity' : 'Hide activity';
747
+ if (!log.hidden) log.innerHTML = t.activity.map((a) => actItem(a).outerHTML).join('');
748
+ };
749
+ return node;
750
+ }
751
+
752
+ function actItem(a) {
753
+ return el(`<li class="act k-${a.kind}">${icon(KIND_ICON[a.kind] || 'tool')}<span>${esc(a.label || '')}</span></li>`);
754
+ }
755
+
756
+ function renderAskCard(s) {
757
+ const a = s.ask;
758
+ const node = el(`
759
+ <div class="ask-card">
760
+ <div class="q-head">${icon('ask')}<span>Needs your call</span></div>
761
+ <p class="q-text">${esc(a.question)}</p>
762
+ ${a.context ? `<div class="prose q-context">${md(a.context)}</div>` : ''}
763
+ ${a.options?.length ? `<div class="q-opts">${a.options.map((o, i) => `<button class="opt" data-i="${i}"><span class="k">${i + 1}</span>${esc(o)}</button>`).join('')}</div>` : ''}
764
+ <form class="q-own"><input placeholder="${a.options?.length ? 'Or answer in your own words…' : 'Your answer…'}"><button class="primary sm">Answer</button></form>
765
+ </div>`);
766
+ const answer = async (text) => {
767
+ if (!text.trim()) return;
768
+ node.classList.add('sent');
769
+ await api(`/sessions/${s.id}/answer`, { method: 'POST', body: { text } }).catch((e) => toast(esc(e.message), { tone: 'bad' }));
770
+ };
771
+ node.addEventListener('click', (e) => {
772
+ const b = e.target.closest('.opt');
773
+ if (b) answer(a.options[b.dataset.i]);
774
+ });
775
+ $('form', node).onsubmit = (e) => (e.preventDefault(), answer($('input', node).value));
776
+ return node;
777
+ }
778
+
779
+ function renderTrail(s, t) {
780
+ const acts = t.activity || [];
781
+ const count = (k) => acts.filter((a) => a.kind === k).length;
782
+ const parts = [
783
+ ['read', 'read', count('read')],
784
+ ['pen', 'edited', count('edit')],
785
+ ['terminal', 'ran', count('run')],
786
+ ['search', 'searched', count('search')],
787
+ ['web', 'web', count('web')],
788
+ ].filter((p) => p[2]);
789
+ const totals = (t.changes || []).reduce((acc, c) => {
790
+ const n = countChanges(c.edits);
791
+ return { add: acc.add + n.add, del: acc.del + n.del };
792
+ }, { add: 0, del: 0 });
793
+ const st = t.stats || {};
794
+ const node = el(`
795
+ <div class="trail">
796
+ <div class="trail-bar">
797
+ ${parts.map(([ic, l, n]) => `<span class="tb" title="${n} ${l}">${icon(ic)}${n}</span>`).join('')}
798
+ ${t.endedAt ? `<span class="tb">${fmtDuration(t.endedAt - t.startedAt)}</span>` : ''}
799
+ ${st.costUsd ? `<span class="tb">$${st.costUsd.toFixed(2)}</span>` : ''}
800
+ <span class="grow"></span>
801
+ ${t.changes?.length ? `<button class="link xs" data-tab="changes">${t.changes.length} file${t.changes.length > 1 ? 's' : ''} <span class="plus">+${totals.add}</span> <span class="minus">−${totals.del}</span></button>` : ''}
802
+ ${acts.length ? `<button class="link xs" data-tab="activity">Activity</button>` : ''}
803
+ ${t.presentation?.notes ? `<button class="link xs" data-tab="notes">Agent notes</button>` : ''}
804
+ </div>
805
+ <div class="trail-body" hidden></div>
806
+ </div>`);
807
+ const body = $('.trail-body', node);
808
+ let open = null;
809
+ node.addEventListener('click', (e) => {
810
+ const b = e.target.closest('[data-tab]');
811
+ if (!b) return;
812
+ const tab = b.dataset.tab;
813
+ $$('[data-tab]', node).forEach((x) => x.classList.toggle('on', x === b && open !== tab));
814
+ if (open === tab) {
815
+ open = null;
816
+ body.hidden = true;
817
+ return;
818
+ }
819
+ open = tab;
820
+ body.hidden = false;
821
+ body.innerHTML = '';
822
+ if (tab === 'activity') body.append(el(`<ul class="log">${acts.map((a) => actItem(a).outerHTML).join('')}</ul>`));
823
+ if (tab === 'notes') body.append(el(`<div class="prose">${md(t.presentation.notes)}</div>`));
824
+ if (tab === 'changes') {
825
+ body.append(renderPresentation({ blocks: t.changes.map((c) => ({ type: 'diff', file: c.file })) }, ctxFor(s, t, false)));
826
+ }
827
+ });
828
+ return node;
829
+ }
830
+
831
+ // Live activity without re-rendering the whole turn.
832
+ function onActivity({ sessionId, turnId, item }) {
833
+ const s = S.full.get(sessionId);
834
+ if (!s) return;
835
+ const turn = s.turns.find((t) => t.id === turnId);
836
+ if (turn && !turn.activity.includes(item)) turn.activity.push(item);
837
+ const node = S.nodes.get(sessionId)?.turnNodes.get(turnId)?.node;
838
+ if (!node || item.kind === 'text') return;
839
+ const ticker = $('.ticker', node);
840
+ if (ticker) {
841
+ const li = actItem(item);
842
+ li.classList.add('new');
843
+ ticker.prepend(li);
844
+ while (ticker.children.length > 5) ticker.lastElementChild.remove();
845
+ }
846
+ const log = $('.log', node);
847
+ if (log && !log.hidden) log.append(actItem(item));
848
+ const c = $('.w-count', node);
849
+ if (c && turn) c.textContent = `${turn.activity.filter((a) => a.kind !== 'text').length} actions`;
850
+ }
851
+
852
+ // --- per-item composer ---------------------------------------------------------
853
+
854
+ function renderDock(id) {
855
+ const n = S.nodes.get(id);
856
+ const dock = $('.it-dock', n.body);
857
+ const comp = el(`
858
+ <div class="composer">
859
+ <textarea class="reply" rows="1" spellcheck="false"></textarea>
860
+ <div class="c-bar">
861
+ <button class="icon-btn c-attach" title="Attach files">${icon('clip')}</button>
862
+ <span class="c-hint"></span>
863
+ <button class="ghost sm c-done" data-a="done" title="Mark done (E)">${icon('done')}<span>Mark done</span></button>
864
+ <button class="send" title="Send (Enter · Shift+Enter for newline)">${icon('enter')}</button>
865
+ </div>
866
+ </div>`);
867
+ dock.append(comp);
868
+ n.tray = attachTray();
869
+ comp.prepend(n.tray.node);
870
+ const ta = $('.reply', comp);
871
+ autosize(ta, 260);
872
+ acceptFiles(comp, ta, n.tray);
873
+ $('.c-attach', comp).onclick = n.tray.pick;
874
+ $('.c-done', comp).onclick = () => toggleDone(id);
875
+ ta.value = S.drafts.get(id) || '';
876
+ ta.addEventListener('input', () => S.drafts.set(id, ta.value));
877
+ ta.addEventListener('focus', () => setFocus(id, { scroll: false }));
878
+ ta.addEventListener('keydown', (e) => {
879
+ if (e.key === 'Enter' && !e.shiftKey && !e.isComposing) {
880
+ e.preventDefault();
881
+ sendReply(id, ta.value);
882
+ }
883
+ });
884
+ $('.send', comp).onclick = () => sendReply(id, ta.value);
885
+ }
886
+
887
+ function updateDock(id) {
888
+ const s = S.full.get(id);
889
+ const n = S.nodes.get(id);
890
+ const ta = n && $('.reply', n.card);
891
+ if (!s || !ta) return;
892
+ const name = agentName(s.agent);
893
+ const tour = s.agent === 'wisp';
894
+ ta.disabled = tour;
895
+ ta.placeholder = tour
896
+ ? 'This is the tour — launch a real wisp from the box at the top'
897
+ : s.ask
898
+ ? `Answer ${name}…`
899
+ : s.state === 'running'
900
+ ? `Queue a follow-up — sent when ${name} finishes`
901
+ : s.state === 'done'
902
+ ? 'Follow up to reopen…'
903
+ : `Follow up with ${name}…`;
904
+ $('.c-hint', n.card).innerHTML = s.queue?.length ? `${s.queue.length} queued` : '';
905
+ }
906
+
907
+ async function sendReply(id, text) {
908
+ const s = S.full.get(id);
909
+ const n = S.nodes.get(id);
910
+ text = (text || '').trim();
911
+ if (!s || !text) return;
912
+ if (n?.tray?.busy) toast('Waiting for uploads…');
913
+ const attachments = n?.tray ? await n.tray.ids() : [];
914
+ try {
915
+ const r = s.ask && !attachments.length
916
+ ? await api(`/sessions/${id}/answer`, { method: 'POST', body: { text } })
917
+ : await api(`/sessions/${id}/reply`, { method: 'POST', body: { text, attachments } });
918
+ const ta = n && $('.reply', n.card);
919
+ if (ta) ((ta.value = ''), ta.dispatchEvent(new Event('input')));
920
+ S.drafts.delete(id);
921
+ n?.tray?.clear();
922
+ if (r.queued) toast('Queued — it goes out as soon as the agent finishes.');
923
+ } catch (err) {
924
+ toast(esc(err.message), { tone: 'bad' });
925
+ }
926
+ }
927
+
928
+ // --- item actions ------------------------------------------------------------------
929
+
930
+ async function toggleDone(id) {
931
+ const s = S.sessions.get(id);
932
+ if (!s) return;
933
+ if (s.state === 'running' || s.state === 'waiting') return toast('Still working — stop it first or let it finish.');
934
+ const next = s.state === 'done' ? 'review' : 'done';
935
+ await api(`/sessions/${id}`, { method: 'PATCH', body: { state: next } });
936
+ if (next === 'done') {
937
+ const nextUp = groups().needs.find((x) => x.id !== id);
938
+ collapse(id);
939
+ if (nextUp) setFocus(nextUp.id);
940
+ toast(`Marked done`, { action: { label: 'Undo', run: () => api(`/sessions/${id}`, { method: 'PATCH', body: { state: 'review' } }) } });
941
+ }
942
+ }
943
+
944
+ async function takeOver(id) {
945
+ const s = S.full.get(id) || (await api(`/sessions/${id}`).catch(() => null));
946
+ if (!s?.agentSessionId) return;
947
+ if (s.state === 'running' && !confirm(`${agentName(s.agent)} is still running. Stop it and take over in your terminal?`)) return;
948
+ try {
949
+ const { command } = await api(`/sessions/${id}/takeover`, { method: 'POST', body: {} });
950
+ toast(`${icon('terminal')} Opened in your terminal`, { action: { label: 'Copy command', run: () => copy(command) } });
951
+ } catch (err) {
952
+ toast(esc(err.message), { tone: 'bad' });
953
+ }
954
+ }
955
+
956
+ function rename(id) {
957
+ const n = S.nodes.get(id);
958
+ const title = n && $('.it-title', n.head);
959
+ if (!title) return;
960
+ title.contentEditable = 'true';
961
+ title.focus();
962
+ document.getSelection().selectAllChildren(title);
963
+ const done = (commit) => {
964
+ title.contentEditable = 'false';
965
+ const s = S.sessions.get(id);
966
+ const t = title.textContent.trim();
967
+ if (commit && t && t !== s.title) api(`/sessions/${id}`, { method: 'PATCH', body: { title: t } }).catch((e) => toast(esc(e.message), { tone: 'bad' }));
968
+ else title.textContent = s.title;
969
+ };
970
+ title.onkeydown = (e) => {
971
+ if (e.key === 'Enter') (e.preventDefault(), title.blur());
972
+ if (e.key === 'Escape') ((title.textContent = S.sessions.get(id).title), title.blur());
973
+ };
974
+ title.onclick = (e) => e.stopPropagation();
975
+ title.onblur = () => done(true);
976
+ }
977
+
978
+ function modelMenu(anchor, models, current, pick) {
979
+ const open = $('.menu.models');
980
+ if (open) return open.close();
981
+ const r = anchor.getBoundingClientRect();
982
+ const below = r.bottom + 60 * models.length + 20 < innerHeight;
983
+ const menu = el(`
984
+ <div class="menu models" role="menu" style="left:${r.left}px;${below ? `top:${r.bottom + 6}px` : `bottom:${innerHeight - r.top + 6}px`}">
985
+ ${models
986
+ .map(
987
+ (m) => `<button role="menuitemradio" aria-checked="${m.id === current}" data-id="${esc(m.id)}" class="${m.id === current ? 'on' : ''}">
988
+ <span class="mm-text"><b>${esc(m.label)}</b>${m.hint ? `<small>${esc(m.hint)}</small>` : ''}</span>${icon('check', 'mm-check')}
989
+ </button>`,
990
+ )
991
+ .join('')}
992
+ </div>`);
993
+ const items = $$('button', menu);
994
+ const close = () => {
995
+ menu.remove();
996
+ anchor.classList.remove('open');
997
+ document.removeEventListener('mousedown', outside);
998
+ document.removeEventListener('keydown', keys, true);
999
+ };
1000
+ menu.close = close;
1001
+ const outside = (e) => !menu.contains(e.target) && !anchor.contains(e.target) && close();
1002
+ const keys = (e) => {
1003
+ const i = items.indexOf(document.activeElement);
1004
+ if (e.key === 'Escape') (e.preventDefault(), e.stopPropagation(), close(), anchor.focus());
1005
+ else if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
1006
+ e.preventDefault();
1007
+ e.stopPropagation();
1008
+ items[(i +(e.key === 'ArrowDown' ? 1 : -1) + items.length) % items.length].focus();
1009
+ } else if (e.key === 'Tab') close();
1010
+ };
1011
+ menu.onclick = (e) => {
1012
+ const b = e.target.closest('button');
1013
+ if (!b) return;
1014
+ close();
1015
+ anchor.focus();
1016
+ if (b.dataset.id !== current) pick(b.dataset.id);
1017
+ };
1018
+ setTimeout(() => document.addEventListener('mousedown', outside));
1019
+ document.addEventListener('keydown', keys, true);
1020
+ document.body.append(menu);
1021
+ anchor.classList.add('open');
1022
+ (items.find((b) => b.classList.contains('on')) || items[0]).focus();
1023
+ }
1024
+
1025
+ function moreMenu(id, anchor) {
1026
+ const s = S.full.get(id);
1027
+ if (!s) return;
1028
+ const r = anchor.getBoundingClientRect();
1029
+ const menu = el(`
1030
+ <div class="menu" style="top:${r.bottom + 6}px;right:${innerWidth - r.right}px">
1031
+ <button data-a="rename">${icon('pen')}Rename</button>
1032
+ ${s.agentSessionId ? `<button data-a="copy">${icon('copy')}Copy resume command</button>` : ''}
1033
+ <button data-a="folder">${icon('folder')}Open project in editor</button>
1034
+ <button data-a="delete" class="danger">${icon('trash')}Delete session</button>
1035
+ </div>`);
1036
+ const close = () => (menu.remove(), document.removeEventListener('mousedown', outside));
1037
+ const outside = (e) => !menu.contains(e.target) && close();
1038
+ setTimeout(() => document.addEventListener('mousedown', outside));
1039
+ menu.onclick = async (e) => {
1040
+ const a = e.target.closest('button')?.dataset.a;
1041
+ close();
1042
+ if (a === 'rename') rename(id);
1043
+ if (a === 'copy') {
1044
+ const { command } = await api(`/sessions/${id}/takeover`);
1045
+ copy(command);
1046
+ toast('Copied resume command');
1047
+ }
1048
+ if (a === 'folder') openInEditor(s, '.');
1049
+ if (a === 'delete' && confirm('Delete this session from wisp? (The agent’s own history is kept.)')) {
1050
+ await api(`/sessions/${id}`, { method: 'DELETE' });
1051
+ }
1052
+ };
1053
+ document.body.append(menu);
1054
+ }
1055
+
1056
+ function replay(id) {
1057
+ const n = S.nodes.get(id);
1058
+ if (!n?.body) return;
1059
+ for (const v of n.turnNodes.values()) v.node.remove();
1060
+ n.turnNodes.clear();
1061
+ renderBody(id, { fresh: false });
1062
+ $('.turns', n.body).lastElementChild?.scrollIntoView({ block: 'start', behavior: 'smooth' });
1063
+ }
1064
+
1065
+ const EDITORS = {
1066
+ vscode: (f, l) => `vscode://file${f}${l ? `:${l}` : ''}`,
1067
+ cursor: (f, l) => `cursor://file${f}${l ? `:${l}` : ''}`,
1068
+ windsurf: (f, l) => `windsurf://file${f}${l ? `:${l}` : ''}`,
1069
+ zed: (f, l) => `zed://file${f}${l ? `:${l}` : ''}`,
1070
+ idea: (f, l) => `idea://open?file=${encodeURIComponent(f)}${l ? `&line=${l}` : ''}`,
1071
+ };
1072
+
1073
+ function openInEditor(s, p, line) {
1074
+ const editor = S.config.defaults.editor || 'vscode';
1075
+ const abs = p.startsWith('/') ? p : `${s.cwd}/${p}`.replace(/\/\.$/, '');
1076
+ if (editor === 'none' || !EDITORS[editor]) {
1077
+ copy(abs);
1078
+ return toast(`Copied path ${esc(abs)}`);
1079
+ }
1080
+ location.href = EDITORS[editor](abs, line);
1081
+ }
1082
+
1083
+ // Bring an item into view, expanded — used by notifications and links.
1084
+ function reveal(id) {
1085
+ const s = S.sessions.get(id);
1086
+ if (!s) return;
1087
+ if (s.state === 'done' && !S.showDone) {
1088
+ S.showDone = true;
1089
+ save('wisp.showDone', true);
1090
+ }
1091
+ if (S.filter !== 'all' && S.filter !== s.projectId) {
1092
+ S.filter = 'all';
1093
+ save('wisp.filter', S.filter);
1094
+ renderLaunchChips();
1095
+ }
1096
+ renderFeed();
1097
+ setFocus(id, { scroll: false });
1098
+ if (S.open.has(id)) S.nodes.get(id)?.card.scrollIntoView({ block: 'start', behavior: 'smooth' });
1099
+ else expand(id);
1100
+ }
1101
+
1102
+ // --- projects modal ---------------------------------------------------------------------
1103
+
1104
+ const COLORS = ['violet', 'teal', 'amber', 'rose', 'sky', 'lime', 'orange', 'indigo'];
1105
+
1106
+ async function openProjects({ focusAdd = false } = {}) {
1107
+ const node = el(`
1108
+ <div class="modal projects-modal">
1109
+ <header><h2>Projects</h2><button class="icon-btn close">${icon('x')}</button></header>
1110
+ <div class="add-row">
1111
+ <div class="path-input">
1112
+ ${window.wispDesktop ? `<button class="browse" title="Browse…">${icon('folder')}</button>` : icon('folder')}
1113
+ <input id="ppath" placeholder="~/code/my-app" spellcheck="false" autocomplete="off">
1114
+ <div class="suggest" hidden></div>
1115
+ </div>
1116
+ ${window.wispDesktop ? `<button class="ghost" id="pbrowse">${icon('folder')}Browse…</button>` : ''}
1117
+ <button class="primary" id="padd">Add</button>
1118
+ </div>
1119
+ <p class="hint">Tip: run <code>wisp add</code> inside any folder, or launch from a terminal with <code>wisp "task"</code> — the folder is added automatically.</p>
1120
+ <div class="plist" id="plist"></div>
1121
+ <div class="discover" id="discover"><h4>Found on this machine</h4><p class="muted">Looking for git repos…</p></div>
1122
+ </div>`);
1123
+ const close = overlay(node);
1124
+ $('.close', node).onclick = close;
1125
+ const input = $('#ppath', node);
1126
+ const sug = $('.suggest', node);
1127
+
1128
+ const add = async (p) => {
1129
+ try {
1130
+ const proj = await api('/projects', { method: 'POST', body: { path: p } });
1131
+ await loadConfig();
1132
+ launchProject = proj.id;
1133
+ save('wisp.launchProject', launchProject);
1134
+ renderLaunchChips();
1135
+ toast(`Added <b>${esc(proj.name)}</b>`);
1136
+ input.value = '';
1137
+ sug.hidden = true;
1138
+ list();
1139
+ discover();
1140
+ } catch (err) {
1141
+ toast(esc(err.message), { tone: 'bad' });
1142
+ }
1143
+ };
1144
+ $('#padd', node).onclick = () => input.value.trim() && add(input.value.trim());
1145
+ const browse = async () => {
1146
+ const dir = await window.wispDesktop.pickFolder();
1147
+ if (dir) add(dir);
1148
+ };
1149
+ $$('#pbrowse, .browse', node).forEach((b) => (b.onclick = browse));
1150
+
1151
+ let sel = -1;
1152
+ let t;
1153
+ input.addEventListener('input', () => {
1154
+ clearTimeout(t);
1155
+ t = setTimeout(async () => {
1156
+ const q = input.value;
1157
+ if (!q) return (sug.hidden = true);
1158
+ const dirs = await api(`/fs?q=${encodeURIComponent(q)}`).catch(() => []);
1159
+ sel = -1;
1160
+ sug.hidden = !dirs.length;
1161
+ sug.innerHTML = dirs.map((d) => `<button data-p="${esc(d)}">${esc(d.replace(S.config.home, '~'))}</button>`).join('');
1162
+ }, 120);
1163
+ });
1164
+ input.addEventListener('keydown', (e) => {
1165
+ const opts = $$('button', sug);
1166
+ if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
1167
+ e.preventDefault();
1168
+ sel = (sel + (e.key === 'ArrowDown' ? 1 : -1) + opts.length) % opts.length;
1169
+ opts.forEach((o, i) => o.classList.toggle('on', i === sel));
1170
+ } else if (e.key === 'Tab' && opts.length) {
1171
+ e.preventDefault();
1172
+ input.value = (opts[Math.max(0, sel)].dataset.p + '/').replace(S.config.home, '~');
1173
+ input.dispatchEvent(new Event('input'));
1174
+ } else if (e.key === 'Enter') {
1175
+ e.preventDefault();
1176
+ add(sel >= 0 ? opts[sel].dataset.p : input.value.trim());
1177
+ }
1178
+ });
1179
+ sug.addEventListener('click', (e) => {
1180
+ const b = e.target.closest('button');
1181
+ if (b) add(b.dataset.p);
1182
+ });
1183
+
1184
+ const list = () => {
1185
+ const box = $('#plist', node);
1186
+ const projects = S.config.projects;
1187
+ box.innerHTML = projects.length
1188
+ ? projects
1189
+ .map((p) => {
1190
+ const n = [...S.sessions.values()].filter((s) => s.projectId === p.id).length;
1191
+ return `<div class="prow c-${p.color}" data-id="${p.id}">
1192
+ <button class="swatch" title="Change colour"><i class="p-dot"></i></button>
1193
+ <div class="prow-main"><input class="pname" value="${esc(p.name)}"><span class="ppath">${esc(p.path.replace(S.config.home, '~'))}</span></div>
1194
+ <span class="pcount">${n} session${n === 1 ? '' : 's'}</span>
1195
+ <button class="icon-btn premove" title="Remove from wisp (files untouched)">${icon('trash')}</button>
1196
+ </div>`;
1197
+ })
1198
+ .join('')
1199
+ : '<p class="muted">No projects yet. Add a folder above, or pick one below.</p>';
1200
+ $$('.prow', box).forEach((row) => {
1201
+ const id = row.dataset.id;
1202
+ const p = projectById(id);
1203
+ $('.pname', row).onchange = (e) => api(`/projects/${id}`, { method: 'PATCH', body: { name: e.target.value } }).then(loadConfig);
1204
+ $('.swatch', row).onclick = () => {
1205
+ const color = COLORS[(COLORS.indexOf(p.color) + 1) % COLORS.length];
1206
+ api(`/projects/${id}`, { method: 'PATCH', body: { color } }).then(async () => (await loadConfig(), list()));
1207
+ };
1208
+ $('.premove', row).onclick = async () => {
1209
+ if (!confirm(`Remove “${p.name}” from wisp? Sessions and files are kept.`)) return;
1210
+ await api(`/projects/${id}`, { method: 'DELETE' });
1211
+ await loadConfig();
1212
+ list();
1213
+ };
1214
+ });
1215
+ };
1216
+
1217
+ const discover = async () => {
1218
+ const box = $('#discover', node);
1219
+ const found = await api('/projects/discover').catch(() => []);
1220
+ box.innerHTML = found.length
1221
+ ? `<h4>Found on this machine <span class="muted">· git repos</span></h4><div class="found">${found
1222
+ .map((f) => `<button class="found-item" data-p="${esc(f.path)}">${icon('plus')}<b>${esc(f.name)}</b><span>${esc(f.path.replace(S.config.home, '~').replace(/\/[^/]+$/, ''))}</span></button>`)
1223
+ .join('')}</div>`
1224
+ : '';
1225
+ box.onclick = (e) => {
1226
+ const b = e.target.closest('.found-item');
1227
+ if (b) add(b.dataset.p);
1228
+ };
1229
+ };
1230
+
1231
+ list();
1232
+ discover();
1233
+ if (focusAdd) setTimeout(() => input.focus(), 60);
1234
+ }
1235
+
1236
+ // --- settings -----------------------------------------------------------------------------
1237
+
1238
+ function openSettings() {
1239
+ const d = S.config.defaults;
1240
+ const node = el(`
1241
+ <div class="modal settings-modal">
1242
+ <header><h2>Settings</h2><button class="icon-btn close">${icon('x')}</button></header>
1243
+ <label><span>Open files in</span>
1244
+ <select name="editor">${[['vscode', 'VS Code'], ['cursor', 'Cursor'], ['windsurf', 'Windsurf'], ['zed', 'Zed'], ['idea', 'JetBrains'], ['none', 'Copy path']]
1245
+ .map(([v, l]) => `<option value="${v}" ${d.editor === v || (!d.editor && v === 'vscode') ? 'selected' : ''}>${l}</option>`)
1246
+ .join('')}</select></label>
1247
+ <label><span>Take over in</span>
1248
+ <select name="terminal">${(S.config.terminals || []).map((t) => `<option value="${t.id}" ${(d.terminal || 'auto') === t.id ? 'selected' : ''}>${t.label}</option>`).join('')}</select></label>
1249
+ <label class="custom-term" ${d.terminal === 'custom' ? '' : 'hidden'}><span>Terminal command</span>
1250
+ <input name="terminalCommand" value="${esc(d.terminalCommand || '')}" placeholder="alacritty --working-directory {cwd} -e {cmd}" spellcheck="false"></label>
1251
+ <label><span>Notifications</span><button class="ghost sm" id="notif">${'Notification' in window && Notification.permission === 'granted' ? 'Enabled' : 'Enable'}</button></label>
1252
+ <div class="agents">${S.agents
1253
+ .map((a) => `<div class="agent-row"><span class="dot ${a.available ? 'ok' : 'bad'}"></span><b>${esc(a.name)}</b><span class="muted">${esc(a.available ? a.version : a.error || 'not found')}</span></div>`)
1254
+ .join('')}<p class="muted xs">More harnesses (Codex, opencode…) plug in via <code>server/adapters</code>.</p></div>
1255
+ </div>`);
1256
+ const close = overlay(node);
1257
+ $('.close', node).onclick = close;
1258
+ node.addEventListener('change', async (e) => {
1259
+ const { name, value } = e.target;
1260
+ if (!name) return;
1261
+ if (name === 'terminal') $('.custom-term', node).hidden = value !== 'custom';
1262
+ await api('/config', { method: 'PATCH', body: { defaults: { [name]: value } } });
1263
+ await loadConfig();
1264
+ });
1265
+ $('#notif', node).onclick = async (e) => {
1266
+ if (!('Notification' in window)) return;
1267
+ const p = await Notification.requestPermission();
1268
+ e.target.textContent = p === 'granted' ? 'Enabled' : 'Blocked in browser';
1269
+ };
1270
+ }
1271
+
1272
+ function shortcuts() {
1273
+ const rows = [
1274
+ ['N', 'New wisp (focus the launcher)'],
1275
+ ['J / K', 'Next / previous item'],
1276
+ ['O / ↵', 'Expand / collapse item'],
1277
+ ['/', 'Follow up on item'],
1278
+ ['E', 'Mark done & go to next'],
1279
+ ['T', 'Take over in terminal'],
1280
+ ['R', 'Replay presentation'],
1281
+ ['1–9', 'Pick an answer when asked'],
1282
+ ['↵', 'Launch / send'],
1283
+ ['⇧↵', 'New line'],
1284
+ ['Esc', 'Close / collapse'],
1285
+ ];
1286
+ const node = el(`<div class="modal keys-modal"><header><h2>Shortcuts</h2></header>${rows.map(([k, l]) => `<div class="krow"><kbd>${k}</kbd><span>${l}</span></div>`).join('')}</div>`);
1287
+ overlay(node);
1288
+ }
1289
+
1290
+ // --- notifications -----------------------------------------------------------------------
1291
+
1292
+ function askNotify() {
1293
+ if ('Notification' in window && Notification.permission === 'default') Notification.requestPermission();
1294
+ }
1295
+
1296
+ function notifyFinished(s) {
1297
+ const n = S.nodes.get(s.id);
1298
+ const viewing = S.open.has(s.id) && n && isInView(n.card) && document.hasFocus();
1299
+ if (viewing) return;
1300
+ const verb = s.state === 'waiting' ? 'has a question' : 'is ready';
1301
+ toast(`${icon(s.state === 'waiting' ? 'ask' : 'spark')} <b>${esc(s.title)}</b> ${verb}`, { action: { label: 'Open', run: () => reveal(s.id) }, timeout: 7000 });
1302
+ if ('Notification' in window && Notification.permission === 'granted' && !document.hasFocus()) {
1303
+ const note = new Notification(`${s.project}: ${s.title}`, { body: s.state === 'waiting' ? s.question : s.preview || 'Ready for review', icon: '/favicon.svg', tag: s.id });
1304
+ note.onclick = () => (window.focus(), reveal(s.id));
1305
+ }
1306
+ }
1307
+
1308
+ // --- live events --------------------------------------------------------------------------
1309
+
1310
+ function connect() {
1311
+ const es = new EventSource('/api/events');
1312
+ es.addEventListener('summary', (e) => {
1313
+ const s = JSON.parse(e.data);
1314
+ const prev = S.sessions.get(s.id);
1315
+ S.sessions.set(s.id, s);
1316
+ renderFeed();
1317
+ if (prev?.state !== s.state || prev?.queued !== s.queued) scheduleRefetch(s.id);
1318
+ });
1319
+ es.addEventListener('activity', (e) => onActivity(JSON.parse(e.data)));
1320
+ es.addEventListener('turn', (e) => scheduleRefetch(JSON.parse(e.data).sessionId));
1321
+ es.addEventListener('finished', (e) => notifyFinished(JSON.parse(e.data).session));
1322
+ es.addEventListener('removed', (e) => {
1323
+ const { id } = JSON.parse(e.data);
1324
+ S.sessions.delete(id);
1325
+ S.full.delete(id);
1326
+ S.open.delete(id);
1327
+ saveOpen();
1328
+ renderFeed();
1329
+ });
1330
+ es.addEventListener('config', () => loadConfig());
1331
+ es.addEventListener('hello', () => loadSessions());
1332
+ }
1333
+
1334
+ async function loadSessions() {
1335
+ const list = await api('/sessions');
1336
+ S.sessions = new Map(list.map((s) => [s.id, s]));
1337
+ for (const id of S.open) if (!S.sessions.has(id)) S.open.delete(id);
1338
+ saveOpen();
1339
+ renderFeed();
1340
+ for (const id of S.open) scheduleRefetch(id);
1341
+ }
1342
+
1343
+ async function loadConfig() {
1344
+ S.config = await api('/config');
1345
+ renderProjects();
1346
+ renderLaunchChips();
1347
+ }
1348
+
1349
+ function renderAgentStatus() {
1350
+ const a = S.agents[0];
1351
+ $('#agentStatus').innerHTML = a
1352
+ ? `<span class="dot ${a.available ? 'ok' : 'bad'}"></span>${esc(a.name)}`
1353
+ : '';
1354
+ $('#agentStatus').title = a ? a.version || a.error || '' : '';
1355
+ }
1356
+
1357
+ // --- keyboard ---------------------------------------------------------------------------
1358
+
1359
+ const feedIds = () => $$('#feed .item').map((x) => x.dataset.id);
1360
+
1361
+ document.addEventListener('keydown', (e) => {
1362
+ const typing = e.target.closest('input, textarea, select, [contenteditable="true"]');
1363
+ if (e.key === 'Escape') {
1364
+ if (!$('#overlay').hidden) return closeOverlay();
1365
+ if (typing) return e.target.blur();
1366
+ if (S.focus && S.open.has(S.focus)) collapse(S.focus);
1367
+ return;
1368
+ }
1369
+ if (typing || e.metaKey || e.ctrlKey || e.altKey || !$('#overlay').hidden) return;
1370
+ const k = e.key.toLowerCase();
1371
+ const id = S.focus && S.nodes.has(S.focus) ? S.focus : null;
1372
+ if (k === 'n') {
1373
+ e.preventDefault();
1374
+ stage.scrollTo({ top: 0, behavior: 'smooth' });
1375
+ $('#prompt')?.focus({ preventScroll: true });
1376
+ } else if (k === 'j' || k === 'k') {
1377
+ const ids = feedIds();
1378
+ if (!ids.length) return;
1379
+ const i = ids.indexOf(id);
1380
+ const next = i < 0 ? ids[0] : ids[k === 'j' ? Math.min(ids.length - 1, i + 1) : Math.max(0, i - 1)];
1381
+ setFocus(next);
1382
+ } else if ((k === 'o' || (k === 'enter' && !e.target.closest('button, a'))) && id) {
1383
+ e.preventDefault();
1384
+ toggle(id);
1385
+ } else if (k === 'e' && id) toggleDone(id);
1386
+ else if (k === 't' && id) takeOver(id);
1387
+ else if (k === 'r' && id) replay(id);
1388
+ else if (k === '/' && id) {
1389
+ e.preventDefault();
1390
+ const focusReply = () => $('.reply', S.nodes.get(id)?.card)?.focus();
1391
+ if (S.open.has(id)) focusReply();
1392
+ else expand(id).then(focusReply);
1393
+ } else if (k === '?') shortcuts();
1394
+ else if (/^[1-9]$/.test(k) && id && S.open.has(id)) {
1395
+ const card = S.nodes.get(id).card;
1396
+ const opts = $$('.ask-card .opt', card).length ? $$('.ask-card .opt', card) : $$('.turn:last-child .b-question:not(.answered) .opt:not(.own)', card);
1397
+ opts[Number(k) - 1]?.click();
1398
+ }
1399
+ });
1400
+
1401
+ $('#settingsBtn').onclick = openSettings;
1402
+ $('#secDone .toggle-done').onclick = () => {
1403
+ S.showDone = !S.showDone;
1404
+ save('wisp.showDone', S.showDone);
1405
+ renderFeed();
1406
+ };
1407
+
1408
+ // elapsed timers + "ago" stamps
1409
+ setInterval(() => {
1410
+ $$('.elapsed[data-start]').forEach((n) => (n.textContent = fmtDuration(Date.now() - Number(n.dataset.start))));
1411
+ }, 1000);
1412
+ setInterval(() => {
1413
+ for (const [id, n] of S.nodes) {
1414
+ const s = S.sessions.get(id);
1415
+ const t = s && $('.q-time', n.head);
1416
+ if (t) t.textContent = ago(s.updatedAt);
1417
+ }
1418
+ }, 30000);
1419
+
1420
+ // Sticky top bar gets a hairline once the page scrolls; "back to top" shows after a screen's worth.
1421
+ const toTop = $('#toTop');
1422
+ stage.addEventListener('scroll', () => {
1423
+ $('#topbar').classList.toggle('scrolled', stage.scrollTop > 4);
1424
+ toTop.hidden = stage.scrollTop < stage.clientHeight * 0.75;
1425
+ }, { passive: true });
1426
+ toTop.onclick = () => stage.scrollTo({ top: 0, behavior: 'smooth' });
1427
+
1428
+ if (/Electron/.test(navigator.userAgent)) document.body.classList.add('electron', /Mac/.test(navigator.platform) ? 'mac' : 'other');
1429
+
1430
+ // --- links -------------------------------------------------------------------------------
1431
+
1432
+ // #/s/<id> still works (notifications, CLI): it reveals that item in the feed.
1433
+ function route() {
1434
+ const m = location.hash.match(/^#\/s\/(\w+)/);
1435
+ if (!m) return;
1436
+ history.replaceState(null, '', '#/');
1437
+ reveal(m[1]);
1438
+ }
1439
+
1440
+ window.addEventListener('hashchange', route);
1441
+ window.addEventListener('focus', () => {
1442
+ for (const id of S.open) if (S.sessions.get(id)?.unread) scheduleRefetch(id);
1443
+ });
1444
+
1445
+ (async () => {
1446
+ [S.agents] = await Promise.all([api('/agents').catch(() => []), loadConfig(), loadSessions()]);
1447
+ renderAgentStatus();
1448
+ renderLauncher();
1449
+ connect();
1450
+ route();
1451
+ $('#prompt')?.focus();
1452
+ })();