wisp-agents 0.17.0 → 0.18.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.
@@ -12,12 +12,14 @@ import { popover } from '../ui/popover.js';
12
12
  import { toast, toastError } from '../ui/toast.js';
13
13
  import { acceptFiles, attachTray, autosize } from './attachments.js';
14
14
  import { openAutomations, recipeParams, runRecipe } from './automations.js';
15
+ import { cloneById, clones } from './clones.js';
15
16
  import { renderFeed } from './feed.js';
16
17
  import { feedbackCommand, isFeedback } from './feedback.js';
17
18
  import { connectMachine, offlineToast, openMachines } from './machines.js';
18
19
  import { mentionBox } from './mentions.js';
19
20
  import { askNotify } from './notify.js';
20
21
  import { MACHINE_STATE, machineById, placeName, placeOf, projectById, projectsAt, projectTitle } from './places.js';
22
+ import { projectList } from './project-list.js';
21
23
  import { openProjects } from './projects.js';
22
24
  import { runnerPicker } from './runner-picker.js';
23
25
  import { needsSetup, nudgeSetup, renderSetup } from './setup.js';
@@ -25,16 +27,27 @@ import { agentName, agentsAt, DEFAULT_MODE, effortLabel, effortsFor, harnessesAt
25
27
  import { feedAnchor, keepInPlace, stage, topInset } from './scroll.js';
26
28
  import { canLead, paintTeamBtn, teamEditor } from './team.js';
27
29
 
30
+ // "No folder": a general ask with no project. The server gives it an empty scratch directory.
31
+ const NO_FOLDER = 'none';
32
+ const GENERAL = { id: null, name: 'General', path: '', color: 'slate', scratch: true };
33
+ const projectOf = (k) => (launch.picks[k] === NO_FOLDER ? GENERAL : projectById(launch.picks[k]));
34
+ // Project chips in the "in" row, at most, before the rest fold into "+N".
35
+ const CHIPS = 5;
36
+
37
+ // Only a change of feed filter moves the launcher, so a "No folder" pick survives re-renders.
38
+ let filterSeen = null;
39
+
28
40
  function syncWhere() {
29
41
  launch.where = launch.where.filter((k, i) => (!k || machineById(k)) && launch.where.indexOf(k) === i);
30
42
  if (!launch.where.length) launch.where = [''];
31
43
  // Filtering the feed to a project points the launcher at it (its machine joins if it wasn't picked).
32
- const f = S.filter !== 'all' && projectById(S.filter);
44
+ const f = S.filter !== 'all' && S.filter !== filterSeen && projectById(S.filter);
45
+ filterSeen = S.filter;
33
46
  if (f) {
34
47
  if (!launch.where.includes(placeOf(f))) launch.where = [placeOf(f)];
35
48
  launch.picks[placeOf(f)] = f.id;
36
49
  } else if (projectById(launch.project) && !Object.values(launch.picks).includes(launch.project)) launch.picks[placeOf(projectById(launch.project))] ??= launch.project;
37
- for (const k of launch.where) if (!projectsAt(k).some((p) => p.id === launch.picks[k])) launch.picks[k] = projectsAt(k)[0]?.id || null;
50
+ for (const k of launch.where) if (launch.picks[k] !== NO_FOLDER && !cloneById(launch.picks[k]) && !projectsAt(k).some((p) => p.id === launch.picks[k])) launch.picks[k] = projectsAt(k)[0]?.id || null;
38
51
  launch.project = launch.picks[launch.where[0]] || null;
39
52
  }
40
53
 
@@ -44,6 +57,12 @@ export function renderLauncher() {
44
57
  const here = harnessesAt(launch.where, { auto: true });
45
58
  const chosen = here.find((a) => a.id === (S.config.defaults.agent || 'auto'));
46
59
  let agent = chosen?.available ? chosen : here.find((a) => a.available) || chosen || here[0];
60
+ // Rendered over what's there (index.html's first paint, or fresh settings or harnesses): what
61
+ // was typed, and the caret if you're typing, carry over.
62
+ const old = $('#prompt', box);
63
+ const typing = old && document.activeElement === old && [old.selectionStart, old.selectionEnd];
64
+ if (old) S.drafts.set('home', old.value);
65
+ box.classList.remove('skel');
47
66
  box.innerHTML = `
48
67
  <div class="l-projects"></div>
49
68
  <div class="l-setup" hidden></div>
@@ -79,6 +98,7 @@ export function renderLauncher() {
79
98
  ta.addEventListener('focus', hint);
80
99
  ta.addEventListener('blur', hint);
81
100
  hint();
101
+ if (typing) (ta.focus(), ta.setSelectionRange(...typing));
82
102
  // Docked and folded to one line, a click anywhere on it opens it up for typing. A property, not
83
103
  // a listener: the launcher re-renders in place, and each render would add another.
84
104
  box.onmousedown = (e) => {
@@ -230,8 +250,8 @@ export function renderLauncher() {
230
250
  return feedbackCommand(prompt, { restore: () => !ta.value && ((ta.value = prompt), ta.dispatchEvent(new Event('input'))) });
231
251
  }
232
252
  // One run per place picked under "Run on", each in the project picked for it.
233
- const targets = launch.where.map((k) => ({ k, m: k ? machineById(k) : null, project: projectById(launch.picks[k]) }));
234
- const unset = targets.find((t) => !t.project);
253
+ const targets = launch.where.map((k) => ({ k, m: k ? machineById(k) : null, project: projectOf(k), clone: cloneById(launch.picks[k]) }));
254
+ const unset = targets.find((t) => !t.project && !t.clone);
235
255
  if (unset) return openProjects({ focusAdd: true, machine: unset.k || null });
236
256
  const off = targets.find((t) => t.m && t.m.state !== 'online');
237
257
  if (off) return offlineToast(off.k);
@@ -250,12 +270,13 @@ export function renderLauncher() {
250
270
  const files = tray.take();
251
271
  const cmp = compare;
252
272
  const runner = { agent: agent?.id || 'claude', model: curModel, effort: curEffort };
253
- const pending = targets.map((t) => addPending(prompt, t, runner));
273
+ // A repo still cloning gets a stand-in project; its card says so until the clone lands.
274
+ const pending = targets.map((t) => addPending(prompt, t.project ? t : { ...t, project: { id: t.clone.id, name: t.clone.name, path: '', color: 'slate' } }, runner, t.clone && `Cloning ${t.clone.label}`));
254
275
  if (cmp.length) (compare = []), paintCompare();
255
276
  ta.value = '';
256
277
  S.drafts.delete('home');
257
278
  ta.dispatchEvent(new Event('input'));
258
- if (S.filter !== 'all' && targets.some((t) => t.project.id !== S.filter)) {
279
+ if (S.filter !== 'all' && targets.some((t) => t.project?.scratch || t.project?.id !== S.filter)) {
259
280
  S.filter = 'all';
260
281
  save('wisp.filter', S.filter);
261
282
  }
@@ -264,13 +285,20 @@ export function renderLauncher() {
264
285
  showLaunched(btn, ghost);
265
286
  ta.focus();
266
287
  try {
288
+ for (const [i, t] of targets.entries()) {
289
+ if (!t.clone) continue;
290
+ const p = (t.project = await t.clone.promise);
291
+ // Now a real project, so the live session can replace the card (dropPending).
292
+ const card = S.sessions.get(pending[i]);
293
+ if (card) Object.assign(card, { projectId: p.id, project: p.name, cwd: p.path, color: p.color, live: 'Starting up' }), renderFeed();
294
+ }
267
295
  // Several places: one shared groupId, so the runs land as one task with a row each.
268
296
  const groupId = targets.length > 1 ? [...crypto.getRandomValues(new Uint8Array(6))].map((b) => b.toString(16).padStart(2, '0')).join('') : undefined;
269
297
  const results = await Promise.allSettled(
270
298
  targets.map((t) =>
271
299
  api('/sessions', {
272
300
  method: 'POST',
273
- body: { prompt, cwd: t.project.path, machine: t.k || undefined, groupId, agent: runner.agent, mode: curMode, model: runner.model, effort: runner.effort, attachments, worktree: S.config.defaults.worktree === 'off' && !cmp.length ? false : undefined, compare: cmp.length ? cmp : undefined, subagents: team.length && canLead(runner.agent) ? team : undefined },
301
+ body: { prompt, cwd: t.project.scratch ? undefined : t.project.path, scratch: t.project.scratch || undefined, machine: t.k || undefined, groupId, agent: runner.agent, mode: curMode, model: runner.model, effort: runner.effort, attachments, worktree: S.config.defaults.worktree === 'off' && !cmp.length ? false : undefined, compare: cmp.length ? cmp : undefined, subagents: team.length && canLead(runner.agent) ? team : undefined },
274
302
  }),
275
303
  ),
276
304
  );
@@ -291,7 +319,10 @@ export function renderLauncher() {
291
319
  } catch (err) {
292
320
  for (const id of pending) S.sessions.delete(id);
293
321
  renderFeed();
294
- toastError(err);
322
+ if (!err.shown) return toastError(err);
323
+ // The clone failed (its toast says why): the task goes back in the box.
324
+ if (!ta.value) ((ta.value = prompt), ta.dispatchEvent(new Event('input')));
325
+ tray.restore(files);
295
326
  }
296
327
  };
297
328
  $('#launch', box).onclick = go;
@@ -321,6 +352,12 @@ export function renderLauncher() {
321
352
  });
322
353
  }
323
354
 
355
+ // No folder everywhere picked: no git repo, so no worktree either; the toggle would only mislead.
356
+ function paintScratch() {
357
+ const wt = $('#launcher #wtBtn');
358
+ if (wt) wt.hidden = launch.where.every((k) => launch.picks[k] === NO_FOLDER);
359
+ }
360
+
324
361
  export function renderLaunchChips() {
325
362
  const box = $('#launcher .l-projects');
326
363
  if (!box) return;
@@ -332,23 +369,70 @@ export function renderLaunchChips() {
332
369
  const list = projectsAt(k);
333
370
  const off = m && m.state !== 'online';
334
371
  const label = k || multi ? `<span class="lp-gl ${off ? 'off' : ''}" title="${esc(k ? `${m?.target} · ${MACHINE_STATE[m?.state] || ''}` : 'This computer')}">${icon(k ? 'server' : 'laptop')}${esc(placeName(k))}${off ? `<button class="link xs" data-connect="${k}">${m.state === 'connecting' || m.state === 'reconnecting' ? 'connecting…' : 'Connect'}</button>` : ''}</span>` : '';
335
- const chips = list.map((p) => `<button class="lp-chip c-${p.color} ${p.id === launch.picks[k] ? 'on' : ''}" data-id="${p.id}" data-k="${k}" title="${esc(projectTitle(p))}"><i class="p-dot"></i>${esc(p.name)}</button>`).join('');
336
- const add = list.length
337
- ? `<button class="lp-chip add" data-add="${k}" title="Another folder${k ? ` on ${esc(placeName(k))}` : ''}">${icon('folder')}${multi ? '' : 'Other folder…'}</button>`
338
- : `<button class="lp-chip add" data-add="${k}">${icon('folder')}${k ? `Pick a project on ${esc(placeName(k))}…` : 'Choose a project folder…'}</button>`;
339
- return `<span class="lp-group">${label}${chips}${add}</span>`;
372
+ // A repo still cloning: picked, with a spinner, until the real project replaces it.
373
+ const cloning = clones
374
+ .filter((c) => (c.where || '') === k)
375
+ .map((c) => `<button class="lp-chip cloning ${c.id === launch.picks[k] ? 'on' : ''}" data-id="${c.id}" data-k="${k}" title="Cloning ${esc(c.label)}…"><span class="orb live"></span>${esc(c.name)}<span class="lp-note">cloning…</span></button>`)
376
+ .join('');
377
+ // Tens of projects: the most recent few as chips (the pick always among them), the rest one
378
+ // search away behind "+N". fitChips() then folds more into it if the row would wrap.
379
+ const max = multi ? 3 : CHIPS;
380
+ let shown = list;
381
+ if (list.length > max) {
382
+ const top = list.slice(0, max);
383
+ const picked = list.find((p) => p.id === launch.picks[k]);
384
+ shown = !picked || top.includes(picked) ? top : [...top.slice(0, -1), picked].sort((a, b) => list.indexOf(a) - list.indexOf(b));
385
+ }
386
+ const chips = cloning + shown.map((p) => `<button class="lp-chip c-${p.color} ${p.id === launch.picks[k] ? 'on' : ''}" data-id="${p.id}" data-k="${k}" title="${esc(projectTitle(p))}"><i class="p-dot"></i>${esc(p.name)}</button>`).join('');
387
+ const more = list.length > 1 ? `<button class="lp-chip more" data-more="${k}" data-total="${list.length}" title="All ${list.length} projects${k ? ` on ${esc(placeName(k))}` : ''}" aria-haspopup="dialog"><span class="lp-more-n"></span>${icon('search')}</button>` : '';
388
+ const add = list.length || cloning
389
+ ? `<button class="lp-chip add" data-add="${k}" title="Another folder${k ? ` on ${esc(placeName(k))}` : ''}">${icon('folder')}${multi || list.length > max ? '' : 'Other folder…'}</button>`
390
+ : `<button class="lp-chip add" data-add="${k}">${icon('folder')}${k ? `Pick a project on ${esc(placeName(k))}…` : 'Choose a folder…'}</button>`;
391
+ const none = `<button class="lp-chip none ${launch.picks[k] === NO_FOLDER ? 'on' : ''}" data-id="${NO_FOLDER}" data-k="${k}" title="No folder: a general ask, with no project and no worktree. The agent gets an empty scratch directory.">${icon('spark')}<span>No folder</span></button>`;
392
+ return `<span class="lp-group">${label}${chips}${more}${none}${add}</span>`;
340
393
  };
341
394
  box.innerHTML = `<span class="l-in">in</span>${launch.where.map(group).join('')}`;
395
+ fitChips(box);
396
+ fitRow ??= new ResizeObserver(([e]) => {
397
+ if (e.contentRect.width === fitRow.w) return; // its own folding only changes the height
398
+ fitRow.w = e.contentRect.width;
399
+ fitChips(e.target);
400
+ });
401
+ fitRow.observe(box);
402
+ paintScratch();
403
+ const pick = (k, id) => {
404
+ launch.picks[k] = id;
405
+ if (k === launch.where[0]) launch.project = id;
406
+ saveLaunch();
407
+ paintScratch();
408
+ renderLaunchRecipes();
409
+ };
342
410
  box.onclick = (e) => {
343
411
  const chip = e.target.closest('.lp-chip[data-id]');
344
412
  if (chip) {
345
- launch.picks[chip.dataset.k] = chip.dataset.id;
346
- if (chip.dataset.k === launch.where[0]) launch.project = chip.dataset.id;
347
- saveLaunch();
413
+ pick(chip.dataset.k, chip.dataset.id);
348
414
  $$(`.lp-chip[data-k="${chip.dataset.k}"]`, box).forEach((x) => x.classList.toggle('on', x === chip));
349
- renderLaunchRecipes();
350
415
  return;
351
416
  }
417
+ const more = e.target.closest('[data-more]');
418
+ if (more) {
419
+ const k = more.dataset.more;
420
+ return projectList(more, {
421
+ projects: projectsAt(k),
422
+ current: launch.picks[k],
423
+ label: `Projects on ${placeName(k)}`,
424
+ extras: [
425
+ { id: NO_FOLDER, label: 'No folder', icon: 'spark' },
426
+ { id: '+add', label: k ? `Another folder on ${placeName(k)}…` : 'Other folder…', icon: 'folder' },
427
+ ],
428
+ onPick: (id) => {
429
+ if (id === '+add') return openProjects({ focusAdd: true, machine: k || null });
430
+ pick(k, id);
431
+ renderLaunchChips(); // the pick joins the chips
432
+ $('#prompt')?.focus();
433
+ },
434
+ });
435
+ }
352
436
  const add = e.target.closest('[data-add]');
353
437
  if (add) openProjects({ focusAdd: true, machine: add.dataset.add || null });
354
438
  const conn = e.target.closest('[data-connect]');
@@ -358,6 +442,41 @@ export function renderLaunchChips() {
358
442
  paintWhereBtn();
359
443
  }
360
444
 
445
+ // One row, however many projects: on a single place, the least recent chips (never the pick, and
446
+ // at least one) fold into "+N" until the row stops wrapping. "+N" counts what isn't a chip.
447
+ let fitRow;
448
+ function fitChips(box) {
449
+ for (const g of $$('.lp-group', box)) {
450
+ const chips = $$('.lp-chip[data-id]:not(.none):not(.cloning)', g);
451
+ const more = $('.lp-chip.more', g);
452
+ chips.forEach((c) => (c.hidden = false));
453
+ const paint = () => {
454
+ const n = Number(more?.dataset.total) - chips.filter((c) => !c.hidden).length;
455
+ if (!more) return;
456
+ more.hidden = !n;
457
+ $('.lp-more-n', more).textContent = `+${n}`;
458
+ };
459
+ paint();
460
+ if (launch.where.length > 1) continue;
461
+ const wraps = () => {
462
+ const kids = [...g.children].filter((x) => !x.hidden && x.offsetParent);
463
+ const lin = $('.l-in', box); // "in" left alone on a line counts too
464
+ return kids.some((x) => x.offsetTop >= kids[0].offsetTop + kids[0].offsetHeight) || (lin && kids[0].offsetTop >= lin.offsetTop + lin.offsetHeight);
465
+ };
466
+ for (const c of chips.filter((x) => !x.classList.contains('on')).reverse()) {
467
+ if (!wraps() || chips.filter((x) => !x.hidden).length <= 1) break;
468
+ c.hidden = true;
469
+ paint();
470
+ }
471
+ // "+1" is no shorter than the one chip it would stand for: keep the chip, let the row wrap.
472
+ const folded = chips.filter((c) => c.hidden);
473
+ if (folded.length && Number(more?.dataset.total) - chips.length + folded.length === 1) {
474
+ folded.forEach((c) => (c.hidden = false));
475
+ paint();
476
+ }
477
+ }
478
+ }
479
+
361
480
  // "Run on": this computer and/or machines. The button says where; its menu picks one or several.
362
481
  function paintWhereBtn() {
363
482
  const b = $('#whereBtn');
@@ -486,7 +605,7 @@ function launchedFx(from, to) {
486
605
  // A launch the server hasn't answered yet: a placeholder summary that shows in Working as a
487
606
  // "Starting up" card. It can't be opened; the real card takes its place.
488
607
  let pendingSeq = 0;
489
- function addPending(prompt, { project: p, m }, { agent, model, effort }) {
608
+ function addPending(prompt, { project: p, m }, { agent, model, effort }, live = 'Starting up') {
490
609
  const id = `pending-${++pendingSeq}`;
491
610
  const line = plain(prompt).split('\n')[0].replace(/\s+/g, ' ');
492
611
  const now = Date.now();
@@ -498,11 +617,12 @@ function addPending(prompt, { project: p, m }, { agent, model, effort }) {
498
617
  model,
499
618
  effort,
500
619
  cwd: p.path,
620
+ scratch: p.scratch,
501
621
  project: p.name,
502
622
  projectId: p.id,
503
623
  color: p.color || 'slate',
504
624
  state: 'running',
505
- live: 'Starting up',
625
+ live,
506
626
  createdAt: now,
507
627
  updatedAt: now,
508
628
  machine: m ? { id: m.id, name: m.name, target: m.target } : null,
@@ -4,7 +4,7 @@ import { esc } from '../md.js';
4
4
  import { api } from '../core/api.js';
5
5
  import * as bus from '../core/bus.js';
6
6
  import { $ } from '../core/dom.js';
7
- import { S, saveOpen } from '../core/state.js';
7
+ import { S, saveOpen, setAgents } from '../core/state.js';
8
8
  import { toast } from '../ui/toast.js';
9
9
  import { paintAutoBtn } from './automations.js';
10
10
  import { collapse, renderFeed, scheduleRefetch, sectionOf } from './feed.js';
@@ -41,7 +41,7 @@ export function connect() {
41
41
  renderFeed();
42
42
  });
43
43
  if (prev?.state !== s.state || prev?.queued !== s.queued) scheduleRefetch(s.id);
44
- if (JSON.stringify([prev?.pr, prev?.toShip, prev?.state]) !== JSON.stringify([s.pr, s.toShip, s.state])) renderPrStrip(s.id);
44
+ if (JSON.stringify([prev?.pr, prev?.toShip, prev?.state, prev?.shipping]) !== JSON.stringify([s.pr, s.toShip, s.state, s.shipping])) renderPrStrip(s.id);
45
45
  });
46
46
  es.addEventListener('activity', (e) => onActivity(JSON.parse(e.data)));
47
47
  es.addEventListener('turn', (e) => scheduleRefetch(JSON.parse(e.data).sessionId));
@@ -66,7 +66,7 @@ export function connect() {
66
66
  });
67
67
  // A harness was installed from wisp (maybe in another tab): fetch the new list.
68
68
  es.addEventListener('agents', async () => {
69
- S.agents = await api('/agents').catch(() => S.agents);
69
+ setAgents(await api('/agents').catch(() => S.agents));
70
70
  $('#launcher')?.placesChanged?.();
71
71
  bus.emit('agents');
72
72
  });
@@ -0,0 +1,99 @@
1
+ // The project list: every project in a searchable list, for when there are too many to show as
2
+ // chips. The launcher's "in" row and the top bar's filter both open it from a "+N" chip.
3
+ //
4
+ // projects the list, in the order to show it (recent first)
5
+ // current the picked id, checked and scrolled to
6
+ // meta(p) extra html after a row's name (status badges)
7
+ // extras [{ id, label, icon }] fixed rows under the list (No folder, Other folder…)
8
+ // onPick (id) => …, a project's id or an extra's
9
+
10
+ import { esc } from '../md.js';
11
+ import { $, el, icon } from '../core/dom.js';
12
+ import { fuzzy, highlight } from '../fuzzy.js';
13
+ import { popover } from '../ui/popover.js';
14
+ import { projectTitle } from './places.js';
15
+
16
+ export function projectList(anchor, { projects, current, meta = () => '', extras = [], onPick, label = 'Projects' }) {
17
+ if ($('.menu.pm')) return $('.menu.pm').close();
18
+ const node = el(`
19
+ <div class="menu pm" role="dialog" aria-label="${esc(label)}">
20
+ <label class="field pm-search">${icon('search')}<input spellcheck="false" autocomplete="off" placeholder="Search ${projects.length} projects" aria-label="Search projects"></label>
21
+ <div class="pm-list" role="listbox"></div>
22
+ ${extras.length ? `<div class="pm-extras">${extras.map((x) => `<button class="pm-row pm-x" data-id="${esc(x.id)}">${icon(x.icon)}<span class="pm-name">${esc(x.label)}</span></button>`).join('')}</div>` : ''}
23
+ </div>`);
24
+ const input = $('input', node);
25
+ const list = $('.pm-list', node);
26
+ let act = 0;
27
+ const rows = () => [...node.querySelectorAll('.pm-row')];
28
+ const mark = (i, scroll = true) => {
29
+ const r = rows();
30
+ if (!r.length) return;
31
+ act = (i + r.length) % r.length;
32
+ r.forEach((x, k) => x.classList.toggle('act', k === act));
33
+ if (scroll) r[act].scrollIntoView({ block: 'nearest' });
34
+ };
35
+ const paint = () => {
36
+ const q = input.value.trim();
37
+ const hits = q
38
+ ? projects
39
+ .map((p) => ({ p, m: fuzzy(q, [p.name, p.path]) }))
40
+ .filter((x) => x.m)
41
+ .sort((a, b) => b.m.score - a.m.score)
42
+ : projects.map((p) => ({ p, m: null }));
43
+ list.innerHTML = hits.length
44
+ ? hits
45
+ .map(
46
+ ({ p, m }) => `<button class="pm-row c-${p.color} ${p.id === current ? 'on' : ''}" data-id="${esc(p.id)}" role="option" aria-selected="${p.id === current}" title="${esc(projectTitle(p))}">
47
+ <i class="p-dot"></i><span class="pm-name">${highlight(p.name, m?.idx, esc)}</span>${p.machine ? icon('server', 'pm-where') : ''}${meta(p)}<small class="pm-path">${esc(p.path.replace(/^\/(Users|home)\/[^/]+/, '~'))}</small>${icon('check', 'pm-check')}
48
+ </button>`,
49
+ )
50
+ .join('')
51
+ : `<div class="pm-empty">No project matches “${esc(q)}”</div>`;
52
+ // Searching starts on the best hit; the full list opens on the current pick.
53
+ const at = q ? 0 : Math.max(0, hits.findIndex((h) => h.p.id === current));
54
+ mark(at, !q);
55
+ };
56
+ const pick = (id) => {
57
+ close();
58
+ onPick(id);
59
+ };
60
+ node.onclick = (e) => {
61
+ const r = e.target.closest('.pm-row');
62
+ if (r) pick(r.dataset.id);
63
+ };
64
+ node.onmousemove = (e) => {
65
+ const r = e.target.closest('.pm-row');
66
+ if (r && !r.classList.contains('act')) mark(rows().indexOf(r), false);
67
+ };
68
+ input.oninput = paint;
69
+ input.onkeydown = (e) => {
70
+ if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
71
+ e.preventDefault();
72
+ mark(act + (e.key === 'ArrowDown' ? 1 : -1));
73
+ } else if (e.key === 'Enter' && !e.isComposing) {
74
+ e.preventDefault();
75
+ const r = rows()[act];
76
+ if (r) pick(r.dataset.id);
77
+ } else if (e.key === 'Tab') close();
78
+ };
79
+
80
+ const close = popover(node, {
81
+ anchor,
82
+ onClose: () => {
83
+ anchor.classList.remove('open');
84
+ anchor.setAttribute('aria-expanded', 'false');
85
+ },
86
+ });
87
+ anchor.classList.add('open');
88
+ anchor.setAttribute('aria-expanded', 'true');
89
+ paint();
90
+ // Under the chip, or over it when the page has no room below; never off the side.
91
+ const r = anchor.getBoundingClientRect();
92
+ const w = node.offsetWidth;
93
+ const h = node.offsetHeight;
94
+ node.style.left = `${Math.max(8, Math.min(r.left, innerWidth - w - 8))}px`;
95
+ node.style.top = `${r.bottom + 6 + h > innerHeight - 8 && r.top - h - 6 > 8 ? r.top - h - 6 : r.bottom + 6}px`;
96
+ rows()[act]?.scrollIntoView({ block: 'nearest' }); // placed: now bring the current pick into view
97
+ input.focus({ preventScroll: true });
98
+ return close;
99
+ }