@oharness/server 0.9.3 → 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (93) hide show
  1. package/dist/analytics.d.ts +32 -29
  2. package/dist/analytics.js +225 -278
  3. package/dist/analytics.js.map +1 -1
  4. package/dist/i18n.js +500 -64
  5. package/dist/i18n.js.map +1 -1
  6. package/dist/icons.js +1 -0
  7. package/dist/icons.js.map +1 -1
  8. package/dist/main.js +4 -0
  9. package/dist/main.js.map +1 -1
  10. package/dist/model-order.js +1 -0
  11. package/dist/model-order.js.map +1 -1
  12. package/dist/page/client/account.js +570 -27
  13. package/dist/page/client/account.js.map +1 -1
  14. package/dist/page/client/base.js +55 -12
  15. package/dist/page/client/base.js.map +1 -1
  16. package/dist/page/client/browser.js +1 -1
  17. package/dist/page/client/composer.js +802 -59
  18. package/dist/page/client/composer.js.map +1 -1
  19. package/dist/page/client/computer.d.ts +27 -0
  20. package/dist/page/client/computer.js +358 -0
  21. package/dist/page/client/computer.js.map +1 -0
  22. package/dist/page/client/dialogs.js +168 -6
  23. package/dist/page/client/dialogs.js.map +1 -1
  24. package/dist/page/client/events.js +119 -9
  25. package/dist/page/client/events.js.map +1 -1
  26. package/dist/page/client/files.js +86 -1
  27. package/dist/page/client/files.js.map +1 -1
  28. package/dist/page/client/index.js +8 -0
  29. package/dist/page/client/index.js.map +1 -1
  30. package/dist/page/client/panels.js +14 -10
  31. package/dist/page/client/panels.js.map +1 -1
  32. package/dist/page/client/ripple.d.ts +13 -0
  33. package/dist/page/client/ripple.js +247 -0
  34. package/dist/page/client/ripple.js.map +1 -0
  35. package/dist/page/client/routines.js +432 -45
  36. package/dist/page/client/routines.js.map +1 -1
  37. package/dist/page/client/shell.js +386 -104
  38. package/dist/page/client/shell.js.map +1 -1
  39. package/dist/page/client/state.js +278 -19
  40. package/dist/page/client/state.js.map +1 -1
  41. package/dist/page/client/tasks.d.ts +8 -0
  42. package/dist/page/client/tasks.js +293 -0
  43. package/dist/page/client/tasks.js.map +1 -0
  44. package/dist/page/client/terminal.js +34 -7
  45. package/dist/page/client/terminal.js.map +1 -1
  46. package/dist/page/client/theme.d.ts +17 -0
  47. package/dist/page/client/theme.js +365 -0
  48. package/dist/page/client/theme.js.map +1 -0
  49. package/dist/page/client/transcript.js +196 -8
  50. package/dist/page/client/transcript.js.map +1 -1
  51. package/dist/page/client/usage.js +1 -11
  52. package/dist/page/client/usage.js.map +1 -1
  53. package/dist/page/style.js +745 -76
  54. package/dist/page/style.js.map +1 -1
  55. package/dist/page.js +94 -16
  56. package/dist/page.js.map +1 -1
  57. package/dist/prefs.d.ts +31 -0
  58. package/dist/prefs.js +13 -1
  59. package/dist/prefs.js.map +1 -1
  60. package/dist/remote-crypto.d.ts +78 -0
  61. package/dist/remote-crypto.js +216 -0
  62. package/dist/remote-crypto.js.map +1 -0
  63. package/dist/remote.d.ts +103 -0
  64. package/dist/remote.js +498 -0
  65. package/dist/remote.js.map +1 -0
  66. package/dist/server.d.ts +70 -0
  67. package/dist/server.js +1738 -73
  68. package/dist/server.js.map +1 -1
  69. package/dist/starters.d.ts +92 -0
  70. package/dist/starters.js +213 -0
  71. package/dist/starters.js.map +1 -0
  72. package/dist/suggest.d.ts +42 -0
  73. package/dist/suggest.js +215 -0
  74. package/dist/suggest.js.map +1 -0
  75. package/dist/terminal.d.ts +28 -2
  76. package/dist/terminal.js +45 -3
  77. package/dist/terminal.js.map +1 -1
  78. package/dist/theme-assets.d.ts +39 -0
  79. package/dist/theme-assets.js +109 -0
  80. package/dist/theme-assets.js.map +1 -0
  81. package/dist/theme-store.d.ts +50 -0
  82. package/dist/theme-store.js +124 -0
  83. package/dist/theme-store.js.map +1 -0
  84. package/dist/theme-tools.d.ts +25 -0
  85. package/dist/theme-tools.js +243 -0
  86. package/dist/theme-tools.js.map +1 -0
  87. package/dist/turn-shots.d.ts +43 -0
  88. package/dist/turn-shots.js +80 -0
  89. package/dist/turn-shots.js.map +1 -0
  90. package/dist/user-theme.d.ts +143 -0
  91. package/dist/user-theme.js +510 -0
  92. package/dist/user-theme.js.map +1 -0
  93. package/package.json +2 -2
@@ -0,0 +1,365 @@
1
+ /**
2
+ * Themes the agent proposes, and the one in use.
3
+ *
4
+ * A proposal arrives as a card in the conversation that asked for it. Nothing
5
+ * changes until the person acts on the card: Preview tries it on this page
6
+ * only, Apply makes it the theme everywhere, Discard drops it. The applied
7
+ * theme is printed into the page by the server and replaced here when another
8
+ * window changes it.
9
+ *
10
+ * Safe mode is the way out of a theme that made the page unreadable, so it is
11
+ * the one thing here that needs no theme, no card and no conversation: the
12
+ * page loaded with ?theme=default, or the shortcut below, and custom colours
13
+ * are off in this window until it is pressed again.
14
+ *
15
+ * One part of the client script; see ./index.ts for how the parts join.
16
+ */
17
+ export const THEME = String.raw `
18
+ // --- theme ----------------------------------------------------------------
19
+
20
+ const themeCards = new Map();
21
+ let themeState = null;
22
+ let themePreviewing = null;
23
+ const THEME_SAFE_KEY = "oharness-theme-safe";
24
+ const themeSafeKey = IS_MAC ? "⌘⇧0" : "Ctrl+Shift+0";
25
+
26
+ function themeSafe() {
27
+ if (new URLSearchParams(location.search).get("theme") === "default") return true;
28
+ try { return sessionStorage.getItem(THEME_SAFE_KEY) === "1"; } catch { return false; }
29
+ }
30
+
31
+ function themeTag(id) {
32
+ let tag = document.getElementById(id);
33
+ if (!tag) {
34
+ tag = document.createElement("style");
35
+ tag.id = id;
36
+ document.head.appendChild(tag);
37
+ }
38
+ return tag;
39
+ }
40
+
41
+ /** Put the applied theme and any preview on the page, unless safe mode is on. */
42
+ function paintTheme() {
43
+ const safe = themeSafe();
44
+ themeTag("oh-user-theme").textContent = safe || !themeState ? "" : themeState.css;
45
+ const preview = themePreviewing ? themeCards.get(themePreviewing) : null;
46
+ // After the applied theme in the document, so a preview wins on order.
47
+ themeTag("oh-theme-preview").textContent = safe || !preview ? "" : preview.preview.css;
48
+ void measureWall();
49
+ }
50
+
51
+ /**
52
+ * How much of the page colour has to cover the conversation background for
53
+ * its text to stay readable, measured from the image itself.
54
+ *
55
+ * The image is drawn small, so each pixel is the average of a region, and its
56
+ * darkest and lightest are taken as the worst cases text could land on. The
57
+ * scrim is then raised until body text and secondary text both clear 4.5:1
58
+ * over both, with the scrim mixed in the way the stylesheet mixes it. The
59
+ * result is a floor: the stylesheet uses it or the theme's own scrim,
60
+ * whichever is more, so a theme can be calmer than this but never busier.
61
+ */
62
+ let wallMeasured = "";
63
+ async function measureWall() {
64
+ const logEl = document.getElementById("log");
65
+ const root = getComputedStyle(document.documentElement);
66
+ const image = getComputedStyle(logEl).getPropertyValue("--wall-image").trim();
67
+ const match = /url\(["']?(\/theme-asset\/[0-9a-f]{64}\.(?:png|jpg|webp))["']?\)/.exec(image);
68
+ // Read from the root: #log may carry a lifted --dim of its own from last time.
69
+ const colours = ["--bg", "--fg", "--dim"].map((name) => root.getPropertyValue(name).trim());
70
+ const key = (match ? match[1] : "") + "|" + colours.join(",");
71
+ if (key === wallMeasured) return;
72
+ wallMeasured = key;
73
+ if (!match) {
74
+ logEl.style.removeProperty("--wall-scrim-min");
75
+ logEl.style.removeProperty("--dim");
76
+ return;
77
+ }
78
+
79
+ const img = new Image();
80
+ img.src = match[1];
81
+ try { await img.decode(); } catch { return; }
82
+ if (wallMeasured !== key) return;
83
+ const canvas = document.createElement("canvas");
84
+ canvas.width = 48; canvas.height = 32;
85
+ const ctx = canvas.getContext("2d", { willReadFrequently: true });
86
+ ctx.drawImage(img, 0, 0, canvas.width, canvas.height);
87
+ const data = ctx.getImageData(0, 0, canvas.width, canvas.height).data;
88
+
89
+ const rgb = (hex) => {
90
+ const m = /^#([0-9a-f]{6})/i.exec(hex);
91
+ if (!m) return null;
92
+ const n = parseInt(m[1], 16);
93
+ return [n >> 16 & 255, n >> 8 & 255, n & 255];
94
+ };
95
+ const [bg, fg, dim] = colours.map(rgb);
96
+ if (!bg || !fg || !dim) return;
97
+ const lin = (v) => { v /= 255; return v <= 0.04045 ? v / 12.92 : Math.pow((v + 0.055) / 1.055, 2.4); };
98
+ const lum = (c) => 0.2126 * lin(c[0]) + 0.7152 * lin(c[1]) + 0.0722 * lin(c[2]);
99
+ const ratio = (a, b) => { const x = lum(a), y = lum(b); return (Math.max(x, y) + 0.05) / (Math.min(x, y) + 0.05); };
100
+
101
+ let darkest = null, lightest = null;
102
+ for (let i = 0; i < data.length; i += 4) {
103
+ const px = [data[i], data[i + 1], data[i + 2]];
104
+ if (!darkest || lum(px) < lum(darkest)) darkest = px;
105
+ if (!lightest || lum(px) > lum(lightest)) lightest = px;
106
+ }
107
+ // Two levers, used in this order: how far secondary text may be lifted
108
+ // toward body text (up to 60%, so it still reads as secondary), and how much
109
+ // of the page colour covers the image. Without the first, a theme whose dim
110
+ // only just passes on its own background would need the image covered
111
+ // completely, and the background would be there and invisible.
112
+ const lift = (t) => dim.map((v, i) => v * (1 - t) + fg[i] * t);
113
+ let cover = 100, lifted = 0;
114
+ search: for (let pct = 0; pct <= 100; pct += 2) {
115
+ const a = pct / 100;
116
+ const unders = [darkest, lightest].map((px) => px.map((v, i) => bg[i] * a + v * (1 - a)));
117
+ if (!unders.every((under) => ratio(fg, under) >= 4.5)) continue;
118
+ for (let step = 0; step <= 12; step++) {
119
+ const t = step * 0.05;
120
+ if (unders.every((under) => ratio(lift(t), under) >= 4.5)) { cover = pct; lifted = t; break search; }
121
+ }
122
+ }
123
+ logEl.style.setProperty("--wall-scrim-min", cover + "%");
124
+ // The theme's own scrim may be heavier than the floor; secondary text then
125
+ // needs less lift, or none, and keeps more of its own colour.
126
+ const themed = parseFloat(getComputedStyle(logEl).getPropertyValue("--wall-scrim")) || 0;
127
+ if (themed > cover) {
128
+ const a = themed / 100;
129
+ const unders = [darkest, lightest].map((px) => px.map((v, i) => bg[i] * a + v * (1 - a)));
130
+ for (let step = 0; step <= 12; step++) {
131
+ if (unders.every((under) => ratio(lift(step * 0.05), under) >= 4.5)) { lifted = step * 0.05; break; }
132
+ }
133
+ }
134
+ if (lifted > 0) {
135
+ const hex = "#" + lift(lifted).map((v) => Math.round(v).toString(16).padStart(2, "0")).join("");
136
+ logEl.style.setProperty("--dim", hex);
137
+ } else {
138
+ logEl.style.removeProperty("--dim");
139
+ }
140
+ }
141
+
142
+ // The colours the floor was measured against change with the system theme
143
+ // and with the light/dark switch; measure again when they do.
144
+ window.matchMedia("(prefers-color-scheme: dark)").addEventListener("change", () => void measureWall());
145
+ new MutationObserver(() => void measureWall())
146
+ .observe(document.documentElement, { attributes: true, attributeFilter: ["data-theme"] });
147
+
148
+ function themeSwatchRow(label, colours) {
149
+ const row = el("div", "theme-swatch-row");
150
+ row.appendChild(el("span", "theme-swatch-label", label));
151
+ for (const colour of colours) {
152
+ const swatch = el("span", "theme-swatch");
153
+ // Validated hex from the server; set as a property, never as markup.
154
+ swatch.style.background = colour;
155
+ swatch.title = colour;
156
+ row.appendChild(swatch);
157
+ }
158
+ return row;
159
+ }
160
+
161
+ function themeReportList(title, lines) {
162
+ if (!lines || !lines.length) return null;
163
+ const box = el("div", "theme-report");
164
+ box.appendChild(el("div", "theme-report-title", title));
165
+ for (const line of lines) box.appendChild(el("div", "theme-report-line", line));
166
+ return box;
167
+ }
168
+
169
+ async function themeAction(path, body, box) {
170
+ const result = await api(path, body);
171
+ if (result.error) {
172
+ const note = el("p", "err", t("theme.failed", { error: result.error }));
173
+ box.appendChild(note);
174
+ return null;
175
+ }
176
+ if (result.state) setThemeState(result.state);
177
+ return result;
178
+ }
179
+
180
+ function renderThemeCard(entry) {
181
+ const { box, preview } = entry;
182
+ box.replaceChildren();
183
+ const head = el("div", "theme-card-head");
184
+ head.appendChild(el("span", "theme-card-name", t("theme.previewTitle", { name: preview.name })));
185
+ const statusKey = themePreviewing === preview.id ? "theme.status.previewing" : "theme.status." + entry.status;
186
+ head.appendChild(el("span", "theme-card-status " + entry.status, t(statusKey)));
187
+ box.appendChild(head);
188
+
189
+ const swatches = el("div", "theme-swatches");
190
+ if (preview.swatches.light) swatches.appendChild(themeSwatchRow(t("theme.light"), preview.swatches.light));
191
+ if (preview.swatches.dark) swatches.appendChild(themeSwatchRow(t("theme.dark"), preview.swatches.dark));
192
+ box.appendChild(swatches);
193
+
194
+ if (preview.images && preview.images.length) {
195
+ const images = el("div", "theme-images");
196
+ for (const image of preview.images) {
197
+ const figure = el("div", "theme-image");
198
+ const thumb = el("img");
199
+ // Only our own asset route, as the server printed it.
200
+ if (/^\/theme-asset\/[0-9a-f]{64}\.(png|jpg|webp)$/.test(image.url)) thumb.src = image.url;
201
+ thumb.alt = "";
202
+ figure.appendChild(thumb);
203
+ figure.appendChild(el("span", "", t("theme.slot." + image.slot)));
204
+ images.appendChild(figure);
205
+ }
206
+ box.appendChild(images);
207
+ }
208
+
209
+ for (const part of [
210
+ themeReportList(t("theme.fixed"), preview.report.fixed),
211
+ themeReportList(t("theme.dropped"), preview.report.dropped),
212
+ themeReportList(t("theme.notes"), preview.report.notes),
213
+ ]) if (part) box.appendChild(part);
214
+
215
+ const actions = el("div", "connection-actions theme-actions");
216
+ if (entry.status === "pending") {
217
+ const previewing = themePreviewing === preview.id;
218
+ const tryOn = el("button", "ghost", t(previewing ? "theme.stopPreview" : "theme.preview"));
219
+ tryOn.type = "button";
220
+ tryOn.setAttribute("aria-pressed", previewing ? "true" : "false");
221
+ tryOn.onclick = () => {
222
+ themePreviewing = previewing ? null : preview.id;
223
+ paintTheme();
224
+ for (const other of themeCards.values()) renderThemeCard(other);
225
+ };
226
+ const discard = el("button", "ghost", t("theme.discard"));
227
+ discard.type = "button";
228
+ discard.onclick = async () => {
229
+ discard.disabled = true;
230
+ await themeAction("/theme/discard", { preview: preview.id }, box);
231
+ closeThemePreview(preview.id, "discarded");
232
+ };
233
+ const apply = el("button", "ghost theme-apply", t("theme.apply"));
234
+ apply.type = "button";
235
+ apply.onclick = async () => {
236
+ apply.disabled = true;
237
+ const done = await themeAction("/theme/apply", { preview: preview.id }, box);
238
+ if (done) closeThemePreview(preview.id, "applied");
239
+ else apply.disabled = false;
240
+ };
241
+ actions.appendChild(tryOn);
242
+ actions.appendChild(discard);
243
+ actions.appendChild(apply);
244
+ } else if (entry.status === "applied") {
245
+ const undo = el("button", "ghost", t("theme.undo"));
246
+ undo.type = "button";
247
+ undo.disabled = !(themeState && themeState.canUndo);
248
+ undo.onclick = async () => { undo.disabled = true; await themeAction("/theme/undo", {}, box); };
249
+ const reset = el("button", "ghost", t("theme.reset"));
250
+ reset.type = "button";
251
+ reset.disabled = !(themeState && themeState.active);
252
+ reset.onclick = async () => { reset.disabled = true; await themeAction("/theme/reset", {}, box); };
253
+ actions.appendChild(undo);
254
+ actions.appendChild(reset);
255
+ }
256
+ if (actions.childNodes.length) box.appendChild(actions);
257
+ box.appendChild(el("p", "theme-safe-hint", t("theme.safeHint", { key: themeSafeKey })));
258
+ }
259
+
260
+ /**
261
+ * The way back that does not depend on a card.
262
+ *
263
+ * A card lives in the conversation that proposed the theme and in this
264
+ * server's memory, so after a restart or in another session there is no card
265
+ * to press Reset on. This row sits under the light/dark switch in the account
266
+ * menu, and is there only while a custom theme is in use.
267
+ */
268
+ function customThemeRows() {
269
+ if (!themeState || !themeState.active) return [];
270
+ const row = el("div", "menu-theme menu-custom-theme");
271
+ // The name alone: the row sits under "Theme", so what it is goes without
272
+ // saying, and every character spent saying it was one the name lost.
273
+ const label = el("span", "menu-label", themeState.active.name);
274
+ label.title = t("theme.custom", { name: themeState.active.name });
275
+ row.appendChild(label);
276
+ const group = el("div", "custom-theme-actions");
277
+ const act = (key, path, enabled, longKey) => {
278
+ const button = el("button", "custom-theme-btn", t(key));
279
+ button.type = "button";
280
+ button.title = t(longKey || key);
281
+ button.setAttribute("aria-label", t(longKey || key));
282
+ button.disabled = !enabled;
283
+ button.addEventListener("click", async (e) => {
284
+ e.stopPropagation();
285
+ button.disabled = true;
286
+ const result = await api(path, {});
287
+ if (result.state) setThemeState(result.state);
288
+ closeRowMenu();
289
+ if (result.error) addNotice(t("theme.failed", { error: result.error }), "err");
290
+ });
291
+ group.appendChild(button);
292
+ };
293
+ act("theme.undo", "/theme/undo", themeState.canUndo);
294
+ act("theme.resetShort", "/theme/reset", true, "theme.reset");
295
+ row.appendChild(group);
296
+ return [row];
297
+ }
298
+
299
+ /** A proposal from set_theme, drawn in the conversation that asked for it. */
300
+ function themePreviewCard(preview) {
301
+ let entry = themeCards.get(preview.id);
302
+ if (!entry) {
303
+ const target = pane(preview.session);
304
+ const box = el("section", "row-card column connection-card theme-card");
305
+ box.dataset.themePreview = preview.id;
306
+ box.setAttribute("aria-live", "polite");
307
+ target.node.appendChild(box);
308
+ entry = { box, preview, status: "pending" };
309
+ themeCards.set(preview.id, entry);
310
+ } else {
311
+ entry.preview = preview;
312
+ }
313
+ renderThemeCard(entry);
314
+ if (preview.session === activeId) follow();
315
+ }
316
+
317
+ function closeThemePreview(id, outcome) {
318
+ const entry = themeCards.get(id);
319
+ if (themePreviewing === id) themePreviewing = null;
320
+ if (entry && entry.status === "pending") entry.status = outcome;
321
+ paintTheme();
322
+ for (const other of themeCards.values()) renderThemeCard(other);
323
+ }
324
+
325
+ function setThemeState(state) {
326
+ themeState = state;
327
+ paintTheme();
328
+ for (const entry of themeCards.values()) renderThemeCard(entry);
329
+ }
330
+
331
+ async function loadThemes() {
332
+ try {
333
+ const result = await fetch(q("/theme")).then((r) => r.json());
334
+ setThemeState(result.state);
335
+ for (const preview of result.previews || []) themePreviewCard(preview);
336
+ } catch {
337
+ // The theme the server printed into the page is already showing; a card
338
+ // that could not be fetched again is not worth an error on screen.
339
+ }
340
+ }
341
+
342
+ document.addEventListener("keydown", (e) => {
343
+ if (e.code !== "Digit0") return;
344
+ if (!e.shiftKey || e.altKey) return;
345
+ if (IS_MAC ? !e.metaKey || e.ctrlKey : !e.ctrlKey || e.metaKey) return;
346
+ e.preventDefault();
347
+ const on = !themeSafe();
348
+ try {
349
+ if (on) sessionStorage.setItem(THEME_SAFE_KEY, "1");
350
+ else sessionStorage.removeItem(THEME_SAFE_KEY);
351
+ } catch { /* storage refused: the URL form of safe mode still works */ }
352
+ if (!on && new URLSearchParams(location.search).get("theme") === "default") {
353
+ // Loaded in safe mode by URL: the way back on is a load without it.
354
+ const url = new URL(location.href);
355
+ url.searchParams.delete("theme");
356
+ location.replace(url.href);
357
+ return;
358
+ }
359
+ paintTheme();
360
+ addNotice(t(on ? "theme.safeOn" : "theme.safeOff", { key: themeSafeKey }));
361
+ });
362
+
363
+ setTimeout(() => { void loadThemes(); }, 0);
364
+ `;
365
+ //# sourceMappingURL=theme.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"theme.js","sourceRoot":"","sources":["../../../src/page/client/theme.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;GAeG;AACH,MAAM,CAAC,MAAM,KAAK,GAAG,MAAM,CAAC,GAAG,CAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CA2V9B,CAAC"}
@@ -88,6 +88,14 @@ log.addEventListener("scroll", () => {
88
88
  jumpBtn.hidden = atBottom;
89
89
  });
90
90
 
91
+ // The form's height, for the jump button to sit above (see #jump in style.ts).
92
+ {
93
+ const form = document.getElementById("composer");
94
+ new ResizeObserver(() => {
95
+ document.documentElement.style.setProperty("--form-h", form.offsetHeight + "px");
96
+ }).observe(form);
97
+ }
98
+
91
99
  jumpBtn.addEventListener("click", () => {
92
100
  atBottom = true;
93
101
  jumpBtn.hidden = true;
@@ -108,13 +116,26 @@ function addUser(text, images) {
108
116
  if (images && images.length) {
109
117
  const strip = el("div", "shots");
110
118
  for (const image of images) {
119
+ const src = image.src.startsWith("blob:") ? image.src : q(image.src);
120
+ if (image.kind === "video") {
121
+ // A clip plays where it was sent, with its own controls: an <img>
122
+ // pointed at a video drew a broken-image icon beside its name.
123
+ const video = document.createElement("video");
124
+ video.src = src;
125
+ video.controls = true;
126
+ video.preload = "metadata";
127
+ video.playsInline = true;
128
+ video.title = image.name || "";
129
+ strip.appendChild(video);
130
+ continue;
131
+ }
111
132
  const img = document.createElement("img");
112
- img.src = image.src.startsWith("blob:") ? image.src : q(image.src);
133
+ img.src = src;
113
134
  img.alt = image.name || "";
114
135
  img.loading = "lazy";
115
- // A full-size screenshot in a chat bubble is unreadable; the tab it
116
- // opens is the same bytes at whatever size the screen allows.
117
- img.addEventListener("click", () => window.open(img.src, "_blank", "noopener"));
136
+ // A full-size screenshot in a chat bubble is unreadable; the preview is
137
+ // the same bytes at whatever size the window allows.
138
+ img.addEventListener("click", () => previewImage(img));
118
139
  strip.appendChild(img);
119
140
  }
120
141
  bubble.appendChild(strip);
@@ -169,8 +190,10 @@ function actButton(icon, label) {
169
190
  }
170
191
 
171
192
  function turnActions(wrap, keepMessages) {
172
- if (!keepMessages || wrap.querySelector(":scope > .actions")) return;
173
- const bar = el("div", "actions");
193
+ if (!keepMessages || wrap.querySelector(":scope > .actions[data-turn]")) return;
194
+ // One bar per reply: an @claude reply already has one, holding its button.
195
+ const bar = wrap.querySelector(":scope > .actions") || el("div", "actions");
196
+ bar.dataset.turn = "1";
174
197
 
175
198
  const fork = actButton("fork", t("turn.fork"));
176
199
  fork.addEventListener("click", () => {
@@ -191,7 +214,26 @@ function turnActions(wrap, keepMessages) {
191
214
  });
192
215
  bar.appendChild(copy);
193
216
 
194
- wrap.appendChild(bar);
217
+ if (!bar.parentNode) wrap.appendChild(bar);
218
+ }
219
+
220
+ /**
221
+ * A reply an external agent wrote (@claude): named for it rather than
222
+ * "assistant", and, when the agent's session is known, a button that opens
223
+ * that session in Claude Code itself — where the whole of it can be read,
224
+ * every tool call and result, and carried on.
225
+ */
226
+ function agentReply(wrap, agent) {
227
+ if (!wrap || !agent || !agent.name) return;
228
+ const who = wrap.querySelector(":scope > .who");
229
+ if (who) who.textContent = agent.name;
230
+ if (!agent.session || wrap.querySelector(":scope > .actions [data-agent-open]")) return;
231
+ const bar = wrap.querySelector(":scope > .actions") || el("div", "actions");
232
+ const open = actButton("command", t("projects.claudeCode"));
233
+ open.dataset.agentOpen = "1";
234
+ open.addEventListener("click", () => void openClaudeCode(agent.cwd || currentCwd, agent.session));
235
+ bar.prepend(open);
236
+ if (!bar.parentNode) wrap.appendChild(bar);
195
237
  }
196
238
 
197
239
  /**
@@ -580,6 +622,7 @@ function finishTool(id, result, ms, isError) {
580
622
  entry.body.appendChild(pre);
581
623
  }
582
624
  }
625
+ toolShots(entry.body, result);
583
626
  if (isError) {
584
627
  // A failed step is the one thing worth surfacing without a click.
585
628
  entry.card.open = true;
@@ -590,6 +633,149 @@ function finishTool(id, result, ms, isError) {
590
633
  follow();
591
634
  }
592
635
 
636
+ /**
637
+ * What a tool saw: the screenshots it returned, as thumbnails in its card.
638
+ *
639
+ * The live pane shows the window as it is now; this is the record of what the
640
+ * agent looked at, for reading a turn back afterwards. It stays inside the
641
+ * folded card — a turn that takes twenty screenshots would otherwise bury its
642
+ * own reply — and a closed card fetches nothing.
643
+ *
644
+ * Live, the bytes arrive in the event and become a blob URL, which a click can
645
+ * open in a tab where a data: URL cannot. Replayed, each is an /attachment
646
+ * position, the same as a user's pasted image.
647
+ */
648
+ function toolShots(body, result) {
649
+ const shots = (result || []).filter((part) => part.type === "image");
650
+ if (!shots.length) return;
651
+ const strip = el("div", "tool-shots");
652
+ for (const shot of shots) {
653
+ const img = document.createElement("img");
654
+ if (shot.src) {
655
+ img.src = q(shot.src);
656
+ } else if (shot.data) {
657
+ const bytes = Uint8Array.from(atob(shot.data), (c) => c.charCodeAt(0));
658
+ img.src = URL.createObjectURL(new Blob([bytes], { type: shot.mimeType || "image/jpeg" }));
659
+ } else {
660
+ continue;
661
+ }
662
+ img.alt = "";
663
+ img.loading = "lazy";
664
+ img.addEventListener("click", () => previewImage(img));
665
+ strip.appendChild(img);
666
+ }
667
+ if (strip.childElementCount) body.appendChild(strip);
668
+ }
669
+
670
+ /** Pictures a turn shows in its row; the rest open from the last of them. */
671
+ const TURN_SHOTS_SHOWN = 5;
672
+
673
+ /**
674
+ * The windows and browser tabs a turn ended on, under its reply.
675
+ *
676
+ * The agent reads the accessibility tree rather than looking, so a turn in a
677
+ * native app often has no picture of its own; the host takes one of each
678
+ * window when the turn ends (see turn-shots.ts). Out in the turn rather than
679
+ * folded into a step, because it is the result — what the work left behind —
680
+ * and one per window at most. Above the fork and copy bar, which belongs last.
681
+ *
682
+ * One row however many there are: a turn through six windows and six tabs
683
+ * would otherwise stack four rows of pictures under a two-line reply.
684
+ */
685
+ function turnShots(wrap, shots) {
686
+ if (!wrap || !shots || !shots.length) return;
687
+ let strip = wrap.querySelector(":scope > .turn-shots");
688
+ if (!strip) {
689
+ strip = el("div", "turn-shots");
690
+ const bar = wrap.querySelector(":scope > .actions");
691
+ if (bar) wrap.insertBefore(strip, bar);
692
+ else wrap.appendChild(strip);
693
+ }
694
+ for (const shot of shots) {
695
+ const figure = el("figure", "turn-shot");
696
+ const frame = el("div", "turn-shot-frame");
697
+ const img = document.createElement("img");
698
+ img.src = q(shot.src);
699
+ img.alt = shot.app || "";
700
+ img.loading = "lazy";
701
+ img.addEventListener("click", () => previewImage(img));
702
+ img.addEventListener("error", () => { figure.remove(); fitTurnShots(strip); });
703
+ frame.appendChild(img);
704
+ figure.appendChild(frame);
705
+ // A browser tab is captioned by the browser and the page; its title says
706
+ // more than its host, which is the fallback.
707
+ const label = shot.kind === "browser"
708
+ ? t("turnShots.browser") + " — " + (shot.title || shot.app)
709
+ : shot.title ? shot.app + " — " + shot.title : shot.app;
710
+ const caption = el("figcaption", "", label);
711
+ caption.title = caption.textContent;
712
+ figure.appendChild(caption);
713
+ strip.appendChild(figure);
714
+ }
715
+ fitTurnShots(strip);
716
+ follow();
717
+ }
718
+
719
+ /**
720
+ * Show the first pictures of a row and fold the rest behind a "+N" on the last
721
+ * one shown. Run again whenever the row changes: Computer use and the browser
722
+ * each add theirs, and a picture that fails to load takes itself out.
723
+ */
724
+ function fitTurnShots(strip) {
725
+ const figures = [...strip.querySelectorAll(":scope > .turn-shot")];
726
+ const more = figures.length - TURN_SHOTS_SHOWN;
727
+ strip.querySelectorAll(".turn-shot-more").forEach((badge) => badge.remove());
728
+ figures.forEach((figure, index) => { figure.hidden = index >= TURN_SHOTS_SHOWN; });
729
+ if (more <= 0) return;
730
+ const last = figures[TURN_SHOTS_SHOWN - 1];
731
+ const img = last.querySelector("img");
732
+ const badge = el("button", "turn-shot-more", "+" + more);
733
+ badge.type = "button";
734
+ badge.setAttribute("aria-label", t("turnShots.more", { n: more }));
735
+ badge.addEventListener("click", () => previewImage(img));
736
+ last.querySelector(".turn-shot-frame").appendChild(badge);
737
+ }
738
+
739
+ /**
740
+ * An image at full size, over the page.
741
+ *
742
+ * It used to open in a new tab, which in the desktop app is a new window with
743
+ * no way back but closing it — and a blob URL there is a blank page. This
744
+ * stays in the conversation: Esc or a click anywhere closes it, and the arrow
745
+ * keys step through the other images in the same row, which for a turn of
746
+ * screenshots is the order the agent saw them in.
747
+ */
748
+ function previewImage(img) {
749
+ const strip = img.closest(".turn-shots, .tool-shots, .shots, #tray") || img.parentElement;
750
+ const row = [...strip.querySelectorAll("img")];
751
+ let at = Math.max(0, row.indexOf(img));
752
+ const scrim = el("div", "img-preview");
753
+ const big = document.createElement("img");
754
+ big.alt = "";
755
+ const count = el("div", "img-preview-count");
756
+ scrim.append(big, count);
757
+ const show = () => {
758
+ big.src = row[at].src;
759
+ count.textContent = row.length > 1 ? at + 1 + " / " + row.length : "";
760
+ };
761
+ const close = () => {
762
+ scrim.remove();
763
+ document.removeEventListener("keydown", onKey, true);
764
+ };
765
+ const onKey = (event) => {
766
+ if (event.key === "Escape") close();
767
+ else if (event.key === "ArrowRight" && at < row.length - 1) { at++; show(); }
768
+ else if (event.key === "ArrowLeft" && at > 0) { at--; show(); }
769
+ else return;
770
+ event.preventDefault();
771
+ event.stopPropagation();
772
+ };
773
+ scrim.addEventListener("click", close);
774
+ document.addEventListener("keydown", onKey, true);
775
+ show();
776
+ document.body.appendChild(scrim);
777
+ }
778
+
593
779
  /**
594
780
  * A clip as a card rather than the browser's player.
595
781
  *
@@ -772,7 +958,7 @@ function pane(id) {
772
958
  const node = el("div", "pane");
773
959
  node.hidden = true;
774
960
  log.appendChild(node);
775
- entry = { node, title: "", cwd: "", busy: false, queued: [], turn: null, streaming: null, tools: new Map(), thinking: null };
961
+ entry = { id, node, title: "", cwd: "", busy: false, queued: [], turn: null, streaming: null, tools: new Map(), thinking: null };
776
962
  panes.set(id, entry);
777
963
  }
778
964
  return entry;
@@ -865,7 +1051,9 @@ async function focusTab(id) {
865
1051
  setNav(null);
866
1052
  paintEmpty(panes.get(id));
867
1053
  paintTabs();
1054
+ paintSuggestion();
868
1055
  browserActivateSession();
1056
+ void computerActivateSession();
869
1057
 
870
1058
  const state = await getJson("/state?session=" + encodeURIComponent(id));
871
1059
  if (!state.error) applyState(state);
@@ -1 +1 @@
1
- {"version":3,"file":"transcript.js","sourceRoot":"","sources":["../../../src/page/client/transcript.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AACH,MAAM,CAAC,MAAM,UAAU,GAAG,MAAM,CAAC,GAAG,CAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CA63BnC,CAAC"}
1
+ {"version":3,"file":"transcript.js","sourceRoot":"","sources":["../../../src/page/client/transcript.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AACH,MAAM,CAAC,MAAM,UAAU,GAAG,MAAM,CAAC,GAAG,CAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CAyjCnC,CAAC"}
@@ -770,19 +770,10 @@ function usageQuery(path) {
770
770
  : path + "?range=" + encodeURIComponent(usageRange);
771
771
  }
772
772
 
773
- /** The page's heading, its way back, and — for an account — whose figures these are. */
773
+ /** The page's heading and, for an account, whose figures these are. */
774
774
  function usageHead(panel) {
775
775
  const head = el("div", "row-head");
776
776
  head.appendChild(el("h2", null, t("usage.title")));
777
- // Back to where this was opened from, because it was opened from a row in
778
- // Settings and the sidebar row it lit is already showing as active — with
779
- // no way out, the only exit is a sidebar click that looks like a no-op.
780
- const back = el("button", "ghost", t("usage.back"));
781
- back.type = "button";
782
- back.addEventListener("click", () => {
783
- setNav("nav-settings");
784
- void showSettings();
785
- });
786
777
  /*
787
778
  * Whose figures these are, as a small switch in the heading rather than a
788
779
  * third row of tabs.
@@ -807,7 +798,6 @@ function usageHead(panel) {
807
798
  }
808
799
  actions.appendChild(box);
809
800
  }
810
- actions.appendChild(back);
811
801
  head.appendChild(actions);
812
802
  panel.appendChild(head);
813
803
  }
@@ -1 +1 @@
1
- {"version":3,"file":"usage.js","sourceRoot":"","sources":["../../../src/page/client/usage.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AACH,MAAM,CAAC,MAAM,KAAK,GAAG,MAAM,CAAC,GAAG,CAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;IA4xC3B,CAAC"}
1
+ {"version":3,"file":"usage.js","sourceRoot":"","sources":["../../../src/page/client/usage.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AACH,MAAM,CAAC,MAAM,KAAK,GAAG,MAAM,CAAC,GAAG,CAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;IAkxC3B,CAAC"}