@oddessentials/agent-guild 0.5.0 → 0.7.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 +182 -135
- package/package.json +1 -1
- package/src/manager/changelog.mjs +195 -0
- package/src/manager/config.mjs +1 -0
- package/src/manager/github.mjs +842 -0
- package/src/manager/main.mjs +10 -3
- package/src/manager/news.mjs +5 -5
- package/src/manager/server.mjs +70 -8
- package/src/manager/session-manager.mjs +32 -3
- package/src/manager/session.mjs +4 -1
- package/web/app.js +1005 -11
- package/web/art/art.css +374 -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 +101 -15
- package/web/styles.css +240 -71
- 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,13 @@ 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 GITHUB_ACCOUNT_KEY = 'agentGuild.githubAccount';
|
|
12
|
+
const CLONE_PARENT_KEY = 'agentGuild.cloneParent';
|
|
13
|
+
const RELEASES_URL = 'https://github.com/oddessentials/agent-guild/releases';
|
|
10
14
|
const isMac = /Mac|iPhone|iPad/.test(navigator.platform || navigator.userAgent);
|
|
15
|
+
const reducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)');
|
|
16
|
+
const finePointer = window.matchMedia('(hover: hover) and (pointer: fine)');
|
|
11
17
|
|
|
12
18
|
const $ = (id) => document.getElementById(id);
|
|
13
19
|
const state = {
|
|
@@ -18,6 +24,8 @@ const state = {
|
|
|
18
24
|
stats: null,
|
|
19
25
|
statsFor: new Map(),
|
|
20
26
|
news: null,
|
|
27
|
+
changelog: null,
|
|
28
|
+
github: null,
|
|
21
29
|
sessions: new Map(),
|
|
22
30
|
views: new Map(),
|
|
23
31
|
activeId: null,
|
|
@@ -50,9 +58,20 @@ function save(key, value) { try { value === null ? localStorage.removeItem(key)
|
|
|
50
58
|
// ---- helpers --------------------------------------------------------------
|
|
51
59
|
|
|
52
60
|
let toastTimer;
|
|
53
|
-
function toast(message, ms = 5000) {
|
|
61
|
+
function toast(message, ms = 5000, action = null) {
|
|
54
62
|
const el = $('toast');
|
|
55
|
-
el.
|
|
63
|
+
el.replaceChildren(message);
|
|
64
|
+
if (action) {
|
|
65
|
+
const button = document.createElement('button');
|
|
66
|
+
button.type = 'button';
|
|
67
|
+
button.className = 'btn toast-action';
|
|
68
|
+
button.textContent = action.label;
|
|
69
|
+
button.addEventListener('click', () => {
|
|
70
|
+
el.hidden = true;
|
|
71
|
+
action.run();
|
|
72
|
+
});
|
|
73
|
+
el.append(button);
|
|
74
|
+
}
|
|
56
75
|
el.hidden = false;
|
|
57
76
|
clearTimeout(toastTimer);
|
|
58
77
|
toastTimer = setTimeout(() => { el.hidden = true; }, ms);
|
|
@@ -67,6 +86,7 @@ function setConnection(kind, label) {
|
|
|
67
86
|
$('stop-manager').hidden = !state.connected;
|
|
68
87
|
$('restart-manager').hidden = !state.connected || !state.restartable;
|
|
69
88
|
renderUpgrade();
|
|
89
|
+
guardLeaving();
|
|
70
90
|
}
|
|
71
91
|
|
|
72
92
|
// ---- the running version --------------------------------------------------
|
|
@@ -82,9 +102,42 @@ function renderVersion() {
|
|
|
82
102
|
badge.hidden = !v;
|
|
83
103
|
if (!v) return;
|
|
84
104
|
const dev = isDevelopmentBuild(v);
|
|
105
|
+
const unread = unreadRelease();
|
|
106
|
+
const notes = unread ? `What’s new in v${unread}` : 'What’s new';
|
|
85
107
|
badge.textContent = dev ? 'dev' : `v${v}`;
|
|
86
|
-
badge.
|
|
87
|
-
badge.
|
|
108
|
+
badge.classList.toggle('unread', Boolean(unread));
|
|
109
|
+
badge.title = [dev ? `Development build (${v})` : `Agent Guild ${v}`, state.pid && `session manager pid ${state.pid}`, notes].filter(Boolean).join(' · ');
|
|
110
|
+
badge.setAttribute('aria-label', `${dev ? `Agent Guild development build ${v}` : `Agent Guild version ${v}`}. ${notes}${unread ? ', not read yet' : ''}`);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const RELEASE_VERSION = /^\d+\.\d+\.\d+$/;
|
|
114
|
+
|
|
115
|
+
function compareReleases(a, b) {
|
|
116
|
+
const [x, y] = [a, b].map((version) => version.split('.').map(Number));
|
|
117
|
+
return x[0] - y[0] || x[1] - y[1] || x[2] - y[2];
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function newestRelease(versions) {
|
|
121
|
+
return versions.filter((v) => RELEASE_VERSION.test(v ?? '')).reduce((newest, v) => (newest && compareReleases(newest, v) >= 0 ? newest : v), null);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function seenRelease() {
|
|
125
|
+
const saved = load(CHANGELOG_SEEN_KEY);
|
|
126
|
+
return RELEASE_VERSION.test(saved ?? '') ? saved : changelogView.seen;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function markReleasesSeen(...versions) {
|
|
130
|
+
const newest = newestRelease([seenRelease(), ...versions]);
|
|
131
|
+
if (!newest) return;
|
|
132
|
+
changelogView.seen = newest;
|
|
133
|
+
save(CHANGELOG_SEEN_KEY, newest);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function unreadRelease() {
|
|
137
|
+
if (!RELEASE_VERSION.test(state.version ?? '')) return null;
|
|
138
|
+
if (!seenRelease()) markReleasesSeen(state.version);
|
|
139
|
+
const newest = newestRelease([state.version, state.upgrade?.pendingVersion, state.upgrade?.latestVersion]);
|
|
140
|
+
return compareReleases(newest, seenRelease()) > 0 ? newest : null;
|
|
88
141
|
}
|
|
89
142
|
|
|
90
143
|
// ---- theme ----------------------------------------------------------------
|
|
@@ -94,7 +147,8 @@ function applyTheme(theme) {
|
|
|
94
147
|
document.documentElement.dataset.theme = theme;
|
|
95
148
|
const button = $('theme-toggle');
|
|
96
149
|
const other = theme === 'dark' ? 'light' : 'dark';
|
|
97
|
-
button.textContent = theme === 'dark' ? '
|
|
150
|
+
button.textContent = theme === 'dark' ? 'Light' : 'Dark';
|
|
151
|
+
button.dataset.next = other;
|
|
98
152
|
button.title = `Switch to the ${other} theme`;
|
|
99
153
|
button.setAttribute('aria-label', `Switch to the ${other} theme`);
|
|
100
154
|
}
|
|
@@ -103,10 +157,18 @@ function currentTheme() {
|
|
|
103
157
|
return document.documentElement.dataset.theme === 'dark' ? 'dark' : 'light';
|
|
104
158
|
}
|
|
105
159
|
|
|
106
|
-
function toggleTheme() {
|
|
160
|
+
function toggleTheme(event) {
|
|
107
161
|
const theme = currentTheme() === 'dark' ? 'light' : 'dark';
|
|
108
162
|
save(THEME_KEY, theme);
|
|
109
|
-
applyTheme(theme);
|
|
163
|
+
if (!document.startViewTransition || reducedMotion.matches) return applyTheme(theme);
|
|
164
|
+
const box = event.currentTarget.getBoundingClientRect();
|
|
165
|
+
const x = box.left + box.width / 2;
|
|
166
|
+
const y = box.top + box.height / 2;
|
|
167
|
+
const root = document.documentElement.style;
|
|
168
|
+
root.setProperty('--reveal-x', `${Math.round(x)}px`);
|
|
169
|
+
root.setProperty('--reveal-y', `${Math.round(y)}px`);
|
|
170
|
+
root.setProperty('--reveal-r', `${Math.ceil(Math.hypot(Math.max(x, innerWidth - x), Math.max(y, innerHeight - y)))}px`);
|
|
171
|
+
document.startViewTransition(() => applyTheme(theme));
|
|
110
172
|
}
|
|
111
173
|
|
|
112
174
|
// ---- upgrading the manager ------------------------------------------------
|
|
@@ -130,6 +192,15 @@ function renderUpgrade() {
|
|
|
130
192
|
restart.title = pending
|
|
131
193
|
? `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
194
|
: 'Stop the session manager and start it again. This ends every session; this page reconnects by itself.';
|
|
195
|
+
const panelUpgrade = $('changelog-upgrade');
|
|
196
|
+
panelUpgrade.hidden = button.hidden;
|
|
197
|
+
panelUpgrade.textContent = button.textContent;
|
|
198
|
+
panelUpgrade.title = button.title;
|
|
199
|
+
const panelRestart = $('changelog-restart');
|
|
200
|
+
panelRestart.hidden = restart.hidden || !pending;
|
|
201
|
+
panelRestart.textContent = restart.textContent;
|
|
202
|
+
panelRestart.title = restart.title;
|
|
203
|
+
$('changelog-actions').hidden = panelUpgrade.hidden && panelRestart.hidden;
|
|
133
204
|
let text = '';
|
|
134
205
|
let title = '';
|
|
135
206
|
const last = u?.lastInstall;
|
|
@@ -158,6 +229,8 @@ function setUpgrade(upgrade) {
|
|
|
158
229
|
const before = state.upgrade;
|
|
159
230
|
state.upgrade = upgrade || null;
|
|
160
231
|
renderUpgrade();
|
|
232
|
+
renderVersion();
|
|
233
|
+
if ($('changelog').open) renderChangelog();
|
|
161
234
|
const pending = state.upgrade?.pendingVersion;
|
|
162
235
|
if (pending && pending !== before?.pendingVersion) {
|
|
163
236
|
toast(state.restartable
|
|
@@ -242,9 +315,13 @@ function paintProviderIcon(el, provider) {
|
|
|
242
315
|
const FAMILIARS = ['flame', 'leaf', 'night', 'aether'];
|
|
243
316
|
|
|
244
317
|
function renderAgents(container, agents) {
|
|
318
|
+
const known = container.dataset.rendered ? new Set([...container.children].map((el) => el.dataset.agent)) : null;
|
|
319
|
+
container.dataset.rendered = 'true';
|
|
245
320
|
container.replaceChildren(...agents.map((agent) => {
|
|
246
321
|
const el = document.createElement('span');
|
|
247
322
|
el.className = `agent ${agent.status}`;
|
|
323
|
+
el.dataset.agent = agent.id;
|
|
324
|
+
if (known && !known.has(agent.id)) el.classList.add('summon');
|
|
248
325
|
el.style.setProperty('--c', `hsl(${hueFor(agent.name)} 65% 50%)`);
|
|
249
326
|
el.dataset.familiar = FAMILIARS[hueFor(agent.name) % FAMILIARS.length];
|
|
250
327
|
el.textContent = (agent.name || '?').charAt(0).toUpperCase();
|
|
@@ -327,6 +404,8 @@ function renderAccounts(card, provider) {
|
|
|
327
404
|
});
|
|
328
405
|
}
|
|
329
406
|
|
|
407
|
+
let dealt = false;
|
|
408
|
+
|
|
330
409
|
function renderProviders() {
|
|
331
410
|
const list = $('providers');
|
|
332
411
|
const tpl = $('provider-template');
|
|
@@ -371,6 +450,10 @@ function renderProviders() {
|
|
|
371
450
|
renderModelStats(node, provider);
|
|
372
451
|
return node;
|
|
373
452
|
}));
|
|
453
|
+
if (!dealt && state.providers.length) {
|
|
454
|
+
dealt = true;
|
|
455
|
+
if (!reducedMotion.matches) list.classList.add('deal');
|
|
456
|
+
}
|
|
374
457
|
}
|
|
375
458
|
|
|
376
459
|
const openCopies = new Set();
|
|
@@ -426,7 +509,7 @@ function renderHint(hint, provider) {
|
|
|
426
509
|
link.href = docs;
|
|
427
510
|
link.target = '_blank';
|
|
428
511
|
link.rel = 'noopener noreferrer';
|
|
429
|
-
link.textContent = 'Docs
|
|
512
|
+
link.textContent = 'Docs';
|
|
430
513
|
hint.append(' ', link);
|
|
431
514
|
}
|
|
432
515
|
|
|
@@ -810,7 +893,7 @@ function modelFacts(card) {
|
|
|
810
893
|
link.href = httpsHref(`https://openrouter.ai/${card.id}`) ?? '';
|
|
811
894
|
link.target = '_blank';
|
|
812
895
|
link.rel = 'noopener noreferrer';
|
|
813
|
-
link.textContent = 'OpenRouter
|
|
896
|
+
link.textContent = 'OpenRouter';
|
|
814
897
|
link.setAttribute('aria-label', `${card.name} on OpenRouter (opens in a new tab)`);
|
|
815
898
|
const facts = document.createElement('div');
|
|
816
899
|
facts.className = 'model-more';
|
|
@@ -1229,6 +1312,212 @@ function tickNews() {
|
|
|
1229
1312
|
if ($('news').open) renderNewsStatus();
|
|
1230
1313
|
}
|
|
1231
1314
|
|
|
1315
|
+
const RELEASE_BADGES = {
|
|
1316
|
+
running: ['in-use', 'Running', 'The session manager runs this version'],
|
|
1317
|
+
installed: ['installed', 'Installed', 'Installed. Restart the session manager to use it.'],
|
|
1318
|
+
newer: ['new', 'New', 'Released after the version the session manager runs'],
|
|
1319
|
+
};
|
|
1320
|
+
const changelogView = { opener: null, failed: null, seen: null };
|
|
1321
|
+
let changelogLoading = null;
|
|
1322
|
+
let changelogAgain = false;
|
|
1323
|
+
|
|
1324
|
+
function loadChangelog() {
|
|
1325
|
+
if (changelogLoading) {
|
|
1326
|
+
changelogAgain = true;
|
|
1327
|
+
return changelogLoading;
|
|
1328
|
+
}
|
|
1329
|
+
changelogLoading = api('GET', '/changelog').then((changelog) => {
|
|
1330
|
+
state.changelog = changelog;
|
|
1331
|
+
changelogView.failed = null;
|
|
1332
|
+
}, (err) => {
|
|
1333
|
+
if (err instanceof AuthError) return showAuth(err.message);
|
|
1334
|
+
changelogView.failed = state.connected ? err.message : 'the session manager is not reachable';
|
|
1335
|
+
}).finally(() => {
|
|
1336
|
+
changelogLoading = null;
|
|
1337
|
+
if ($('changelog').open) {
|
|
1338
|
+
markReleasesSeen(state.changelog?.releases[0]?.version);
|
|
1339
|
+
renderVersion();
|
|
1340
|
+
renderChangelog();
|
|
1341
|
+
}
|
|
1342
|
+
if (changelogAgain) {
|
|
1343
|
+
changelogAgain = false;
|
|
1344
|
+
loadChangelog();
|
|
1345
|
+
}
|
|
1346
|
+
});
|
|
1347
|
+
return changelogLoading;
|
|
1348
|
+
}
|
|
1349
|
+
|
|
1350
|
+
function openChangelog() {
|
|
1351
|
+
const dialog = $('changelog');
|
|
1352
|
+
renderChangelog();
|
|
1353
|
+
if (!dialog.open) {
|
|
1354
|
+
changelogView.opener = document.activeElement;
|
|
1355
|
+
dialog.showModal();
|
|
1356
|
+
}
|
|
1357
|
+
($('changelog-list').querySelector('a') ?? $('changelog-close')).focus();
|
|
1358
|
+
markReleasesSeen(state.version, state.upgrade?.pendingVersion, state.upgrade?.latestVersion, state.changelog?.releases[0]?.version);
|
|
1359
|
+
renderVersion();
|
|
1360
|
+
loadChangelog();
|
|
1361
|
+
}
|
|
1362
|
+
|
|
1363
|
+
function closeChangelog() {
|
|
1364
|
+
if ($('changelog').open) $('changelog').close();
|
|
1365
|
+
}
|
|
1366
|
+
|
|
1367
|
+
function releasesLink(label) {
|
|
1368
|
+
const link = document.createElement('a');
|
|
1369
|
+
link.className = 'console-link';
|
|
1370
|
+
link.href = RELEASES_URL;
|
|
1371
|
+
link.target = '_blank';
|
|
1372
|
+
link.rel = 'noopener noreferrer';
|
|
1373
|
+
link.textContent = label;
|
|
1374
|
+
link.setAttribute('aria-description', 'Opens in a new tab');
|
|
1375
|
+
return link;
|
|
1376
|
+
}
|
|
1377
|
+
|
|
1378
|
+
function releaseStatus(version) {
|
|
1379
|
+
const running = state.version;
|
|
1380
|
+
if (!RELEASE_VERSION.test(running ?? '')) return null;
|
|
1381
|
+
const order = compareReleases(version, running);
|
|
1382
|
+
if (order <= 0) return order === 0 ? 'running' : null;
|
|
1383
|
+
const installed = state.upgrade?.pendingVersion;
|
|
1384
|
+
return RELEASE_VERSION.test(installed ?? '') && compareReleases(version, installed) <= 0 ? 'installed' : 'newer';
|
|
1385
|
+
}
|
|
1386
|
+
|
|
1387
|
+
function changeRun(run) {
|
|
1388
|
+
const href = run.url ? webHref(run.url) : null;
|
|
1389
|
+
let node;
|
|
1390
|
+
if (href) {
|
|
1391
|
+
node = document.createElement('a');
|
|
1392
|
+
node.className = 'console-link';
|
|
1393
|
+
node.href = href;
|
|
1394
|
+
node.target = '_blank';
|
|
1395
|
+
node.rel = 'noopener noreferrer';
|
|
1396
|
+
node.textContent = run.text;
|
|
1397
|
+
} else if (run.code) {
|
|
1398
|
+
node = document.createElement('code');
|
|
1399
|
+
node.textContent = run.text;
|
|
1400
|
+
} else {
|
|
1401
|
+
node = document.createTextNode(run.text);
|
|
1402
|
+
}
|
|
1403
|
+
if (!run.strong) return node;
|
|
1404
|
+
const strong = document.createElement('strong');
|
|
1405
|
+
strong.append(node);
|
|
1406
|
+
return strong;
|
|
1407
|
+
}
|
|
1408
|
+
|
|
1409
|
+
function changeGroup(section) {
|
|
1410
|
+
const group = document.createElement('div');
|
|
1411
|
+
group.className = 'changelog-group';
|
|
1412
|
+
if (section.title) {
|
|
1413
|
+
const title = document.createElement('h4');
|
|
1414
|
+
title.className = 'changelog-type';
|
|
1415
|
+
title.classList.toggle('breaking', /breaking/i.test(section.title));
|
|
1416
|
+
title.textContent = section.title;
|
|
1417
|
+
group.append(title);
|
|
1418
|
+
}
|
|
1419
|
+
const list = document.createElement('ul');
|
|
1420
|
+
list.className = 'changelog-changes';
|
|
1421
|
+
list.append(...section.changes.map((runs) => {
|
|
1422
|
+
const item = document.createElement('li');
|
|
1423
|
+
item.append(...runs.map(changeRun));
|
|
1424
|
+
return item;
|
|
1425
|
+
}));
|
|
1426
|
+
group.append(list);
|
|
1427
|
+
return group;
|
|
1428
|
+
}
|
|
1429
|
+
|
|
1430
|
+
function releaseEntry(release) {
|
|
1431
|
+
const entry = document.createElement('li');
|
|
1432
|
+
entry.className = 'changelog-release';
|
|
1433
|
+
entry.dataset.version = release.version;
|
|
1434
|
+
const heading = document.createElement('h3');
|
|
1435
|
+
heading.className = 'news-head';
|
|
1436
|
+
const href = webHref(release.url);
|
|
1437
|
+
const title = document.createElement(href ? 'a' : 'span');
|
|
1438
|
+
title.className = 'news-link';
|
|
1439
|
+
title.textContent = `v${release.version}`;
|
|
1440
|
+
if (href) {
|
|
1441
|
+
title.href = href;
|
|
1442
|
+
title.target = '_blank';
|
|
1443
|
+
title.rel = 'noopener noreferrer';
|
|
1444
|
+
title.setAttribute('aria-description', 'Opens the release on GitHub in a new tab');
|
|
1445
|
+
}
|
|
1446
|
+
heading.append(title);
|
|
1447
|
+
const status = releaseStatus(release.version);
|
|
1448
|
+
if (status) heading.append(badge(...RELEASE_BADGES[status]));
|
|
1449
|
+
entry.append(heading);
|
|
1450
|
+
if (release.publishedAt) {
|
|
1451
|
+
const time = document.createElement('time');
|
|
1452
|
+
time.className = 'news-meta';
|
|
1453
|
+
time.dateTime = release.publishedAt;
|
|
1454
|
+
time.textContent = relativeTime(release.publishedAt);
|
|
1455
|
+
time.title = new Date(release.publishedAt).toLocaleString();
|
|
1456
|
+
entry.append(time);
|
|
1457
|
+
}
|
|
1458
|
+
entry.append(...release.sections.map(changeGroup));
|
|
1459
|
+
return entry;
|
|
1460
|
+
}
|
|
1461
|
+
|
|
1462
|
+
function changelogSummary() {
|
|
1463
|
+
const v = state.version;
|
|
1464
|
+
if (!v) return 'Agent Guild releases, newest first';
|
|
1465
|
+
if (isDevelopmentBuild(v)) return `Development build (${v}) · releases, newest first`;
|
|
1466
|
+
const u = state.upgrade;
|
|
1467
|
+
const parts = [`Running v${v}`];
|
|
1468
|
+
if (u?.pendingVersion) parts.push(`v${u.pendingVersion} installed`);
|
|
1469
|
+
const newer = state.changelog?.releases.some((release) => compareReleases(release.version, v) > 0);
|
|
1470
|
+
if (u?.available && u.latestVersion) parts.push(`v${u.latestVersion} available`);
|
|
1471
|
+
else if (!u?.pendingVersion && u?.latestVersion === v && !newer) parts.push('the latest release');
|
|
1472
|
+
return parts.join(' · ');
|
|
1473
|
+
}
|
|
1474
|
+
|
|
1475
|
+
function changelogNote(changelog) {
|
|
1476
|
+
if (changelog?.releases.length) return [];
|
|
1477
|
+
if (changelogView.failed) return [`Release notes could not be loaded: ${changelogView.failed}. `, releasesLink('Read them on GitHub')];
|
|
1478
|
+
if (!changelog || changelog.refreshing) return ['Loading release notes…'];
|
|
1479
|
+
if (changelog.error) return [`Release notes could not be loaded. GitHub Releases: ${changelog.error}. `, releasesLink('Read them on GitHub')];
|
|
1480
|
+
return ['No releases yet.'];
|
|
1481
|
+
}
|
|
1482
|
+
|
|
1483
|
+
function renderChangelogStatus(changelog) {
|
|
1484
|
+
const lines = [];
|
|
1485
|
+
if (changelog) {
|
|
1486
|
+
const updated = changelog.refreshing ? 'checking for new releases…' : changelog.okAt ? `updated ${relativeTime(changelog.okAt)}` : '';
|
|
1487
|
+
lines.push(['From GitHub Releases', updated].filter(Boolean).join(' · '));
|
|
1488
|
+
if (changelog.error && changelog.releases.length) lines.push(`The last check failed (${changelog.error}); showing notes fetched ${relativeTime(changelog.okAt)}.`);
|
|
1489
|
+
}
|
|
1490
|
+
lines.push(releasesLink('All releases on GitHub'));
|
|
1491
|
+
$('changelog-status').replaceChildren(...lines.map((content) => {
|
|
1492
|
+
const line = document.createElement('span');
|
|
1493
|
+
line.append(content);
|
|
1494
|
+
return line;
|
|
1495
|
+
}));
|
|
1496
|
+
$('changelog-status').hidden = false;
|
|
1497
|
+
}
|
|
1498
|
+
|
|
1499
|
+
function renderChangelog() {
|
|
1500
|
+
const changelog = state.changelog;
|
|
1501
|
+
const releases = changelog?.releases ?? [];
|
|
1502
|
+
$('changelog-sub').textContent = changelogSummary();
|
|
1503
|
+
const list = $('changelog-list');
|
|
1504
|
+
const body = list.parentElement;
|
|
1505
|
+
const top = body.scrollTop;
|
|
1506
|
+
const entry = document.activeElement?.closest?.('#changelog-list .changelog-release');
|
|
1507
|
+
const focus = entry && { version: entry.dataset.version, index: [...entry.querySelectorAll('a')].indexOf(document.activeElement) };
|
|
1508
|
+
list.replaceChildren(...releases.map(releaseEntry));
|
|
1509
|
+
list.hidden = releases.length === 0;
|
|
1510
|
+
const note = changelogNote(changelog);
|
|
1511
|
+
$('changelog-note').replaceChildren(...note);
|
|
1512
|
+
$('changelog-note').hidden = note.length === 0;
|
|
1513
|
+
renderChangelogStatus(changelog);
|
|
1514
|
+
body.scrollTop = top;
|
|
1515
|
+
if (focus) {
|
|
1516
|
+
const again = [...list.children].find((el) => el.dataset.version === focus.version);
|
|
1517
|
+
(again?.querySelectorAll('a')[focus.index] ?? again?.querySelector('a') ?? $('changelog-close')).focus({ preventScroll: true });
|
|
1518
|
+
}
|
|
1519
|
+
}
|
|
1520
|
+
|
|
1232
1521
|
const CHANNEL_LABELS = {
|
|
1233
1522
|
npm: 'npm', native: 'native', brew: 'Homebrew', winget: 'WinGet', legacy: 'legacy install', unknown: 'unknown install',
|
|
1234
1523
|
};
|
|
@@ -1336,8 +1625,12 @@ function shortId(id) {
|
|
|
1336
1625
|
return id.length > 12 ? id.slice(0, 8) : id;
|
|
1337
1626
|
}
|
|
1338
1627
|
|
|
1628
|
+
function copyText(text, what) {
|
|
1629
|
+
navigator.clipboard?.writeText(text).then(() => toast(`Copied ${what}`, 2500), () => toast(text, 10000));
|
|
1630
|
+
}
|
|
1631
|
+
|
|
1339
1632
|
function copyId(id) {
|
|
1340
|
-
|
|
1633
|
+
copyText(id, id);
|
|
1341
1634
|
}
|
|
1342
1635
|
|
|
1343
1636
|
function paintIdButton(button, id) {
|
|
@@ -1507,9 +1800,602 @@ function resumeById(event) {
|
|
|
1507
1800
|
if (provider && id) resumeFromHistory(provider, id, null);
|
|
1508
1801
|
}
|
|
1509
1802
|
|
|
1803
|
+
// ---- GitHub ---------------------------------------------------------------
|
|
1804
|
+
|
|
1805
|
+
const GITHUB_SCOPES = {
|
|
1806
|
+
repo: 'Read and write access to all your repositories, private ones included. GitHub offers apps no read-only choice; Agent Guild only lists them.',
|
|
1807
|
+
'write:public_key': 'Add the SSH key Agent Guild creates for this account.',
|
|
1808
|
+
};
|
|
1809
|
+
const githubView = { accountId: null, repos: null, reposFor: null, loading: null, error: null, parentError: null, opener: null, card: null, started: new Set(), announced: new Set() };
|
|
1810
|
+
let githubLoading = null;
|
|
1811
|
+
|
|
1812
|
+
function el(tag, className, ...children) {
|
|
1813
|
+
const node = document.createElement(tag);
|
|
1814
|
+
if (className) node.className = className;
|
|
1815
|
+
node.append(...children.filter((child) => child !== null && child !== undefined && child !== false));
|
|
1816
|
+
return node;
|
|
1817
|
+
}
|
|
1818
|
+
|
|
1819
|
+
function button(label, onClick, className = 'btn') {
|
|
1820
|
+
const node = el('button', className, label);
|
|
1821
|
+
node.type = 'button';
|
|
1822
|
+
node.addEventListener('click', onClick);
|
|
1823
|
+
return node;
|
|
1824
|
+
}
|
|
1825
|
+
|
|
1826
|
+
function externalLink(label, href, className = 'console-link') {
|
|
1827
|
+
const link = el('a', className, label);
|
|
1828
|
+
link.href = webHref(href) ?? '';
|
|
1829
|
+
link.target = '_blank';
|
|
1830
|
+
link.rel = 'noopener noreferrer';
|
|
1831
|
+
link.setAttribute('aria-description', 'Opens in a new tab');
|
|
1832
|
+
return link;
|
|
1833
|
+
}
|
|
1834
|
+
|
|
1835
|
+
function githubAccount() {
|
|
1836
|
+
return state.github?.accounts.find((a) => a.id === githubView.accountId) ?? null;
|
|
1837
|
+
}
|
|
1838
|
+
|
|
1839
|
+
function selectGitHubAccount(id) {
|
|
1840
|
+
if (githubView.accountId === id) return;
|
|
1841
|
+
githubView.accountId = id;
|
|
1842
|
+
githubView.repos = null;
|
|
1843
|
+
githubView.reposFor = null;
|
|
1844
|
+
githubView.error = null;
|
|
1845
|
+
save(GITHUB_ACCOUNT_KEY, id === null ? null : String(id));
|
|
1846
|
+
}
|
|
1847
|
+
|
|
1848
|
+
let githubAgain = false;
|
|
1849
|
+
|
|
1850
|
+
function loadGitHub() {
|
|
1851
|
+
if (githubLoading) {
|
|
1852
|
+
githubAgain = true;
|
|
1853
|
+
return githubLoading;
|
|
1854
|
+
}
|
|
1855
|
+
githubLoading = api('GET', '/github').then(({ github }) => setGitHub(github), (err) => {
|
|
1856
|
+
if (err instanceof AuthError) return showAuth(err.message);
|
|
1857
|
+
toast(err.message, 8000);
|
|
1858
|
+
}).finally(() => {
|
|
1859
|
+
githubLoading = null;
|
|
1860
|
+
if (githubAgain) {
|
|
1861
|
+
githubAgain = false;
|
|
1862
|
+
loadGitHub();
|
|
1863
|
+
}
|
|
1864
|
+
});
|
|
1865
|
+
return githubLoading;
|
|
1866
|
+
}
|
|
1867
|
+
|
|
1868
|
+
function setGitHub(github) {
|
|
1869
|
+
const before = state.github?.signIn;
|
|
1870
|
+
state.github = github;
|
|
1871
|
+
const done = github.signIn?.status === 'done' && before?.status === 'pending' ? github.signIn : null;
|
|
1872
|
+
if (done) {
|
|
1873
|
+
selectGitHubAccount(done.accountId);
|
|
1874
|
+
Object.assign(githubView, { repos: null, reposFor: null, error: null });
|
|
1875
|
+
}
|
|
1876
|
+
if (!githubAccount()) selectGitHubAccount(github.accounts[0]?.id ?? null);
|
|
1877
|
+
const account = githubAccount();
|
|
1878
|
+
if (done && account) {
|
|
1879
|
+
toast(done.again
|
|
1880
|
+
? `You are already signed in as @${account.login}; that sign-in was renewed. To add another account, switch to it on github.com first.`
|
|
1881
|
+
: `Signed in to GitHub as @${account.login}.`, done.again ? 10000 : 4000);
|
|
1882
|
+
}
|
|
1883
|
+
if (!$('github').open) return;
|
|
1884
|
+
renderGitHub();
|
|
1885
|
+
ensureRepos();
|
|
1886
|
+
}
|
|
1887
|
+
|
|
1888
|
+
function cloneParent() {
|
|
1889
|
+
return $('github-parent').value.trim();
|
|
1890
|
+
}
|
|
1891
|
+
|
|
1892
|
+
function reposKey(account) {
|
|
1893
|
+
return account ? `${account.id}\n${cloneParent()}` : null;
|
|
1894
|
+
}
|
|
1895
|
+
|
|
1896
|
+
function ensureRepos() {
|
|
1897
|
+
const account = githubAccount();
|
|
1898
|
+
if (!account || account.needsSignIn) return;
|
|
1899
|
+
const key = reposKey(account);
|
|
1900
|
+
if (githubView.reposFor !== key && githubView.loading !== key) loadRepos();
|
|
1901
|
+
}
|
|
1902
|
+
|
|
1903
|
+
async function loadRepos({ refresh = false } = {}) {
|
|
1904
|
+
const account = githubAccount();
|
|
1905
|
+
if (!account) return;
|
|
1906
|
+
const key = reposKey(account);
|
|
1907
|
+
const parent = cloneParent();
|
|
1908
|
+
githubView.loading = key;
|
|
1909
|
+
renderGitHub();
|
|
1910
|
+
const ask = (withParent) => {
|
|
1911
|
+
const query = new URLSearchParams();
|
|
1912
|
+
if (withParent && parent) query.set('parent', parent);
|
|
1913
|
+
if (refresh) query.set('refresh', '1');
|
|
1914
|
+
return api('GET', `/github/accounts/${account.id}/repos?${query}`);
|
|
1915
|
+
};
|
|
1916
|
+
let result = null;
|
|
1917
|
+
let error = null;
|
|
1918
|
+
let parentError = null;
|
|
1919
|
+
try {
|
|
1920
|
+
try {
|
|
1921
|
+
result = (await ask(true)).repos;
|
|
1922
|
+
} catch (err) {
|
|
1923
|
+
if (err.code !== 'bad_cwd') throw err;
|
|
1924
|
+
parentError = err.message;
|
|
1925
|
+
result = (await ask(false)).repos;
|
|
1926
|
+
}
|
|
1927
|
+
} catch (err) {
|
|
1928
|
+
if (err instanceof AuthError) return showAuth(err.message);
|
|
1929
|
+
error = err.message;
|
|
1930
|
+
}
|
|
1931
|
+
if (githubView.loading !== key) return;
|
|
1932
|
+
Object.assign(githubView, { loading: null, reposFor: key, error, parentError });
|
|
1933
|
+
if (result || !githubView.repos || githubView.repos.accountId !== account.id) githubView.repos = result;
|
|
1934
|
+
renderGitHub();
|
|
1935
|
+
}
|
|
1936
|
+
|
|
1937
|
+
async function startGitHubSignIn() {
|
|
1938
|
+
try {
|
|
1939
|
+
setGitHub((await api('POST', '/github/sign-in')).github);
|
|
1940
|
+
} catch (err) {
|
|
1941
|
+
if (err instanceof AuthError) return showAuth(err.message);
|
|
1942
|
+
toast(err.message, 8000);
|
|
1943
|
+
}
|
|
1944
|
+
}
|
|
1945
|
+
|
|
1946
|
+
async function cancelGitHubSignIn() {
|
|
1947
|
+
try {
|
|
1948
|
+
setGitHub((await api('DELETE', '/github/sign-in')).github);
|
|
1949
|
+
} catch (err) {
|
|
1950
|
+
toast(err.message);
|
|
1951
|
+
}
|
|
1952
|
+
}
|
|
1953
|
+
|
|
1954
|
+
async function setupGitHubSsh(account) {
|
|
1955
|
+
try {
|
|
1956
|
+
const result = await api('POST', `/github/accounts/${account.id}/ssh`);
|
|
1957
|
+
if (result.account.ssh.status === 'ready') toast(`SSH is ready for @${account.login}.`, 4000);
|
|
1958
|
+
} catch (err) {
|
|
1959
|
+
if (err instanceof AuthError) return showAuth(err.message);
|
|
1960
|
+
toast(err.message, 8000);
|
|
1961
|
+
}
|
|
1962
|
+
loadGitHub();
|
|
1963
|
+
}
|
|
1964
|
+
|
|
1965
|
+
async function signOutGitHub(account) {
|
|
1966
|
+
const text = `Sign out of @${account.login}? Agent Guild forgets this sign-in. Its SSH key stays in the Agent Guild data folder and on your GitHub account, so existing clones keep working.\n\nTo remove Agent Guild's access entirely, revoke it on GitHub; GitHub then also removes the SSH keys Agent Guild added.`;
|
|
1967
|
+
if (!confirm(text)) return;
|
|
1968
|
+
try {
|
|
1969
|
+
const { github } = await api('DELETE', `/github/accounts/${account.id}`);
|
|
1970
|
+
if (githubView.accountId === account.id) selectGitHubAccount(null);
|
|
1971
|
+
setGitHub(github);
|
|
1972
|
+
} catch (err) {
|
|
1973
|
+
toast(err.message);
|
|
1974
|
+
}
|
|
1975
|
+
}
|
|
1976
|
+
|
|
1977
|
+
function openGitHub() {
|
|
1978
|
+
const dialog = $('github');
|
|
1979
|
+
$('github-parent').value = load(CLONE_PARENT_KEY) ?? $('cwd').value.trim();
|
|
1980
|
+
$('github-filter').value = '';
|
|
1981
|
+
githubView.card = null;
|
|
1982
|
+
if (!dialog.open) {
|
|
1983
|
+
githubView.opener = document.activeElement;
|
|
1984
|
+
dialog.showModal();
|
|
1985
|
+
}
|
|
1986
|
+
renderGitHub();
|
|
1987
|
+
if (githubAccount() && !githubAccount().needsSignIn) loadRepos();
|
|
1988
|
+
loadGitHub();
|
|
1989
|
+
($('github-card').querySelector('.btn.primary') ?? $('github-close')).focus();
|
|
1990
|
+
}
|
|
1991
|
+
|
|
1992
|
+
function closeGitHub({ focusOpener = true } = {}) {
|
|
1993
|
+
if (!$('github').open) return;
|
|
1994
|
+
if (!focusOpener) githubView.opener = false;
|
|
1995
|
+
$('github').close();
|
|
1996
|
+
}
|
|
1997
|
+
|
|
1998
|
+
function renderGitHub() {
|
|
1999
|
+
const github = state.github;
|
|
2000
|
+
const account = githubAccount();
|
|
2001
|
+
const repos = githubView.repos?.accountId === account?.id ? githubView.repos : null;
|
|
2002
|
+
const sub = !github ? 'Loading…'
|
|
2003
|
+
: !account ? 'Clone your repositories over SSH'
|
|
2004
|
+
: [`@${account.login}`, account.name, repos && `${repos.repos.length} ${repos.repos.length === 1 ? 'repository' : 'repositories'}`].filter(Boolean).join(' · ');
|
|
2005
|
+
$('github-sub').textContent = sub;
|
|
2006
|
+
renderGitHubChips(github);
|
|
2007
|
+
renderGitHubCard(github, account);
|
|
2008
|
+
renderGitHubStatus(github, account);
|
|
2009
|
+
renderGitHubRepos(github, account, repos);
|
|
2010
|
+
}
|
|
2011
|
+
|
|
2012
|
+
function githubAvatar(account) {
|
|
2013
|
+
if (account.avatar?.startsWith('data:image/')) {
|
|
2014
|
+
const img = el('img', 'github-avatar');
|
|
2015
|
+
img.src = account.avatar;
|
|
2016
|
+
img.alt = '';
|
|
2017
|
+
return img;
|
|
2018
|
+
}
|
|
2019
|
+
const mono = el('span', 'github-avatar', account.login.charAt(0).toUpperCase());
|
|
2020
|
+
mono.style.setProperty('--c', `hsl(${hueFor(account.login)} 45% 45%)`);
|
|
2021
|
+
return mono;
|
|
2022
|
+
}
|
|
2023
|
+
|
|
2024
|
+
function renderGitHubChips(github) {
|
|
2025
|
+
const accounts = github?.accounts ?? [];
|
|
2026
|
+
$('github-accounts').hidden = accounts.length === 0;
|
|
2027
|
+
const host = $('github-chips');
|
|
2028
|
+
const focused = document.activeElement?.closest?.('#github-chips .account-chip')?.dataset.account;
|
|
2029
|
+
host.replaceChildren(...accounts.map((account) => {
|
|
2030
|
+
const chip = el('button', `account-chip github-chip${account.needsSignIn ? ' unsigned' : ''}`, githubAvatar(account), el('span', null, account.login));
|
|
2031
|
+
chip.type = 'button';
|
|
2032
|
+
chip.setAttribute('role', 'tab');
|
|
2033
|
+
chip.dataset.account = account.id;
|
|
2034
|
+
chip.setAttribute('aria-selected', String(account.id === githubView.accountId));
|
|
2035
|
+
chip.title = account.needsSignIn ? `@${account.login}: sign in again` : `${account.name ? `${account.name} · ` : ''}@${account.login}${account.ssh.status === 'ready' ? ' · SSH ready' : ' · SSH not set up'}`;
|
|
2036
|
+
chip.addEventListener('click', () => {
|
|
2037
|
+
selectGitHubAccount(account.id);
|
|
2038
|
+
githubView.card = null;
|
|
2039
|
+
renderGitHub();
|
|
2040
|
+
ensureRepos();
|
|
2041
|
+
});
|
|
2042
|
+
return chip;
|
|
2043
|
+
}));
|
|
2044
|
+
if (focused) [...host.children].find((chip) => chip.dataset.account === focused)?.focus({ preventScroll: true });
|
|
2045
|
+
const pending = github?.signIn?.status === 'pending';
|
|
2046
|
+
$('github-add').hidden = accounts.length === 0 || pending;
|
|
2047
|
+
}
|
|
2048
|
+
|
|
2049
|
+
function minutesLeft(iso) {
|
|
2050
|
+
return Math.max(1, Math.ceil((Date.parse(iso) - Date.now()) / 60000));
|
|
2051
|
+
}
|
|
2052
|
+
|
|
2053
|
+
function scopeList(github) {
|
|
2054
|
+
return el('ul', 'github-scopes', ...(github.scopes ?? []).map((scope) => el('li', null, el('code', null, scope), ` ${GITHUB_SCOPES[scope] ?? ''}`)));
|
|
2055
|
+
}
|
|
2056
|
+
|
|
2057
|
+
function signInCard(github) {
|
|
2058
|
+
return [
|
|
2059
|
+
el('h3', null, 'Sign in to GitHub'),
|
|
2060
|
+
el('p', null, 'See your repositories and clone them over SSH, with a key Agent Guild keeps for each account.'),
|
|
2061
|
+
el('p', 'github-small', 'GitHub will ask you to allow Agent Guild to:'),
|
|
2062
|
+
scopeList(github),
|
|
2063
|
+
el('p', 'github-small', 'Your sign-in stays in the Agent Guild data folder on this computer; this page never receives it.'),
|
|
2064
|
+
el('div', 'github-actions', button('Sign in with GitHub', startGitHubSignIn, 'btn primary')),
|
|
2065
|
+
];
|
|
2066
|
+
}
|
|
2067
|
+
|
|
2068
|
+
function codeCard(signIn) {
|
|
2069
|
+
const code = signIn.userCode;
|
|
2070
|
+
const open = externalLink('Open GitHub', signIn.verificationUri, 'btn primary');
|
|
2071
|
+
open.addEventListener('click', () => navigator.clipboard?.writeText(code).catch(() => {}));
|
|
2072
|
+
open.title = `Copies the code and opens ${signIn.verificationUri}`;
|
|
2073
|
+
const codeText = el('span', 'github-code-text', code);
|
|
2074
|
+
codeText.setAttribute('aria-label', `Code ${[...code].join(' ')}`);
|
|
2075
|
+
return [
|
|
2076
|
+
el('h3', null, 'Enter this code on GitHub'),
|
|
2077
|
+
el('div', 'github-code', codeText, button('Copy', () => copyText(code, 'the code'))),
|
|
2078
|
+
el('div', 'github-actions', open, button('Cancel', cancelGitHubSignIn)),
|
|
2079
|
+
el('p', 'github-small github-waiting', `Waiting for you to approve Agent Guild on GitHub. The code expires in ${minutesLeft(signIn.expiresAt)} min.`),
|
|
2080
|
+
el('p', 'github-small', 'Adding another account? Switch to it on github.com before you enter the code.'),
|
|
2081
|
+
];
|
|
2082
|
+
}
|
|
2083
|
+
|
|
2084
|
+
function signInResultCard(signIn) {
|
|
2085
|
+
const text = signIn.status === 'expired' ? 'The code expired before it was entered on GitHub.'
|
|
2086
|
+
: signIn.status === 'denied' ? 'The sign-in was declined on GitHub.'
|
|
2087
|
+
: `The sign-in did not finish: ${signIn.error || 'GitHub refused it'}.`;
|
|
2088
|
+
return [
|
|
2089
|
+
el('h3', null, 'Not signed in'),
|
|
2090
|
+
el('p', null, text),
|
|
2091
|
+
el('div', 'github-actions', button('Try again', startGitHubSignIn, 'btn primary'), button('Dismiss', cancelGitHubSignIn)),
|
|
2092
|
+
];
|
|
2093
|
+
}
|
|
2094
|
+
|
|
2095
|
+
function signInAgainCard(account) {
|
|
2096
|
+
return [
|
|
2097
|
+
el('h3', null, `Sign in again as @${account.login}`),
|
|
2098
|
+
el('p', null, `GitHub no longer accepts Agent Guild's sign-in for @${account.login}. If github.com is signed in to another account, switch to @${account.login} there first.`),
|
|
2099
|
+
el('div', 'github-actions', button('Sign in again', startGitHubSignIn, 'btn primary'), button('Sign out', () => signOutGitHub(account))),
|
|
2100
|
+
];
|
|
2101
|
+
}
|
|
2102
|
+
|
|
2103
|
+
function sshCard(github, account) {
|
|
2104
|
+
const ssh = account.ssh;
|
|
2105
|
+
const missing = !github.tools.ssh || !github.tools.sshKeygen;
|
|
2106
|
+
const step = (done, text) => el('li', done ? 'done' : null, text);
|
|
2107
|
+
const parts = [
|
|
2108
|
+
el('h3', null, `Set up SSH for @${account.login}`),
|
|
2109
|
+
el('p', null, 'Agent Guild clones over SSH with a key of its own for each account, so your other SSH keys and settings are never used or changed.'),
|
|
2110
|
+
el('ol', 'github-steps',
|
|
2111
|
+
step(Boolean(ssh.key), 'Create a key for this account in the Agent Guild data folder'),
|
|
2112
|
+
step(false, 'Add its public key to your GitHub account'),
|
|
2113
|
+
step(false, `Check that GitHub signs in as @${account.login}`)),
|
|
2114
|
+
];
|
|
2115
|
+
if (missing) {
|
|
2116
|
+
parts.push(el('p', 'github-error', 'OpenSSH (ssh and ssh-keygen) was not found. On Windows, add the "OpenSSH Client" optional feature in Settings, or install Git for Windows; on Linux, install the openssh-client package. Then check again.'));
|
|
2117
|
+
} else if (ssh.error) {
|
|
2118
|
+
parts.push(el('p', 'github-error', ssh.error.message));
|
|
2119
|
+
}
|
|
2120
|
+
const setup = button(ssh.settingUp ? 'Setting up…' : ssh.error || ssh.key ? 'Check again' : 'Set up SSH', () => setupGitHubSsh(account), 'btn primary');
|
|
2121
|
+
setup.disabled = ssh.settingUp || missing;
|
|
2122
|
+
const actions = el('div', 'github-actions', setup);
|
|
2123
|
+
if (ssh.error?.manual && ssh.publicKey) {
|
|
2124
|
+
actions.append(button('Copy public key', () => copyText(ssh.publicKey, 'the public key')), externalLink('Open GitHub SSH settings', github.newKeyUrl, 'btn'));
|
|
2125
|
+
}
|
|
2126
|
+
parts.push(actions);
|
|
2127
|
+
const approve = externalLink('request access', github.appUrl);
|
|
2128
|
+
parts.push(el('p', 'github-small', 'Organizations that restrict third-party apps accept this key only once an owner approves Agent Guild: ', approve, '.'));
|
|
2129
|
+
return parts;
|
|
2130
|
+
}
|
|
2131
|
+
|
|
2132
|
+
function renderGitHubCard(github, account) {
|
|
2133
|
+
const card = $('github-card');
|
|
2134
|
+
let kind = null;
|
|
2135
|
+
let build = null;
|
|
2136
|
+
const signIn = github?.signIn;
|
|
2137
|
+
if (!github) kind = null;
|
|
2138
|
+
else if (signIn?.status === 'pending') [kind, build] = [`code:${signIn.userCode}:${minutesLeft(signIn.expiresAt)}`, () => codeCard(signIn)];
|
|
2139
|
+
else if (signIn && signIn.status !== 'done') [kind, build] = [`result:${signIn.status}`, () => signInResultCard(signIn)];
|
|
2140
|
+
else if (!account) [kind, build] = ['welcome', () => signInCard(github)];
|
|
2141
|
+
else if (account.needsSignIn) [kind, build] = [`again:${account.id}`, () => signInAgainCard(account)];
|
|
2142
|
+
else if (account.ssh.status !== 'ready') {
|
|
2143
|
+
const { settingUp, key, error } = account.ssh;
|
|
2144
|
+
[kind, build] = [`ssh:${account.id}:${settingUp}:${Boolean(key)}:${error?.message}:${github.tools.ssh && github.tools.sshKeygen}`, () => sshCard(github, account)];
|
|
2145
|
+
}
|
|
2146
|
+
card.hidden = !kind;
|
|
2147
|
+
if (kind === githubView.card) return;
|
|
2148
|
+
const hadFocus = card.contains(document.activeElement);
|
|
2149
|
+
githubView.card = kind;
|
|
2150
|
+
card.replaceChildren(...(build ? build() : []));
|
|
2151
|
+
if (hadFocus) (card.querySelector('.btn.primary:not(:disabled)') ?? card.querySelector('button, a'))?.focus({ preventScroll: true });
|
|
2152
|
+
}
|
|
2153
|
+
|
|
2154
|
+
function renderGitHubStatus(github, account) {
|
|
2155
|
+
const strip = $('github-status');
|
|
2156
|
+
strip.hidden = !account || account.needsSignIn;
|
|
2157
|
+
if (strip.hidden) return strip.replaceChildren();
|
|
2158
|
+
const ssh = account.ssh;
|
|
2159
|
+
const text = ssh.status === 'ready'
|
|
2160
|
+
? el('span', 'github-ready', 'SSH ready', el('span', 'github-small', ` · checked ${relativeTime(ssh.verifiedAt)}`))
|
|
2161
|
+
: el('span', 'github-small', 'SSH not set up yet');
|
|
2162
|
+
const actions = el('span', 'github-status-actions');
|
|
2163
|
+
if (ssh.status === 'ready') {
|
|
2164
|
+
const check = button(ssh.settingUp ? 'Checking…' : 'Check SSH', () => setupGitHubSsh(account));
|
|
2165
|
+
check.disabled = ssh.settingUp;
|
|
2166
|
+
check.title = `Check that GitHub still signs in as @${account.login} with Agent Guild's key`;
|
|
2167
|
+
actions.append(check);
|
|
2168
|
+
}
|
|
2169
|
+
actions.append(button('Sign out', () => signOutGitHub(account)));
|
|
2170
|
+
const focused = strip.contains(document.activeElement) ? document.activeElement.textContent : null;
|
|
2171
|
+
strip.replaceChildren(text, actions);
|
|
2172
|
+
if (focused) [...strip.querySelectorAll('button')].find((b) => b.textContent === focused)?.focus({ preventScroll: true });
|
|
2173
|
+
}
|
|
2174
|
+
|
|
2175
|
+
function runningClone(target) {
|
|
2176
|
+
return [...state.sessions.values()].find((s) => s.task === 'clone' && s.status === 'running' && s.clone?.path === target) ?? null;
|
|
2177
|
+
}
|
|
2178
|
+
|
|
2179
|
+
function cloneBlocker(github, account) {
|
|
2180
|
+
if (!github.tools.git) return 'Git was not found. Install it from git-scm.com, then reopen this dialog.';
|
|
2181
|
+
if (account.ssh.status !== 'ready') return `Set up SSH for @${account.login} first.`;
|
|
2182
|
+
if (githubView.parentError) return githubView.parentError;
|
|
2183
|
+
if (!cloneParent()) return 'Choose the folder to clone into below.';
|
|
2184
|
+
return null;
|
|
2185
|
+
}
|
|
2186
|
+
|
|
2187
|
+
function buildRepoRow(fullName) {
|
|
2188
|
+
const node = $('github-repo-template').content.firstElementChild.cloneNode(true);
|
|
2189
|
+
node.dataset.repo = fullName;
|
|
2190
|
+
node.querySelector('.github-action').addEventListener('click', (event) => {
|
|
2191
|
+
const repo = githubView.repos?.repos.find((r) => r.fullName === fullName);
|
|
2192
|
+
if (repo) repoAction(repo, event.currentTarget);
|
|
2193
|
+
});
|
|
2194
|
+
return node;
|
|
2195
|
+
}
|
|
2196
|
+
|
|
2197
|
+
function repoAction(repo, control) {
|
|
2198
|
+
const running = runningClone(repo.target);
|
|
2199
|
+
if (running) {
|
|
2200
|
+
closeGitHub({ focusOpener: false });
|
|
2201
|
+
openPanel(running.id);
|
|
2202
|
+
} else if (repo.local === 'cloned') {
|
|
2203
|
+
useFolder(repo.target);
|
|
2204
|
+
} else {
|
|
2205
|
+
cloneRepo(repo, control);
|
|
2206
|
+
}
|
|
2207
|
+
}
|
|
2208
|
+
|
|
2209
|
+
function updateRepoRow(node, repo, blocker) {
|
|
2210
|
+
const name = node.querySelector('.repo-name');
|
|
2211
|
+
name.replaceChildren(el('span', 'repo-owner', `${repo.owner}/`), repo.name);
|
|
2212
|
+
name.title = repo.url;
|
|
2213
|
+
const title = node.querySelector('.history-title');
|
|
2214
|
+
title.replaceChildren(name,
|
|
2215
|
+
...(repo.private ? [badge('', 'Private', 'Only people with access can see it')] : []),
|
|
2216
|
+
...(repo.fork ? [badge('', 'Fork', 'A fork of another repository')] : []),
|
|
2217
|
+
...(repo.archived ? [badge('', 'Archived', 'Read-only on GitHub')] : []));
|
|
2218
|
+
const running = runningClone(repo.target);
|
|
2219
|
+
const conflict = repo.local === 'conflict';
|
|
2220
|
+
const meta = node.querySelector('.history-meta');
|
|
2221
|
+
meta.textContent = conflict
|
|
2222
|
+
? `${repo.target} already exists and is not a clone of ${repo.fullName}`
|
|
2223
|
+
: [running && 'Cloning…', repo.local === 'cloned' && `Cloned in ${repo.target}`, repo.description, repo.language, repo.pushedAt && `pushed ${relativeTime(repo.pushedAt)}`].filter(Boolean).join(' · ');
|
|
2224
|
+
meta.title = [repo.description, repo.target].filter(Boolean).join('\n');
|
|
2225
|
+
node.classList.toggle('conflict', conflict);
|
|
2226
|
+
node.classList.toggle('running', Boolean(running) || repo.local === 'cloned');
|
|
2227
|
+
const action = node.querySelector('.github-action');
|
|
2228
|
+
action.hidden = conflict && !running;
|
|
2229
|
+
action.className = `btn github-action${running ? '' : ' primary'}`;
|
|
2230
|
+
action.textContent = running ? 'Show' : repo.local === 'cloned' ? 'Use folder' : 'Clone';
|
|
2231
|
+
action.disabled = !running && repo.local !== 'cloned' && Boolean(blocker);
|
|
2232
|
+
action.title = running ? `Show the session cloning ${repo.fullName}`
|
|
2233
|
+
: repo.local === 'cloned' ? `Make ${repo.target} the working folder, so new sessions start there`
|
|
2234
|
+
: blocker || `Clone ${repo.fullName} into ${repo.target} over SSH`;
|
|
2235
|
+
action.setAttribute('aria-label', `${action.textContent} ${repo.fullName}`);
|
|
2236
|
+
}
|
|
2237
|
+
|
|
2238
|
+
function renderGitHubRepos(github, account, repos) {
|
|
2239
|
+
const ready = Boolean(github && account && !account.needsSignIn);
|
|
2240
|
+
$('github-tools').hidden = !ready;
|
|
2241
|
+
$('github-parent').closest('.github-form').hidden = !ready;
|
|
2242
|
+
const list = $('github-list');
|
|
2243
|
+
const filter = $('github-filter').value.trim().toLowerCase();
|
|
2244
|
+
const shown = ready && repos ? repos.repos.filter((repo) => !filter || `${repo.fullName}\n${repo.description ?? ''}`.toLowerCase().includes(filter)) : [];
|
|
2245
|
+
const blocker = ready ? cloneBlocker(github, account) : null;
|
|
2246
|
+
const rows = new Map([...list.children].map((node) => [node.dataset.repo, node]));
|
|
2247
|
+
const wanted = new Set(shown.map((repo) => repo.fullName));
|
|
2248
|
+
for (const [id, node] of rows) if (!wanted.has(id)) node.remove();
|
|
2249
|
+
shown.forEach((repo, index) => {
|
|
2250
|
+
const node = rows.get(repo.fullName) ?? buildRepoRow(repo.fullName);
|
|
2251
|
+
updateRepoRow(node, repo, blocker);
|
|
2252
|
+
if (list.children[index] !== node) list.insertBefore(node, list.children[index] || null);
|
|
2253
|
+
});
|
|
2254
|
+
const note = $('github-note');
|
|
2255
|
+
const lines = [];
|
|
2256
|
+
if (ready) {
|
|
2257
|
+
if (githubView.loading && !repos) lines.push('Loading repositories…');
|
|
2258
|
+
else if (githubView.error) lines.push(`Repositories could not be loaded: ${githubView.error}`);
|
|
2259
|
+
else if (repos && repos.repos.length === 0) lines.push(`@${account.login} has no repositories yet.`);
|
|
2260
|
+
else if (repos && shown.length === 0) lines.push('No repository matches the filter.');
|
|
2261
|
+
if (githubView.parentError) lines.push(githubView.parentError);
|
|
2262
|
+
if (repos?.truncated) lines.push(`Showing the ${repos.repos.length} most recently pushed repositories.`);
|
|
2263
|
+
if (repos) lines.push(['Missing an organization\'s repositories? Its owners may need to approve Agent Guild: ', externalLink('request access', github.appUrl), '.']);
|
|
2264
|
+
}
|
|
2265
|
+
note.replaceChildren(...lines.map((line) => el('span', null, ...[line].flat())));
|
|
2266
|
+
note.hidden = lines.length === 0;
|
|
2267
|
+
$('github-refresh').disabled = Boolean(githubView.loading);
|
|
2268
|
+
if (!ready) showCreate(false);
|
|
2269
|
+
renderCreate();
|
|
2270
|
+
}
|
|
2271
|
+
|
|
2272
|
+
async function startClone(account, fullName) {
|
|
2273
|
+
const { session } = await api('POST', '/github/clone', { account: account.id, repo: fullName, parent: cloneParent() });
|
|
2274
|
+
githubView.started.add(session.id);
|
|
2275
|
+
upsertSession(session);
|
|
2276
|
+
closeGitHub({ focusOpener: false });
|
|
2277
|
+
openPanel(session.id);
|
|
2278
|
+
}
|
|
2279
|
+
|
|
2280
|
+
async function cloneRepo(repo, control) {
|
|
2281
|
+
const account = githubAccount();
|
|
2282
|
+
if (!account) return;
|
|
2283
|
+
control.disabled = true;
|
|
2284
|
+
try {
|
|
2285
|
+
await startClone(account, repo.fullName);
|
|
2286
|
+
} catch (err) {
|
|
2287
|
+
if (err instanceof AuthError) return showAuth(err.message);
|
|
2288
|
+
toast(err.message, 8000);
|
|
2289
|
+
if (err.code === 'clone_exists' || err.code === 'folder_conflict') loadRepos();
|
|
2290
|
+
} finally {
|
|
2291
|
+
control.disabled = false;
|
|
2292
|
+
}
|
|
2293
|
+
}
|
|
2294
|
+
|
|
2295
|
+
function showCreate(open) {
|
|
2296
|
+
const form = $('github-create');
|
|
2297
|
+
form.hidden = !open;
|
|
2298
|
+
$('github-new').setAttribute('aria-expanded', String(open));
|
|
2299
|
+
if (!open) return;
|
|
2300
|
+
$('github-create-name').value = '';
|
|
2301
|
+
$('github-create-description').value = '';
|
|
2302
|
+
$('github-create-error').hidden = true;
|
|
2303
|
+
renderCreate();
|
|
2304
|
+
$('github-create-name').focus();
|
|
2305
|
+
}
|
|
2306
|
+
|
|
2307
|
+
function renderCreate() {
|
|
2308
|
+
const form = $('github-create');
|
|
2309
|
+
const account = githubAccount();
|
|
2310
|
+
if (form.hidden || !account) return;
|
|
2311
|
+
const repos = githubView.repos?.accountId === account.id ? githubView.repos : null;
|
|
2312
|
+
const owners = repos?.owners ?? [account.login];
|
|
2313
|
+
const select = $('github-create-owner');
|
|
2314
|
+
const chosen = select.value;
|
|
2315
|
+
if ([...select.options].map((o) => o.value).join('\n') !== owners.join('\n')) {
|
|
2316
|
+
select.replaceChildren(...owners.map((owner) => {
|
|
2317
|
+
const option = el('option', null, owner === account.login ? `${owner} (you)` : owner);
|
|
2318
|
+
option.value = owner;
|
|
2319
|
+
return option;
|
|
2320
|
+
}));
|
|
2321
|
+
select.value = owners.includes(chosen) ? chosen : owners[0];
|
|
2322
|
+
}
|
|
2323
|
+
const blocker = cloneBlocker(state.github, account);
|
|
2324
|
+
const clone = $('github-create-clone');
|
|
2325
|
+
clone.disabled = Boolean(blocker);
|
|
2326
|
+
if (blocker) clone.checked = false;
|
|
2327
|
+
const name = $('github-create-name').value.trim();
|
|
2328
|
+
$('github-create-clone-label').textContent = blocker ? 'Clone it' : `Clone it into ${cloneParent()}${name ? `/${name}` : ''}`;
|
|
2329
|
+
clone.parentElement.title = blocker ?? '';
|
|
2330
|
+
}
|
|
2331
|
+
|
|
2332
|
+
async function createRepo(event) {
|
|
2333
|
+
event.preventDefault();
|
|
2334
|
+
const account = githubAccount();
|
|
2335
|
+
if (!account) return;
|
|
2336
|
+
const submit = $('github-create-submit');
|
|
2337
|
+
const error = $('github-create-error');
|
|
2338
|
+
const owner = $('github-create-owner').value;
|
|
2339
|
+
const name = $('github-create-name').value.trim();
|
|
2340
|
+
const clone = $('github-create-clone').checked && !$('github-create-clone').disabled;
|
|
2341
|
+
submit.disabled = true;
|
|
2342
|
+
error.hidden = true;
|
|
2343
|
+
try {
|
|
2344
|
+
const { repo } = await api('POST', `/github/accounts/${account.id}/repos`, {
|
|
2345
|
+
owner,
|
|
2346
|
+
name,
|
|
2347
|
+
description: $('github-create-description').value.trim() || null,
|
|
2348
|
+
private: $('github-create-private').checked,
|
|
2349
|
+
readme: $('github-create-readme').checked,
|
|
2350
|
+
});
|
|
2351
|
+
showCreate(false);
|
|
2352
|
+
githubView.reposFor = null;
|
|
2353
|
+
if (clone) {
|
|
2354
|
+
toast(`Created ${repo.fullName} on GitHub.`, 4000);
|
|
2355
|
+
await startClone(account, repo.fullName);
|
|
2356
|
+
} else {
|
|
2357
|
+
toast(`Created ${repo.fullName} on GitHub.`, 6000);
|
|
2358
|
+
await loadRepos();
|
|
2359
|
+
}
|
|
2360
|
+
} catch (err) {
|
|
2361
|
+
if (err instanceof AuthError) return showAuth(err.message);
|
|
2362
|
+
if ($('github-create').hidden) {
|
|
2363
|
+
toast(err.message, 8000);
|
|
2364
|
+
loadRepos();
|
|
2365
|
+
} else {
|
|
2366
|
+
error.textContent = err.message;
|
|
2367
|
+
error.hidden = false;
|
|
2368
|
+
}
|
|
2369
|
+
} finally {
|
|
2370
|
+
submit.disabled = false;
|
|
2371
|
+
}
|
|
2372
|
+
}
|
|
2373
|
+
|
|
2374
|
+
function clonedPath(s) {
|
|
2375
|
+
return s.task === 'clone' && s.status === 'exited' && s.exitCode === 0 && s.clone?.path ? s.clone.path : null;
|
|
2376
|
+
}
|
|
2377
|
+
|
|
2378
|
+
function noticeClone(s) {
|
|
2379
|
+
if (s.task !== 'clone' || s.status !== 'exited' || githubView.announced.has(s.id)) return;
|
|
2380
|
+
githubView.announced.add(s.id);
|
|
2381
|
+
githubView.reposFor = null;
|
|
2382
|
+
if ($('github').open) loadRepos();
|
|
2383
|
+
if (!githubView.started.has(s.id)) return;
|
|
2384
|
+
if (clonedPath(s)) toast(`Cloned ${s.clone.repo} into ${s.clone.path}.`, 12000, { label: 'Use folder', run: () => useFolder(s.clone.path) });
|
|
2385
|
+
else toast(`Cloning ${s.clone?.repo ?? 'the repository'} did not finish. Its session shows why.`, 8000);
|
|
2386
|
+
}
|
|
2387
|
+
|
|
2388
|
+
function useFolder(dir) {
|
|
2389
|
+
$('cwd').value = dir;
|
|
2390
|
+
save(CWD_KEY, dir);
|
|
2391
|
+
if ($('github').open) renderGitHub();
|
|
2392
|
+
toast(`New sessions start in ${dir}.`, 4000);
|
|
2393
|
+
}
|
|
2394
|
+
|
|
1510
2395
|
// ---- session cards --------------------------------------------------------
|
|
1511
2396
|
|
|
1512
2397
|
const cards = new Map();
|
|
2398
|
+
let sessionsShown = false;
|
|
1513
2399
|
|
|
1514
2400
|
const MODEL_SOURCES = { report: 'reported by the tool', screen: 'seen on the tool\'s screen', args: 'from the --model argument' };
|
|
1515
2401
|
|
|
@@ -1548,11 +2434,19 @@ function buildCard(session) {
|
|
|
1548
2434
|
node.querySelector('.remove').addEventListener('click', () => removeSession(session.id));
|
|
1549
2435
|
node.querySelector('.rename').addEventListener('click', () => renameSession(session.id));
|
|
1550
2436
|
node.querySelector('.resume').addEventListener('click', () => resumeCard(session.id));
|
|
2437
|
+
node.querySelector('.use-folder').addEventListener('click', () => {
|
|
2438
|
+
const path = state.sessions.get(session.id)?.clone?.path;
|
|
2439
|
+
if (path) useFolder(path);
|
|
2440
|
+
});
|
|
1551
2441
|
node.querySelector('.session-id').addEventListener('click', () => {
|
|
1552
2442
|
const id = toolSessionId(state.sessions.get(session.id) ?? session);
|
|
1553
2443
|
if (id) copyId(id);
|
|
1554
2444
|
});
|
|
1555
2445
|
node.querySelector('.model-pill').addEventListener('click', () => openSessionModel(session.id));
|
|
2446
|
+
node.addEventListener('animationend', (e) => {
|
|
2447
|
+
if (e.animationName === 'level-up') e.target.classList.remove('level-up');
|
|
2448
|
+
else if (e.animationName === 'card-enter' && e.target === node) node.classList.remove('enter');
|
|
2449
|
+
});
|
|
1556
2450
|
return node;
|
|
1557
2451
|
}
|
|
1558
2452
|
|
|
@@ -1584,6 +2478,7 @@ function updateCard(node, s) {
|
|
|
1584
2478
|
paintProviderIcon(node.querySelector('.provider-icon'), s.provider);
|
|
1585
2479
|
const level = sessionLevel(s);
|
|
1586
2480
|
const badge = node.querySelector('.level-badge');
|
|
2481
|
+
if (badge.textContent && Number(badge.textContent) < level) badge.classList.add('level-up');
|
|
1587
2482
|
badge.textContent = level;
|
|
1588
2483
|
badge.title = `Level ${level}`;
|
|
1589
2484
|
node.querySelector('.name').textContent = s.name;
|
|
@@ -1608,6 +2503,9 @@ function updateCard(node, s) {
|
|
|
1608
2503
|
node.classList.toggle('exited', s.status === 'exited');
|
|
1609
2504
|
node.querySelector('.stop').hidden = s.status !== 'running';
|
|
1610
2505
|
node.querySelector('.remove').hidden = s.status === 'running';
|
|
2506
|
+
const useButton = node.querySelector('.use-folder');
|
|
2507
|
+
useButton.hidden = !clonedPath(s);
|
|
2508
|
+
useButton.title = clonedPath(s) ? `Make ${s.clone.path} the working folder, so new sessions start there` : '';
|
|
1611
2509
|
const resume = node.querySelector('.resume');
|
|
1612
2510
|
resume.hidden = !resumable(s);
|
|
1613
2511
|
resume.title = `Start ${s.provider.tool} again on this session${id ? ` (${id})` : ''} in ${s.cwd}`;
|
|
@@ -1624,7 +2522,11 @@ function renderSessions() {
|
|
|
1624
2522
|
}
|
|
1625
2523
|
sessions.forEach((s, index) => {
|
|
1626
2524
|
let node = cards.get(s.id);
|
|
1627
|
-
if (!node) {
|
|
2525
|
+
if (!node) {
|
|
2526
|
+
node = buildCard(s);
|
|
2527
|
+
cards.set(s.id, node);
|
|
2528
|
+
if (sessionsShown) node.classList.add('enter');
|
|
2529
|
+
}
|
|
1628
2530
|
updateCard(node, s);
|
|
1629
2531
|
// Move a card only when it is out of place: re-inserting a node drops
|
|
1630
2532
|
// keyboard focus and can swallow a click that is in progress.
|
|
@@ -1633,14 +2535,27 @@ function renderSessions() {
|
|
|
1633
2535
|
const running = sessions.filter((s) => s.status === 'running').length;
|
|
1634
2536
|
$('session-count').textContent = sessions.length ? `· ${running} running` : '';
|
|
1635
2537
|
$('empty').hidden = sessions.length > 0;
|
|
2538
|
+
guardLeaving();
|
|
1636
2539
|
if (state.activeId) updatePanel();
|
|
1637
2540
|
if ($('history').open) renderHistory();
|
|
1638
2541
|
if (state.stats && sessions.some((s) => s.model && state.statsFor.get(s.id) !== modelKey(s))) scheduleStats();
|
|
1639
2542
|
}
|
|
1640
2543
|
|
|
2544
|
+
function confirmLeaving(event) {
|
|
2545
|
+
event.preventDefault();
|
|
2546
|
+
event.returnValue = true;
|
|
2547
|
+
}
|
|
2548
|
+
|
|
2549
|
+
function guardLeaving() {
|
|
2550
|
+
const running = state.connected && [...state.sessions.values()].some((s) => s.status === 'running');
|
|
2551
|
+
if (running) addEventListener('beforeunload', confirmLeaving);
|
|
2552
|
+
else removeEventListener('beforeunload', confirmLeaving);
|
|
2553
|
+
}
|
|
2554
|
+
|
|
1641
2555
|
function upsertSession(session) {
|
|
1642
2556
|
state.sessions.set(session.id, session);
|
|
1643
2557
|
renderSessions();
|
|
2558
|
+
noticeClone(session);
|
|
1644
2559
|
}
|
|
1645
2560
|
|
|
1646
2561
|
function dropSession(id) {
|
|
@@ -1908,7 +2823,9 @@ function enterStopping(running = 0, restart = false) {
|
|
|
1908
2823
|
closePanel();
|
|
1909
2824
|
closeModels();
|
|
1910
2825
|
closeNews();
|
|
2826
|
+
closeChangelog();
|
|
1911
2827
|
closeHistory();
|
|
2828
|
+
closeGitHub();
|
|
1912
2829
|
for (const view of state.views.values()) view.dispose();
|
|
1913
2830
|
state.views.clear();
|
|
1914
2831
|
state.sessions.clear();
|
|
@@ -2022,10 +2939,18 @@ function connectEvents() {
|
|
|
2022
2939
|
state.sessions = new Map(msg.sessions.map((s) => [s.id, s]));
|
|
2023
2940
|
for (const id of [...state.views.keys()]) if (!state.sessions.has(id)) dropSession(id);
|
|
2024
2941
|
renderSessions();
|
|
2942
|
+
sessionsShown = true;
|
|
2025
2943
|
setUpgrade(msg.upgrade);
|
|
2026
2944
|
loadNews();
|
|
2945
|
+
// A changelog.updated sent while the socket was down is lost; catch up the open panel.
|
|
2946
|
+
if ($('changelog').open) loadChangelog();
|
|
2947
|
+
if ($('github').open) loadGitHub();
|
|
2027
2948
|
} else if (msg.type === 'news.updated') {
|
|
2028
2949
|
loadNews();
|
|
2950
|
+
} else if (msg.type === 'github.updated') {
|
|
2951
|
+
if ($('github').open) loadGitHub();
|
|
2952
|
+
} else if (msg.type === 'changelog.updated') {
|
|
2953
|
+
if ($('changelog').open) loadChangelog();
|
|
2029
2954
|
} else if (msg.type === 'manager.upgrade') {
|
|
2030
2955
|
setUpgrade(msg.upgrade);
|
|
2031
2956
|
} else if (msg.type === 'manager.stopping') {
|
|
@@ -2085,7 +3010,9 @@ let newsTimer;
|
|
|
2085
3010
|
function showAuth(message = '') {
|
|
2086
3011
|
closeModels();
|
|
2087
3012
|
closeNews();
|
|
3013
|
+
closeChangelog();
|
|
2088
3014
|
closeHistory();
|
|
3015
|
+
closeGitHub();
|
|
2089
3016
|
$('app').hidden = true;
|
|
2090
3017
|
$('terminal-panel').hidden = true;
|
|
2091
3018
|
$('stopped').hidden = true;
|
|
@@ -2144,6 +3071,30 @@ $('history').addEventListener('close', () => {
|
|
|
2144
3071
|
historyOpener = null;
|
|
2145
3072
|
});
|
|
2146
3073
|
$('history-filter').addEventListener('input', renderHistory);
|
|
3074
|
+
$('github-open').addEventListener('click', openGitHub);
|
|
3075
|
+
$('github-close').addEventListener('click', () => closeGitHub());
|
|
3076
|
+
$('github').addEventListener('click', (e) => { if (e.target === $('github')) closeGitHub(); });
|
|
3077
|
+
$('github').addEventListener('close', () => {
|
|
3078
|
+
if (githubView.opener !== false) (githubView.opener?.isConnected && !githubView.opener.closest('[hidden]') ? githubView.opener : $('github-open')).focus();
|
|
3079
|
+
githubView.opener = null;
|
|
3080
|
+
});
|
|
3081
|
+
$('github-add').addEventListener('click', startGitHubSignIn);
|
|
3082
|
+
$('github-filter').addEventListener('input', () => renderGitHub());
|
|
3083
|
+
$('github-refresh').addEventListener('click', () => loadRepos({ refresh: true }));
|
|
3084
|
+
$('github-new').addEventListener('click', () => showCreate($('github-create').hidden));
|
|
3085
|
+
$('github-create').addEventListener('submit', createRepo);
|
|
3086
|
+
$('github-create-cancel').addEventListener('click', () => {
|
|
3087
|
+
showCreate(false);
|
|
3088
|
+
$('github-new').focus();
|
|
3089
|
+
});
|
|
3090
|
+
$('github-create').addEventListener('input', () => {
|
|
3091
|
+
$('github-create-error').hidden = true;
|
|
3092
|
+
renderCreate();
|
|
3093
|
+
});
|
|
3094
|
+
$('github-parent').addEventListener('change', () => {
|
|
3095
|
+
save(CLONE_PARENT_KEY, cloneParent());
|
|
3096
|
+
loadRepos();
|
|
3097
|
+
});
|
|
2147
3098
|
$('history-here').addEventListener('change', renderHistory);
|
|
2148
3099
|
$('history-form').addEventListener('submit', resumeById);
|
|
2149
3100
|
$('models').addEventListener('click', (e) => { if (e.target === $('models')) closeModels(); });
|
|
@@ -2192,6 +3143,23 @@ $('news').addEventListener('close', () => {
|
|
|
2192
3143
|
document.addEventListener('visibilitychange', () => {
|
|
2193
3144
|
if (document.visibilityState === 'visible' && state.connected && Date.now() - newsLoadedAt > 60000) loadNews();
|
|
2194
3145
|
});
|
|
3146
|
+
$('version').addEventListener('click', openChangelog);
|
|
3147
|
+
$('changelog-close').addEventListener('click', closeChangelog);
|
|
3148
|
+
$('changelog').addEventListener('click', (e) => { if (e.target === $('changelog')) closeChangelog(); });
|
|
3149
|
+
$('changelog').addEventListener('close', () => {
|
|
3150
|
+
const opener = changelogView.opener?.isConnected && !changelogView.opener.closest('[hidden]') ? changelogView.opener : $('version');
|
|
3151
|
+
opener.focus();
|
|
3152
|
+
changelogView.opener = null;
|
|
3153
|
+
});
|
|
3154
|
+
$('changelog-upgrade').addEventListener('click', () => {
|
|
3155
|
+
closeChangelog();
|
|
3156
|
+
upgradeManager();
|
|
3157
|
+
});
|
|
3158
|
+
$('changelog-restart').addEventListener('click', () => {
|
|
3159
|
+
closeChangelog();
|
|
3160
|
+
stopManager({ restart: true });
|
|
3161
|
+
});
|
|
3162
|
+
addEventListener('storage', (e) => { if (e.key === CHANGELOG_SEEN_KEY) renderVersion(); });
|
|
2195
3163
|
$('models-more').addEventListener('click', () => {
|
|
2196
3164
|
const before = $('models-list').childElementCount;
|
|
2197
3165
|
modelsView.all = true;
|
|
@@ -2203,6 +3171,30 @@ $('restart-manager').addEventListener('click', () => stopManager({ restart: true
|
|
|
2203
3171
|
$('copy-command').addEventListener('click', copyCommand);
|
|
2204
3172
|
$('upgrade').addEventListener('click', upgradeManager);
|
|
2205
3173
|
$('theme-toggle').addEventListener('click', toggleTheme);
|
|
3174
|
+
$('providers').addEventListener('animationend', (e) => {
|
|
3175
|
+
if (e.animationName === 'deal' && e.target === e.currentTarget.lastElementChild) e.currentTarget.classList.remove('deal');
|
|
3176
|
+
});
|
|
3177
|
+
let tiltFrame = 0;
|
|
3178
|
+
$('providers').addEventListener('pointermove', (e) => {
|
|
3179
|
+
const card = e.target.closest('.provider');
|
|
3180
|
+
if (!card || !finePointer.matches || reducedMotion.matches) return;
|
|
3181
|
+
const box = card.getBoundingClientRect();
|
|
3182
|
+
const x = ((e.clientX - box.left) / box.width) * 2 - 1;
|
|
3183
|
+
const y = ((e.clientY - box.top) / box.height) * 2 - 1;
|
|
3184
|
+
cancelAnimationFrame(tiltFrame);
|
|
3185
|
+
tiltFrame = requestAnimationFrame(() => {
|
|
3186
|
+
card.style.setProperty('--px', x.toFixed(3));
|
|
3187
|
+
card.style.setProperty('--py', y.toFixed(3));
|
|
3188
|
+
});
|
|
3189
|
+
});
|
|
3190
|
+
$('providers').addEventListener('pointerout', (e) => {
|
|
3191
|
+
const card = e.target.closest('.provider');
|
|
3192
|
+
if (card && !card.contains(e.relatedTarget)) {
|
|
3193
|
+
cancelAnimationFrame(tiltFrame);
|
|
3194
|
+
card.style.removeProperty('--px');
|
|
3195
|
+
card.style.removeProperty('--py');
|
|
3196
|
+
}
|
|
3197
|
+
});
|
|
2206
3198
|
applyTheme(currentTheme());
|
|
2207
3199
|
// Follow the system setting until the user picks a theme.
|
|
2208
3200
|
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', (e) => {
|
|
@@ -2216,9 +3208,11 @@ $('panel-stop').addEventListener('click', () => {
|
|
|
2216
3208
|
});
|
|
2217
3209
|
$('cwd').value = load(CWD_KEY) || '';
|
|
2218
3210
|
try { state.accounts = JSON.parse(load(ACCOUNTS_KEY)) || {}; } catch { state.accounts = {}; }
|
|
3211
|
+
githubView.accountId = Number(load(GITHUB_ACCOUNT_KEY)) || null;
|
|
2219
3212
|
if (typeof state.accounts !== 'object' || Array.isArray(state.accounts)) state.accounts = {};
|
|
2220
3213
|
setInterval(renderSessions, 30000);
|
|
2221
3214
|
setInterval(tickNews, 30000);
|
|
3215
|
+
setInterval(() => { if ($('github').open && state.github) renderGitHub(); }, 30000);
|
|
2222
3216
|
|
|
2223
3217
|
// The terminal panel sits below the top bar, which wraps onto two rows on
|
|
2224
3218
|
// narrow screens; publish its height so the panel never covers its controls.
|