@oddessentials/agent-guild 0.2.0 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +20 -7
- package/config/providers.default.json +10 -0
- package/package.json +1 -1
- package/src/manager/config.mjs +7 -1
- package/src/manager/main.mjs +20 -7
- package/src/manager/news.mjs +470 -0
- package/src/manager/providers.mjs +89 -5
- package/src/manager/self-update.mjs +205 -0
- package/src/manager/server.mjs +15 -1
- package/src/manager/session-manager.mjs +58 -7
- package/src/manager/session.mjs +4 -1
- package/src/manager/usage.mjs +106 -25
- package/web/app.js +489 -12
- package/web/art/art.css +28 -6
- package/web/index.html +31 -0
- package/web/styles.css +87 -19
- package/web/theme.js +10 -0
package/web/app.js
CHANGED
|
@@ -3,6 +3,10 @@
|
|
|
3
3
|
|
|
4
4
|
const TOKEN_KEY = 'agentGuild.token';
|
|
5
5
|
const CWD_KEY = 'agentGuild.cwd';
|
|
6
|
+
const ACCOUNTS_KEY = 'agentGuild.accounts';
|
|
7
|
+
const THEME_KEY = 'agentGuild.theme';
|
|
8
|
+
const NEWS_SEEN_KEY = 'agentGuild.newsSeen';
|
|
9
|
+
const NEWS_FILTER_KEY = 'agentGuild.newsFilter';
|
|
6
10
|
const isMac = /Mac|iPhone|iPad/.test(navigator.platform || navigator.userAgent);
|
|
7
11
|
|
|
8
12
|
const $ = (id) => document.getElementById(id);
|
|
@@ -10,13 +14,19 @@ const state = {
|
|
|
10
14
|
token: null,
|
|
11
15
|
providers: [],
|
|
12
16
|
usage: new Map(),
|
|
17
|
+
accounts: {},
|
|
13
18
|
stats: null,
|
|
14
19
|
statsFor: new Map(),
|
|
20
|
+
news: null,
|
|
15
21
|
sessions: new Map(),
|
|
16
22
|
views: new Map(),
|
|
17
23
|
activeId: null,
|
|
18
24
|
eventsSocket: null,
|
|
19
25
|
eventsRetry: 0,
|
|
26
|
+
/** True while the events socket is open. */
|
|
27
|
+
connected: false,
|
|
28
|
+
/** The manager's own version check, from `hello` and `manager.upgrade`. */
|
|
29
|
+
upgrade: null,
|
|
20
30
|
/** True from a shutdown request until the manager is reachable again. */
|
|
21
31
|
stopping: false,
|
|
22
32
|
/** After a stop: how many session processes did not confirm exiting, or null if the manager never said. */
|
|
@@ -43,8 +53,93 @@ function setConnection(kind, label) {
|
|
|
43
53
|
const el = $('connection');
|
|
44
54
|
el.className = `connection ${kind}`;
|
|
45
55
|
el.querySelector('.label').textContent = label;
|
|
46
|
-
// The manager can only be stopped while the page can reach it.
|
|
47
|
-
|
|
56
|
+
// The manager can only be stopped or upgraded while the page can reach it.
|
|
57
|
+
state.connected = kind === 'ok';
|
|
58
|
+
$('stop-manager').hidden = !state.connected;
|
|
59
|
+
renderUpgrade();
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// ---- theme ----------------------------------------------------------------
|
|
63
|
+
|
|
64
|
+
/** theme.js applied the saved or system theme before the first paint; this keeps the button in step. */
|
|
65
|
+
function applyTheme(theme) {
|
|
66
|
+
document.documentElement.dataset.theme = theme;
|
|
67
|
+
const button = $('theme-toggle');
|
|
68
|
+
const other = theme === 'dark' ? 'light' : 'dark';
|
|
69
|
+
button.textContent = theme === 'dark' ? '☀ Light' : '☾ Dark';
|
|
70
|
+
button.title = `Switch to the ${other} theme`;
|
|
71
|
+
button.setAttribute('aria-label', `Switch to the ${other} theme`);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function currentTheme() {
|
|
75
|
+
return document.documentElement.dataset.theme === 'dark' ? 'dark' : 'light';
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function toggleTheme() {
|
|
79
|
+
const theme = currentTheme() === 'dark' ? 'light' : 'dark';
|
|
80
|
+
save(THEME_KEY, theme);
|
|
81
|
+
applyTheme(theme);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// ---- upgrading the manager ------------------------------------------------
|
|
85
|
+
|
|
86
|
+
function renderUpgrade() {
|
|
87
|
+
const u = state.upgrade;
|
|
88
|
+
const button = $('upgrade');
|
|
89
|
+
const note = $('upgrade-note');
|
|
90
|
+
const offer = Boolean(state.connected && u?.available && u.command);
|
|
91
|
+
button.hidden = !offer;
|
|
92
|
+
if (offer) {
|
|
93
|
+
button.textContent = `Upgrade to ${u.latestVersion}`;
|
|
94
|
+
button.title = `Run "${u.command}" in a session. Sessions keep running; the new version is used once the manager is restarted.`;
|
|
95
|
+
}
|
|
96
|
+
let text = '';
|
|
97
|
+
let title = '';
|
|
98
|
+
const last = u?.lastInstall;
|
|
99
|
+
if (u?.installing) {
|
|
100
|
+
text = `Upgrading${u.latestVersion ? ` to v${u.latestVersion}` : ''}…`;
|
|
101
|
+
title = 'npm is running in a session. Keep the manager running until it finishes.';
|
|
102
|
+
} else if (u?.pendingVersion) {
|
|
103
|
+
text = `v${u.pendingVersion} installed · restart to use it`;
|
|
104
|
+
title = `Agent Guild ${u.pendingVersion} is installed, but this manager is still ${u.version}. Stop the manager and run "agent-guild open" to use it.`;
|
|
105
|
+
} else if (last?.outcome === 'failed') {
|
|
106
|
+
text = last.exitCode === null ? 'Upgrade failed' : `Upgrade failed (exit ${last.exitCode})`;
|
|
107
|
+
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.';
|
|
108
|
+
} else if (last?.outcome === 'unchanged') {
|
|
109
|
+
text = 'Upgrade finished, but this copy was not replaced';
|
|
110
|
+
title = `npm did not replace the files this manager runs from. Run${u.command ? ` "${u.command}"` : ' the npm install'} where Agent Guild is installed.`;
|
|
111
|
+
} else if (u?.available && !u.command) {
|
|
112
|
+
text = `v${u.latestVersion} available`;
|
|
113
|
+
title = u.guidance || '';
|
|
114
|
+
}
|
|
115
|
+
note.hidden = !state.connected || !text;
|
|
116
|
+
note.textContent = text;
|
|
117
|
+
note.title = title;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function setUpgrade(upgrade) {
|
|
121
|
+
const before = state.upgrade;
|
|
122
|
+
state.upgrade = upgrade || null;
|
|
123
|
+
renderUpgrade();
|
|
124
|
+
const pending = state.upgrade?.pendingVersion;
|
|
125
|
+
if (pending && pending !== before?.pendingVersion) {
|
|
126
|
+
toast(`Agent Guild ${pending} is installed. Stop the manager and run "agent-guild open" to use it.`, 10000);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
async function upgradeManager() {
|
|
131
|
+
const button = $('upgrade');
|
|
132
|
+
button.disabled = true;
|
|
133
|
+
try {
|
|
134
|
+
const { session } = await api('POST', '/upgrade');
|
|
135
|
+
upsertSession(session);
|
|
136
|
+
openPanel(session.id);
|
|
137
|
+
} catch (err) {
|
|
138
|
+
if (err instanceof AuthError) return showAuth(err.message);
|
|
139
|
+
toast(err.message, 8000);
|
|
140
|
+
} finally {
|
|
141
|
+
button.disabled = false;
|
|
142
|
+
}
|
|
48
143
|
}
|
|
49
144
|
|
|
50
145
|
function relativeTime(iso) {
|
|
@@ -148,6 +243,51 @@ function wsUrl(path) {
|
|
|
148
243
|
|
|
149
244
|
// ---- providers ------------------------------------------------------------
|
|
150
245
|
|
|
246
|
+
function selectedAccount(provider) {
|
|
247
|
+
const accounts = provider.accounts || [];
|
|
248
|
+
return accounts.find((a) => a.id === state.accounts[provider.id]) || accounts[0] || { id: 'default', label: 'Default' };
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
function selectAccount(provider, id) {
|
|
252
|
+
state.accounts[provider.id] = id;
|
|
253
|
+
save(ACCOUNTS_KEY, JSON.stringify(state.accounts));
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
function usageFor(provider, account = selectedAccount(provider)) {
|
|
257
|
+
return state.usage.get(`${provider.id}/${account.id}`);
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
function renderAccounts(card, provider) {
|
|
261
|
+
const host = card.querySelector('.accounts');
|
|
262
|
+
const accounts = provider.accounts || [];
|
|
263
|
+
host.hidden = accounts.length < 2;
|
|
264
|
+
if (host.hidden) return host.replaceChildren();
|
|
265
|
+
const selected = selectedAccount(provider).id;
|
|
266
|
+
const same = host.children.length === accounts.length && accounts.every((a, i) => host.children[i].dataset.account === a.id);
|
|
267
|
+
if (!same) {
|
|
268
|
+
host.replaceChildren(...accounts.map((account) => {
|
|
269
|
+
const chip = document.createElement('button');
|
|
270
|
+
chip.type = 'button';
|
|
271
|
+
chip.className = 'account-chip';
|
|
272
|
+
chip.setAttribute('role', 'tab');
|
|
273
|
+
chip.dataset.account = account.id;
|
|
274
|
+
chip.addEventListener('click', () => {
|
|
275
|
+
selectAccount(provider, account.id);
|
|
276
|
+
renderAccounts(card, provider);
|
|
277
|
+
renderUsage(card, provider);
|
|
278
|
+
});
|
|
279
|
+
return chip;
|
|
280
|
+
}));
|
|
281
|
+
}
|
|
282
|
+
accounts.forEach((account, i) => {
|
|
283
|
+
const chip = host.children[i];
|
|
284
|
+
chip.classList.toggle('unsigned', usageFor(provider, account)?.signedIn === false);
|
|
285
|
+
chip.setAttribute('aria-selected', String(account.id === selected));
|
|
286
|
+
chip.textContent = account.label;
|
|
287
|
+
chip.title = `Start new ${provider.tool} sessions as the ${account.label} account`;
|
|
288
|
+
});
|
|
289
|
+
}
|
|
290
|
+
|
|
151
291
|
function renderProviders() {
|
|
152
292
|
const list = $('providers');
|
|
153
293
|
const tpl = $('provider-template');
|
|
@@ -168,7 +308,6 @@ function renderProviders() {
|
|
|
168
308
|
const existing = node.querySelector('.existing');
|
|
169
309
|
const hint = node.querySelector('.hint');
|
|
170
310
|
start.hidden = !provider.available;
|
|
171
|
-
start.title = `Start a new ${provider.tool} session`;
|
|
172
311
|
start.addEventListener('click', () => startSession(provider, node));
|
|
173
312
|
existing.hidden = !provider.available || !provider.resumable;
|
|
174
313
|
existing.title = `Resume one of ${provider.tool}'s own sessions by its id`;
|
|
@@ -186,6 +325,7 @@ function renderProviders() {
|
|
|
186
325
|
renderHint(hint, provider);
|
|
187
326
|
renderCopies(node.querySelector('.copies'), provider);
|
|
188
327
|
renderConsoleLinks(node, provider);
|
|
328
|
+
renderAccounts(node, provider);
|
|
189
329
|
renderUsage(node, provider);
|
|
190
330
|
renderModelStats(node, provider);
|
|
191
331
|
return node;
|
|
@@ -284,14 +424,21 @@ function creditsNote(usage) {
|
|
|
284
424
|
|
|
285
425
|
function renderUsage(card, provider) {
|
|
286
426
|
const host = card.querySelector('.usage');
|
|
287
|
-
const
|
|
427
|
+
const account = selectedAccount(provider);
|
|
428
|
+
const usage = usageFor(provider, account);
|
|
288
429
|
renderTier(card, provider, provider.usageSource ? usage : null);
|
|
430
|
+
const start = card.querySelector('.new');
|
|
431
|
+
const unsigned = Boolean(provider.available && usage?.signedIn === false);
|
|
432
|
+
start.textContent = unsigned ? 'Sign in' : 'New';
|
|
433
|
+
start.title = unsigned
|
|
434
|
+
? `Start a ${provider.tool} session and sign in as the ${account.label} account`
|
|
435
|
+
: `Start a new ${provider.tool} session${provider.accounts?.length > 1 ? ` as the ${account.label} account` : ''}`;
|
|
289
436
|
if (!provider.available || !provider.usageSource || !usage) return host.replaceChildren();
|
|
290
437
|
if (usage.error || usage.windows.length === 0) {
|
|
291
438
|
const note = document.createElement('div');
|
|
292
439
|
note.className = 'usage-note';
|
|
293
|
-
note.textContent = `Usage: ${usage.error || 'no limits reported'}`;
|
|
294
|
-
note.title = note.textContent;
|
|
440
|
+
note.textContent = unsigned ? 'Not signed in yet' : `Usage: ${usage.error || 'no limits reported'}`;
|
|
441
|
+
note.title = unsigned ? usage.error : note.textContent;
|
|
295
442
|
return host.replaceChildren(note, ...creditsNote(usage));
|
|
296
443
|
}
|
|
297
444
|
host.replaceChildren(...usage.windows.map((w) => {
|
|
@@ -313,10 +460,12 @@ function renderUsage(card, provider) {
|
|
|
313
460
|
async function loadUsage() {
|
|
314
461
|
let usage;
|
|
315
462
|
try { ({ usage } = await api('GET', '/usage')); } catch { return; }
|
|
316
|
-
state.usage = new Map(usage.map((u) => [u.providerId
|
|
463
|
+
state.usage = new Map(usage.map((u) => [`${u.providerId}/${u.accountId ?? 'default'}`, u]));
|
|
317
464
|
for (const card of $('providers').children) {
|
|
318
465
|
const provider = state.providers.find((p) => p.id === card.dataset.id);
|
|
319
|
-
if (provider)
|
|
466
|
+
if (!provider) continue;
|
|
467
|
+
renderAccounts(card, provider);
|
|
468
|
+
renderUsage(card, provider);
|
|
320
469
|
}
|
|
321
470
|
}
|
|
322
471
|
|
|
@@ -754,6 +903,291 @@ function closeModels() {
|
|
|
754
903
|
if ($('models').open) $('models').close();
|
|
755
904
|
}
|
|
756
905
|
|
|
906
|
+
const NEWS_LATEST = 5;
|
|
907
|
+
const NEWS_POLL_MS = 10 * 60 * 1000;
|
|
908
|
+
const NEWS_FILTERS = [['all', 'All'], ['news', 'News'], ['releases', 'Releases'], ['research', 'Research']];
|
|
909
|
+
const newsView = { shown: null, since: Infinity, filter: 'all', opener: null };
|
|
910
|
+
let newsLoading = null;
|
|
911
|
+
let newsAgain = false;
|
|
912
|
+
let newsLoadedAt = 0;
|
|
913
|
+
|
|
914
|
+
function newsSeen() {
|
|
915
|
+
const seen = Date.parse(load(NEWS_SEEN_KEY));
|
|
916
|
+
return Number.isFinite(seen) ? seen : null;
|
|
917
|
+
}
|
|
918
|
+
|
|
919
|
+
function newestTime(items) {
|
|
920
|
+
return items.reduce((newest, item) => Math.max(newest, Date.parse(item.publishedAt) || 0), 0);
|
|
921
|
+
}
|
|
922
|
+
|
|
923
|
+
function loadNews() {
|
|
924
|
+
if (newsLoading) {
|
|
925
|
+
newsAgain = true;
|
|
926
|
+
return newsLoading;
|
|
927
|
+
}
|
|
928
|
+
newsLoading = api('GET', '/news').then(setNews, () => {}).finally(() => {
|
|
929
|
+
newsLoading = null;
|
|
930
|
+
newsLoadedAt = Date.now();
|
|
931
|
+
if (newsAgain) {
|
|
932
|
+
newsAgain = false;
|
|
933
|
+
loadNews();
|
|
934
|
+
}
|
|
935
|
+
});
|
|
936
|
+
return newsLoading;
|
|
937
|
+
}
|
|
938
|
+
|
|
939
|
+
function setNews(news) {
|
|
940
|
+
state.news = news;
|
|
941
|
+
if (newsSeen() === null && news.items.length) save(NEWS_SEEN_KEY, new Date(newestTime(news.items)).toISOString());
|
|
942
|
+
renderLatestNews();
|
|
943
|
+
if ($('news').open) updateNewsPanel();
|
|
944
|
+
}
|
|
945
|
+
|
|
946
|
+
function webHref(value) {
|
|
947
|
+
try {
|
|
948
|
+
const url = new URL(value);
|
|
949
|
+
return url.protocol === 'https:' || url.protocol === 'http:' ? url.href : null;
|
|
950
|
+
} catch {
|
|
951
|
+
return null;
|
|
952
|
+
}
|
|
953
|
+
}
|
|
954
|
+
|
|
955
|
+
function newsTitle(item) {
|
|
956
|
+
const href = webHref(item.url);
|
|
957
|
+
const title = document.createElement(href ? 'a' : 'span');
|
|
958
|
+
title.className = 'news-link';
|
|
959
|
+
title.textContent = item.title;
|
|
960
|
+
if (href) {
|
|
961
|
+
title.href = href;
|
|
962
|
+
title.target = '_blank';
|
|
963
|
+
title.rel = 'noopener noreferrer';
|
|
964
|
+
title.setAttribute('aria-description', 'Opens in a new tab');
|
|
965
|
+
}
|
|
966
|
+
return title;
|
|
967
|
+
}
|
|
968
|
+
|
|
969
|
+
function newsMeta(item) {
|
|
970
|
+
const meta = document.createElement('span');
|
|
971
|
+
meta.className = 'news-meta';
|
|
972
|
+
const time = document.createElement('time');
|
|
973
|
+
time.dateTime = item.publishedAt;
|
|
974
|
+
time.textContent = relativeTime(item.publishedAt);
|
|
975
|
+
time.title = new Date(item.publishedAt).toLocaleString();
|
|
976
|
+
meta.append(`${item.source} · `, time);
|
|
977
|
+
return meta;
|
|
978
|
+
}
|
|
979
|
+
|
|
980
|
+
function isNew(item, since) {
|
|
981
|
+
return Date.parse(item.publishedAt) > since;
|
|
982
|
+
}
|
|
983
|
+
|
|
984
|
+
function newBadge() {
|
|
985
|
+
return badge('new', 'New', 'Published since you last opened the news');
|
|
986
|
+
}
|
|
987
|
+
|
|
988
|
+
function latestNews(items) {
|
|
989
|
+
const sources = new Set();
|
|
990
|
+
const latest = [];
|
|
991
|
+
for (const item of items) {
|
|
992
|
+
if (item.category !== 'news' || sources.has(item.source)) continue;
|
|
993
|
+
sources.add(item.source);
|
|
994
|
+
latest.push(item);
|
|
995
|
+
if (latest.length === NEWS_LATEST) break;
|
|
996
|
+
}
|
|
997
|
+
return latest;
|
|
998
|
+
}
|
|
999
|
+
|
|
1000
|
+
function failureLines(news) {
|
|
1001
|
+
const groups = new Map();
|
|
1002
|
+
for (const source of news.sources) if (source.error) groups.set(source.error, [...(groups.get(source.error) ?? []), source]);
|
|
1003
|
+
return [...groups].map(([error, sources]) => {
|
|
1004
|
+
if (sources.length === 1) {
|
|
1005
|
+
const [source] = sources;
|
|
1006
|
+
return `${source.name}: ${error} · ${source.okAt ? `showing items fetched ${relativeTime(source.okAt)}` : 'nothing fetched yet'}`;
|
|
1007
|
+
}
|
|
1008
|
+
const names = [...new Set(sources.map((source) => source.name))];
|
|
1009
|
+
return `${names.length <= 3 ? listJoin(names) : `${sources.length} sources`}: ${error}`;
|
|
1010
|
+
});
|
|
1011
|
+
}
|
|
1012
|
+
|
|
1013
|
+
function newsNote(news) {
|
|
1014
|
+
if (!news || (news.refreshing && news.items.length === 0)) return 'Loading news…';
|
|
1015
|
+
const failed = news.sources.filter((s) => s.error);
|
|
1016
|
+
if (news.items.length === 0 && failed.length > 0 && failed.length === news.sources.length) {
|
|
1017
|
+
return `News could not be loaded. ${failureLines(news)[0]}. Agent Guild tries again in a few minutes.`;
|
|
1018
|
+
}
|
|
1019
|
+
return 'No news from the last 30 days.';
|
|
1020
|
+
}
|
|
1021
|
+
|
|
1022
|
+
function renderLatestNews() {
|
|
1023
|
+
const news = state.news;
|
|
1024
|
+
const list = $('news-latest');
|
|
1025
|
+
const seen = newsSeen() ?? Infinity;
|
|
1026
|
+
const items = news ? latestNews(news.items) : [];
|
|
1027
|
+
const focused = document.activeElement?.closest?.('#news-latest .news-row')?.dataset.id;
|
|
1028
|
+
list.replaceChildren(...items.map((item) => {
|
|
1029
|
+
const row = document.createElement('li');
|
|
1030
|
+
row.className = 'news-row';
|
|
1031
|
+
row.dataset.id = item.id;
|
|
1032
|
+
row.append(newsTitle(item), ...(isNew(item, seen) ? [newBadge()] : []), newsMeta(item));
|
|
1033
|
+
return row;
|
|
1034
|
+
}));
|
|
1035
|
+
if (focused) [...list.children].find((row) => row.dataset.id === focused)?.querySelector('.news-link')?.focus({ preventScroll: true });
|
|
1036
|
+
list.hidden = items.length === 0;
|
|
1037
|
+
const fresh = news ? news.items.filter((item) => item.category === 'news' && isNew(item, seen)).length : 0;
|
|
1038
|
+
$('news-count').textContent = fresh ? `· ${fresh} new` : '';
|
|
1039
|
+
$('news-note').textContent = items.length ? '' : newsNote(news);
|
|
1040
|
+
$('news-note').hidden = items.length > 0;
|
|
1041
|
+
}
|
|
1042
|
+
|
|
1043
|
+
function dayLabel(time) {
|
|
1044
|
+
const date = new Date(time);
|
|
1045
|
+
const today = new Date();
|
|
1046
|
+
const yesterday = new Date(today.getFullYear(), today.getMonth(), today.getDate() - 1);
|
|
1047
|
+
if (date.toDateString() === today.toDateString()) return 'Today';
|
|
1048
|
+
if (date.toDateString() === yesterday.toDateString()) return 'Yesterday';
|
|
1049
|
+
return date.toLocaleDateString(undefined, { weekday: 'long', month: 'short', day: 'numeric' });
|
|
1050
|
+
}
|
|
1051
|
+
|
|
1052
|
+
function newsEntry(item) {
|
|
1053
|
+
const entry = document.createElement('li');
|
|
1054
|
+
entry.className = 'news-item';
|
|
1055
|
+
entry.dataset.id = item.id;
|
|
1056
|
+
const head = document.createElement('div');
|
|
1057
|
+
head.className = 'news-head';
|
|
1058
|
+
head.append(newsTitle(item), ...(isNew(item, newsView.since) ? [newBadge()] : []));
|
|
1059
|
+
entry.append(head, newsMeta(item));
|
|
1060
|
+
if (item.summary) {
|
|
1061
|
+
const summary = document.createElement('p');
|
|
1062
|
+
summary.className = 'news-summary';
|
|
1063
|
+
const discussion = webHref(item.discussion);
|
|
1064
|
+
if (discussion) {
|
|
1065
|
+
const link = document.createElement('a');
|
|
1066
|
+
link.className = 'news-discussion';
|
|
1067
|
+
link.href = discussion;
|
|
1068
|
+
link.target = '_blank';
|
|
1069
|
+
link.rel = 'noopener noreferrer';
|
|
1070
|
+
link.textContent = item.summary;
|
|
1071
|
+
link.setAttribute('aria-description', `Opens the discussion on ${item.source} in a new tab`);
|
|
1072
|
+
summary.append(link);
|
|
1073
|
+
} else {
|
|
1074
|
+
summary.textContent = item.summary;
|
|
1075
|
+
}
|
|
1076
|
+
entry.append(summary);
|
|
1077
|
+
}
|
|
1078
|
+
return entry;
|
|
1079
|
+
}
|
|
1080
|
+
|
|
1081
|
+
function renderNewsFilters() {
|
|
1082
|
+
$('news-filters').replaceChildren(...NEWS_FILTERS.map(([id, label]) => {
|
|
1083
|
+
const chip = document.createElement('button');
|
|
1084
|
+
chip.type = 'button';
|
|
1085
|
+
chip.className = 'news-filter';
|
|
1086
|
+
chip.textContent = label;
|
|
1087
|
+
chip.setAttribute('aria-pressed', String(id === newsView.filter));
|
|
1088
|
+
chip.addEventListener('click', () => {
|
|
1089
|
+
newsView.filter = id;
|
|
1090
|
+
save(NEWS_FILTER_KEY, id);
|
|
1091
|
+
for (const other of $('news-filters').children) other.setAttribute('aria-pressed', String(other === chip));
|
|
1092
|
+
newsView.shown = state.news;
|
|
1093
|
+
renderNewsList();
|
|
1094
|
+
document.querySelector('.news-body').scrollTop = 0;
|
|
1095
|
+
});
|
|
1096
|
+
return chip;
|
|
1097
|
+
}));
|
|
1098
|
+
}
|
|
1099
|
+
|
|
1100
|
+
function renderNewsList() {
|
|
1101
|
+
const shown = newsView.shown;
|
|
1102
|
+
const items = (shown?.items ?? []).filter((item) => newsView.filter === 'all' || item.category === newsView.filter);
|
|
1103
|
+
const groups = [];
|
|
1104
|
+
for (const item of items) {
|
|
1105
|
+
const label = dayLabel(Date.parse(item.publishedAt));
|
|
1106
|
+
if (groups.at(-1)?.label !== label) groups.push({ label, items: [] });
|
|
1107
|
+
groups.at(-1).items.push(item);
|
|
1108
|
+
}
|
|
1109
|
+
$('news-list').replaceChildren(...groups.map((group) => {
|
|
1110
|
+
const section = document.createElement('section');
|
|
1111
|
+
section.className = 'news-group';
|
|
1112
|
+
const heading = document.createElement('h3');
|
|
1113
|
+
heading.className = 'news-day';
|
|
1114
|
+
heading.textContent = group.label;
|
|
1115
|
+
const list = document.createElement('ul');
|
|
1116
|
+
list.className = 'news-items';
|
|
1117
|
+
list.append(...group.items.map(newsEntry));
|
|
1118
|
+
section.append(heading, list);
|
|
1119
|
+
return section;
|
|
1120
|
+
}));
|
|
1121
|
+
const label = NEWS_FILTERS.find(([id]) => id === newsView.filter)[1];
|
|
1122
|
+
$('news-empty').textContent = items.length ? '' : newsView.filter === 'all' || !shown ? newsNote(shown) : `Nothing in ${label} from the last 30 days.`;
|
|
1123
|
+
$('news-empty').hidden = items.length > 0;
|
|
1124
|
+
$('news-fresh').hidden = true;
|
|
1125
|
+
const fresh = (shown?.items ?? []).filter((item) => isNew(item, newsView.since)).length;
|
|
1126
|
+
$('news-sub').textContent = fresh ? `${fresh} new since your last visit` : 'Newest first';
|
|
1127
|
+
}
|
|
1128
|
+
|
|
1129
|
+
function renderNewsStatus() {
|
|
1130
|
+
const news = state.news;
|
|
1131
|
+
const status = $('news-status');
|
|
1132
|
+
status.hidden = !news;
|
|
1133
|
+
if (!news) return;
|
|
1134
|
+
const updated = news.refreshing ? 'checking for news…' : news.refreshedAt ? `updated ${relativeTime(news.refreshedAt)}` : '';
|
|
1135
|
+
const lines = [[`${news.sources.length} source${news.sources.length === 1 ? '' : 's'}`, updated].filter(Boolean).join(' · '), ...failureLines(news)];
|
|
1136
|
+
status.replaceChildren(...lines.map((text) => {
|
|
1137
|
+
const line = document.createElement('span');
|
|
1138
|
+
line.textContent = text;
|
|
1139
|
+
return line;
|
|
1140
|
+
}));
|
|
1141
|
+
}
|
|
1142
|
+
|
|
1143
|
+
function updateNewsPanel() {
|
|
1144
|
+
renderNewsStatus();
|
|
1145
|
+
if (!newsView.shown?.items.length) {
|
|
1146
|
+
newsView.shown = state.news;
|
|
1147
|
+
renderNewsList();
|
|
1148
|
+
return;
|
|
1149
|
+
}
|
|
1150
|
+
const shownIds = new Set(newsView.shown.items.map((item) => item.id));
|
|
1151
|
+
const added = state.news.items.filter((item) => !shownIds.has(item.id) && (newsView.filter === 'all' || item.category === newsView.filter)).length;
|
|
1152
|
+
const fresh = $('news-fresh');
|
|
1153
|
+
fresh.hidden = added === 0;
|
|
1154
|
+
fresh.textContent = `Show ${added} new item${added === 1 ? '' : 's'}`;
|
|
1155
|
+
}
|
|
1156
|
+
|
|
1157
|
+
function showFreshNews() {
|
|
1158
|
+
const before = new Set(newsView.shown.items.map((item) => item.id));
|
|
1159
|
+
newsView.shown = state.news;
|
|
1160
|
+
renderNewsList();
|
|
1161
|
+
const first = [...$('news-list').querySelectorAll('.news-item')].find((entry) => !before.has(entry.dataset.id));
|
|
1162
|
+
(first?.querySelector('.news-link') ?? $('news-close')).focus();
|
|
1163
|
+
}
|
|
1164
|
+
|
|
1165
|
+
function openNews() {
|
|
1166
|
+
const dialog = $('news');
|
|
1167
|
+
const saved = load(NEWS_FILTER_KEY);
|
|
1168
|
+
newsView.filter = NEWS_FILTERS.some(([id]) => id === saved) ? saved : 'all';
|
|
1169
|
+
newsView.since = newsSeen() ?? Infinity;
|
|
1170
|
+
newsView.shown = state.news;
|
|
1171
|
+
renderNewsFilters();
|
|
1172
|
+
renderNewsList();
|
|
1173
|
+
renderNewsStatus();
|
|
1174
|
+
if (!dialog.open) {
|
|
1175
|
+
newsView.opener = document.activeElement;
|
|
1176
|
+
dialog.showModal();
|
|
1177
|
+
}
|
|
1178
|
+
($('news-list').querySelector('.news-link') ?? $('news-close')).focus();
|
|
1179
|
+
if (!state.news) loadNews();
|
|
1180
|
+
}
|
|
1181
|
+
|
|
1182
|
+
function closeNews() {
|
|
1183
|
+
if ($('news').open) $('news').close();
|
|
1184
|
+
}
|
|
1185
|
+
|
|
1186
|
+
function tickNews() {
|
|
1187
|
+
for (const time of document.querySelectorAll('#news-latest time, #news-list time')) time.textContent = relativeTime(time.dateTime);
|
|
1188
|
+
if ($('news').open) renderNewsStatus();
|
|
1189
|
+
}
|
|
1190
|
+
|
|
757
1191
|
const CHANNEL_LABELS = {
|
|
758
1192
|
npm: 'npm', native: 'native', brew: 'Homebrew', winget: 'WinGet', legacy: 'legacy install', unknown: 'unknown install',
|
|
759
1193
|
};
|
|
@@ -818,7 +1252,7 @@ async function startSession(provider, card, { resume } = {}) {
|
|
|
818
1252
|
save(CWD_KEY, cwd);
|
|
819
1253
|
card.classList.add('busy');
|
|
820
1254
|
try {
|
|
821
|
-
const body = { providerId: provider.id, cwd: cwd || undefined, cols: 120, rows: 32, resume };
|
|
1255
|
+
const body = { providerId: provider.id, account: selectedAccount(provider).id, cwd: cwd || undefined, cols: 120, rows: 32, resume };
|
|
822
1256
|
const { session } = await api('POST', '/sessions', body);
|
|
823
1257
|
upsertSession(session);
|
|
824
1258
|
openPanel(session.id);
|
|
@@ -852,6 +1286,12 @@ function modelTitle(s) {
|
|
|
852
1286
|
return `Model ${modelText(s)}${id}, ${MODEL_SOURCES[s.model.source] || s.model.source}`;
|
|
853
1287
|
}
|
|
854
1288
|
|
|
1289
|
+
function accountLabel(s) {
|
|
1290
|
+
if (!s.account) return '';
|
|
1291
|
+
const provider = state.providers.find((p) => p.id === s.provider.id);
|
|
1292
|
+
return (provider?.accounts?.length ?? 0) > 1 || s.account.id !== 'default' ? s.account.label : '';
|
|
1293
|
+
}
|
|
1294
|
+
|
|
855
1295
|
function statusText(s) {
|
|
856
1296
|
if (s.status === 'exited') {
|
|
857
1297
|
if (s.signal) return `Exited (${s.signal})`;
|
|
@@ -891,7 +1331,7 @@ function updateCard(node, s) {
|
|
|
891
1331
|
badge.title = `Level ${level}`;
|
|
892
1332
|
node.querySelector('.name').textContent = s.name;
|
|
893
1333
|
const resumed = s.resume ? ` · resumed ${s.resume}` : '';
|
|
894
|
-
node.querySelector('.meta').textContent =
|
|
1334
|
+
node.querySelector('.meta').textContent = [s.provider.vendor, s.provider.tool, accountLabel(s), `started ${relativeTime(s.createdAt)}${resumed}`].filter(Boolean).join(' · ');
|
|
895
1335
|
const pill = node.querySelector('.status-pill');
|
|
896
1336
|
pill.textContent = statusText(s);
|
|
897
1337
|
pill.className = `status-pill ${s.status === 'exited' ? 'exited' : s.activity}`;
|
|
@@ -910,7 +1350,8 @@ function updateCard(node, s) {
|
|
|
910
1350
|
node.querySelector('.stop').hidden = s.status !== 'running';
|
|
911
1351
|
node.querySelector('.remove').hidden = s.status === 'running';
|
|
912
1352
|
const modelLabel = s.model ? `, model ${modelText(s)}` : '';
|
|
913
|
-
|
|
1353
|
+
const accountName = accountLabel(s) ? `, ${accountLabel(s)} account` : '';
|
|
1354
|
+
node.setAttribute('aria-label', `${s.name}, ${s.provider.vendor}${accountName}${modelLabel}, ${statusText(s)}, ${s.agents.length} agents`);
|
|
914
1355
|
}
|
|
915
1356
|
|
|
916
1357
|
function renderSessions() {
|
|
@@ -1146,7 +1587,7 @@ function updatePanel() {
|
|
|
1146
1587
|
if (!s) return;
|
|
1147
1588
|
paintProviderIcon($('panel-icon'), s.provider);
|
|
1148
1589
|
$('panel-title').textContent = s.name;
|
|
1149
|
-
$('panel-sub').textContent = [s.provider.tool, modelText(s), statusText(s), s.cwd].filter(Boolean).join(' · ');
|
|
1590
|
+
$('panel-sub').textContent = [s.provider.tool, accountLabel(s), modelText(s), statusText(s), s.cwd].filter(Boolean).join(' · ');
|
|
1150
1591
|
$('panel-sub').title = modelTitle(s);
|
|
1151
1592
|
renderAgents($('panel-agents'), s.agents);
|
|
1152
1593
|
const stop = $('panel-stop');
|
|
@@ -1190,6 +1631,7 @@ function enterStopping(running = 0) {
|
|
|
1190
1631
|
state.stopRemaining = null;
|
|
1191
1632
|
closePanel();
|
|
1192
1633
|
closeModels();
|
|
1634
|
+
closeNews();
|
|
1193
1635
|
for (const view of state.views.values()) view.dispose();
|
|
1194
1636
|
state.views.clear();
|
|
1195
1637
|
state.sessions.clear();
|
|
@@ -1246,6 +1688,12 @@ function connectEvents() {
|
|
|
1246
1688
|
state.sessions = new Map(msg.sessions.map((s) => [s.id, s]));
|
|
1247
1689
|
for (const id of [...state.views.keys()]) if (!state.sessions.has(id)) dropSession(id);
|
|
1248
1690
|
renderSessions();
|
|
1691
|
+
setUpgrade(msg.upgrade);
|
|
1692
|
+
loadNews();
|
|
1693
|
+
} else if (msg.type === 'news.updated') {
|
|
1694
|
+
loadNews();
|
|
1695
|
+
} else if (msg.type === 'manager.upgrade') {
|
|
1696
|
+
setUpgrade(msg.upgrade);
|
|
1249
1697
|
} else if (msg.type === 'manager.stopping') {
|
|
1250
1698
|
enterStopping(msg.running);
|
|
1251
1699
|
} else if (msg.type === 'manager.stopped') {
|
|
@@ -1297,9 +1745,11 @@ function readTokenFromHash() {
|
|
|
1297
1745
|
|
|
1298
1746
|
let usageTimer;
|
|
1299
1747
|
let statsInterval;
|
|
1748
|
+
let newsTimer;
|
|
1300
1749
|
|
|
1301
1750
|
function showAuth(message = '') {
|
|
1302
1751
|
closeModels();
|
|
1752
|
+
closeNews();
|
|
1303
1753
|
$('app').hidden = true;
|
|
1304
1754
|
$('terminal-panel').hidden = true;
|
|
1305
1755
|
$('stopped').hidden = true;
|
|
@@ -1332,6 +1782,8 @@ async function boot() {
|
|
|
1332
1782
|
loadStats();
|
|
1333
1783
|
clearInterval(statsInterval);
|
|
1334
1784
|
statsInterval = setInterval(loadStats, 60 * 60 * 1000);
|
|
1785
|
+
clearInterval(newsTimer);
|
|
1786
|
+
newsTimer = setInterval(() => { if (document.visibilityState === 'visible') loadNews(); }, NEWS_POLL_MS);
|
|
1335
1787
|
}
|
|
1336
1788
|
|
|
1337
1789
|
$('auth-form').addEventListener('submit', (e) => {
|
|
@@ -1374,6 +1826,21 @@ document.addEventListener('keydown', (e) => {
|
|
|
1374
1826
|
}, true);
|
|
1375
1827
|
addEventListener('scroll', () => { if (tipFor) hideTip(); }, true);
|
|
1376
1828
|
addEventListener('resize', () => { if (tipFor) hideTip(); });
|
|
1829
|
+
$('news-all').addEventListener('click', openNews);
|
|
1830
|
+
$('news-close').addEventListener('click', closeNews);
|
|
1831
|
+
$('news-fresh').addEventListener('click', showFreshNews);
|
|
1832
|
+
$('news').addEventListener('click', (e) => { if (e.target === $('news')) closeNews(); });
|
|
1833
|
+
$('news').addEventListener('close', () => {
|
|
1834
|
+
const newest = newestTime(newsView.shown?.items ?? []);
|
|
1835
|
+
if (newest > (newsSeen() ?? 0)) save(NEWS_SEEN_KEY, new Date(newest).toISOString());
|
|
1836
|
+
renderLatestNews();
|
|
1837
|
+
const opener = newsView.opener?.isConnected && !newsView.opener.closest('[hidden]') ? newsView.opener : $('news-all');
|
|
1838
|
+
opener.focus();
|
|
1839
|
+
newsView.opener = null;
|
|
1840
|
+
});
|
|
1841
|
+
document.addEventListener('visibilitychange', () => {
|
|
1842
|
+
if (document.visibilityState === 'visible' && state.connected && Date.now() - newsLoadedAt > 60000) loadNews();
|
|
1843
|
+
});
|
|
1377
1844
|
$('models-more').addEventListener('click', () => {
|
|
1378
1845
|
const before = $('models-list').childElementCount;
|
|
1379
1846
|
modelsView.all = true;
|
|
@@ -1381,6 +1848,13 @@ $('models-more').addEventListener('click', () => {
|
|
|
1381
1848
|
$('models-list').children[before]?.querySelector('.model-toggle')?.focus();
|
|
1382
1849
|
});
|
|
1383
1850
|
$('stop-manager').addEventListener('click', () => stopManager());
|
|
1851
|
+
$('upgrade').addEventListener('click', upgradeManager);
|
|
1852
|
+
$('theme-toggle').addEventListener('click', toggleTheme);
|
|
1853
|
+
applyTheme(currentTheme());
|
|
1854
|
+
// Follow the system setting until the user picks a theme.
|
|
1855
|
+
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', (e) => {
|
|
1856
|
+
if (!load(THEME_KEY)) applyTheme(e.matches ? 'dark' : 'light');
|
|
1857
|
+
});
|
|
1384
1858
|
$('panel-stop').addEventListener('click', () => {
|
|
1385
1859
|
const s = state.sessions.get(state.activeId);
|
|
1386
1860
|
if (!s) return;
|
|
@@ -1388,7 +1862,10 @@ $('panel-stop').addEventListener('click', () => {
|
|
|
1388
1862
|
else removeSession(s.id);
|
|
1389
1863
|
});
|
|
1390
1864
|
$('cwd').value = load(CWD_KEY) || '';
|
|
1865
|
+
try { state.accounts = JSON.parse(load(ACCOUNTS_KEY)) || {}; } catch { state.accounts = {}; }
|
|
1866
|
+
if (typeof state.accounts !== 'object' || Array.isArray(state.accounts)) state.accounts = {};
|
|
1391
1867
|
setInterval(renderSessions, 30000);
|
|
1868
|
+
setInterval(tickNews, 30000);
|
|
1392
1869
|
|
|
1393
1870
|
// The terminal panel sits below the top bar, which wraps onto two rows on
|
|
1394
1871
|
// narrow screens; publish its height so the panel never covers its controls.
|