@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/CHANGELOG.md +22 -0
- package/README.md +13 -1
- package/dist/core/summary.js +41 -0
- package/dist/server/server.js +125 -12
- package/dist/server/session.js +49 -5
- package/package.json +1 -1
- package/web/app.js +766 -43
- package/web/index.html +9 -1
- package/web/style.css +124 -2
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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-
|
|
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 (
|
|
1520
|
-
// task, the panel shows what it showed before. A closed panel
|
|
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 =
|
|
1529
|
-
S.focus = { at: '
|
|
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,
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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' &&
|
|
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 (
|
|
4335
|
-
|
|
4336
|
-
|
|
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();
|