@oddessentials/agent-guild 0.18.0 → 0.20.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 CHANGED
@@ -56,7 +56,9 @@ own. The cloud icon on each card opens the vendor's web app.
56
56
  **Real terminals that outlive the page.** Every session is a full interactive
57
57
  terminal: type instructions, answer prompts, watch output. Run as many as you
58
58
  like. A local session manager owns them, so you can close or reload the page
59
- and come back to the same screens.
59
+ and come back to the same screens. Drag a session card by its grip, with a
60
+ mouse or a finger, to put the cards in the order you want; this browser
61
+ remembers it, and new sessions join at the end.
60
62
 
61
63
  **Pick your shell.** The Shell card offers the shells installed on your
62
64
  computer and remembers your choice in this browser. On Windows it defaults
@@ -118,7 +120,23 @@ puts a crew of little robots in deep space; **Grove** is a calm moss garden
118
120
  of gentle nature spirits; **Gnomeland** is a lantern-lit village of gnome
119
121
  builders, engineers with a little magic. The page
120
122
  follows your system's light or dark setting until you pick one. See
121
- [docs/SKINS.md](docs/SKINS.md) to make another.
123
+ [docs/SKINS.md](docs/SKINS.md) to make another. **Alert sounds**, off until
124
+ you turn them on in the same menu, chime when the session manager confirms
125
+ a stop or restart, remains unavailable after a brief recovery check, or a
126
+ new version is discovered after the initial version check. Session activity
127
+ and assistant responses have no sounds:
128
+ the providers do not yet offer consistently reliable completion signals.
129
+
130
+ Sounds play in one eligible tab per browser and origin after browser playback
131
+ permission (usually a click or keypress). They require Web Locks and writable
132
+ local storage. Opening or reconnecting a page does not replay old alerts,
133
+ even when a different manager has taken over. Closing a browser does not
134
+ stop the manager. After an unexpected disconnect, an already connected page
135
+ checks the local health endpoint twice, two seconds apart, with a one-second
136
+ timeout per request. If both fail, it shows “Manager unavailable” and alerts
137
+ once; it cannot confirm whether the process exited or sessions ended.
138
+ A successful health check, reconnection, or closing the page cancels the alert.
139
+ There is no added polling while connected and no additional monitoring process.
122
140
 
123
141
  ## Commands
124
142
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oddessentials/agent-guild",
3
- "version": "0.18.0",
3
+ "version": "0.20.0",
4
4
  "description": "Launch and watch AI coding-assistant terminal sessions from one local web page. A bundled session manager owns the terminals so the page can close and reconnect.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -25,6 +25,7 @@ const MIME = {
25
25
  '.webp': 'image/webp',
26
26
  '.woff2': 'font/woff2',
27
27
  '.ico': 'image/x-icon',
28
+ '.wav': 'audio/wav',
28
29
  '.json': 'application/json; charset=utf-8',
29
30
  };
30
31
 
@@ -450,7 +451,7 @@ export function createManagerServer({
450
451
 
451
452
  function handleEvents(ws) {
452
453
  eventClients.add(ws);
453
- safeSend(ws, { type: 'hello', version, pid: process.pid, launcher, upgrade: upgradeInfo(), sessions: manager.list() });
454
+ safeSend(ws, { type: 'hello', version, pid: process.pid, startedAt, launcher, upgrade: upgradeInfo(), sessions: manager.list() });
454
455
  ws.on('close', () => eventClients.delete(ws));
455
456
  ws.on('message', () => { /* events socket is server -> client only */ });
456
457
  }
package/web/alerts.js ADDED
@@ -0,0 +1,155 @@
1
+ // Sounds follow manager availability and new releases, never session activity.
2
+ export const SOUNDS = {
3
+ stopped: 'sounds/manager-stopped.wav',
4
+ update: 'sounds/update-available.wav',
5
+ };
6
+
7
+ export const MAX_ALERT_AGE_MS = 10000;
8
+ export const RECOVERY_WAIT_MS = 2000;
9
+ export const HEALTH_TIMEOUT_MS = 1000;
10
+ const PLAYED_KEY = 'agentGuild.playedSounds';
11
+
12
+ /** Queue eligible pages, allowing another page to try if playback fails.
13
+ * Record only successful playback, under the same cross-tab lock. A bounded
14
+ * ledger prevents delayed tabs from replaying the event after lock release.
15
+ * Without shared coordination, stay silent rather than multiply alerts.
16
+ */
17
+ export async function playOnce(key, play, { locks = globalThis.navigator?.locks, storage, fresh = () => true, related = [] } = {}) {
18
+ if (!locks) return false;
19
+ try {
20
+ storage ??= globalThis.localStorage;
21
+ if (!storage) return false;
22
+ return await locks.request('agentGuild.sound', async () => {
23
+ if (!fresh()) return false;
24
+ let played = JSON.parse(storage.getItem(PLAYED_KEY) || '[]');
25
+ if (!Array.isArray(played)) played = [];
26
+ if (played.includes(key) || related.some((other) => played.includes(other))) return false;
27
+ // Check storage is writable before making a sound.
28
+ storage.setItem(PLAYED_KEY, JSON.stringify(played.slice(-127)));
29
+ try {
30
+ if (await play() === false) return false;
31
+ } catch { return false; }
32
+ storage.setItem(PLAYED_KEY, JSON.stringify([...played.slice(-127), key]));
33
+ return true;
34
+ });
35
+ } catch { return false; }
36
+ }
37
+
38
+ /** A live connection rearms an availability alert for a later outage. */
39
+ export async function rearmSound(key, { locks = globalThis.navigator?.locks, storage } = {}) {
40
+ if (!locks) return;
41
+ try {
42
+ storage ??= globalThis.localStorage;
43
+ if (!storage) return;
44
+ await locks.request('agentGuild.sound', () => {
45
+ const played = JSON.parse(storage.getItem(PLAYED_KEY) || '[]');
46
+ if (Array.isArray(played) && played.includes(key)) {
47
+ storage.setItem(PLAYED_KEY, JSON.stringify(played.filter((item) => item !== key)));
48
+ }
49
+ });
50
+ } catch { /* Optional sound coordination may be unavailable. */ }
51
+ }
52
+
53
+ /** Two bounded health checks, only after losing a previously live connection.
54
+ * Reconnect, shutdown and page departure cancel both requests and timers.
55
+ * The callback's freshness check also cancels playback waiting on another tab.
56
+ */
57
+ export function managerLossWatcher({ reachable, unavailable, now = Date.now, setTimer = setTimeout, clearTimer = clearTimeout }) {
58
+ let live = false;
59
+ let pending = null;
60
+ const cancel = () => {
61
+ pending?.abort();
62
+ pending = null;
63
+ live = false;
64
+ };
65
+ const check = async (signal) => {
66
+ const request = new AbortController();
67
+ const abort = () => request.abort();
68
+ signal.addEventListener('abort', abort, { once: true });
69
+ const timer = setTimer(abort, HEALTH_TIMEOUT_MS);
70
+ try { return await reachable(request.signal); }
71
+ catch { return false; }
72
+ finally {
73
+ clearTimer(timer);
74
+ signal.removeEventListener('abort', abort);
75
+ }
76
+ };
77
+ const wait = (signal) => new Promise((resolve) => {
78
+ const done = () => {
79
+ clearTimer(timer);
80
+ signal.removeEventListener('abort', done);
81
+ resolve();
82
+ };
83
+ const timer = setTimer(done, RECOVERY_WAIT_MS);
84
+ signal.addEventListener('abort', done, { once: true });
85
+ });
86
+ return {
87
+ connected() { cancel(); live = true; },
88
+ cancel,
89
+ async disconnected() {
90
+ if (!live) return;
91
+ live = false;
92
+ const controller = new AbortController();
93
+ pending = controller;
94
+ const at = now();
95
+ const fresh = () => pending === controller && !controller.signal.aborted && now() - at <= MAX_ALERT_AGE_MS;
96
+ if (await check(controller.signal) || !fresh()) return;
97
+ await wait(controller.signal);
98
+ if (!fresh() || await check(controller.signal) || !fresh()) return;
99
+ unavailable(at, fresh);
100
+ },
101
+ };
102
+ }
103
+
104
+ /** Reconnecting establishes a baseline; only a live stop event alerts. */
105
+ export function stopWatcher() {
106
+ let instance = null;
107
+ let stopped = false;
108
+ return {
109
+ connected(next) {
110
+ if (next !== instance) stopped = false;
111
+ instance = next;
112
+ },
113
+ stopped() {
114
+ if (!instance || stopped) return null;
115
+ stopped = true;
116
+ return instance;
117
+ },
118
+ };
119
+ }
120
+
121
+ // Published releases use semantic versions, including numerically ordered
122
+ // prerelease identifiers. Build metadata does not affect precedence.
123
+ function versionParts(value) {
124
+ return typeof value === 'string' ? value.match(/^(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?(?:\+[0-9A-Za-z.-]+)?$/) : null;
125
+ }
126
+ function newer(a, b) {
127
+ const av = versionParts(a), bv = versionParts(b);
128
+ for (let i = 1; i <= 3; i++) if (+av[i] !== +bv[i]) return +av[i] > +bv[i];
129
+ if (!av[4] || !bv[4]) return Boolean(bv[4]) && !av[4];
130
+ const ap = av[4].split('.'), bp = bv[4].split('.');
131
+ for (let i = 0; i < Math.max(ap.length, bp.length); i++) {
132
+ if (ap[i] === bp[i]) continue;
133
+ if (ap[i] === undefined || bp[i] === undefined) return bp[i] === undefined;
134
+ const an = /^\d+$/.test(ap[i]), bn = /^\d+$/.test(bp[i]);
135
+ if (an && bn) return +ap[i] > +bp[i];
136
+ if (an !== bn) return bn;
137
+ return ap[i] > bp[i];
138
+ }
139
+ return false;
140
+ }
141
+
142
+ /** The first successful version report is a baseline, even after nulls.
143
+ * Reconnect snapshots also advance it silently. Never alert on a rollback.
144
+ */
145
+ export function updateWatcher() {
146
+ let seen = null;
147
+ return (upgrade, baseline = false) => {
148
+ const version = upgrade?.latestVersion;
149
+ if (!versionParts(version)) return false;
150
+ if (!seen) { seen = version; return false; }
151
+ if (!newer(version, seen)) return false;
152
+ seen = version;
153
+ return !baseline && upgrade.available === true;
154
+ };
155
+ }
package/web/app.js CHANGED
@@ -1,6 +1,8 @@
1
1
  // Agent Guild web page. A thin client of the session manager's local API:
2
2
  // it never owns sessions, so closing the page leaves them running.
3
3
 
4
+ import { SOUNDS, MAX_ALERT_AGE_MS, playOnce, rearmSound, managerLossWatcher, stopWatcher, updateWatcher } from './alerts.js';
5
+
4
6
  const TOKEN_KEY = 'agentGuild.token';
5
7
  const CWD_KEY = 'agentGuild.cwd';
6
8
  const ACCOUNTS_KEY = 'agentGuild.accounts';
@@ -12,6 +14,8 @@ const NEWS_FILTER_KEY = 'agentGuild.newsFilter';
12
14
  const CHANGELOG_SEEN_KEY = 'agentGuild.changelogSeen';
13
15
  const GITHUB_ACCOUNT_KEY = 'agentGuild.githubAccount';
14
16
  const CLONE_PARENT_KEY = 'agentGuild.cloneParent';
17
+ const SESSION_ORDER_KEY = 'agentGuild.sessionOrder';
18
+ const SOUND_KEY = 'agentGuild.sound';
15
19
  const RELEASES_URL = 'https://github.com/oddessentials/agent-guild/releases';
16
20
  const isMac = /Mac|iPhone|iPad/.test(navigator.platform || navigator.userAgent);
17
21
  const reducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)');
@@ -35,6 +39,8 @@ const state = {
35
39
  activeId: null,
36
40
  eventsSocket: null,
37
41
  eventsRetry: 0,
42
+ pageAway: false,
43
+ managerUnavailable: false,
38
44
  /** True while the events socket is open. */
39
45
  connected: false,
40
46
  /** The manager's own version check, from `hello` and `manager.upgrade`. */
@@ -205,6 +211,80 @@ function changeSkin(input) {
205
211
  revealChange(input.closest('label'), () => { document.documentElement.dataset.skin = skin; });
206
212
  }
207
213
 
214
+ // ---- sound ----------------------------------------------------------------
215
+
216
+ const audio = {};
217
+ const stopAlert = stopWatcher();
218
+ const updateAlert = updateWatcher();
219
+ const managerLoss = managerLossWatcher({
220
+ async reachable(signal) {
221
+ const res = await fetch('/api/v1/health', { cache: 'no-store', signal });
222
+ const health = await res.json();
223
+ return res.ok && health.ok === true && health.name === 'agent-guild';
224
+ },
225
+ unavailable(at, fresh) {
226
+ state.managerUnavailable = true;
227
+ showManagerUnavailable();
228
+ alertSound('stopped', `unavailable.${state.managerInstance}`, at, fresh, [`stopped.${state.managerInstance}`]);
229
+ },
230
+ });
231
+
232
+ function soundOn() {
233
+ return load(SOUND_KEY) === 'on';
234
+ }
235
+
236
+ /** Keep the tiny clips ready while the manager can still serve them. */
237
+ function prepareSounds() {
238
+ if (!soundOn()) return;
239
+ for (const [name, file] of Object.entries(SOUNDS)) {
240
+ audio[name] ??= new Audio(file);
241
+ audio[name].preload = 'auto';
242
+ if (audio[name].error) audio[name].load();
243
+ }
244
+ }
245
+
246
+ /** Plays a sound when sounds are on. Browsers allow it once the user has clicked or typed on the page. */
247
+ function playSound(name) {
248
+ if (!soundOn()) return Promise.resolve(false);
249
+ audio[name] ??= new Audio(SOUNDS[name]);
250
+ audio[name].currentTime = 0;
251
+ return audio[name].play().then(() => true);
252
+ }
253
+
254
+ /** Plays an alert in one open page only; `key` names the event. */
255
+ function alertSound(name, key, at = Date.now(), fresh = () => true, related = []) {
256
+ if (soundOn()) playOnce(key, () => playSound(name), {
257
+ fresh: () => !state.pageAway && soundOn() && fresh() && Date.now() - at <= MAX_ALERT_AGE_MS,
258
+ related,
259
+ });
260
+ }
261
+
262
+ function changeSound(input) {
263
+ save(SOUND_KEY, input.checked ? 'on' : null);
264
+ prepareSounds();
265
+ playSound('update').catch(() => {});
266
+ }
267
+
268
+ /** A hello identifies this manager lifetime, independent of connections. */
269
+ function managerConnected(msg) {
270
+ prepareSounds();
271
+ state.managerInstance = msg.startedAt && msg.pid ? `${msg.pid}.${msg.startedAt}` : null;
272
+ stopAlert.connected(state.managerInstance);
273
+ state.managerUnavailable = false;
274
+ if (state.managerInstance) managerLoss.connected();
275
+ else managerLoss.cancel();
276
+ if (state.managerInstance) rearmSound(`unavailable.${state.managerInstance}`);
277
+ }
278
+
279
+ /** A confirmed manager stop; a browser socket closing never calls this. */
280
+ function managerGone() {
281
+ managerLoss.cancel();
282
+ state.managerUnavailable = false;
283
+ const instance = stopAlert.stopped();
284
+ if (instance) alertSound('stopped', `stopped.${instance}`, Date.now(),
285
+ () => state.managerInstance === instance && !state.connected, [`unavailable.${instance}`]);
286
+ }
287
+
208
288
  /** Places the open menu under its button, right-aligned with it and kept on screen. */
209
289
  function placeAppearanceMenu() {
210
290
  const menu = $('appearance-menu');
@@ -268,9 +348,10 @@ function renderUpgrade() {
268
348
  note.title = title;
269
349
  }
270
350
 
271
- function setUpgrade(upgrade) {
351
+ function setUpgrade(upgrade, baseline = false) {
272
352
  const before = state.upgrade;
273
353
  state.upgrade = upgrade || null;
354
+ if (updateAlert(state.upgrade, baseline)) alertSound('update', `update.${state.upgrade.latestVersion}`);
274
355
  renderUpgrade();
275
356
  renderVersion();
276
357
  if ($('changelog').open) renderChangelog();
@@ -2585,6 +2666,8 @@ function useFolder(dir) {
2585
2666
 
2586
2667
  const cards = new Map();
2587
2668
  let sessionsShown = false;
2669
+ /** Session ids in the order the user arranged the cards, kept in this browser. */
2670
+ let sessionOrder = parseOrder(load(SESSION_ORDER_KEY));
2588
2671
 
2589
2672
  const MODEL_SOURCES = { report: 'reported by the tool', screen: 'seen on the tool\'s screen', args: 'from the --model argument' };
2590
2673
 
@@ -2632,6 +2715,7 @@ function buildCard(session) {
2632
2715
  if (id) copyId(id);
2633
2716
  });
2634
2717
  node.querySelector('.model-pill').addEventListener('click', () => openSessionModel(session.id));
2718
+ node.querySelector('.drag-handle').addEventListener('keydown', (e) => moveByKey(e, session.id));
2635
2719
  // Skins name their own keyframes, so the one-shot classes clear on any animation they run.
2636
2720
  node.addEventListener('animationend', (e) => {
2637
2721
  if (e.target.classList.contains('level-up')) e.target.classList.remove('level-up');
@@ -2673,6 +2757,7 @@ function updateCard(node, s) {
2673
2757
  badge.textContent = level;
2674
2758
  badge.title = `Level ${level}`;
2675
2759
  node.querySelector('.name').textContent = s.name;
2760
+ node.querySelector('.drag-handle').setAttribute('aria-label', `Move ${s.name}`);
2676
2761
  const id = toolSessionId(s);
2677
2762
  const resumed = s.resume && s.resume !== id ? ` · resumed ${s.resume}` : s.resume ? ' · resumed' : '';
2678
2763
  node.querySelector('.meta-text').textContent = [s.provider.vendor, s.provider.tool, accountLabel(s), `started ${relativeTime(s.createdAt)}${resumed}`].filter(Boolean).join(' · ');
@@ -2710,9 +2795,12 @@ function updateCard(node, s) {
2710
2795
 
2711
2796
  function renderSessions() {
2712
2797
  const grid = $('sessions');
2713
- const sessions = [...state.sessions.values()].sort((a, b) => a.createdAt.localeCompare(b.createdAt));
2798
+ const sessions = orderSessions(state.sessions.values(), sessionOrder);
2714
2799
  for (const [id, node] of cards) {
2715
- if (!state.sessions.has(id)) { node.remove(); cards.delete(id); }
2800
+ if (state.sessions.has(id)) continue;
2801
+ if (drag?.id === id) releaseDrag();
2802
+ node.remove();
2803
+ cards.delete(id);
2716
2804
  }
2717
2805
  sessions.forEach((s, index) => {
2718
2806
  let node = cards.get(s.id);
@@ -2722,6 +2810,8 @@ function renderSessions() {
2722
2810
  if (sessionsShown) node.classList.add('enter');
2723
2811
  }
2724
2812
  updateCard(node, s);
2813
+ // With one card there is nothing to reorder.
2814
+ node.querySelector('.drag-handle').hidden = sessions.length < 2;
2725
2815
  // Move a card only when it is out of place: re-inserting a node drops
2726
2816
  // keyboard focus and can swallow a click that is in progress.
2727
2817
  if (grid.children[index] !== node) grid.insertBefore(node, grid.children[index] || null);
@@ -2782,6 +2872,231 @@ async function renameSession(id) {
2782
2872
  try { upsertSession((await api('PATCH', `/sessions/${id}`, { name })).session); } catch (err) { toast(err.message); }
2783
2873
  }
2784
2874
 
2875
+ // ---- reordering session cards ---------------------------------------------
2876
+
2877
+ /** The saved card order: session ids. Anything unreadable counts as no order. */
2878
+ function parseOrder(raw) {
2879
+ try {
2880
+ const ids = JSON.parse(raw);
2881
+ return Array.isArray(ids) ? ids.filter((id) => typeof id === 'string') : [];
2882
+ } catch {
2883
+ return [];
2884
+ }
2885
+ }
2886
+
2887
+ /** Sessions in the user's order. Any the order does not name, such as new ones, follow oldest first. */
2888
+ function orderSessions(sessions, order) {
2889
+ const rank = new Map(order.map((id, index) => [id, index]));
2890
+ const at = (s) => rank.get(s.id) ?? Infinity;
2891
+ return [...sessions].sort((a, b) => (at(a) - at(b)) || a.createdAt.localeCompare(b.createdAt));
2892
+ }
2893
+
2894
+ /** The ids with one of them moved to a new index. */
2895
+ function moveId(ids, id, index) {
2896
+ const rest = ids.filter((other) => other !== id);
2897
+ rest.splice(Math.max(0, Math.min(index, rest.length)), 0, id);
2898
+ return rest;
2899
+ }
2900
+
2901
+ const shownIds = () => orderSessions(state.sessions.values(), sessionOrder).map((s) => s.id);
2902
+ const sameOrder = (a, b) => a.length === b.length && a.every((id, i) => id === b[i]);
2903
+ const REORDER_EASE = 'cubic-bezier(0.2, 0.8, 0.2, 1)';
2904
+ /** How close to the top or bottom of the window a carried card scrolls the page. */
2905
+ const SCROLL_EDGE = 72;
2906
+
2907
+ /** The card being carried, or null. */
2908
+ let drag = null;
2909
+
2910
+ function saveOrder() {
2911
+ sessionOrder = shownIds();
2912
+ save(SESSION_ORDER_KEY, JSON.stringify(sessionOrder));
2913
+ }
2914
+
2915
+ function announceOrder(id) {
2916
+ const ids = shownIds();
2917
+ const s = state.sessions.get(id);
2918
+ if (s) $('reorder-status').textContent = `${s.name} moved to position ${ids.indexOf(id) + 1} of ${ids.length}.`;
2919
+ }
2920
+
2921
+ /** Plays a card's move from where it was drawn to where it now sits. Returns the animation, if any. */
2922
+ function slideFrom(node, was) {
2923
+ for (const animation of node.getAnimations()) if (animation.id === 'reorder') animation.cancel();
2924
+ if (reducedMotion.matches) return null;
2925
+ const now = node.getBoundingClientRect();
2926
+ const dx = was.left + was.width / 2 - (now.left + now.width / 2);
2927
+ const dy = was.top + was.height / 2 - (now.top + now.height / 2);
2928
+ if (Math.abs(dx) < 1 && Math.abs(dy) < 1) return null;
2929
+ const slide = node.animate([{ translate: `${dx}px ${dy}px` }, { translate: '0 0' }], { duration: 260, easing: REORDER_EASE });
2930
+ slide.id = 'reorder';
2931
+ return slide;
2932
+ }
2933
+
2934
+ /** Lays the cards out in a new order, sliding each one from its old place. */
2935
+ function arrange(order) {
2936
+ const focused = $('sessions').contains(document.activeElement) ? document.activeElement : null;
2937
+ const was = new Map([...cards.values()].map((node) => [node, node.getBoundingClientRect()]));
2938
+ sessionOrder = order;
2939
+ renderSessions();
2940
+ // Moving a card can take keyboard focus with it, as when another tab reorders the cards.
2941
+ if (focused?.isConnected && document.activeElement !== focused) focused.focus({ preventScroll: true });
2942
+ for (const [node, rect] of was) if (node !== drag?.node) slideFrom(node, rect);
2943
+ }
2944
+
2945
+ function moveByKey(e, id) {
2946
+ if (drag || e.altKey || e.ctrlKey || e.metaKey || e.shiftKey) return;
2947
+ const step = { ArrowUp: -1, ArrowLeft: -1, ArrowDown: 1, ArrowRight: 1 }[e.key];
2948
+ if (!step && e.key !== 'Home' && e.key !== 'End') return;
2949
+ e.preventDefault();
2950
+ const ids = shownIds();
2951
+ const from = ids.indexOf(id);
2952
+ const to = e.key === 'Home' ? 0 : e.key === 'End' ? ids.length - 1 : from + step;
2953
+ if (from === -1 || to < 0 || to >= ids.length || to === from) return;
2954
+ arrange(moveId(ids, id, to));
2955
+ saveOrder();
2956
+ announceOrder(id);
2957
+ }
2958
+
2959
+ /** A card's place in the grid, ignoring the transforms that animate it. */
2960
+ function layoutBox(node) {
2961
+ return { left: node.offsetLeft, top: node.offsetTop, right: node.offsetLeft + node.offsetWidth, bottom: node.offsetTop + node.offsetHeight };
2962
+ }
2963
+
2964
+ function startDrag(e, handle) {
2965
+ const node = handle.closest('.session-card');
2966
+ const grid = $('sessions');
2967
+ if (drag || !node || !e.isPrimary || (e.pointerType === 'mouse' && e.button !== 0)) return;
2968
+ e.preventDefault();
2969
+ // Where on the card, as drawn, it was picked up: it may still be sliding into place.
2970
+ const seen = node.getBoundingClientRect();
2971
+ drag = {
2972
+ id: node.dataset.id, node, grid, pointerId: e.pointerId,
2973
+ before: sessionOrder, start: shownIds(),
2974
+ grabX: e.clientX - seen.left, grabY: e.clientY - seen.top,
2975
+ x: e.clientX, y: e.clientY,
2976
+ // The card just stepped over; it is not a target again until the pointer leaves it.
2977
+ passed: null, frame: 0,
2978
+ };
2979
+ // The grid is never moved, so it keeps the pointer while the cards inside it are.
2980
+ try { grid.setPointerCapture(e.pointerId); } catch { /* the pointer is already gone */ }
2981
+ // A card picked up again while it settles would otherwise be held in place by that animation.
2982
+ for (const animation of node.getAnimations()) if (animation.id === 'reorder') animation.cancel();
2983
+ node.classList.remove('settling');
2984
+ // The card lifts around the point it was picked up by, which stays under the pointer.
2985
+ node.style.transformOrigin = `${drag.grabX}px ${drag.grabY}px`;
2986
+ node.classList.add('dragging');
2987
+ follow();
2988
+ grid.getBoundingClientRect(); // the outline appears where the card is, rather than gliding in
2989
+ grid.classList.add('reordering');
2990
+ document.documentElement.classList.add('reordering');
2991
+ if (e.pointerType !== 'mouse' && navigator.userActivation?.hasBeenActive) navigator.vibrate?.(8);
2992
+ }
2993
+
2994
+ /** Puts the carried card under the pointer and the outline where it will land. */
2995
+ function follow() {
2996
+ const { node, grid } = drag;
2997
+ const origin = grid.getBoundingClientRect();
2998
+ const box = layoutBox(node);
2999
+ node.style.translate = `${drag.x - drag.grabX - origin.left - box.left}px ${drag.y - drag.grabY - origin.top - box.top}px`;
3000
+ grid.style.setProperty('--slot-x', `${box.left}px`);
3001
+ grid.style.setProperty('--slot-y', `${box.top}px`);
3002
+ grid.style.setProperty('--slot-w', `${box.right - box.left}px`);
3003
+ grid.style.setProperty('--slot-h', `${box.bottom - box.top}px`);
3004
+ }
3005
+
3006
+ /** Moves the carried card to the place the pointer is over, if that is another card's. */
3007
+ function retarget() {
3008
+ const { node, grid } = drag;
3009
+ const origin = grid.getBoundingClientRect();
3010
+ // The middle of the carried card, not the grip at its corner, picks the place, the same in every direction.
3011
+ const x = drag.x - drag.grabX - origin.left + node.offsetWidth / 2;
3012
+ const y = drag.y - drag.grabY - origin.top + node.offsetHeight / 2;
3013
+ const over = (n) => {
3014
+ const box = layoutBox(n);
3015
+ return x >= box.left && x < box.right && y >= box.top && y < box.bottom;
3016
+ };
3017
+ if (drag.passed && !(drag.passed.isConnected && over(drag.passed))) drag.passed = null;
3018
+ const shown = [...grid.children];
3019
+ let target = shown.find((n) => n !== node && n !== drag.passed && over(n));
3020
+ // Past the last card, beside it or below it, is the last place.
3021
+ const last = shown.at(-1);
3022
+ if (!target && last !== node && last !== drag.passed) {
3023
+ const box = layoutBox(last);
3024
+ if (y >= box.bottom || (y >= box.top && x >= box.right)) target = last;
3025
+ }
3026
+ if (!target) return;
3027
+ const next = moveId(shownIds(), drag.id, shown.indexOf(target));
3028
+ if (sameOrder(next, shownIds())) return;
3029
+ arrange(next);
3030
+ drag.passed = target;
3031
+ }
3032
+
3033
+ /** Scrolls the page while the pointer is near its top or bottom edge. Returns whether it moved. */
3034
+ function autoScroll() {
3035
+ const top = topbar.getBoundingClientRect().bottom;
3036
+ const bottom = window.innerHeight;
3037
+ let pull = 0;
3038
+ if (drag.y < top + SCROLL_EDGE) pull = -(top + SCROLL_EDGE - drag.y) / SCROLL_EDGE;
3039
+ else if (drag.y > bottom - SCROLL_EDGE) pull = (drag.y - bottom + SCROLL_EDGE) / SCROLL_EDGE;
3040
+ const step = Math.round(Math.max(-1, Math.min(1, pull)) * 18);
3041
+ if (!step) return false;
3042
+ const was = window.scrollY;
3043
+ window.scrollBy(0, step);
3044
+ return window.scrollY !== was;
3045
+ }
3046
+
3047
+ function scheduleDragFrame() {
3048
+ if (drag && !drag.frame) drag.frame = requestAnimationFrame(dragFrame);
3049
+ }
3050
+
3051
+ function dragFrame() {
3052
+ drag.frame = 0;
3053
+ const scrolled = autoScroll();
3054
+ retarget();
3055
+ if (!drag) return;
3056
+ follow();
3057
+ if (scrolled) drag.frame = requestAnimationFrame(dragFrame);
3058
+ }
3059
+
3060
+ /** Lets go of the carried card without touching the order, as when its session is removed. */
3061
+ function releaseDrag() {
3062
+ if (!drag) return null;
3063
+ const ended = drag;
3064
+ drag = null;
3065
+ cancelAnimationFrame(ended.frame);
3066
+ // The grid keeps the pointer until the button comes up, which ends the capture: a drag cancelled with
3067
+ // Escape or by leaving the window must not end in a click that opens the card under the pointer.
3068
+ ended.node.style.translate = '';
3069
+ ended.node.classList.remove('dragging');
3070
+ ended.grid.classList.remove('reordering');
3071
+ document.documentElement.classList.remove('reordering');
3072
+ return ended;
3073
+ }
3074
+
3075
+ /** Drops the carried card where it is, or with `keep` false puts every card back. */
3076
+ function endDrag(keep) {
3077
+ if (!drag) return;
3078
+ const { node } = drag;
3079
+ const was = node.getBoundingClientRect();
3080
+ const ended = releaseDrag();
3081
+ if (!keep) arrange(ended.before);
3082
+ // Compare only the sessions there before and after: one created or removed meanwhile is no move.
3083
+ const now = shownIds().filter((id) => ended.start.includes(id));
3084
+ const moved = !sameOrder(now, ended.start.filter((id) => now.includes(id)));
3085
+ if (keep && moved) {
3086
+ saveOrder();
3087
+ announceOrder(ended.id);
3088
+ }
3089
+ node.classList.add('settling');
3090
+ const settle = slideFrom(node, was);
3091
+ const done = () => {
3092
+ if (node.classList.contains('dragging')) return; // picked up again
3093
+ node.classList.remove('settling');
3094
+ node.style.transformOrigin = '';
3095
+ };
3096
+ if (settle) settle.finished.then(done, done);
3097
+ else done();
3098
+ }
3099
+
2785
3100
  // ---- terminal views -------------------------------------------------------
2786
3101
 
2787
3102
  const TERMINAL_THEME = {
@@ -2833,7 +3148,9 @@ class TerminalView {
2833
3148
  this.term.loadAddon(new window.WebLinksAddon.WebLinksAddon((_e, uri) => window.open(uri, '_blank', 'noopener,noreferrer')));
2834
3149
  this.term.attachCustomKeyEventHandler((e) => this.handleKey(e));
2835
3150
  suppressQueryReplies(this.term);
2836
- this.term.onData((data) => this.send({ type: 'input', data }));
3151
+ this.term.onData((data) => {
3152
+ this.send({ type: 'input', data });
3153
+ });
2837
3154
  this.opened = false;
2838
3155
  this.disposed = false;
2839
3156
  this.retry = 0;
@@ -3089,7 +3406,7 @@ function leaveStopping() {
3089
3406
  function restartGaveUp() {
3090
3407
  if (!state.stopping || !state.restarting) return;
3091
3408
  state.restarting = false;
3092
- setConnection('down', 'Session manager stopped');
3409
+ setConnection('down', state.stopRemaining === null ? 'Manager unavailable' : 'Session manager stopped');
3093
3410
  showStopped('stopped', 'The session manager did not come back',
3094
3411
  `Nothing answered within ${Math.round(RESTART_WAIT_MS / 1000)} seconds of the restart. Check manager.log in the Agent Guild data folder, then start it yourself.`);
3095
3412
  }
@@ -3151,14 +3468,26 @@ function showManagerStopped() {
3151
3468
 
3152
3469
  // ---- events ---------------------------------------------------------------
3153
3470
 
3471
+ function showManagerUnavailable() {
3472
+ setConnection('down', 'Manager unavailable. Trying to reconnect…');
3473
+ if (state.stopping) {
3474
+ clearTimeout(restartTimer);
3475
+ showStopped('stopped', 'Manager unavailable', 'The manager stopped responding before confirming shutdown. Sessions may still be running. This page will reconnect automatically.');
3476
+ }
3477
+ }
3478
+
3154
3479
  function connectEvents() {
3480
+ if (state.pageAway) return;
3155
3481
  const ws = new WebSocket(wsUrl('/events'));
3156
3482
  state.eventsSocket = ws;
3157
3483
  ws.onopen = () => {
3484
+ if (state.pageAway || state.eventsSocket !== ws) return;
3485
+ managerLoss.cancel();
3158
3486
  state.eventsRetry = 0;
3159
3487
  setConnection('ok', 'Connected to session manager');
3160
3488
  };
3161
3489
  ws.onmessage = (event) => {
3490
+ if (state.pageAway || state.eventsSocket !== ws) return;
3162
3491
  const msg = JSON.parse(event.data);
3163
3492
  if (msg.type === 'hello') {
3164
3493
  // The manager is back after a stop or restart; the page picks up where it was.
@@ -3171,9 +3500,10 @@ function connectEvents() {
3171
3500
  setConnection('ok', 'Connected to session manager');
3172
3501
  state.sessions = new Map(msg.sessions.map((s) => [s.id, s]));
3173
3502
  for (const id of [...state.views.keys()]) if (!state.sessions.has(id)) dropSession(id);
3503
+ managerConnected(msg);
3174
3504
  renderSessions();
3175
3505
  sessionsShown = true;
3176
- setUpgrade(msg.upgrade);
3506
+ setUpgrade(msg.upgrade, true);
3177
3507
  loadNews();
3178
3508
  // A changelog.updated sent while the socket was down is lost; catch up the open panel.
3179
3509
  if ($('changelog').open) loadChangelog();
@@ -3192,6 +3522,7 @@ function connectEvents() {
3192
3522
  enterStopping(0, msg.restart === true);
3193
3523
  state.stopRemaining = Number(msg.remaining) || 0;
3194
3524
  showManagerStopped();
3525
+ managerGone();
3195
3526
  } else if (msg.type === 'session.created' || msg.type === 'session.updated') {
3196
3527
  upsertSession(msg.session);
3197
3528
  } else if (msg.type === 'session.removed') {
@@ -3204,16 +3535,21 @@ function connectEvents() {
3204
3535
  }
3205
3536
  };
3206
3537
  ws.onclose = () => {
3207
- if (state.stopping) {
3538
+ if (state.pageAway || state.eventsSocket !== ws) return;
3539
+ if (state.managerUnavailable) {
3540
+ showManagerUnavailable();
3541
+ } else if (state.stopping && state.stopRemaining !== null) {
3208
3542
  showManagerStopped();
3209
3543
  } else {
3210
- setConnection('down', 'Session manager not reachable. Run "agent-guild open" to start it.');
3544
+ setConnection('down', 'Connection interrupted. Trying to reconnect…');
3211
3545
  }
3546
+ managerLoss.disconnected();
3212
3547
  // Keep trying: after a stop, a relaunched manager brings the page back by itself.
3213
3548
  const delay = Math.min(5000, 500 * 2 ** state.eventsRetry++);
3214
3549
  setTimeout(async () => {
3550
+ if (state.pageAway || state.eventsSocket !== ws) return;
3215
3551
  try { await loadProviders(); } catch (err) { if (err instanceof AuthError) return showAuth(err.message); }
3216
- connectEvents();
3552
+ if (!state.pageAway && state.eventsSocket === ws) connectEvents();
3217
3553
  }, delay);
3218
3554
  };
3219
3555
  }
@@ -3241,6 +3577,7 @@ let statsInterval;
3241
3577
  let newsTimer;
3242
3578
 
3243
3579
  function showAuth(message = '') {
3580
+ managerLoss.cancel();
3244
3581
  closeModels();
3245
3582
  closeNews();
3246
3583
  closeChangelog();
@@ -3376,6 +3713,16 @@ $('news').addEventListener('close', () => {
3376
3713
  document.addEventListener('visibilitychange', () => {
3377
3714
  if (document.visibilityState === 'visible' && state.connected && Date.now() - newsLoadedAt > 60000) loadNews();
3378
3715
  });
3716
+ addEventListener('pagehide', () => {
3717
+ state.pageAway = true;
3718
+ managerLoss.cancel();
3719
+ state.eventsSocket?.close();
3720
+ });
3721
+ addEventListener('pageshow', (event) => {
3722
+ if (!event.persisted) return;
3723
+ state.pageAway = false;
3724
+ connectEvents();
3725
+ });
3379
3726
  $('version').addEventListener('click', openChangelog);
3380
3727
  $('changelog-close').addEventListener('click', closeChangelog);
3381
3728
  $('changelog').addEventListener('click', (e) => { if (e.target === $('changelog')) closeChangelog(); });
@@ -3392,7 +3739,41 @@ $('changelog-restart').addEventListener('click', () => {
3392
3739
  closeChangelog();
3393
3740
  stopManager({ restart: true });
3394
3741
  });
3395
- addEventListener('storage', (e) => { if (e.key === CHANGELOG_SEEN_KEY) renderVersion(); });
3742
+ addEventListener('storage', (e) => {
3743
+ if (e.key === CHANGELOG_SEEN_KEY) renderVersion();
3744
+ else if (e.key === SOUND_KEY) { $('sound').checked = soundOn(); prepareSounds(); }
3745
+ // Another tab reordered the cards.
3746
+ else if (e.key === SESSION_ORDER_KEY && !drag) arrange(parseOrder(e.newValue));
3747
+ });
3748
+ $('sessions').addEventListener('pointerdown', (e) => {
3749
+ const handle = e.target.closest?.('.drag-handle');
3750
+ if (handle) startDrag(e, handle);
3751
+ });
3752
+ $('sessions').addEventListener('pointermove', (e) => {
3753
+ if (drag?.pointerId !== e.pointerId) return;
3754
+ // A mouse released outside the window sends no pointerup.
3755
+ if (e.pointerType === 'mouse' && !(e.buttons & 1)) return endDrag(true);
3756
+ drag.x = e.clientX;
3757
+ drag.y = e.clientY;
3758
+ scheduleDragFrame();
3759
+ });
3760
+ $('sessions').addEventListener('pointerup', (e) => { if (drag?.pointerId === e.pointerId) endDrag(true); });
3761
+ $('sessions').addEventListener('pointercancel', (e) => { if (drag?.pointerId === e.pointerId) endDrag(false); });
3762
+ // The loss from the last drop can arrive after a quick new pickup, which holds the capture again.
3763
+ $('sessions').addEventListener('lostpointercapture', (e) => {
3764
+ if (drag?.pointerId === e.pointerId && !drag.grid.hasPointerCapture(e.pointerId)) endDrag(true);
3765
+ });
3766
+ // A long press on the grip would open the context menu on a touch screen.
3767
+ $('sessions').addEventListener('contextmenu', (e) => { if (e.target.closest?.('.drag-handle')) e.preventDefault(); });
3768
+ // The page can move under a carried card without the pointer moving.
3769
+ addEventListener('scroll', scheduleDragFrame, { passive: true });
3770
+ addEventListener('resize', scheduleDragFrame);
3771
+ document.addEventListener('keydown', (e) => {
3772
+ if (!drag || e.key !== 'Escape') return;
3773
+ e.preventDefault();
3774
+ endDrag(false);
3775
+ });
3776
+ addEventListener('blur', () => endDrag(false));
3396
3777
  $('models-more').addEventListener('click', () => {
3397
3778
  const before = $('models-list').childElementCount;
3398
3779
  modelsView.all = true;
@@ -3406,6 +3787,7 @@ $('upgrade').addEventListener('click', upgradeManager);
3406
3787
  $('appearance-menu').addEventListener('change', (e) => {
3407
3788
  if (e.target.name === 'skin') changeSkin(e.target);
3408
3789
  else if (e.target.name === 'theme') changeTheme(e.target);
3790
+ else if (e.target.name === 'sound') changeSound(e.target);
3409
3791
  });
3410
3792
  $('appearance-menu').addEventListener('toggle', (e) => {
3411
3793
  if (e.newState !== 'open') return;
@@ -3439,6 +3821,7 @@ $('providers').addEventListener('pointerout', (e) => {
3439
3821
  });
3440
3822
  applyTheme(currentTheme());
3441
3823
  renderSkinChoices();
3824
+ $('sound').checked = soundOn();
3442
3825
  // Follow the system setting until the user picks a theme.
3443
3826
  window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', (e) => {
3444
3827
  if (!load(THEME_KEY)) applyTheme(e.matches ? 'dark' : 'light');
package/web/index.html CHANGED
@@ -36,6 +36,10 @@
36
36
  <label class="choice"><input type="radio" name="theme" value="light"><span class="mode-light">Light</span></label>
37
37
  <label class="choice"><input type="radio" name="theme" value="dark"><span class="mode-dark">Dark</span></label>
38
38
  </fieldset>
39
+ <fieldset>
40
+ <legend>Sound</legend>
41
+ <label class="choice" title="Plays when the session manager stops, remains unavailable after a brief recovery check, or a new Agent Guild update is discovered. Click or type on this page to allow playback."><input id="sound" type="checkbox" name="sound"><span class="sound-alerts">Alert sounds</span></label>
42
+ </fieldset>
39
43
  </div>
40
44
  <button id="restart-manager" class="btn restart-manager" type="button" hidden>Restart manager</button>
41
45
  <button id="stop-manager" class="btn danger stop-manager" type="button" hidden title="Stop the session manager. This ends every session.">Stop manager</button>
@@ -62,6 +66,8 @@
62
66
  <h2 id="sessions-heading">Sessions <span id="session-count" class="count"></span></h2>
63
67
  </div>
64
68
  <div id="sessions" class="session-grid" role="list"></div>
69
+ <p id="reorder-status" class="visually-hidden" aria-live="polite"></p>
70
+ <p id="reorder-help" hidden>Drag to move the card, or press the arrow keys, Home or End.</p>
65
71
  <p id="empty" class="empty">No sessions yet. Pick a provider above to start one. Sessions keep running when you close this page.</p>
66
72
  </section>
67
73
 
@@ -346,7 +352,10 @@
346
352
  <div class="card-top">
347
353
  <span class="provider-icon"></span>
348
354
  <div class="card-title">
349
- <div class="name"></div>
355
+ <div class="name-row">
356
+ <div class="name"></div>
357
+ <button class="drag-handle" type="button" title="Drag to move this card, or use the arrow keys" aria-describedby="reorder-help"></button>
358
+ </div>
350
359
  <div class="meta"><span class="meta-text"></span><button class="session-id" type="button" hidden></button></div>
351
360
  </div>
352
361
  <button class="model-pill" type="button"></button>
@@ -147,6 +147,8 @@
147
147
  .session-card .card-top > .provider-icon { display: none; }
148
148
  .session-card .card-top { flex-wrap: wrap; gap: 6px 8px; }
149
149
  .session-card .card-title { flex-basis: 100%; }
150
+ /* The reorder grip steps out from under the corner sprig. */
151
+ .session-card .drag-handle { margin-right: 24px; }
150
152
 
151
153
  /* Working: ripples spread across still water beneath the spirit while fireflies rise around it. */
152
154
  .card-aura { position: absolute; z-index: -1; overflow: hidden; pointer-events: none; opacity: 0; transition: opacity 0.9s var(--ease); }
@@ -0,0 +1,22 @@
1
+
2
+
3
+ Interface Sounds (1.0)
4
+
5
+ Created/distributed by Kenney (www.kenney.nl)
6
+ Creation date: 11-02-2020
7
+
8
+ ------------------------------
9
+
10
+ License: (Creative Commons Zero, CC0)
11
+ http://creativecommons.org/publicdomain/zero/1.0/
12
+
13
+ This content is free to use in personal, educational and commercial projects.
14
+ Support us by crediting Kenney or www.kenney.nl (this is not mandatory)
15
+
16
+ ------------------------------
17
+
18
+ Donate: http://support.kenney.nl
19
+ Patreon: http://patreon.com/kenney/
20
+
21
+ Follow on Twitter for updates:
22
+ http://twitter.com/KenneyNL
Binary file
Binary file
package/web/styles.css CHANGED
@@ -22,11 +22,13 @@
22
22
  --font-mono: ui-monospace, "Cascadia Code", "SF Mono", Menlo, Consolas, monospace;
23
23
  --icon-sun: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round'%3E%3Ccircle cx='12' cy='12' r='4.2'/%3E%3Cpath d='M12 2.5v2.2M12 19.3v2.2M2.5 12h2.2M19.3 12h2.2M5.3 5.3l1.6 1.6M17.1 17.1l1.6 1.6M5.3 18.7l1.6-1.6M17.1 6.9l1.6-1.6'/%3E%3C/svg%3E");
24
24
  --icon-moon: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linejoin='round'%3E%3Cpath d='M19.5 14.6A8 8 0 0 1 9.4 4.5a8 8 0 1 0 10.1 10.1z'/%3E%3C/svg%3E");
25
- --icon-external: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2.2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M7 17L17 7M9 7h8v8'/%3E%3C/svg%3E");
25
+ --icon-sound: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M4 9.5h3.5L12 5.5v13l-4.5-4H4z'/%3E%3Cpath d='M15.5 9a4.2 4.2 0 0 1 0 6M18.3 6.5a8 8 0 0 1 0 11'/%3E%3C/svg%3E");
26
+ --icon-external:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2.2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M7 17L17 7M9 7h8v8'/%3E%3C/svg%3E");
26
27
  --icon-cloud: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linejoin='round'%3E%3Cpath d='M7 18.5h10.5a4 4 0 0 0 .6-7.96A6 6 0 0 0 6.5 9.6 4.5 4.5 0 0 0 7 18.5z'/%3E%3C/svg%3E");
27
28
  --icon-chevron:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2.4' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M9 5.5l6.5 6.5L9 18.5'/%3E%3C/svg%3E");
28
29
  --icon-github: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Cpath d='M8 0c4.42 0 8 3.58 8 8a8.013 8.013 0 0 1-5.45 7.59c-.4.08-.55-.17-.55-.38 0-.27.01-1.13.01-2.2 0-.75-.25-1.23-.54-1.48 1.78-.2 3.65-.88 3.65-3.95 0-.88-.31-1.59-.82-2.15.08-.2.36-1.02-.08-2.12 0 0-.67-.22-2.2.82-.64-.18-1.32-.27-2-.27-.68 0-1.36.09-2 .27-1.53-1.03-2.2-.82-2.2-.82-.44 1.1-.16 1.92-.08 2.12-.51.56-.82 1.28-.82 2.15 0 3.06 1.86 3.75 3.64 3.95-.23.2-.44.55-.51 1.07-.46.21-1.61.55-2.33-.66-.15-.24-.6-.83-1.23-.82-.67.01-.27.38.01.53.34.19.73.9.82 1.13.16.45.68 1.31 2.69.94 0 .67.01 1.3.01 1.49 0 .21-.15.45-.55.38A7.995 7.995 0 0 1 0 8c0-4.42 3.58-8 8-8Z'/%3E%3C/svg%3E");
29
30
  --icon-info: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='1.8' stroke-linecap='round'%3E%3Ccircle cx='12' cy='12' r='10'/%3E%3Cpath d='M12 10.8v6.2' stroke-width='2.4'/%3E%3Ccircle cx='12' cy='7.3' r='1.2' fill='black' stroke='none'/%3E%3C/svg%3E");
31
+ --icon-grip: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='black'%3E%3Ccircle cx='9' cy='5.5' r='1.9'/%3E%3Ccircle cx='15' cy='5.5' r='1.9'/%3E%3Ccircle cx='9' cy='12' r='1.9'/%3E%3Ccircle cx='15' cy='12' r='1.9'/%3E%3Ccircle cx='9' cy='18.5' r='1.9'/%3E%3Ccircle cx='15' cy='18.5' r='1.9'/%3E%3C/svg%3E");
30
32
  font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
31
33
  font-size: 15px;
32
34
  color-scheme: light;
@@ -263,6 +265,40 @@ main { max-width: 1200px; margin: 0 auto; padding: 26px 16px 56px; display: grid
263
265
  }
264
266
  .status-pill.exited { color: var(--danger); }
265
267
 
268
+ /* Reordering. The grip ends the name line without making it taller; its hit area grows for a finger. */
269
+ .name-row { display: flex; align-items: center; gap: 6px; min-width: 0; }
270
+ .name-row .name { flex: 1; min-width: 0; }
271
+ .drag-handle {
272
+ position: relative; flex: none; align-self: stretch; display: grid; place-items: center; width: 24px; margin-right: -4px; padding: 0;
273
+ border: 0; border-radius: 6px; background: none; color: var(--muted); font: inherit; cursor: grab; opacity: 0.85;
274
+ touch-action: none; user-select: none; -webkit-user-select: none; -webkit-touch-callout: none; -webkit-tap-highlight-color: transparent;
275
+ transition: opacity 0.15s, color 0.15s, background-color 0.15s;
276
+ }
277
+ .drag-handle::before { content: ""; width: 16px; height: 16px; background: currentColor; mask: var(--icon-grip) center / contain no-repeat; }
278
+ /* Grown up and sideways, never down over the id chip on the line below. */
279
+ .drag-handle::after { content: ""; position: absolute; inset: -20px -10px 0; }
280
+ .session-card:hover .drag-handle, .session-card:focus-within .drag-handle { opacity: 1; }
281
+ .drag-handle:hover { color: var(--text); background: color-mix(in srgb, var(--text) 9%, transparent); }
282
+ .drag-handle:focus-visible { outline: 2px solid var(--focus); outline-offset: 1px; opacity: 1; }
283
+ /* While a card is carried, the grid's own outline marks the place it will land. */
284
+ .session-grid { position: relative; }
285
+ .session-grid::before {
286
+ content: ""; position: absolute; left: 0; top: 0; width: var(--slot-w, 0); height: var(--slot-h, 0); translate: var(--slot-x, 0) var(--slot-y, 0);
287
+ border: 2px dashed color-mix(in srgb, var(--accent) 60%, transparent); border-radius: var(--radius);
288
+ background: color-mix(in srgb, var(--accent) 9%, transparent); opacity: 0; pointer-events: none; transition: opacity 0.2s var(--ease);
289
+ }
290
+ .session-grid.reordering::before { opacity: 1; transition: opacity 0.2s var(--ease), translate 0.24s var(--ease); }
291
+ .session-grid > .session-card.dragging, .session-grid > .session-card.settling { z-index: 4; transform: none; }
292
+ .session-grid > .session-card.dragging {
293
+ scale: 1.03; cursor: grabbing;
294
+ box-shadow: 0 24px 48px -14px rgb(0 0 0 / 0.5), 0 0 0 1.5px color-mix(in srgb, var(--accent) 60%, transparent);
295
+ transition: scale 0.18s var(--ease), box-shadow 0.18s var(--ease);
296
+ }
297
+ .session-grid > .session-card.settling { transition: scale 0.24s var(--ease), box-shadow 0.24s var(--ease); }
298
+ .session-grid > .session-card.dragging .drag-handle { opacity: 1; color: var(--text); cursor: grabbing; }
299
+ :root.reordering, :root.reordering * { cursor: grabbing; user-select: none; -webkit-user-select: none; }
300
+ .visually-hidden { position: absolute; width: 1px; height: 1px; margin: -1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
301
+
266
302
  .agents-row { display: flex; align-items: center; gap: 10px; min-height: 30px; }
267
303
  .agents-label { font-size: 0.75rem; color: var(--muted); }
268
304
  .agents { display: flex; flex-wrap: wrap; gap: 6px; }
@@ -306,6 +342,7 @@ main { max-width: 1200px; margin: 0 auto; padding: 26px 16px 56px; display: grid
306
342
  background: currentColor; mask: var(--icon-sun) center / contain no-repeat;
307
343
  }
308
344
  :root[data-theme="dark"] .appearance::before, .appearance-menu .choice > .mode-dark::before { mask-image: var(--icon-moon); }
345
+ .appearance-menu .choice > .sound-alerts::before { mask-image: var(--icon-sound); }
309
346
  .appearance::after { margin-left: 0.45em; transform: rotate(90deg); transition: transform 0.15s var(--ease); }
310
347
  .appearance:has(+ :popover-open)::after { transform: rotate(-90deg); }
311
348
  .appearance-menu {
@@ -637,6 +674,8 @@ a.news-link:visited { color: var(--muted); }
637
674
  .model-name::before, .provider .copies summary::before, .appearance::after, .btn, .models, .news-panel, .models::backdrop, .news-panel::backdrop { transition: none; }
638
675
  .github-waiting::before, body:has(#app[hidden]):has(#auth[hidden]):has(#stopped[hidden])::after { animation: none; }
639
676
  .btn:active:not(:disabled) { transform: none; }
677
+ .drag-handle, .session-grid::before, .session-grid.reordering::before,
678
+ .session-grid > .session-card.dragging, .session-grid > .session-card.settling { transition: none; }
640
679
  }
641
680
 
642
681
  @media (max-width: 640px) {