wisp-agents 0.2.2 → 0.4.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 +45 -1
- package/README.md +41 -13
- package/bin/wisp.js +8 -3
- package/package.json +3 -1
- package/server/adapters/claude.js +14 -32
- package/server/adapters/codex.js +190 -0
- package/server/adapters/common.js +175 -0
- package/server/adapters/cursor.js +156 -0
- package/server/adapters/index.js +16 -9
- package/server/adapters/opencode.js +197 -0
- package/server/index.js +27 -12
- package/server/proc.js +127 -0
- package/server/protocol.js +13 -4
- package/server/runner.js +88 -19
- package/server/store.js +38 -1
- package/server/terminal.js +110 -41
- package/web/css/app.css +173 -17
- package/web/index.html +6 -1
- package/web/js/app.js +691 -99
- 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,42 @@ 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
|
+
// Reasoning levels the harness accepts for a model: the model's own list (opencode variants),
|
|
77
|
+
// else the harness-wide one. Empty means the harness has no knob for it.
|
|
78
|
+
function effortsFor(agent, model) {
|
|
79
|
+
const a = S.agents.find((x) => x.id === agent);
|
|
80
|
+
return a?.models?.find((m) => m.id === model)?.efforts ?? a?.efforts ?? [];
|
|
81
|
+
}
|
|
82
|
+
const effortLabel = (e) => ({ xhigh: 'X-High' })[e] || e.charAt(0).toUpperCase() + e.slice(1);
|
|
83
|
+
|
|
84
|
+
function modelBadge(agent, model, effort) {
|
|
85
|
+
if (agent === 'wisp') return '';
|
|
86
|
+
const label = (model && S.agents.find((a) => a.id === agent)?.models?.find((x) => x.id === model)?.label) || model || 'Default model';
|
|
87
|
+
const tip = `${agentName(agent)} · ${label}${effort ? ` · ${effortLabel(effort)} thinking` : ''}`;
|
|
88
|
+
const logo = harnessLogo(agent);
|
|
89
|
+
return `<span class="model-logo" data-tip="${esc(tip)}" aria-label="${esc(tip)}" tabindex="0">${logo}</span>`;
|
|
90
|
+
}
|
|
59
91
|
const projectById = (id) => S.config.projects.find((p) => p.id === id);
|
|
60
92
|
|
|
61
93
|
function ago(t) {
|
|
@@ -84,7 +116,8 @@ function overlay(node, { onClose } = {}) {
|
|
|
84
116
|
requestAnimationFrame(() => o.classList.add('on'));
|
|
85
117
|
const close = () => {
|
|
86
118
|
o.classList.remove('on');
|
|
87
|
-
|
|
119
|
+
// Another overlay may have replaced this one while it faded (palette → settings): leave that alone.
|
|
120
|
+
setTimeout(() => o.firstChild === node && ((o.hidden = true), (o.innerHTML = '')), 200);
|
|
88
121
|
onClose?.();
|
|
89
122
|
};
|
|
90
123
|
o.onclick = (e) => e.target === o && close();
|
|
@@ -100,18 +133,20 @@ function visible(list) {
|
|
|
100
133
|
}
|
|
101
134
|
|
|
102
135
|
function groups() {
|
|
103
|
-
const
|
|
136
|
+
const every = visible([...S.sessions.values()]);
|
|
137
|
+
const all = every.filter((s) => !s.archived);
|
|
104
138
|
const needs = all
|
|
105
139
|
.filter((s) => s.state === 'review' || s.state === 'waiting')
|
|
106
140
|
.sort((a, b) => (b.state === 'waiting') - (a.state === 'waiting') || b.updatedAt - a.updatedAt);
|
|
107
141
|
// Working items keep launch order so the feed doesn't reshuffle on every tool call.
|
|
108
142
|
const working = all.filter((s) => s.state === 'running').sort((a, b) => b.createdAt - a.createdAt);
|
|
109
143
|
const done = all.filter((s) => s.state === 'done').sort((a, b) => b.updatedAt - a.updatedAt);
|
|
110
|
-
|
|
144
|
+
const archived = every.filter((s) => s.archived).sort((a, b) => b.updatedAt - a.updatedAt);
|
|
145
|
+
return { needs, working, done, archived };
|
|
111
146
|
}
|
|
112
147
|
|
|
113
148
|
function renderFeed() {
|
|
114
|
-
const { needs, working, done } = groups();
|
|
149
|
+
const { needs, working, done, archived } = groups();
|
|
115
150
|
const place = (sec, list, show = true) => {
|
|
116
151
|
const box = $('.f-list', sec);
|
|
117
152
|
sec.hidden = !list.length;
|
|
@@ -129,12 +164,14 @@ function renderFeed() {
|
|
|
129
164
|
place($('#secWorking'), working);
|
|
130
165
|
place($('#secDone'), done.slice(0, 60), S.showDone);
|
|
131
166
|
$('#secDone').classList.toggle('collapsed', !S.showDone);
|
|
167
|
+
place($('#secArchived'), archived.slice(0, 60), S.showArchived);
|
|
168
|
+
$('#secArchived').classList.toggle('collapsed', !S.showArchived);
|
|
132
169
|
|
|
133
170
|
// 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));
|
|
171
|
+
const shown = new Set([...needs, ...working, ...done, ...archived].map((s) => s.id));
|
|
135
172
|
for (const [id, n] of S.nodes) if (!shown.has(id)) (n.card.remove(), S.nodes.delete(id), S.full.delete(id));
|
|
136
173
|
|
|
137
|
-
const empty = !needs.length && !working.length && !done.length;
|
|
174
|
+
const empty = !needs.length && !working.length && !done.length && !archived.length;
|
|
138
175
|
$('#feedEmpty').hidden = !empty;
|
|
139
176
|
if (empty)
|
|
140
177
|
$('#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 +194,14 @@ function renderFeedHead(needs, working) {
|
|
|
157
194
|
|
|
158
195
|
// "Collapse all" floats over the page instead of living in the flow: showing it
|
|
159
196
|
// 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
|
|
197
|
+
const collapseAllBtn = el(`<button class="collapse-all" title="Collapse all (C) · Esc collapses the focused one"><span></span><kbd>C</kbd></button>`);
|
|
161
198
|
document.body.append(collapseAllBtn);
|
|
162
|
-
|
|
199
|
+
const collapseAll = () => [...S.open].forEach((id) => collapse(id));
|
|
200
|
+
collapseAllBtn.addEventListener('click', collapseAll);
|
|
163
201
|
function placeCollapseAll() {
|
|
164
202
|
const open = [...S.open].filter((id) => S.nodes.get(id)?.card.isConnected).length;
|
|
165
203
|
collapseAllBtn.classList.toggle('on', open > 0);
|
|
166
|
-
if (open) collapseAllBtn.textContent = open > 1 ? `Collapse all · ${open}` : 'Collapse';
|
|
204
|
+
if (open) collapseAllBtn.firstChild.textContent = open > 1 ? `Collapse all · ${open}` : 'Collapse';
|
|
167
205
|
}
|
|
168
206
|
|
|
169
207
|
// Get (or create) the card for a summary and refresh its header.
|
|
@@ -180,6 +218,11 @@ function item(s) {
|
|
|
180
218
|
toggle(s.id);
|
|
181
219
|
});
|
|
182
220
|
card.addEventListener('mousedown', () => setFocus(s.id, { scroll: false }));
|
|
221
|
+
n.head.addEventListener('contextmenu', (e) => {
|
|
222
|
+
e.preventDefault();
|
|
223
|
+
setFocus(s.id, { scroll: false });
|
|
224
|
+
taskMenu(s.id, e);
|
|
225
|
+
});
|
|
183
226
|
if (S.open.has(s.id)) expand(s.id, { scroll: false });
|
|
184
227
|
}
|
|
185
228
|
renderHead(n, s);
|
|
@@ -206,9 +249,10 @@ function renderHead(n, s) {
|
|
|
206
249
|
<span class="it-side">
|
|
207
250
|
${pill}
|
|
208
251
|
${s.queued ? `<span class="q-queued" title="Follow-ups queued">+${s.queued}</span>` : ''}
|
|
252
|
+
${modelBadge(s.agent, s.model, s.effort)}
|
|
209
253
|
<span class="q-proj">${esc(s.project)}</span>
|
|
210
254
|
<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>` : ''}
|
|
255
|
+
${s.state === 'review' && !open && !s.archived ? `<button class="it-quick" data-a="quick-done" title="Mark done (E)">${icon('done')}</button>` : ''}
|
|
212
256
|
${icon('chevron', 'chev')}
|
|
213
257
|
</span>`;
|
|
214
258
|
}
|
|
@@ -392,14 +436,16 @@ let launchProject = load('wisp.launchProject', null);
|
|
|
392
436
|
function renderLauncher() {
|
|
393
437
|
const box = $('#launcher');
|
|
394
438
|
const mode = S.config.defaults.mode || 'edits';
|
|
395
|
-
const
|
|
439
|
+
const ready = S.agents.filter((a) => a.available);
|
|
440
|
+
const chosen = S.agents.find((a) => a.id === (S.config.defaults.agent || 'claude'));
|
|
441
|
+
let agent = chosen?.available ? chosen : ready[0] || chosen || S.agents[0];
|
|
396
442
|
box.innerHTML = `
|
|
397
443
|
<div class="l-projects"></div>
|
|
398
|
-
<textarea id="prompt" rows="2"
|
|
444
|
+
<textarea id="prompt" rows="2" spellcheck="false"></textarea>
|
|
399
445
|
<div class="l-bar">
|
|
400
446
|
<button class="icon-btn" id="attach" title="Attach files">${icon('clip')}</button>
|
|
401
|
-
<
|
|
402
|
-
<button class="
|
|
447
|
+
<button class="mode-btn" id="mode" aria-haspopup="menu"></button>
|
|
448
|
+
<button class="runner-btn" id="runner" title="Harness and model" aria-haspopup="dialog"></button>
|
|
403
449
|
<span class="grow"></span>
|
|
404
450
|
<button class="send" id="launch" title="Launch (Enter · Shift+Enter for newline)">${icon('enter')}</button>
|
|
405
451
|
</div>`;
|
|
@@ -412,30 +458,67 @@ function renderLauncher() {
|
|
|
412
458
|
autosize(ta);
|
|
413
459
|
ta.value = S.drafts.get('home') || '';
|
|
414
460
|
ta.addEventListener('input', () => S.drafts.set('home', ta.value));
|
|
461
|
+
// While focus is elsewhere, the placeholder advertises the shortcut that brings you back.
|
|
462
|
+
const hint = () => (ta.placeholder = document.activeElement === ta
|
|
463
|
+
? 'What should I go do? Paste screenshots, drop logs or videos…'
|
|
464
|
+
: `What should I go do? Press ${combo('L')} to start typing`);
|
|
465
|
+
ta.addEventListener('focus', hint);
|
|
466
|
+
ta.addEventListener('blur', hint);
|
|
467
|
+
hint();
|
|
415
468
|
$('#attach', box).onclick = tray.pick;
|
|
416
469
|
|
|
470
|
+
// Mode is set-and-forget, so it's one small button: just an icon on the default (Edits),
|
|
471
|
+
// spelled out when it's Plan or Full auto so you can't miss it.
|
|
417
472
|
let curMode = mode;
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
}
|
|
424
|
-
|
|
425
|
-
const
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
modelBtn.innerHTML = `${icon('spark')}<span>${esc(m.label)}</span>${icon('chevron', 'chev')}`;
|
|
431
|
-
modelBtn.classList.toggle('set', !!curModel);
|
|
473
|
+
const modeBtn = $('#mode', box);
|
|
474
|
+
const paintMode = () => {
|
|
475
|
+
const m = MODES.find((x) => x.id === curMode) || MODES[1];
|
|
476
|
+
modeBtn.className = `mode-btn m-${m.id}`;
|
|
477
|
+
modeBtn.innerHTML = `${icon(m.icon)}${m.id === 'edits' ? '' : `<span>${m.label}</span>`}`;
|
|
478
|
+
modeBtn.title = `${m.label}: ${m.hint}\nShift+Tab to switch`;
|
|
479
|
+
};
|
|
480
|
+
const setMode = (id) => {
|
|
481
|
+
curMode = id;
|
|
482
|
+
S.config.defaults.mode = id;
|
|
483
|
+
paintMode();
|
|
484
|
+
api('/config', { method: 'PATCH', body: { defaults: { mode: id } } }).catch(() => {});
|
|
432
485
|
};
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
486
|
+
paintMode();
|
|
487
|
+
modeBtn.onclick = () => modeMenu(modeBtn, curMode, setMode);
|
|
488
|
+
// The model is remembered per harness (ids aren't portable between them).
|
|
489
|
+
const savedModel = (id) => {
|
|
490
|
+
const d = S.config.defaults;
|
|
491
|
+
const want = d.models?.[id] ?? (id === d.agent ? d.model : '');
|
|
492
|
+
const models = S.agents.find((a) => a.id === id)?.models || [];
|
|
493
|
+
return models.some((m) => m.id === want) ? want : '';
|
|
494
|
+
};
|
|
495
|
+
// Thinking level too, and only if the chosen model still accepts it.
|
|
496
|
+
const savedEffort = (id, model) => {
|
|
497
|
+
const d = S.config.defaults;
|
|
498
|
+
const want = d.efforts?.[id] ?? (id === d.agent ? d.effort : '');
|
|
499
|
+
return effortsFor(id, model).includes(want) ? want : '';
|
|
500
|
+
};
|
|
501
|
+
let curModel = savedModel(agent?.id);
|
|
502
|
+
let curEffort = savedEffort(agent?.id, curModel);
|
|
503
|
+
const runnerBtn = $('#runner', box);
|
|
504
|
+
const paintRunner = () => {
|
|
505
|
+
const m = (agent?.models || []).find((x) => x.id === curModel);
|
|
506
|
+
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>${curEffort ? `<span class="rb-e">${esc(effortLabel(curEffort))}</span>` : ''}${icon('chevron', 'chev')}`;
|
|
507
|
+
runnerBtn.title = `Harness, model and thinking level${curEffort ? `\nThinking: ${effortLabel(curEffort)}` : ''}`;
|
|
508
|
+
};
|
|
509
|
+
paintRunner();
|
|
510
|
+
runnerBtn.onclick = () =>
|
|
511
|
+
runnerPicker(runnerBtn, { agent: agent?.id, model: curModel, effort: curEffort, savedModel, savedEffort }, (id, model, effort) => {
|
|
512
|
+
agent = S.agents.find((a) => a.id === id);
|
|
513
|
+
curModel = model;
|
|
514
|
+
curEffort = effort;
|
|
515
|
+
paintRunner();
|
|
516
|
+
const d = S.config.defaults;
|
|
517
|
+
const models = { ...d.models, [id]: model };
|
|
518
|
+
// A model without levels leaves the harness's saved level alone, so switching back restores it.
|
|
519
|
+
const efforts = effort || effortsFor(id, model).length ? { ...d.efforts, [id]: effort } : d.efforts;
|
|
520
|
+
Object.assign(d, { agent: id, model, models, effort, efforts });
|
|
521
|
+
api('/config', { method: 'PATCH', body: { defaults: { agent: id, model, models, effort, efforts } } }).catch(() => {});
|
|
439
522
|
});
|
|
440
523
|
|
|
441
524
|
const launch = async () => {
|
|
@@ -447,7 +530,7 @@ function renderLauncher() {
|
|
|
447
530
|
btn.disabled = true;
|
|
448
531
|
try {
|
|
449
532
|
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 } });
|
|
533
|
+
const s = await api('/sessions', { method: 'POST', body: { prompt, cwd: project.path, agent: agent?.id || 'claude', mode: curMode, model: curModel, effort: curEffort, attachments } });
|
|
451
534
|
S.sessions.set(s.id, s);
|
|
452
535
|
ta.value = '';
|
|
453
536
|
S.drafts.delete('home');
|
|
@@ -473,7 +556,49 @@ function renderLauncher() {
|
|
|
473
556
|
e.preventDefault();
|
|
474
557
|
launch();
|
|
475
558
|
}
|
|
559
|
+
if (e.key === 'Tab' && e.shiftKey) {
|
|
560
|
+
e.preventDefault();
|
|
561
|
+
setMode(MODES[(MODES.findIndex((m) => m.id === curMode) + 1) % MODES.length].id);
|
|
562
|
+
}
|
|
563
|
+
});
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
function modeMenu(anchor, current, pick) {
|
|
567
|
+
const open = $('.menu.mode');
|
|
568
|
+
if (open) return open.close();
|
|
569
|
+
const menu = el(`
|
|
570
|
+
<div class="menu mode" role="menu">
|
|
571
|
+
${MODES.map((m) => `<button role="menuitemradio" aria-checked="${m.id === current}" data-mode="${m.id}" class="m-${m.id}${m.id === current ? ' on' : ''}">
|
|
572
|
+
${icon(m.icon)}<span class="mm-text"><b>${m.label}</b><small>${esc(m.hint)}</small></span>${icon('check', 'mm-check')}
|
|
573
|
+
</button>`).join('')}
|
|
574
|
+
</div>`);
|
|
575
|
+
const close = () => (menu.remove(), anchor.classList.remove('open'), document.removeEventListener('mousedown', outside));
|
|
576
|
+
const outside = (ev) => !menu.contains(ev.target) && !anchor.contains(ev.target) && close();
|
|
577
|
+
menu.close = close;
|
|
578
|
+
setTimeout(() => document.addEventListener('mousedown', outside));
|
|
579
|
+
menu.onclick = (ev) => {
|
|
580
|
+
const b = ev.target.closest('button');
|
|
581
|
+
if (!b) return;
|
|
582
|
+
close();
|
|
583
|
+
pick(b.dataset.mode);
|
|
584
|
+
$('#prompt')?.focus();
|
|
585
|
+
};
|
|
586
|
+
menu.addEventListener('keydown', (ev) => {
|
|
587
|
+
const items = $$('button', menu);
|
|
588
|
+
const i = items.indexOf(document.activeElement);
|
|
589
|
+
if (ev.key === 'Escape') (close(), anchor.focus());
|
|
590
|
+
else if (ev.key === 'ArrowDown' || ev.key === 'ArrowUp') {
|
|
591
|
+
ev.preventDefault();
|
|
592
|
+
items[(i + (ev.key === 'ArrowDown' ? 1 : items.length - 1)) % items.length].focus();
|
|
593
|
+
}
|
|
476
594
|
});
|
|
595
|
+
document.body.append(menu);
|
|
596
|
+
anchor.classList.add('open');
|
|
597
|
+
const r = anchor.getBoundingClientRect();
|
|
598
|
+
const h = menu.offsetHeight;
|
|
599
|
+
menu.style.left = `${Math.max(8, Math.min(r.left, innerWidth - menu.offsetWidth - 8))}px`;
|
|
600
|
+
menu.style.top = `${r.bottom + 6 + h < innerHeight - 8 ? r.bottom + 6 : r.top - 6 - h}px`;
|
|
601
|
+
$('button.on', menu)?.focus();
|
|
477
602
|
}
|
|
478
603
|
|
|
479
604
|
function renderLaunchChips() {
|
|
@@ -546,9 +671,53 @@ function renderProjects() {
|
|
|
546
671
|
renderLaunchChips();
|
|
547
672
|
}),
|
|
548
673
|
);
|
|
674
|
+
$$('.p-chip[data-id]:not([data-id="all"])').forEach((b) =>
|
|
675
|
+
b.addEventListener('contextmenu', (e) => {
|
|
676
|
+
e.preventDefault();
|
|
677
|
+
projectMenu(b.dataset.id, e);
|
|
678
|
+
}),
|
|
679
|
+
);
|
|
549
680
|
$('#manageProjects').onclick = () => openProjects();
|
|
550
681
|
}
|
|
551
682
|
|
|
683
|
+
function projectMenu(id, e) {
|
|
684
|
+
const p = projectById(id);
|
|
685
|
+
if (!p) return;
|
|
686
|
+
$('.menu.project')?.close();
|
|
687
|
+
const menu = el(`
|
|
688
|
+
<div class="menu project" style="top:${e.clientY}px;left:${e.clientX}px">
|
|
689
|
+
<button data-a="manage">${icon('pen')}Manage projects…</button>
|
|
690
|
+
<button data-a="delete" class="danger">${icon('trash')}Remove project</button>
|
|
691
|
+
</div>`);
|
|
692
|
+
const close = () => (menu.remove(), document.removeEventListener('mousedown', outside));
|
|
693
|
+
const outside = (ev) => !menu.contains(ev.target) && close();
|
|
694
|
+
menu.close = close;
|
|
695
|
+
setTimeout(() => document.addEventListener('mousedown', outside));
|
|
696
|
+
menu.onclick = async (ev) => {
|
|
697
|
+
const a = ev.target.closest('button')?.dataset.a;
|
|
698
|
+
close();
|
|
699
|
+
if (a === 'manage') openProjects();
|
|
700
|
+
if (a === 'delete' && confirm(`Remove “${p.name}” from wisp? Sessions and files are kept.`)) {
|
|
701
|
+
try {
|
|
702
|
+
await api(`/projects/${id}`, { method: 'DELETE' });
|
|
703
|
+
} catch (err) {
|
|
704
|
+
return toast(esc(err.message), { tone: 'bad' });
|
|
705
|
+
}
|
|
706
|
+
if (S.filter === id) {
|
|
707
|
+
S.filter = 'all';
|
|
708
|
+
save('wisp.filter', S.filter);
|
|
709
|
+
}
|
|
710
|
+
await loadConfig();
|
|
711
|
+
renderFeed();
|
|
712
|
+
}
|
|
713
|
+
};
|
|
714
|
+
document.body.append(menu);
|
|
715
|
+
// Keep the menu on-screen when right-clicking near the window edge.
|
|
716
|
+
const r = menu.getBoundingClientRect();
|
|
717
|
+
if (r.right > innerWidth - 8) menu.style.left = `${innerWidth - r.width - 8}px`;
|
|
718
|
+
if (r.bottom > innerHeight - 8) menu.style.top = `${innerHeight - r.height - 8}px`;
|
|
719
|
+
}
|
|
720
|
+
|
|
552
721
|
// --- expanded item body ------------------------------------------------------------
|
|
553
722
|
|
|
554
723
|
function ctxFor(s, turn, isLatest) {
|
|
@@ -616,6 +785,7 @@ function renderBody(id, { fresh }) {
|
|
|
616
785
|
<span class="m">${esc(agentName(s.agent))}</span>
|
|
617
786
|
<span class="m">${esc(MODES.find((m) => m.id === s.mode)?.label || s.mode)}</span>
|
|
618
787
|
${s.model ? `<span class="m">${esc(s.model)}</span>` : ''}
|
|
788
|
+
${s.effort ? `<span class="m">${esc(effortLabel(s.effort))} thinking</span>` : ''}
|
|
619
789
|
<span class="m path" title="${esc(s.cwd)}">${esc((proj?.path || s.cwd).replace(S.config.home || '', '~'))}</span>`;
|
|
620
790
|
// Done lives both in the top bar and next to the composer, so it's reachable after reading.
|
|
621
791
|
$$('[data-a="done"]', body).forEach((b) => {
|
|
@@ -689,7 +859,7 @@ function renderTurn(s, t, isLatest, fold) {
|
|
|
689
859
|
<div class="ask-text ${long ? 'clamped' : ''}">${esc(t.prompt)}</div>
|
|
690
860
|
${long ? '<button class="link xs more-btn">Show all</button>' : ''}
|
|
691
861
|
${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>
|
|
862
|
+
<div class="ask-foot">${modelBadge(t.agent || s.agent, t.model ?? s.model, t.effort ?? s.effort)}<time>${new Date(t.startedAt).toLocaleString([], { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' })}</time></div>
|
|
693
863
|
${fold ? `<button class="ghost xs t-unfold" title="Collapse this exchange">${icon('chevron', 'chev')}</button>` : ''}
|
|
694
864
|
</div>`);
|
|
695
865
|
if (fold) ask.querySelector('.t-unfold').onclick = fold;
|
|
@@ -856,11 +1026,13 @@ function renderDock(id) {
|
|
|
856
1026
|
const dock = $('.it-dock', n.body);
|
|
857
1027
|
const comp = el(`
|
|
858
1028
|
<div class="composer">
|
|
1029
|
+
<ul class="c-queued"></ul>
|
|
859
1030
|
<textarea class="reply" rows="1" spellcheck="false"></textarea>
|
|
860
1031
|
<div class="c-bar">
|
|
861
1032
|
<button class="icon-btn c-attach" title="Attach files">${icon('clip')}</button>
|
|
862
1033
|
<span class="c-hint"></span>
|
|
863
1034
|
<button class="ghost sm c-done" data-a="done" title="Mark done (E)">${icon('done')}<span>Mark done</span></button>
|
|
1035
|
+
<button class="ghost xs c-queue" hidden>${icon('queue')}<span>Queue</span></button>
|
|
864
1036
|
<button class="send" title="Send (Enter · Shift+Enter for newline)">${icon('enter')}</button>
|
|
865
1037
|
</div>
|
|
866
1038
|
</div>`);
|
|
@@ -872,6 +1044,7 @@ function renderDock(id) {
|
|
|
872
1044
|
acceptFiles(comp, ta, n.tray);
|
|
873
1045
|
$('.c-attach', comp).onclick = n.tray.pick;
|
|
874
1046
|
$('.c-done', comp).onclick = () => toggleDone(id);
|
|
1047
|
+
$('.c-queue', comp).onclick = toggleQueue;
|
|
875
1048
|
ta.value = S.drafts.get(id) || '';
|
|
876
1049
|
ta.addEventListener('input', () => S.drafts.set(id, ta.value));
|
|
877
1050
|
ta.addEventListener('focus', () => setFocus(id, { scroll: false }));
|
|
@@ -897,11 +1070,82 @@ function updateDock(id) {
|
|
|
897
1070
|
: s.ask
|
|
898
1071
|
? `Answer ${name}…`
|
|
899
1072
|
: s.state === 'running'
|
|
900
|
-
?
|
|
1073
|
+
? queueing()
|
|
1074
|
+
? `Queue a follow-up — sent when ${name} finishes`
|
|
1075
|
+
: `Steer ${name} — interrupts and sends now`
|
|
901
1076
|
: s.state === 'done'
|
|
902
1077
|
? 'Follow up to reopen…'
|
|
903
1078
|
: `Follow up with ${name}…`;
|
|
904
1079
|
$('.c-hint', n.card).innerHTML = s.queue?.length ? `${s.queue.length} queued` : '';
|
|
1080
|
+
renderQueued(s, $('.c-queued', n.card), name);
|
|
1081
|
+
const q = $('.c-queue', n.card);
|
|
1082
|
+
q.hidden = tour || s.state !== 'running';
|
|
1083
|
+
q.classList.toggle('on', queueing());
|
|
1084
|
+
q.title = queueing()
|
|
1085
|
+
? `Queueing: follow-ups wait until ${name} finishes. Click to send now instead.`
|
|
1086
|
+
: `Sending now: follow-ups interrupt ${name}. Click to queue them instead.`;
|
|
1087
|
+
}
|
|
1088
|
+
|
|
1089
|
+
// Follow-ups waiting their turn, shown above the composer so it's clear what goes out next.
|
|
1090
|
+
function renderQueued(s, list, name) {
|
|
1091
|
+
const key = (s.queue || []).map((q) => q.id).join();
|
|
1092
|
+
if (list.dataset.key === key) return;
|
|
1093
|
+
list.dataset.key = key;
|
|
1094
|
+
list.innerHTML = '';
|
|
1095
|
+
(s.queue || []).forEach((q, i) => {
|
|
1096
|
+
const li = el(`
|
|
1097
|
+
<li class="cq">
|
|
1098
|
+
<span class="cq-tag">${i === 0 ? `Next · after ${esc(name)} finishes` : `Queued #${i + 1}`}</span>
|
|
1099
|
+
<span class="cq-text">${esc(q.text)}</span>
|
|
1100
|
+
<button class="att-x" title="Cancel this follow-up">${icon('x')}</button>
|
|
1101
|
+
</li>`);
|
|
1102
|
+
const cancel = () => {
|
|
1103
|
+
// Items queued by a wisp older than queue ids get one when wisp restarts.
|
|
1104
|
+
if (!q.id) return toast('Restart wisp to cancel this follow-up.', { tone: 'bad' });
|
|
1105
|
+
const short = q.text.length > 80 ? `${q.text.slice(0, 80)}…` : q.text;
|
|
1106
|
+
if (!confirm(`Cancel this queued follow-up?\n\n“${short}”`)) return;
|
|
1107
|
+
api(`/sessions/${s.id}/queue?item=${encodeURIComponent(q.id)}`, { method: 'DELETE' }).catch((err) => toast(esc(err.message), { tone: 'bad' }));
|
|
1108
|
+
};
|
|
1109
|
+
$('.att-x', li).onclick = cancel;
|
|
1110
|
+
li.addEventListener('contextmenu', (e) => {
|
|
1111
|
+
e.preventDefault();
|
|
1112
|
+
e.stopPropagation();
|
|
1113
|
+
queuedMenu(e, cancel);
|
|
1114
|
+
});
|
|
1115
|
+
list.append(li);
|
|
1116
|
+
});
|
|
1117
|
+
}
|
|
1118
|
+
|
|
1119
|
+
// Right-click on a queued follow-up.
|
|
1120
|
+
function queuedMenu(e, cancel) {
|
|
1121
|
+
$('.menu.queued')?.close();
|
|
1122
|
+
const menu = el(`
|
|
1123
|
+
<div class="menu queued" style="top:${e.clientY}px;left:${e.clientX}px">
|
|
1124
|
+
<button data-a="cancel" class="danger">${icon('x')}Cancel follow-up</button>
|
|
1125
|
+
</div>`);
|
|
1126
|
+
const close = () => (menu.remove(), document.removeEventListener('mousedown', outside));
|
|
1127
|
+
const outside = (ev) => !menu.contains(ev.target) && close();
|
|
1128
|
+
menu.close = close;
|
|
1129
|
+
setTimeout(() => document.addEventListener('mousedown', outside));
|
|
1130
|
+
menu.onclick = (ev) => {
|
|
1131
|
+
const a = ev.target.closest('button')?.dataset.a;
|
|
1132
|
+
close();
|
|
1133
|
+
if (a === 'cancel') cancel();
|
|
1134
|
+
};
|
|
1135
|
+
document.body.append(menu);
|
|
1136
|
+
const r = menu.getBoundingClientRect();
|
|
1137
|
+
if (r.right > innerWidth - 8) menu.style.left = `${innerWidth - r.width - 8}px`;
|
|
1138
|
+
if (r.bottom > innerHeight - 8) menu.style.top = `${innerHeight - r.height - 8}px`;
|
|
1139
|
+
}
|
|
1140
|
+
|
|
1141
|
+
// Follow-ups sent while an agent works interrupt it, unless the user flipped this on.
|
|
1142
|
+
const queueing = () => S.config?.defaults?.followUp === 'queue';
|
|
1143
|
+
|
|
1144
|
+
async function toggleQueue() {
|
|
1145
|
+
const followUp = queueing() ? 'now' : 'queue';
|
|
1146
|
+
S.config.defaults.followUp = followUp;
|
|
1147
|
+
S.nodes.forEach((_, id) => updateDock(id));
|
|
1148
|
+
await api('/config', { method: 'PATCH', body: { defaults: { followUp } } }).catch((err) => toast(esc(err.message), { tone: 'bad' }));
|
|
905
1149
|
}
|
|
906
1150
|
|
|
907
1151
|
async function sendReply(id, text) {
|
|
@@ -914,12 +1158,13 @@ async function sendReply(id, text) {
|
|
|
914
1158
|
try {
|
|
915
1159
|
const r = s.ask && !attachments.length
|
|
916
1160
|
? await api(`/sessions/${id}/answer`, { method: 'POST', body: { text } })
|
|
917
|
-
: await api(`/sessions/${id}/reply`, { method: 'POST', body: { text, attachments } });
|
|
1161
|
+
: await api(`/sessions/${id}/reply`, { method: 'POST', body: { text, attachments, queue: queueing() } });
|
|
918
1162
|
const ta = n && $('.reply', n.card);
|
|
919
1163
|
if (ta) ((ta.value = ''), ta.dispatchEvent(new Event('input')));
|
|
920
1164
|
S.drafts.delete(id);
|
|
921
1165
|
n?.tray?.clear();
|
|
922
|
-
if (r.queued)
|
|
1166
|
+
if (r.queued)
|
|
1167
|
+
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
1168
|
} catch (err) {
|
|
924
1169
|
toast(esc(err.message), { tone: 'bad' });
|
|
925
1170
|
}
|
|
@@ -975,51 +1220,202 @@ function rename(id) {
|
|
|
975
1220
|
title.onblur = () => done(true);
|
|
976
1221
|
}
|
|
977
1222
|
|
|
978
|
-
|
|
979
|
-
|
|
1223
|
+
// One popover for all three choices: harnesses on the left, the focused harness's models on the
|
|
1224
|
+
// right, and its thinking levels underneath. Clicking (or arrowing to) a harness previews its
|
|
1225
|
+
// models; picking a model commits harness + model, picking a thinking level commits it in place.
|
|
1226
|
+
function runnerPicker(anchor, { agent, model, effort, savedModel, savedEffort }, pick) {
|
|
1227
|
+
const open = $('.picker');
|
|
980
1228
|
if (open) return open.close();
|
|
1229
|
+
let focusAgent = S.agents.find((a) => a.id === agent && a.available) ? agent : S.agents.find((a) => a.available)?.id;
|
|
1230
|
+
|
|
981
1231
|
const r = anchor.getBoundingClientRect();
|
|
982
|
-
const
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
1232
|
+
const width = Math.min(580, innerWidth - 24);
|
|
1233
|
+
// Stay inside the launcher card when there's room, else inside the window.
|
|
1234
|
+
const box = anchor.closest('.launcher')?.getBoundingClientRect();
|
|
1235
|
+
const right = Math.min(innerWidth - 12, box && box.width >= width ? box.right : innerWidth - 12);
|
|
1236
|
+
const left = Math.max(12, Math.min(r.left, right - width));
|
|
1237
|
+
const below = r.bottom + 440 < innerHeight;
|
|
1238
|
+
const node = el(`
|
|
1239
|
+
<div class="picker" role="dialog" aria-label="Harness, model and thinking level" style="left:${left}px;width:${width}px;${below ? `top:${r.bottom + 6}px` : `bottom:${innerHeight - r.top + 6}px`}">
|
|
1240
|
+
<div class="pk-side" role="listbox" aria-label="Harness">
|
|
1241
|
+
<div class="pk-head">Harness</div>
|
|
1242
|
+
${S.agents
|
|
1243
|
+
.map(
|
|
1244
|
+
(a) => `<button class="pk-h${a.available ? '' : ' off'}" role="option" data-id="${esc(a.id)}" ${a.available ? '' : 'disabled'}>
|
|
1245
|
+
<span class="pk-logo">${harnessLogo(a.id)}</span>
|
|
1246
|
+
<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>
|
|
1247
|
+
${a.id === agent ? '<span class="pk-dot" title="In use"></span>' : ''}
|
|
1248
|
+
</button>`,
|
|
1249
|
+
)
|
|
1250
|
+
.join('')}
|
|
1251
|
+
</div>
|
|
1252
|
+
<div class="pk-main">
|
|
1253
|
+
<label class="pk-search">${icon('search')}<input spellcheck="false" autocomplete="off"></label>
|
|
1254
|
+
<div class="pk-list" role="listbox" aria-label="Model"></div>
|
|
1255
|
+
<div class="pk-effort" role="radiogroup" aria-label="Thinking level"></div>
|
|
1256
|
+
</div>
|
|
992
1257
|
</div>`);
|
|
993
|
-
const
|
|
1258
|
+
const input = $('.pk-search input', node);
|
|
1259
|
+
const list = $('.pk-list', node);
|
|
1260
|
+
const sideRows = $$('.pk-h', node);
|
|
1261
|
+
const seg = $('.pk-effort', node);
|
|
1262
|
+
const modelOf = (id) => (id === agent ? model : savedModel(id));
|
|
1263
|
+
const effortOf = (id) => (id === agent ? effort : savedEffort(id, modelOf(id)));
|
|
1264
|
+
|
|
1265
|
+
const paintEffort = () => {
|
|
1266
|
+
const levels = effortsFor(focusAgent, modelOf(focusAgent));
|
|
1267
|
+
const current = effortOf(focusAgent);
|
|
1268
|
+
seg.hidden = !levels.length;
|
|
1269
|
+
seg.innerHTML = `<span class="pk-ehead">Thinking</span><span class="pk-seg">${['', ...levels]
|
|
1270
|
+
.map((e) => `<button role="radio" aria-checked="${e === current}" class="${e === current ? 'on' : ''}" data-e="${esc(e)}" title="${e ? `${esc(effortLabel(e))} reasoning effort` : 'Whatever the harness is set to'}">${esc(e ? effortLabel(e) : 'Default')}</button>`)
|
|
1271
|
+
.join('')}</span>`;
|
|
1272
|
+
};
|
|
1273
|
+
const chooseEffort = (e) => {
|
|
1274
|
+
const m = modelOf(focusAgent);
|
|
1275
|
+
if (focusAgent === agent && e === effort) return;
|
|
1276
|
+
if (focusAgent !== agent) {
|
|
1277
|
+
$$('.pk-dot', node).forEach((d) => d.remove());
|
|
1278
|
+
sideRows.find((b) => b.dataset.id === focusAgent)?.insertAdjacentHTML('beforeend', '<span class="pk-dot" title="In use"></span>');
|
|
1279
|
+
}
|
|
1280
|
+
agent = focusAgent;
|
|
1281
|
+
model = m;
|
|
1282
|
+
effort = e;
|
|
1283
|
+
pick(agent, model, effort);
|
|
1284
|
+
paintEffort();
|
|
1285
|
+
$(`button[data-e="${CSS.escape(e)}"]`, seg)?.focus();
|
|
1286
|
+
};
|
|
1287
|
+
|
|
1288
|
+
const paintModels = () => {
|
|
1289
|
+
const a = S.agents.find((x) => x.id === focusAgent);
|
|
1290
|
+
sideRows.forEach((b) => b.classList.toggle('on', b.dataset.id === focusAgent));
|
|
1291
|
+
const models = a?.models || [{ id: '', label: 'Default' }];
|
|
1292
|
+
const current = focusAgent === agent ? model : savedModel(focusAgent);
|
|
1293
|
+
input.placeholder = `Search ${a?.name || ''} models`;
|
|
1294
|
+
$('.pk-search', node).hidden = models.length <= 6;
|
|
1295
|
+
// opencode-style ids (provider/model) group by provider.
|
|
1296
|
+
const groups = new Map();
|
|
1297
|
+
for (const m of models) {
|
|
1298
|
+
const g = m.id && m.id.includes('/') ? m.hint || '' : '';
|
|
1299
|
+
if (!groups.has(g)) groups.set(g, []);
|
|
1300
|
+
groups.get(g).push(m);
|
|
1301
|
+
}
|
|
1302
|
+
list.innerHTML = [...groups]
|
|
1303
|
+
.map(
|
|
1304
|
+
([g, ms]) =>
|
|
1305
|
+
`<div class="pk-group">${g ? `<div class="pk-ghead">${esc(g)}</div>` : ''}${ms
|
|
1306
|
+
.map(
|
|
1307
|
+
(m) => `<button class="pk-m${m.id === current ? ' on' : ''}" role="option" aria-selected="${m.id === current}" data-id="${esc(m.id)}">
|
|
1308
|
+
<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')}
|
|
1309
|
+
</button>`,
|
|
1310
|
+
)
|
|
1311
|
+
.join('')}</div>`,
|
|
1312
|
+
)
|
|
1313
|
+
.join('') + '<div class="pk-empty" hidden>No models match</div>';
|
|
1314
|
+
filter();
|
|
1315
|
+
paintEffort();
|
|
1316
|
+
};
|
|
1317
|
+
const rows = () => $$('.pk-m', list).filter((b) => !b.hidden);
|
|
1318
|
+
const filter = () => {
|
|
1319
|
+
const q = input.value.trim().toLowerCase();
|
|
1320
|
+
for (const b of $$('.pk-m', list)) b.hidden = !!q && !`${b.dataset.id} ${b.textContent}`.toLowerCase().includes(q);
|
|
1321
|
+
for (const g of $$('.pk-group', list)) g.hidden = !$$('.pk-m', g).some((b) => !b.hidden);
|
|
1322
|
+
$('.pk-empty', list).hidden = rows().length > 0;
|
|
1323
|
+
};
|
|
1324
|
+
const focusModels = () => {
|
|
1325
|
+
const rs = rows();
|
|
1326
|
+
const target = rs.find((b) => b.classList.contains('on')) || rs[0];
|
|
1327
|
+
target?.focus();
|
|
1328
|
+
target?.scrollIntoView({ block: 'nearest' });
|
|
1329
|
+
};
|
|
1330
|
+
const preview = (id, { focus } = {}) => {
|
|
1331
|
+
if (id !== focusAgent) {
|
|
1332
|
+
focusAgent = id;
|
|
1333
|
+
input.value = '';
|
|
1334
|
+
paintModels();
|
|
1335
|
+
}
|
|
1336
|
+
if (focus) sideRows.find((b) => b.dataset.id === id)?.focus();
|
|
1337
|
+
};
|
|
1338
|
+
|
|
994
1339
|
const close = () => {
|
|
995
|
-
|
|
1340
|
+
node.remove();
|
|
996
1341
|
anchor.classList.remove('open');
|
|
997
1342
|
document.removeEventListener('mousedown', outside);
|
|
998
1343
|
document.removeEventListener('keydown', keys, true);
|
|
999
1344
|
};
|
|
1000
|
-
|
|
1001
|
-
const
|
|
1002
|
-
|
|
1345
|
+
node.close = close;
|
|
1346
|
+
const choose = (m) => {
|
|
1347
|
+
close();
|
|
1348
|
+
anchor.focus();
|
|
1349
|
+
// Carry the thinking level over when the new model accepts it, else fall back to the saved one.
|
|
1350
|
+
const cur = effortOf(focusAgent);
|
|
1351
|
+
const e = effortsFor(focusAgent, m).includes(cur) ? cur : savedEffort(focusAgent, m);
|
|
1352
|
+
if (focusAgent !== agent || m !== model || e !== effort) pick(focusAgent, m, e);
|
|
1353
|
+
};
|
|
1354
|
+
const outside = (e) => !node.contains(e.target) && !anchor.contains(e.target) && close();
|
|
1355
|
+
const step = (items, d) => {
|
|
1356
|
+
if (!items.length) return;
|
|
1003
1357
|
const i = items.indexOf(document.activeElement);
|
|
1004
|
-
|
|
1005
|
-
|
|
1358
|
+
const next = items[i < 0 ? (d > 0 ? 0 : items.length - 1) : (i + d + items.length) % items.length];
|
|
1359
|
+
next.focus();
|
|
1360
|
+
next.scrollIntoView({ block: 'nearest' });
|
|
1361
|
+
return next;
|
|
1362
|
+
};
|
|
1363
|
+
const keys = (e) => {
|
|
1364
|
+
const inSide = sideRows.includes(document.activeElement);
|
|
1365
|
+
const live = sideRows.filter((b) => !b.disabled);
|
|
1366
|
+
const segBtns = $$('button', seg);
|
|
1367
|
+
const inSeg = segBtns.includes(document.activeElement);
|
|
1368
|
+
if (e.key === 'Escape') return e.preventDefault(), e.stopPropagation(), close(), anchor.focus();
|
|
1369
|
+
// Tab hops from the model list to the thinking levels and back; anywhere else it leaves.
|
|
1370
|
+
if (e.key === 'Tab' && !seg.hidden && (inSeg ? e.shiftKey : !e.shiftKey && !inSide)) {
|
|
1371
|
+
e.preventDefault();
|
|
1372
|
+
return inSeg ? focusModels() : (segBtns.find((b) => b.classList.contains('on')) || segBtns[0]).focus();
|
|
1373
|
+
}
|
|
1374
|
+
if (e.key === 'Tab') return close();
|
|
1375
|
+
if (inSeg && (e.key === 'ArrowLeft' || e.key === 'ArrowRight')) {
|
|
1376
|
+
e.preventDefault();
|
|
1377
|
+
e.stopPropagation();
|
|
1378
|
+
return step(segBtns, e.key === 'ArrowRight' ? 1 : -1);
|
|
1379
|
+
}
|
|
1380
|
+
if (inSeg && e.key === 'ArrowUp') return e.preventDefault(), focusModels();
|
|
1381
|
+
if (inSeg && (e.key === 'Enter' || e.key === ' ')) return e.preventDefault(), chooseEffort(document.activeElement.dataset.e);
|
|
1382
|
+
if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
|
|
1006
1383
|
e.preventDefault();
|
|
1007
1384
|
e.stopPropagation();
|
|
1008
|
-
|
|
1009
|
-
|
|
1385
|
+
const d = e.key === 'ArrowDown' ? 1 : -1;
|
|
1386
|
+
if (inSide) preview(step(live, d)?.dataset.id);
|
|
1387
|
+
else step(rows(), d);
|
|
1388
|
+
} else if (e.key === 'ArrowLeft' && document.activeElement !== input) {
|
|
1389
|
+
e.preventDefault();
|
|
1390
|
+
preview(focusAgent, { focus: true });
|
|
1391
|
+
} else if ((e.key === 'ArrowRight' || e.key === 'Enter') && inSide) {
|
|
1392
|
+
e.preventDefault();
|
|
1393
|
+
focusModels();
|
|
1394
|
+
} else if (e.key === 'Enter' && document.activeElement === input && rows()[0]) {
|
|
1395
|
+
e.preventDefault();
|
|
1396
|
+
choose(rows()[0].dataset.id);
|
|
1397
|
+
} else if (e.key.length === 1 && !e.metaKey && !e.ctrlKey && !$('.pk-search', node).hidden && document.activeElement !== input) {
|
|
1398
|
+
input.focus(); // type-to-search from anywhere in the picker
|
|
1399
|
+
}
|
|
1010
1400
|
};
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
anchor.focus();
|
|
1016
|
-
if (b.dataset.id !== current) pick(b.dataset.id);
|
|
1401
|
+
sideRows.forEach((b) => (b.onclick = () => preview(b.dataset.id)));
|
|
1402
|
+
list.onclick = (e) => {
|
|
1403
|
+
const b = e.target.closest('.pk-m');
|
|
1404
|
+
if (b) choose(b.dataset.id);
|
|
1017
1405
|
};
|
|
1406
|
+
input.oninput = filter;
|
|
1407
|
+
seg.onclick = (e) => {
|
|
1408
|
+
const b = e.target.closest('button[data-e]');
|
|
1409
|
+
if (b) chooseEffort(b.dataset.e);
|
|
1410
|
+
};
|
|
1411
|
+
|
|
1412
|
+
paintModels();
|
|
1018
1413
|
setTimeout(() => document.addEventListener('mousedown', outside));
|
|
1019
1414
|
document.addEventListener('keydown', keys, true);
|
|
1020
|
-
document.body.append(
|
|
1415
|
+
document.body.append(node);
|
|
1021
1416
|
anchor.classList.add('open');
|
|
1022
|
-
(
|
|
1417
|
+
focusModels(); // scrolls the current model into view…
|
|
1418
|
+
if (!$('.pk-search', node).hidden) input.focus({ preventScroll: true }); // …then search is ready to type
|
|
1023
1419
|
}
|
|
1024
1420
|
|
|
1025
1421
|
function moreMenu(id, anchor) {
|
|
@@ -1028,10 +1424,11 @@ function moreMenu(id, anchor) {
|
|
|
1028
1424
|
const r = anchor.getBoundingClientRect();
|
|
1029
1425
|
const menu = el(`
|
|
1030
1426
|
<div class="menu" style="top:${r.bottom + 6}px;right:${innerWidth - r.right}px">
|
|
1031
|
-
<button data-a="rename">${icon('pen')}Rename</button>
|
|
1427
|
+
<button data-a="rename">${icon('pen')}Rename${keyHint('M')}</button>
|
|
1032
1428
|
${s.agentSessionId ? `<button data-a="copy">${icon('copy')}Copy resume command</button>` : ''}
|
|
1033
1429
|
<button data-a="folder">${icon('folder')}Open project in editor</button>
|
|
1034
|
-
|
|
1430
|
+
${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>` : ''}
|
|
1431
|
+
<button data-a="delete" class="danger">${icon('trash')}Delete session${keyHint('⌫')}</button>
|
|
1035
1432
|
</div>`);
|
|
1036
1433
|
const close = () => (menu.remove(), document.removeEventListener('mousedown', outside));
|
|
1037
1434
|
const outside = (e) => !menu.contains(e.target) && close();
|
|
@@ -1046,11 +1443,65 @@ function moreMenu(id, anchor) {
|
|
|
1046
1443
|
toast('Copied resume command');
|
|
1047
1444
|
}
|
|
1048
1445
|
if (a === 'folder') openInEditor(s, '.');
|
|
1049
|
-
if (a === '
|
|
1050
|
-
|
|
1051
|
-
|
|
1446
|
+
if (a === 'archive' || a === 'unarchive') setArchived(id, a === 'archive');
|
|
1447
|
+
if (a === 'delete') deleteSession(id);
|
|
1448
|
+
};
|
|
1449
|
+
document.body.append(menu);
|
|
1450
|
+
}
|
|
1451
|
+
|
|
1452
|
+
// Right-click on a feed item.
|
|
1453
|
+
function taskMenu(id, e) {
|
|
1454
|
+
const s = S.sessions.get(id);
|
|
1455
|
+
if (!s) return;
|
|
1456
|
+
$('.menu.task')?.close();
|
|
1457
|
+
const busy = s.state === 'running' || s.state === 'waiting';
|
|
1458
|
+
const menu = el(`
|
|
1459
|
+
<div class="menu task" style="top:${e.clientY}px;left:${e.clientX}px">
|
|
1460
|
+
${S.open.has(id) ? `<button data-a="collapse">${icon('chevron')}Collapse${keyHint('O')}</button>` : `<button data-a="open">${icon('chevron')}Open${keyHint('O')}</button>`}
|
|
1461
|
+
<button data-a="rename">${icon('pen')}Rename${keyHint('M')}</button>
|
|
1462
|
+
${busy || s.archived ? '' : `<button data-a="done">${icon('done')}${s.state === 'done' ? 'Reopen' : 'Mark done'}${keyHint('E')}</button>`}
|
|
1463
|
+
${s.archived ? `<button data-a="unarchive">${icon('archive')}Unarchive${keyHint('A')}</button>` : busy ? '' : `<button data-a="archive">${icon('archive')}Archive${keyHint('A')}</button>`}
|
|
1464
|
+
<button data-a="delete" class="danger">${icon('trash')}Delete session${keyHint('⌫')}</button>
|
|
1465
|
+
</div>`);
|
|
1466
|
+
const close = () => (menu.remove(), document.removeEventListener('mousedown', outside));
|
|
1467
|
+
const outside = (ev) => !menu.contains(ev.target) && close();
|
|
1468
|
+
menu.close = close;
|
|
1469
|
+
setTimeout(() => document.addEventListener('mousedown', outside));
|
|
1470
|
+
menu.onclick = async (ev) => {
|
|
1471
|
+
const a = ev.target.closest('button')?.dataset.a;
|
|
1472
|
+
close();
|
|
1473
|
+
if (a === 'open') expand(id);
|
|
1474
|
+
if (a === 'collapse') collapse(id);
|
|
1475
|
+
if (a === 'rename') rename(id);
|
|
1476
|
+
if (a === 'done') toggleDone(id);
|
|
1477
|
+
if (a === 'archive' || a === 'unarchive') setArchived(id, a === 'archive');
|
|
1478
|
+
if (a === 'delete') deleteSession(id);
|
|
1052
1479
|
};
|
|
1053
1480
|
document.body.append(menu);
|
|
1481
|
+
const r = menu.getBoundingClientRect();
|
|
1482
|
+
if (r.right > innerWidth - 8) menu.style.left = `${innerWidth - r.width - 8}px`;
|
|
1483
|
+
if (r.bottom > innerHeight - 8) menu.style.top = `${innerHeight - r.height - 8}px`;
|
|
1484
|
+
}
|
|
1485
|
+
|
|
1486
|
+
// The keyboard shortcut for a menu item, shown at its right edge. Menus act on the
|
|
1487
|
+
// focused card, so the key does the same thing as the click.
|
|
1488
|
+
const keyHint = (k) => `<kbd class="menu-kbd">${k}</kbd>`;
|
|
1489
|
+
|
|
1490
|
+
async function deleteSession(id) {
|
|
1491
|
+
if (!confirm('Delete this session from wisp? (The agent’s own history is kept.)')) return;
|
|
1492
|
+
await api(`/sessions/${id}`, { method: 'DELETE' }).catch((err) => toast(esc(err.message), { tone: 'bad' }));
|
|
1493
|
+
}
|
|
1494
|
+
|
|
1495
|
+
async function setArchived(id, archived) {
|
|
1496
|
+
try {
|
|
1497
|
+
await api(`/sessions/${id}`, { method: 'PATCH', body: { archived } });
|
|
1498
|
+
} catch (err) {
|
|
1499
|
+
return toast(esc(err.message), { tone: 'bad' });
|
|
1500
|
+
}
|
|
1501
|
+
if (archived) {
|
|
1502
|
+
if (S.open.has(id)) collapse(id);
|
|
1503
|
+
toast('Archived', { action: { label: 'Undo', run: () => setArchived(id, false) } });
|
|
1504
|
+
}
|
|
1054
1505
|
}
|
|
1055
1506
|
|
|
1056
1507
|
function replay(id) {
|
|
@@ -1084,7 +1535,8 @@ function openInEditor(s, p, line) {
|
|
|
1084
1535
|
function reveal(id) {
|
|
1085
1536
|
const s = S.sessions.get(id);
|
|
1086
1537
|
if (!s) return;
|
|
1087
|
-
if (s.
|
|
1538
|
+
if (s.archived) S.showArchived = true;
|
|
1539
|
+
else if (s.state === 'done' && !S.showDone) {
|
|
1088
1540
|
S.showDone = true;
|
|
1089
1541
|
save('wisp.showDone', true);
|
|
1090
1542
|
}
|
|
@@ -1248,10 +1700,10 @@ function openSettings() {
|
|
|
1248
1700
|
<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
1701
|
<label class="custom-term" ${d.terminal === 'custom' ? '' : 'hidden'}><span>Terminal command</span>
|
|
1250
1702
|
<input name="terminalCommand" value="${esc(d.terminalCommand || '')}" placeholder="alacritty --working-directory {cwd} -e {cmd}" spellcheck="false"></label>
|
|
1251
|
-
<
|
|
1703
|
+
<div class="setting"><span>Desktop notifications</span><span class="notif-hint muted xs"></span><button class="switch" id="notif" role="switch"></button></div>
|
|
1252
1704
|
<div class="agents">${S.agents
|
|
1253
1705
|
.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">
|
|
1706
|
+
.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
1707
|
</div>`);
|
|
1256
1708
|
const close = overlay(node);
|
|
1257
1709
|
$('.close', node).onclick = close;
|
|
@@ -1262,35 +1714,152 @@ function openSettings() {
|
|
|
1262
1714
|
await api('/config', { method: 'PATCH', body: { defaults: { [name]: value } } });
|
|
1263
1715
|
await loadConfig();
|
|
1264
1716
|
});
|
|
1265
|
-
|
|
1266
|
-
|
|
1267
|
-
|
|
1268
|
-
|
|
1717
|
+
// The switch shows wisp's own on/off preference; the browser permission can only be
|
|
1718
|
+
// granted from here, never revoked, so it surfaces as a hint when it blocks the switch.
|
|
1719
|
+
const sw = $('#notif', node);
|
|
1720
|
+
const paint = () => {
|
|
1721
|
+
const perm = 'Notification' in window ? Notification.permission : 'unsupported';
|
|
1722
|
+
const on = notifyOn();
|
|
1723
|
+
sw.setAttribute('aria-checked', on);
|
|
1724
|
+
sw.disabled = perm === 'denied' || perm === 'unsupported';
|
|
1725
|
+
$('.notif-hint', node).textContent =
|
|
1726
|
+
perm === 'denied' ? 'Blocked — allow notifications for this site in your browser' : perm === 'unsupported' ? 'Not supported here' : on ? 'On' : 'Off';
|
|
1727
|
+
};
|
|
1728
|
+
sw.onclick = async () => {
|
|
1729
|
+
let want = !notifyOn();
|
|
1730
|
+
if (want && Notification.permission !== 'granted') want = (await Notification.requestPermission()) === 'granted';
|
|
1731
|
+
await api('/config', { method: 'PATCH', body: { defaults: { notify: want ? 'on' : 'off' } } });
|
|
1732
|
+
await loadConfig();
|
|
1733
|
+
paint();
|
|
1269
1734
|
};
|
|
1735
|
+
paint();
|
|
1270
1736
|
}
|
|
1271
1737
|
|
|
1272
1738
|
function shortcuts() {
|
|
1273
1739
|
const rows = [
|
|
1274
|
-
['
|
|
1740
|
+
[combo('K'), 'Search & commands'],
|
|
1741
|
+
[`N · ${combo('L')}`, 'New wisp (focus the launcher)'],
|
|
1275
1742
|
['J / K', 'Next / previous item'],
|
|
1276
1743
|
['O / ↵', 'Expand / collapse item'],
|
|
1277
1744
|
['/', 'Follow up on item'],
|
|
1278
1745
|
['E', 'Mark done & go to next'],
|
|
1746
|
+
['A', 'Archive / unarchive'],
|
|
1747
|
+
['M', 'Rename item'],
|
|
1748
|
+
['⌫', 'Delete item'],
|
|
1279
1749
|
['T', 'Take over in terminal'],
|
|
1280
1750
|
['R', 'Replay presentation'],
|
|
1281
1751
|
['1–9', 'Pick an answer when asked'],
|
|
1282
1752
|
['↵', 'Launch / send'],
|
|
1283
1753
|
['⇧↵', 'New line'],
|
|
1754
|
+
['C', 'Collapse all'],
|
|
1284
1755
|
['Esc', 'Close / collapse'],
|
|
1285
1756
|
];
|
|
1286
1757
|
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
1758
|
overlay(node);
|
|
1288
1759
|
}
|
|
1289
1760
|
|
|
1761
|
+
// --- command palette ------------------------------------------------------------------
|
|
1762
|
+
|
|
1763
|
+
const IS_MAC = /Mac|iPhone|iPad/.test(navigator.platform);
|
|
1764
|
+
// How a modifier shortcut reads on this OS: "⌘L" on macOS, "Ctrl+L" on Windows/Linux.
|
|
1765
|
+
const combo = (k) => (IS_MAC ? `⌘${k}` : `Ctrl+${k}`);
|
|
1766
|
+
// ⌘ on macOS, Ctrl elsewhere (AltGr on Windows reports Ctrl+Alt, so Alt is excluded). Falls back to
|
|
1767
|
+
// the physical key on non-Latin layouts, where e.key is e.g. "д" for the L key.
|
|
1768
|
+
const modKey = (e, k) =>
|
|
1769
|
+
(e.metaKey || e.ctrlKey) && !e.altKey && !e.shiftKey &&
|
|
1770
|
+
(e.key.toLowerCase() === k || (!/^[a-z]$/i.test(e.key) && e.code === `Key${k.toUpperCase()}`));
|
|
1771
|
+
const STATE_LABEL = { running: 'Working', waiting: 'Asking', review: 'Review', done: 'Done' };
|
|
1772
|
+
|
|
1773
|
+
function focusLauncher() {
|
|
1774
|
+
stage.scrollTo({ top: 0, behavior: 'smooth' });
|
|
1775
|
+
$('#prompt')?.focus({ preventScroll: true });
|
|
1776
|
+
}
|
|
1777
|
+
|
|
1778
|
+
// Run something on a session's card, expanding it first if the action needs the body.
|
|
1779
|
+
function withCard(id, fn, { open = false } = {}) {
|
|
1780
|
+
reveal(id);
|
|
1781
|
+
if (!open || S.open.has(id)) return setTimeout(fn, 50);
|
|
1782
|
+
expand(id).then(fn);
|
|
1783
|
+
}
|
|
1784
|
+
|
|
1785
|
+
function paletteItems() {
|
|
1786
|
+
const items = [];
|
|
1787
|
+
const cur = S.focus && S.sessions.get(S.focus);
|
|
1788
|
+
if (cur) {
|
|
1789
|
+
const id = cur.id;
|
|
1790
|
+
const live = cur.state === 'running' || cur.state === 'waiting';
|
|
1791
|
+
const g = 'This wisp';
|
|
1792
|
+
const sub = cur.title;
|
|
1793
|
+
items.push({ group: g, label: 'Follow up', sub, icon: 'send', kbd: '/', run: () => withCard(id, () => $('.reply', S.nodes.get(id)?.card)?.focus(), { open: true }) });
|
|
1794
|
+
if (!live) items.push({ group: g, label: cur.state === 'done' ? 'Move back to review' : 'Mark done', sub, icon: 'done', kbd: 'E', run: () => toggleDone(id) });
|
|
1795
|
+
if (cur.archived || !live) items.push({ group: g, label: cur.archived ? 'Unarchive' : 'Archive', sub, icon: 'archive', kbd: 'A', run: () => setArchived(id, !cur.archived) });
|
|
1796
|
+
items.push({ group: g, label: 'Take over in terminal', sub, icon: 'terminal', kbd: 'T', run: () => takeOver(id) });
|
|
1797
|
+
items.push({ group: g, label: 'Replay presentation', sub, icon: 'replay', kbd: 'R', run: () => withCard(id, () => replay(id), { open: true }) });
|
|
1798
|
+
items.push({ group: g, label: 'Rename', sub, icon: 'pen', kbd: 'M', run: () => withCard(id, () => rename(id)) });
|
|
1799
|
+
items.push({ group: g, label: 'Delete session', sub, icon: 'trash', kbd: '⌫', keywords: 'remove', run: () => deleteSession(id) });
|
|
1800
|
+
}
|
|
1801
|
+
|
|
1802
|
+
const sessions = [...S.sessions.values()].sort((a, b) => a.archived - b.archived || b.updatedAt - a.updatedAt);
|
|
1803
|
+
for (const s of sessions) {
|
|
1804
|
+
const state = s.archived ? 'Archived' : s.state === 'review' ? STATUS_LABEL[s.status] || 'Review' : STATE_LABEL[s.state] || s.state;
|
|
1805
|
+
items.push({
|
|
1806
|
+
group: 'Wisps',
|
|
1807
|
+
label: s.title,
|
|
1808
|
+
sub: `${s.project} · ${state} · ${ago(s.updatedAt)}`,
|
|
1809
|
+
dot: s.color,
|
|
1810
|
+
keywords: [s.preview, s.live, s.question, agentName(s.agent)].filter(Boolean).join(' '),
|
|
1811
|
+
// Live and unread work floats up; archived sinks.
|
|
1812
|
+
rank: (s.state === 'waiting' ? 3 : 0) + (s.unread ? 2 : 0) + (s.state === 'running' ? 1 : 0) - (s.archived ? 4 : 0),
|
|
1813
|
+
run: () => reveal(s.id),
|
|
1814
|
+
});
|
|
1815
|
+
}
|
|
1816
|
+
|
|
1817
|
+
const a = 'Actions';
|
|
1818
|
+
items.push(
|
|
1819
|
+
{ group: a, label: 'New wisp', icon: 'plus', kbd: 'N', keywords: 'launch create prompt task', run: focusLauncher },
|
|
1820
|
+
{ group: a, label: S.showDone ? 'Hide done' : 'Show done', icon: 'done', keywords: 'toggle finished', run: () => $('#secDone .toggle-done').click() },
|
|
1821
|
+
{ group: a, label: S.showArchived ? 'Hide archived' : 'Show archived', icon: 'archive', keywords: 'toggle', run: () => $('#secArchived .toggle-done').click() },
|
|
1822
|
+
);
|
|
1823
|
+
if (S.open.size) items.push({ group: a, label: 'Collapse all', icon: 'minus', kbd: 'C', run: collapseAll });
|
|
1824
|
+
items.push(
|
|
1825
|
+
{ group: a, label: 'Add project…', icon: 'folder', keywords: 'folder repo directory', run: () => openProjects({ focusAdd: true }) },
|
|
1826
|
+
{ group: a, label: 'Manage projects', icon: 'folder', keywords: 'rename color remove', run: () => openProjects() },
|
|
1827
|
+
{ group: a, label: 'Settings', icon: 'settings', keywords: 'preferences editor terminal notifications', run: openSettings },
|
|
1828
|
+
{ group: a, label: 'Keyboard shortcuts', icon: 'info', kbd: '?', keywords: 'keys help hotkeys', run: shortcuts },
|
|
1829
|
+
);
|
|
1830
|
+
|
|
1831
|
+
const filter = (id) => () => {
|
|
1832
|
+
S.filter = id;
|
|
1833
|
+
save('wisp.filter', id);
|
|
1834
|
+
renderProjects();
|
|
1835
|
+
renderFeed();
|
|
1836
|
+
renderLaunchChips();
|
|
1837
|
+
};
|
|
1838
|
+
items.push({ group: 'Projects', label: 'All projects', sub: S.filter === 'all' ? 'Current filter' : '', dot: 'slate', keywords: 'filter clear', run: filter('all') });
|
|
1839
|
+
for (const p of S.config.projects.slice().sort((x, y) => (y.lastUsedAt || y.addedAt) - (x.lastUsedAt || x.addedAt))) {
|
|
1840
|
+
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) });
|
|
1841
|
+
}
|
|
1842
|
+
return items;
|
|
1843
|
+
}
|
|
1844
|
+
|
|
1845
|
+
let paletteOpen = false;
|
|
1846
|
+
function openPalette() {
|
|
1847
|
+
if (paletteOpen) return closeOverlay();
|
|
1848
|
+
// Don't stack over another modal (settings, projects…); the palette replaces nothing.
|
|
1849
|
+
if (!$('#overlay').hidden) return;
|
|
1850
|
+
paletteOpen = true;
|
|
1851
|
+
palette({ items: paletteItems(), overlay: (node) => overlay(node, { onClose: () => (paletteOpen = false) }) });
|
|
1852
|
+
}
|
|
1853
|
+
|
|
1290
1854
|
// --- notifications -----------------------------------------------------------------------
|
|
1291
1855
|
|
|
1856
|
+
// On unless turned off in settings, and only if the browser lets us.
|
|
1857
|
+
function notifyOn() {
|
|
1858
|
+
return S.config.defaults.notify !== 'off' && 'Notification' in window && Notification.permission === 'granted';
|
|
1859
|
+
}
|
|
1860
|
+
|
|
1292
1861
|
function askNotify() {
|
|
1293
|
-
if ('Notification' in window && Notification.permission === 'default') Notification.requestPermission();
|
|
1862
|
+
if (S.config.defaults.notify !== 'off' && 'Notification' in window && Notification.permission === 'default') Notification.requestPermission();
|
|
1294
1863
|
}
|
|
1295
1864
|
|
|
1296
1865
|
function notifyFinished(s) {
|
|
@@ -1299,7 +1868,7 @@ function notifyFinished(s) {
|
|
|
1299
1868
|
if (viewing) return;
|
|
1300
1869
|
const verb = s.state === 'waiting' ? 'has a question' : 'is ready';
|
|
1301
1870
|
toast(`${icon(s.state === 'waiting' ? 'ask' : 'spark')} <b>${esc(s.title)}</b> ${verb}`, { action: { label: 'Open', run: () => reveal(s.id) }, timeout: 7000 });
|
|
1302
|
-
if (
|
|
1871
|
+
if (notifyOn() && !document.hasFocus()) {
|
|
1303
1872
|
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
1873
|
note.onclick = () => (window.focus(), reveal(s.id));
|
|
1305
1874
|
}
|
|
@@ -1313,6 +1882,10 @@ function connect() {
|
|
|
1313
1882
|
const s = JSON.parse(e.data);
|
|
1314
1883
|
const prev = S.sessions.get(s.id);
|
|
1315
1884
|
S.sessions.set(s.id, s);
|
|
1885
|
+
// A card left open while it worked shouldn't land in "Needs you" expanded —
|
|
1886
|
+
// fold it unless the user is actually watching it.
|
|
1887
|
+
const n = S.nodes.get(s.id);
|
|
1888
|
+
if (prev?.state === 'running' && s.state !== 'running' && S.open.has(s.id) && !(n && isInView(n.card) && document.hasFocus())) collapse(s.id);
|
|
1316
1889
|
renderFeed();
|
|
1317
1890
|
if (prev?.state !== s.state || prev?.queued !== s.queued) scheduleRefetch(s.id);
|
|
1318
1891
|
});
|
|
@@ -1346,19 +1919,22 @@ async function loadConfig() {
|
|
|
1346
1919
|
renderLaunchChips();
|
|
1347
1920
|
}
|
|
1348
1921
|
|
|
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
1922
|
// --- keyboard ---------------------------------------------------------------------------
|
|
1358
1923
|
|
|
1359
1924
|
const feedIds = () => $$('#feed .item').map((x) => x.dataset.id);
|
|
1360
1925
|
|
|
1361
1926
|
document.addEventListener('keydown', (e) => {
|
|
1927
|
+
// ⌘K / Ctrl+K works from anywhere, even mid-sentence in the launcher.
|
|
1928
|
+
if (modKey(e, 'k')) {
|
|
1929
|
+
e.preventDefault();
|
|
1930
|
+
return openPalette();
|
|
1931
|
+
}
|
|
1932
|
+
// ⌘L / Ctrl+L jumps to the launcher from anywhere, including another composer.
|
|
1933
|
+
if (modKey(e, 'l')) {
|
|
1934
|
+
e.preventDefault();
|
|
1935
|
+
if (!$('#overlay').hidden) closeOverlay();
|
|
1936
|
+
return focusLauncher();
|
|
1937
|
+
}
|
|
1362
1938
|
const typing = e.target.closest('input, textarea, select, [contenteditable="true"]');
|
|
1363
1939
|
if (e.key === 'Escape') {
|
|
1364
1940
|
if (!$('#overlay').hidden) return closeOverlay();
|
|
@@ -1371,8 +1947,7 @@ document.addEventListener('keydown', (e) => {
|
|
|
1371
1947
|
const id = S.focus && S.nodes.has(S.focus) ? S.focus : null;
|
|
1372
1948
|
if (k === 'n') {
|
|
1373
1949
|
e.preventDefault();
|
|
1374
|
-
|
|
1375
|
-
$('#prompt')?.focus({ preventScroll: true });
|
|
1950
|
+
focusLauncher();
|
|
1376
1951
|
} else if (k === 'j' || k === 'k') {
|
|
1377
1952
|
const ids = feedIds();
|
|
1378
1953
|
if (!ids.length) return;
|
|
@@ -1383,7 +1958,18 @@ document.addEventListener('keydown', (e) => {
|
|
|
1383
1958
|
e.preventDefault();
|
|
1384
1959
|
toggle(id);
|
|
1385
1960
|
} else if (k === 'e' && id) toggleDone(id);
|
|
1386
|
-
else if (k === '
|
|
1961
|
+
else if (k === 'a' && id) {
|
|
1962
|
+
const s = S.sessions.get(id);
|
|
1963
|
+
if (s && (s.archived || (s.state !== 'running' && s.state !== 'waiting'))) setArchived(id, !s.archived);
|
|
1964
|
+
}
|
|
1965
|
+
else if (k === 'c' && S.open.size) collapseAll();
|
|
1966
|
+
else if (k === 'm' && id) {
|
|
1967
|
+
e.preventDefault();
|
|
1968
|
+
rename(id);
|
|
1969
|
+
} else if ((k === 'delete' || k === 'backspace') && id) {
|
|
1970
|
+
e.preventDefault();
|
|
1971
|
+
deleteSession(id);
|
|
1972
|
+
} else if (k === 't' && id) takeOver(id);
|
|
1387
1973
|
else if (k === 'r' && id) replay(id);
|
|
1388
1974
|
else if (k === '/' && id) {
|
|
1389
1975
|
e.preventDefault();
|
|
@@ -1399,11 +1985,18 @@ document.addEventListener('keydown', (e) => {
|
|
|
1399
1985
|
});
|
|
1400
1986
|
|
|
1401
1987
|
$('#settingsBtn').onclick = openSettings;
|
|
1988
|
+
$('#searchBtn').onclick = openPalette;
|
|
1989
|
+
$('#searchBtn').title = `Search (${combo('K')})`;
|
|
1990
|
+
$('#searchBtn .mod-k').textContent = combo('K');
|
|
1402
1991
|
$('#secDone .toggle-done').onclick = () => {
|
|
1403
1992
|
S.showDone = !S.showDone;
|
|
1404
1993
|
save('wisp.showDone', S.showDone);
|
|
1405
1994
|
renderFeed();
|
|
1406
1995
|
};
|
|
1996
|
+
$('#secArchived .toggle-done').onclick = () => {
|
|
1997
|
+
S.showArchived = !S.showArchived;
|
|
1998
|
+
renderFeed();
|
|
1999
|
+
};
|
|
1407
2000
|
|
|
1408
2001
|
// elapsed timers + "ago" stamps
|
|
1409
2002
|
setInterval(() => {
|
|
@@ -1444,7 +2037,6 @@ window.addEventListener('focus', () => {
|
|
|
1444
2037
|
|
|
1445
2038
|
(async () => {
|
|
1446
2039
|
[S.agents] = await Promise.all([api('/agents').catch(() => []), loadConfig(), loadSessions()]);
|
|
1447
|
-
renderAgentStatus();
|
|
1448
2040
|
renderLauncher();
|
|
1449
2041
|
connect();
|
|
1450
2042
|
route();
|