@oddessentials/agent-guild 0.4.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.
- package/README.md +13 -8
- package/bin/agent-guild-report.mjs +14 -1
- package/bin/agent-guild.mjs +85 -46
- package/config/providers.default.json +4 -0
- package/package.json +1 -1
- package/src/manager/changelog.mjs +195 -0
- package/src/manager/launch.mjs +64 -0
- package/src/manager/main.mjs +27 -4
- package/src/manager/news.mjs +5 -5
- package/src/manager/providers.mjs +11 -0
- package/src/manager/server.mjs +52 -16
- package/src/manager/session-history.mjs +496 -0
- package/src/manager/session-manager.mjs +4 -0
- package/src/manager/session.mjs +16 -0
- package/src/report/hooks.mjs +4 -0
- package/web/app.js +754 -56
- package/web/art/art.css +372 -159
- package/web/art/characters/anthropic/idle.avif +0 -0
- package/web/art/characters/anthropic/idle.webp +0 -0
- package/web/art/characters/anthropic/locked.avif +0 -0
- package/web/art/characters/anthropic/locked.webp +0 -0
- package/web/art/characters/anthropic/working.avif +0 -0
- package/web/art/characters/anthropic/working.webp +0 -0
- package/web/art/characters/google/idle.avif +0 -0
- package/web/art/characters/google/idle.webp +0 -0
- package/web/art/characters/google/locked.avif +0 -0
- package/web/art/characters/google/locked.webp +0 -0
- package/web/art/characters/google/working.avif +0 -0
- package/web/art/characters/google/working.webp +0 -0
- package/web/art/characters/openai/idle.avif +0 -0
- package/web/art/characters/openai/idle.webp +0 -0
- package/web/art/characters/openai/locked.avif +0 -0
- package/web/art/characters/openai/locked.webp +0 -0
- package/web/art/characters/openai/working.avif +0 -0
- package/web/art/characters/openai/working.webp +0 -0
- package/web/art/characters/shell/idle.avif +0 -0
- package/web/art/characters/shell/idle.webp +0 -0
- package/web/art/characters/shell/locked.avif +0 -0
- package/web/art/characters/shell/locked.webp +0 -0
- package/web/art/characters/shell/working.avif +0 -0
- package/web/art/characters/shell/working.webp +0 -0
- package/web/art/characters/xai/idle.avif +0 -0
- package/web/art/characters/xai/idle.webp +0 -0
- package/web/art/characters/xai/locked.avif +0 -0
- package/web/art/characters/xai/locked.webp +0 -0
- package/web/art/characters/xai/working.avif +0 -0
- package/web/art/characters/xai/working.webp +0 -0
- package/web/art/icons/anthropic.avif +0 -0
- package/web/art/icons/anthropic.webp +0 -0
- package/web/art/icons/google.avif +0 -0
- package/web/art/icons/google.webp +0 -0
- package/web/art/icons/openai.avif +0 -0
- package/web/art/icons/openai.webp +0 -0
- package/web/art/icons/shell.avif +0 -0
- package/web/art/icons/shell.webp +0 -0
- package/web/art/icons/xai.avif +0 -0
- package/web/art/icons/xai.webp +0 -0
- package/web/art/page-light.avif +0 -0
- package/web/art/page-light.webp +0 -0
- package/web/art/page.avif +0 -0
- package/web/art/page.webp +0 -0
- package/web/art/ui/empty-state.avif +0 -0
- package/web/art/ui/empty-state.webp +0 -0
- package/web/art/ui/favicon.png +0 -0
- package/web/art/ui/frame-ornate.png +0 -0
- package/web/art/ui/guild-crest.png +0 -0
- package/web/fonts/OFL.txt +93 -0
- package/web/fonts/cinzel.woff2 +0 -0
- package/web/index.html +82 -14
- package/web/styles.css +208 -68
- package/web/art/characters/anthropic/idle.png +0 -0
- package/web/art/characters/anthropic/locked.png +0 -0
- package/web/art/characters/anthropic/working.png +0 -0
- package/web/art/characters/google/idle.png +0 -0
- package/web/art/characters/google/locked.png +0 -0
- package/web/art/characters/google/working.png +0 -0
- package/web/art/characters/openai/idle.png +0 -0
- package/web/art/characters/openai/locked.png +0 -0
- package/web/art/characters/openai/working.png +0 -0
- package/web/art/characters/shell/idle.png +0 -0
- package/web/art/characters/shell/locked.png +0 -0
- package/web/art/characters/shell/working.png +0 -0
- package/web/art/characters/xai/idle.png +0 -0
- package/web/art/characters/xai/locked.png +0 -0
- package/web/art/characters/xai/working.png +0 -0
- package/web/art/icons/anthropic.png +0 -0
- package/web/art/icons/google.png +0 -0
- package/web/art/icons/openai.png +0 -0
- package/web/art/icons/shell.png +0 -0
- package/web/art/icons/xai.png +0 -0
- 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,
|
|
@@ -27,8 +32,17 @@ const state = {
|
|
|
27
32
|
connected: false,
|
|
28
33
|
/** The manager's own version check, from `hello` and `manager.upgrade`. */
|
|
29
34
|
upgrade: null,
|
|
35
|
+
/** The running manager's version and pid, from `hello`. */
|
|
36
|
+
version: null,
|
|
37
|
+
pid: null,
|
|
38
|
+
/** False for a manager from before restarts, which only stops: `agent-guild restart` replaces it. */
|
|
39
|
+
restartable: false,
|
|
40
|
+
/** The double-click launcher file on this computer, or null when the install has none. */
|
|
41
|
+
launcher: null,
|
|
30
42
|
/** True from a shutdown request until the manager is reachable again. */
|
|
31
43
|
stopping: false,
|
|
44
|
+
/** True while the stop is a restart: a new manager is expected to take over. */
|
|
45
|
+
restarting: false,
|
|
32
46
|
/** After a stop: how many session processes did not confirm exiting, or null if the manager never said. */
|
|
33
47
|
stopRemaining: null,
|
|
34
48
|
};
|
|
@@ -53,10 +67,63 @@ function setConnection(kind, label) {
|
|
|
53
67
|
const el = $('connection');
|
|
54
68
|
el.className = `connection ${kind}`;
|
|
55
69
|
el.querySelector('.label').textContent = label;
|
|
56
|
-
// The manager can only be stopped or upgraded while the page can reach it.
|
|
70
|
+
// The manager can only be stopped, restarted or upgraded while the page can reach it.
|
|
57
71
|
state.connected = kind === 'ok';
|
|
58
72
|
$('stop-manager').hidden = !state.connected;
|
|
73
|
+
$('restart-manager').hidden = !state.connected || !state.restartable;
|
|
59
74
|
renderUpgrade();
|
|
75
|
+
guardLeaving();
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// ---- the running version --------------------------------------------------
|
|
79
|
+
|
|
80
|
+
/** A development checkout runs as 0.0.0-development; releases carry a real version. */
|
|
81
|
+
function isDevelopmentBuild(version) {
|
|
82
|
+
return !version || /^0\.0\.0(?:-|$)/.test(String(version));
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function renderVersion() {
|
|
86
|
+
const badge = $('version');
|
|
87
|
+
const v = state.version;
|
|
88
|
+
badge.hidden = !v;
|
|
89
|
+
if (!v) return;
|
|
90
|
+
const dev = isDevelopmentBuild(v);
|
|
91
|
+
const unread = unreadRelease();
|
|
92
|
+
const notes = unread ? `What’s new in v${unread}` : 'What’s new';
|
|
93
|
+
badge.textContent = dev ? 'dev' : `v${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;
|
|
60
127
|
}
|
|
61
128
|
|
|
62
129
|
// ---- theme ----------------------------------------------------------------
|
|
@@ -66,7 +133,8 @@ function applyTheme(theme) {
|
|
|
66
133
|
document.documentElement.dataset.theme = theme;
|
|
67
134
|
const button = $('theme-toggle');
|
|
68
135
|
const other = theme === 'dark' ? 'light' : 'dark';
|
|
69
|
-
button.textContent = theme === 'dark' ? '
|
|
136
|
+
button.textContent = theme === 'dark' ? 'Light' : 'Dark';
|
|
137
|
+
button.dataset.next = other;
|
|
70
138
|
button.title = `Switch to the ${other} theme`;
|
|
71
139
|
button.setAttribute('aria-label', `Switch to the ${other} theme`);
|
|
72
140
|
}
|
|
@@ -75,10 +143,18 @@ function currentTheme() {
|
|
|
75
143
|
return document.documentElement.dataset.theme === 'dark' ? 'dark' : 'light';
|
|
76
144
|
}
|
|
77
145
|
|
|
78
|
-
function toggleTheme() {
|
|
146
|
+
function toggleTheme(event) {
|
|
79
147
|
const theme = currentTheme() === 'dark' ? 'light' : 'dark';
|
|
80
148
|
save(THEME_KEY, theme);
|
|
81
|
-
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));
|
|
82
158
|
}
|
|
83
159
|
|
|
84
160
|
// ---- upgrading the manager ------------------------------------------------
|
|
@@ -93,15 +169,33 @@ function renderUpgrade() {
|
|
|
93
169
|
button.textContent = `Upgrade to ${u.latestVersion}`;
|
|
94
170
|
button.title = `Run "${u.command}" in a session. Sessions keep running; the new version is used once the manager is restarted.`;
|
|
95
171
|
}
|
|
172
|
+
// A newer version on disk is used by the next manager, so the restart
|
|
173
|
+
// button becomes the way to pick it up.
|
|
174
|
+
const restart = $('restart-manager');
|
|
175
|
+
const pending = u?.pendingVersion;
|
|
176
|
+
restart.classList.toggle('pending', Boolean(pending));
|
|
177
|
+
restart.textContent = pending ? `Restart to use v${pending}` : 'Restart manager';
|
|
178
|
+
restart.title = pending
|
|
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.`
|
|
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;
|
|
96
190
|
let text = '';
|
|
97
191
|
let title = '';
|
|
98
192
|
const last = u?.lastInstall;
|
|
99
193
|
if (u?.installing) {
|
|
100
194
|
text = `Upgrading${u.latestVersion ? ` to v${u.latestVersion}` : ''}…`;
|
|
101
195
|
title = 'npm is running in a session. Keep the manager running until it finishes.';
|
|
102
|
-
} else if (
|
|
103
|
-
text = `v${
|
|
104
|
-
title = `Agent Guild ${
|
|
196
|
+
} else if (pending && !state.restartable) {
|
|
197
|
+
text = `v${pending} installed · run "agent-guild restart" to use it`;
|
|
198
|
+
title = `Agent Guild ${pending} is installed, but this manager is still ${u.version} and cannot restart itself. Run "agent-guild restart" in a terminal when your sessions are done; this page reconnects by itself.`;
|
|
105
199
|
} else if (last?.outcome === 'failed') {
|
|
106
200
|
text = last.exitCode === null ? 'Upgrade failed' : `Upgrade failed (exit ${last.exitCode})`;
|
|
107
201
|
title = 'See the upgrade session for npm\'s output, then run the upgrade again: the files on disk may be incomplete. On Windows, files in use cannot be replaced: stop the manager first and run the command yourself.';
|
|
@@ -121,9 +215,13 @@ function setUpgrade(upgrade) {
|
|
|
121
215
|
const before = state.upgrade;
|
|
122
216
|
state.upgrade = upgrade || null;
|
|
123
217
|
renderUpgrade();
|
|
218
|
+
renderVersion();
|
|
219
|
+
if ($('changelog').open) renderChangelog();
|
|
124
220
|
const pending = state.upgrade?.pendingVersion;
|
|
125
221
|
if (pending && pending !== before?.pendingVersion) {
|
|
126
|
-
toast(
|
|
222
|
+
toast(state.restartable
|
|
223
|
+
? `Agent Guild ${pending} is installed. Use "Restart to use v${pending}" in the top bar when your sessions are done.`
|
|
224
|
+
: `Agent Guild ${pending} is installed. Run "agent-guild restart" in a terminal when your sessions are done.`, 10000);
|
|
127
225
|
}
|
|
128
226
|
}
|
|
129
227
|
|
|
@@ -203,9 +301,13 @@ function paintProviderIcon(el, provider) {
|
|
|
203
301
|
const FAMILIARS = ['flame', 'leaf', 'night', 'aether'];
|
|
204
302
|
|
|
205
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';
|
|
206
306
|
container.replaceChildren(...agents.map((agent) => {
|
|
207
307
|
const el = document.createElement('span');
|
|
208
308
|
el.className = `agent ${agent.status}`;
|
|
309
|
+
el.dataset.agent = agent.id;
|
|
310
|
+
if (known && !known.has(agent.id)) el.classList.add('summon');
|
|
209
311
|
el.style.setProperty('--c', `hsl(${hueFor(agent.name)} 65% 50%)`);
|
|
210
312
|
el.dataset.familiar = FAMILIARS[hueFor(agent.name) % FAMILIARS.length];
|
|
211
313
|
el.textContent = (agent.name || '?').charAt(0).toUpperCase();
|
|
@@ -288,6 +390,8 @@ function renderAccounts(card, provider) {
|
|
|
288
390
|
});
|
|
289
391
|
}
|
|
290
392
|
|
|
393
|
+
let dealt = false;
|
|
394
|
+
|
|
291
395
|
function renderProviders() {
|
|
292
396
|
const list = $('providers');
|
|
293
397
|
const tpl = $('provider-template');
|
|
@@ -310,8 +414,10 @@ function renderProviders() {
|
|
|
310
414
|
start.hidden = !provider.available;
|
|
311
415
|
start.addEventListener('click', () => startSession(provider, node));
|
|
312
416
|
existing.hidden = !provider.available || !provider.resumable;
|
|
313
|
-
existing.title =
|
|
314
|
-
|
|
417
|
+
existing.title = provider.historySource
|
|
418
|
+
? `Resume one of ${provider.tool}'s own earlier sessions`
|
|
419
|
+
: `Resume one of ${provider.tool}'s own sessions by its id`;
|
|
420
|
+
existing.addEventListener('click', () => showHistory(provider));
|
|
315
421
|
const install = node.querySelector('.install');
|
|
316
422
|
install.hidden = provider.available || !provider.installable;
|
|
317
423
|
install.title = `Install ${provider.tool} using npm.${provider.npmNote ? ` ${provider.npmNote}` : ''}`;
|
|
@@ -330,6 +436,10 @@ function renderProviders() {
|
|
|
330
436
|
renderModelStats(node, provider);
|
|
331
437
|
return node;
|
|
332
438
|
}));
|
|
439
|
+
if (!dealt && state.providers.length) {
|
|
440
|
+
dealt = true;
|
|
441
|
+
if (!reducedMotion.matches) list.classList.add('deal');
|
|
442
|
+
}
|
|
333
443
|
}
|
|
334
444
|
|
|
335
445
|
const openCopies = new Set();
|
|
@@ -385,7 +495,7 @@ function renderHint(hint, provider) {
|
|
|
385
495
|
link.href = docs;
|
|
386
496
|
link.target = '_blank';
|
|
387
497
|
link.rel = 'noopener noreferrer';
|
|
388
|
-
link.textContent = 'Docs
|
|
498
|
+
link.textContent = 'Docs';
|
|
389
499
|
hint.append(' ', link);
|
|
390
500
|
}
|
|
391
501
|
|
|
@@ -769,7 +879,7 @@ function modelFacts(card) {
|
|
|
769
879
|
link.href = httpsHref(`https://openrouter.ai/${card.id}`) ?? '';
|
|
770
880
|
link.target = '_blank';
|
|
771
881
|
link.rel = 'noopener noreferrer';
|
|
772
|
-
link.textContent = 'OpenRouter
|
|
882
|
+
link.textContent = 'OpenRouter';
|
|
773
883
|
link.setAttribute('aria-label', `${card.name} on OpenRouter (opens in a new tab)`);
|
|
774
884
|
const facts = document.createElement('div');
|
|
775
885
|
facts.className = 'model-more';
|
|
@@ -1188,6 +1298,212 @@ function tickNews() {
|
|
|
1188
1298
|
if ($('news').open) renderNewsStatus();
|
|
1189
1299
|
}
|
|
1190
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
|
+
|
|
1191
1507
|
const CHANNEL_LABELS = {
|
|
1192
1508
|
npm: 'npm', native: 'native', brew: 'Homebrew', winget: 'WinGet', legacy: 'legacy install', unknown: 'unknown install',
|
|
1193
1509
|
};
|
|
@@ -1247,32 +1563,229 @@ async function installProvider(provider, card, { force = false } = {}) {
|
|
|
1247
1563
|
}
|
|
1248
1564
|
}
|
|
1249
1565
|
|
|
1250
|
-
|
|
1251
|
-
|
|
1252
|
-
|
|
1253
|
-
|
|
1566
|
+
/**
|
|
1567
|
+
* Start a session. A resumed session starts in the folder its transcript
|
|
1568
|
+
* names, since Claude Code and Gemini CLI only find a session from there;
|
|
1569
|
+
* when that folder is gone, the working folder is used instead.
|
|
1570
|
+
*/
|
|
1571
|
+
async function startSession(provider, card, { resume, cwd, account = selectedAccount(provider).id } = {}) {
|
|
1572
|
+
const working = $('cwd').value.trim();
|
|
1573
|
+
save(CWD_KEY, working);
|
|
1574
|
+
card?.classList.add('busy');
|
|
1254
1575
|
try {
|
|
1255
|
-
const body = { providerId: provider.id, account
|
|
1256
|
-
|
|
1576
|
+
const body = { providerId: provider.id, account, cwd: cwd || working || undefined, cols: 120, rows: 32, resume };
|
|
1577
|
+
let session;
|
|
1578
|
+
try {
|
|
1579
|
+
({ session } = await api('POST', '/sessions', body));
|
|
1580
|
+
} catch (err) {
|
|
1581
|
+
if (err.code !== 'bad_cwd' || !cwd) throw err;
|
|
1582
|
+
toast(`${cwd} no longer exists; starting in the working folder instead.`, 8000);
|
|
1583
|
+
({ session } = await api('POST', '/sessions', { ...body, cwd: working || undefined }));
|
|
1584
|
+
}
|
|
1257
1585
|
upsertSession(session);
|
|
1586
|
+
closeHistory({ focusOpener: false });
|
|
1258
1587
|
openPanel(session.id);
|
|
1259
1588
|
} catch (err) {
|
|
1260
1589
|
if (err instanceof AuthError) return showAuth(err.message);
|
|
1261
1590
|
toast(err.message, 8000);
|
|
1262
1591
|
} finally {
|
|
1263
|
-
card
|
|
1592
|
+
card?.classList.remove('busy');
|
|
1264
1593
|
}
|
|
1265
1594
|
}
|
|
1266
1595
|
|
|
1267
|
-
|
|
1268
|
-
|
|
1269
|
-
|
|
1270
|
-
|
|
1596
|
+
// ---- session history ------------------------------------------------------
|
|
1597
|
+
|
|
1598
|
+
const HISTORY_LIMIT = 200;
|
|
1599
|
+
const historyView = { providerId: null, accountId: null, snapshot: null, loading: false };
|
|
1600
|
+
let historyOpener = null;
|
|
1601
|
+
|
|
1602
|
+
function historyProvider() {
|
|
1603
|
+
return state.providers.find((p) => p.id === historyView.providerId) ?? null;
|
|
1604
|
+
}
|
|
1605
|
+
|
|
1606
|
+
function toolSessionId(s) {
|
|
1607
|
+
return s.toolSessionId || s.resume || null;
|
|
1608
|
+
}
|
|
1609
|
+
|
|
1610
|
+
function shortId(id) {
|
|
1611
|
+
return id.length > 12 ? id.slice(0, 8) : id;
|
|
1612
|
+
}
|
|
1613
|
+
|
|
1614
|
+
function copyId(id) {
|
|
1615
|
+
navigator.clipboard?.writeText(id).then(() => toast(`Copied ${id}`, 2500), () => toast(id, 8000));
|
|
1616
|
+
}
|
|
1617
|
+
|
|
1618
|
+
function paintIdButton(button, id) {
|
|
1619
|
+
button.hidden = !id;
|
|
1620
|
+
if (!id) return;
|
|
1621
|
+
button.textContent = shortId(id);
|
|
1622
|
+
button.title = `Session id ${id}. Click to copy it.`;
|
|
1623
|
+
button.setAttribute('aria-label', `Copy session id ${id}`);
|
|
1624
|
+
}
|
|
1625
|
+
|
|
1626
|
+
function runningOn(providerId, accountId, id) {
|
|
1627
|
+
return [...state.sessions.values()].find((s) =>
|
|
1628
|
+
s.status === 'running' && s.task === null && s.provider.id === providerId && (s.account?.id ?? 'default') === accountId && toolSessionId(s) === id) ?? null;
|
|
1629
|
+
}
|
|
1630
|
+
|
|
1631
|
+
function showHistory(provider) {
|
|
1632
|
+
const account = selectedAccount(provider);
|
|
1633
|
+
const same = historyView.providerId === provider.id && historyView.accountId === account.id;
|
|
1634
|
+
if (!same) Object.assign(historyView, { providerId: provider.id, accountId: account.id, snapshot: null, loading: false });
|
|
1635
|
+
const dialog = $('history');
|
|
1636
|
+
$('history-filter').value = '';
|
|
1637
|
+
$('history-id').value = '';
|
|
1638
|
+
renderHistory();
|
|
1639
|
+
if (!dialog.open) {
|
|
1640
|
+
historyOpener = document.activeElement;
|
|
1641
|
+
dialog.showModal();
|
|
1642
|
+
}
|
|
1643
|
+
if (provider.historySource) loadHistory();
|
|
1644
|
+
else $('history-id').focus();
|
|
1645
|
+
}
|
|
1646
|
+
|
|
1647
|
+
async function loadHistory() {
|
|
1648
|
+
const provider = historyProvider();
|
|
1649
|
+
if (!provider || historyView.loading) return;
|
|
1650
|
+
const { providerId, accountId } = historyView;
|
|
1651
|
+
historyView.loading = true;
|
|
1652
|
+
renderHistory();
|
|
1653
|
+
try {
|
|
1654
|
+
const { history } = await api('GET', `/providers/${providerId}/history?account=${encodeURIComponent(accountId)}&limit=${HISTORY_LIMIT}`);
|
|
1655
|
+
if (historyView.providerId === providerId && historyView.accountId === accountId) historyView.snapshot = history;
|
|
1656
|
+
} catch (err) {
|
|
1657
|
+
if (err instanceof AuthError) return showAuth(err.message);
|
|
1658
|
+
if (historyView.providerId === providerId) historyView.snapshot = { sessions: [], total: 0, error: err.message };
|
|
1659
|
+
} finally {
|
|
1660
|
+
historyView.loading = false;
|
|
1661
|
+
renderHistory();
|
|
1662
|
+
}
|
|
1663
|
+
}
|
|
1664
|
+
|
|
1665
|
+
function historyText(entry) {
|
|
1666
|
+
return `${entry.title ?? ''}\n${entry.cwd ?? ''}\n${entry.id}`.toLowerCase();
|
|
1667
|
+
}
|
|
1668
|
+
|
|
1669
|
+
function folderName(dir) {
|
|
1670
|
+
const parts = dir.replace(/[\\/]+$/, '').split(/[\\/]/);
|
|
1671
|
+
return parts.at(-1) || dir;
|
|
1672
|
+
}
|
|
1673
|
+
|
|
1674
|
+
function sameFolder(a, b) {
|
|
1675
|
+
const clean = (dir) => (dir || '').replace(/[\\/]+$/, '').replace(/\\/g, '/').toLowerCase();
|
|
1676
|
+
return clean(a) === clean(b);
|
|
1677
|
+
}
|
|
1678
|
+
|
|
1679
|
+
function historyEntry(id) {
|
|
1680
|
+
return historyView.snapshot?.sessions.find((entry) => entry.id === id) ?? null;
|
|
1681
|
+
}
|
|
1682
|
+
|
|
1683
|
+
function resumeFromHistory(provider, id, cwd) {
|
|
1684
|
+
const running = runningOn(provider.id, historyView.accountId, id);
|
|
1685
|
+
if (running) {
|
|
1686
|
+
closeHistory({ focusOpener: false });
|
|
1687
|
+
openPanel(running.id);
|
|
1688
|
+
} else startSession(provider, null, { resume: id, cwd: cwd || undefined, account: historyView.accountId });
|
|
1689
|
+
}
|
|
1690
|
+
|
|
1691
|
+
function buildHistoryRow(id) {
|
|
1692
|
+
const node = $('history-template').content.firstElementChild.cloneNode(true);
|
|
1693
|
+
node.dataset.id = id;
|
|
1694
|
+
const idButton = node.querySelector('.session-id');
|
|
1695
|
+
paintIdButton(idButton, id);
|
|
1696
|
+
idButton.addEventListener('click', () => copyId(id));
|
|
1697
|
+
node.querySelector('.history-resume').addEventListener('click', () => {
|
|
1698
|
+
const provider = historyProvider();
|
|
1699
|
+
const entry = historyEntry(id);
|
|
1700
|
+
if (provider && entry) resumeFromHistory(provider, id, entry.cwd);
|
|
1701
|
+
});
|
|
1702
|
+
return node;
|
|
1703
|
+
}
|
|
1704
|
+
|
|
1705
|
+
function updateHistoryRow(node, provider, entry) {
|
|
1706
|
+
const running = runningOn(provider.id, historyView.accountId, entry.id);
|
|
1707
|
+
node.classList.toggle('untitled', !entry.title);
|
|
1708
|
+
node.classList.toggle('running', Boolean(running));
|
|
1709
|
+
node.querySelector('.history-title').textContent = entry.title ?? 'Untitled session';
|
|
1710
|
+
node.querySelector('.history-title').title = entry.title ?? '';
|
|
1711
|
+
const meta = node.querySelector('.history-meta');
|
|
1712
|
+
const when = entry.updatedAt ? `updated ${relativeTime(entry.updatedAt)}` : '';
|
|
1713
|
+
meta.textContent = [entry.cwd && folderName(entry.cwd), when, running && `open in Agent Guild as ${running.name}`].filter(Boolean).join(' · ');
|
|
1714
|
+
meta.title = [entry.cwd, entry.startedAt && `started ${new Date(entry.startedAt).toLocaleString()}`].filter(Boolean).join('\n');
|
|
1715
|
+
const action = node.querySelector('.history-resume');
|
|
1716
|
+
action.textContent = running ? 'Open' : 'Resume';
|
|
1717
|
+
action.title = running
|
|
1718
|
+
? `This session is running in Agent Guild as "${running.name}". Open it instead of resuming it twice.`
|
|
1719
|
+
: `Resume this ${provider.tool} session${entry.cwd ? ` in ${entry.cwd}` : ''}`;
|
|
1720
|
+
action.setAttribute('aria-label', `${action.textContent} ${entry.title ?? entry.id}`);
|
|
1721
|
+
}
|
|
1722
|
+
|
|
1723
|
+
/** Rows are kept and updated in place, so a refresh never drops keyboard focus from a row's buttons. */
|
|
1724
|
+
function renderHistoryRows(provider, shown) {
|
|
1725
|
+
const list = $('history-list');
|
|
1726
|
+
const rows = new Map([...list.children].map((node) => [node.dataset.id, node]));
|
|
1727
|
+
const wanted = new Set(shown.map((entry) => entry.id));
|
|
1728
|
+
for (const [id, node] of rows) if (!wanted.has(id)) node.remove();
|
|
1729
|
+
shown.forEach((entry, index) => {
|
|
1730
|
+
let node = rows.get(entry.id);
|
|
1731
|
+
if (!node) node = buildHistoryRow(entry.id);
|
|
1732
|
+
updateHistoryRow(node, provider, entry);
|
|
1733
|
+
if (list.children[index] !== node) list.insertBefore(node, list.children[index] || null);
|
|
1734
|
+
});
|
|
1735
|
+
}
|
|
1736
|
+
|
|
1737
|
+
function renderHistory() {
|
|
1738
|
+
const provider = historyProvider();
|
|
1739
|
+
if (!provider) return closeHistory();
|
|
1740
|
+
const account = provider.accounts?.find((a) => a.id === historyView.accountId);
|
|
1741
|
+
paintProviderIcon($('history-icon'), provider);
|
|
1742
|
+
$('history-title').textContent = `${provider.tool} sessions`;
|
|
1743
|
+
const snapshot = historyView.snapshot;
|
|
1744
|
+
const filter = $('history-filter').value.trim().toLowerCase();
|
|
1745
|
+
const working = $('cwd').value.trim();
|
|
1746
|
+
const here = $('history-here');
|
|
1747
|
+
here.disabled = !working;
|
|
1748
|
+
here.parentElement.title = working ? `Only sessions started in ${working}` : 'Set a working folder above to filter by it';
|
|
1749
|
+
const all = snapshot?.sessions ?? [];
|
|
1750
|
+
const shown = all.filter((entry) => (!filter || historyText(entry).includes(filter)) && (!here.checked || here.disabled || sameFolder(entry.cwd, working)));
|
|
1751
|
+
const parts = [];
|
|
1752
|
+
if ((provider.accounts?.length ?? 0) > 1 && account) parts.push(`${account.label} account`);
|
|
1753
|
+
if (snapshot && !snapshot.error) {
|
|
1754
|
+
parts.push(snapshot.total === 0 ? 'no sessions found' : `${snapshot.total} session${snapshot.total === 1 ? '' : 's'}, newest first`);
|
|
1755
|
+
if (shown.length !== all.length) parts.push(`${shown.length} shown`);
|
|
1756
|
+
}
|
|
1757
|
+
$('history-sub').textContent = parts.join(' · ');
|
|
1758
|
+
renderHistoryRows(provider, shown);
|
|
1759
|
+
let note = '';
|
|
1760
|
+
if (!provider.historySource) note = `Agent Guild cannot list ${provider.tool}'s sessions. Enter the id of one to resume it.`;
|
|
1761
|
+
else if (historyView.loading && !snapshot) note = `Reading ${provider.tool}'s sessions…`;
|
|
1762
|
+
else if (snapshot?.error) note = `Sessions could not be read: ${snapshot.error}`;
|
|
1763
|
+
else if (snapshot && all.length === 0) note = `No ${provider.tool} sessions were found${account && account.id !== 'default' ? ` for the ${account.label} account` : ''}.`;
|
|
1764
|
+
else if (snapshot && shown.length === 0) note = 'No session matches the filter.';
|
|
1765
|
+
$('history-note').textContent = note;
|
|
1766
|
+
$('history-note').hidden = !note;
|
|
1767
|
+
$('history-filter').disabled = !provider.historySource;
|
|
1768
|
+
here.parentElement.hidden = !provider.historySource;
|
|
1769
|
+
}
|
|
1770
|
+
|
|
1771
|
+
/** Closing to open a session leaves focus with the terminal; otherwise it returns to the opener. */
|
|
1772
|
+
function closeHistory({ focusOpener = true } = {}) {
|
|
1773
|
+
if (!$('history').open) return;
|
|
1774
|
+
if (!focusOpener) historyOpener = false;
|
|
1775
|
+
$('history').close();
|
|
1776
|
+
}
|
|
1777
|
+
|
|
1778
|
+
function resumeById(event) {
|
|
1779
|
+
event.preventDefault();
|
|
1780
|
+
const provider = historyProvider();
|
|
1781
|
+
const id = $('history-id').value.trim();
|
|
1782
|
+
if (provider && id) resumeFromHistory(provider, id, null);
|
|
1271
1783
|
}
|
|
1272
1784
|
|
|
1273
1785
|
// ---- session cards --------------------------------------------------------
|
|
1274
1786
|
|
|
1275
1787
|
const cards = new Map();
|
|
1788
|
+
let sessionsShown = false;
|
|
1276
1789
|
|
|
1277
1790
|
const MODEL_SOURCES = { report: 'reported by the tool', screen: 'seen on the tool\'s screen', args: 'from the --model argument' };
|
|
1278
1791
|
|
|
@@ -1310,10 +1823,34 @@ function buildCard(session) {
|
|
|
1310
1823
|
node.querySelector('.stop').addEventListener('click', () => stopSession(session.id));
|
|
1311
1824
|
node.querySelector('.remove').addEventListener('click', () => removeSession(session.id));
|
|
1312
1825
|
node.querySelector('.rename').addEventListener('click', () => renameSession(session.id));
|
|
1826
|
+
node.querySelector('.resume').addEventListener('click', () => resumeCard(session.id));
|
|
1827
|
+
node.querySelector('.session-id').addEventListener('click', () => {
|
|
1828
|
+
const id = toolSessionId(state.sessions.get(session.id) ?? session);
|
|
1829
|
+
if (id) copyId(id);
|
|
1830
|
+
});
|
|
1313
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
|
+
});
|
|
1314
1836
|
return node;
|
|
1315
1837
|
}
|
|
1316
1838
|
|
|
1839
|
+
function resumable(s) {
|
|
1840
|
+
const provider = state.providers.find((p) => p.id === s.provider.id);
|
|
1841
|
+
const id = toolSessionId(s);
|
|
1842
|
+
return Boolean(s.status === 'exited' && s.task === null && id && provider?.available && provider.resumable
|
|
1843
|
+
&& !runningOn(s.provider.id, s.account?.id ?? 'default', id));
|
|
1844
|
+
}
|
|
1845
|
+
|
|
1846
|
+
function resumeCard(id) {
|
|
1847
|
+
const s = state.sessions.get(id);
|
|
1848
|
+
if (!s || !resumable(s)) return;
|
|
1849
|
+
const provider = state.providers.find((p) => p.id === s.provider.id);
|
|
1850
|
+
const account = provider.accounts?.find((a) => a.id === s.account?.id)?.id;
|
|
1851
|
+
startSession(provider, cards.get(id), { resume: toolSessionId(s), cwd: s.cwd, account });
|
|
1852
|
+
}
|
|
1853
|
+
|
|
1317
1854
|
function sessionLevel(s) {
|
|
1318
1855
|
const end = s.status === 'exited'
|
|
1319
1856
|
? Date.parse(s.exitedAt ?? s.lastOutputAt ?? s.createdAt)
|
|
@@ -1327,11 +1864,14 @@ function updateCard(node, s) {
|
|
|
1327
1864
|
paintProviderIcon(node.querySelector('.provider-icon'), s.provider);
|
|
1328
1865
|
const level = sessionLevel(s);
|
|
1329
1866
|
const badge = node.querySelector('.level-badge');
|
|
1867
|
+
if (badge.textContent && Number(badge.textContent) < level) badge.classList.add('level-up');
|
|
1330
1868
|
badge.textContent = level;
|
|
1331
1869
|
badge.title = `Level ${level}`;
|
|
1332
1870
|
node.querySelector('.name').textContent = s.name;
|
|
1333
|
-
const
|
|
1334
|
-
|
|
1871
|
+
const id = toolSessionId(s);
|
|
1872
|
+
const resumed = s.resume && s.resume !== id ? ` · resumed ${s.resume}` : s.resume ? ' · resumed' : '';
|
|
1873
|
+
node.querySelector('.meta-text').textContent = [s.provider.vendor, s.provider.tool, accountLabel(s), `started ${relativeTime(s.createdAt)}${resumed}`].filter(Boolean).join(' · ');
|
|
1874
|
+
paintIdButton(node.querySelector('.session-id'), id);
|
|
1335
1875
|
const pill = node.querySelector('.status-pill');
|
|
1336
1876
|
pill.textContent = statusText(s);
|
|
1337
1877
|
pill.className = `status-pill ${s.status === 'exited' ? 'exited' : s.activity}`;
|
|
@@ -1349,6 +1889,9 @@ function updateCard(node, s) {
|
|
|
1349
1889
|
node.classList.toggle('exited', s.status === 'exited');
|
|
1350
1890
|
node.querySelector('.stop').hidden = s.status !== 'running';
|
|
1351
1891
|
node.querySelector('.remove').hidden = s.status === 'running';
|
|
1892
|
+
const resume = node.querySelector('.resume');
|
|
1893
|
+
resume.hidden = !resumable(s);
|
|
1894
|
+
resume.title = `Start ${s.provider.tool} again on this session${id ? ` (${id})` : ''} in ${s.cwd}`;
|
|
1352
1895
|
const modelLabel = s.model ? `, model ${modelText(s)}` : '';
|
|
1353
1896
|
const accountName = accountLabel(s) ? `, ${accountLabel(s)} account` : '';
|
|
1354
1897
|
node.setAttribute('aria-label', `${s.name}, ${s.provider.vendor}${accountName}${modelLabel}, ${statusText(s)}, ${s.agents.length} agents`);
|
|
@@ -1362,7 +1905,11 @@ function renderSessions() {
|
|
|
1362
1905
|
}
|
|
1363
1906
|
sessions.forEach((s, index) => {
|
|
1364
1907
|
let node = cards.get(s.id);
|
|
1365
|
-
if (!node) {
|
|
1908
|
+
if (!node) {
|
|
1909
|
+
node = buildCard(s);
|
|
1910
|
+
cards.set(s.id, node);
|
|
1911
|
+
if (sessionsShown) node.classList.add('enter');
|
|
1912
|
+
}
|
|
1366
1913
|
updateCard(node, s);
|
|
1367
1914
|
// Move a card only when it is out of place: re-inserting a node drops
|
|
1368
1915
|
// keyboard focus and can swallow a click that is in progress.
|
|
@@ -1371,10 +1918,23 @@ function renderSessions() {
|
|
|
1371
1918
|
const running = sessions.filter((s) => s.status === 'running').length;
|
|
1372
1919
|
$('session-count').textContent = sessions.length ? `· ${running} running` : '';
|
|
1373
1920
|
$('empty').hidden = sessions.length > 0;
|
|
1921
|
+
guardLeaving();
|
|
1374
1922
|
if (state.activeId) updatePanel();
|
|
1923
|
+
if ($('history').open) renderHistory();
|
|
1375
1924
|
if (state.stats && sessions.some((s) => s.model && state.statsFor.get(s.id) !== modelKey(s))) scheduleStats();
|
|
1376
1925
|
}
|
|
1377
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
|
+
|
|
1378
1938
|
function upsertSession(session) {
|
|
1379
1939
|
state.sessions.set(session.id, session);
|
|
1380
1940
|
renderSessions();
|
|
@@ -1587,7 +2147,8 @@ function updatePanel() {
|
|
|
1587
2147
|
if (!s) return;
|
|
1588
2148
|
paintProviderIcon($('panel-icon'), s.provider);
|
|
1589
2149
|
$('panel-title').textContent = s.name;
|
|
1590
|
-
|
|
2150
|
+
const id = toolSessionId(s);
|
|
2151
|
+
$('panel-sub').textContent = [s.provider.tool, accountLabel(s), modelText(s), statusText(s), s.cwd, id && `session ${id}`].filter(Boolean).join(' · ');
|
|
1591
2152
|
$('panel-sub').title = modelTitle(s);
|
|
1592
2153
|
renderAgents($('panel-agents'), s.agents);
|
|
1593
2154
|
const stop = $('panel-stop');
|
|
@@ -1597,74 +2158,143 @@ function updatePanel() {
|
|
|
1597
2158
|
// ---- stopping the manager -------------------------------------------------
|
|
1598
2159
|
|
|
1599
2160
|
/**
|
|
1600
|
-
* Stop the session manager
|
|
1601
|
-
* unless told to force, so the warning is
|
|
1602
|
-
* count in the dialog is the manager's,
|
|
2161
|
+
* Stop the session manager, or stop it and start it again. The manager
|
|
2162
|
+
* refuses while sessions are running unless told to force, so the warning is
|
|
2163
|
+
* enforced for every client and the count in the dialog is the manager's,
|
|
2164
|
+
* not this page's possibly stale list.
|
|
1603
2165
|
*/
|
|
1604
|
-
async function stopManager({ force = false } = {}) {
|
|
1605
|
-
const
|
|
1606
|
-
button.disabled = true;
|
|
2166
|
+
async function stopManager({ force = false, restart = false } = {}) {
|
|
2167
|
+
const buttons = [$('stop-manager'), $('restart-manager')];
|
|
2168
|
+
for (const button of buttons) button.disabled = true;
|
|
1607
2169
|
try {
|
|
1608
|
-
const
|
|
1609
|
-
|
|
2170
|
+
const body = force || restart ? { ...(force && { force: true }), ...(restart && { restart: true }) } : undefined;
|
|
2171
|
+
const answer = await api('POST', '/shutdown', body);
|
|
2172
|
+
// A manager that does not say it will restart only stops.
|
|
2173
|
+
enterStopping(answer.running, restart && answer.restart === true);
|
|
2174
|
+
if (restart && answer.restart !== true) {
|
|
2175
|
+
toast('This manager cannot restart itself. Run "agent-guild restart" in a terminal to start the new one.', 12000);
|
|
2176
|
+
}
|
|
1610
2177
|
} catch (err) {
|
|
1611
2178
|
if (err instanceof AuthError) return showAuth(err.message);
|
|
1612
2179
|
if (err.code === 'sessions_running') {
|
|
1613
2180
|
const n = err.running;
|
|
1614
2181
|
const what = `${n} session${n === 1 ? ' is' : 's are'} still running`;
|
|
1615
2182
|
const them = n === 1 ? 'it' : 'all of them';
|
|
1616
|
-
|
|
1617
|
-
|
|
2183
|
+
const verb = restart ? 'Restarting' : 'Stopping';
|
|
2184
|
+
if (confirm(`${what}. ${verb} the session manager ends ${them}. ${restart ? 'Restart' : 'Stop'} anyway?`)) {
|
|
2185
|
+
return stopManager({ force: true, restart });
|
|
1618
2186
|
}
|
|
1619
2187
|
return;
|
|
1620
2188
|
}
|
|
1621
2189
|
toast(err.message, 8000);
|
|
1622
2190
|
} finally {
|
|
1623
|
-
button.disabled = false;
|
|
2191
|
+
for (const button of buttons) button.disabled = false;
|
|
1624
2192
|
}
|
|
1625
2193
|
}
|
|
1626
2194
|
|
|
2195
|
+
/** How long to wait for a restarted manager before telling the user how to start one by hand. */
|
|
2196
|
+
const RESTART_WAIT_MS = 30000;
|
|
2197
|
+
let restartTimer;
|
|
2198
|
+
|
|
1627
2199
|
/** The manager is going down, by this page's request or another client's. */
|
|
1628
|
-
function enterStopping(running = 0) {
|
|
2200
|
+
function enterStopping(running = 0, restart = false) {
|
|
2201
|
+
if (restart) state.restarting = true;
|
|
1629
2202
|
if (state.stopping) return;
|
|
1630
2203
|
state.stopping = true;
|
|
1631
2204
|
state.stopRemaining = null;
|
|
1632
2205
|
closePanel();
|
|
1633
2206
|
closeModels();
|
|
1634
2207
|
closeNews();
|
|
2208
|
+
closeChangelog();
|
|
2209
|
+
closeHistory();
|
|
1635
2210
|
for (const view of state.views.values()) view.dispose();
|
|
1636
2211
|
state.views.clear();
|
|
1637
2212
|
state.sessions.clear();
|
|
1638
2213
|
renderSessions();
|
|
1639
2214
|
$('app').hidden = true;
|
|
1640
2215
|
const n = Number(running) || 0;
|
|
1641
|
-
|
|
1642
|
-
|
|
1643
|
-
|
|
2216
|
+
const ending = n ? `Ending ${n} running session${n === 1 ? '' : 's'}. ` : '';
|
|
2217
|
+
if (state.restarting) {
|
|
2218
|
+
showStopped('restarting', 'Restarting the session manager…', `${ending}A new manager starts in a moment and this page reconnects to it by itself.`);
|
|
2219
|
+
clearTimeout(restartTimer);
|
|
2220
|
+
restartTimer = setTimeout(restartGaveUp, RESTART_WAIT_MS);
|
|
2221
|
+
} else {
|
|
2222
|
+
showStopped('stopping', 'Stopping the session manager…', `${ending}This can take a few seconds.`);
|
|
2223
|
+
}
|
|
2224
|
+
setConnection('down', state.restarting ? 'Restarting the session manager…' : 'Stopping the session manager…');
|
|
2225
|
+
}
|
|
2226
|
+
|
|
2227
|
+
/** The manager is back: a `hello` arrived while the page was waiting out a stop. */
|
|
2228
|
+
function leaveStopping() {
|
|
2229
|
+
state.stopping = false;
|
|
2230
|
+
state.restarting = false;
|
|
2231
|
+
clearTimeout(restartTimer);
|
|
2232
|
+
$('stopped').hidden = true;
|
|
2233
|
+
$('app').hidden = false;
|
|
2234
|
+
}
|
|
2235
|
+
|
|
2236
|
+
/** A restarted manager did not come back in time; the user has to start one by hand. */
|
|
2237
|
+
function restartGaveUp() {
|
|
2238
|
+
if (!state.stopping || !state.restarting) return;
|
|
2239
|
+
state.restarting = false;
|
|
2240
|
+
setConnection('down', 'Session manager stopped');
|
|
2241
|
+
showStopped('stopped', 'The session manager did not come back',
|
|
2242
|
+
`Nothing answered within ${Math.round(RESTART_WAIT_MS / 1000)} seconds of the restart. Check manager.log in the Agent Guild data folder, then start it yourself.`);
|
|
1644
2243
|
}
|
|
1645
2244
|
|
|
1646
2245
|
function showStopped(phase, title, text) {
|
|
1647
2246
|
const el = $('stopped');
|
|
1648
2247
|
el.classList.toggle('stopping', phase === 'stopping');
|
|
2248
|
+
el.classList.toggle('restarting', phase === 'restarting');
|
|
1649
2249
|
$('stopped-title').textContent = title;
|
|
1650
2250
|
$('stopped-text').textContent = text;
|
|
2251
|
+
// How to start again is only useful once the manager is really gone; a
|
|
2252
|
+
// restart brings it back without the user doing anything.
|
|
2253
|
+
$('stopped-help').hidden = phase !== 'stopped';
|
|
2254
|
+
if (phase === 'stopped') renderStoppedHelp();
|
|
1651
2255
|
el.hidden = false;
|
|
1652
2256
|
}
|
|
1653
2257
|
|
|
2258
|
+
/** Instructions for starting the manager again, worded for this computer. */
|
|
2259
|
+
function renderStoppedHelp() {
|
|
2260
|
+
const windows = /Win/.test(navigator.platform || navigator.userAgent);
|
|
2261
|
+
$('stopped-how').textContent = isMac
|
|
2262
|
+
? 'To start again, open Terminal (search for it with Spotlight) and run:'
|
|
2263
|
+
: windows
|
|
2264
|
+
? 'To start again, open Windows Terminal or PowerShell (search for it in the Start menu) and run:'
|
|
2265
|
+
: 'To start again, open a terminal and run:';
|
|
2266
|
+
const launcher = state.launcher;
|
|
2267
|
+
$('stopped-launcher').hidden = !launcher;
|
|
2268
|
+
$('stopped-launcher-path').textContent = launcher || '';
|
|
2269
|
+
}
|
|
2270
|
+
|
|
2271
|
+
async function copyCommand() {
|
|
2272
|
+
const button = $('copy-command');
|
|
2273
|
+
try {
|
|
2274
|
+
await navigator.clipboard.writeText('agent-guild open');
|
|
2275
|
+
button.textContent = 'Copied';
|
|
2276
|
+
setTimeout(() => { button.textContent = 'Copy'; }, 1500);
|
|
2277
|
+
} catch {
|
|
2278
|
+
toast('Could not copy. Select the command and copy it yourself.');
|
|
2279
|
+
}
|
|
2280
|
+
}
|
|
2281
|
+
|
|
1654
2282
|
/**
|
|
1655
2283
|
* The manager has stopped. Only a manager.stopped event with nothing
|
|
1656
2284
|
* remaining confirms that every process exited; a timeout, or a socket that
|
|
1657
2285
|
* dropped without the event, must not be announced as a clean stop.
|
|
1658
2286
|
*/
|
|
1659
2287
|
function showManagerStopped() {
|
|
1660
|
-
setConnection('down', 'Session manager stopped');
|
|
1661
2288
|
const n = state.stopRemaining;
|
|
1662
|
-
|
|
1663
|
-
|
|
1664
|
-
|
|
1665
|
-
|
|
2289
|
+
const sessions = n === 0 ? 'Every session has ended.'
|
|
2290
|
+
: n > 0 ? `${n} session process${n === 1 ? '' : 'es'} did not confirm exiting in time and may still be running. Check your system's process list.`
|
|
2291
|
+
: 'The manager went away before confirming that every session had ended.';
|
|
2292
|
+
if (state.restarting) {
|
|
2293
|
+
setConnection('down', 'Restarting the session manager…');
|
|
2294
|
+
return showStopped('restarting', 'Restarting the session manager…', `${sessions} Waiting for the new manager; this page reconnects to it by itself.`);
|
|
1666
2295
|
}
|
|
1667
|
-
|
|
2296
|
+
setConnection('down', 'Session manager stopped');
|
|
2297
|
+
showStopped('stopped', 'Session manager stopped', sessions);
|
|
1668
2298
|
}
|
|
1669
2299
|
|
|
1670
2300
|
// ---- events ---------------------------------------------------------------
|
|
@@ -1679,25 +2309,32 @@ function connectEvents() {
|
|
|
1679
2309
|
ws.onmessage = (event) => {
|
|
1680
2310
|
const msg = JSON.parse(event.data);
|
|
1681
2311
|
if (msg.type === 'hello') {
|
|
1682
|
-
|
|
1683
|
-
|
|
1684
|
-
|
|
1685
|
-
|
|
1686
|
-
|
|
1687
|
-
|
|
2312
|
+
// The manager is back after a stop or restart; the page picks up where it was.
|
|
2313
|
+
if (state.stopping) leaveStopping();
|
|
2314
|
+
state.version = msg.version || null;
|
|
2315
|
+
state.pid = msg.pid || null;
|
|
2316
|
+
state.restartable = typeof msg.pid === 'number';
|
|
2317
|
+
state.launcher = typeof msg.launcher === 'string' ? msg.launcher : null;
|
|
2318
|
+
renderVersion();
|
|
2319
|
+
setConnection('ok', 'Connected to session manager');
|
|
1688
2320
|
state.sessions = new Map(msg.sessions.map((s) => [s.id, s]));
|
|
1689
2321
|
for (const id of [...state.views.keys()]) if (!state.sessions.has(id)) dropSession(id);
|
|
1690
2322
|
renderSessions();
|
|
2323
|
+
sessionsShown = true;
|
|
1691
2324
|
setUpgrade(msg.upgrade);
|
|
1692
2325
|
loadNews();
|
|
2326
|
+
// A changelog.updated sent while the socket was down is lost; catch up the open panel.
|
|
2327
|
+
if ($('changelog').open) loadChangelog();
|
|
1693
2328
|
} else if (msg.type === 'news.updated') {
|
|
1694
2329
|
loadNews();
|
|
2330
|
+
} else if (msg.type === 'changelog.updated') {
|
|
2331
|
+
if ($('changelog').open) loadChangelog();
|
|
1695
2332
|
} else if (msg.type === 'manager.upgrade') {
|
|
1696
2333
|
setUpgrade(msg.upgrade);
|
|
1697
2334
|
} else if (msg.type === 'manager.stopping') {
|
|
1698
|
-
enterStopping(msg.running);
|
|
2335
|
+
enterStopping(msg.running, msg.restart === true);
|
|
1699
2336
|
} else if (msg.type === 'manager.stopped') {
|
|
1700
|
-
enterStopping();
|
|
2337
|
+
enterStopping(0, msg.restart === true);
|
|
1701
2338
|
state.stopRemaining = Number(msg.remaining) || 0;
|
|
1702
2339
|
showManagerStopped();
|
|
1703
2340
|
} else if (msg.type === 'session.created' || msg.type === 'session.updated') {
|
|
@@ -1707,6 +2344,7 @@ function connectEvents() {
|
|
|
1707
2344
|
} else if (msg.type === 'providers.updated') {
|
|
1708
2345
|
state.providers = msg.providers;
|
|
1709
2346
|
renderProviders();
|
|
2347
|
+
if ($('history').open) renderHistory();
|
|
1710
2348
|
scheduleStats();
|
|
1711
2349
|
}
|
|
1712
2350
|
};
|
|
@@ -1750,10 +2388,14 @@ let newsTimer;
|
|
|
1750
2388
|
function showAuth(message = '') {
|
|
1751
2389
|
closeModels();
|
|
1752
2390
|
closeNews();
|
|
2391
|
+
closeChangelog();
|
|
2392
|
+
closeHistory();
|
|
1753
2393
|
$('app').hidden = true;
|
|
1754
2394
|
$('terminal-panel').hidden = true;
|
|
1755
2395
|
$('stopped').hidden = true;
|
|
1756
2396
|
state.stopping = false;
|
|
2397
|
+
state.restarting = false;
|
|
2398
|
+
clearTimeout(restartTimer);
|
|
1757
2399
|
$('auth').hidden = false;
|
|
1758
2400
|
$('auth-error').textContent = message;
|
|
1759
2401
|
setConnection('down', 'Not connected');
|
|
@@ -1795,6 +2437,19 @@ $('auth-form').addEventListener('submit', (e) => {
|
|
|
1795
2437
|
});
|
|
1796
2438
|
$('panel-close').addEventListener('click', closePanel);
|
|
1797
2439
|
$('models-close').addEventListener('click', closeModels);
|
|
2440
|
+
$('history-close').addEventListener('click', closeHistory);
|
|
2441
|
+
$('history').addEventListener('click', (e) => { if (e.target === $('history')) closeHistory(); });
|
|
2442
|
+
$('history').addEventListener('close', () => {
|
|
2443
|
+
if (historyOpener !== false) {
|
|
2444
|
+
const opener = historyOpener?.isConnected ? historyOpener
|
|
2445
|
+
: $('providers').querySelector(`.provider[data-id="${historyView.providerId}"] .existing`);
|
|
2446
|
+
opener?.focus();
|
|
2447
|
+
}
|
|
2448
|
+
historyOpener = null;
|
|
2449
|
+
});
|
|
2450
|
+
$('history-filter').addEventListener('input', renderHistory);
|
|
2451
|
+
$('history-here').addEventListener('change', renderHistory);
|
|
2452
|
+
$('history-form').addEventListener('submit', resumeById);
|
|
1798
2453
|
$('models').addEventListener('click', (e) => { if (e.target === $('models')) closeModels(); });
|
|
1799
2454
|
$('models').addEventListener('close', () => {
|
|
1800
2455
|
hideTip();
|
|
@@ -1841,6 +2496,23 @@ $('news').addEventListener('close', () => {
|
|
|
1841
2496
|
document.addEventListener('visibilitychange', () => {
|
|
1842
2497
|
if (document.visibilityState === 'visible' && state.connected && Date.now() - newsLoadedAt > 60000) loadNews();
|
|
1843
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(); });
|
|
1844
2516
|
$('models-more').addEventListener('click', () => {
|
|
1845
2517
|
const before = $('models-list').childElementCount;
|
|
1846
2518
|
modelsView.all = true;
|
|
@@ -1848,8 +2520,34 @@ $('models-more').addEventListener('click', () => {
|
|
|
1848
2520
|
$('models-list').children[before]?.querySelector('.model-toggle')?.focus();
|
|
1849
2521
|
});
|
|
1850
2522
|
$('stop-manager').addEventListener('click', () => stopManager());
|
|
2523
|
+
$('restart-manager').addEventListener('click', () => stopManager({ restart: true }));
|
|
2524
|
+
$('copy-command').addEventListener('click', copyCommand);
|
|
1851
2525
|
$('upgrade').addEventListener('click', upgradeManager);
|
|
1852
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
|
+
});
|
|
1853
2551
|
applyTheme(currentTheme());
|
|
1854
2552
|
// Follow the system setting until the user picks a theme.
|
|
1855
2553
|
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', (e) => {
|