@vale-dinap/lucidly 0.3.0 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +15 -0
- package/README.md +12 -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 +521 -43
- package/web/index.html +3 -1
- package/web/style.css +99 -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,273 @@ 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
|
+
for (const [id, sum] of Object.entries(runs)) {
|
|
3759
|
+
const tab = tabOf(id);
|
|
3760
|
+
if (!tab) continue;
|
|
3761
|
+
if (sum.gone) {
|
|
3762
|
+
void tabGone(id);
|
|
3763
|
+
continue;
|
|
3764
|
+
}
|
|
3765
|
+
const before = tab.sum?.state;
|
|
3766
|
+
tab.sum = sum;
|
|
3767
|
+
if (sum.title) tab.title = sum.title;
|
|
3768
|
+
if (before && before !== sum.state && TAB_ATTN.has(sum.state) && (tab.uid !== T.active || document.hidden)) {
|
|
3769
|
+
tab.attn = true;
|
|
3770
|
+
tab.flashAt = Date.now();
|
|
3771
|
+
setTimeout(renderTabs, TAB_FLASH_MS + 50);
|
|
3772
|
+
}
|
|
3773
|
+
if (!TAB_ATTN.has(sum.state)) tab.attn = false;
|
|
3774
|
+
}
|
|
3775
|
+
renderTabs();
|
|
3776
|
+
saveTabs();
|
|
3777
|
+
});
|
|
3778
|
+
}
|
|
3779
|
+
|
|
3780
|
+
// Back on the page: the tab on screen no longer needs to call you.
|
|
3781
|
+
document.addEventListener('visibilitychange', () => {
|
|
3782
|
+
const t = activeTab();
|
|
3783
|
+
if (!document.hidden && t?.attn) {
|
|
3784
|
+
t.attn = false;
|
|
3785
|
+
renderTabs();
|
|
3786
|
+
}
|
|
3787
|
+
});
|
|
3788
|
+
|
|
3789
|
+
function renderTabs() {
|
|
3790
|
+
const bar = $('#tabs');
|
|
3791
|
+
bar.hidden = T.tabs.length < 2;
|
|
3792
|
+
const act = activeTab();
|
|
3793
|
+
const now = Date.now();
|
|
3794
|
+
const html =
|
|
3795
|
+
T.tabs
|
|
3796
|
+
.map((t) => {
|
|
3797
|
+
const on = t === act;
|
|
3798
|
+
const title = (on && S.run?.title) || t.title || 'Untitled session';
|
|
3799
|
+
const st = t.sum?.state ?? 'quiet';
|
|
3800
|
+
const say = TAB_SAY[st] ?? '';
|
|
3801
|
+
const q = st === 'you' && t.sum?.question ? `: “${clip(t.sum.question, 160)}”` : '';
|
|
3802
|
+
const ag = t.sum?.agents ? ` · ${plural(t.sum.agents, 'subagent')} running` : '';
|
|
3803
|
+
const tip = `${title}\n${say}${q}${ag}`;
|
|
3804
|
+
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>`;
|
|
3805
|
+
})
|
|
3806
|
+
.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>`;
|
|
3807
|
+
fill(bar, html);
|
|
3808
|
+
pageTitle();
|
|
3809
|
+
}
|
|
3810
|
+
|
|
3811
|
+
/** The page's title: the session on screen, after how many tabs want you, if any. */
|
|
3812
|
+
function pageTitle() {
|
|
3813
|
+
const name = S.run && (S.run.title ?? activeTab()?.title);
|
|
3814
|
+
const n = T.tabs.filter((t) => t.attn).length;
|
|
3815
|
+
document.title = `${n ? `(${n}) ` : ''}${name ? `${name} · Lucidly` : 'Lucidly'}`;
|
|
3816
|
+
}
|
|
3817
|
+
|
|
3818
|
+
function cycleTab(by) {
|
|
3819
|
+
const i = T.tabs.indexOf(activeTab());
|
|
3820
|
+
if (T.tabs.length < 2 || i < 0) return;
|
|
3821
|
+
showTab(T.tabs[(i + by + T.tabs.length) % T.tabs.length]);
|
|
3822
|
+
}
|
|
3823
|
+
|
|
3418
3824
|
// ───────────────────────────── picker
|
|
3419
3825
|
/*
|
|
3420
3826
|
* The session list. While it is open (and the tab is visible) it refreshes every few seconds, so a
|
|
@@ -3423,12 +3829,19 @@ function connect(runId) {
|
|
|
3423
3829
|
* one leaves the list as it was (the server-stopped bar says why).
|
|
3424
3830
|
*/
|
|
3425
3831
|
const PICKER_REFRESH = 4000;
|
|
3426
|
-
async function openPicker({ quiet = false } = {}) {
|
|
3832
|
+
async function openPicker({ quiet = false, newTab = false } = {}) {
|
|
3427
3833
|
const dlg = $('#picker');
|
|
3428
3834
|
if (!dlg.open) {
|
|
3429
3835
|
if (quiet) return;
|
|
3430
3836
|
dlg.showModal();
|
|
3431
3837
|
}
|
|
3838
|
+
if (!quiet) {
|
|
3839
|
+
// From the strip's +, what you pick opens in a new tab; otherwise it replaces this tab's session.
|
|
3840
|
+
S.pickNew = newTab && T.tabs.length > 0;
|
|
3841
|
+
$('#pickerTitle').textContent = S.pickNew ? 'Open in a new tab' : 'Open session';
|
|
3842
|
+
const mod = /Mac|iP(hone|ad)/.test(navigator.platform) ? '⌘' : 'Ctrl';
|
|
3843
|
+
$('#pickerHint').textContent = T.tabs.length && !S.pickNew ? `${mod}-click or middle-click a session to open it in a new tab.` : '';
|
|
3844
|
+
}
|
|
3432
3845
|
S.pickerTimer ??= setInterval(() => {
|
|
3433
3846
|
if (!dlg.open) S.pickerTimer = void clearInterval(S.pickerTimer);
|
|
3434
3847
|
else if (!document.hidden) openPicker({ quiet: true });
|
|
@@ -3478,10 +3891,14 @@ function fill(el, html) {
|
|
|
3478
3891
|
function sessionRow(s, withDir) {
|
|
3479
3892
|
const title = s.title ?? s.firstPrompt ?? s.id;
|
|
3480
3893
|
const sub = [s.title ? s.firstPrompt : '', withDir ? s.cwd ?? s.project : ''].filter(Boolean).join(' · ');
|
|
3481
|
-
|
|
3894
|
+
// A session already in a tab: picking it switches to that tab.
|
|
3895
|
+
const tab = tabOfPath(s.path);
|
|
3896
|
+
const mark = tab ? `<span class="pin-tab">${tab === activeTab() ? 'Showing' : 'In a tab'}</span>` : '';
|
|
3897
|
+
const add = tab ? '' : '<button type="button" class="pnew" data-newtab title="Open in a new tab">New tab</button>';
|
|
3898
|
+
return `<li data-path="${esc(s.path)}" title="${esc(s.path)}"${tab ? ' class="intab"' : ''}>
|
|
3482
3899
|
<span class="dot${s.live ? ' live' : ''}" title="${s.live ? 'Active in the last few minutes' : ''}"></span>
|
|
3483
3900
|
<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
|
|
3901
|
+
${mark}<span class="pw">${esc(ago(s.mtime))}</span>${T.tabs.length ? add : ''}</li>`;
|
|
3485
3902
|
}
|
|
3486
3903
|
|
|
3487
3904
|
/** A directory as a comparable key: forward slashes, no trailing slash, any case on Windows. */
|
|
@@ -3530,12 +3947,15 @@ function sessionListHtml(data) {
|
|
|
3530
3947
|
);
|
|
3531
3948
|
}
|
|
3532
3949
|
|
|
3533
|
-
async function openSession(path) {
|
|
3950
|
+
async function openSession(path, newTab = false) {
|
|
3951
|
+
$('#picker').close();
|
|
3952
|
+
// Already in a tab: switch to it.
|
|
3953
|
+
const have = tabOfPath(path);
|
|
3954
|
+
if (have && T.tabs.includes(have)) return showTab(have);
|
|
3534
3955
|
const res = await fetch('/api/open', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ path }) });
|
|
3535
3956
|
const data = await res.json();
|
|
3536
3957
|
if (!res.ok) return alert(data.error ?? 'Could not open the session');
|
|
3537
|
-
|
|
3538
|
-
connect(data.run.id);
|
|
3958
|
+
openRun(data.run.id, { path: data.run.path ?? path, title: data.run.title, newTab: newTab || S.pickNew });
|
|
3539
3959
|
}
|
|
3540
3960
|
|
|
3541
3961
|
async function loadFile(file) {
|
|
@@ -3543,7 +3963,7 @@ async function loadFile(file) {
|
|
|
3543
3963
|
const res = await fetch('/api/load', { method: 'POST', headers: { 'content-type': 'application/json' }, body: text });
|
|
3544
3964
|
const data = await res.json();
|
|
3545
3965
|
if (!res.ok) return alert(data.error ?? 'Could not load the file');
|
|
3546
|
-
|
|
3966
|
+
openRun(data.run.id, { title: data.run.title ?? data.run.label, newTab: S.pickNew });
|
|
3547
3967
|
}
|
|
3548
3968
|
|
|
3549
3969
|
// ───────────────────────────── interaction
|
|
@@ -3714,9 +4134,16 @@ $('#pathView').addEventListener(
|
|
|
3714
4134
|
|
|
3715
4135
|
// ───────────────────────────── theme
|
|
3716
4136
|
const THEMES = {
|
|
3717
|
-
|
|
4137
|
+
// Light or dark, as the system says: the Light and Dark icons, pushed apart into opposite corners
|
|
4138
|
+
// and cut along a "/" diagonal, the sun above the cut and the moon (flipped, so it faces the
|
|
4139
|
+
// sun) below it.
|
|
4140
|
+
system: {
|
|
4141
|
+
label: 'System',
|
|
4142
|
+
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>',
|
|
4143
|
+
},
|
|
3718
4144
|
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
|
-
|
|
4145
|
+
// The moon faces left, as in the System icon.
|
|
4146
|
+
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
4147
|
};
|
|
3721
4148
|
const THEME_ORDER = ['system', 'light', 'dark'];
|
|
3722
4149
|
const darkMedia = matchMedia('(prefers-color-scheme: dark)');
|
|
@@ -3815,8 +4242,18 @@ document.addEventListener('click', (e) => {
|
|
|
3815
4242
|
if (!(t instanceof Element)) return;
|
|
3816
4243
|
const on = (sel) => t.closest(sel);
|
|
3817
4244
|
let el;
|
|
4245
|
+
// The running-subagents list closes on a click anywhere else.
|
|
4246
|
+
if (S.gAgOpen && !on('.gx-agpop') && !on('.gx-agents')) {
|
|
4247
|
+
S.gAgOpen = false;
|
|
4248
|
+
schedule();
|
|
4249
|
+
}
|
|
3818
4250
|
if (on('[data-glive]')) return void glanceMove(0);
|
|
3819
4251
|
if ((el = on('[data-gspot]'))) return void glanceSpot(el.dataset.gspot);
|
|
4252
|
+
if ((el = on('[data-gpick]'))) return void glancePickAgent(el.dataset.gpick);
|
|
4253
|
+
if (on('.gx-agents')) {
|
|
4254
|
+
S.gAgOpen = !S.gAgOpen;
|
|
4255
|
+
return void schedule();
|
|
4256
|
+
}
|
|
3820
4257
|
if ((el = on('[data-copy]'))) {
|
|
3821
4258
|
e.preventDefault();
|
|
3822
4259
|
return void copyText(el);
|
|
@@ -4024,11 +4461,14 @@ document.addEventListener('click', (e) => {
|
|
|
4024
4461
|
}
|
|
4025
4462
|
return renderDetail();
|
|
4026
4463
|
}
|
|
4027
|
-
if ((el = on('#picker li[data-path]'))) return void openSession(el.dataset.path);
|
|
4464
|
+
if ((el = on('#picker li[data-path]'))) return void openSession(el.dataset.path, !!on('[data-newtab]') || e.ctrlKey || e.metaKey);
|
|
4028
4465
|
if ((el = on('#picker li[data-run]'))) {
|
|
4029
4466
|
$('#picker').close();
|
|
4030
|
-
return
|
|
4467
|
+
return openRun(el.dataset.run, { title: el.querySelector('.pt')?.textContent, newTab: S.pickNew || e.ctrlKey || e.metaKey });
|
|
4031
4468
|
}
|
|
4469
|
+
if ((el = on('[data-tabx]'))) return closeTab(T.tabs.find((t) => t.uid === Number(el.dataset.tabx)));
|
|
4470
|
+
if (on('[data-tabadd]')) return void openPicker({ newTab: true });
|
|
4471
|
+
if ((el = on('[data-tab]'))) return showTab(T.tabs.find((t) => t.uid === Number(el.dataset.tab)));
|
|
4032
4472
|
if (on('[data-picker-retry]')) return void openPicker();
|
|
4033
4473
|
if ((el = on('[data-pgroup]'))) {
|
|
4034
4474
|
S.pgOpen ??= new Map();
|
|
@@ -4270,6 +4710,18 @@ function updateCompact() {
|
|
|
4270
4710
|
}
|
|
4271
4711
|
$('#pane').addEventListener('scroll', () => requestAnimationFrame(updateCompact), { passive: true });
|
|
4272
4712
|
$('#sessionsBtn').addEventListener('click', () => openPicker());
|
|
4713
|
+
// A middle click closes a tab, or opens a session in a new tab.
|
|
4714
|
+
document.addEventListener('auxclick', (e) => {
|
|
4715
|
+
if (e.button !== 1) return;
|
|
4716
|
+
const tab = e.target.closest?.('[data-tab]');
|
|
4717
|
+
const row = e.target.closest?.('#picker li[data-path]');
|
|
4718
|
+
if (!tab && !row) return;
|
|
4719
|
+
e.preventDefault();
|
|
4720
|
+
if (tab) closeTab(T.tabs.find((t) => t.uid === Number(tab.dataset.tab)));
|
|
4721
|
+
else void openSession(row.dataset.path, true);
|
|
4722
|
+
});
|
|
4723
|
+
// No page scrolling on a middle press over them (the browser's autoscroll).
|
|
4724
|
+
document.addEventListener('mousedown', (e) => e.button === 1 && e.target.closest?.('[data-tab], #picker li[data-path]') && e.preventDefault());
|
|
4273
4725
|
$('#runBox').addEventListener('click', () => openPicker());
|
|
4274
4726
|
$('#loadInput').addEventListener('change', (e) => {
|
|
4275
4727
|
const f = e.target.files?.[0];
|
|
@@ -4308,11 +4760,15 @@ document.addEventListener('keydown', (e) => {
|
|
|
4308
4760
|
e.preventDefault();
|
|
4309
4761
|
$(S.view === 'path' ? '#pathSearch' : '#actionsSearch').focus();
|
|
4310
4762
|
} else if (e.key === 'o') openPicker();
|
|
4763
|
+
else if (e.key === '[' || e.key === ']') cycleTab(e.key === '[' ? -1 : 1);
|
|
4311
4764
|
else if (e.key === 'p' && S.run) togglePin();
|
|
4312
4765
|
else if (e.key === 'h' && S.run && S.view === 'path') toggleHeat();
|
|
4313
4766
|
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
4767
|
else if (e.key === 't' && S.run?.usage) toggleTokens();
|
|
4315
|
-
else if (e.key === 'Escape' &&
|
|
4768
|
+
else if (e.key === 'Escape' && S.gAgOpen) {
|
|
4769
|
+
S.gAgOpen = false;
|
|
4770
|
+
schedule();
|
|
4771
|
+
} else if (e.key === 'Escape' && !$('#tokensPop').hidden) toggleTokens(false);
|
|
4316
4772
|
else if (e.key === 'Escape' && !$('#wakePop').hidden) toggleWake(false);
|
|
4317
4773
|
else if (e.key === 'Escape' && !$('#picker').open) {
|
|
4318
4774
|
S.follow = false;
|
|
@@ -4330,10 +4786,32 @@ setInterval(() => S.run && renderHeader(), 15000);
|
|
|
4330
4786
|
const m = /run=([\w-]+)/.exec(location.hash);
|
|
4331
4787
|
const { runs } = await fetch('/api/runs').then((r) => r.json());
|
|
4332
4788
|
if (location.hash.includes('view=actions')) S.view = 'actions';
|
|
4789
|
+
const kept = loadTabs();
|
|
4790
|
+
let active = null;
|
|
4791
|
+
for (const [i, k] of kept.tabs.entries()) {
|
|
4792
|
+
// Run ids start again from r1 when the server restarts: one counts only if it's the same session.
|
|
4793
|
+
let id = runs.find((r) => r.id === k.runId && (k.path ? samePath(r.path, k.path) : !r.path))?.id ?? null;
|
|
4794
|
+
if (!id && k.path) {
|
|
4795
|
+
try {
|
|
4796
|
+
const res = await fetch('/api/open', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ path: k.path }) });
|
|
4797
|
+
if (res.ok) id = (await res.json()).run.id;
|
|
4798
|
+
} catch {
|
|
4799
|
+
// Gone: the tab is dropped.
|
|
4800
|
+
}
|
|
4801
|
+
}
|
|
4802
|
+
if (!id || tabOf(id)) continue;
|
|
4803
|
+
const t = newTab(id, k);
|
|
4804
|
+
T.tabs.push(t);
|
|
4805
|
+
if (i === kept.active) active = t;
|
|
4806
|
+
}
|
|
4333
4807
|
const wanted = m && runs.find((r) => r.id === m[1]);
|
|
4334
|
-
if (
|
|
4335
|
-
|
|
4336
|
-
|
|
4808
|
+
if (T.tabs.length) {
|
|
4809
|
+
showTab(active ?? T.tabs[0]);
|
|
4810
|
+
tabsChanged();
|
|
4811
|
+
} else if (wanted || runs.length) {
|
|
4812
|
+
const r = wanted ?? runs[runs.length - 1];
|
|
4813
|
+
openRun(r.id, { path: r.path, title: r.title ?? r.label });
|
|
4814
|
+
} else {
|
|
4337
4815
|
// The server opened nothing (no obvious session for this folder): let the user choose.
|
|
4338
4816
|
render();
|
|
4339
4817
|
openPicker();
|