wisp-agents 0.2.1 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +44 -1
- package/README.md +41 -13
- package/bin/wisp.js +8 -3
- package/package.json +3 -1
- package/server/adapters/claude.js +9 -29
- package/server/adapters/codex.js +188 -0
- package/server/adapters/common.js +175 -0
- package/server/adapters/cursor.js +156 -0
- package/server/adapters/index.js +11 -6
- package/server/adapters/opencode.js +180 -0
- package/server/index.js +25 -10
- package/server/proc.js +127 -0
- package/server/protocol.js +13 -4
- package/server/runner.js +86 -19
- package/server/store.js +37 -0
- package/server/terminal.js +95 -41
- package/web/css/app.css +164 -17
- package/web/index.html +6 -1
- package/web/js/app.js +619 -98
- package/web/js/fuzzy.js +100 -0
- package/web/js/palette.js +110 -0
- package/web/logos/LICENSE +3 -0
- package/web/logos/claudecode-color.svg +1 -0
- package/web/logos/codex-color.svg +1 -0
- package/web/logos/cursor.svg +1 -0
- package/web/logos/opencode.svg +1 -0
package/web/js/app.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { renderPresentation, icon, el, fmtDuration, copy, countChanges } from './blocks.js';
|
|
2
2
|
import { esc, md } from './md.js';
|
|
3
|
+
import { palette } from './palette.js';
|
|
3
4
|
|
|
4
5
|
// One page: the launcher on top, every session in a feed under it. Sessions
|
|
5
6
|
// expand in place; nothing navigates away.
|
|
@@ -16,6 +17,7 @@ const S = {
|
|
|
16
17
|
agents: [],
|
|
17
18
|
filter: load('wisp.filter', 'all'),
|
|
18
19
|
showDone: load('wisp.showDone', false),
|
|
20
|
+
showArchived: false,
|
|
19
21
|
drafts: new Map(),
|
|
20
22
|
};
|
|
21
23
|
|
|
@@ -40,7 +42,8 @@ const saveOpen = () => save('wisp.open', [...S.open]);
|
|
|
40
42
|
async function api(path, { method = 'GET', body } = {}) {
|
|
41
43
|
const r = await fetch(`/api${path}`, {
|
|
42
44
|
method,
|
|
43
|
-
|
|
45
|
+
// The server only accepts JSON on non-GET requests (CSRF guard), even body-less DELETEs.
|
|
46
|
+
headers: method !== 'GET' ? { 'content-type': 'application/json' } : {},
|
|
44
47
|
body: body !== undefined ? JSON.stringify(body) : undefined,
|
|
45
48
|
});
|
|
46
49
|
const j = await r.json().catch(() => ({}));
|
|
@@ -49,13 +52,34 @@ async function api(path, { method = 'GET', body } = {}) {
|
|
|
49
52
|
}
|
|
50
53
|
|
|
51
54
|
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
|
+
{ id: 'plan', label: 'Plan', icon: 'plan', hint: 'Read-only. The agent investigates and proposes.' },
|
|
56
|
+
{ id: 'edits', label: 'Edits', icon: 'pen', hint: 'May edit files. Shell commands that need approval are denied.' },
|
|
57
|
+
{ id: 'auto', label: 'Full auto', icon: 'spark', hint: 'No permission prompts at all. Trusted repos only.' },
|
|
55
58
|
];
|
|
56
59
|
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
60
|
const STATUS_LABEL = { done: 'Done', partial: 'Partial', blocked: 'Blocked', info: 'Report' };
|
|
58
61
|
const agentName = (id) => S.agents.find((a) => a.id === id)?.name || (id === 'wisp' ? 'wisp' : id);
|
|
62
|
+
|
|
63
|
+
// Harness logos for the request bubble, vendored from @lobehub/icons into web/logos/.
|
|
64
|
+
// `mono` marks are drawn in the text colour so they work in both themes.
|
|
65
|
+
const HARNESS_LOGO = {
|
|
66
|
+
claude: { file: 'claudecode-color' },
|
|
67
|
+
codex: { file: 'codex-color' },
|
|
68
|
+
opencode: { file: 'opencode', mono: true },
|
|
69
|
+
cursor: { file: 'cursor', mono: true },
|
|
70
|
+
};
|
|
71
|
+
function harnessLogo(agent) {
|
|
72
|
+
const l = HARNESS_LOGO[agent];
|
|
73
|
+
const src = l && `/logos/${l.file}.svg`;
|
|
74
|
+
return !l ? icon('spark') : l.mono ? `<i class="mono" style="--logo:url(${src})"></i>` : `<img src="${src}" alt="">`;
|
|
75
|
+
}
|
|
76
|
+
function modelBadge(agent, model) {
|
|
77
|
+
if (agent === 'wisp') return '';
|
|
78
|
+
const label = (model && S.agents.find((a) => a.id === agent)?.models?.find((x) => x.id === model)?.label) || model || 'Default model';
|
|
79
|
+
const tip = `${agentName(agent)} · ${label}`;
|
|
80
|
+
const logo = harnessLogo(agent);
|
|
81
|
+
return `<span class="model-logo" data-tip="${esc(tip)}" aria-label="${esc(tip)}" tabindex="0">${logo}</span>`;
|
|
82
|
+
}
|
|
59
83
|
const projectById = (id) => S.config.projects.find((p) => p.id === id);
|
|
60
84
|
|
|
61
85
|
function ago(t) {
|
|
@@ -84,7 +108,8 @@ function overlay(node, { onClose } = {}) {
|
|
|
84
108
|
requestAnimationFrame(() => o.classList.add('on'));
|
|
85
109
|
const close = () => {
|
|
86
110
|
o.classList.remove('on');
|
|
87
|
-
|
|
111
|
+
// Another overlay may have replaced this one while it faded (palette → settings): leave that alone.
|
|
112
|
+
setTimeout(() => o.firstChild === node && ((o.hidden = true), (o.innerHTML = '')), 200);
|
|
88
113
|
onClose?.();
|
|
89
114
|
};
|
|
90
115
|
o.onclick = (e) => e.target === o && close();
|
|
@@ -100,18 +125,20 @@ function visible(list) {
|
|
|
100
125
|
}
|
|
101
126
|
|
|
102
127
|
function groups() {
|
|
103
|
-
const
|
|
128
|
+
const every = visible([...S.sessions.values()]);
|
|
129
|
+
const all = every.filter((s) => !s.archived);
|
|
104
130
|
const needs = all
|
|
105
131
|
.filter((s) => s.state === 'review' || s.state === 'waiting')
|
|
106
132
|
.sort((a, b) => (b.state === 'waiting') - (a.state === 'waiting') || b.updatedAt - a.updatedAt);
|
|
107
133
|
// Working items keep launch order so the feed doesn't reshuffle on every tool call.
|
|
108
134
|
const working = all.filter((s) => s.state === 'running').sort((a, b) => b.createdAt - a.createdAt);
|
|
109
135
|
const done = all.filter((s) => s.state === 'done').sort((a, b) => b.updatedAt - a.updatedAt);
|
|
110
|
-
|
|
136
|
+
const archived = every.filter((s) => s.archived).sort((a, b) => b.updatedAt - a.updatedAt);
|
|
137
|
+
return { needs, working, done, archived };
|
|
111
138
|
}
|
|
112
139
|
|
|
113
140
|
function renderFeed() {
|
|
114
|
-
const { needs, working, done } = groups();
|
|
141
|
+
const { needs, working, done, archived } = groups();
|
|
115
142
|
const place = (sec, list, show = true) => {
|
|
116
143
|
const box = $('.f-list', sec);
|
|
117
144
|
sec.hidden = !list.length;
|
|
@@ -129,12 +156,14 @@ function renderFeed() {
|
|
|
129
156
|
place($('#secWorking'), working);
|
|
130
157
|
place($('#secDone'), done.slice(0, 60), S.showDone);
|
|
131
158
|
$('#secDone').classList.toggle('collapsed', !S.showDone);
|
|
159
|
+
place($('#secArchived'), archived.slice(0, 60), S.showArchived);
|
|
160
|
+
$('#secArchived').classList.toggle('collapsed', !S.showArchived);
|
|
132
161
|
|
|
133
162
|
// 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));
|
|
163
|
+
const shown = new Set([...needs, ...working, ...done, ...archived].map((s) => s.id));
|
|
135
164
|
for (const [id, n] of S.nodes) if (!shown.has(id)) (n.card.remove(), S.nodes.delete(id), S.full.delete(id));
|
|
136
165
|
|
|
137
|
-
const empty = !needs.length && !working.length && !done.length;
|
|
166
|
+
const empty = !needs.length && !working.length && !done.length && !archived.length;
|
|
138
167
|
$('#feedEmpty').hidden = !empty;
|
|
139
168
|
if (empty)
|
|
140
169
|
$('#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>`;
|
|
@@ -157,13 +186,14 @@ function renderFeedHead(needs, working) {
|
|
|
157
186
|
|
|
158
187
|
// "Collapse all" floats over the page instead of living in the flow: showing it
|
|
159
188
|
// 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
|
|
189
|
+
const collapseAllBtn = el(`<button class="collapse-all" title="Collapse all (C) · Esc collapses the focused one"><span></span><kbd>C</kbd></button>`);
|
|
161
190
|
document.body.append(collapseAllBtn);
|
|
162
|
-
|
|
191
|
+
const collapseAll = () => [...S.open].forEach((id) => collapse(id));
|
|
192
|
+
collapseAllBtn.addEventListener('click', collapseAll);
|
|
163
193
|
function placeCollapseAll() {
|
|
164
194
|
const open = [...S.open].filter((id) => S.nodes.get(id)?.card.isConnected).length;
|
|
165
195
|
collapseAllBtn.classList.toggle('on', open > 0);
|
|
166
|
-
if (open) collapseAllBtn.textContent = open > 1 ? `Collapse all · ${open}` : 'Collapse';
|
|
196
|
+
if (open) collapseAllBtn.firstChild.textContent = open > 1 ? `Collapse all · ${open}` : 'Collapse';
|
|
167
197
|
}
|
|
168
198
|
|
|
169
199
|
// Get (or create) the card for a summary and refresh its header.
|
|
@@ -180,6 +210,11 @@ function item(s) {
|
|
|
180
210
|
toggle(s.id);
|
|
181
211
|
});
|
|
182
212
|
card.addEventListener('mousedown', () => setFocus(s.id, { scroll: false }));
|
|
213
|
+
n.head.addEventListener('contextmenu', (e) => {
|
|
214
|
+
e.preventDefault();
|
|
215
|
+
setFocus(s.id, { scroll: false });
|
|
216
|
+
taskMenu(s.id, e);
|
|
217
|
+
});
|
|
183
218
|
if (S.open.has(s.id)) expand(s.id, { scroll: false });
|
|
184
219
|
}
|
|
185
220
|
renderHead(n, s);
|
|
@@ -206,9 +241,10 @@ function renderHead(n, s) {
|
|
|
206
241
|
<span class="it-side">
|
|
207
242
|
${pill}
|
|
208
243
|
${s.queued ? `<span class="q-queued" title="Follow-ups queued">+${s.queued}</span>` : ''}
|
|
244
|
+
${modelBadge(s.agent, s.model)}
|
|
209
245
|
<span class="q-proj">${esc(s.project)}</span>
|
|
210
246
|
<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>` : ''}
|
|
247
|
+
${s.state === 'review' && !open && !s.archived ? `<button class="it-quick" data-a="quick-done" title="Mark done (E)">${icon('done')}</button>` : ''}
|
|
212
248
|
${icon('chevron', 'chev')}
|
|
213
249
|
</span>`;
|
|
214
250
|
}
|
|
@@ -392,14 +428,16 @@ let launchProject = load('wisp.launchProject', null);
|
|
|
392
428
|
function renderLauncher() {
|
|
393
429
|
const box = $('#launcher');
|
|
394
430
|
const mode = S.config.defaults.mode || 'edits';
|
|
395
|
-
const
|
|
431
|
+
const ready = S.agents.filter((a) => a.available);
|
|
432
|
+
const chosen = S.agents.find((a) => a.id === (S.config.defaults.agent || 'claude'));
|
|
433
|
+
let agent = chosen?.available ? chosen : ready[0] || chosen || S.agents[0];
|
|
396
434
|
box.innerHTML = `
|
|
397
435
|
<div class="l-projects"></div>
|
|
398
|
-
<textarea id="prompt" rows="2"
|
|
436
|
+
<textarea id="prompt" rows="2" spellcheck="false"></textarea>
|
|
399
437
|
<div class="l-bar">
|
|
400
438
|
<button class="icon-btn" id="attach" title="Attach files">${icon('clip')}</button>
|
|
401
|
-
<
|
|
402
|
-
<button class="
|
|
439
|
+
<button class="mode-btn" id="mode" aria-haspopup="menu"></button>
|
|
440
|
+
<button class="runner-btn" id="runner" title="Harness and model" aria-haspopup="dialog"></button>
|
|
403
441
|
<span class="grow"></span>
|
|
404
442
|
<button class="send" id="launch" title="Launch (Enter · Shift+Enter for newline)">${icon('enter')}</button>
|
|
405
443
|
</div>`;
|
|
@@ -412,30 +450,55 @@ function renderLauncher() {
|
|
|
412
450
|
autosize(ta);
|
|
413
451
|
ta.value = S.drafts.get('home') || '';
|
|
414
452
|
ta.addEventListener('input', () => S.drafts.set('home', ta.value));
|
|
453
|
+
// While focus is elsewhere, the placeholder advertises the shortcut that brings you back.
|
|
454
|
+
const hint = () => (ta.placeholder = document.activeElement === ta
|
|
455
|
+
? 'What should I go do? Paste screenshots, drop logs or videos…'
|
|
456
|
+
: `What should I go do? Press ${combo('L')} to start typing`);
|
|
457
|
+
ta.addEventListener('focus', hint);
|
|
458
|
+
ta.addEventListener('blur', hint);
|
|
459
|
+
hint();
|
|
415
460
|
$('#attach', box).onclick = tray.pick;
|
|
416
461
|
|
|
462
|
+
// Mode is set-and-forget, so it's one small button: just an icon on the default (Edits),
|
|
463
|
+
// spelled out when it's Plan or Full auto so you can't miss it.
|
|
417
464
|
let curMode = mode;
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
}
|
|
424
|
-
|
|
425
|
-
const
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
465
|
+
const modeBtn = $('#mode', box);
|
|
466
|
+
const paintMode = () => {
|
|
467
|
+
const m = MODES.find((x) => x.id === curMode) || MODES[1];
|
|
468
|
+
modeBtn.className = `mode-btn m-${m.id}`;
|
|
469
|
+
modeBtn.innerHTML = `${icon(m.icon)}${m.id === 'edits' ? '' : `<span>${m.label}</span>`}`;
|
|
470
|
+
modeBtn.title = `${m.label}: ${m.hint}\nShift+Tab to switch`;
|
|
471
|
+
};
|
|
472
|
+
const setMode = (id) => {
|
|
473
|
+
curMode = id;
|
|
474
|
+
S.config.defaults.mode = id;
|
|
475
|
+
paintMode();
|
|
476
|
+
api('/config', { method: 'PATCH', body: { defaults: { mode: id } } }).catch(() => {});
|
|
477
|
+
};
|
|
478
|
+
paintMode();
|
|
479
|
+
modeBtn.onclick = () => modeMenu(modeBtn, curMode, setMode);
|
|
480
|
+
// The model is remembered per harness (ids aren't portable between them).
|
|
481
|
+
const savedModel = (id) => {
|
|
482
|
+
const d = S.config.defaults;
|
|
483
|
+
const want = d.models?.[id] ?? (id === d.agent ? d.model : '');
|
|
484
|
+
const models = S.agents.find((a) => a.id === id)?.models || [];
|
|
485
|
+
return models.some((m) => m.id === want) ? want : '';
|
|
432
486
|
};
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
487
|
+
let curModel = savedModel(agent?.id);
|
|
488
|
+
const runnerBtn = $('#runner', box);
|
|
489
|
+
const paintRunner = () => {
|
|
490
|
+
const m = (agent?.models || []).find((x) => x.id === curModel);
|
|
491
|
+
runnerBtn.innerHTML = `<span class="rb-logo">${harnessLogo(agent?.id)}</span><span class="rb-h">${esc(agent?.name || 'No agent')}</span><span class="rb-m">${esc(m && m.id ? m.label : 'Default')}</span>${icon('chevron', 'chev')}`;
|
|
492
|
+
};
|
|
493
|
+
paintRunner();
|
|
494
|
+
runnerBtn.onclick = () =>
|
|
495
|
+
runnerPicker(runnerBtn, { agent: agent?.id, model: curModel, savedModel }, (id, model) => {
|
|
496
|
+
agent = S.agents.find((a) => a.id === id);
|
|
497
|
+
curModel = model;
|
|
498
|
+
paintRunner();
|
|
499
|
+
const models = { ...S.config.defaults.models, [id]: model };
|
|
500
|
+
Object.assign(S.config.defaults, { agent: id, model, models });
|
|
501
|
+
api('/config', { method: 'PATCH', body: { defaults: { agent: id, model, models } } }).catch(() => {});
|
|
439
502
|
});
|
|
440
503
|
|
|
441
504
|
const launch = async () => {
|
|
@@ -473,9 +536,51 @@ function renderLauncher() {
|
|
|
473
536
|
e.preventDefault();
|
|
474
537
|
launch();
|
|
475
538
|
}
|
|
539
|
+
if (e.key === 'Tab' && e.shiftKey) {
|
|
540
|
+
e.preventDefault();
|
|
541
|
+
setMode(MODES[(MODES.findIndex((m) => m.id === curMode) + 1) % MODES.length].id);
|
|
542
|
+
}
|
|
476
543
|
});
|
|
477
544
|
}
|
|
478
545
|
|
|
546
|
+
function modeMenu(anchor, current, pick) {
|
|
547
|
+
const open = $('.menu.mode');
|
|
548
|
+
if (open) return open.close();
|
|
549
|
+
const menu = el(`
|
|
550
|
+
<div class="menu mode" role="menu">
|
|
551
|
+
${MODES.map((m) => `<button role="menuitemradio" aria-checked="${m.id === current}" data-mode="${m.id}" class="m-${m.id}${m.id === current ? ' on' : ''}">
|
|
552
|
+
${icon(m.icon)}<span class="mm-text"><b>${m.label}</b><small>${esc(m.hint)}</small></span>${icon('check', 'mm-check')}
|
|
553
|
+
</button>`).join('')}
|
|
554
|
+
</div>`);
|
|
555
|
+
const close = () => (menu.remove(), anchor.classList.remove('open'), document.removeEventListener('mousedown', outside));
|
|
556
|
+
const outside = (ev) => !menu.contains(ev.target) && !anchor.contains(ev.target) && close();
|
|
557
|
+
menu.close = close;
|
|
558
|
+
setTimeout(() => document.addEventListener('mousedown', outside));
|
|
559
|
+
menu.onclick = (ev) => {
|
|
560
|
+
const b = ev.target.closest('button');
|
|
561
|
+
if (!b) return;
|
|
562
|
+
close();
|
|
563
|
+
pick(b.dataset.mode);
|
|
564
|
+
$('#prompt')?.focus();
|
|
565
|
+
};
|
|
566
|
+
menu.addEventListener('keydown', (ev) => {
|
|
567
|
+
const items = $$('button', menu);
|
|
568
|
+
const i = items.indexOf(document.activeElement);
|
|
569
|
+
if (ev.key === 'Escape') (close(), anchor.focus());
|
|
570
|
+
else if (ev.key === 'ArrowDown' || ev.key === 'ArrowUp') {
|
|
571
|
+
ev.preventDefault();
|
|
572
|
+
items[(i + (ev.key === 'ArrowDown' ? 1 : items.length - 1)) % items.length].focus();
|
|
573
|
+
}
|
|
574
|
+
});
|
|
575
|
+
document.body.append(menu);
|
|
576
|
+
anchor.classList.add('open');
|
|
577
|
+
const r = anchor.getBoundingClientRect();
|
|
578
|
+
const h = menu.offsetHeight;
|
|
579
|
+
menu.style.left = `${Math.max(8, Math.min(r.left, innerWidth - menu.offsetWidth - 8))}px`;
|
|
580
|
+
menu.style.top = `${r.bottom + 6 + h < innerHeight - 8 ? r.bottom + 6 : r.top - 6 - h}px`;
|
|
581
|
+
$('button.on', menu)?.focus();
|
|
582
|
+
}
|
|
583
|
+
|
|
479
584
|
function renderLaunchChips() {
|
|
480
585
|
const box = $('#launcher .l-projects');
|
|
481
586
|
if (!box) return;
|
|
@@ -546,9 +651,53 @@ function renderProjects() {
|
|
|
546
651
|
renderLaunchChips();
|
|
547
652
|
}),
|
|
548
653
|
);
|
|
654
|
+
$$('.p-chip[data-id]:not([data-id="all"])').forEach((b) =>
|
|
655
|
+
b.addEventListener('contextmenu', (e) => {
|
|
656
|
+
e.preventDefault();
|
|
657
|
+
projectMenu(b.dataset.id, e);
|
|
658
|
+
}),
|
|
659
|
+
);
|
|
549
660
|
$('#manageProjects').onclick = () => openProjects();
|
|
550
661
|
}
|
|
551
662
|
|
|
663
|
+
function projectMenu(id, e) {
|
|
664
|
+
const p = projectById(id);
|
|
665
|
+
if (!p) return;
|
|
666
|
+
$('.menu.project')?.close();
|
|
667
|
+
const menu = el(`
|
|
668
|
+
<div class="menu project" style="top:${e.clientY}px;left:${e.clientX}px">
|
|
669
|
+
<button data-a="manage">${icon('pen')}Manage projects…</button>
|
|
670
|
+
<button data-a="delete" class="danger">${icon('trash')}Remove project</button>
|
|
671
|
+
</div>`);
|
|
672
|
+
const close = () => (menu.remove(), document.removeEventListener('mousedown', outside));
|
|
673
|
+
const outside = (ev) => !menu.contains(ev.target) && close();
|
|
674
|
+
menu.close = close;
|
|
675
|
+
setTimeout(() => document.addEventListener('mousedown', outside));
|
|
676
|
+
menu.onclick = async (ev) => {
|
|
677
|
+
const a = ev.target.closest('button')?.dataset.a;
|
|
678
|
+
close();
|
|
679
|
+
if (a === 'manage') openProjects();
|
|
680
|
+
if (a === 'delete' && confirm(`Remove “${p.name}” from wisp? Sessions and files are kept.`)) {
|
|
681
|
+
try {
|
|
682
|
+
await api(`/projects/${id}`, { method: 'DELETE' });
|
|
683
|
+
} catch (err) {
|
|
684
|
+
return toast(esc(err.message), { tone: 'bad' });
|
|
685
|
+
}
|
|
686
|
+
if (S.filter === id) {
|
|
687
|
+
S.filter = 'all';
|
|
688
|
+
save('wisp.filter', S.filter);
|
|
689
|
+
}
|
|
690
|
+
await loadConfig();
|
|
691
|
+
renderFeed();
|
|
692
|
+
}
|
|
693
|
+
};
|
|
694
|
+
document.body.append(menu);
|
|
695
|
+
// Keep the menu on-screen when right-clicking near the window edge.
|
|
696
|
+
const r = menu.getBoundingClientRect();
|
|
697
|
+
if (r.right > innerWidth - 8) menu.style.left = `${innerWidth - r.width - 8}px`;
|
|
698
|
+
if (r.bottom > innerHeight - 8) menu.style.top = `${innerHeight - r.height - 8}px`;
|
|
699
|
+
}
|
|
700
|
+
|
|
552
701
|
// --- expanded item body ------------------------------------------------------------
|
|
553
702
|
|
|
554
703
|
function ctxFor(s, turn, isLatest) {
|
|
@@ -689,7 +838,7 @@ function renderTurn(s, t, isLatest, fold) {
|
|
|
689
838
|
<div class="ask-text ${long ? 'clamped' : ''}">${esc(t.prompt)}</div>
|
|
690
839
|
${long ? '<button class="link xs more-btn">Show all</button>' : ''}
|
|
691
840
|
${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>
|
|
841
|
+
<div class="ask-foot">${modelBadge(t.agent || s.agent, t.model ?? s.model)}<time>${new Date(t.startedAt).toLocaleString([], { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' })}</time></div>
|
|
693
842
|
${fold ? `<button class="ghost xs t-unfold" title="Collapse this exchange">${icon('chevron', 'chev')}</button>` : ''}
|
|
694
843
|
</div>`);
|
|
695
844
|
if (fold) ask.querySelector('.t-unfold').onclick = fold;
|
|
@@ -856,11 +1005,13 @@ function renderDock(id) {
|
|
|
856
1005
|
const dock = $('.it-dock', n.body);
|
|
857
1006
|
const comp = el(`
|
|
858
1007
|
<div class="composer">
|
|
1008
|
+
<ul class="c-queued"></ul>
|
|
859
1009
|
<textarea class="reply" rows="1" spellcheck="false"></textarea>
|
|
860
1010
|
<div class="c-bar">
|
|
861
1011
|
<button class="icon-btn c-attach" title="Attach files">${icon('clip')}</button>
|
|
862
1012
|
<span class="c-hint"></span>
|
|
863
1013
|
<button class="ghost sm c-done" data-a="done" title="Mark done (E)">${icon('done')}<span>Mark done</span></button>
|
|
1014
|
+
<button class="ghost xs c-queue" hidden>${icon('queue')}<span>Queue</span></button>
|
|
864
1015
|
<button class="send" title="Send (Enter · Shift+Enter for newline)">${icon('enter')}</button>
|
|
865
1016
|
</div>
|
|
866
1017
|
</div>`);
|
|
@@ -872,6 +1023,7 @@ function renderDock(id) {
|
|
|
872
1023
|
acceptFiles(comp, ta, n.tray);
|
|
873
1024
|
$('.c-attach', comp).onclick = n.tray.pick;
|
|
874
1025
|
$('.c-done', comp).onclick = () => toggleDone(id);
|
|
1026
|
+
$('.c-queue', comp).onclick = toggleQueue;
|
|
875
1027
|
ta.value = S.drafts.get(id) || '';
|
|
876
1028
|
ta.addEventListener('input', () => S.drafts.set(id, ta.value));
|
|
877
1029
|
ta.addEventListener('focus', () => setFocus(id, { scroll: false }));
|
|
@@ -897,11 +1049,82 @@ function updateDock(id) {
|
|
|
897
1049
|
: s.ask
|
|
898
1050
|
? `Answer ${name}…`
|
|
899
1051
|
: s.state === 'running'
|
|
900
|
-
?
|
|
1052
|
+
? queueing()
|
|
1053
|
+
? `Queue a follow-up — sent when ${name} finishes`
|
|
1054
|
+
: `Steer ${name} — interrupts and sends now`
|
|
901
1055
|
: s.state === 'done'
|
|
902
1056
|
? 'Follow up to reopen…'
|
|
903
1057
|
: `Follow up with ${name}…`;
|
|
904
1058
|
$('.c-hint', n.card).innerHTML = s.queue?.length ? `${s.queue.length} queued` : '';
|
|
1059
|
+
renderQueued(s, $('.c-queued', n.card), name);
|
|
1060
|
+
const q = $('.c-queue', n.card);
|
|
1061
|
+
q.hidden = tour || s.state !== 'running';
|
|
1062
|
+
q.classList.toggle('on', queueing());
|
|
1063
|
+
q.title = queueing()
|
|
1064
|
+
? `Queueing: follow-ups wait until ${name} finishes. Click to send now instead.`
|
|
1065
|
+
: `Sending now: follow-ups interrupt ${name}. Click to queue them instead.`;
|
|
1066
|
+
}
|
|
1067
|
+
|
|
1068
|
+
// Follow-ups waiting their turn, shown above the composer so it's clear what goes out next.
|
|
1069
|
+
function renderQueued(s, list, name) {
|
|
1070
|
+
const key = (s.queue || []).map((q) => q.id).join();
|
|
1071
|
+
if (list.dataset.key === key) return;
|
|
1072
|
+
list.dataset.key = key;
|
|
1073
|
+
list.innerHTML = '';
|
|
1074
|
+
(s.queue || []).forEach((q, i) => {
|
|
1075
|
+
const li = el(`
|
|
1076
|
+
<li class="cq">
|
|
1077
|
+
<span class="cq-tag">${i === 0 ? `Next · after ${esc(name)} finishes` : `Queued #${i + 1}`}</span>
|
|
1078
|
+
<span class="cq-text">${esc(q.text)}</span>
|
|
1079
|
+
<button class="att-x" title="Cancel this follow-up">${icon('x')}</button>
|
|
1080
|
+
</li>`);
|
|
1081
|
+
const cancel = () => {
|
|
1082
|
+
// Items queued by a wisp older than queue ids get one when wisp restarts.
|
|
1083
|
+
if (!q.id) return toast('Restart wisp to cancel this follow-up.', { tone: 'bad' });
|
|
1084
|
+
const short = q.text.length > 80 ? `${q.text.slice(0, 80)}…` : q.text;
|
|
1085
|
+
if (!confirm(`Cancel this queued follow-up?\n\n“${short}”`)) return;
|
|
1086
|
+
api(`/sessions/${s.id}/queue?item=${encodeURIComponent(q.id)}`, { method: 'DELETE' }).catch((err) => toast(esc(err.message), { tone: 'bad' }));
|
|
1087
|
+
};
|
|
1088
|
+
$('.att-x', li).onclick = cancel;
|
|
1089
|
+
li.addEventListener('contextmenu', (e) => {
|
|
1090
|
+
e.preventDefault();
|
|
1091
|
+
e.stopPropagation();
|
|
1092
|
+
queuedMenu(e, cancel);
|
|
1093
|
+
});
|
|
1094
|
+
list.append(li);
|
|
1095
|
+
});
|
|
1096
|
+
}
|
|
1097
|
+
|
|
1098
|
+
// Right-click on a queued follow-up.
|
|
1099
|
+
function queuedMenu(e, cancel) {
|
|
1100
|
+
$('.menu.queued')?.close();
|
|
1101
|
+
const menu = el(`
|
|
1102
|
+
<div class="menu queued" style="top:${e.clientY}px;left:${e.clientX}px">
|
|
1103
|
+
<button data-a="cancel" class="danger">${icon('x')}Cancel follow-up</button>
|
|
1104
|
+
</div>`);
|
|
1105
|
+
const close = () => (menu.remove(), document.removeEventListener('mousedown', outside));
|
|
1106
|
+
const outside = (ev) => !menu.contains(ev.target) && close();
|
|
1107
|
+
menu.close = close;
|
|
1108
|
+
setTimeout(() => document.addEventListener('mousedown', outside));
|
|
1109
|
+
menu.onclick = (ev) => {
|
|
1110
|
+
const a = ev.target.closest('button')?.dataset.a;
|
|
1111
|
+
close();
|
|
1112
|
+
if (a === 'cancel') cancel();
|
|
1113
|
+
};
|
|
1114
|
+
document.body.append(menu);
|
|
1115
|
+
const r = menu.getBoundingClientRect();
|
|
1116
|
+
if (r.right > innerWidth - 8) menu.style.left = `${innerWidth - r.width - 8}px`;
|
|
1117
|
+
if (r.bottom > innerHeight - 8) menu.style.top = `${innerHeight - r.height - 8}px`;
|
|
1118
|
+
}
|
|
1119
|
+
|
|
1120
|
+
// Follow-ups sent while an agent works interrupt it, unless the user flipped this on.
|
|
1121
|
+
const queueing = () => S.config?.defaults?.followUp === 'queue';
|
|
1122
|
+
|
|
1123
|
+
async function toggleQueue() {
|
|
1124
|
+
const followUp = queueing() ? 'now' : 'queue';
|
|
1125
|
+
S.config.defaults.followUp = followUp;
|
|
1126
|
+
S.nodes.forEach((_, id) => updateDock(id));
|
|
1127
|
+
await api('/config', { method: 'PATCH', body: { defaults: { followUp } } }).catch((err) => toast(esc(err.message), { tone: 'bad' }));
|
|
905
1128
|
}
|
|
906
1129
|
|
|
907
1130
|
async function sendReply(id, text) {
|
|
@@ -914,12 +1137,13 @@ async function sendReply(id, text) {
|
|
|
914
1137
|
try {
|
|
915
1138
|
const r = s.ask && !attachments.length
|
|
916
1139
|
? await api(`/sessions/${id}/answer`, { method: 'POST', body: { text } })
|
|
917
|
-
: await api(`/sessions/${id}/reply`, { method: 'POST', body: { text, attachments } });
|
|
1140
|
+
: await api(`/sessions/${id}/reply`, { method: 'POST', body: { text, attachments, queue: queueing() } });
|
|
918
1141
|
const ta = n && $('.reply', n.card);
|
|
919
1142
|
if (ta) ((ta.value = ''), ta.dispatchEvent(new Event('input')));
|
|
920
1143
|
S.drafts.delete(id);
|
|
921
1144
|
n?.tray?.clear();
|
|
922
|
-
if (r.queued)
|
|
1145
|
+
if (r.queued)
|
|
1146
|
+
toast(r.ahead ? `Queued behind ${r.ahead} earlier follow-up${r.ahead > 1 ? 's' : ''} — they go out first, in order.` : 'Queued — it goes out as soon as the agent finishes.');
|
|
923
1147
|
} catch (err) {
|
|
924
1148
|
toast(esc(err.message), { tone: 'bad' });
|
|
925
1149
|
}
|
|
@@ -975,51 +1199,152 @@ function rename(id) {
|
|
|
975
1199
|
title.onblur = () => done(true);
|
|
976
1200
|
}
|
|
977
1201
|
|
|
978
|
-
|
|
979
|
-
|
|
1202
|
+
// One popover for both choices: harnesses on the left, the focused harness's models on the right.
|
|
1203
|
+
// Clicking (or arrowing to) a harness previews its models; picking a model commits both.
|
|
1204
|
+
function runnerPicker(anchor, { agent, model, savedModel }, pick) {
|
|
1205
|
+
const open = $('.picker');
|
|
980
1206
|
if (open) return open.close();
|
|
1207
|
+
let focusAgent = S.agents.find((a) => a.id === agent && a.available) ? agent : S.agents.find((a) => a.available)?.id;
|
|
1208
|
+
|
|
981
1209
|
const r = anchor.getBoundingClientRect();
|
|
982
|
-
const
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
1210
|
+
const width = Math.min(580, innerWidth - 24);
|
|
1211
|
+
// Stay inside the launcher card when there's room, else inside the window.
|
|
1212
|
+
const box = anchor.closest('.launcher')?.getBoundingClientRect();
|
|
1213
|
+
const right = Math.min(innerWidth - 12, box && box.width >= width ? box.right : innerWidth - 12);
|
|
1214
|
+
const left = Math.max(12, Math.min(r.left, right - width));
|
|
1215
|
+
const below = r.bottom + 440 < innerHeight;
|
|
1216
|
+
const node = el(`
|
|
1217
|
+
<div class="picker" role="dialog" aria-label="Harness and model" style="left:${left}px;width:${width}px;${below ? `top:${r.bottom + 6}px` : `bottom:${innerHeight - r.top + 6}px`}">
|
|
1218
|
+
<div class="pk-side" role="listbox" aria-label="Harness">
|
|
1219
|
+
<div class="pk-head">Harness</div>
|
|
1220
|
+
${S.agents
|
|
1221
|
+
.map(
|
|
1222
|
+
(a) => `<button class="pk-h${a.available ? '' : ' off'}" role="option" data-id="${esc(a.id)}" ${a.available ? '' : 'disabled'}>
|
|
1223
|
+
<span class="pk-logo">${harnessLogo(a.id)}</span>
|
|
1224
|
+
<span class="pk-text"><b>${esc(a.name)}</b><small>${esc(a.available ? `${(a.models || []).length > 1 ? `${a.models.length - 1} models` : 'Default model'}` : 'Not installed')}</small></span>
|
|
1225
|
+
${a.id === agent ? '<span class="pk-dot" title="In use"></span>' : ''}
|
|
1226
|
+
</button>`,
|
|
1227
|
+
)
|
|
1228
|
+
.join('')}
|
|
1229
|
+
</div>
|
|
1230
|
+
<div class="pk-main">
|
|
1231
|
+
<label class="pk-search">${icon('search')}<input spellcheck="false" autocomplete="off"></label>
|
|
1232
|
+
<div class="pk-list" role="listbox" aria-label="Model"></div>
|
|
1233
|
+
</div>
|
|
992
1234
|
</div>`);
|
|
993
|
-
const
|
|
1235
|
+
const input = $('.pk-search input', node);
|
|
1236
|
+
const list = $('.pk-list', node);
|
|
1237
|
+
const sideRows = $$('.pk-h', node);
|
|
1238
|
+
|
|
1239
|
+
const paintModels = () => {
|
|
1240
|
+
const a = S.agents.find((x) => x.id === focusAgent);
|
|
1241
|
+
sideRows.forEach((b) => b.classList.toggle('on', b.dataset.id === focusAgent));
|
|
1242
|
+
const models = a?.models || [{ id: '', label: 'Default' }];
|
|
1243
|
+
const current = focusAgent === agent ? model : savedModel(focusAgent);
|
|
1244
|
+
input.placeholder = `Search ${a?.name || ''} models`;
|
|
1245
|
+
$('.pk-search', node).hidden = models.length <= 6;
|
|
1246
|
+
// opencode-style ids (provider/model) group by provider.
|
|
1247
|
+
const groups = new Map();
|
|
1248
|
+
for (const m of models) {
|
|
1249
|
+
const g = m.id && m.id.includes('/') ? m.hint || '' : '';
|
|
1250
|
+
if (!groups.has(g)) groups.set(g, []);
|
|
1251
|
+
groups.get(g).push(m);
|
|
1252
|
+
}
|
|
1253
|
+
list.innerHTML = [...groups]
|
|
1254
|
+
.map(
|
|
1255
|
+
([g, ms]) =>
|
|
1256
|
+
`<div class="pk-group">${g ? `<div class="pk-ghead">${esc(g)}</div>` : ''}${ms
|
|
1257
|
+
.map(
|
|
1258
|
+
(m) => `<button class="pk-m${m.id === current ? ' on' : ''}" role="option" aria-selected="${m.id === current}" data-id="${esc(m.id)}">
|
|
1259
|
+
<span class="pk-text"><b>${esc(m.id ? m.label : 'Default')}</b><small>${esc(m.id ? (m.hint && m.hint !== g ? m.hint : '') : m.hint || `Whatever ${a?.name} is set to`)}</small></span>${icon('check', 'pk-check')}
|
|
1260
|
+
</button>`,
|
|
1261
|
+
)
|
|
1262
|
+
.join('')}</div>`,
|
|
1263
|
+
)
|
|
1264
|
+
.join('') + '<div class="pk-empty" hidden>No models match</div>';
|
|
1265
|
+
filter();
|
|
1266
|
+
};
|
|
1267
|
+
const rows = () => $$('.pk-m', list).filter((b) => !b.hidden);
|
|
1268
|
+
const filter = () => {
|
|
1269
|
+
const q = input.value.trim().toLowerCase();
|
|
1270
|
+
for (const b of $$('.pk-m', list)) b.hidden = !!q && !`${b.dataset.id} ${b.textContent}`.toLowerCase().includes(q);
|
|
1271
|
+
for (const g of $$('.pk-group', list)) g.hidden = !$$('.pk-m', g).some((b) => !b.hidden);
|
|
1272
|
+
$('.pk-empty', list).hidden = rows().length > 0;
|
|
1273
|
+
};
|
|
1274
|
+
const focusModels = () => {
|
|
1275
|
+
const rs = rows();
|
|
1276
|
+
const target = rs.find((b) => b.classList.contains('on')) || rs[0];
|
|
1277
|
+
target?.focus();
|
|
1278
|
+
target?.scrollIntoView({ block: 'nearest' });
|
|
1279
|
+
};
|
|
1280
|
+
const preview = (id, { focus } = {}) => {
|
|
1281
|
+
if (id !== focusAgent) {
|
|
1282
|
+
focusAgent = id;
|
|
1283
|
+
input.value = '';
|
|
1284
|
+
paintModels();
|
|
1285
|
+
}
|
|
1286
|
+
if (focus) sideRows.find((b) => b.dataset.id === id)?.focus();
|
|
1287
|
+
};
|
|
1288
|
+
|
|
994
1289
|
const close = () => {
|
|
995
|
-
|
|
1290
|
+
node.remove();
|
|
996
1291
|
anchor.classList.remove('open');
|
|
997
1292
|
document.removeEventListener('mousedown', outside);
|
|
998
1293
|
document.removeEventListener('keydown', keys, true);
|
|
999
1294
|
};
|
|
1000
|
-
|
|
1001
|
-
const
|
|
1002
|
-
|
|
1295
|
+
node.close = close;
|
|
1296
|
+
const choose = (m) => {
|
|
1297
|
+
close();
|
|
1298
|
+
anchor.focus();
|
|
1299
|
+
if (focusAgent !== agent || m !== model) pick(focusAgent, m);
|
|
1300
|
+
};
|
|
1301
|
+
const outside = (e) => !node.contains(e.target) && !anchor.contains(e.target) && close();
|
|
1302
|
+
const step = (items, d) => {
|
|
1303
|
+
if (!items.length) return;
|
|
1003
1304
|
const i = items.indexOf(document.activeElement);
|
|
1004
|
-
|
|
1005
|
-
|
|
1305
|
+
const next = items[i < 0 ? (d > 0 ? 0 : items.length - 1) : (i + d + items.length) % items.length];
|
|
1306
|
+
next.focus();
|
|
1307
|
+
next.scrollIntoView({ block: 'nearest' });
|
|
1308
|
+
return next;
|
|
1309
|
+
};
|
|
1310
|
+
const keys = (e) => {
|
|
1311
|
+
const inSide = sideRows.includes(document.activeElement);
|
|
1312
|
+
const live = sideRows.filter((b) => !b.disabled);
|
|
1313
|
+
if (e.key === 'Escape') return e.preventDefault(), e.stopPropagation(), close(), anchor.focus();
|
|
1314
|
+
if (e.key === 'Tab') return close();
|
|
1315
|
+
if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
|
|
1006
1316
|
e.preventDefault();
|
|
1007
1317
|
e.stopPropagation();
|
|
1008
|
-
|
|
1009
|
-
|
|
1318
|
+
const d = e.key === 'ArrowDown' ? 1 : -1;
|
|
1319
|
+
if (inSide) preview(step(live, d)?.dataset.id);
|
|
1320
|
+
else step(rows(), d);
|
|
1321
|
+
} else if (e.key === 'ArrowLeft' && document.activeElement !== input) {
|
|
1322
|
+
e.preventDefault();
|
|
1323
|
+
preview(focusAgent, { focus: true });
|
|
1324
|
+
} else if ((e.key === 'ArrowRight' || e.key === 'Enter') && inSide) {
|
|
1325
|
+
e.preventDefault();
|
|
1326
|
+
focusModels();
|
|
1327
|
+
} else if (e.key === 'Enter' && document.activeElement === input && rows()[0]) {
|
|
1328
|
+
e.preventDefault();
|
|
1329
|
+
choose(rows()[0].dataset.id);
|
|
1330
|
+
} else if (e.key.length === 1 && !e.metaKey && !e.ctrlKey && !$('.pk-search', node).hidden && document.activeElement !== input) {
|
|
1331
|
+
input.focus(); // type-to-search from anywhere in the picker
|
|
1332
|
+
}
|
|
1010
1333
|
};
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
anchor.focus();
|
|
1016
|
-
if (b.dataset.id !== current) pick(b.dataset.id);
|
|
1334
|
+
sideRows.forEach((b) => (b.onclick = () => preview(b.dataset.id)));
|
|
1335
|
+
list.onclick = (e) => {
|
|
1336
|
+
const b = e.target.closest('.pk-m');
|
|
1337
|
+
if (b) choose(b.dataset.id);
|
|
1017
1338
|
};
|
|
1339
|
+
input.oninput = filter;
|
|
1340
|
+
|
|
1341
|
+
paintModels();
|
|
1018
1342
|
setTimeout(() => document.addEventListener('mousedown', outside));
|
|
1019
1343
|
document.addEventListener('keydown', keys, true);
|
|
1020
|
-
document.body.append(
|
|
1344
|
+
document.body.append(node);
|
|
1021
1345
|
anchor.classList.add('open');
|
|
1022
|
-
(
|
|
1346
|
+
focusModels(); // scrolls the current model into view…
|
|
1347
|
+
if (!$('.pk-search', node).hidden) input.focus({ preventScroll: true }); // …then search is ready to type
|
|
1023
1348
|
}
|
|
1024
1349
|
|
|
1025
1350
|
function moreMenu(id, anchor) {
|
|
@@ -1028,10 +1353,11 @@ function moreMenu(id, anchor) {
|
|
|
1028
1353
|
const r = anchor.getBoundingClientRect();
|
|
1029
1354
|
const menu = el(`
|
|
1030
1355
|
<div class="menu" style="top:${r.bottom + 6}px;right:${innerWidth - r.right}px">
|
|
1031
|
-
<button data-a="rename">${icon('pen')}Rename</button>
|
|
1356
|
+
<button data-a="rename">${icon('pen')}Rename${keyHint('M')}</button>
|
|
1032
1357
|
${s.agentSessionId ? `<button data-a="copy">${icon('copy')}Copy resume command</button>` : ''}
|
|
1033
1358
|
<button data-a="folder">${icon('folder')}Open project in editor</button>
|
|
1034
|
-
|
|
1359
|
+
${s.archived ? `<button data-a="unarchive">${icon('archive')}Unarchive${keyHint('A')}</button>` : s.state !== 'running' && s.state !== 'waiting' ? `<button data-a="archive">${icon('archive')}Archive${keyHint('A')}</button>` : ''}
|
|
1360
|
+
<button data-a="delete" class="danger">${icon('trash')}Delete session${keyHint('⌫')}</button>
|
|
1035
1361
|
</div>`);
|
|
1036
1362
|
const close = () => (menu.remove(), document.removeEventListener('mousedown', outside));
|
|
1037
1363
|
const outside = (e) => !menu.contains(e.target) && close();
|
|
@@ -1046,11 +1372,65 @@ function moreMenu(id, anchor) {
|
|
|
1046
1372
|
toast('Copied resume command');
|
|
1047
1373
|
}
|
|
1048
1374
|
if (a === 'folder') openInEditor(s, '.');
|
|
1049
|
-
if (a === '
|
|
1050
|
-
|
|
1051
|
-
|
|
1375
|
+
if (a === 'archive' || a === 'unarchive') setArchived(id, a === 'archive');
|
|
1376
|
+
if (a === 'delete') deleteSession(id);
|
|
1377
|
+
};
|
|
1378
|
+
document.body.append(menu);
|
|
1379
|
+
}
|
|
1380
|
+
|
|
1381
|
+
// Right-click on a feed item.
|
|
1382
|
+
function taskMenu(id, e) {
|
|
1383
|
+
const s = S.sessions.get(id);
|
|
1384
|
+
if (!s) return;
|
|
1385
|
+
$('.menu.task')?.close();
|
|
1386
|
+
const busy = s.state === 'running' || s.state === 'waiting';
|
|
1387
|
+
const menu = el(`
|
|
1388
|
+
<div class="menu task" style="top:${e.clientY}px;left:${e.clientX}px">
|
|
1389
|
+
${S.open.has(id) ? `<button data-a="collapse">${icon('chevron')}Collapse${keyHint('O')}</button>` : `<button data-a="open">${icon('chevron')}Open${keyHint('O')}</button>`}
|
|
1390
|
+
<button data-a="rename">${icon('pen')}Rename${keyHint('M')}</button>
|
|
1391
|
+
${busy || s.archived ? '' : `<button data-a="done">${icon('done')}${s.state === 'done' ? 'Reopen' : 'Mark done'}${keyHint('E')}</button>`}
|
|
1392
|
+
${s.archived ? `<button data-a="unarchive">${icon('archive')}Unarchive${keyHint('A')}</button>` : busy ? '' : `<button data-a="archive">${icon('archive')}Archive${keyHint('A')}</button>`}
|
|
1393
|
+
<button data-a="delete" class="danger">${icon('trash')}Delete session${keyHint('⌫')}</button>
|
|
1394
|
+
</div>`);
|
|
1395
|
+
const close = () => (menu.remove(), document.removeEventListener('mousedown', outside));
|
|
1396
|
+
const outside = (ev) => !menu.contains(ev.target) && close();
|
|
1397
|
+
menu.close = close;
|
|
1398
|
+
setTimeout(() => document.addEventListener('mousedown', outside));
|
|
1399
|
+
menu.onclick = async (ev) => {
|
|
1400
|
+
const a = ev.target.closest('button')?.dataset.a;
|
|
1401
|
+
close();
|
|
1402
|
+
if (a === 'open') expand(id);
|
|
1403
|
+
if (a === 'collapse') collapse(id);
|
|
1404
|
+
if (a === 'rename') rename(id);
|
|
1405
|
+
if (a === 'done') toggleDone(id);
|
|
1406
|
+
if (a === 'archive' || a === 'unarchive') setArchived(id, a === 'archive');
|
|
1407
|
+
if (a === 'delete') deleteSession(id);
|
|
1052
1408
|
};
|
|
1053
1409
|
document.body.append(menu);
|
|
1410
|
+
const r = menu.getBoundingClientRect();
|
|
1411
|
+
if (r.right > innerWidth - 8) menu.style.left = `${innerWidth - r.width - 8}px`;
|
|
1412
|
+
if (r.bottom > innerHeight - 8) menu.style.top = `${innerHeight - r.height - 8}px`;
|
|
1413
|
+
}
|
|
1414
|
+
|
|
1415
|
+
// The keyboard shortcut for a menu item, shown at its right edge. Menus act on the
|
|
1416
|
+
// focused card, so the key does the same thing as the click.
|
|
1417
|
+
const keyHint = (k) => `<kbd class="menu-kbd">${k}</kbd>`;
|
|
1418
|
+
|
|
1419
|
+
async function deleteSession(id) {
|
|
1420
|
+
if (!confirm('Delete this session from wisp? (The agent’s own history is kept.)')) return;
|
|
1421
|
+
await api(`/sessions/${id}`, { method: 'DELETE' }).catch((err) => toast(esc(err.message), { tone: 'bad' }));
|
|
1422
|
+
}
|
|
1423
|
+
|
|
1424
|
+
async function setArchived(id, archived) {
|
|
1425
|
+
try {
|
|
1426
|
+
await api(`/sessions/${id}`, { method: 'PATCH', body: { archived } });
|
|
1427
|
+
} catch (err) {
|
|
1428
|
+
return toast(esc(err.message), { tone: 'bad' });
|
|
1429
|
+
}
|
|
1430
|
+
if (archived) {
|
|
1431
|
+
if (S.open.has(id)) collapse(id);
|
|
1432
|
+
toast('Archived', { action: { label: 'Undo', run: () => setArchived(id, false) } });
|
|
1433
|
+
}
|
|
1054
1434
|
}
|
|
1055
1435
|
|
|
1056
1436
|
function replay(id) {
|
|
@@ -1084,7 +1464,8 @@ function openInEditor(s, p, line) {
|
|
|
1084
1464
|
function reveal(id) {
|
|
1085
1465
|
const s = S.sessions.get(id);
|
|
1086
1466
|
if (!s) return;
|
|
1087
|
-
if (s.
|
|
1467
|
+
if (s.archived) S.showArchived = true;
|
|
1468
|
+
else if (s.state === 'done' && !S.showDone) {
|
|
1088
1469
|
S.showDone = true;
|
|
1089
1470
|
save('wisp.showDone', true);
|
|
1090
1471
|
}
|
|
@@ -1248,10 +1629,10 @@ function openSettings() {
|
|
|
1248
1629
|
<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
1630
|
<label class="custom-term" ${d.terminal === 'custom' ? '' : 'hidden'}><span>Terminal command</span>
|
|
1250
1631
|
<input name="terminalCommand" value="${esc(d.terminalCommand || '')}" placeholder="alacritty --working-directory {cwd} -e {cmd}" spellcheck="false"></label>
|
|
1251
|
-
<
|
|
1632
|
+
<div class="setting"><span>Desktop notifications</span><span class="notif-hint muted xs"></span><button class="switch" id="notif" role="switch"></button></div>
|
|
1252
1633
|
<div class="agents">${S.agents
|
|
1253
1634
|
.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">
|
|
1635
|
+
.join('')}<p class="muted xs">Pick the agent next to the model in the launcher. Others plug in via <code>server/adapters</code>.</p></div>
|
|
1255
1636
|
</div>`);
|
|
1256
1637
|
const close = overlay(node);
|
|
1257
1638
|
$('.close', node).onclick = close;
|
|
@@ -1262,35 +1643,152 @@ function openSettings() {
|
|
|
1262
1643
|
await api('/config', { method: 'PATCH', body: { defaults: { [name]: value } } });
|
|
1263
1644
|
await loadConfig();
|
|
1264
1645
|
});
|
|
1265
|
-
|
|
1266
|
-
|
|
1267
|
-
|
|
1268
|
-
|
|
1646
|
+
// The switch shows wisp's own on/off preference; the browser permission can only be
|
|
1647
|
+
// granted from here, never revoked, so it surfaces as a hint when it blocks the switch.
|
|
1648
|
+
const sw = $('#notif', node);
|
|
1649
|
+
const paint = () => {
|
|
1650
|
+
const perm = 'Notification' in window ? Notification.permission : 'unsupported';
|
|
1651
|
+
const on = notifyOn();
|
|
1652
|
+
sw.setAttribute('aria-checked', on);
|
|
1653
|
+
sw.disabled = perm === 'denied' || perm === 'unsupported';
|
|
1654
|
+
$('.notif-hint', node).textContent =
|
|
1655
|
+
perm === 'denied' ? 'Blocked — allow notifications for this site in your browser' : perm === 'unsupported' ? 'Not supported here' : on ? 'On' : 'Off';
|
|
1656
|
+
};
|
|
1657
|
+
sw.onclick = async () => {
|
|
1658
|
+
let want = !notifyOn();
|
|
1659
|
+
if (want && Notification.permission !== 'granted') want = (await Notification.requestPermission()) === 'granted';
|
|
1660
|
+
await api('/config', { method: 'PATCH', body: { defaults: { notify: want ? 'on' : 'off' } } });
|
|
1661
|
+
await loadConfig();
|
|
1662
|
+
paint();
|
|
1269
1663
|
};
|
|
1664
|
+
paint();
|
|
1270
1665
|
}
|
|
1271
1666
|
|
|
1272
1667
|
function shortcuts() {
|
|
1273
1668
|
const rows = [
|
|
1274
|
-
['
|
|
1669
|
+
[combo('K'), 'Search & commands'],
|
|
1670
|
+
[`N · ${combo('L')}`, 'New wisp (focus the launcher)'],
|
|
1275
1671
|
['J / K', 'Next / previous item'],
|
|
1276
1672
|
['O / ↵', 'Expand / collapse item'],
|
|
1277
1673
|
['/', 'Follow up on item'],
|
|
1278
1674
|
['E', 'Mark done & go to next'],
|
|
1675
|
+
['A', 'Archive / unarchive'],
|
|
1676
|
+
['M', 'Rename item'],
|
|
1677
|
+
['⌫', 'Delete item'],
|
|
1279
1678
|
['T', 'Take over in terminal'],
|
|
1280
1679
|
['R', 'Replay presentation'],
|
|
1281
1680
|
['1–9', 'Pick an answer when asked'],
|
|
1282
1681
|
['↵', 'Launch / send'],
|
|
1283
1682
|
['⇧↵', 'New line'],
|
|
1683
|
+
['C', 'Collapse all'],
|
|
1284
1684
|
['Esc', 'Close / collapse'],
|
|
1285
1685
|
];
|
|
1286
1686
|
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
1687
|
overlay(node);
|
|
1288
1688
|
}
|
|
1289
1689
|
|
|
1690
|
+
// --- command palette ------------------------------------------------------------------
|
|
1691
|
+
|
|
1692
|
+
const IS_MAC = /Mac|iPhone|iPad/.test(navigator.platform);
|
|
1693
|
+
// How a modifier shortcut reads on this OS: "⌘L" on macOS, "Ctrl+L" on Windows/Linux.
|
|
1694
|
+
const combo = (k) => (IS_MAC ? `⌘${k}` : `Ctrl+${k}`);
|
|
1695
|
+
// ⌘ on macOS, Ctrl elsewhere (AltGr on Windows reports Ctrl+Alt, so Alt is excluded). Falls back to
|
|
1696
|
+
// the physical key on non-Latin layouts, where e.key is e.g. "д" for the L key.
|
|
1697
|
+
const modKey = (e, k) =>
|
|
1698
|
+
(e.metaKey || e.ctrlKey) && !e.altKey && !e.shiftKey &&
|
|
1699
|
+
(e.key.toLowerCase() === k || (!/^[a-z]$/i.test(e.key) && e.code === `Key${k.toUpperCase()}`));
|
|
1700
|
+
const STATE_LABEL = { running: 'Working', waiting: 'Asking', review: 'Review', done: 'Done' };
|
|
1701
|
+
|
|
1702
|
+
function focusLauncher() {
|
|
1703
|
+
stage.scrollTo({ top: 0, behavior: 'smooth' });
|
|
1704
|
+
$('#prompt')?.focus({ preventScroll: true });
|
|
1705
|
+
}
|
|
1706
|
+
|
|
1707
|
+
// Run something on a session's card, expanding it first if the action needs the body.
|
|
1708
|
+
function withCard(id, fn, { open = false } = {}) {
|
|
1709
|
+
reveal(id);
|
|
1710
|
+
if (!open || S.open.has(id)) return setTimeout(fn, 50);
|
|
1711
|
+
expand(id).then(fn);
|
|
1712
|
+
}
|
|
1713
|
+
|
|
1714
|
+
function paletteItems() {
|
|
1715
|
+
const items = [];
|
|
1716
|
+
const cur = S.focus && S.sessions.get(S.focus);
|
|
1717
|
+
if (cur) {
|
|
1718
|
+
const id = cur.id;
|
|
1719
|
+
const live = cur.state === 'running' || cur.state === 'waiting';
|
|
1720
|
+
const g = 'This wisp';
|
|
1721
|
+
const sub = cur.title;
|
|
1722
|
+
items.push({ group: g, label: 'Follow up', sub, icon: 'send', kbd: '/', run: () => withCard(id, () => $('.reply', S.nodes.get(id)?.card)?.focus(), { open: true }) });
|
|
1723
|
+
if (!live) items.push({ group: g, label: cur.state === 'done' ? 'Move back to review' : 'Mark done', sub, icon: 'done', kbd: 'E', run: () => toggleDone(id) });
|
|
1724
|
+
if (cur.archived || !live) items.push({ group: g, label: cur.archived ? 'Unarchive' : 'Archive', sub, icon: 'archive', kbd: 'A', run: () => setArchived(id, !cur.archived) });
|
|
1725
|
+
items.push({ group: g, label: 'Take over in terminal', sub, icon: 'terminal', kbd: 'T', run: () => takeOver(id) });
|
|
1726
|
+
items.push({ group: g, label: 'Replay presentation', sub, icon: 'replay', kbd: 'R', run: () => withCard(id, () => replay(id), { open: true }) });
|
|
1727
|
+
items.push({ group: g, label: 'Rename', sub, icon: 'pen', kbd: 'M', run: () => withCard(id, () => rename(id)) });
|
|
1728
|
+
items.push({ group: g, label: 'Delete session', sub, icon: 'trash', kbd: '⌫', keywords: 'remove', run: () => deleteSession(id) });
|
|
1729
|
+
}
|
|
1730
|
+
|
|
1731
|
+
const sessions = [...S.sessions.values()].sort((a, b) => a.archived - b.archived || b.updatedAt - a.updatedAt);
|
|
1732
|
+
for (const s of sessions) {
|
|
1733
|
+
const state = s.archived ? 'Archived' : s.state === 'review' ? STATUS_LABEL[s.status] || 'Review' : STATE_LABEL[s.state] || s.state;
|
|
1734
|
+
items.push({
|
|
1735
|
+
group: 'Wisps',
|
|
1736
|
+
label: s.title,
|
|
1737
|
+
sub: `${s.project} · ${state} · ${ago(s.updatedAt)}`,
|
|
1738
|
+
dot: s.color,
|
|
1739
|
+
keywords: [s.preview, s.live, s.question, agentName(s.agent)].filter(Boolean).join(' '),
|
|
1740
|
+
// Live and unread work floats up; archived sinks.
|
|
1741
|
+
rank: (s.state === 'waiting' ? 3 : 0) + (s.unread ? 2 : 0) + (s.state === 'running' ? 1 : 0) - (s.archived ? 4 : 0),
|
|
1742
|
+
run: () => reveal(s.id),
|
|
1743
|
+
});
|
|
1744
|
+
}
|
|
1745
|
+
|
|
1746
|
+
const a = 'Actions';
|
|
1747
|
+
items.push(
|
|
1748
|
+
{ group: a, label: 'New wisp', icon: 'plus', kbd: 'N', keywords: 'launch create prompt task', run: focusLauncher },
|
|
1749
|
+
{ group: a, label: S.showDone ? 'Hide done' : 'Show done', icon: 'done', keywords: 'toggle finished', run: () => $('#secDone .toggle-done').click() },
|
|
1750
|
+
{ group: a, label: S.showArchived ? 'Hide archived' : 'Show archived', icon: 'archive', keywords: 'toggle', run: () => $('#secArchived .toggle-done').click() },
|
|
1751
|
+
);
|
|
1752
|
+
if (S.open.size) items.push({ group: a, label: 'Collapse all', icon: 'minus', kbd: 'C', run: collapseAll });
|
|
1753
|
+
items.push(
|
|
1754
|
+
{ group: a, label: 'Add project…', icon: 'folder', keywords: 'folder repo directory', run: () => openProjects({ focusAdd: true }) },
|
|
1755
|
+
{ group: a, label: 'Manage projects', icon: 'folder', keywords: 'rename color remove', run: () => openProjects() },
|
|
1756
|
+
{ group: a, label: 'Settings', icon: 'settings', keywords: 'preferences editor terminal notifications', run: openSettings },
|
|
1757
|
+
{ group: a, label: 'Keyboard shortcuts', icon: 'info', kbd: '?', keywords: 'keys help hotkeys', run: shortcuts },
|
|
1758
|
+
);
|
|
1759
|
+
|
|
1760
|
+
const filter = (id) => () => {
|
|
1761
|
+
S.filter = id;
|
|
1762
|
+
save('wisp.filter', id);
|
|
1763
|
+
renderProjects();
|
|
1764
|
+
renderFeed();
|
|
1765
|
+
renderLaunchChips();
|
|
1766
|
+
};
|
|
1767
|
+
items.push({ group: 'Projects', label: 'All projects', sub: S.filter === 'all' ? 'Current filter' : '', dot: 'slate', keywords: 'filter clear', run: filter('all') });
|
|
1768
|
+
for (const p of S.config.projects.slice().sort((x, y) => (y.lastUsedAt || y.addedAt) - (x.lastUsedAt || x.addedAt))) {
|
|
1769
|
+
items.push({ group: 'Projects', label: p.name, sub: S.filter === p.id ? 'Current filter' : p.path, dot: p.color, keywords: `filter ${p.path}`, run: filter(p.id) });
|
|
1770
|
+
}
|
|
1771
|
+
return items;
|
|
1772
|
+
}
|
|
1773
|
+
|
|
1774
|
+
let paletteOpen = false;
|
|
1775
|
+
function openPalette() {
|
|
1776
|
+
if (paletteOpen) return closeOverlay();
|
|
1777
|
+
// Don't stack over another modal (settings, projects…); the palette replaces nothing.
|
|
1778
|
+
if (!$('#overlay').hidden) return;
|
|
1779
|
+
paletteOpen = true;
|
|
1780
|
+
palette({ items: paletteItems(), overlay: (node) => overlay(node, { onClose: () => (paletteOpen = false) }) });
|
|
1781
|
+
}
|
|
1782
|
+
|
|
1290
1783
|
// --- notifications -----------------------------------------------------------------------
|
|
1291
1784
|
|
|
1785
|
+
// On unless turned off in settings, and only if the browser lets us.
|
|
1786
|
+
function notifyOn() {
|
|
1787
|
+
return S.config.defaults.notify !== 'off' && 'Notification' in window && Notification.permission === 'granted';
|
|
1788
|
+
}
|
|
1789
|
+
|
|
1292
1790
|
function askNotify() {
|
|
1293
|
-
if ('Notification' in window && Notification.permission === 'default') Notification.requestPermission();
|
|
1791
|
+
if (S.config.defaults.notify !== 'off' && 'Notification' in window && Notification.permission === 'default') Notification.requestPermission();
|
|
1294
1792
|
}
|
|
1295
1793
|
|
|
1296
1794
|
function notifyFinished(s) {
|
|
@@ -1299,7 +1797,7 @@ function notifyFinished(s) {
|
|
|
1299
1797
|
if (viewing) return;
|
|
1300
1798
|
const verb = s.state === 'waiting' ? 'has a question' : 'is ready';
|
|
1301
1799
|
toast(`${icon(s.state === 'waiting' ? 'ask' : 'spark')} <b>${esc(s.title)}</b> ${verb}`, { action: { label: 'Open', run: () => reveal(s.id) }, timeout: 7000 });
|
|
1302
|
-
if (
|
|
1800
|
+
if (notifyOn() && !document.hasFocus()) {
|
|
1303
1801
|
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
1802
|
note.onclick = () => (window.focus(), reveal(s.id));
|
|
1305
1803
|
}
|
|
@@ -1313,6 +1811,10 @@ function connect() {
|
|
|
1313
1811
|
const s = JSON.parse(e.data);
|
|
1314
1812
|
const prev = S.sessions.get(s.id);
|
|
1315
1813
|
S.sessions.set(s.id, s);
|
|
1814
|
+
// A card left open while it worked shouldn't land in "Needs you" expanded —
|
|
1815
|
+
// fold it unless the user is actually watching it.
|
|
1816
|
+
const n = S.nodes.get(s.id);
|
|
1817
|
+
if (prev?.state === 'running' && s.state !== 'running' && S.open.has(s.id) && !(n && isInView(n.card) && document.hasFocus())) collapse(s.id);
|
|
1316
1818
|
renderFeed();
|
|
1317
1819
|
if (prev?.state !== s.state || prev?.queued !== s.queued) scheduleRefetch(s.id);
|
|
1318
1820
|
});
|
|
@@ -1346,19 +1848,22 @@ async function loadConfig() {
|
|
|
1346
1848
|
renderLaunchChips();
|
|
1347
1849
|
}
|
|
1348
1850
|
|
|
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
1851
|
// --- keyboard ---------------------------------------------------------------------------
|
|
1358
1852
|
|
|
1359
1853
|
const feedIds = () => $$('#feed .item').map((x) => x.dataset.id);
|
|
1360
1854
|
|
|
1361
1855
|
document.addEventListener('keydown', (e) => {
|
|
1856
|
+
// ⌘K / Ctrl+K works from anywhere, even mid-sentence in the launcher.
|
|
1857
|
+
if (modKey(e, 'k')) {
|
|
1858
|
+
e.preventDefault();
|
|
1859
|
+
return openPalette();
|
|
1860
|
+
}
|
|
1861
|
+
// ⌘L / Ctrl+L jumps to the launcher from anywhere, including another composer.
|
|
1862
|
+
if (modKey(e, 'l')) {
|
|
1863
|
+
e.preventDefault();
|
|
1864
|
+
if (!$('#overlay').hidden) closeOverlay();
|
|
1865
|
+
return focusLauncher();
|
|
1866
|
+
}
|
|
1362
1867
|
const typing = e.target.closest('input, textarea, select, [contenteditable="true"]');
|
|
1363
1868
|
if (e.key === 'Escape') {
|
|
1364
1869
|
if (!$('#overlay').hidden) return closeOverlay();
|
|
@@ -1371,8 +1876,7 @@ document.addEventListener('keydown', (e) => {
|
|
|
1371
1876
|
const id = S.focus && S.nodes.has(S.focus) ? S.focus : null;
|
|
1372
1877
|
if (k === 'n') {
|
|
1373
1878
|
e.preventDefault();
|
|
1374
|
-
|
|
1375
|
-
$('#prompt')?.focus({ preventScroll: true });
|
|
1879
|
+
focusLauncher();
|
|
1376
1880
|
} else if (k === 'j' || k === 'k') {
|
|
1377
1881
|
const ids = feedIds();
|
|
1378
1882
|
if (!ids.length) return;
|
|
@@ -1383,7 +1887,18 @@ document.addEventListener('keydown', (e) => {
|
|
|
1383
1887
|
e.preventDefault();
|
|
1384
1888
|
toggle(id);
|
|
1385
1889
|
} else if (k === 'e' && id) toggleDone(id);
|
|
1386
|
-
else if (k === '
|
|
1890
|
+
else if (k === 'a' && id) {
|
|
1891
|
+
const s = S.sessions.get(id);
|
|
1892
|
+
if (s && (s.archived || (s.state !== 'running' && s.state !== 'waiting'))) setArchived(id, !s.archived);
|
|
1893
|
+
}
|
|
1894
|
+
else if (k === 'c' && S.open.size) collapseAll();
|
|
1895
|
+
else if (k === 'm' && id) {
|
|
1896
|
+
e.preventDefault();
|
|
1897
|
+
rename(id);
|
|
1898
|
+
} else if ((k === 'delete' || k === 'backspace') && id) {
|
|
1899
|
+
e.preventDefault();
|
|
1900
|
+
deleteSession(id);
|
|
1901
|
+
} else if (k === 't' && id) takeOver(id);
|
|
1387
1902
|
else if (k === 'r' && id) replay(id);
|
|
1388
1903
|
else if (k === '/' && id) {
|
|
1389
1904
|
e.preventDefault();
|
|
@@ -1399,11 +1914,18 @@ document.addEventListener('keydown', (e) => {
|
|
|
1399
1914
|
});
|
|
1400
1915
|
|
|
1401
1916
|
$('#settingsBtn').onclick = openSettings;
|
|
1917
|
+
$('#searchBtn').onclick = openPalette;
|
|
1918
|
+
$('#searchBtn').title = `Search (${combo('K')})`;
|
|
1919
|
+
$('#searchBtn .mod-k').textContent = combo('K');
|
|
1402
1920
|
$('#secDone .toggle-done').onclick = () => {
|
|
1403
1921
|
S.showDone = !S.showDone;
|
|
1404
1922
|
save('wisp.showDone', S.showDone);
|
|
1405
1923
|
renderFeed();
|
|
1406
1924
|
};
|
|
1925
|
+
$('#secArchived .toggle-done').onclick = () => {
|
|
1926
|
+
S.showArchived = !S.showArchived;
|
|
1927
|
+
renderFeed();
|
|
1928
|
+
};
|
|
1407
1929
|
|
|
1408
1930
|
// elapsed timers + "ago" stamps
|
|
1409
1931
|
setInterval(() => {
|
|
@@ -1444,7 +1966,6 @@ window.addEventListener('focus', () => {
|
|
|
1444
1966
|
|
|
1445
1967
|
(async () => {
|
|
1446
1968
|
[S.agents] = await Promise.all([api('/agents').catch(() => []), loadConfig(), loadSessions()]);
|
|
1447
|
-
renderAgentStatus();
|
|
1448
1969
|
renderLauncher();
|
|
1449
1970
|
connect();
|
|
1450
1971
|
route();
|