wisp-agents 0.2.2 → 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/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
- headers: body !== undefined ? { 'content-type': 'application/json' } : {},
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
- setTimeout(() => ((o.hidden = true), (o.innerHTML = '')), 200);
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 all = visible([...S.sessions.values()]);
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
- return { needs, working, done };
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)"></button>`);
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
- collapseAllBtn.addEventListener('click', () => [...S.open].forEach((id) => collapse(id)));
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 agent = S.agents.find((a) => a.id === (S.config.defaults.agent || 'claude')) || S.agents[0];
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" placeholder="What should I go do? Paste screenshots, drop logs or videos…" spellcheck="false"></textarea>
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
- <div class="seg" id="mode">${MODES.map((m) => `<button data-mode="${m.id}" class="${m.id === mode ? 'on' : ''}" title="${esc(m.hint)}">${m.label}</button>`).join('')}</div>
402
- <button class="model-btn" id="model" title="Model" aria-haspopup="menu"></button>
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
- $$('#mode button', box).forEach((b) =>
419
- b.addEventListener('click', () => {
420
- curMode = b.dataset.mode;
421
- $$('#mode button', box).forEach((x) => x.classList.toggle('on', x === b));
422
- api('/config', { method: 'PATCH', body: { defaults: { mode: curMode } } }).catch(() => {});
423
- }),
424
- );
425
- const models = agent?.models || [{ id: '', label: 'Default' }];
426
- let curModel = models.some((m) => m.id === S.config.defaults.model) ? S.config.defaults.model : models[0].id;
427
- const modelBtn = $('#model', box);
428
- const paintModel = () => {
429
- const m = models.find((x) => x.id === curModel);
430
- modelBtn.innerHTML = `${icon('spark')}<span>${esc(m.label)}</span>${icon('chevron', 'chev')}`;
431
- modelBtn.classList.toggle('set', !!curModel);
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
- paintModel();
434
- modelBtn.onclick = () =>
435
- modelMenu(modelBtn, models, curModel, (id) => {
436
- curModel = id;
437
- paintModel();
438
- api('/config', { method: 'PATCH', body: { defaults: { model: id } } }).catch(() => {});
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
- ? `Queue a follow-up — sent when ${name} finishes`
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) toast('Queued — it goes out as soon as the agent finishes.');
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
- function modelMenu(anchor, models, current, pick) {
979
- const open = $('.menu.models');
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 below = r.bottom + 60 * models.length + 20 < innerHeight;
983
- const menu = el(`
984
- <div class="menu models" role="menu" style="left:${r.left}px;${below ? `top:${r.bottom + 6}px` : `bottom:${innerHeight - r.top + 6}px`}">
985
- ${models
986
- .map(
987
- (m) => `<button role="menuitemradio" aria-checked="${m.id === current}" data-id="${esc(m.id)}" class="${m.id === current ? 'on' : ''}">
988
- <span class="mm-text"><b>${esc(m.label)}</b>${m.hint ? `<small>${esc(m.hint)}</small>` : ''}</span>${icon('check', 'mm-check')}
989
- </button>`,
990
- )
991
- .join('')}
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 items = $$('button', menu);
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
- menu.remove();
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
- menu.close = close;
1001
- const outside = (e) => !menu.contains(e.target) && !anchor.contains(e.target) && close();
1002
- const keys = (e) => {
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
- if (e.key === 'Escape') (e.preventDefault(), e.stopPropagation(), close(), anchor.focus());
1005
- else if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
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
- items[(i +(e.key === 'ArrowDown' ? 1 : -1) + items.length) % items.length].focus();
1009
- } else if (e.key === 'Tab') close();
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
- menu.onclick = (e) => {
1012
- const b = e.target.closest('button');
1013
- if (!b) return;
1014
- close();
1015
- anchor.focus();
1016
- if (b.dataset.id !== current) pick(b.dataset.id);
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(menu);
1344
+ document.body.append(node);
1021
1345
  anchor.classList.add('open');
1022
- (items.find((b) => b.classList.contains('on')) || items[0]).focus();
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
- <button data-a="delete" class="danger">${icon('trash')}Delete session</button>
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 === 'delete' && confirm('Delete this session from wisp? (The agent’s own history is kept.)')) {
1050
- await api(`/sessions/${id}`, { method: 'DELETE' });
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.state === 'done' && !S.showDone) {
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
- <label><span>Notifications</span><button class="ghost sm" id="notif">${'Notification' in window && Notification.permission === 'granted' ? 'Enabled' : 'Enable'}</button></label>
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">More harnesses (Codex, opencode…) plug in via <code>server/adapters</code>.</p></div>
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
- $('#notif', node).onclick = async (e) => {
1266
- if (!('Notification' in window)) return;
1267
- const p = await Notification.requestPermission();
1268
- e.target.textContent = p === 'granted' ? 'Enabled' : 'Blocked in browser';
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
- ['N', 'New wisp (focus the launcher)'],
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 ('Notification' in window && Notification.permission === 'granted' && !document.hasFocus()) {
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
- stage.scrollTo({ top: 0, behavior: 'smooth' });
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 === 't' && id) takeOver(id);
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();