@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.
- package/dist/ansi-faint.d.ts +25 -0
- package/dist/ansi-faint.js +90 -0
- package/dist/ansi-faint.js.map +1 -0
- package/dist/auth-api.js +1 -1
- package/dist/auth-api.js.map +1 -1
- package/dist/fleet-manager.d.ts +13 -0
- package/dist/fleet-manager.js +118 -28
- package/dist/fleet-manager.js.map +1 -1
- package/dist/general-knowledge/skills/fleet-health/SKILL.md +11 -2
- package/dist/general-knowledge/skills/instance-lifecycle/SKILL.md +2 -1
- package/dist/locale.js +4 -0
- package/dist/locale.js.map +1 -1
- package/dist/outbound-handlers.js +8 -1
- package/dist/outbound-handlers.js.map +1 -1
- package/dist/tz-utils.d.ts +6 -0
- package/dist/tz-utils.js +23 -0
- package/dist/tz-utils.js.map +1 -1
- package/dist/ui/chat-render.js +29 -1
- package/dist/ui/chat-strings.js +2 -0
- package/dist/ui/chat-thread.js +128 -3
- package/dist/ui/panel-chat.js +4 -13
- package/dist/ui/panel-settings.js +54 -5
- package/dist/ui/settings-strings.js +2 -0
- package/dist/ui/shared/app-i18n.js +4 -4
- package/dist/ui/shared/app-route.js +1 -1
- package/dist/ui/shared/app-shell.js +30 -15
- package/dist/ui/shared/app-stream.js +186 -43
- package/dist/ui/shared/app-swipe.js +89 -0
- package/dist/ui/shared/app.css +32 -1
- package/dist/ui/shared/app.js +15 -1
- package/dist/ui/shared/panel-view.js +21 -8
- package/dist/ui/shared/usage-dialog.js +16 -1
- package/dist/ui/shared/view-strings.js +10 -0
- package/dist/usage/usage-api.d.ts +8 -1
- package/dist/usage/usage-api.js +100 -31
- package/dist/usage/usage-api.js.map +1 -1
- package/dist/web-api.d.ts +1 -1
- package/dist/web-api.js +64 -3
- package/dist/web-api.js.map +1 -1
- package/dist/web-host-guard.d.ts +2 -0
- package/dist/web-host-guard.js +7 -1
- package/dist/web-host-guard.js.map +1 -1
- package/dist/web-shell-routes.d.ts +1 -1
- package/dist/web-shell-routes.js +2 -1
- package/dist/web-shell-routes.js.map +1 -1
- package/dist/web-upload.d.ts +17 -0
- package/dist/web-upload.js +38 -7
- package/dist/web-upload.js.map +1 -1
- 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,
|
|
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
|
|
142
|
+
return p;
|
|
105
143
|
}
|
|
106
|
-
|
|
107
|
-
|
|
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
|
-
|
|
110
|
-
pollTimer = env.setInterval(
|
|
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
|
-
|
|
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
|
-
|
|
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)
|
|
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)
|
|
276
|
+
if (++overflows > MAX_OVERFLOW_READS) return done(false, "failed");
|
|
141
277
|
continue;
|
|
142
278
|
}
|
|
143
|
-
if (failures >= RETRY_DELAYS.length)
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
337
|
+
cancelWork();
|
|
196
338
|
stopPolling();
|
|
197
|
-
|
|
198
|
-
|
|
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
|
+
}
|
package/dist/ui/shared/app.css
CHANGED
|
@@ -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
|
-
|
|
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)); }
|
package/dist/ui/shared/app.js
CHANGED
|
@@ -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
|
|
38
|
-
// paintAnsi() sets the colours through the style object once it is on the page. The colours are the palette's or
|
|
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) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[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
|
-
|
|
56
|
-
|
|
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 ===
|
|
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"
|
|
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}>`;
|