@oddessentials/agent-guild 0.5.0 → 0.6.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.
Files changed (82) hide show
  1. package/README.md +1 -1
  2. package/package.json +1 -1
  3. package/src/manager/changelog.mjs +195 -0
  4. package/src/manager/main.mjs +3 -0
  5. package/src/manager/news.mjs +5 -5
  6. package/src/manager/server.mjs +27 -8
  7. package/web/app.js +353 -8
  8. package/web/art/art.css +372 -159
  9. package/web/art/characters/anthropic/idle.avif +0 -0
  10. package/web/art/characters/anthropic/idle.webp +0 -0
  11. package/web/art/characters/anthropic/locked.avif +0 -0
  12. package/web/art/characters/anthropic/locked.webp +0 -0
  13. package/web/art/characters/anthropic/working.avif +0 -0
  14. package/web/art/characters/anthropic/working.webp +0 -0
  15. package/web/art/characters/google/idle.avif +0 -0
  16. package/web/art/characters/google/idle.webp +0 -0
  17. package/web/art/characters/google/locked.avif +0 -0
  18. package/web/art/characters/google/locked.webp +0 -0
  19. package/web/art/characters/google/working.avif +0 -0
  20. package/web/art/characters/google/working.webp +0 -0
  21. package/web/art/characters/openai/idle.avif +0 -0
  22. package/web/art/characters/openai/idle.webp +0 -0
  23. package/web/art/characters/openai/locked.avif +0 -0
  24. package/web/art/characters/openai/locked.webp +0 -0
  25. package/web/art/characters/openai/working.avif +0 -0
  26. package/web/art/characters/openai/working.webp +0 -0
  27. package/web/art/characters/shell/idle.avif +0 -0
  28. package/web/art/characters/shell/idle.webp +0 -0
  29. package/web/art/characters/shell/locked.avif +0 -0
  30. package/web/art/characters/shell/locked.webp +0 -0
  31. package/web/art/characters/shell/working.avif +0 -0
  32. package/web/art/characters/shell/working.webp +0 -0
  33. package/web/art/characters/xai/idle.avif +0 -0
  34. package/web/art/characters/xai/idle.webp +0 -0
  35. package/web/art/characters/xai/locked.avif +0 -0
  36. package/web/art/characters/xai/locked.webp +0 -0
  37. package/web/art/characters/xai/working.avif +0 -0
  38. package/web/art/characters/xai/working.webp +0 -0
  39. package/web/art/icons/anthropic.avif +0 -0
  40. package/web/art/icons/anthropic.webp +0 -0
  41. package/web/art/icons/google.avif +0 -0
  42. package/web/art/icons/google.webp +0 -0
  43. package/web/art/icons/openai.avif +0 -0
  44. package/web/art/icons/openai.webp +0 -0
  45. package/web/art/icons/shell.avif +0 -0
  46. package/web/art/icons/shell.webp +0 -0
  47. package/web/art/icons/xai.avif +0 -0
  48. package/web/art/icons/xai.webp +0 -0
  49. package/web/art/page-light.avif +0 -0
  50. package/web/art/page-light.webp +0 -0
  51. package/web/art/page.avif +0 -0
  52. package/web/art/page.webp +0 -0
  53. package/web/art/ui/empty-state.avif +0 -0
  54. package/web/art/ui/empty-state.webp +0 -0
  55. package/web/art/ui/favicon.png +0 -0
  56. package/web/art/ui/frame-ornate.png +0 -0
  57. package/web/art/ui/guild-crest.png +0 -0
  58. package/web/fonts/OFL.txt +93 -0
  59. package/web/fonts/cinzel.woff2 +0 -0
  60. package/web/index.html +35 -11
  61. package/web/styles.css +168 -71
  62. package/web/art/characters/anthropic/idle.png +0 -0
  63. package/web/art/characters/anthropic/locked.png +0 -0
  64. package/web/art/characters/anthropic/working.png +0 -0
  65. package/web/art/characters/google/idle.png +0 -0
  66. package/web/art/characters/google/locked.png +0 -0
  67. package/web/art/characters/google/working.png +0 -0
  68. package/web/art/characters/openai/idle.png +0 -0
  69. package/web/art/characters/openai/locked.png +0 -0
  70. package/web/art/characters/openai/working.png +0 -0
  71. package/web/art/characters/shell/idle.png +0 -0
  72. package/web/art/characters/shell/locked.png +0 -0
  73. package/web/art/characters/shell/working.png +0 -0
  74. package/web/art/characters/xai/idle.png +0 -0
  75. package/web/art/characters/xai/locked.png +0 -0
  76. package/web/art/characters/xai/working.png +0 -0
  77. package/web/art/icons/anthropic.png +0 -0
  78. package/web/art/icons/google.png +0 -0
  79. package/web/art/icons/openai.png +0 -0
  80. package/web/art/icons/shell.png +0 -0
  81. package/web/art/icons/xai.png +0 -0
  82. package/web/art/page.png +0 -0
package/web/app.js CHANGED
@@ -7,7 +7,11 @@ const ACCOUNTS_KEY = 'agentGuild.accounts';
7
7
  const THEME_KEY = 'agentGuild.theme';
8
8
  const NEWS_SEEN_KEY = 'agentGuild.newsSeen';
9
9
  const NEWS_FILTER_KEY = 'agentGuild.newsFilter';
10
+ const CHANGELOG_SEEN_KEY = 'agentGuild.changelogSeen';
11
+ const RELEASES_URL = 'https://github.com/oddessentials/agent-guild/releases';
10
12
  const isMac = /Mac|iPhone|iPad/.test(navigator.platform || navigator.userAgent);
13
+ const reducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)');
14
+ const finePointer = window.matchMedia('(hover: hover) and (pointer: fine)');
11
15
 
12
16
  const $ = (id) => document.getElementById(id);
13
17
  const state = {
@@ -18,6 +22,7 @@ const state = {
18
22
  stats: null,
19
23
  statsFor: new Map(),
20
24
  news: null,
25
+ changelog: null,
21
26
  sessions: new Map(),
22
27
  views: new Map(),
23
28
  activeId: null,
@@ -67,6 +72,7 @@ function setConnection(kind, label) {
67
72
  $('stop-manager').hidden = !state.connected;
68
73
  $('restart-manager').hidden = !state.connected || !state.restartable;
69
74
  renderUpgrade();
75
+ guardLeaving();
70
76
  }
71
77
 
72
78
  // ---- the running version --------------------------------------------------
@@ -82,9 +88,42 @@ function renderVersion() {
82
88
  badge.hidden = !v;
83
89
  if (!v) return;
84
90
  const dev = isDevelopmentBuild(v);
91
+ const unread = unreadRelease();
92
+ const notes = unread ? `What’s new in v${unread}` : 'What’s new';
85
93
  badge.textContent = dev ? 'dev' : `v${v}`;
86
- badge.title = [dev ? `Development build (${v})` : `Agent Guild ${v}`, state.pid && `session manager pid ${state.pid}`].filter(Boolean).join(' · ');
87
- badge.setAttribute('aria-label', dev ? `Agent Guild development build ${v}` : `Agent Guild version ${v}`);
94
+ badge.classList.toggle('unread', Boolean(unread));
95
+ badge.title = [dev ? `Development build (${v})` : `Agent Guild ${v}`, state.pid && `session manager pid ${state.pid}`, notes].filter(Boolean).join(' · ');
96
+ badge.setAttribute('aria-label', `${dev ? `Agent Guild development build ${v}` : `Agent Guild version ${v}`}. ${notes}${unread ? ', not read yet' : ''}`);
97
+ }
98
+
99
+ const RELEASE_VERSION = /^\d+\.\d+\.\d+$/;
100
+
101
+ function compareReleases(a, b) {
102
+ const [x, y] = [a, b].map((version) => version.split('.').map(Number));
103
+ return x[0] - y[0] || x[1] - y[1] || x[2] - y[2];
104
+ }
105
+
106
+ function newestRelease(versions) {
107
+ return versions.filter((v) => RELEASE_VERSION.test(v ?? '')).reduce((newest, v) => (newest && compareReleases(newest, v) >= 0 ? newest : v), null);
108
+ }
109
+
110
+ function seenRelease() {
111
+ const saved = load(CHANGELOG_SEEN_KEY);
112
+ return RELEASE_VERSION.test(saved ?? '') ? saved : changelogView.seen;
113
+ }
114
+
115
+ function markReleasesSeen(...versions) {
116
+ const newest = newestRelease([seenRelease(), ...versions]);
117
+ if (!newest) return;
118
+ changelogView.seen = newest;
119
+ save(CHANGELOG_SEEN_KEY, newest);
120
+ }
121
+
122
+ function unreadRelease() {
123
+ if (!RELEASE_VERSION.test(state.version ?? '')) return null;
124
+ if (!seenRelease()) markReleasesSeen(state.version);
125
+ const newest = newestRelease([state.version, state.upgrade?.pendingVersion, state.upgrade?.latestVersion]);
126
+ return compareReleases(newest, seenRelease()) > 0 ? newest : null;
88
127
  }
89
128
 
90
129
  // ---- theme ----------------------------------------------------------------
@@ -94,7 +133,8 @@ function applyTheme(theme) {
94
133
  document.documentElement.dataset.theme = theme;
95
134
  const button = $('theme-toggle');
96
135
  const other = theme === 'dark' ? 'light' : 'dark';
97
- button.textContent = theme === 'dark' ? '☀ Light' : '☾ Dark';
136
+ button.textContent = theme === 'dark' ? 'Light' : 'Dark';
137
+ button.dataset.next = other;
98
138
  button.title = `Switch to the ${other} theme`;
99
139
  button.setAttribute('aria-label', `Switch to the ${other} theme`);
100
140
  }
@@ -103,10 +143,18 @@ function currentTheme() {
103
143
  return document.documentElement.dataset.theme === 'dark' ? 'dark' : 'light';
104
144
  }
105
145
 
106
- function toggleTheme() {
146
+ function toggleTheme(event) {
107
147
  const theme = currentTheme() === 'dark' ? 'light' : 'dark';
108
148
  save(THEME_KEY, theme);
109
- applyTheme(theme);
149
+ if (!document.startViewTransition || reducedMotion.matches) return applyTheme(theme);
150
+ const box = event.currentTarget.getBoundingClientRect();
151
+ const x = box.left + box.width / 2;
152
+ const y = box.top + box.height / 2;
153
+ const root = document.documentElement.style;
154
+ root.setProperty('--reveal-x', `${Math.round(x)}px`);
155
+ root.setProperty('--reveal-y', `${Math.round(y)}px`);
156
+ root.setProperty('--reveal-r', `${Math.ceil(Math.hypot(Math.max(x, innerWidth - x), Math.max(y, innerHeight - y)))}px`);
157
+ document.startViewTransition(() => applyTheme(theme));
110
158
  }
111
159
 
112
160
  // ---- upgrading the manager ------------------------------------------------
@@ -130,6 +178,15 @@ function renderUpgrade() {
130
178
  restart.title = pending
131
179
  ? `Agent Guild ${pending} is installed, but this manager is still ${u.version}. Restarting ends every session and starts the new version; this page reconnects by itself.`
132
180
  : 'Stop the session manager and start it again. This ends every session; this page reconnects by itself.';
181
+ const panelUpgrade = $('changelog-upgrade');
182
+ panelUpgrade.hidden = button.hidden;
183
+ panelUpgrade.textContent = button.textContent;
184
+ panelUpgrade.title = button.title;
185
+ const panelRestart = $('changelog-restart');
186
+ panelRestart.hidden = restart.hidden || !pending;
187
+ panelRestart.textContent = restart.textContent;
188
+ panelRestart.title = restart.title;
189
+ $('changelog-actions').hidden = panelUpgrade.hidden && panelRestart.hidden;
133
190
  let text = '';
134
191
  let title = '';
135
192
  const last = u?.lastInstall;
@@ -158,6 +215,8 @@ function setUpgrade(upgrade) {
158
215
  const before = state.upgrade;
159
216
  state.upgrade = upgrade || null;
160
217
  renderUpgrade();
218
+ renderVersion();
219
+ if ($('changelog').open) renderChangelog();
161
220
  const pending = state.upgrade?.pendingVersion;
162
221
  if (pending && pending !== before?.pendingVersion) {
163
222
  toast(state.restartable
@@ -242,9 +301,13 @@ function paintProviderIcon(el, provider) {
242
301
  const FAMILIARS = ['flame', 'leaf', 'night', 'aether'];
243
302
 
244
303
  function renderAgents(container, agents) {
304
+ const known = container.dataset.rendered ? new Set([...container.children].map((el) => el.dataset.agent)) : null;
305
+ container.dataset.rendered = 'true';
245
306
  container.replaceChildren(...agents.map((agent) => {
246
307
  const el = document.createElement('span');
247
308
  el.className = `agent ${agent.status}`;
309
+ el.dataset.agent = agent.id;
310
+ if (known && !known.has(agent.id)) el.classList.add('summon');
248
311
  el.style.setProperty('--c', `hsl(${hueFor(agent.name)} 65% 50%)`);
249
312
  el.dataset.familiar = FAMILIARS[hueFor(agent.name) % FAMILIARS.length];
250
313
  el.textContent = (agent.name || '?').charAt(0).toUpperCase();
@@ -327,6 +390,8 @@ function renderAccounts(card, provider) {
327
390
  });
328
391
  }
329
392
 
393
+ let dealt = false;
394
+
330
395
  function renderProviders() {
331
396
  const list = $('providers');
332
397
  const tpl = $('provider-template');
@@ -371,6 +436,10 @@ function renderProviders() {
371
436
  renderModelStats(node, provider);
372
437
  return node;
373
438
  }));
439
+ if (!dealt && state.providers.length) {
440
+ dealt = true;
441
+ if (!reducedMotion.matches) list.classList.add('deal');
442
+ }
374
443
  }
375
444
 
376
445
  const openCopies = new Set();
@@ -426,7 +495,7 @@ function renderHint(hint, provider) {
426
495
  link.href = docs;
427
496
  link.target = '_blank';
428
497
  link.rel = 'noopener noreferrer';
429
- link.textContent = 'Docs ↗';
498
+ link.textContent = 'Docs';
430
499
  hint.append(' ', link);
431
500
  }
432
501
 
@@ -810,7 +879,7 @@ function modelFacts(card) {
810
879
  link.href = httpsHref(`https://openrouter.ai/${card.id}`) ?? '';
811
880
  link.target = '_blank';
812
881
  link.rel = 'noopener noreferrer';
813
- link.textContent = 'OpenRouter ↗';
882
+ link.textContent = 'OpenRouter';
814
883
  link.setAttribute('aria-label', `${card.name} on OpenRouter (opens in a new tab)`);
815
884
  const facts = document.createElement('div');
816
885
  facts.className = 'model-more';
@@ -1229,6 +1298,212 @@ function tickNews() {
1229
1298
  if ($('news').open) renderNewsStatus();
1230
1299
  }
1231
1300
 
1301
+ const RELEASE_BADGES = {
1302
+ running: ['in-use', 'Running', 'The session manager runs this version'],
1303
+ installed: ['installed', 'Installed', 'Installed. Restart the session manager to use it.'],
1304
+ newer: ['new', 'New', 'Released after the version the session manager runs'],
1305
+ };
1306
+ const changelogView = { opener: null, failed: null, seen: null };
1307
+ let changelogLoading = null;
1308
+ let changelogAgain = false;
1309
+
1310
+ function loadChangelog() {
1311
+ if (changelogLoading) {
1312
+ changelogAgain = true;
1313
+ return changelogLoading;
1314
+ }
1315
+ changelogLoading = api('GET', '/changelog').then((changelog) => {
1316
+ state.changelog = changelog;
1317
+ changelogView.failed = null;
1318
+ }, (err) => {
1319
+ if (err instanceof AuthError) return showAuth(err.message);
1320
+ changelogView.failed = state.connected ? err.message : 'the session manager is not reachable';
1321
+ }).finally(() => {
1322
+ changelogLoading = null;
1323
+ if ($('changelog').open) {
1324
+ markReleasesSeen(state.changelog?.releases[0]?.version);
1325
+ renderVersion();
1326
+ renderChangelog();
1327
+ }
1328
+ if (changelogAgain) {
1329
+ changelogAgain = false;
1330
+ loadChangelog();
1331
+ }
1332
+ });
1333
+ return changelogLoading;
1334
+ }
1335
+
1336
+ function openChangelog() {
1337
+ const dialog = $('changelog');
1338
+ renderChangelog();
1339
+ if (!dialog.open) {
1340
+ changelogView.opener = document.activeElement;
1341
+ dialog.showModal();
1342
+ }
1343
+ ($('changelog-list').querySelector('a') ?? $('changelog-close')).focus();
1344
+ markReleasesSeen(state.version, state.upgrade?.pendingVersion, state.upgrade?.latestVersion, state.changelog?.releases[0]?.version);
1345
+ renderVersion();
1346
+ loadChangelog();
1347
+ }
1348
+
1349
+ function closeChangelog() {
1350
+ if ($('changelog').open) $('changelog').close();
1351
+ }
1352
+
1353
+ function releasesLink(label) {
1354
+ const link = document.createElement('a');
1355
+ link.className = 'console-link';
1356
+ link.href = RELEASES_URL;
1357
+ link.target = '_blank';
1358
+ link.rel = 'noopener noreferrer';
1359
+ link.textContent = label;
1360
+ link.setAttribute('aria-description', 'Opens in a new tab');
1361
+ return link;
1362
+ }
1363
+
1364
+ function releaseStatus(version) {
1365
+ const running = state.version;
1366
+ if (!RELEASE_VERSION.test(running ?? '')) return null;
1367
+ const order = compareReleases(version, running);
1368
+ if (order <= 0) return order === 0 ? 'running' : null;
1369
+ const installed = state.upgrade?.pendingVersion;
1370
+ return RELEASE_VERSION.test(installed ?? '') && compareReleases(version, installed) <= 0 ? 'installed' : 'newer';
1371
+ }
1372
+
1373
+ function changeRun(run) {
1374
+ const href = run.url ? webHref(run.url) : null;
1375
+ let node;
1376
+ if (href) {
1377
+ node = document.createElement('a');
1378
+ node.className = 'console-link';
1379
+ node.href = href;
1380
+ node.target = '_blank';
1381
+ node.rel = 'noopener noreferrer';
1382
+ node.textContent = run.text;
1383
+ } else if (run.code) {
1384
+ node = document.createElement('code');
1385
+ node.textContent = run.text;
1386
+ } else {
1387
+ node = document.createTextNode(run.text);
1388
+ }
1389
+ if (!run.strong) return node;
1390
+ const strong = document.createElement('strong');
1391
+ strong.append(node);
1392
+ return strong;
1393
+ }
1394
+
1395
+ function changeGroup(section) {
1396
+ const group = document.createElement('div');
1397
+ group.className = 'changelog-group';
1398
+ if (section.title) {
1399
+ const title = document.createElement('h4');
1400
+ title.className = 'changelog-type';
1401
+ title.classList.toggle('breaking', /breaking/i.test(section.title));
1402
+ title.textContent = section.title;
1403
+ group.append(title);
1404
+ }
1405
+ const list = document.createElement('ul');
1406
+ list.className = 'changelog-changes';
1407
+ list.append(...section.changes.map((runs) => {
1408
+ const item = document.createElement('li');
1409
+ item.append(...runs.map(changeRun));
1410
+ return item;
1411
+ }));
1412
+ group.append(list);
1413
+ return group;
1414
+ }
1415
+
1416
+ function releaseEntry(release) {
1417
+ const entry = document.createElement('li');
1418
+ entry.className = 'changelog-release';
1419
+ entry.dataset.version = release.version;
1420
+ const heading = document.createElement('h3');
1421
+ heading.className = 'news-head';
1422
+ const href = webHref(release.url);
1423
+ const title = document.createElement(href ? 'a' : 'span');
1424
+ title.className = 'news-link';
1425
+ title.textContent = `v${release.version}`;
1426
+ if (href) {
1427
+ title.href = href;
1428
+ title.target = '_blank';
1429
+ title.rel = 'noopener noreferrer';
1430
+ title.setAttribute('aria-description', 'Opens the release on GitHub in a new tab');
1431
+ }
1432
+ heading.append(title);
1433
+ const status = releaseStatus(release.version);
1434
+ if (status) heading.append(badge(...RELEASE_BADGES[status]));
1435
+ entry.append(heading);
1436
+ if (release.publishedAt) {
1437
+ const time = document.createElement('time');
1438
+ time.className = 'news-meta';
1439
+ time.dateTime = release.publishedAt;
1440
+ time.textContent = relativeTime(release.publishedAt);
1441
+ time.title = new Date(release.publishedAt).toLocaleString();
1442
+ entry.append(time);
1443
+ }
1444
+ entry.append(...release.sections.map(changeGroup));
1445
+ return entry;
1446
+ }
1447
+
1448
+ function changelogSummary() {
1449
+ const v = state.version;
1450
+ if (!v) return 'Agent Guild releases, newest first';
1451
+ if (isDevelopmentBuild(v)) return `Development build (${v}) · releases, newest first`;
1452
+ const u = state.upgrade;
1453
+ const parts = [`Running v${v}`];
1454
+ if (u?.pendingVersion) parts.push(`v${u.pendingVersion} installed`);
1455
+ const newer = state.changelog?.releases.some((release) => compareReleases(release.version, v) > 0);
1456
+ if (u?.available && u.latestVersion) parts.push(`v${u.latestVersion} available`);
1457
+ else if (!u?.pendingVersion && u?.latestVersion === v && !newer) parts.push('the latest release');
1458
+ return parts.join(' · ');
1459
+ }
1460
+
1461
+ function changelogNote(changelog) {
1462
+ if (changelog?.releases.length) return [];
1463
+ if (changelogView.failed) return [`Release notes could not be loaded: ${changelogView.failed}. `, releasesLink('Read them on GitHub')];
1464
+ if (!changelog || changelog.refreshing) return ['Loading release notes…'];
1465
+ if (changelog.error) return [`Release notes could not be loaded. GitHub Releases: ${changelog.error}. `, releasesLink('Read them on GitHub')];
1466
+ return ['No releases yet.'];
1467
+ }
1468
+
1469
+ function renderChangelogStatus(changelog) {
1470
+ const lines = [];
1471
+ if (changelog) {
1472
+ const updated = changelog.refreshing ? 'checking for new releases…' : changelog.okAt ? `updated ${relativeTime(changelog.okAt)}` : '';
1473
+ lines.push(['From GitHub Releases', updated].filter(Boolean).join(' · '));
1474
+ if (changelog.error && changelog.releases.length) lines.push(`The last check failed (${changelog.error}); showing notes fetched ${relativeTime(changelog.okAt)}.`);
1475
+ }
1476
+ lines.push(releasesLink('All releases on GitHub'));
1477
+ $('changelog-status').replaceChildren(...lines.map((content) => {
1478
+ const line = document.createElement('span');
1479
+ line.append(content);
1480
+ return line;
1481
+ }));
1482
+ $('changelog-status').hidden = false;
1483
+ }
1484
+
1485
+ function renderChangelog() {
1486
+ const changelog = state.changelog;
1487
+ const releases = changelog?.releases ?? [];
1488
+ $('changelog-sub').textContent = changelogSummary();
1489
+ const list = $('changelog-list');
1490
+ const body = list.parentElement;
1491
+ const top = body.scrollTop;
1492
+ const entry = document.activeElement?.closest?.('#changelog-list .changelog-release');
1493
+ const focus = entry && { version: entry.dataset.version, index: [...entry.querySelectorAll('a')].indexOf(document.activeElement) };
1494
+ list.replaceChildren(...releases.map(releaseEntry));
1495
+ list.hidden = releases.length === 0;
1496
+ const note = changelogNote(changelog);
1497
+ $('changelog-note').replaceChildren(...note);
1498
+ $('changelog-note').hidden = note.length === 0;
1499
+ renderChangelogStatus(changelog);
1500
+ body.scrollTop = top;
1501
+ if (focus) {
1502
+ const again = [...list.children].find((el) => el.dataset.version === focus.version);
1503
+ (again?.querySelectorAll('a')[focus.index] ?? again?.querySelector('a') ?? $('changelog-close')).focus({ preventScroll: true });
1504
+ }
1505
+ }
1506
+
1232
1507
  const CHANNEL_LABELS = {
1233
1508
  npm: 'npm', native: 'native', brew: 'Homebrew', winget: 'WinGet', legacy: 'legacy install', unknown: 'unknown install',
1234
1509
  };
@@ -1510,6 +1785,7 @@ function resumeById(event) {
1510
1785
  // ---- session cards --------------------------------------------------------
1511
1786
 
1512
1787
  const cards = new Map();
1788
+ let sessionsShown = false;
1513
1789
 
1514
1790
  const MODEL_SOURCES = { report: 'reported by the tool', screen: 'seen on the tool\'s screen', args: 'from the --model argument' };
1515
1791
 
@@ -1553,6 +1829,10 @@ function buildCard(session) {
1553
1829
  if (id) copyId(id);
1554
1830
  });
1555
1831
  node.querySelector('.model-pill').addEventListener('click', () => openSessionModel(session.id));
1832
+ node.addEventListener('animationend', (e) => {
1833
+ if (e.animationName === 'level-up') e.target.classList.remove('level-up');
1834
+ else if (e.animationName === 'card-enter' && e.target === node) node.classList.remove('enter');
1835
+ });
1556
1836
  return node;
1557
1837
  }
1558
1838
 
@@ -1584,6 +1864,7 @@ function updateCard(node, s) {
1584
1864
  paintProviderIcon(node.querySelector('.provider-icon'), s.provider);
1585
1865
  const level = sessionLevel(s);
1586
1866
  const badge = node.querySelector('.level-badge');
1867
+ if (badge.textContent && Number(badge.textContent) < level) badge.classList.add('level-up');
1587
1868
  badge.textContent = level;
1588
1869
  badge.title = `Level ${level}`;
1589
1870
  node.querySelector('.name').textContent = s.name;
@@ -1624,7 +1905,11 @@ function renderSessions() {
1624
1905
  }
1625
1906
  sessions.forEach((s, index) => {
1626
1907
  let node = cards.get(s.id);
1627
- if (!node) { node = buildCard(s); cards.set(s.id, node); }
1908
+ if (!node) {
1909
+ node = buildCard(s);
1910
+ cards.set(s.id, node);
1911
+ if (sessionsShown) node.classList.add('enter');
1912
+ }
1628
1913
  updateCard(node, s);
1629
1914
  // Move a card only when it is out of place: re-inserting a node drops
1630
1915
  // keyboard focus and can swallow a click that is in progress.
@@ -1633,11 +1918,23 @@ function renderSessions() {
1633
1918
  const running = sessions.filter((s) => s.status === 'running').length;
1634
1919
  $('session-count').textContent = sessions.length ? `· ${running} running` : '';
1635
1920
  $('empty').hidden = sessions.length > 0;
1921
+ guardLeaving();
1636
1922
  if (state.activeId) updatePanel();
1637
1923
  if ($('history').open) renderHistory();
1638
1924
  if (state.stats && sessions.some((s) => s.model && state.statsFor.get(s.id) !== modelKey(s))) scheduleStats();
1639
1925
  }
1640
1926
 
1927
+ function confirmLeaving(event) {
1928
+ event.preventDefault();
1929
+ event.returnValue = true;
1930
+ }
1931
+
1932
+ function guardLeaving() {
1933
+ const running = state.connected && [...state.sessions.values()].some((s) => s.status === 'running');
1934
+ if (running) addEventListener('beforeunload', confirmLeaving);
1935
+ else removeEventListener('beforeunload', confirmLeaving);
1936
+ }
1937
+
1641
1938
  function upsertSession(session) {
1642
1939
  state.sessions.set(session.id, session);
1643
1940
  renderSessions();
@@ -1908,6 +2205,7 @@ function enterStopping(running = 0, restart = false) {
1908
2205
  closePanel();
1909
2206
  closeModels();
1910
2207
  closeNews();
2208
+ closeChangelog();
1911
2209
  closeHistory();
1912
2210
  for (const view of state.views.values()) view.dispose();
1913
2211
  state.views.clear();
@@ -2022,10 +2320,15 @@ function connectEvents() {
2022
2320
  state.sessions = new Map(msg.sessions.map((s) => [s.id, s]));
2023
2321
  for (const id of [...state.views.keys()]) if (!state.sessions.has(id)) dropSession(id);
2024
2322
  renderSessions();
2323
+ sessionsShown = true;
2025
2324
  setUpgrade(msg.upgrade);
2026
2325
  loadNews();
2326
+ // A changelog.updated sent while the socket was down is lost; catch up the open panel.
2327
+ if ($('changelog').open) loadChangelog();
2027
2328
  } else if (msg.type === 'news.updated') {
2028
2329
  loadNews();
2330
+ } else if (msg.type === 'changelog.updated') {
2331
+ if ($('changelog').open) loadChangelog();
2029
2332
  } else if (msg.type === 'manager.upgrade') {
2030
2333
  setUpgrade(msg.upgrade);
2031
2334
  } else if (msg.type === 'manager.stopping') {
@@ -2085,6 +2388,7 @@ let newsTimer;
2085
2388
  function showAuth(message = '') {
2086
2389
  closeModels();
2087
2390
  closeNews();
2391
+ closeChangelog();
2088
2392
  closeHistory();
2089
2393
  $('app').hidden = true;
2090
2394
  $('terminal-panel').hidden = true;
@@ -2192,6 +2496,23 @@ $('news').addEventListener('close', () => {
2192
2496
  document.addEventListener('visibilitychange', () => {
2193
2497
  if (document.visibilityState === 'visible' && state.connected && Date.now() - newsLoadedAt > 60000) loadNews();
2194
2498
  });
2499
+ $('version').addEventListener('click', openChangelog);
2500
+ $('changelog-close').addEventListener('click', closeChangelog);
2501
+ $('changelog').addEventListener('click', (e) => { if (e.target === $('changelog')) closeChangelog(); });
2502
+ $('changelog').addEventListener('close', () => {
2503
+ const opener = changelogView.opener?.isConnected && !changelogView.opener.closest('[hidden]') ? changelogView.opener : $('version');
2504
+ opener.focus();
2505
+ changelogView.opener = null;
2506
+ });
2507
+ $('changelog-upgrade').addEventListener('click', () => {
2508
+ closeChangelog();
2509
+ upgradeManager();
2510
+ });
2511
+ $('changelog-restart').addEventListener('click', () => {
2512
+ closeChangelog();
2513
+ stopManager({ restart: true });
2514
+ });
2515
+ addEventListener('storage', (e) => { if (e.key === CHANGELOG_SEEN_KEY) renderVersion(); });
2195
2516
  $('models-more').addEventListener('click', () => {
2196
2517
  const before = $('models-list').childElementCount;
2197
2518
  modelsView.all = true;
@@ -2203,6 +2524,30 @@ $('restart-manager').addEventListener('click', () => stopManager({ restart: true
2203
2524
  $('copy-command').addEventListener('click', copyCommand);
2204
2525
  $('upgrade').addEventListener('click', upgradeManager);
2205
2526
  $('theme-toggle').addEventListener('click', toggleTheme);
2527
+ $('providers').addEventListener('animationend', (e) => {
2528
+ if (e.animationName === 'deal' && e.target === e.currentTarget.lastElementChild) e.currentTarget.classList.remove('deal');
2529
+ });
2530
+ let tiltFrame = 0;
2531
+ $('providers').addEventListener('pointermove', (e) => {
2532
+ const card = e.target.closest('.provider');
2533
+ if (!card || !finePointer.matches || reducedMotion.matches) return;
2534
+ const box = card.getBoundingClientRect();
2535
+ const x = ((e.clientX - box.left) / box.width) * 2 - 1;
2536
+ const y = ((e.clientY - box.top) / box.height) * 2 - 1;
2537
+ cancelAnimationFrame(tiltFrame);
2538
+ tiltFrame = requestAnimationFrame(() => {
2539
+ card.style.setProperty('--px', x.toFixed(3));
2540
+ card.style.setProperty('--py', y.toFixed(3));
2541
+ });
2542
+ });
2543
+ $('providers').addEventListener('pointerout', (e) => {
2544
+ const card = e.target.closest('.provider');
2545
+ if (card && !card.contains(e.relatedTarget)) {
2546
+ cancelAnimationFrame(tiltFrame);
2547
+ card.style.removeProperty('--px');
2548
+ card.style.removeProperty('--py');
2549
+ }
2550
+ });
2206
2551
  applyTheme(currentTheme());
2207
2552
  // Follow the system setting until the user picks a theme.
2208
2553
  window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', (e) => {