@songsid/agend 2.2.0-alpha.7 → 2.2.0-alpha.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. package/dist/ansi-faint.d.ts +25 -0
  2. package/dist/ansi-faint.js +90 -0
  3. package/dist/ansi-faint.js.map +1 -0
  4. package/dist/auth-api.js +1 -1
  5. package/dist/auth-api.js.map +1 -1
  6. package/dist/fleet-manager.d.ts +13 -0
  7. package/dist/fleet-manager.js +118 -28
  8. package/dist/fleet-manager.js.map +1 -1
  9. package/dist/general-knowledge/skills/fleet-health/SKILL.md +11 -2
  10. package/dist/general-knowledge/skills/instance-lifecycle/SKILL.md +2 -1
  11. package/dist/locale.js +4 -0
  12. package/dist/locale.js.map +1 -1
  13. package/dist/outbound-handlers.js +8 -1
  14. package/dist/outbound-handlers.js.map +1 -1
  15. package/dist/tz-utils.d.ts +6 -0
  16. package/dist/tz-utils.js +23 -0
  17. package/dist/tz-utils.js.map +1 -1
  18. package/dist/ui/chat-render.js +29 -1
  19. package/dist/ui/chat-strings.js +2 -0
  20. package/dist/ui/chat-thread.js +128 -3
  21. package/dist/ui/panel-chat.js +4 -13
  22. package/dist/ui/panel-settings.js +54 -5
  23. package/dist/ui/settings-strings.js +2 -0
  24. package/dist/ui/shared/app-i18n.js +4 -4
  25. package/dist/ui/shared/app-route.js +1 -1
  26. package/dist/ui/shared/app-shell.js +30 -15
  27. package/dist/ui/shared/app-stream.js +186 -43
  28. package/dist/ui/shared/app-swipe.js +89 -0
  29. package/dist/ui/shared/app.css +32 -1
  30. package/dist/ui/shared/app.js +15 -1
  31. package/dist/ui/shared/panel-view.js +21 -8
  32. package/dist/ui/shared/usage-dialog.js +16 -1
  33. package/dist/ui/shared/view-strings.js +10 -0
  34. package/dist/usage/usage-api.d.ts +8 -1
  35. package/dist/usage/usage-api.js +100 -31
  36. package/dist/usage/usage-api.js.map +1 -1
  37. package/dist/web-api.d.ts +1 -1
  38. package/dist/web-api.js +64 -3
  39. package/dist/web-api.js.map +1 -1
  40. package/dist/web-host-guard.d.ts +2 -0
  41. package/dist/web-host-guard.js +7 -1
  42. package/dist/web-host-guard.js.map +1 -1
  43. package/dist/web-shell-routes.d.ts +1 -1
  44. package/dist/web-shell-routes.js +2 -1
  45. package/dist/web-shell-routes.js.map +1 -1
  46. package/dist/web-upload.d.ts +17 -0
  47. package/dist/web-upload.js +38 -7
  48. package/dist/web-upload.js.map +1 -1
  49. package/package.json +1 -1
@@ -14,21 +14,53 @@
14
14
  // applied they are replayed in arrival order, so an answer or a new prompt that came meanwhile is never undone by the
15
15
  // older snapshot. A failed read is retried a few times with a growing delay, and then waits for the person (Retry);
16
16
  // the state is told as "hydration" events. Polls never overlap: a poll asked for while one runs follows it.
17
+ //
18
+ // Reconnecting (#1580): a fleet restart drops every open tab's stream at once, and used to have each tab retry through
19
+ // the browser's own EventSource loop (about every 3 s) AND poll every 5 s, both forever, hidden or not — through a
20
+ // relay (WSL2's wslrelay, a tunnel) that is dozens of fresh connections per tab per minute. Now this file owns it:
21
+ // - a dropped stream is closed (no browser retry) and reopened on a capped exponential backoff with jitter (about
22
+ // 1, 2, 4, 8, 16, then 30 s; each 50–100 % of its step). One EventSource at a time, ever.
23
+ // - a stream that fails before it speaks is followed by ONE /ui/poll probe: answered → the fleet is up and only the
24
+ // stream is broken (a buffering proxy): poll every 5 s as before, and keep retrying the stream on the backoff; not
25
+ // answered → "reconnecting", and no poll loop at all. The public link's poll loop backs off the same way.
26
+ // - a step due while the tab is hidden waits until it is shown again (a live stream stays open: Needs you).
27
+ // - every reconnect catches up once the new stream speaks (catchUp(), with the page's cursor — #1566/#1578), so what
28
+ // was said meanwhile is shown, once.
29
+ // - a 401 is the session ending: nothing is retried (agend-auth.js says so).
30
+ // - suspend()/resume(): the page is leaving (pagehide) / came back from the back-forward cache.
17
31
 
18
32
  const EVENTS = ["status", "message", "delivery", "deliveries", "prompt", "prompts", "prompt_resolved", "activity", "needs", "reply_buttons"];
19
33
  const MAX_HELD = 1000; // live events held during one catch-up read; more → read again afterwards
20
34
  const RETRY_DELAYS = [1000, 2000, 4000]; // after the first failed catch-up read; then it waits for Retry
21
35
  const POLL_LIMIT_MS = 10000; // a poll that has not answered by then counts as failed
22
36
  const MAX_OVERFLOW_READS = 3; // reads again after dropped live events, within one catch-up
37
+ // #1580: the reconnect backoff — step n waits min(cap, base·2ⁿ), drawn from 50–100 % of that (jitter: tabs that lost
38
+ // the fleet together do not come back together).
39
+ export const RECONNECT = { baseMs: 1000, capMs: 30000 };
40
+ export function reconnectDelay(step, random = Math.random) {
41
+ const full = Math.min(RECONNECT.capMs, RECONNECT.baseMs * 2 ** Math.min(step, 20));
42
+ return Math.round(full * (0.5 + random() * 0.5));
43
+ }
23
44
 
24
45
  export function createStream(opts) {
25
46
  const env = opts.env || globalThis;
26
47
  const listeners = new Map(); // event name → Set of handlers
27
48
  const state = { connection: "connecting", cursor: "", hydration: "none" };
28
- let sse = null, pollTimer = null, silentTimer = null, errorTimer = null, closed = false, started = false;
49
+ let sse = null, pollTimer = null, silentTimer = null, closed = false, started = false;
50
+ // #1580: the reconnect step waiting, how many failed in a row, a step waiting for the tab to be shown, a page that left.
51
+ let reconnectTimer = null, attempts = 0, waitingVisible = false, suspended = false, ended = false, retryAt = 0;
52
+ const random = typeof env.random === "function" ? env.random : Math.random;
53
+ const doc = env.document || null;
54
+ const hidden = () => !!(doc && doc.hidden);
55
+ const now = () => (env.performance && typeof env.performance.now === "function" ? env.performance.now() : Date.now());
29
56
  let polling = null, pollAgain = false, pollSeq = 0; // the poll in flight, whether another was asked for meanwhile
30
57
  let held = null; // live events held while a catch-up read is in flight
31
- let catching = null, retryTimer = null;
58
+ let catching = null, retryTimer = null, retryWake = null;
59
+ // #1580 review: who owns the work in flight. `readEpoch` moves when the page lets everything go (suspend, end,
60
+ // close): a read, a catch-up or a queued poll begun before it neither emits, nor releases, nor queues, nor decides
61
+ // anything. `epoch` also moves when the stream comes back live: a fallback read's or a probe's late failure must not
62
+ // turn a healthy stream into "reconnecting" — those control decisions belong to the epoch they started in.
63
+ let readEpoch = 0, epoch = 0;
32
64
 
33
65
  function emit(name, data, extra) {
34
66
  const set = listeners.get(name);
@@ -69,12 +101,14 @@ export function createStream(opts) {
69
101
  */
70
102
  function pollOnce(opts = {}) {
71
103
  if (polling) { if (!opts.join) pollAgain = true; return polling; }
72
- const mine = ++pollSeq;
104
+ const mine = ++pollSeq, ep = readEpoch;
105
+ let status = 0; // this read's HTTP status — its own, never another read's
73
106
  if (sse && !held) held = [];
74
107
  let limit = null;
75
108
  const read = (async () => {
76
109
  try {
77
110
  const r = await env.fetch(`/ui/poll?after=${encodeURIComponent(state.cursor)}`, { cache: "no-store" });
111
+ status = r.status | 0;
78
112
  if (closed || mine !== pollSeq || !r.ok) return false;
79
113
  const d = await r.json();
80
114
  if (closed || mine !== pollSeq) return false;
@@ -89,65 +123,168 @@ export function createStream(opts) {
89
123
  } catch { return false; /* the next tick, or the catch-up's retry, tries again */ }
90
124
  })();
91
125
  const timedOut = new Promise((resolve) => { limit = env.setTimeout(() => { if (mine === pollSeq) pollSeq++; resolve(false); }, POLL_LIMIT_MS); });
92
- polling = Promise.race([read, timedOut]).then((ok) => {
126
+ const p = polling = Promise.race([read, timedOut]).then((ok) => {
93
127
  env.clearTimeout(limit);
128
+ // Let go of (suspend, end, close) while it was out: it decides nothing — and touches nothing that is newer.
129
+ if (ep !== readEpoch) return { ok: false, overflow: false, status, stale: true };
94
130
  if (mine === pollSeq) pollSeq++; // whatever this read still does from here on is ignored
95
- polling = null;
96
- if (closed) { held = null; return { ok: false, overflow: false }; }
131
+ if (polling === p) polling = null;
132
+ if (closed) { held = null; return { ok: false, overflow: false, status }; }
133
+ // #1580: the session ended, whichever read heard it — everything stops here, queued work included.
134
+ if (status === 401) { held = null; end(); return { ok: false, overflow: false, status }; }
97
135
  const overflow = release();
98
136
  // Live events were dropped: what they changed is recovered by catching up — inside its lifecycle, so a failed
99
137
  // recovery read is retried and shown, never taken for done (#1425 review). A catch-up awaiting this read loops.
100
138
  if (overflow && !catching) catchUp();
101
139
  if (pollAgain) { pollAgain = false; pollOnce(); }
102
- return { ok, overflow };
140
+ return { ok, overflow, status };
103
141
  });
104
- return polling;
142
+ return p;
105
143
  }
106
- function startPolling() {
107
- if (pollTimer || closed) return;
144
+ /** #1580 review: the page lets go of everything in flight — reads, the queued poll, the catch-up and its retry wait. */
145
+ function cancelWork() {
146
+ readEpoch++; epoch++;
147
+ pollSeq++; polling = null; pollAgain = false; held = null;
148
+ env.clearTimeout(retryTimer); retryTimer = null;
149
+ const wake = retryWake; retryWake = null;
150
+ catching = null;
151
+ if (wake) wake(); // the old catch-up settles (false) instead of waiting forever
152
+ }
153
+ /** The 5 s poll: the public link's transport, and a reachable fleet whose stream does not get through. */
154
+ function startPolling(pollNow = true) {
155
+ if (pollTimer || closed || suspended || ended) return;
108
156
  setConnection("polling");
109
- pollOnce();
110
- pollTimer = env.setInterval(pollOnce, 5000);
157
+ if (pollNow) pollTick();
158
+ pollTimer = env.setInterval(pollTick, 5000);
159
+ }
160
+ async function pollTick() {
161
+ const ep = epoch;
162
+ const r = await pollOnce();
163
+ if (closed || suspended || ended || ep !== epoch || r.stale || r.ok || r.overflow) return;
164
+ // Not answered: the fleet is not there (a restart). The loop stops — no poll storm — and the backoff takes over.
165
+ stopPolling();
166
+ setConnection("reconnecting");
167
+ scheduleStep();
111
168
  }
112
169
  function stopPolling() { if (pollTimer) { env.clearInterval(pollTimer); pollTimer = null; } }
113
170
  function alive() {
171
+ if (state.connection !== "live") epoch++; // back live: earlier fallback/probe failures decide nothing now
114
172
  stopPolling();
115
- env.clearTimeout(errorTimer); errorTimer = null;
173
+ attempts = 0;
174
+ env.clearTimeout(reconnectTimer); reconnectTimer = null; waitingVisible = false;
116
175
  env.clearTimeout(silentTimer); silentTimer = env.setTimeout(startPolling, 30000);
117
176
  setConnection("live");
118
177
  }
119
178
 
179
+ // ── #1580: reconnecting ──
180
+ function closeStream() {
181
+ if (sse) { sse.onerror = null; try { sse.close(); } catch { /* already closed */ } }
182
+ sse = null;
183
+ env.clearTimeout(silentTimer); silentTimer = null;
184
+ }
185
+ /** Open the one EventSource. `again`: a reconnect — once it speaks, catch up on what was said while it was away. */
186
+ function openStream(again) {
187
+ closeStream();
188
+ const es = sse = new env.EventSource("/ui/events");
189
+ let spoke = false;
190
+ silentTimer = env.setTimeout(startPolling, 15000); // the server sends a status frame the moment a stream opens
191
+ for (const name of EVENTS) {
192
+ es.addEventListener(name, (e) => {
193
+ if (es !== sse) return; // a stream this page already let go of
194
+ let data;
195
+ try { data = JSON.parse(e.data); } catch { return; }
196
+ if (!spoke) { spoke = true; alive(); if (again) catchUp(); }
197
+ else alive();
198
+ live(name, data, name === "message" ? e.lastEventId : null);
199
+ });
200
+ }
201
+ es.onerror = () => {
202
+ if (es !== sse) return;
203
+ closeStream(); // never the browser's own retry loop
204
+ if (closed || suspended || ended) return;
205
+ if (spoke) { setConnection("reconnecting"); scheduleStep(); return; }
206
+ probe();
207
+ };
208
+ }
209
+ /** The stream failed before it spoke: is the fleet there at all? One poll says. */
210
+ async function probe() {
211
+ if (pollTimer) { scheduleStep(); return; } // already polling: that loop is the probe
212
+ const ep = epoch;
213
+ const r = await pollOnce({ join: true });
214
+ if (closed || suspended || ended || ep !== epoch || r.stale) return;
215
+ if (r.ok) startPolling(false); // reachable, the stream does not get through: poll meanwhile
216
+ else setConnection("reconnecting");
217
+ scheduleStep();
218
+ }
219
+ function scheduleStep() {
220
+ if (reconnectTimer || closed || suspended || ended) return;
221
+ const ms = reconnectDelay(attempts++, random);
222
+ retryAt = now() + ms;
223
+ emit("retry", { inMs: ms });
224
+ reconnectTimer = env.setTimeout(() => { reconnectTimer = null; step(); }, ms);
225
+ }
226
+ function step() {
227
+ if (closed || suspended || ended) return;
228
+ if (hidden()) { waitingVisible = true; return; } // not while nobody looks: the next step waits to be shown
229
+ if (opts.transport === "poll") { pollProbe(); return; }
230
+ openStream(true);
231
+ }
232
+ /** The public link's reconnect step: one poll; answered → the 5 s loop again (it just read), else the next step. */
233
+ async function pollProbe() {
234
+ const ep = epoch;
235
+ const r = await pollOnce();
236
+ if (closed || suspended || ended || ep !== epoch || r.stale) return;
237
+ if (r.ok) { attempts = 0; startPolling(false); }
238
+ else scheduleStep();
239
+ }
240
+ function onVisible() {
241
+ if (!hidden() && waitingVisible) { waitingVisible = false; step(); }
242
+ }
243
+ function end() {
244
+ if (ended) return;
245
+ ended = true;
246
+ cancelWork();
247
+ closeStream(); stopPolling();
248
+ env.clearTimeout(reconnectTimer); reconnectTimer = null;
249
+ setConnection("ended");
250
+ }
251
+
120
252
  /**
121
253
  * Read what is current now, once, ordered against the live stream (see the top). Resolves true once a read was
122
254
  * applied. While retries are spent it resolves false and the state stays "failed" until catchUp() is called again.
123
255
  * It opens no transport: still the one EventSource, and no polling loop.
124
256
  */
125
257
  function catchUp() {
126
- if (closed) return Promise.resolve(false);
258
+ if (closed || ended) return Promise.resolve(false);
127
259
  if (catching) return catching;
128
260
  env.clearTimeout(retryTimer); retryTimer = null;
129
261
  setHydration("catching");
130
- catching = (async () => {
262
+ const ep = readEpoch;
263
+ // Settles this catch-up; the page's current one is cleared only if it is still this one (a resume may have
264
+ // started a newer one meanwhile).
265
+ const done = (value, hydration) => { if (catching === me) { catching = null; if (hydration) setHydration(hydration); } return value; };
266
+ const me = (async () => {
131
267
  let failures = 0, overflows = 0;
132
268
  for (;;) {
133
269
  // A poll already on its way will do (its answer reaches the listener that just attached); nothing is queued.
134
270
  const { ok, overflow } = await pollOnce({ join: true });
135
- if (closed) return false;
136
- if (ok && !overflow) { catching = null; setHydration("ok"); return true; }
271
+ if (closed || ep !== readEpoch) return done(false); // let go of (suspend, end — a 401 — or close): no retry
272
+ if (ok && !overflow) return done(true, "ok");
137
273
  if (ok) {
138
274
  // Read, but live events were dropped meanwhile: not done until a read without that succeeds. At once (nothing
139
275
  // failed), and bounded: a stream that floods every read gives up as failed, with Retry.
140
- if (++overflows > MAX_OVERFLOW_READS) { catching = null; setHydration("failed"); return false; }
276
+ if (++overflows > MAX_OVERFLOW_READS) return done(false, "failed");
141
277
  continue;
142
278
  }
143
- if (failures >= RETRY_DELAYS.length) { catching = null; setHydration("failed"); return false; }
279
+ if (failures >= RETRY_DELAYS.length) return done(false, "failed");
144
280
  setHydration("retrying");
145
- await new Promise((resolve) => { retryTimer = env.setTimeout(resolve, RETRY_DELAYS[failures++]); });
146
- retryTimer = null;
147
- if (closed) return false;
281
+ await new Promise((resolve) => { retryWake = resolve; retryTimer = env.setTimeout(resolve, RETRY_DELAYS[failures++]); });
282
+ retryTimer = null; retryWake = null;
283
+ if (closed || ep !== readEpoch) return done(false);
148
284
  }
149
285
  })();
150
- return catching;
286
+ catching = me;
287
+ return me;
151
288
  }
152
289
 
153
290
  /** Open the transport. Called once every listener that must see the first frames is attached (the chat's store):
@@ -155,24 +292,10 @@ export function createStream(opts) {
155
292
  function start() {
156
293
  if (started || closed) return;
157
294
  started = true;
295
+ if (opts.mode === "full" && doc && typeof doc.addEventListener === "function") doc.addEventListener("visibilitychange", onVisible);
158
296
  if (opts.mode === "full" && opts.transport === "poll") startPolling();
159
- else if (opts.mode === "full") {
160
- sse = new env.EventSource("/ui/events");
161
- silentTimer = env.setTimeout(startPolling, 15000); // the server sends a status frame the moment a stream opens
162
- for (const name of EVENTS) {
163
- sse.addEventListener(name, (e) => {
164
- alive();
165
- let data;
166
- try { data = JSON.parse(e.data); } catch { return; }
167
- live(name, data, name === "message" ? e.lastEventId : null);
168
- });
169
- }
170
- sse.onerror = () => {
171
- if (state.connection === "live") setConnection("down");
172
- // Armed once, not re-armed: the browser retries every few seconds and each retry fires this again.
173
- if (!errorTimer) errorTimer = env.setTimeout(startPolling, 5000);
174
- };
175
- } else setConnection("none");
297
+ else if (opts.mode === "full") openStream(false);
298
+ else setConnection("none");
176
299
  }
177
300
 
178
301
  return {
@@ -189,13 +312,33 @@ export function createStream(opts) {
189
312
  hydration: () => state.hydration,
190
313
  cursor: () => state.cursor,
191
314
  usesEventSource: () => sse !== null,
315
+ /** #1580: ms until the next reconnect step (0 when none is waiting). */
316
+ retryIn: () => (reconnectTimer ? Math.max(0, retryAt - now()) : 0),
317
+ /** #1580: the page is leaving (pagehide): the stream and every timer go — nothing is left half-open. */
318
+ suspend() {
319
+ if (closed || !started || suspended) return;
320
+ suspended = true;
321
+ cancelWork();
322
+ closeStream(); stopPolling();
323
+ env.clearTimeout(reconnectTimer); reconnectTimer = null; waitingVisible = false;
324
+ },
325
+ /** #1580: back from the back-forward cache (pageshow, persisted): reconnect at once and catch up. */
326
+ resume() {
327
+ if (closed || !suspended || ended) return;
328
+ suspended = false;
329
+ epoch++;
330
+ attempts = 0;
331
+ if (opts.transport === "poll") { startPolling(); return; }
332
+ openStream(true);
333
+ },
192
334
  /** For tests and page unload: nothing is emitted after this. */
193
335
  close() {
194
336
  closed = true;
195
- held = null;
337
+ cancelWork();
196
338
  stopPolling();
197
- env.clearTimeout(silentTimer); env.clearTimeout(errorTimer); env.clearTimeout(retryTimer);
198
- if (sse) sse.close();
339
+ closeStream();
340
+ env.clearTimeout(reconnectTimer); env.clearTimeout(retryTimer);
341
+ if (doc && typeof doc.removeEventListener === "function") doc.removeEventListener("visibilitychange", onVisible);
199
342
  },
200
343
  _pollOnce: pollOnce,
201
344
  };
@@ -0,0 +1,89 @@
1
+ // #1605: on a phone (whenever the sidebar is a drawer), a swipe right in the main area opens the drawer and a swipe left
2
+ // on the drawer or its scrim closes it — through the same open/close as ☰, so focus, the scrim and Esc are unchanged.
3
+ //
4
+ // Never in the way of anything that owns a sideways drag or a touch:
5
+ // - the left screen edge (the first EDGE px) belongs to the browser: on iOS Safari a swipe from there is "back";
6
+ // - a gesture that starts in something that scrolls sideways (a code block, a wide table or CSV card — found as the
7
+ // nearest horizontally scrollable ancestor, not a fixed list), in the terminal view (xterm), in the composer or any
8
+ // input, or while a dialog, the image lightbox or the preview panel is open, is left alone;
9
+ // - vertical scrolling is never taken: only a mostly-horizontal swipe counts (dx ≥ MIN_DX and |dy| < |dx|/2), and the
10
+ // listeners are passive — nothing is ever prevented, the browser scrolls as it always does.
11
+
12
+ export const SWIPE = { EDGE: 24, MIN_DX: 60 };
13
+
14
+ /** Elements a sideways gesture belongs to (their own scrolling, selection, typing or drag). */
15
+ function owns(el) {
16
+ const tag = (el.tagName || "").toLowerCase();
17
+ if (tag === "input" || tag === "textarea" || tag === "select" || tag === "pre" || tag === "table" || tag === "iframe") return true;
18
+ if (el.isContentEditable || (el.getAttribute && el.getAttribute("contenteditable") === "true")) return true;
19
+ const cl = el.classList;
20
+ return !!cl && (cl.contains("xterm") || cl.contains("composer") || cl.contains("composer-box") || cl.contains("pv-panel")
21
+ || cl.contains("lb") || cl.contains("csv-card") || cl.contains("codeblock"));
22
+ }
23
+ /** Scrolls sideways (and has room to): the gesture is the content's. */
24
+ function scrollsSideways(el, win) {
25
+ if (!(el.scrollWidth > el.clientWidth + 1)) return false;
26
+ const cs = win && typeof win.getComputedStyle === "function" ? win.getComputedStyle(el) : null;
27
+ const ox = cs ? cs.overflowX : "";
28
+ return ox === "auto" || ox === "scroll";
29
+ }
30
+ function blocked(target, win) {
31
+ for (let el = target; el && el.nodeType === 1; el = el.parentNode) {
32
+ if (owns(el) || scrollsSideways(el, win)) return true;
33
+ }
34
+ return false;
35
+ }
36
+ function inside(target, test) {
37
+ for (let el = target; el && el.nodeType === 1; el = el.parentNode) if (test(el)) return true;
38
+ return false;
39
+ }
40
+
41
+ /**
42
+ * Install once per page. `isDrawer()`: the sidebar is a drawer now (phone width); `isOpen()`; `open()` / `close()`: the
43
+ * same paths ☰ and the scrim use. Returns the uninstall function.
44
+ */
45
+ export function installSwipe({ doc = document, win = window, isDrawer, isOpen, open, close }) {
46
+ let start = null; // { x, y, opening }
47
+ const modalUp = () => !!(doc.querySelector && (doc.querySelector("dialog[open]") || doc.querySelector(".pv-panel")))
48
+ || !!(doc.documentElement && doc.documentElement.classList && doc.documentElement.classList.contains("lb-open"));
49
+ function onStart(e) {
50
+ start = null;
51
+ if (!e.touches || e.touches.length !== 1 || !isDrawer()) return;
52
+ const t = e.touches[0], target = e.target;
53
+ if (modalUp() || blocked(target, win)) return;
54
+ if (isOpen()) {
55
+ // Closing: a swipe left that starts on the drawer or its scrim.
56
+ if (inside(target, (el) => el.id === "sidebar" || (el.classList && el.classList.contains("scrim")))) start = { x: t.clientX, y: t.clientY, opening: false };
57
+ return;
58
+ }
59
+ // Opening: in the main area, and never from the edge the browser owns.
60
+ if (t.clientX < SWIPE.EDGE) return;
61
+ if (!inside(target, (el) => el.id === "main")) return;
62
+ start = { x: t.clientX, y: t.clientY, opening: true };
63
+ }
64
+ function onEnd(e) {
65
+ const s = start;
66
+ start = null;
67
+ if (!s || !e.changedTouches || !e.changedTouches.length || !isDrawer()) return;
68
+ // A dialog, the lightbox or the preview panel that came up during the gesture still owns it (#1609 review).
69
+ if (modalUp()) return;
70
+ const t = e.changedTouches[0];
71
+ const dx = t.clientX - s.x, dy = t.clientY - s.y;
72
+ if (Math.abs(dx) < SWIPE.MIN_DX || Math.abs(dy) >= Math.abs(dx) / 2) return; // short, or mostly vertical: a scroll
73
+ if (s.opening && dx > 0 && !isOpen()) open();
74
+ else if (!s.opening && dx < 0 && isOpen()) close();
75
+ }
76
+ function onCancel() { start = null; }
77
+ function onMove(e) { if (start && e.touches && e.touches.length !== 1) start = null; } // a second finger: a pinch
78
+ const opts = { passive: true };
79
+ doc.addEventListener("touchstart", onStart, opts);
80
+ doc.addEventListener("touchmove", onMove, opts);
81
+ doc.addEventListener("touchend", onEnd, opts);
82
+ doc.addEventListener("touchcancel", onCancel, opts);
83
+ return () => {
84
+ doc.removeEventListener("touchstart", onStart, opts);
85
+ doc.removeEventListener("touchmove", onMove, opts);
86
+ doc.removeEventListener("touchend", onEnd, opts);
87
+ doc.removeEventListener("touchcancel", onCancel, opts);
88
+ };
89
+ }
@@ -108,7 +108,22 @@ html:has(.shell), html:has(.shell) body { overflow: hidden; }
108
108
  flex-shrink: 0; font-size: 11px; line-height: 16px; font-weight: var(--fw-semibold); padding: 0 var(--s-2); border-radius: var(--r-full);
109
109
  background: var(--warn-bg); color: var(--warn); border: 1px solid var(--warn-line); white-space: nowrap;
110
110
  }
111
- .side-foot { display: flex; flex-direction: column; gap: 1px; padding: var(--s-2); }
111
+ /* #1604: one row — Settings and Session (the anonymous View reader: Sign in and the theme/language popover). Their
112
+ pop-ups open over the whole footer's width. */
113
+ .side-foot { display: flex; align-items: center; gap: var(--s-1); padding: var(--s-2); position: relative; }
114
+ .side-foot > .side-row, .side-foot > .session { flex: 1; min-width: 0; }
115
+ .side-foot > .session { position: static; }
116
+ .side-foot .side-row span:not(.dot):not(.meta) { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
117
+ .side-foot .session .meta { display: none; }
118
+ .prefs-pop > summary { list-style: none; cursor: pointer; }
119
+ .prefs-pop > summary::-webkit-details-marker { display: none; }
120
+ .prefs.labelled { flex-direction: column; gap: var(--s-2); padding: 0; }
121
+ .prefs.labelled .pref { flex: none; gap: var(--s-2); color: var(--text); }
122
+ .prefs.labelled .pref-label { min-width: 7em; font-size: var(--fs-sm); }
123
+ .prefs.labelled .pref select { flex: 0 1 14em; height: 34px; border: 1px solid var(--border); background: var(--bg); color: var(--text); font-size: var(--fs-sm); padding: 0 var(--s-2); }
124
+ .s-device { display: flex; flex-direction: column; gap: var(--s-3); }
125
+ .s-device .sub { margin: 0; color: var(--text-3); font-size: var(--fs-sm); }
126
+ .s-device-row { display: flex; gap: var(--s-2); }
112
127
  .prefs { display: flex; gap: var(--s-2); padding: var(--s-1) var(--s-3); }
113
128
  .pref { display: flex; align-items: center; gap: var(--s-1); color: var(--text-2); flex: 1; min-width: 0; }
114
129
  .pref select { flex: 1; min-width: 0; height: 30px; border: 0; border-radius: var(--r-sm); background: transparent; color: var(--text-2); font-size: var(--fs-xs); cursor: pointer; }
@@ -292,6 +307,20 @@ html:has(.shell), html:has(.shell) body { overflow: hidden; }
292
307
  .msg .att-img { cursor: zoom-in; }
293
308
  /* #1565: a file from before the fleet restarted that could not be served again — its name only, nothing to fetch. */
294
309
  .att-file.att-gone { opacity: 0.6; cursor: default; text-decoration: none; }
310
+ /* #1589: media plays inline (native controls); text attachments open in a card, escaped. */
311
+ .att-media { margin-top: var(--s-2); }
312
+ .att-media .att-video { display: block; max-width: min(100%, 480px); max-height: 360px; border-radius: var(--r-sm); background: #000; }
313
+ .att-media .att-audio { display: block; width: min(100%, 420px); }
314
+ .text-card { margin-top: var(--s-2); border: 1px solid var(--border); border-radius: var(--r-sm); padding: var(--s-2) var(--s-3); }
315
+ .text-card .tc-head { display: flex; align-items: center; gap: var(--s-2); }
316
+ .text-card .tc-label { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: var(--fs-xs); font-family: var(--font-mono); color: var(--text-2); }
317
+ .text-card .tc-body { margin-top: var(--s-2); max-height: 480px; overflow: auto; }
318
+ .text-card .tc-body[hidden] { display: none; }
319
+ .text-card .tc-pre { margin: 0; white-space: pre-wrap; word-break: break-word; font-size: var(--fs-xs); }
320
+ .text-card .tc-table-wrap { overflow-x: auto; }
321
+ .text-card .tc-table { border-collapse: collapse; font-size: var(--fs-xs); }
322
+ .text-card .tc-table th, .text-card .tc-table td { border: 1px solid var(--border); padding: 2px 6px; text-align: left; vertical-align: top; }
323
+ .text-card .tc-more { margin-top: var(--s-2); }
295
324
  /* A chat image, full size, in the page (image-lightbox.js): the whole viewport, dark, the image fitted to it. */
296
325
  html.lb-open .scroller { overflow: hidden; }
297
326
  .lb { position: fixed; inset: 0; width: 100vw; height: 100vh; height: 100dvh; max-width: none; max-height: none; margin: 0; padding: 0; border: 0;
@@ -567,6 +596,8 @@ html.lb-open .scroller { overflow: hidden; }
567
596
  .v-term { flex: 1; min-height: 0; overflow: auto; scrollbar-gutter: stable; padding: 5px 6px; background: #000000; position: relative; }
568
597
  .v-pre { margin: 0; min-width: 100%; min-height: 100%; font-family: var(--font-mono); line-height: 1.2; white-space: pre; color: #d0d0d0; }
569
598
  .v-pre .ansi[data-b] { font-weight: var(--fw-bold); }
599
+ /* #1582: faint text (SGR 2) — a CLI's suggestion or placeholder, never input — stays faint here. */
600
+ .v-pre .ansi[data-dim] { opacity: .5; }
570
601
  .v-term-note { position: absolute; left: var(--s-3); right: var(--s-3); top: var(--s-3); padding: var(--s-2) var(--s-3); border-radius: var(--r-sm); background: var(--warn-bg); border: 1px solid var(--warn-line); color: #f0f0f0; font-size: var(--fs-sm); }
571
602
  .v-probe { position: absolute; visibility: hidden; white-space: pre; left: -9999px; top: 0; font-size: 100px; }
572
603
  .v-card { flex-shrink: 0; display: flex; align-items: center; gap: var(--s-3); padding: var(--s-2) var(--s-4); background: var(--bg); padding-bottom: max(var(--s-2), env(safe-area-inset-bottom)); }
@@ -6,7 +6,8 @@
6
6
  // reached only by the dynamic imports below, in "full" mode. A test walks the static imports over real HTTP and asserts
7
7
  // none of them names /ui/.
8
8
  import { html, render } from "./app-html.js";
9
- import { Shell, handleKey, showDialog, closeDrawer, retryable, setNewInstanceOpener } from "./app-shell.js";
9
+ import { Shell, handleKey, showDialog, closeDrawer, openDrawer, shellStore, retryable, setNewInstanceOpener } from "./app-shell.js";
10
+ import { installSwipe } from "./app-swipe.js";
10
11
  import { startRouter, navStore, navigate } from "./app-nav.js";
11
12
  import { createStream } from "./app-stream.js";
12
13
  import { appStore, applyStatus, applyActivity } from "./app-store.js";
@@ -37,6 +38,12 @@ if (mode === "full") {
37
38
  stream.on("status", applyStatus);
38
39
  stream.on("activity", applyActivity);
39
40
  stream.on("connection", (connection) => appStore.set({ connection }));
41
+ // #1580: when the next reconnect try is (the connection line counts down to it; monotonic, like the stream's clock).
42
+ stream.on("retry", (r) => appStore.set({ retryAt: (typeof performance !== "undefined" ? performance.now() : Date.now()) + r.inMs }));
43
+ // #1580: a page that leaves closes its stream and every timer (nothing half-open behind it); one restored from the
44
+ // back-forward cache reconnects and catches up.
45
+ window.addEventListener("pagehide", () => stream.suspend());
46
+ window.addEventListener("pageshow", (e) => { if (e.persisted) stream.resume(); });
40
47
  // A chat that loaded late catches up on what is open now; the shell shows that while it is not done, with Retry.
41
48
  stream.on("hydration", (hydration) => appStore.set({ hydration }));
42
49
  appStore.set({ retryHydration: () => stream.catchUp() });
@@ -91,5 +98,12 @@ startRouter(window);
91
98
  }
92
99
 
93
100
  document.addEventListener("keydown", handleKey);
101
+ // #1605: on a phone (the sidebar is a drawer), swipe right in the main area to open it, left to close it — the same
102
+ // open/close as ☰ and the scrim. Nothing that owns a sideways drag (code, tables, the terminal, inputs, dialogs) and
103
+ // never the left edge (iOS: "back").
104
+ installSwipe({
105
+ isDrawer: () => typeof matchMedia === "function" && matchMedia("(max-width: 899px)").matches,
106
+ isOpen: () => shellStore.get().drawer, open: openDrawer, close: closeDrawer,
107
+ });
94
108
 
95
109
  render(html`<${Shell} panels=${panels} onNewInstance=${onNewInstance} viewOnly=${mode === "view-only"} />`, document.getElementById("app"));
@@ -18,6 +18,9 @@ import { readStream } from "./read-stream.js";
18
18
  export { UsageMetric } from "./usage-dialog.js";
19
19
  import { backendLabel, STATUS_KEYS } from "./instance-nav.js";
20
20
  import { viewStore } from "./view-roster-store.js";
21
+
22
+ /** #1580: the app's stream says the fleet is not answering (a restart): the View's polls skip their turn until it is. */
23
+ const fleetUnreachable = () => appStore.get().connection === "reconnecting";
21
24
  import { navigate } from "./app-nav.js";
22
25
  import { viewPath } from "./app-route.js";
23
26
  import { Dialog } from "./ui-dialog.js";
@@ -34,9 +37,11 @@ function tn(key, values = {}) {
34
37
  const stored = (k) => { try { return localStorage.getItem(k); } catch { return null; } };
35
38
  const store = (k, v) => { try { localStorage.setItem(k, v); } catch { /* this page only */ } };
36
39
 
37
- // ── ANSI (SGR) → markup ── A coloured run is <span class="ansi" data-fg data-bg data-b>, never a style attribute (#1300);
38
- // paintAnsi() sets the colours through the style object once it is on the page. The colours are the palette's or rgb()
39
- // of numbers — nothing from the pane's text.
40
+ // ── ANSI (SGR) → markup ── A coloured run is <span class="ansi" data-fg data-bg data-b data-dim>, never a style attribute
41
+ // (#1300); paintAnsi() sets the colours through the style object once it is on the page. The colours are the palette's or
42
+ // rgb() of numbers — nothing from the pane's text. Dim (SGR 2) is kept because a CLI paints text that is not input dim:
43
+ // Claude Code's prompt suggestion and empty-box placeholder sit in the composer exactly like typed text but faint (#1582,
44
+ // suzuke/agend-terminal#3744). Reverse video (7) swaps the run's colours, the terminal's own standing in for unset ones.
40
45
  const BASE = ["#000000", "#cd3131", "#0dbc79", "#e5e510", "#2472c8", "#bc3fbc", "#11a8cd", "#e5e5e5"];
41
46
  const BRIGHT = ["#666666", "#f14c4c", "#23d18b", "#f5f543", "#3b8eea", "#d670d6", "#29b8db", "#ffffff"];
42
47
  export const esc = (s) => String(s).replace(/[&<>"']/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[c]));
@@ -46,14 +51,17 @@ function xterm256(n) {
46
51
  if (n < 232) { n -= 16; const r = Math.floor(n / 36), g = Math.floor((n % 36) / 6), b = n % 6; const v = (x) => (x ? x * 40 + 55 : 0); return `rgb(${v(r)},${v(g)},${v(b)})`; }
47
52
  const l = (n - 232) * 10 + 8; return `rgb(${l},${l},${l})`;
48
53
  }
54
+ /** The `.v-pre` terminal's own colours (app.css), for a reversed run whose colours were not set. */
55
+ const TERM_FG = "#d0d0d0", TERM_BG = "#000000";
49
56
  export function ansiToHtml(text) {
50
- let out = "", fg = null, bg = null, bold = false, open = false;
57
+ let out = "", fg = null, bg = null, bold = false, dim = false, reverse = false, open = false;
51
58
  const attrs = () => {
52
59
  let f = fg;
53
60
  if (bold && typeof f === "number" && f < 8) f = BRIGHT[f];
54
61
  else if (typeof f === "number") f = BASE[f];
55
- const b = bg == null ? null : typeof bg === "number" ? BASE[bg] : bg;
56
- return (f ? ` data-fg="${esc(f)}"` : "") + (b ? ` data-bg="${esc(b)}"` : "") + (bold ? " data-b" : "");
62
+ let b = bg == null ? null : typeof bg === "number" ? BASE[bg] : bg;
63
+ if (reverse) [f, b] = [b ?? TERM_BG, f ?? TERM_FG];
64
+ return (f ? ` data-fg="${esc(f)}"` : "") + (b ? ` data-bg="${esc(b)}"` : "") + (bold ? " data-b" : "") + (dim ? " data-dim" : "");
57
65
  };
58
66
  const flush = () => { if (open) { out += "</span>"; open = false; } };
59
67
  const openSpan = () => { const a = attrs(); if (a) { out += `<span class="ansi"${a}>`; open = true; } };
@@ -68,9 +76,12 @@ export function ansiToHtml(text) {
68
76
  if (codes.length === 0) codes.push(0);
69
77
  for (let i = 0; i < codes.length; i++) {
70
78
  const c = codes[i];
71
- if (c === 0) { fg = bg = null; bold = false; }
79
+ if (c === 0) { fg = bg = null; bold = dim = reverse = false; }
72
80
  else if (c === 1) bold = true;
73
- else if (c === 22) bold = false;
81
+ else if (c === 2) dim = true;
82
+ else if (c === 22) bold = dim = false; // "normal intensity": neither bold nor faint
83
+ else if (c === 7) reverse = true;
84
+ else if (c === 27) reverse = false;
74
85
  else if (c === 39) fg = null;
75
86
  else if (c === 49) bg = null;
76
87
  else if (c >= 30 && c <= 37) fg = c - 30;
@@ -129,6 +140,7 @@ export function ViewPanel({ route, navKey }) {
129
140
  const reads = readStream(lease);
130
141
  const load = async () => {
131
142
  if (typeof document !== "undefined" && document.hidden) return;
143
+ if (fleetUnreachable()) return; // #1580: the app's stream is reconnecting — no read storm
132
144
  const token = reads.begin();
133
145
  if (!token) return;
134
146
  try {
@@ -213,6 +225,7 @@ function Terminal({ name, lease, density }) {
213
225
  const reads = readStream(lease);
214
226
  const refresh = async () => {
215
227
  if (document.hidden) return;
228
+ if (fleetUnreachable()) return; // #1580: every 0.8 s would be a connection storm through a relay
216
229
  // A selection inside the pane is being copied: keep this frame until it is released.
217
230
  const sel = typeof window.getSelection === "function" ? window.getSelection() : null;
218
231
  if (sel && sel.toString().length > 0 && sel.anchorNode && pre.current && pre.current.contains(sel.anchorNode)) return;
@@ -65,6 +65,14 @@ export function UsageMetric({ m }) {
65
65
  const sub = [note, expiryText(m.expiresAt)].filter(Boolean).join(" · ");
66
66
  return html`<div class="u-metric"><div class="u-row"><span class="u-label">${label}</span><span class="u-val">${val}</span></div>${sub ? html`<div class="u-sub">${sub}</div>` : null}</div>`;
67
67
  }
68
+ /** "Updated 3 min ago" (#1585): how old the numbers are — the panel may show the last snapshot while a fresh one comes. */
69
+ export function updatedAgo(fetchedAt, now = Date.now()) {
70
+ const ms = now - new Date(fetchedAt).getTime();
71
+ if (!Number.isFinite(ms) || ms < 60_000) return tn("usageUpdatedJustNow");
72
+ const min = Math.floor(ms / 60_000);
73
+ return tn("usageUpdatedAgo", { age: min < 60 ? tn("usageMinutes", { n: min }) : tn("usageHours", { n: Math.floor(min / 60) }) });
74
+ }
75
+
68
76
  /** `owner`: the navigation that opened it — its reads and the minute's timer live and end with it. */
69
77
  export function UsageDialog({ onClose, owner = "" }) {
70
78
  const lease = useLease(`usage-dialog:${owner}`);
@@ -84,6 +92,13 @@ export function UsageDialog({ onClose, owner = "" }) {
84
92
  finally { reads.end(token); }
85
93
  };
86
94
  useEffect(() => { load(false); lease.interval(() => load(false), 60_000); }, [lease]);
95
+ // #1585: the server answered with the last snapshot while it fetches a fresh one — read again shortly (the same
96
+ // cache: no vendor call of its own) so the fresh numbers replace it without waiting for the minute's tick.
97
+ useEffect(() => {
98
+ if (!data || !data.refreshing) return undefined;
99
+ const timer = setTimeout(() => load(false), 4_000);
100
+ return () => clearTimeout(timer);
101
+ }, [data]);
87
102
  const key = (p) => String(p.id || p.name || "");
88
103
  const ordered = (providers) => {
89
104
  const rank = new Map(order.map((id, i) => [id, i]));
@@ -118,7 +133,7 @@ export function UsageDialog({ onClose, owner = "" }) {
118
133
  : html`${(p.metrics || []).map((m, j) => html`<${UsageMetric} key=${j} m=${m} />`)}
119
134
  ${!p.stale && p.hint ? html`<p class="note">${usageText(p.hint, p.hintI18n)}</p>` : !(p.metrics || []).length ? html`<p class="note">${tn("usage.no_data")}</p>` : null}`}
120
135
  </section>`)}
121
- <div class="u-foot"><span class="note">${tn("usageUpdated")} ${new Date(data.fetchedAt).toLocaleTimeString()}</span>
136
+ <div class="u-foot"><span class="note" title=${new Date(data.fetchedAt).toLocaleString()}>${updatedAgo(data.fetchedAt)}${data.refreshing ? html` · <span class="u-refreshing">${tn("usageRefreshing")}</span>` : null}</span>
122
137
  <button type="button" class="btn btn-sm" onClick=${() => load(true)}>${tn("usageRefresh")}</button></div>`;
123
138
  }
124
139
  return html`<${Dialog} title=${tn("usage.title")} onClose=${onClose} wide=${true}>${content}</${Dialog}>`;