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/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,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
- setTimeout(() => ((o.hidden = true), (o.innerHTML = '')), 200);
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 all = visible([...S.sessions.values()]);
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
- return { needs, working, done };
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)"></button>`);
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
- collapseAllBtn.addEventListener('click', () => [...S.open].forEach((id) => collapse(id)));
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 agent = S.agents.find((a) => a.id === (S.config.defaults.agent || 'claude')) || S.agents[0];
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" placeholder="What should I go do? Paste screenshots, drop logs or videos…" spellcheck="false"></textarea>
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
- <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>
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
- $$('#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);
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
- 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(() => {});
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
- ? `Queue a follow-up — sent when ${name} finishes`
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) toast('Queued — it goes out as soon as the agent finishes.');
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
- function modelMenu(anchor, models, current, pick) {
979
- const open = $('.menu.models');
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 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('')}
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 items = $$('button', menu);
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
- menu.remove();
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
- menu.close = close;
1001
- const outside = (e) => !menu.contains(e.target) && !anchor.contains(e.target) && close();
1002
- const keys = (e) => {
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
- if (e.key === 'Escape') (e.preventDefault(), e.stopPropagation(), close(), anchor.focus());
1005
- else if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
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
- items[(i +(e.key === 'ArrowDown' ? 1 : -1) + items.length) % items.length].focus();
1009
- } else if (e.key === 'Tab') close();
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
- 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);
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(menu);
1415
+ document.body.append(node);
1021
1416
  anchor.classList.add('open');
1022
- (items.find((b) => b.classList.contains('on')) || items[0]).focus();
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
- <button data-a="delete" class="danger">${icon('trash')}Delete session</button>
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 === 'delete' && confirm('Delete this session from wisp? (The agent’s own history is kept.)')) {
1050
- await api(`/sessions/${id}`, { method: 'DELETE' });
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.state === 'done' && !S.showDone) {
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
- <label><span>Notifications</span><button class="ghost sm" id="notif">${'Notification' in window && Notification.permission === 'granted' ? 'Enabled' : 'Enable'}</button></label>
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">More harnesses (Codex, opencode…) plug in via <code>server/adapters</code>.</p></div>
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
- $('#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';
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
- ['N', 'New wisp (focus the launcher)'],
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 ('Notification' in window && Notification.permission === 'granted' && !document.hasFocus()) {
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
- stage.scrollTo({ top: 0, behavior: 'smooth' });
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 === 't' && id) takeOver(id);
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();