@songsid/agend 2.3.4-beta.3 → 2.3.4-beta.5

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 (73) hide show
  1. package/dist/backend/claude-code.js +1 -1
  2. package/dist/backend/claude-code.js.map +1 -1
  3. package/dist/backend/codex-session-lookup.d.ts +35 -0
  4. package/dist/backend/codex-session-lookup.js +93 -0
  5. package/dist/backend/codex-session-lookup.js.map +1 -1
  6. package/dist/backend/codex.d.ts +27 -0
  7. package/dist/backend/codex.js +116 -20
  8. package/dist/backend/codex.js.map +1 -1
  9. package/dist/backend/grok.d.ts +0 -2
  10. package/dist/backend/grok.js +0 -22
  11. package/dist/backend/grok.js.map +1 -1
  12. package/dist/backend/types.d.ts +0 -2
  13. package/dist/backend/types.js.map +1 -1
  14. package/dist/daemon.d.ts +10 -1
  15. package/dist/daemon.js +122 -80
  16. package/dist/daemon.js.map +1 -1
  17. package/dist/delivery-outbox.test.js +2 -2
  18. package/dist/delivery-outbox.test.js.map +1 -1
  19. package/dist/dialog-screen-text.d.ts +16 -0
  20. package/dist/dialog-screen-text.js +51 -0
  21. package/dist/dialog-screen-text.js.map +1 -0
  22. package/dist/fleet-manager.d.ts +4 -0
  23. package/dist/fleet-manager.js +46 -12
  24. package/dist/fleet-manager.js.map +1 -1
  25. package/dist/install-stream.d.ts +53 -0
  26. package/dist/install-stream.js +134 -0
  27. package/dist/install-stream.js.map +1 -0
  28. package/dist/instance-lifecycle.d.ts +12 -0
  29. package/dist/instance-lifecycle.js +31 -2
  30. package/dist/instance-lifecycle.js.map +1 -1
  31. package/dist/locale.js +26 -6
  32. package/dist/locale.js.map +1 -1
  33. package/dist/log-service.d.ts +2 -0
  34. package/dist/log-service.js +7 -3
  35. package/dist/log-service.js.map +1 -1
  36. package/dist/login-manager.d.ts +6 -0
  37. package/dist/login-manager.js +10 -1
  38. package/dist/login-manager.js.map +1 -1
  39. package/dist/outbound-handlers.js +56 -0
  40. package/dist/outbound-handlers.js.map +1 -1
  41. package/dist/outbound-schemas.d.ts +1 -0
  42. package/dist/outbound-schemas.js +3 -0
  43. package/dist/outbound-schemas.js.map +1 -1
  44. package/dist/selection-card.d.ts +2 -3
  45. package/dist/selection-card.js +99 -26
  46. package/dist/selection-card.js.map +1 -1
  47. package/dist/tmux-manager.d.ts +6 -0
  48. package/dist/tmux-manager.js +17 -0
  49. package/dist/tmux-manager.js.map +1 -1
  50. package/dist/ui/chat-store.js +20 -3
  51. package/dist/ui/fleet-canvas.js +4 -3
  52. package/dist/ui/fleet-office-model.js +55 -22
  53. package/dist/ui/fleet-office-overview.js +6 -38
  54. package/dist/ui/fleet-office-skin.js +12 -4
  55. package/dist/ui/fleet-office-store.js +5 -0
  56. package/dist/ui/fleet-office-zones.js +2 -2
  57. package/dist/ui/fleet-office.js +31 -1
  58. package/dist/ui/panel-log.js +7 -1
  59. package/dist/ui/shared/app-i18n.js +2 -2
  60. package/dist/ui/shared/app-shell.js +4 -2
  61. package/dist/ui/shared/app-stream.js +26 -0
  62. package/dist/ui/shared/app.css +6 -0
  63. package/dist/unattended-install.d.ts +6 -0
  64. package/dist/unattended-install.js +65 -0
  65. package/dist/unattended-install.js.map +1 -0
  66. package/dist/web-api.js +7 -2
  67. package/dist/web-api.js.map +1 -1
  68. package/dist/web-chat-history.d.ts +20 -0
  69. package/dist/web-chat-history.js +21 -0
  70. package/dist/web-chat-history.js.map +1 -1
  71. package/dist/web-guest-access.js +5 -1
  72. package/dist/web-guest-access.js.map +1 -1
  73. package/package.json +1 -1
@@ -9,6 +9,8 @@
9
9
  // navigation — it counts as use, as the old click did). Coming back to it reads nothing; the stream keeps it current.
10
10
  import "./chat-render.js";
11
11
  import { createLiveAgentReplies } from "./chat-live-replies.js";
12
+ /** A message's identity on this page (ids restart with each fleet process; the boot makes them unique). */
13
+ const msgKeyOf = (m) => `${m.boot}-${m.id}`;
12
14
 
13
15
  const R = () => globalThis.AgendChatRender;
14
16
 
@@ -43,7 +45,7 @@ const COMPACT_PAGE_LIMIT_MS = 11 * 60_000;
43
45
  export function createChatStore(deps) {
44
46
  const s = {
45
47
  msgs: {}, drafts: {}, replies: {}, pendingFiles: {}, sending: {}, outgoing: {}, instanceStatus: {}, waking: {},
46
- prompts: {}, workingSince: {}, stopping: {}, cancelling: {}, scrollMemo: {}, historyRead: new Set(), allHidden: new Set(), rbBusy: new Set(),
48
+ prompts: {}, workingSince: {}, stopping: {}, cancelling: {}, scrollMemo: {}, historyRead: new Set(), gapped: new Set(), allHidden: new Set(), rbBusy: new Set(),
47
49
  pollMine: readPollMine(),
48
50
  // #1823 (#1828 review): the server's clock minus this device's. A poll's deadline is the server's; a page whose clock
49
51
  // is a few seconds off would count a 10 s poll wrong. Only the server's explicit `clock` sets it (#1830 review): a
@@ -161,6 +163,9 @@ export function createChatStore(deps) {
161
163
  async function openHistory(name, lease, signal) {
162
164
  if (s.historyRead.has(name) || signal?.aborted) return false;
163
165
  s.historyRead.add(name);
166
+ // M-2260: after a gap, what this chat held when the read began may sit across a hole: the answer replaces it, and
167
+ // only what arrived while the read was out (the live stream) is kept beside it — exactly what a reload would show.
168
+ const stale = s.gapped.has(name) ? new Set((s.msgs[name] || []).map(msgKeyOf)) : null;
164
169
  try {
165
170
  const r = await api("GET", `/ui/history?instance=${encodeURIComponent(name)}&limit=200`, undefined, signal);
166
171
  if (signal?.aborted) { s.historyRead.delete(name); return false; }
@@ -169,7 +174,10 @@ export function createChatStore(deps) {
169
174
  // #2848: a guest's chat whose every message is hidden from it (between agents): its empty chat says so.
170
175
  if (r.allHidden === true) s.allHidden.add(name); else s.allHidden.delete(name);
171
176
  for (const m of r.messages) liveReplies.message(m);
172
- s.msgs[name] = R().mergeMessages(s.msgs[name], r.messages, 500);
177
+ if (stale && s.gapped.has(name)) {
178
+ s.gapped.delete(name);
179
+ s.msgs[name] = R().mergeMessages((s.msgs[name] || []).filter((m) => !stale.has(msgKeyOf(m))), r.messages, 500);
180
+ } else s.msgs[name] = R().mergeMessages(s.msgs[name], r.messages, 500);
173
181
  settleOutgoing(name);
174
182
  changed(name, "msgs");
175
183
  } catch { s.historyRead.delete(name); /* the live stream still works; the next visit tries again */ }
@@ -557,8 +565,17 @@ export function createChatStore(deps) {
557
565
  /** Whether a command can start for `target` now (none is out): checked before a typed command line is consumed. */
558
566
  const commandFree = (target) => !(s.commands[target] && s.commands[target].busy);
559
567
 
568
+ /**
569
+ * M-2260: the catch-up after a long absence brought only the newest messages ("gap"): every chat this page holds may
570
+ * have a hole. Each reads its newest history again — the one on screen now, the others when they are next opened.
571
+ */
572
+ function onGap() {
573
+ for (const name of new Set([...Object.keys(s.msgs), ...s.historyRead])) { s.historyRead.delete(name); s.gapped.add(name); }
574
+ if (s.current) void openHistory(s.current);
575
+ }
560
576
  /** Wire the store to the app's stream, for the life of the page. */
561
577
  function attach(stream) {
578
+ stream.on("gap", onGap);
562
579
  stream.on("status", applyStatus);
563
580
  stream.on("activity", applyActivity);
564
581
  stream.on("message", ingest);
@@ -578,7 +595,7 @@ export function createChatStore(deps) {
578
595
  subscribe(fn) { subs.add(fn); return () => subs.delete(fn); },
579
596
  setCurrent(name) { s.current = name; },
580
597
  onAgentReply: liveReplies.subscribe,
581
- attach, applyStatus, applyActivity, ingest, applyDeliveries, openHistory,
598
+ attach, applyStatus, applyActivity, ingest, applyDeliveries, openHistory, onGap,
582
599
  onPrompt, applyPrompts, resolvePrompt, answerPrompt, answerByNonce, holdPrompt, releasePrompt, promptsFor,
583
600
  applyReplyButtons, clickReplyButton,
584
601
  uploadFile, send, retryOutgoing, deleteOutgoing, putBackOutgoing, threadRows, addFiles, removeFile, attachPastedText, fileBackAsText, isPasted, setDraft,
@@ -207,7 +207,8 @@ const HEAD_REF_X = 10 - (10 * HEAD.BACK) / HEAD.LEN;
207
207
  /**
208
208
  * The key to the lines (#2052): a **Key** toggle in the toolbar; open, its rows are a strip between the toolbar and the
209
209
  * canvas — never over the canvas, so it covers no card (it used to sit in the canvas's bottom-left corner, over the
210
- * team there at the opening view). Folded until the person opens it; kept per device.
210
+ * team there at the opening view). Folded until the person opens it; kept per device. The lines' key: the Office has its
211
+ * own, for the items it carries (fleet-office.js OfficeKey, #2946).
211
212
  */
212
213
  function readKeyOpen() { try { return localStorage.getItem(KEY_KEY) === "1"; } catch { return false; } }
213
214
  function KeyToggle({ open, onToggle }) {
@@ -490,13 +491,13 @@ export function OrgCanvas({ org, app, lease }) {
490
491
  ${mode !== "list" ? html`<label class="s-search oc-find"><${Icon} name="search" size=${14} /><span class="sr-only">${tc("find")}</span>
491
492
  <input type="search" value=${query} placeholder=${tc("findShort")} autocomplete="off" onInput=${(e) => setQuery(e.target.value)} onKeyDown=${onFind} />
492
493
  ${q ? html`<span class="oc-found" role="status">${found ? (found === 1 ? tc("foundOne") : tc("found", found)) : tc("noMatch")}</span>` : null}</label>` : null}
493
- ${mode !== "list" && live ? html`<${KeyToggle} open=${keyOpen} onToggle=${toggleKey} />` : null}
494
+ ${mode === "canvas" && live ? html`<${KeyToggle} open=${keyOpen} onToggle=${toggleKey} />` : null}
494
495
  <button type="button" class=${`btn btn-sm oc-live${live ? " on" : ""}`} aria-pressed=${live ? "true" : "false"} title=${live ? tc("liveOn") : tc("liveOff")}
495
496
  onClick=${() => setLive((v) => !v)}><span class="oc-live-dot" aria-hidden="true"></span>${tc("live")}</button>
496
497
  <div class="seg-inline" role="group" aria-label=${tc("view")}>${modeBtn("canvas")}${modeBtn("list")}${modeBtn("office")}</div>
497
498
  </div>
498
499
  </div>
499
- ${mode !== "list" && live && keyOpen ? html`<${Legend} />` : null}
500
+ ${mode === "canvas" && live && keyOpen ? html`<${Legend} />` : null}
500
501
  ${mode === "list" ? html`<${OrgChart} org=${org} app=${app} bare=${true} sending=${sending} />`
501
502
  : mode === "office" ? html`<${Office} view=${view} traffic=${live ? traffic : null} q=${q} lease=${lease} onOpen=${openChat} />`
502
503
  : html`<${Canvas} view=${view} folded=${folded} onFold=${onFold} q=${q} jump=${jump} traffic=${live ? traffic : null} />`}
@@ -322,47 +322,80 @@ export function along(pts, t) {
322
322
  // ── Traffic: an item carried from desk to desk ──
323
323
 
324
324
  /** px per ms along the path, the shortest and longest trip, the stagger between items that arrive together. */
325
- export const FLIGHT = { SPEED: 0.35, MIN_MS: 900, MAX_MS: 4000, STAGGER_MS: 150, MAX: 40 };
325
+ /**
326
+ * The items' flight: SPEED px/ms along the corridors, at least MIN_MS (#2946: under a second read as a glitch) and at
327
+ * most MAX_MS in the air; a burst's trips start STAGGER_MS apart. At most MAX are in the air or waiting to start: a
328
+ * burst over that flies its newest and drops the rest (they are on the Traffic view), so the room never lags behind a
329
+ * backlog of old events, and a trip under way is never cut short to make room.
330
+ */
331
+ export const FLIGHT = { SPEED: 0.35, MIN_MS: 2500, MAX_MS: 4500, STAGGER_MS: 200, MAX: 12 };
326
332
  const MAX_SEEN = 2000;
333
+ /** A trip's time in the air for a path `len` px long. */
334
+ export const flightMs = (len) => Math.min(FLIGHT.MAX_MS, Math.max(FLIGHT.MIN_MS, len / FLIGHT.SPEED));
335
+ /** How many new items may wait for their first frame (a frame not drawn for a while: the newest kept). */
336
+ const MAX_WAITING = 200;
327
337
  /**
328
- * The items in the air. `sync(items, now, still)` takes fleet-traffic.js's store items: each new one (a message, a
329
- * delegation's work, its report coming back) starts one trip from its sender's desk to its receiver's; a delegation with
330
- * no report (stale) and anything seen before start none; `still` (reduced motion) starts none. `frame(now, L)` the
331
- * trips under way on layout L: { id, hue, kind, from, x, y, t } (from: who sent it — a pack can draw it as theirs),
332
- * finished ones dropped; a trip whose desks are not drawn goes.
338
+ * The trips' lifecycle, on whatever `pathOf(on, from, to)` routes them through (`on`: what `frame` is given — a layout,
339
+ * or All offices' tiles; null: not drawn there). `sync(items, now, still)`: each new item is seen once (`seen`,
340
+ * bounded: never flown again) and waits for the next frame — none when `still` (reduced motion) or for a stale
341
+ * delegation. `frame(now, on)`: first the waiting are admitted, #2946 — only those with a path here (all controllers
342
+ * get the whole fleet's traffic: the rest are not this one's to fly, and never take a place), and of those the newest
343
+ * that fit in `max` beside the trips still up (landed ones and those with no path any more pruned first), each batch
344
+ * staggered from when it came, one already past its flight time never flown late; then the trips under way
345
+ * { id, hue, kind, from, x, y, t }. A trip under way
346
+ * is never cut short to make room. `size()`: trips up or waiting.
333
347
  */
334
- export function createFlights({ max = FLIGHT.MAX } = {}) {
348
+ export function createTrips(pathOf, { max = FLIGHT.MAX } = {}) {
335
349
  const seen = new Set();
336
- let trips = [];
350
+ let trips = [], waiting = [];
351
+ const route = (f, on) => { f.on = on; f.pts = pathOf(on, f.from, f.to); f.ms = f.pts ? flightMs(pathLength(f.pts)) : 0; };
337
352
  return {
338
353
  sync(items, now, still = false) {
339
- let k = 0, started = 0;
354
+ let fresh = 0;
340
355
  for (const it of items || []) {
341
356
  if (seen.has(it.id)) continue;
342
357
  seen.add(it.id);
343
358
  if (seen.size > MAX_SEEN) seen.delete(seen.values().next().value);
344
359
  if (still || it.phase === "stale") continue;
345
- trips.push({ id: it.id, from: it.from, to: it.to, kind: it.kind, hue: hue(it.kind), start: now + k++ * FLIGHT.STAGGER_MS, L: null, pts: null, ms: 0 });
346
- started++;
360
+ waiting.push({ id: it.id, from: it.from, to: it.to, kind: it.kind, hue: hue(it.kind), at: now });
361
+ fresh++;
347
362
  }
348
- if (trips.length > max) trips = trips.slice(trips.length - max);
349
- return started;
363
+ if (waiting.length > MAX_WAITING) waiting = waiting.slice(-MAX_WAITING);
364
+ return fresh;
350
365
  },
351
- frame(now, L) {
352
- const out = [];
366
+ frame(now, on) {
367
+ // First the trips up: routed again on a new layout, the ones with no path or landed dropped — so the room below is
368
+ // what is really free (#2977 review: after a gap with no frame, a hidden tab, landed trips held every place).
353
369
  trips = trips.filter((f) => {
354
- if (f.L !== L) { f.L = L; f.pts = walkPath(L, f.from, f.to); f.ms = f.pts ? Math.min(FLIGHT.MAX_MS, Math.max(FLIGHT.MIN_MS, pathLength(f.pts) / FLIGHT.SPEED)) : 0; }
355
- if (!f.pts) return false;
356
- const t = (now - f.start) / f.ms;
357
- if (t >= 1) return false;
358
- if (t >= 0) { const p = along(f.pts, t); out.push({ id: f.id, hue: f.hue, kind: f.kind, from: f.from, x: p.x, y: p.y, t }); }
359
- return true;
370
+ if (f.on !== on) route(f, on);
371
+ return !!f.pts && (now - f.start) / f.ms < 1;
360
372
  });
373
+ if (waiting.length) {
374
+ // Then the waiting with a path here and still due (one whose own flight time has passed since it came is stale:
375
+ // never flown late), the newest that fit, each batch staggered from when it came.
376
+ const ok = [];
377
+ for (const w of waiting) { const f = { ...w, start: w.at }; route(f, on); if (f.pts && now - w.at < f.ms) ok.push(f); }
378
+ waiting = [];
379
+ const room = max - trips.length, add = room > 0 ? ok.slice(-room) : []; // never slice(-0): that is all of them
380
+ const k = new Map();
381
+ for (const f of add) { const n = k.get(f.at) || 0; k.set(f.at, n + 1); f.start = f.at + n * FLIGHT.STAGGER_MS; trips.push(f); }
382
+ }
383
+ const out = [];
384
+ for (const f of trips) {
385
+ const t = (now - f.start) / f.ms;
386
+ if (t >= 0 && t < 1) { const p = along(f.pts, t); out.push({ id: f.id, hue: f.hue, kind: f.kind, from: f.from, x: p.x, y: p.y, t }); }
387
+ }
361
388
  return out;
362
389
  },
363
- size: () => trips.length,
390
+ size: () => trips.length + waiting.length,
364
391
  };
365
392
  }
393
+ /**
394
+ * The items in the air in one office: each new one (a message, a delegation's work, its report coming back) carried from
395
+ * its sender's desk to its receiver's along layout L's corridors (createTrips; `frame(now, L)`; `from`: who sent it — a
396
+ * pack can draw it as theirs). A trip whose desks are not drawn on L is not this office's.
397
+ */
398
+ export const createFlights = (opts = {}) => createTrips((L, from, to) => (L ? walkPath(L, from, to) : null), opts);
366
399
 
367
400
  /** How many delegations each instance is working on now (the folders on its desk). */
368
401
  export function workOn(items) {
@@ -13,8 +13,7 @@
13
13
  // contain); a tie keeps the narrower. The same input always gives the same picture.
14
14
  // - Traffic between people in different tiles is a straight flight between their seats in overview coordinates
15
15
  // (createCrossFlights); between two people in the same tile it is that tile's own (createFlights, along its corridors).
16
- import { FLIGHT, along, desksOf, membersOf, pathLength } from "./fleet-office-model.js";
17
- import { hue } from "./fleet-traffic.js";
16
+ import { FLIGHT, createTrips, desksOf, membersOf } from "./fleet-office-model.js";
18
17
 
19
18
  /** The switcher's "All offices" value: '*' is never in a stored office's id (fleet-office-model.js officeOk). */
20
19
  export const ALL_ID = "*all";
@@ -127,41 +126,10 @@ export function crossPath(parts, from, to) {
127
126
  return a && z ? [a, z] : null;
128
127
  }
129
128
 
130
- const MAX_SEEN = 2000;
131
129
  /**
132
- * The items in the air between tiles — fleet-office-model.js createFlights's lifecycle (each new item once; none for a
133
- * stale delegation or with reduced motion), on crossPath's straight line: `frame(now, parts)` → { id, hue, kind, from, x, y,
134
- * t } in overview units; a trip with no crossing (both in one tile, or either gone) is dropped.
130
+ * The items in the air between tiles — fleet-office-model.js createTrips (each new item once; none for a stale delegation
131
+ * or with reduced motion; at most `max` at once, newest first, of those with a crossing), on crossPath's straight line:
132
+ * `frame(now, parts)` → { id, hue, kind, from, x, y, t } in overview units; one with no crossing (both in one tile, or
133
+ * either gone) is not this one's.
135
134
  */
136
- export function createCrossFlights({ max = FLIGHT.MAX } = {}) {
137
- const seen = new Set();
138
- let trips = [];
139
- return {
140
- sync(items, now, still = false) {
141
- let k = 0, started = 0;
142
- for (const it of items || []) {
143
- if (seen.has(it.id)) continue;
144
- seen.add(it.id);
145
- if (seen.size > MAX_SEEN) seen.delete(seen.values().next().value);
146
- if (still || it.phase === "stale") continue;
147
- trips.push({ id: it.id, from: it.from, to: it.to, kind: it.kind, hue: hue(it.kind), start: now + k++ * FLIGHT.STAGGER_MS, parts: null, pts: null, ms: 0 });
148
- started++;
149
- }
150
- if (trips.length > max) trips = trips.slice(trips.length - max);
151
- return started;
152
- },
153
- frame(now, parts) {
154
- const out = [];
155
- trips = trips.filter((f) => {
156
- if (f.parts !== parts) { f.parts = parts; f.pts = crossPath(parts, f.from, f.to); f.ms = f.pts ? Math.min(FLIGHT.MAX_MS, Math.max(FLIGHT.MIN_MS, pathLength(f.pts) / FLIGHT.SPEED)) : 0; }
157
- if (!f.pts) return false;
158
- const t = (now - f.start) / f.ms;
159
- if (t >= 1) return false;
160
- if (t >= 0) { const p = along(f.pts, t); out.push({ id: f.id, hue: f.hue, kind: f.kind, from: f.from, x: p.x, y: p.y, t }); }
161
- return true;
162
- });
163
- return out;
164
- },
165
- size: () => trips.length,
166
- };
167
- }
135
+ export const createCrossFlights = ({ max = FLIGHT.MAX } = {}) => createTrips((parts, from, to) => (parts ? crossPath(parts, from, to) : null), { max });
@@ -244,13 +244,21 @@ function drawDeskFront(ctx, d, look, env) {
244
244
  if (look.match) { ctx.save(); ctx.strokeStyle = pal.focus; ctx.lineWidth = 2; ctx.strokeRect(d.x + 2, d.y + 2, d.w - 4, d.h - 6); ctx.restore(); }
245
245
  }
246
246
 
247
- const Q = 2; // a prop's art pixel
247
+ const Q = 2; // a prop's art pixel, before PROP_SCALE
248
+ /**
249
+ * #2946: the built-in props are drawn this much larger than their 16 px art (a pack's own `props` keep their size). In
250
+ * the scene's units, so they scale with the room as its people do (zoom, a phone, an All offices tile).
251
+ */
252
+ export const PROP_SCALE = 1.75;
248
253
  function drawProp(ctx, it, ms, env) {
249
- const c = env.pal.fx[it.hue] || env.pal.fx.send, x0 = it.x - 8, y0 = it.y - 8 - Math.sin(Math.PI * it.t) * 10;
250
- const b = (c2, r, w, h, col) => px(ctx, x0 + c2 * Q, y0 + r * Q, w * Q, h * Q, col);
254
+ const c = env.pal.fx[it.hue] || env.pal.fx.send, lift = Math.sin(Math.PI * it.t) * 10;
255
+ const b = (c2, r, w, h, col) => px(ctx, -8 + c2 * Q, -8 + r * Q, w * Q, h * Q, col);
251
256
  ctx.save();
257
+ ctx.translate(it.x, it.y);
258
+ ctx.scale(PROP_SCALE, PROP_SCALE);
252
259
  ctx.fillStyle = "rgba(0, 0, 0, 0.18)";
253
- ctx.beginPath(); ctx.ellipse(it.x, it.y + 4, 7, 2.5, 0, 0, Math.PI * 2); ctx.fill();
260
+ ctx.beginPath(); ctx.ellipse(0, 4, 7, 2.5, 0, 0, Math.PI * 2); ctx.fill();
261
+ ctx.translate(0, -lift);
254
262
  if (it.hue === "task") { b(0, 0, 4, 2, c); b(0, 1, 8, 6, c); b(0, 2, 8, 1, "rgba(255, 255, 255, 0.35)"); }
255
263
  else if (it.hue === "report") { b(1, 0, 6, 8, "#fbfbfb"); b(1, 0, 6, 1, c); b(2, 4, 1, 1, c); b(3, 5, 1, 1, c); b(4, 4, 1, 1, c); b(5, 3, 1, 1, c); }
256
264
  else if (it.hue === "broadcast") { b(0, 3, 2, 2, c); b(2, 2, 2, 4, c); b(4, 1, 2, 6, c); if (frameAt(ms, 2, 6)) { b(7, 1, 1, 2, c); b(7, 5, 1, 2, c); } else b(7, 3, 1, 2, c); }
@@ -19,6 +19,8 @@ import { levelsOk } from "./fleet-office-levels.js";
19
19
  const CHOICE_KEY = "agend_org_office", FILTER_KEY = "agend_org_office_filter", SKIN_KEY = "agend_org_office_skin", ASLEEP_KEY = "agend_org_office_hide_asleep", NIGHT_KEY = "agend_org_office_night";
20
20
  /** #2554: "0" when All offices leaves the lobby tile out (default: shown). */
21
21
  const LOBBY_KEY = "agend_org_office_lobby";
22
+ /** #2946: whether the key to the items in the air is open here (default: folded). */
23
+ const KEY_OPEN_KEY = "agend_org_office_key";
22
24
  /** #2540: { "<office>@<style>": [k, cx, cy] (and a 1 after them: a fit) } — the last VIEWS_MAX views, the most recent last. */
23
25
  const VIEW_KEY = "agend_org_office_view", VIEWS_MAX = 32;
24
26
  /** #2540: { "<office>@<style>": "cover" } — the offices whose fit fills the panel (the rest: "contain"), the last VIEWS_MAX. */
@@ -195,6 +197,9 @@ export function createOfficeStore({ fetch: get = globalThis.fetch, storage } = {
195
197
  saveNightOn: (v) => write(NIGHT_KEY, v ? "1" : "0"),
196
198
  /** #2554: whether All offices shows the lobby tile (the instances in no stored office) here (default: shown). */
197
199
  lobbyTile: () => read(LOBBY_KEY) !== "0",
200
+ /** #2946: whether the key to the items carried between desks is open here (default: folded). */
201
+ keyOpen: () => read(KEY_OPEN_KEY) === "1",
202
+ saveKeyOpen: (v) => write(KEY_OPEN_KEY, v ? "1" : "0"),
198
203
  saveLobbyTile: (v) => write(LOBBY_KEY, v ? "1" : "0"),
199
204
  /** The view kept for `key` (an office and its style): { k, cx, cy } (fleet-office-view.js savedOf), or null. */
200
205
  view: (key) => {
@@ -3,7 +3,7 @@
3
3
  // changes it walks (along the pack's waypoints) to the new area. Pure — fleet-office.js draws it. Also here: what a
4
4
  // character says in its speech bubble, and which furniture is active.
5
5
  import { LOGICAL, own } from "./fleet-office-pack.js";
6
- import { FLIGHT, nameHash, pathLength, along, routeVia } from "./fleet-office-model.js";
6
+ import { flightMs, nameHash, pathLength, along, routeVia } from "./fleet-office-model.js";
7
7
  import { hue } from "./fleet-traffic.js";
8
8
 
9
9
  /** The area for a state: the pack's own, else its "default", else its first area. */
@@ -281,7 +281,7 @@ export function createMail({ max = MAIL.MAX } = {}) {
281
281
  if (!drop) continue; // nowhere off the seats: not delivered here
282
282
  const pts = [{ x: L.door.x, y: L.door.y }, drop];
283
283
  letters.push({ id: it.id, from: it.from, to: it.to, kind: it.kind, hue: hue(it.kind), start: now, drop, pts,
284
- ms: Math.min(FLIGHT.MAX_MS, Math.max(FLIGHT.MIN_MS, pathLength(pts) / FLIGHT.SPEED)) });
284
+ ms: flightMs(pathLength(pts)) });
285
285
  started++;
286
286
  }
287
287
  if (letters.length > max) letters = letters.slice(letters.length - max);
@@ -64,7 +64,7 @@ import { nodeText, stateWords } from "./fleet-org.js";
64
64
  import { hits, needle } from "./fleet-canvas-layout.js";
65
65
  import { AUTO_ID, LOBBY, autoLayout, backingScale, coatOf, createFlights, customLayout, deskAt, desksOf, filterOk, lampOf, layoutOk, lightOf, membersOf,
66
66
  officePose, officeRooms, signOf, workOn } from "./fleet-office-model.js";
67
- import { BUILT_IN_SKINS, CHAR_DEPTH, DEFAULT_PAL, PICTURE_SKINS, bubbleBox, loadBuiltInSkin, loadPackSkin, modeOf, skinFor, tagBox, tagFootprint } from "./fleet-office-skin.js";
67
+ import { BUILT_IN_SKINS, CHAR_DEPTH, DEFAULT_PAL, PICTURE_SKINS, bubbleBox, pixelSkin, loadBuiltInSkin, loadPackSkin, modeOf, skinFor, tagBox, tagFootprint } from "./fleet-office-skin.js";
68
68
  import { CALLING, activeFurniture, bubbleAt, hiddenBy, bubbleRank, NIGHT, WANDER, createMail, createWalkers, isNight, linesFor, npcKey, personAt, placeBubbles, thinBubbles, walkTargets, zonesLayout } from "./fleet-office-zones.js";
69
69
  import { actsOk, createOfficeStore } from "./fleet-office-store.js";
70
70
  import { badgeOf, fixedOf } from "./fleet-office-levels.js";
@@ -96,6 +96,8 @@ register("orgo", {
96
96
  fit: "Fit the office", overview: "Overview: click to move there", fullscreen: "Fullscreen", exitFullscreen: "Exit fullscreen",
97
97
  fill: "Fill", fillTitle: "Fill the panel (the scene's overflow is cropped; drag to see it)", zoomLevel: "Zoom {0}",
98
98
  allOffices: "All offices", labelAll: "All offices: each office is a tile, drawn in its own style. Drag to pan; the wheel, a pinch, + and − zoom, 0 fits, F is fullscreen. Double-click a tile to open that office.",
99
+ key: "Key", keyTitle: "What the items carried between desks are", keyLabel: "Carried between desks",
100
+ keyTask: "Folder: a task", keySend: "Envelope: a message", keyReport: "Paper: a report", keyBroadcast: "Horn: a broadcast",
99
101
  tileLabel: "{0} ({1})", openOffice: "Open the office {0}", lobbyTile: "Lobby tile (in no office)", lobbyEveryone: "Everyone is in an office", overviewNoEdit: "Pick one office to edit offices or a layout",
100
102
  },
101
103
  "zh-TW": {
@@ -114,6 +116,8 @@ register("orgo", {
114
116
  fit: "顯示整個辦公室", overview: "總覽:點一下移到那裡", fullscreen: "全螢幕", exitFullscreen: "結束全螢幕",
115
117
  fill: "填滿", fillTitle: "填滿整個面板(超出的部分會被裁切,拖曳即可看到)", zoomLevel: "縮放 {0}",
116
118
  allOffices: "所有辦公室", labelAll: "所有辦公室:每間辦公室是一格,以它自己的風格顯示。拖曳平移;滾輪、雙指、+ 和 − 縮放,0 顯示全部,F 切換全螢幕。雙擊一格即可開啟那間辦公室。",
119
+ key: "圖例", keyTitle: "桌子之間傳遞的物品代表什麼", keyLabel: "桌子之間傳遞的物品",
120
+ keyTask: "資料夾:任務", keySend: "信封:訊息", keyReport: "紙張:回報", keyBroadcast: "喇叭:廣播",
117
121
  tileLabel: "{0}({1})", openOffice: "開啟辦公室「{0}」", lobbyTile: "大廳格(不在任何辦公室)", lobbyEveryone: "每個成員都已在某間辦公室", overviewNoEdit: "請先選一間辦公室,再編輯辦公室或配置",
118
122
  },
119
123
  });
@@ -584,6 +588,28 @@ function People({ nodes, pinned, onChange }) {
584
588
  </div>`;
585
589
  }
586
590
 
591
+ /** #2946: the key's rows — each kind's item as the scene draws it (the built-in prop, in the theme's colour), and its words. */
592
+ export const KEY_KINDS = [["task", "keyTask"], ["send", "keySend"], ["report", "keyReport"], ["broadcast", "keyBroadcast"]];
593
+ const KEY_PX = 32;
594
+ /**
595
+ * #2946 (hxhsandy): the key to the items carried between desks — a strip under the Office's bar (never over the scene),
596
+ * opened by its Key toggle. The shapes are drawn by the built-in skin, so they are the ones in the air, at their size.
597
+ */
598
+ export function OfficeKey({ pal }) {
599
+ const paint = (hue) => (cv) => {
600
+ const g = cv && typeof cv.getContext === "function" ? cv.getContext("2d") : null;
601
+ if (!g) return;
602
+ const r = Math.max(1, Math.round(globalThis.devicePixelRatio || 1));
603
+ if (cv.width !== KEY_PX * r) { cv.width = KEY_PX * r; cv.height = KEY_PX * r; }
604
+ g.setTransform(r, 0, 0, r, 0, 0); g.clearRect(0, 0, KEY_PX, KEY_PX);
605
+ try { pixelSkin.drawProp(g, { hue, kind: hue, x: KEY_PX / 2, y: KEY_PX / 2 + 4, t: 0 }, 0, { pal }); } catch { /* left blank */ }
606
+ };
607
+ return html`<div class="oo-key" role="region" aria-label=${to("keyLabel")}>
608
+ <ul class="oo-key-rows">${KEY_KINDS.map(([hue, k]) => html`<li key=${hue} class="oo-key-row" data-kind=${hue}>
609
+ <canvas class="oo-key-icon" width=${KEY_PX} height=${KEY_PX} aria-hidden="true" ref=${paint(hue)}></canvas><span>${to(k)}</span></li>`)}</ul>
610
+ </div>`;
611
+ }
612
+
587
613
  /**
588
614
  * The view: `view` buildOrgView's answer; `traffic` the live items (null: Live is off); `q` the Find box's needle;
589
615
  * `lease` the tab's (its reads); `onOpen(name)` what a desk opens (the canvas's: the chat; none: a desk opens nothing,
@@ -609,6 +635,7 @@ export function Office({ view, traffic, q = "", lease, onOpen, assetUrl, assetIm
609
635
  const [hideAsleep, setHideAsleep] = useState(() => store.hideAsleep());
610
636
  // Bedtime (#2568), when it is on here: the idle sleep from NIGHT.FROM to NIGHT.TO on this device's clock (read every NIGHT.TICK).
611
637
  const [nightOn, setNightOn] = useState(() => store.nightOn());
638
+ const [keyOpen, setKeyOpen] = useState(() => store.keyOpen());
612
639
  const [nightNow, setNightNow] = useState(() => isNight());
613
640
  useEffect(() => { if (!nightOn) return undefined; setNightNow(isNight()); const h = setInterval(() => setNightNow(isNight()), NIGHT.TICK); return () => clearInterval(h); }, [nightOn]);
614
641
  const night = nightOn && nightNow;
@@ -1458,6 +1485,8 @@ export function Office({ view, traffic, q = "", lease, onOpen, assetUrl, assetIm
1458
1485
  ${custom && baseSkin.id !== custom.skin && !loading ? html`<span class="muted oo-note">${to("skinUnknown", styleName(custom.skin))}</span>` : null}
1459
1486
  ${placed.ignored ? html`<span class="muted oo-note">${to("layoutIgnored")}</span>` : null}
1460
1487
  ${styleErr && auto ? html`<span class="note oo-note oo-style-err" role="status">${styleErr}</span>` : null}
1488
+ ${traffic ? html`<button type="button" class=${`btn btn-sm oo-key-btn${keyOpen ? " on" : ""}`} aria-expanded=${keyOpen ? "true" : "false"} title=${to("keyTitle")}
1489
+ onClick=${() => { const v = !keyOpen; store.saveKeyOpen(v); setKeyOpen(v); }}>${to("key")}</button>` : null}
1461
1490
  ${L.unseated ? html`<span class="muted oo-note">${to("unseated", L.unseated)}</span>` : null}
1462
1491
  ${canEdit && storedRev !== null ? html`<button type="button" class="btn btn-sm oo-edit" onClick=${() => { if (!overview) setEditing(true); }} disabled=${!!layout || overview}
1463
1492
  title=${overview ? to("overviewNoEdit") : undefined}>${t("orge.edit")}</button>` : null}
@@ -1465,6 +1494,7 @@ export function Office({ view, traffic, q = "", lease, onOpen, assetUrl, assetIm
1465
1494
  ${canShare ? html`<${ShareButton} offices=${stored} current=${overview ? "all" : custom ? custom.id : null} disabled=${!!layout}
1466
1495
  get=${lease ? (path, o) => lease.fetch(path, o) : undefined} />` : null}
1467
1496
  </div>
1497
+ ${traffic && keyOpen ? html`<${OfficeKey} pal=${pal} />` : null}
1468
1498
  ${editing && !overview ? html`<${OfficeEditor} offices=${stored} rev=${storedRev} view=${view} store=${store} packs=${packs}
1469
1499
  ask=${ask} current=${() => shown.current} onSaved=${adopt} onClose=${() => setEditing(false)} />` : null}
1470
1500
  ${overview || people.length || L.rooms.length ? null : html`<p class="note oo-empty">${to("nobody")} ${auto && (f.team || f.pinned.length) ? html`<button type="button" class="btn btn-sm" onClick=${() => setF({ team: "", pinned: [] })}>${to("everyone")}</button>` : null}</p>`}
@@ -49,6 +49,7 @@ register("log", {
49
49
  user: "Asked", assistant: "Replied", error: "Error", errorCode: "Error {0}", turnEnd: "Turn ended", turnEndIn: "Turn ended · {0}",
50
50
  compacted: "Conversation compacted", turn: "Turn",
51
51
  running: "running", done: "done", failed: "failed", interrupted: "interrupted", exit: "exit {0}",
52
+ showScreen: "Show what was on screen", hideScreen: "Hide what was on screen",
52
53
  showOutput: "Show output", hideOutput: "Hide output", args: "Arguments", output: "Output", noOutput: "No output.",
53
54
  showAll: "Show all", showLess: "Show less", cut: "… {0} more characters not shown.", tooLarge: "Output of {0}: only its start is shown.",
54
55
  times: "×{0}", newer: "{0} new",
@@ -87,6 +88,7 @@ register("log", {
87
88
  user: "收到要求", assistant: "回覆", error: "錯誤", errorCode: "錯誤 {0}", turnEnd: "回合結束", turnEndIn: "回合結束 · {0}",
88
89
  compacted: "對話已壓縮", turn: "回合",
89
90
  running: "執行中", done: "完成", failed: "失敗", interrupted: "中斷", exit: "結束碼 {0}",
91
+ showScreen: "顯示當時的畫面", hideScreen: "隱藏當時的畫面",
90
92
  showOutput: "顯示輸出", hideOutput: "隱藏輸出", args: "參數", output: "輸出", noOutput: "沒有輸出。",
91
93
  showAll: "顯示全部", showLess: "收合", cut: "… 還有 {0} 個字元沒有顯示。", tooLarge: "輸出共 {0},只顯示開頭。",
92
94
  times: "×{0}", newer: "{0} 則新的",
@@ -402,7 +404,11 @@ function Row({ x, prev }) {
402
404
  };
403
405
  let row;
404
406
  if (x.kind === "event") {
405
- row = html`${head(KIND_ICON.event, eventLabel(x.type))}${x.detail ? html`<div class="log-text muted">${x.detail}</div>` : null}`;
407
+ // M-2269: a dialog or prompt AgEnD saw (the CLI never writes it to its transcript): its text as it was on screen,
408
+ // folded until asked for. Text only (a text node), masked by the server.
409
+ row = html`${head(KIND_ICON.event, eventLabel(x.type))}${x.detail ? html`<div class="log-text muted">${x.detail}</div>` : null}
410
+ ${x.screen ? html`<button type="button" class="link-btn log-toggle log-screen-toggle" aria-expanded=${open ? "true" : "false"} onClick=${() => setOpen(!open)}>${open ? tl("hideScreen") : tl("showScreen")}</button>` : null}
411
+ ${x.screen && open ? html`<div class="log-detail"><div class="log-text mono log-screen">${x.screen}</div></div>` : null}`;
406
412
  } else if (x.kind === "user" || x.kind === "assistant") {
407
413
  row = html`${head(KIND_ICON[x.kind], tl(x.kind))}${text(x.body, x.bodyCut)}`;
408
414
  } else if (x.kind === "error") {
@@ -78,7 +78,7 @@ register("app", {
78
78
  statusQueued: "queued", statusStarting: "starting", fleetStarting: "Fleet starting {0}/{1}…", statusRunning: "running", statusStopped: "stopped", statusPaused: "paused", statusCrashed: "crashed", statusWorking: "working", statusStuck: "looks stuck", statusIdle: "idle",
79
79
  connReconnecting: "Can't reach AgEnD — it may be restarting. Reconnecting…", connReconnectingIn: "Can't reach AgEnD — it may be restarting. Trying again in {0} s.",
80
80
  hydrating: "Loading what is open now…", hydrateFailed: "Could not load what is open now (prompts, ticks).",
81
- skip: "Skip to the conversation", loadFailed: "This could not be loaded.", retry: "Try again", loading: "Loading…",
81
+ skip: "Skip to the conversation", loadFailed: "This could not be loaded.", retry: "Try again", reloadPage: "Reload the page", loading: "Loading…",
82
82
  noInstances: "No instances yet", noInstancesHint: "Create one to start chatting.",
83
83
  guestChip: "Guest", guestAs: "Guest · {0}", guestAsOn: "Guest · {0} ({1})", guestHint: "You see only the agents you can already use in chat.",
84
84
  session: "Session", signIn: "Sign in", signedIn: "Signed in", signedOut: "Not signed in", thisDevice: "This device", devicePrefs: "Theme and language",
@@ -126,7 +126,7 @@ register("app", {
126
126
  statusQueued: "等待啟動", statusStarting: "啟動中", fleetStarting: "Fleet 啟動中 {0}/{1}…", statusRunning: "執行中", statusStopped: "已停止", statusPaused: "已暫停", statusCrashed: "已當掉", statusWorking: "工作中", statusStuck: "似乎卡住", statusIdle: "閒置",
127
127
  connReconnecting: "連不上 AgEnD——可能正在重新啟動。正在重新連線…", connReconnectingIn: "連不上 AgEnD——可能正在重新啟動。{0} 秒後再試。",
128
128
  hydrating: "正在載入目前的狀態…", hydrateFailed: "無法載入目前的狀態(提示、送達標記)。",
129
- skip: "跳到對話", loadFailed: "無法載入。", retry: "再試一次", loading: "載入中…",
129
+ skip: "跳到對話", loadFailed: "無法載入。", retry: "再試一次", reloadPage: "重新載入頁面", loading: "載入中…",
130
130
  noInstances: "還沒有 instance", noInstancesHint: "建立一個就能開始聊天。",
131
131
  guestChip: "訪客", guestAs: "訪客 · {0}", guestAsOn: "訪客 · {0}({1})", guestHint: "你只會看到在聊天中本來就能用的 agent。",
132
132
  session: "登入狀態", signIn: "登入", signedIn: "已登入", signedOut: "尚未登入", thisDevice: "這台裝置", devicePrefs: "主題與語言",
@@ -508,12 +508,14 @@ function BottomTabs({ route, viewOnly }) {
508
508
  </nav>`;
509
509
  }
510
510
 
511
- function ConnectionLine() {
511
+ export function ConnectionLine() {
512
512
  const { connection, hydration, retryHydration } = useStore(appStore);
513
513
  // A late catch-up that is not done: the chat may be missing what was open before it loaded (#1425 review).
514
+ // M-2260: never a dead end — beside Try again, a reload reads everything current, whatever kept the catch-up from it.
514
515
  if (hydration === "retrying" || hydration === "failed") {
515
516
  return html`<div data-trust="status" class="conn" role="status">${hydration === "failed" ? t("app.hydrateFailed") : t("app.hydrating")}
516
- ${hydration === "failed" && retryHydration ? html` <button type="button" class="btn btn-sm" onClick=${() => retryHydration()}>${t("app.retry")}</button>` : null}</div>`;
517
+ ${hydration === "failed" && retryHydration ? html` <button type="button" class="btn btn-sm" onClick=${() => retryHydration()}>${t("app.retry")}</button>` : null}
518
+ ${hydration === "failed" ? html` <button type="button" class="btn btn-sm" onClick=${() => location.reload()}>${t("app.reloadPage")}</button>` : null}</div>`;
517
519
  }
518
520
  if (connection === "reconnecting") return html`<${Reconnecting} />`;
519
521
  // #1603: polling is a working transport (the public link polls by design; a buffering proxy makes a page poll), not
@@ -27,6 +27,9 @@
27
27
  // - every reconnect catches up once the new stream speaks (catchUp(), with the page's cursor — #1566/#1578), so what
28
28
  // was said meanwhile is shown, once.
29
29
  // - a 401 is the session ending: nothing is retried (agend-auth.js says so).
30
+ // - M-2260: a catch-up after a long absence carries only the newest messages (the server's budget) and says so
31
+ // (`truncated`): "gap" tells the chats to read their newest history again — it converges like a reload, never by
32
+ // asking a slow link for hours of replies in one answer.
30
33
  // - suspend()/resume(): the page is leaving (pagehide) / came back from the back-forward cache.
31
34
 
32
35
  // #1218: steps_tick — an instance's transcript moved while a Log watches it (SSE only: a poll page asks the Log itself).
@@ -79,6 +82,22 @@ export function createStream(opts) {
79
82
  try { return Object.freeze({ source: "sse", receivedAt: now() }); } catch { return undefined; }
80
83
  }
81
84
  function setConnection(c) { if (state.connection !== c) { state.connection = c; emit("connection", c); } }
85
+
86
+ // M-2260: a tab the browser froze for hours gets every heartbeat it missed at once when it wakes — thousands of status
87
+ // frames, each a whole snapshot, each redrawing the page (seconds of work before the newest message shows). A status
88
+ // that comes after a quiet moment is delivered at once, as before; more statuses before the next turn of the page's
89
+ // task loop replace the waiting one, delivered then — or before any other event, so the order of events never changes.
90
+ // The next turn is a 0 ms timer: in a hidden tab the browser may run it late (a minute when throttled) — a status
91
+ // then waits that long while nobody looks (Needs you, the title's count and notifications ride `needs`, never held).
92
+ let pendingStatus = null, statusBurst = false;
93
+ const turn = () => env.setTimeout(endBurst, 0);
94
+ function endBurst() { statusBurst = false; flushStatus(); }
95
+ function flushStatus() { const d = pendingStatus; pendingStatus = null; if (d !== null) live("status", d, null); }
96
+ function liveStatus(data) {
97
+ if (statusBurst) { pendingStatus = data; return; }
98
+ statusBurst = true; turn();
99
+ live("status", data, null);
100
+ }
82
101
  function setHydration(h) { if (state.hydration !== h) { state.hydration = h; emit("hydration", h); } }
83
102
 
84
103
  /** A live event: delivered now, or held (in order) while a catch-up snapshot is on its way. */
@@ -115,6 +134,7 @@ export function createStream(opts) {
115
134
  if (polling) { if (!opts.join) pollAgain = true; return polling; }
116
135
  const mine = ++pollSeq, ep = readEpoch;
117
136
  let status = 0; // this read's HTTP status — its own, never another read's
137
+ flushStatus(); // a status that came before this read is older than its snapshot
118
138
  if (sse && !held) held = [];
119
139
  let limit = null;
120
140
  const read = (async () => {
@@ -128,6 +148,9 @@ export function createStream(opts) {
128
148
  if (d.status) emit("status", d.status);
129
149
  for (const m of d.messages || []) emit("message", m, { source: "poll" });
130
150
  if (typeof d.cursor === "string") state.cursor = d.cursor;
151
+ // M-2260: the server sent only the newest messages of a long absence (its catch-up budget): the chats read their
152
+ // newest history again, as a reload would, instead of this read carrying everything that was missed.
153
+ if (d.truncated === true) emit("gap", {});
131
154
  if (Array.isArray(d.deliveries)) emit("deliveries", d.deliveries);
132
155
  if (Array.isArray(d.prompts)) emit("prompts", d.prompts);
133
156
  if (Array.isArray(d.reply_buttons)) for (const u of d.reply_buttons) emit("reply_buttons", u); // #1266
@@ -157,6 +180,7 @@ export function createStream(opts) {
157
180
  }
158
181
  /** #1580 review: the page lets go of everything in flight — reads, the queued poll, the catch-up and its retry wait. */
159
182
  function cancelWork() {
183
+ pendingStatus = null; // a page let go of (suspend, end, close) draws no waiting status
160
184
  readEpoch++; epoch++;
161
185
  pollSeq++; polling = null; pollAgain = false; held = null;
162
186
  env.clearTimeout(retryTimer); retryTimer = null;
@@ -225,6 +249,8 @@ export function createStream(opts) {
225
249
  else alive();
226
250
  // #1823: a clock reading is about now, not about order: it is never held behind a catch-up (it would be stale).
227
251
  if (name === "clock") { emit(name, data); return; }
252
+ if (name === "status") { liveStatus(data); return; }
253
+ flushStatus(); // a status waiting goes first: the order never changes
228
254
  live(name, data, name === "message" ? e.lastEventId : null, ingress);
229
255
  });
230
256
  }
@@ -1872,6 +1872,12 @@ strong.org-name { font-size: var(--fs-sm); color: var(--text); }
1872
1872
  .oo-canvas:focus-visible { outline: 2px solid var(--focus); outline-offset: -2px; }
1873
1873
  .oo-canvas.hot { cursor: pointer; }
1874
1874
  /* The canvas controls, as the org chart canvas's (.oc-zoom, .oc-mini): over the stage's corners. */
1875
+ /* #2946: the key to the items carried between desks — a strip under the bar, as the org chart's (.oc-legend). */
1876
+ .oo-key { font-size: var(--fs-xs); color: var(--text-2); background: var(--surface); border: 1px solid var(--border); border-radius: var(--r-md); padding: 4px 10px; }
1877
+ .oo-key-rows { display: flex; flex-wrap: wrap; gap: var(--s-1) var(--s-4); margin: 0; padding: 0; list-style: none; }
1878
+ .oo-key-row { display: inline-flex; align-items: center; gap: 6px; }
1879
+ .oo-key-icon { width: 32px; height: 32px; flex: none; image-rendering: pixelated; }
1880
+ .oo-key-btn.on { border-color: var(--accent); }
1875
1881
  .oo-zoom { z-index: 6; }
1876
1882
  .oo-zoom .icon-btn { color: var(--text); }
1877
1883
  /* The overview's box (left, top, width, height) is set by the page, inside the stage and scaled to it (miniBox). */
@@ -0,0 +1,6 @@
1
+ /** Runs `$1` detached from any terminal, at low priority; HUP/INT/TERM on this shell reach its whole process group. */
2
+ export declare const UNATTENDED_INSTALL_SCRIPT: string;
3
+ /** The account's login shell, when it is an absolute path to an executable; else undefined (keep the inherited one). */
4
+ export declare function loginShell(read?: () => string | null | undefined): string | undefined;
5
+ /** The window's command for an install command. `shell`: the $SHELL to give it; null keeps the inherited one. */
6
+ export declare function unattendedInstallCommand(command: string, shell?: string | null): string;