@vale-dinap/lucidly 0.3.0 → 0.5.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/app.js CHANGED
@@ -1359,14 +1359,19 @@ function flowNode({ key, id, kind, st, label, tip, badge = '', extra = '' }) {
1359
1359
 
1360
1360
  function chainItems(g, q) {
1361
1361
  const steps = kids(g).filter((c) => c.type === 'step');
1362
+ // Each node also says, in a few words, what it stands for (`say`: Glance's line under the orb).
1362
1363
  const items = [
1363
- flowNode({ key: 'start', id: g.id, kind: 'start', st: 'start', label: 'Request', tip: `You asked (${clock(g.startedAt)}): ${clip(g.title, 300)}`, extra: ' start' }),
1364
+ {
1365
+ ...flowNode({ key: 'start', id: g.id, kind: 'start', st: 'start', label: 'Request', tip: `You asked (${clock(g.startedAt)}): ${clip(g.title, 300)}`, extra: ' start' }),
1366
+ say: { tag: 'Your request', st: 'you', text: g.startedAt ? `sent at ${clock(g.startedAt)}` : '' },
1367
+ },
1364
1368
  ];
1365
1369
  // A task with no steps but some work of its own: one node stands for that work.
1366
1370
  if (!steps.length && (g.stats?.actions ?? 0) > 0) {
1367
1371
  const kind = stepKind(g);
1368
1372
  const st = unresolvedOf(g) ? 'bad' : g.status === 'active' ? (liveNow() ? 'live' : 'idle') : 'done';
1369
- items.push(flowNode({ key: `w:${g.id}`, id: g.id, kind, st, label: plural(g.stats.actions, 'action'), tip: `${plural(g.stats.actions, 'action')}, mostly ${KIND_LABEL[kind]}` }));
1373
+ const text = `${plural(g.stats.actions, 'action')}, mostly ${KIND_LABEL[kind]}`;
1374
+ items.push({ ...flowNode({ key: `w:${g.id}`, id: g.id, kind, st, label: plural(g.stats.actions, 'action'), tip: text }), say: { tag: 'Work', st, text } });
1370
1375
  }
1371
1376
  // A task can run over several rounds: a reply after its turn ended continues it. The round's
1372
1377
  // end node stays where it was, turned into an intermediate goal, and the reply follows it.
@@ -1378,15 +1383,17 @@ function chainItems(g, q) {
1378
1383
  const st = reached ? 'done' : g.status === 'waiting' ? 'wait' : 'pend';
1379
1384
  const label = reached ? 'Done' : g.status === 'waiting' ? 'Interrupted' : 'Goal';
1380
1385
  const tip = g.outcome ? `Result: ${clip(g.outcome, 400)}` : `Goal: ${clip(goalAim(g), 300)}`;
1381
- return flowNode({ key: `end:${round}`, id: g.id, kind: 'end', st, label, tip, extra: ' end' });
1386
+ const say = { tag: g.outcome ? 'Result' : label === 'Done' ? 'Done' : label, st: st === 'done' ? 'done' : st === 'wait' ? 'wait' : 'pend', text: g.outcome ?? goalAim(g) };
1387
+ return { ...flowNode({ key: `end:${round}`, id: g.id, kind: 'end', st, label, tip, extra: ' end' }), say };
1382
1388
  }
1383
1389
  const label = r.interrupted ? 'Interrupted' : r.asked ? 'Asked' : 'Done';
1384
1390
  const tip = `${r.interrupted ? 'Interrupted' : r.asked ? 'Asked you' : 'Done for now'}${r.outcome ? `: ${clip(r.outcome, 400)}` : ''}\nThe task went on after your reply.`;
1385
- return flowNode({ key: `end:${round}`, id: g.id, kind: r.interrupted ? 'mid-wait' : r.asked ? 'mid-ask' : 'mid', st: r.interrupted ? 'wait' : 'done', label, tip, extra: ' mid' });
1391
+ const say = { tag: r.interrupted ? 'Interrupted' : r.asked ? 'Asked you' : 'Done for now', st: r.interrupted ? 'wait' : r.asked ? 'ask' : 'done', text: r.outcome ?? '' };
1392
+ return { ...flowNode({ key: `end:${round}`, id: g.id, kind: r.interrupted ? 'mid-wait' : r.asked ? 'mid-ask' : 'mid', st: r.interrupted ? 'wait' : 'done', label, tip, extra: ' mid' }), say };
1386
1393
  };
1387
1394
  // Every message of yours is a node: a reply that continued the task, or one sent while it worked.
1388
- const replyNode = (c) =>
1389
- flowNode({
1395
+ const replyNode = (c) => ({
1396
+ ...flowNode({
1390
1397
  key: `u:${c.id}`,
1391
1398
  id: g.id,
1392
1399
  kind: 'start',
@@ -1394,7 +1401,9 @@ function chainItems(g, q) {
1394
1401
  label: c.round ? 'Reply' : 'Message',
1395
1402
  tip: `${c.round ? 'You replied' : 'You wrote, while the agent worked'} (${clock(c.startedAt)}): ${clip(c.title.replace(/^You: /, ''), 300)}`,
1396
1403
  extra: ' reply',
1397
- });
1404
+ }),
1405
+ say: { tag: c.round ? 'Your reply' : 'Your message', st: 'you', text: c.title.replace(/^You: /, '') },
1406
+ });
1398
1407
  let i = 0;
1399
1408
  for (const c of kids(g)) {
1400
1409
  if (c.kind === 'user') {
@@ -1424,7 +1433,8 @@ function chainItems(g, q) {
1424
1433
  st === 'bad' ? 'Failed: nothing has worked after it yet' : rec ? 'Failed along the way, then recovered' : s.untracked ? "Done with the others: its work wasn't told apart" : '',
1425
1434
  ].filter(Boolean).join('\n');
1426
1435
  const badge = (st === 'bad' ? '<span class="fn-b bad">!</span>' : rec ? '<span class="fn-b rec"></span>' : '') + (a > 1 ? `<span class="fn-n">${a > 99 ? '99+' : a}</span>` : '');
1427
- items.push({ ...flowNode({ key: `s:${s.id}`, id: s.id, kind, st, label: s.title, tip, badge, extra: (s.untracked ? ' untr' : '') + dim }), i });
1436
+ const say = { tag: `Step ${i + 1} of ${steps.length}`, st, text: s.title };
1437
+ items.push({ ...flowNode({ key: `s:${s.id}`, id: s.id, kind, st, label: s.title, tip, badge, extra: (s.untracked ? ' untr' : '') + dim }), i, say });
1428
1438
  const cap = capsuleItem(s, dim);
1429
1439
  if (cap) items.push(cap);
1430
1440
  }
@@ -1486,8 +1496,8 @@ const glanceTime = (s) => (s.ago ? ago(s.since) : dur(s.since));
1486
1496
  function renderGlance(view) {
1487
1497
  if (!view.__glance) {
1488
1498
  view.innerHTML = `<div class="glance">
1489
- <div class="gx-state"><button type="button" class="gx-orb" data-glive><svg viewBox="0 0 24 24" aria-hidden="true"><path d=""/></svg></button><div class="gx-time"></div><div class="gx-ask" hidden><small>Waiting for your answer</small><span></span></div></div>
1490
- <div class="gx-title"></div>
1499
+ <div class="gx-state"><button type="button" class="gx-orb" data-glive><svg viewBox="0 0 24 24" aria-hidden="true"><path d=""/></svg></button><div class="gx-time"></div><div class="gx-ask" hidden><small>Waiting for your answer</small><span></span></div><button type="button" class="gx-agents" aria-haspopup="true" aria-expanded="false" hidden></button><div class="gx-agpop" role="menu" hidden></div></div>
1500
+ <div class="gx-lines"><div class="gx-line gx-task"></div><div class="gx-line gx-step"></div></div>
1491
1501
  <div class="gx-wheel"></div>
1492
1502
  <div class="gx-hint">Scroll or <kbd>↑</kbd> <kbd>↓</kbd> for other tasks · <kbd>←</kbd> <kbd>→</kbd> along the steps · <kbd>Enter</kbd> toggle selection · <kbd>F</kbd> back to now</div>
1493
1503
  </div>`;
@@ -1516,17 +1526,20 @@ function renderGlance(view) {
1516
1526
  S.gSettleTimer = setTimeout(schedule, wait + 20);
1517
1527
  }
1518
1528
  // With the side panel open, a past task shows its conversation there as soon as it reaches the
1519
- // spotlight (from your first message), without waiting for it to unfold; back on the live
1520
- // task, the panel shows what it showed before. A closed panel stays closed.
1529
+ // spotlight, at its goal (where the chain is centred, see placeChain), without waiting for it
1530
+ // to unfold; back on the live task, the panel shows what it showed before. A closed panel
1531
+ // stays closed.
1521
1532
  const past = !!spot && spot !== live;
1522
1533
  if (past && S.gOpened !== spot.id) {
1523
1534
  S.gOpened = spot.id;
1524
1535
  if (S.selected) {
1525
1536
  if (!S.gAutoSel) S.gPrevSel = { sel: S.selected, pick: S.flowPick, focus: S.focus };
1537
+ // The goal is the last round's end (each reply that continued the task began a round).
1538
+ const round = kids(spot).filter((c) => c.kind === 'user' && c.round).length;
1526
1539
  S.gAutoSel = spot.id;
1527
1540
  S.selected = spot.id;
1528
- S.flowPick = 'start';
1529
- S.focus = { at: 'request' };
1541
+ S.flowPick = `end:${round}`;
1542
+ S.focus = { at: 'end', round };
1530
1543
  }
1531
1544
  } else if (!past) {
1532
1545
  S.gOpened = null;
@@ -1562,10 +1575,6 @@ function renderGlance(view) {
1562
1575
  if (ask.hidden !== (q === null)) ask.hidden = q === null;
1563
1576
  const words = q || 'The agent asked you something.';
1564
1577
  if (q !== null && ask.lastChild.textContent !== words) ask.lastChild.textContent = words;
1565
- const title = root.querySelector('.gx-title');
1566
- const t = spot ? spot.title : '';
1567
- if (title.textContent !== t) title.textContent = t;
1568
- title.title = t;
1569
1578
 
1570
1579
  // The wheel: two tasks above the spotlight and two below, compressed to their node and fading
1571
1580
  // with distance. Empty slots keep the spotlight on the same spot at the start and end of the run.
@@ -1608,9 +1617,135 @@ function renderGlance(view) {
1608
1617
  if (!el.classList.contains('pad')) el.animate([{ opacity: 0, transform: `translateY(${(S.gDir ?? 1) > 0 ? 36 : -36}px)` }, {}], { duration: glanceFast() ? 170 : 380, easing: 'cubic-bezier(.2,.7,.3,1)' });
1609
1618
  } else if (Math.abs(from - top) > 1) el.animate([{ transform: `translateY(${from - top}px)` }, { transform: 'none' }], { duration: glanceFast() ? 170 : 380, easing: 'cubic-bezier(.2,.7,.3,1)' });
1610
1619
  }
1620
+ glanceLines(root, spot, spot === live ? s : null);
1621
+ glanceAgents(root);
1611
1622
  S.glanceReady = true;
1612
1623
  }
1613
1624
 
1625
+ // Small status marks for the lines under the orb: a spinning ring while it runs, a tick, a
1626
+ // cross, a balloon, a pause, or an empty ring for what hasn't started (or has gone quiet).
1627
+ const GX_MARK = { done: GLYPH.check, bad: 'M7 7l10 10 M17 7L7 17', ask: GLYPH['mid-ask'], wait: GLYPH.pause, you: PATHS.user };
1628
+ const gxMark = (st) => `<span class="gx-mk m-${st}" aria-hidden="true">${GX_MARK[st] ? SVG(GX_MARK[st]) : ''}</span>`;
1629
+
1630
+ /** The task in the spotlight's state, for its line: the live one follows the orb. */
1631
+ function glanceTaskSt(g, s) {
1632
+ if (s?.st === 'you') return 'ask';
1633
+ if (s?.st === 'bad' || g.status === 'failed') return 'bad';
1634
+ if (g.status === 'active') return s?.st === 'work' ? 'live' : 'idle';
1635
+ if (g.status === 'waiting') return 'wait';
1636
+ if (g.asked) return 'ask';
1637
+ return 'done';
1638
+ }
1639
+
1640
+ const GX_SAY = { live: 'Under way', idle: 'Under way', done: 'Done', bad: 'In trouble', ask: 'Asked you', wait: 'Interrupted' };
1641
+
1642
+ /**
1643
+ * Two lines under the orb: the task in the spotlight, and the node in the middle of its chain
1644
+ * (the step under way by default; ← → move it), each with a mark saying how it stands.
1645
+ */
1646
+ function glanceLines(root, g, s) {
1647
+ const task = root.querySelector('.gx-task');
1648
+ const step = root.querySelector('.gx-step');
1649
+ const set = (el, html, tip) => {
1650
+ if (el.__html !== html) {
1651
+ el.innerHTML = html;
1652
+ el.__html = html;
1653
+ }
1654
+ el.title = tip;
1655
+ };
1656
+ if (!g) {
1657
+ set(task, '', '');
1658
+ set(step, '', '');
1659
+ return;
1660
+ }
1661
+ const ts = glanceTaskSt(g, s);
1662
+ set(task, `${gxMark(ts)}<span class="gx-tx">${esc(g.title)}</span>`, `${GX_SAY[ts] ?? ''}: ${g.title}`);
1663
+ // The chain's middle, once the task has unfolded in the spotlight (until then, the line keeps
1664
+ // its place, empty, so nothing below it moves).
1665
+ const f = S.gFocusNow;
1666
+ const it = f?.goal === g.id ? S.gItemsNow?.[f.c] : null;
1667
+ const say = it?.say;
1668
+ if (!say) return set(step, '', '');
1669
+ const text = clip(say.text.replace(/\s+/g, ' '), 300);
1670
+ set(step, `${gxMark(say.st)}<small class="m-${say.st}">${esc(say.tag)}</small>${text ? `<span class="gx-tx">${esc(text)}</span>` : ''}`, `${say.tag}${text ? `: ${text}` : ''}`);
1671
+ }
1672
+
1673
+ /** Subagents still at work, anywhere in the run (a background one can outlive the agent's turn). */
1674
+ const runningAgents = () =>
1675
+ S.downAt || isStale() ? [] : Object.values(S.nodes).filter((n) => n.type === 'subtask' && n.origin === 'subagent' && n.status === 'active');
1676
+
1677
+ const agentName = (a) => a.title.replace(/^Subagent:\s*/, '');
1678
+
1679
+ /**
1680
+ * Running subagents, at a glance: a tag on the orb's left (the side the question balloon doesn't
1681
+ * use), there only while some run, naming the latest one, "+N" for the others. A click lists them
1682
+ * all; picking one brings the step that launched it into the spotlight (see glancePickAgent).
1683
+ */
1684
+ function glanceAgents(root) {
1685
+ // Latest first.
1686
+ const ags = runningAgents().sort((a, b) => (b.startedAt ?? '').localeCompare(a.startedAt ?? ''));
1687
+ const tag = root.querySelector('.gx-agents');
1688
+ const pop = root.querySelector('.gx-agpop');
1689
+ if (tag.hidden !== !ags.length) tag.hidden = !ags.length;
1690
+ if (!ags.length) S.gAgOpen = false;
1691
+ if (pop.hidden !== !S.gAgOpen) pop.hidden = !S.gAgOpen;
1692
+ tag.setAttribute('aria-expanded', String(!!S.gAgOpen));
1693
+ tag.classList.toggle('open', !!S.gAgOpen);
1694
+ if (!ags.length) return;
1695
+ const more = ags.length - 1;
1696
+ const html = `<span class="gx-mk m-live" aria-hidden="true"></span><span class="gx-ag-t"><small>${more ? 'Subagents running' : 'Subagent running'}</small><span class="gx-ag-n"><span class="gx-tx">${esc(agentName(ags[0]))}</span>${more ? `<b>+${more}</b>` : ''}</span></span>`;
1697
+ if (tag.__html !== html) {
1698
+ tag.innerHTML = html;
1699
+ tag.__html = html;
1700
+ }
1701
+ const tip = `${more ? `${ags.length} subagents are` : 'A subagent is'} still running${S.run.status === 'working' ? '' : ', after the agent finished its turn'}\nClick to see ${more ? 'them' : 'it'}`;
1702
+ tag.title = tip;
1703
+ tag.setAttribute('aria-label', tip);
1704
+ if (!S.gAgOpen) return;
1705
+ // The list: each subagent, how long it has run, and the task (and step) that launched it.
1706
+ const rows = ags
1707
+ .map((a) => {
1708
+ const up = ancestors(a.id);
1709
+ const step = up.find((n) => n.type === 'step');
1710
+ const goal = up.find((n) => n.type === 'goal');
1711
+ const where = [goal ? clip(goal.title, 60) : '', step ? clip(step.title, 60) : ''].filter(Boolean).join(' › ');
1712
+ const acts = a.stats?.actions ?? 0;
1713
+ return `<button type="button" class="gx-ag-row" role="menuitem" data-gpick="${a.id}" title="${esc(`${agentName(a)}\n${where}\nBring it into the spotlight`)}"><span class="gx-mk m-live" aria-hidden="true"></span><span class="gx-ag-t"><span class="gx-tx">${esc(agentName(a))}</span><small>${esc([a.startedAt ? dur(a.startedAt) : '', acts ? plural(acts, 'action') : '', where].filter(Boolean).join(' · '))}</small></span></button>`;
1714
+ })
1715
+ .join('');
1716
+ if (pop.__html !== rows) {
1717
+ pop.innerHTML = rows;
1718
+ pop.__html = rows;
1719
+ }
1720
+ }
1721
+
1722
+ /**
1723
+ * A subagent picked from the list: the wheel turns to its task, the chain centres on the step that
1724
+ * launched it, and the side panel, if it's open, shows the subagent.
1725
+ */
1726
+ function glancePickAgent(id) {
1727
+ const a = N(id);
1728
+ if (!a) return;
1729
+ const up = ancestors(id);
1730
+ const goal = up.find((n) => n.type === 'goal');
1731
+ const step = up.find((n) => n.type === 'step');
1732
+ S.gAgOpen = false;
1733
+ if (goal) {
1734
+ glanceSpot(goal.id);
1735
+ S.gFocus = { goal: goal.id, key: step ? `s:${step.id}` : `w:${goal.id}` };
1736
+ }
1737
+ if (S.selected) {
1738
+ // The panel follows the pick, not the task the wheel turned to (see renderGlance).
1739
+ S.gOpened = goal?.id ?? null;
1740
+ S.gAutoSel = null;
1741
+ S.gPrevSel = null;
1742
+ S.follow = false;
1743
+ S.selected = id;
1744
+ S.focus = null;
1745
+ }
1746
+ schedule();
1747
+ }
1748
+
1614
1749
  /** Moving fast through the wheel: rows slide quicker so they keep up. */
1615
1750
  const glanceFast = () => (S.gStreak ?? 0) >= 2 && Date.now() - (S.gStepAt ?? 0) < 200;
1616
1751
 
@@ -1657,15 +1792,16 @@ function placeChain(row, g, items, unfold) {
1657
1792
  const keys = items.map((it) => it.key);
1658
1793
  // The node in the middle.
1659
1794
  let c = S.gFocus?.goal === g.id ? keys.indexOf(S.gFocus.key) : -1;
1660
- // By default: on a past task, your request (where it began); on the live one, the step under
1795
+ // By default: on a past task, its goal (where it ended up); on the live one, the step under
1661
1796
  // way, its goal once it's done, or the latest step.
1662
- if (c < 0 && g.id !== (S.run.currentGoal ?? S.run.goals[S.run.goals.length - 1])) c = 0;
1797
+ if (c < 0 && g.id !== (S.run.currentGoal ?? S.run.goals[S.run.goals.length - 1])) c = n - 1;
1663
1798
  if (c < 0 && (g.status === 'done' || g.status === 'failed')) c = n - 1;
1664
1799
  if (c < 0) {
1665
1800
  for (let i = n - 1; i >= 0 && c < 0; i--) if (items[i].st === 'live' || items[i].st === 'idle' || items[i].st === 'bad') c = i;
1666
1801
  if (c < 0) c = Math.max(0, n - 2);
1667
1802
  }
1668
1803
  S.gFocusNow = { goal: g.id, keys, c };
1804
+ S.gItemsNow = items;
1669
1805
  const s = GLANCE_SLOT;
1670
1806
  const W = track.clientWidth;
1671
1807
  const mid = W / 2;
@@ -1835,6 +1971,8 @@ $('#pathView').addEventListener(
1835
1971
  'wheel',
1836
1972
  (e) => {
1837
1973
  if (S.view !== 'path' || S.layout !== 'glance' || e.ctrlKey) return;
1974
+ // The running-subagents list scrolls on its own.
1975
+ if (e.target instanceof Element && e.target.closest('.gx-agpop')) return;
1838
1976
  e.preventDefault();
1839
1977
  const now = Date.now();
1840
1978
  S.gWheel = (now - (S.gWheelAt ?? 0) > 400 ? 0 : (S.gWheel ?? 0)) + e.deltaY;
@@ -2272,7 +2410,7 @@ function renderHeader() {
2272
2410
  const r = S.run;
2273
2411
  $('#runTitle').textContent = r ? (r.title ?? r.cwd ?? 'Untitled run') : 'No run open';
2274
2412
  $('#runSub').textContent = r ? [r.cwd, r.updatedAt ? `updated ${ago(r.updatedAt)}` : ''].filter(Boolean).join(' · ') : '';
2275
- document.title = r?.title ? `${r.title} · Lucidly` : 'Lucidly';
2413
+ pageTitle();
2276
2414
  const [st, text] = runState();
2277
2415
  $('#status').dataset.status = st;
2278
2416
  $('#statusText').textContent = text;
@@ -3361,14 +3499,17 @@ function render() {
3361
3499
  }
3362
3500
 
3363
3501
  // ───────────────────────────── connection
3364
- function connect(runId) {
3502
+ function connect(runId, restore = null) {
3365
3503
  S.es?.close();
3366
3504
  // A session opens at the top, on the timeline; the arrow button (or F) jumps to the end.
3367
3505
  Object.assign(S, { runId, run: null, nodes: {}, selected: null, follow: false, raw: new Map(), lastFollowTarget: null, expandAll: null, firstPaint: true, seenActs: null });
3368
3506
  for (const set of [S.openGoals, S.closedGoals, S.openSteps, S.closedSteps, S.allActs, S.openActs, S.closedActs]) set.clear();
3369
3507
  Object.assign(S, { actions: null, actionsStale: true, actionsLimit: 400, stripRows: [], zoom: { a: 0, b: 1 } });
3508
+ // Back on a tab: its place is put back once its data is in (see restoreTab).
3509
+ S.restore = restore;
3510
+ if (restore) S.view = restore.view;
3370
3511
  writeHash();
3371
- const es = new EventSource(`/api/s/${runId}/stream`);
3512
+ const es = new EventSource(`/api/s/${runId}/stream?client=${CLIENT}`);
3372
3513
  S.es = es;
3373
3514
  es.addEventListener('open', () => setDown(false));
3374
3515
  es.addEventListener('snapshot', (e) => {
@@ -3376,8 +3517,10 @@ function connect(runId) {
3376
3517
  S.run = snap.run;
3377
3518
  S.nodes = snap.nodes;
3378
3519
  S.actionsStale = true;
3520
+ if (S.restore) restoreTab();
3379
3521
  for (const v of S.raw.values()) v.stale = true;
3380
3522
  schedule();
3523
+ renderTabs();
3381
3524
  });
3382
3525
  es.addEventListener('patch', (e) => {
3383
3526
  const p = JSON.parse(e.data);
@@ -3402,11 +3545,7 @@ function connect(runId) {
3402
3545
  setDown(false);
3403
3546
  if (!runs.some((r) => r.id === runId) && S.runId === runId) {
3404
3547
  es.close();
3405
- S.run = null;
3406
- S.runId = null;
3407
- history.replaceState(null, '', '#');
3408
- schedule();
3409
- openPicker();
3548
+ tabGone(runId);
3410
3549
  }
3411
3550
  } catch {
3412
3551
  // The server is gone: say so. EventSource keeps retrying, and reconnects when it is back.
@@ -3415,6 +3554,276 @@ function connect(runId) {
3415
3554
  };
3416
3555
  }
3417
3556
 
3557
+ // ───────────────────────────── session tabs
3558
+ /*
3559
+ * Several sessions open at once, one tab each. Only the tab you're on is fully loaded (its own
3560
+ * stream, as before); the others get their state (working, your turn, done...) from one light
3561
+ * stream for all of them (/api/tabs/stream), so their dots stay live. Switching saves where you
3562
+ * were in the tab you leave (view, selection, spotlight, scroll) and puts it back when you return.
3563
+ *
3564
+ * Picking a session replaces the one in the current tab; Ctrl/⌘-click, a middle click, the row's
3565
+ * "new tab" button or the strip's + open it in a new tab. A session already in a tab is never
3566
+ * opened twice: picking it switches to its tab. Closing a tab stops its session on the server,
3567
+ * unless another page has it too. The strip shows from two tabs on; the tabs are kept per browser
3568
+ * tab (sessionStorage), by session file, so they come back after a reload or a server restart.
3569
+ */
3570
+ const CLIENT = (() => {
3571
+ const make = () => globalThis.crypto?.randomUUID?.() ?? `c${Math.random().toString(36).slice(2)}${Date.now().toString(36)}`;
3572
+ try {
3573
+ let id = sessionStorage.getItem('lucidly.client');
3574
+ if (!id) sessionStorage.setItem('lucidly.client', (id = make()));
3575
+ return id;
3576
+ } catch {
3577
+ return make();
3578
+ }
3579
+ })();
3580
+
3581
+ const T = { tabs: [], active: null, seq: 0, es: null, esIds: '' };
3582
+ /** A tab not on screen that turns one of these wants you: it flashes, then glows until you open it. */
3583
+ const TAB_ATTN = new Set(['you', 'bad', 'done']);
3584
+ /** How long it flashes (the CSS animation: 4 × 0.8s). */
3585
+ const TAB_FLASH_MS = 3200;
3586
+ const TAB_SAY = { work: 'Working', you: 'Your turn: it asked you', bad: 'Working, with failures', done: 'Finished', rest: 'Finished a while ago', quiet: 'Quiet', paused: 'Interrupted' };
3587
+ // What a tab keeps of where you were.
3588
+ const TAB_KEEP = ['selected', 'follow', 'expandAll', 'layout', 'pf', 'af', 'ff', 'zoom', 'focus', 'flowPick', 'gSpot', 'gFocus', 'gOpened', 'gPrevSel', 'gAutoSel'];
3589
+ const TAB_SETS = ['openGoals', 'closedGoals', 'openSteps', 'closedSteps', 'allActs', 'openActs', 'closedActs'];
3590
+
3591
+ const activeTab = () => T.tabs.find((t) => t.uid === T.active) ?? null;
3592
+ const tabOf = (runId) => T.tabs.find((t) => t.runId === runId) ?? null;
3593
+ const samePath = (a, b) => !!a && !!b && a.replace(/\\/g, '/').toLowerCase() === b.replace(/\\/g, '/').toLowerCase();
3594
+ const tabOfPath = (path) => T.tabs.find((t) => samePath(t.path, path)) ?? null;
3595
+ const newTab = (runId, o = {}) => ({ uid: ++T.seq, runId, path: o.path ?? null, title: o.title ?? '', sum: null, attn: false, flashAt: 0, state: null });
3596
+
3597
+ function saveTabs() {
3598
+ try {
3599
+ const i = T.tabs.findIndex((t) => t.uid === T.active);
3600
+ sessionStorage.setItem('lucidly.tabs', JSON.stringify({ tabs: T.tabs.map((t) => ({ runId: t.runId, path: t.path, title: t.title })), active: i }));
3601
+ } catch {
3602
+ // Storage blocked: the tabs just won't survive a reload.
3603
+ }
3604
+ }
3605
+
3606
+ function loadTabs() {
3607
+ try {
3608
+ const v = JSON.parse(sessionStorage.getItem('lucidly.tabs') ?? 'null');
3609
+ return v && Array.isArray(v.tabs) ? v : { tabs: [], active: -1 };
3610
+ } catch {
3611
+ return { tabs: [], active: -1 };
3612
+ }
3613
+ }
3614
+
3615
+ /** Where you are in the session on screen, to put back when you return to its tab. */
3616
+ function tabState() {
3617
+ const st = { view: S.view, top: $('#pane').scrollTop, raw: S.raw };
3618
+ for (const k of TAB_KEEP) st[k] = S[k] && typeof S[k] === 'object' ? structuredClone(S[k]) : S[k];
3619
+ for (const k of TAB_SETS) st[k] = new Set(S[k]);
3620
+ return st;
3621
+ }
3622
+
3623
+ /** Called with the session's first data, once back on its tab (see connect). */
3624
+ function restoreTab() {
3625
+ const st = S.restore;
3626
+ S.restore = null;
3627
+ for (const k of TAB_KEEP) S[k] = st[k];
3628
+ for (const k of TAB_SETS) S[k] = st[k];
3629
+ S.raw = st.raw;
3630
+ S.view = st.view;
3631
+ S.firstPaint = false;
3632
+ // Following finds its own place; otherwise the scroll position comes back once drawn.
3633
+ requestAnimationFrame(() => requestAnimationFrame(() => S.follow || ($('#pane').scrollTop = st.top)));
3634
+ }
3635
+
3636
+ /** Shows a tab's session (saving where you were in the one you leave). */
3637
+ function showTab(tab) {
3638
+ if (!tab) return;
3639
+ if (T.active === tab.uid && S.runId === tab.runId) return;
3640
+ const cur = activeTab();
3641
+ if (cur && cur !== tab && S.run && S.runId === cur.runId) cur.state = tabState();
3642
+ T.active = tab.uid;
3643
+ tab.attn = false;
3644
+ S.gAgOpen = false;
3645
+ connect(tab.runId, tab.state);
3646
+ tab.state = null;
3647
+ renderTabs();
3648
+ saveTabs();
3649
+ }
3650
+
3651
+ /**
3652
+ * Shows a run: switches to its tab if one has it; otherwise opens it in a new tab (`newTab`, or
3653
+ * when there's none yet) or in place of the current tab's session, which then stops.
3654
+ */
3655
+ function openRun(runId, o = {}) {
3656
+ const have = tabOf(runId) ?? (o.path ? tabOfPath(o.path) : null);
3657
+ if (have) {
3658
+ have.runId = runId;
3659
+ showTab(have);
3660
+ return tabsChanged();
3661
+ }
3662
+ const cur = activeTab();
3663
+ if (cur && !o.newTab) {
3664
+ const old = cur.runId;
3665
+ Object.assign(cur, newTab(runId, o), { uid: cur.uid });
3666
+ T.active = null;
3667
+ showTab(cur);
3668
+ releaseRun(old);
3669
+ } else {
3670
+ const t = newTab(runId, o);
3671
+ // A new tab goes right after the current one.
3672
+ const i = cur ? T.tabs.indexOf(cur) + 1 : T.tabs.length;
3673
+ T.tabs.splice(i, 0, t);
3674
+ showTab(t);
3675
+ }
3676
+ tabsChanged();
3677
+ }
3678
+
3679
+ /** The run stops on the server, unless another tab or page still has it. */
3680
+ function releaseRun(runId) {
3681
+ if (!runId || tabOf(runId)) return;
3682
+ fetch(`/api/s/${runId}?client=${encodeURIComponent(CLIENT)}`, { method: 'DELETE' }).catch(() => {});
3683
+ }
3684
+
3685
+ function closeTab(tab) {
3686
+ const i = T.tabs.indexOf(tab);
3687
+ if (i < 0) return;
3688
+ T.tabs.splice(i, 1);
3689
+ if (T.active === tab.uid) {
3690
+ T.active = null;
3691
+ const next = T.tabs[i] ?? T.tabs[i - 1];
3692
+ if (next) showTab(next);
3693
+ else closeAll();
3694
+ }
3695
+ releaseRun(tab.runId);
3696
+ tabsChanged();
3697
+ }
3698
+
3699
+ /** No tab left: the page is empty, and asks which session to open. */
3700
+ function closeAll() {
3701
+ S.es?.close();
3702
+ S.es = null;
3703
+ Object.assign(S, { runId: null, run: null, nodes: {}, selected: null, restore: null });
3704
+ history.replaceState(null, '', '#');
3705
+ schedule();
3706
+ openPicker();
3707
+ }
3708
+
3709
+ /**
3710
+ * A tab's run is gone from the server (it restarted, or closed it): a session file is opened
3711
+ * again, in the same tab; a run that can't be (a saved snapshot, a stream) closes its tab.
3712
+ */
3713
+ async function tabGone(runId) {
3714
+ const tab = tabOf(runId);
3715
+ if (!tab) {
3716
+ if (S.runId === runId) closeAll();
3717
+ return;
3718
+ }
3719
+ if (tab.reopening) return;
3720
+ tab.reopening = true;
3721
+ let id = null;
3722
+ if (tab.path) {
3723
+ try {
3724
+ const res = await fetch('/api/open', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ path: tab.path }) });
3725
+ if (res.ok) id = (await res.json()).run.id;
3726
+ } catch {
3727
+ // The server is down: the page says so (setDown), and the tab waits for it.
3728
+ tab.reopening = false;
3729
+ return;
3730
+ }
3731
+ }
3732
+ tab.reopening = false;
3733
+ if (!id) return closeTab(tab);
3734
+ const shown = T.active === tab.uid;
3735
+ const st = shown && S.run ? tabState() : tab.state;
3736
+ tab.runId = id;
3737
+ if (shown) {
3738
+ connect(id, st);
3739
+ tab.state = null;
3740
+ }
3741
+ tabsChanged();
3742
+ }
3743
+
3744
+ /** The tab set changed: keep it, and follow the states of exactly these runs. */
3745
+ function tabsChanged() {
3746
+ saveTabs();
3747
+ renderTabs();
3748
+ const ids = T.tabs.map((t) => t.runId).join(',');
3749
+ if (T.es && T.esIds === ids) return;
3750
+ T.es?.close();
3751
+ T.es = null;
3752
+ T.esIds = ids;
3753
+ if (!ids) return;
3754
+ const es = new EventSource(`/api/tabs/stream?client=${encodeURIComponent(CLIENT)}&runs=${ids}`);
3755
+ T.es = es;
3756
+ es.addEventListener('tabs', (e) => {
3757
+ const { runs } = JSON.parse(e.data);
3758
+ const turned = [];
3759
+ for (const [id, sum] of Object.entries(runs)) {
3760
+ const tab = tabOf(id);
3761
+ if (!tab) continue;
3762
+ if (sum.gone) {
3763
+ void tabGone(id);
3764
+ continue;
3765
+ }
3766
+ const before = tab.sum?.state;
3767
+ tab.sum = sum;
3768
+ if (before && before !== sum.state && TAB_ATTN.has(sum.state)) turned.push([tab, sum.state]);
3769
+ if (sum.title) tab.title = sum.title;
3770
+ if (before && before !== sum.state && TAB_ATTN.has(sum.state) && (tab.uid !== T.active || document.hidden)) {
3771
+ tab.attn = true;
3772
+ tab.flashAt = Date.now();
3773
+ setTimeout(renderTabs, TAB_FLASH_MS + 50);
3774
+ }
3775
+ if (!TAB_ATTN.has(sum.state)) tab.attn = false;
3776
+ }
3777
+ renderTabs();
3778
+ saveTabs();
3779
+ soundFor(turned);
3780
+ });
3781
+ }
3782
+
3783
+ // Back on the page: the tab on screen no longer needs to call you.
3784
+ document.addEventListener('visibilitychange', () => {
3785
+ const t = activeTab();
3786
+ if (!document.hidden && t?.attn) {
3787
+ t.attn = false;
3788
+ renderTabs();
3789
+ }
3790
+ });
3791
+
3792
+ function renderTabs() {
3793
+ const bar = $('#tabs');
3794
+ bar.hidden = T.tabs.length < 2;
3795
+ const act = activeTab();
3796
+ const now = Date.now();
3797
+ const html =
3798
+ T.tabs
3799
+ .map((t) => {
3800
+ const on = t === act;
3801
+ const title = (on && S.run?.title) || t.title || 'Untitled session';
3802
+ const st = t.sum?.state ?? 'quiet';
3803
+ const say = TAB_SAY[st] ?? '';
3804
+ const q = st === 'you' && t.sum?.question ? `: “${clip(t.sum.question, 160)}”` : '';
3805
+ const ag = t.sum?.agents ? ` · ${plural(t.sum.agents, 'subagent')} running` : '';
3806
+ const tip = `${title}\n${say}${q}${ag}`;
3807
+ return `<div class="tab${on ? ' on' : ''}${t.attn ? ' attn' : ''}${t.attn && now - t.flashAt < TAB_FLASH_MS ? ' flash' : ''}" role="tab" aria-selected="${on}" tabindex="${on ? 0 : -1}" data-tab="${t.uid}" title="${esc(tip)}"><span class="tdot s-${st}" aria-label="${esc(say)}"></span><span class="tt">${esc(title)}</span>${t.sum?.agents ? `<span class="tag" aria-hidden="true">${t.sum.agents}</span>` : ''}<button type="button" class="tx" data-tabx="${t.uid}" aria-label="Close tab" title="Close tab">${CLOSE}</button></div>`;
3808
+ })
3809
+ .join('') + `<button type="button" class="tab-add" data-tabadd title="Open a session in a new tab" aria-label="Open a session in a new tab">${SVG('M12 5v14M5 12h14')}</button>`;
3810
+ fill(bar, html);
3811
+ pageTitle();
3812
+ }
3813
+
3814
+ /** The page's title: the session on screen, after how many tabs want you, if any. */
3815
+ function pageTitle() {
3816
+ const name = S.run && (S.run.title ?? activeTab()?.title);
3817
+ const n = T.tabs.filter((t) => t.attn).length;
3818
+ document.title = `${n ? `(${n}) ` : ''}${name ? `${name} · Lucidly` : 'Lucidly'}`;
3819
+ }
3820
+
3821
+ function cycleTab(by) {
3822
+ const i = T.tabs.indexOf(activeTab());
3823
+ if (T.tabs.length < 2 || i < 0) return;
3824
+ showTab(T.tabs[(i + by + T.tabs.length) % T.tabs.length]);
3825
+ }
3826
+
3418
3827
  // ───────────────────────────── picker
3419
3828
  /*
3420
3829
  * The session list. While it is open (and the tab is visible) it refreshes every few seconds, so a
@@ -3423,12 +3832,19 @@ function connect(runId) {
3423
3832
  * one leaves the list as it was (the server-stopped bar says why).
3424
3833
  */
3425
3834
  const PICKER_REFRESH = 4000;
3426
- async function openPicker({ quiet = false } = {}) {
3835
+ async function openPicker({ quiet = false, newTab = false } = {}) {
3427
3836
  const dlg = $('#picker');
3428
3837
  if (!dlg.open) {
3429
3838
  if (quiet) return;
3430
3839
  dlg.showModal();
3431
3840
  }
3841
+ if (!quiet) {
3842
+ // From the strip's +, what you pick opens in a new tab; otherwise it replaces this tab's session.
3843
+ S.pickNew = newTab && T.tabs.length > 0;
3844
+ $('#pickerTitle').textContent = S.pickNew ? 'Open in a new tab' : 'Open session';
3845
+ const mod = /Mac|iP(hone|ad)/.test(navigator.platform) ? '⌘' : 'Ctrl';
3846
+ $('#pickerHint').textContent = T.tabs.length && !S.pickNew ? `${mod}-click or middle-click a session to open it in a new tab.` : '';
3847
+ }
3432
3848
  S.pickerTimer ??= setInterval(() => {
3433
3849
  if (!dlg.open) S.pickerTimer = void clearInterval(S.pickerTimer);
3434
3850
  else if (!document.hidden) openPicker({ quiet: true });
@@ -3478,10 +3894,14 @@ function fill(el, html) {
3478
3894
  function sessionRow(s, withDir) {
3479
3895
  const title = s.title ?? s.firstPrompt ?? s.id;
3480
3896
  const sub = [s.title ? s.firstPrompt : '', withDir ? s.cwd ?? s.project : ''].filter(Boolean).join(' · ');
3481
- return `<li data-path="${esc(s.path)}" title="${esc(s.path)}">
3897
+ // A session already in a tab: picking it switches to that tab.
3898
+ const tab = tabOfPath(s.path);
3899
+ const mark = tab ? `<span class="pin-tab">${tab === activeTab() ? 'Showing' : 'In a tab'}</span>` : '';
3900
+ const add = tab ? '' : '<button type="button" class="pnew" data-newtab title="Open in a new tab">New tab</button>';
3901
+ return `<li data-path="${esc(s.path)}" title="${esc(s.path)}"${tab ? ' class="intab"' : ''}>
3482
3902
  <span class="dot${s.live ? ' live' : ''}" title="${s.live ? 'Active in the last few minutes' : ''}"></span>
3483
3903
  <div class="pm"><div class="pt">${esc(title)}</div><div class="ps">${esc(sub)}</div></div>
3484
- <span class="pw">${esc(ago(s.mtime))}</span></li>`;
3904
+ ${mark}<span class="pw">${esc(ago(s.mtime))}</span>${T.tabs.length ? add : ''}</li>`;
3485
3905
  }
3486
3906
 
3487
3907
  /** A directory as a comparable key: forward slashes, no trailing slash, any case on Windows. */
@@ -3530,12 +3950,15 @@ function sessionListHtml(data) {
3530
3950
  );
3531
3951
  }
3532
3952
 
3533
- async function openSession(path) {
3953
+ async function openSession(path, newTab = false) {
3954
+ $('#picker').close();
3955
+ // Already in a tab: switch to it.
3956
+ const have = tabOfPath(path);
3957
+ if (have && T.tabs.includes(have)) return showTab(have);
3534
3958
  const res = await fetch('/api/open', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ path }) });
3535
3959
  const data = await res.json();
3536
3960
  if (!res.ok) return alert(data.error ?? 'Could not open the session');
3537
- $('#picker').close();
3538
- connect(data.run.id);
3961
+ openRun(data.run.id, { path: data.run.path ?? path, title: data.run.title, newTab: newTab || S.pickNew });
3539
3962
  }
3540
3963
 
3541
3964
  async function loadFile(file) {
@@ -3543,7 +3966,7 @@ async function loadFile(file) {
3543
3966
  const res = await fetch('/api/load', { method: 'POST', headers: { 'content-type': 'application/json' }, body: text });
3544
3967
  const data = await res.json();
3545
3968
  if (!res.ok) return alert(data.error ?? 'Could not load the file');
3546
- connect(data.run.id);
3969
+ openRun(data.run.id, { title: data.run.title ?? data.run.label, newTab: S.pickNew });
3547
3970
  }
3548
3971
 
3549
3972
  // ───────────────────────────── interaction
@@ -3647,6 +4070,240 @@ document.addEventListener('change', (e) => {
3647
4070
  void syncWake();
3648
4071
  });
3649
4072
 
4073
+ // ───────────────────────────── sounds
4074
+ /*
4075
+ * Optional sound alerts: a short sound when a session finishes, asks you something or runs into
4076
+ * trouble, the same moments that make a tab flash (see tabsChanged), for every open tab. Off by
4077
+ * default. Each event has its own sound, chosen from a few, or None. "Only when I'm not looking"
4078
+ * (on by default) keeps quiet for the session on screen while the page is visible. The sounds are
4079
+ * made here (Web Audio), no files.
4080
+ *
4081
+ * Browsers only play sound once you've interacted with the page: turning sounds on, or a Test
4082
+ * button, counts. After a reload they wait for your first click or key, and the bell says so.
4083
+ */
4084
+ const SND_KINDS = ['done', 'you', 'bad'];
4085
+ const SND_LABEL = {
4086
+ done: ['Finished', 'The agent finished its turn.'],
4087
+ you: ['Asked you', 'It asked you something, and waits for your answer.'],
4088
+ bad: ['In trouble', 'Failures in the current step that nothing has fixed yet.'],
4089
+ };
4090
+ const SND_DEFAULT = { done: 'chime', you: 'question', bad: 'low' };
4091
+ S.snd = (() => {
4092
+ const d = { on: false, tones: { ...SND_DEFAULT }, away: true, vol: 0.6 };
4093
+ try {
4094
+ const v = JSON.parse(localStorage.getItem('lucidly.sound') ?? 'null');
4095
+ if (v && typeof v === 'object') {
4096
+ // (Names are checked against the sounds once those are defined, see SOUNDS.)
4097
+ const known = { has: (t) => typeof t === 'string' };
4098
+ const tones = { ...d.tones };
4099
+ for (const k of SND_KINDS) {
4100
+ const t = v.tones?.[k] ?? (v.kinds?.[k] === false ? 'none' : undefined);
4101
+ if (known.has(t)) tones[k] = t;
4102
+ }
4103
+ return { on: !!v.on, away: v.away !== false, vol: Number(v.vol) || d.vol, tones };
4104
+ }
4105
+ } catch {
4106
+ // Unreadable: the defaults.
4107
+ }
4108
+ return d;
4109
+ })();
4110
+ const saveSnd = () => {
4111
+ try {
4112
+ localStorage.setItem('lucidly.sound', JSON.stringify(S.snd));
4113
+ } catch {
4114
+ // Storage blocked: the choice lasts until the page closes.
4115
+ }
4116
+ };
4117
+
4118
+ let sndCtx = null;
4119
+ let sndLast = 0;
4120
+ /** The audio context, resumed when the browser allows it (needs a gesture on the page). */
4121
+ function audio() {
4122
+ if (!sndCtx) {
4123
+ const AC = globalThis.AudioContext ?? globalThis.webkitAudioContext;
4124
+ if (!AC) return null;
4125
+ sndCtx = new AC();
4126
+ sndCtx.addEventListener('statechange', renderSound);
4127
+ }
4128
+ if (sndCtx.state === 'suspended') sndCtx.resume().catch(() => {});
4129
+ return sndCtx;
4130
+ }
4131
+ const sndBlocked = () => S.snd.on && (!sndCtx || sndCtx.state !== 'running');
4132
+ // Your first click or key on the page lets sound play (after a reload).
4133
+ for (const ev of ['pointerdown', 'keydown'])
4134
+ addEventListener(ev, () => S.snd.on && sndBlocked() && audio(), { capture: true, passive: true });
4135
+
4136
+ /** One soft note: a quick attack and a gentle fade. */
4137
+ function note(c, out, at, freq, dur, type = 'sine', level = 1, to = 0) {
4138
+ const o = c.createOscillator();
4139
+ const g = c.createGain();
4140
+ o.type = type;
4141
+ o.frequency.setValueAtTime(freq, at);
4142
+ // A pitch that slides (a knock's thud, a rising call).
4143
+ if (to) o.frequency.exponentialRampToValueAtTime(to, at + dur);
4144
+ g.gain.setValueAtTime(0.0001, at);
4145
+ g.gain.exponentialRampToValueAtTime(level, at + 0.012);
4146
+ g.gain.exponentialRampToValueAtTime(0.0001, at + dur);
4147
+ o.connect(g).connect(out);
4148
+ o.start(at);
4149
+ o.stop(at + dur + 0.05);
4150
+ }
4151
+
4152
+ /** One held note, at full level until just before its end (alarms, sirens), its pitch sliding to `to`. */
4153
+ function hold(c, out, at, freq, dur, type = 'square', level = 0.3, to = 0) {
4154
+ const o = c.createOscillator();
4155
+ const g = c.createGain();
4156
+ o.type = type;
4157
+ o.frequency.setValueAtTime(freq, at);
4158
+ if (to) o.frequency.linearRampToValueAtTime(to, at + dur);
4159
+ g.gain.setValueAtTime(0.0001, at);
4160
+ g.gain.exponentialRampToValueAtTime(level, at + 0.01);
4161
+ g.gain.setValueAtTime(level, at + Math.max(0.01, dur - 0.03));
4162
+ g.gain.exponentialRampToValueAtTime(0.0001, at + dur);
4163
+ o.connect(g).connect(out);
4164
+ o.start(at);
4165
+ o.stop(at + dur + 0.05);
4166
+ }
4167
+
4168
+ /**
4169
+ * The sounds, in groups for the dropdown. Each draws its notes from time `t` into `out` (whose
4170
+ * gain is the volume). Gentle ones are soft and short; questions sound like they call you; alerts
4171
+ * are louder, longer and harsher, to be noticed from across the room.
4172
+ */
4173
+ const SND_GROUPS = [
4174
+ [
4175
+ 'Gentle',
4176
+ {
4177
+ chime: ['Chime', (c, o, t) => [523.25, 659.25, 783.99].forEach((f, i) => note(c, o, t + i * 0.11, f, i === 2 ? 0.8 : 0.5))],
4178
+ bell: ['Bell', (c, o, t) => (note(c, o, t, 659.25, 1.6, 'sine', 0.9), note(c, o, t, 1318.5, 0.9, 'sine', 0.3), note(c, o, t, 1810, 0.5, 'sine', 0.14))],
4179
+ marimba: ['Marimba', (c, o, t) => [[0, 783.99], [0.14, 1046.5]].forEach(([dt, f]) => (note(c, o, t + dt, f, 0.32), note(c, o, t + dt, f * 4, 0.06, 'sine', 0.15)))],
4180
+ harp: ['Harp', (c, o, t) => [523.25, 659.25, 783.99, 1046.5].forEach((f, i) => note(c, o, t + i * 0.07, f, 0.7, 'triangle', 0.7))],
4181
+ glass: ['Glass', (c, o, t) => (note(c, o, t, 1567.98, 0.9, 'sine', 0.55), note(c, o, t + 0.12, 2093, 1.1, 'sine', 0.42))],
4182
+ },
4183
+ ],
4184
+ [
4185
+ 'Questions',
4186
+ {
4187
+ question: ['Question', (c, o, t) => (note(c, o, t, 587.33, 0.45), note(c, o, t + 0.2, 880, 0.9), note(c, o, t + 0.2, 1760, 0.4, 'sine', 0.12))],
4188
+ doorbell: ['Doorbell', (c, o, t) => [[0, 659.25, 0.9], [0.45, 523.25, 1.3]].forEach(([dt, f, d]) => (note(c, o, t + dt, f, d, 'sine', 0.9), note(c, o, t + dt, f * 2, d / 2, 'sine', 0.25)))],
4189
+ ping: ['Ping', (c, o, t) => (note(c, o, t, 1318.5, 0.6, 'sine', 0.8), note(c, o, t, 2637, 0.25, 'sine', 0.15))],
4190
+ knock: ['Knock knock', (c, o, t) => [0, 0.17].forEach((dt) => note(c, o, t + dt, 190, 0.13, 'sine', 1, 95))],
4191
+ rising: ['Rising', (c, o, t) => (note(c, o, t, 440, 0.4, 'sine', 0.9, 880), note(c, o, t + 0.34, 880, 0.5, 'sine', 0.7))],
4192
+ },
4193
+ ],
4194
+ [
4195
+ 'Alerts',
4196
+ {
4197
+ low: ['Low tone', (c, o, t) => (note(c, o, t, 311.13, 0.4, 'triangle', 0.9), note(c, o, t + 0.22, 233.08, 0.75, 'triangle', 0.9))],
4198
+ beep: ['Beep', (c, o, t) => [0, 0.18].forEach((dt) => note(c, o, t + dt, 880, 0.12, 'square', 0.28))],
4199
+ triple: ['Triple beep', (c, o, t) => [0, 0.13, 0.26].forEach((dt) => hold(c, o, t + dt, 1000, 0.09, 'square', 0.3))],
4200
+ alarm: ['Alarm', (c, o, t) => [0, 1, 2, 3, 4, 5].forEach((i) => hold(c, o, t + i * 0.17, i % 2 ? 660 : 880, 0.15, 'square', 0.4))],
4201
+ siren: ['Siren', (c, o, t) => [0, 0.9].forEach((dt) => (hold(c, o, t + dt, 600, 0.45, 'sawtooth', 0.28, 1200), hold(c, o, t + dt + 0.45, 1200, 0.45, 'sawtooth', 0.28, 600)))],
4202
+ buzzer: ['Buzzer', (c, o, t) => (hold(c, o, t, 110, 0.7, 'sawtooth', 0.5), hold(c, o, t, 116, 0.7, 'square', 0.3))],
4203
+ klaxon: ['Klaxon', (c, o, t) => [[0, [466.16, 554.37], 0.35], [0.45, [415.3, 493.88], 0.55]].forEach(([dt, fs, d]) => fs.forEach((f) => hold(c, o, t + dt, f, d, 'square', 0.3)))],
4204
+ },
4205
+ ],
4206
+ ];
4207
+ const SOUNDS = Object.fromEntries(SND_GROUPS.flatMap(([, g]) => Object.entries(g)));
4208
+ // A saved sound that no longer exists falls back to the default.
4209
+ for (const k of SND_KINDS) if (S.snd.tones[k] !== 'none' && !SOUNDS[S.snd.tones[k]]) S.snd.tones[k] = SND_DEFAULT[k];
4210
+
4211
+ function playSound(tone) {
4212
+ const s = SOUNDS[tone];
4213
+ if (!s) return;
4214
+ const c = audio();
4215
+ if (!c) return;
4216
+ const out = c.createGain();
4217
+ out.gain.value = 0.32 * S.snd.vol;
4218
+ out.connect(c.destination);
4219
+ s[1](c, out, c.currentTime + 0.02);
4220
+ }
4221
+
4222
+ /**
4223
+ * Sessions whose state just changed ([tab, new state] pairs): plays one sound for them all, the
4224
+ * most pressing (in trouble, then asked you, then finished), at most every couple of seconds.
4225
+ */
4226
+ function soundFor(changes) {
4227
+ const s = S.snd;
4228
+ if (!s.on) return;
4229
+ const wanted = changes.filter(([tab, st]) => s.tones[st] !== 'none' && !(s.away && !document.hidden && tab.uid === T.active)).map(([, st]) => st);
4230
+ const kind = ['bad', 'you', 'done'].find((k) => wanted.includes(k));
4231
+ if (!kind || Date.now() - sndLast < 2000) return;
4232
+ sndLast = Date.now();
4233
+ playSound(s.tones[kind]);
4234
+ }
4235
+
4236
+ function soundState() {
4237
+ const s = S.snd;
4238
+ if (!s.on) return ['off', 'Off'];
4239
+ if (!globalThis.AudioContext && !globalThis.webkitAudioContext) return ['err', "This browser can't play sounds"];
4240
+ if (sndBlocked()) return ['wait', 'Waiting for a click on the page: browsers only play sound after you interact with it'];
4241
+ const kinds = SND_KINDS.filter((k) => s.tones[k] !== 'none').map((k) => SND_LABEL[k][0].toLowerCase());
4242
+ if (!kinds.length) return ['wait', 'On, but every event is set to None'];
4243
+ return ['on', `On: ${kinds.join(', ')}${s.away ? ", when you're not looking" : ''}`];
4244
+ }
4245
+
4246
+ function renderSound() {
4247
+ const [st, text] = soundState();
4248
+ const btn = $('#soundBtn');
4249
+ btn.dataset.snd = st;
4250
+ const label = st === 'off' ? 'Sound alerts' : `Sound alerts: ${text.charAt(0).toLowerCase()}${text.slice(1)}`;
4251
+ if (btn.title !== label) {
4252
+ btn.title = label;
4253
+ btn.setAttribute('aria-label', label);
4254
+ }
4255
+ const pop = $('#soundPop');
4256
+ if (pop.hidden) return;
4257
+ const s = S.snd;
4258
+ const rows = SND_KINDS.map(
4259
+ (k) =>
4260
+ `<div class="sn-ev"><span class="sn-l"><b>${SND_LABEL[k][0]}</b><small>${SND_LABEL[k][1]}</small></span><select data-snd="tone" data-kind="${k}" aria-label="Sound when ${SND_LABEL[k][0].toLowerCase()}"><option value="none"${s.tones[k] === 'none' ? ' selected' : ''}>None</option>${SND_GROUPS.map(([g, list]) => `<optgroup label="${g}">${Object.entries(list).map(([v, [l]]) => `<option value="${v}"${s.tones[k] === v ? ' selected' : ''}>${l}</option>`).join('')}</optgroup>`).join('')}</select><button type="button" class="tbtn" data-sndtest="${k}" title="Play this sound"${s.tones[k] === 'none' ? ' disabled' : ''}>Test</button></div>`,
4261
+ ).join('');
4262
+ setHTML(
4263
+ pop,
4264
+ `<div class="pop-h">Sounds</div>
4265
+ <label class="switch wk-row"><input type="checkbox" data-snd="on"${s.on ? ' checked' : ''} /><span></span>Play a sound when a session…</label>
4266
+ ${
4267
+ s.on
4268
+ ? `${rows}
4269
+ <label class="wk-opt sn-away"><input type="checkbox" data-snd="away"${s.away ? ' checked' : ''} /><span><b>Only when I'm not looking</b><small>No sound for the session on screen while this page is visible.</small></span></label>
4270
+ <label class="sn-vol">Volume<input type="range" min="0.1" max="1" step="0.05" value="${s.vol}" data-snd="vol" aria-label="Volume" /></label>
4271
+ <div class="wk-st st-${st}"><i></i>${esc(text)}</div>`
4272
+ : ''
4273
+ }
4274
+ <p class="wk-note">For the sessions in all your tabs.</p>`,
4275
+ );
4276
+ }
4277
+
4278
+ function toggleSound(show = $('#soundPop').hidden) {
4279
+ $('#soundPop').hidden = !show;
4280
+ $('#soundBtn').classList.toggle('on', show);
4281
+ renderSound();
4282
+ }
4283
+
4284
+ document.addEventListener('change', (e) => {
4285
+ const t = e.target;
4286
+ if (!(t instanceof HTMLInputElement || t instanceof HTMLSelectElement) || !t.dataset.snd) return;
4287
+ const s = S.snd;
4288
+ if (t.dataset.snd === 'on') {
4289
+ s.on = t.checked;
4290
+ // Turning it on is the click that lets the browser play sound.
4291
+ if (s.on) audio();
4292
+ } else if (t.dataset.snd === 'tone') {
4293
+ // Picking a sound plays it.
4294
+ s.tones[t.dataset.kind] = t.value;
4295
+ playSound(t.value);
4296
+ }
4297
+ else if (t.dataset.snd === 'away') s.away = t.checked;
4298
+ else if (t.dataset.snd === 'vol') {
4299
+ s.vol = Number(t.value);
4300
+ playSound(SND_KINDS.map((k) => s.tones[k]).find((v) => v !== 'none') ?? 'chime');
4301
+ }
4302
+ saveSnd();
4303
+ renderSound();
4304
+ });
4305
+ renderSound();
4306
+
3650
4307
  function toggleTokens(show = $('#tokensPop').hidden) {
3651
4308
  $('#tokensPop').hidden = !show;
3652
4309
  $('#tokens').classList.toggle('on', show);
@@ -3714,9 +4371,16 @@ $('#pathView').addEventListener(
3714
4371
 
3715
4372
  // ───────────────────────────── theme
3716
4373
  const THEMES = {
3717
- system: { label: 'System', icon: '<svg viewBox="0 0 24 24" aria-hidden="true"><rect x="3.5" y="4.5" width="17" height="11.5" rx="1.5" /><path d="M9 20h6M12 16v4" /></svg>' },
4374
+ // Light or dark, as the system says: the Light and Dark icons, pushed apart into opposite corners
4375
+ // and cut along a "/" diagonal, the sun above the cut and the moon (flipped, so it faces the
4376
+ // sun) below it.
4377
+ system: {
4378
+ label: 'System',
4379
+ icon: '<svg viewBox="0 0 24 24" aria-hidden="true"><clipPath id="thSun"><path d="M0 0h22.3L0 22.3z" /></clipPath><clipPath id="thMoon"><path d="M24 1.7V24H1.7z" /></clipPath><g clip-path="url(#thSun)"><g transform="translate(-2.26 -2.26) scale(.88)" stroke-width="2.05"><circle cx="12" cy="12" r="3.8" /><path d="M12 3v2M12 19v2M3 12h2M19 12h2M5.6 5.6l1.4 1.4M17 17l1.4 1.4M5.6 18.4L7 17M17 7l1.4-1.4" /></g></g><g clip-path="url(#thMoon)"><path transform="translate(5.24 5.24) scale(.88) translate(24 0) scale(-1 1)" stroke-width="2.05" d="M19.5 14.5A7.5 7.5 0 0 1 9.5 4.5a7.5 7.5 0 1 0 10 10z" /></g></svg>',
4380
+ },
3718
4381
  light: { label: 'Light', icon: '<svg viewBox="0 0 24 24" aria-hidden="true"><circle cx="12" cy="12" r="3.8" /><path d="M12 3v2M12 19v2M3 12h2M19 12h2M5.6 5.6l1.4 1.4M17 17l1.4 1.4M5.6 18.4L7 17M17 7l1.4-1.4" /></svg>' },
3719
- dark: { label: 'Dark', icon: '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M19.5 14.5A7.5 7.5 0 0 1 9.5 4.5a7.5 7.5 0 1 0 10 10z" /></svg>' },
4382
+ // The moon faces left, as in the System icon.
4383
+ dark: { label: 'Dark', icon: '<svg viewBox="0 0 24 24" aria-hidden="true"><path transform="translate(24 0) scale(-1 1)" d="M19.5 14.5A7.5 7.5 0 0 1 9.5 4.5a7.5 7.5 0 1 0 10 10z" /></svg>' },
3720
4384
  };
3721
4385
  const THEME_ORDER = ['system', 'light', 'dark'];
3722
4386
  const darkMedia = matchMedia('(prefers-color-scheme: dark)');
@@ -3815,8 +4479,18 @@ document.addEventListener('click', (e) => {
3815
4479
  if (!(t instanceof Element)) return;
3816
4480
  const on = (sel) => t.closest(sel);
3817
4481
  let el;
4482
+ // The running-subagents list closes on a click anywhere else.
4483
+ if (S.gAgOpen && !on('.gx-agpop') && !on('.gx-agents')) {
4484
+ S.gAgOpen = false;
4485
+ schedule();
4486
+ }
3818
4487
  if (on('[data-glive]')) return void glanceMove(0);
3819
4488
  if ((el = on('[data-gspot]'))) return void glanceSpot(el.dataset.gspot);
4489
+ if ((el = on('[data-gpick]'))) return void glancePickAgent(el.dataset.gpick);
4490
+ if (on('.gx-agents')) {
4491
+ S.gAgOpen = !S.gAgOpen;
4492
+ return void schedule();
4493
+ }
3820
4494
  if ((el = on('[data-copy]'))) {
3821
4495
  e.preventDefault();
3822
4496
  return void copyText(el);
@@ -3831,6 +4505,13 @@ document.addEventListener('click', (e) => {
3831
4505
  if (on('#wakeBtn')) return toggleWake();
3832
4506
  if (!on('#wakePop') && !$('#wakePop').hidden) toggleWake(false);
3833
4507
  if (on('#wakePop')) return;
4508
+ if (on('#soundBtn')) return toggleSound();
4509
+ if (!on('#soundPop') && !$('#soundPop').hidden) toggleSound(false);
4510
+ if ((el = on('[data-sndtest]'))) {
4511
+ audio();
4512
+ return playSound(S.snd.tones[el.dataset.sndtest]);
4513
+ }
4514
+ if (on('#soundPop')) return;
3834
4515
  if (on('#emptyOpen')) return void openPicker();
3835
4516
  if ((el = on('[data-view]'))) return setView(el.dataset.view);
3836
4517
  if (on('[data-pin]')) return togglePin();
@@ -4024,11 +4705,14 @@ document.addEventListener('click', (e) => {
4024
4705
  }
4025
4706
  return renderDetail();
4026
4707
  }
4027
- if ((el = on('#picker li[data-path]'))) return void openSession(el.dataset.path);
4708
+ if ((el = on('#picker li[data-path]'))) return void openSession(el.dataset.path, !!on('[data-newtab]') || e.ctrlKey || e.metaKey);
4028
4709
  if ((el = on('#picker li[data-run]'))) {
4029
4710
  $('#picker').close();
4030
- return connect(el.dataset.run);
4711
+ return openRun(el.dataset.run, { title: el.querySelector('.pt')?.textContent, newTab: S.pickNew || e.ctrlKey || e.metaKey });
4031
4712
  }
4713
+ if ((el = on('[data-tabx]'))) return closeTab(T.tabs.find((t) => t.uid === Number(el.dataset.tabx)));
4714
+ if (on('[data-tabadd]')) return void openPicker({ newTab: true });
4715
+ if ((el = on('[data-tab]'))) return showTab(T.tabs.find((t) => t.uid === Number(el.dataset.tab)));
4032
4716
  if (on('[data-picker-retry]')) return void openPicker();
4033
4717
  if ((el = on('[data-pgroup]'))) {
4034
4718
  S.pgOpen ??= new Map();
@@ -4270,6 +4954,18 @@ function updateCompact() {
4270
4954
  }
4271
4955
  $('#pane').addEventListener('scroll', () => requestAnimationFrame(updateCompact), { passive: true });
4272
4956
  $('#sessionsBtn').addEventListener('click', () => openPicker());
4957
+ // A middle click closes a tab, or opens a session in a new tab.
4958
+ document.addEventListener('auxclick', (e) => {
4959
+ if (e.button !== 1) return;
4960
+ const tab = e.target.closest?.('[data-tab]');
4961
+ const row = e.target.closest?.('#picker li[data-path]');
4962
+ if (!tab && !row) return;
4963
+ e.preventDefault();
4964
+ if (tab) closeTab(T.tabs.find((t) => t.uid === Number(tab.dataset.tab)));
4965
+ else void openSession(row.dataset.path, true);
4966
+ });
4967
+ // No page scrolling on a middle press over them (the browser's autoscroll).
4968
+ document.addEventListener('mousedown', (e) => e.button === 1 && e.target.closest?.('[data-tab], #picker li[data-path]') && e.preventDefault());
4273
4969
  $('#runBox').addEventListener('click', () => openPicker());
4274
4970
  $('#loadInput').addEventListener('change', (e) => {
4275
4971
  const f = e.target.files?.[0];
@@ -4308,12 +5004,17 @@ document.addEventListener('keydown', (e) => {
4308
5004
  e.preventDefault();
4309
5005
  $(S.view === 'path' ? '#pathSearch' : '#actionsSearch').focus();
4310
5006
  } else if (e.key === 'o') openPicker();
5007
+ else if (e.key === '[' || e.key === ']') cycleTab(e.key === '[' ? -1 : 1);
4311
5008
  else if (e.key === 'p' && S.run) togglePin();
4312
5009
  else if (e.key === 'h' && S.run && S.view === 'path') toggleHeat();
4313
5010
  else if ((e.key === '-' || e.key === '=' || e.key === '+' || e.key === '0') && S.run && S.view === 'path' && S.layout === 'simple') zoomFlow(e.key === '-' ? -1 : e.key === '0' ? 0 : 1);
4314
5011
  else if (e.key === 't' && S.run?.usage) toggleTokens();
4315
- else if (e.key === 'Escape' && !$('#tokensPop').hidden) toggleTokens(false);
5012
+ else if (e.key === 'Escape' && S.gAgOpen) {
5013
+ S.gAgOpen = false;
5014
+ schedule();
5015
+ } else if (e.key === 'Escape' && !$('#tokensPop').hidden) toggleTokens(false);
4316
5016
  else if (e.key === 'Escape' && !$('#wakePop').hidden) toggleWake(false);
5017
+ else if (e.key === 'Escape' && !$('#soundPop').hidden) toggleSound(false);
4317
5018
  else if (e.key === 'Escape' && !$('#picker').open) {
4318
5019
  S.follow = false;
4319
5020
  S.selected = null;
@@ -4330,10 +5031,32 @@ setInterval(() => S.run && renderHeader(), 15000);
4330
5031
  const m = /run=([\w-]+)/.exec(location.hash);
4331
5032
  const { runs } = await fetch('/api/runs').then((r) => r.json());
4332
5033
  if (location.hash.includes('view=actions')) S.view = 'actions';
5034
+ const kept = loadTabs();
5035
+ let active = null;
5036
+ for (const [i, k] of kept.tabs.entries()) {
5037
+ // Run ids start again from r1 when the server restarts: one counts only if it's the same session.
5038
+ let id = runs.find((r) => r.id === k.runId && (k.path ? samePath(r.path, k.path) : !r.path))?.id ?? null;
5039
+ if (!id && k.path) {
5040
+ try {
5041
+ const res = await fetch('/api/open', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ path: k.path }) });
5042
+ if (res.ok) id = (await res.json()).run.id;
5043
+ } catch {
5044
+ // Gone: the tab is dropped.
5045
+ }
5046
+ }
5047
+ if (!id || tabOf(id)) continue;
5048
+ const t = newTab(id, k);
5049
+ T.tabs.push(t);
5050
+ if (i === kept.active) active = t;
5051
+ }
4333
5052
  const wanted = m && runs.find((r) => r.id === m[1]);
4334
- if (wanted) connect(wanted.id);
4335
- else if (runs.length) connect(runs[runs.length - 1].id);
4336
- else {
5053
+ if (T.tabs.length) {
5054
+ showTab(active ?? T.tabs[0]);
5055
+ tabsChanged();
5056
+ } else if (wanted || runs.length) {
5057
+ const r = wanted ?? runs[runs.length - 1];
5058
+ openRun(r.id, { path: r.path, title: r.title ?? r.label });
5059
+ } else {
4337
5060
  // The server opened nothing (no obvious session for this folder): let the user choose.
4338
5061
  render();
4339
5062
  openPicker();