clauderipple 0.9.5 → 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 (163) hide show
  1. package/CHANGELOG.md +118 -0
  2. package/README.es.md +45 -0
  3. package/README.ja.md +17 -0
  4. package/README.ko.md +31 -0
  5. package/README.md +70 -0
  6. package/README.pt-BR.md +45 -0
  7. package/README.zh-CN.md +17 -0
  8. package/dist/app/assets/icon.icns +0 -0
  9. package/dist/app/assets/icon.ico +0 -0
  10. package/dist/app/assets/icon.png +0 -0
  11. package/dist/app/assets/trayDirectTemplate.png +0 -0
  12. package/dist/app/assets/trayDirectTemplate@2x.png +0 -0
  13. package/dist/app/assets/trayDownTemplate.png +0 -0
  14. package/dist/app/assets/trayDownTemplate@2x.png +0 -0
  15. package/dist/app/assets/trayTemplate.png +0 -0
  16. package/dist/app/assets/trayTemplate@2x.png +0 -0
  17. package/dist/app/assets/trayWarnTemplate.png +0 -0
  18. package/dist/app/assets/trayWarnTemplate@2x.png +0 -0
  19. package/dist/app/assets/trayWin.png +0 -0
  20. package/dist/app/assets/trayWin@2x.png +0 -0
  21. package/dist/app/assets/trayWinDirect.png +0 -0
  22. package/dist/app/assets/trayWinDirect@2x.png +0 -0
  23. package/dist/app/assets/trayWinDown.png +0 -0
  24. package/dist/app/assets/trayWinDown@2x.png +0 -0
  25. package/dist/app/assets/trayWinWarn.png +0 -0
  26. package/dist/app/assets/trayWinWarn@2x.png +0 -0
  27. package/dist/app/dist/claude-desktop.js +67 -0
  28. package/dist/app/dist/main.js +423 -110
  29. package/dist/app/dist/preload.js +21 -0
  30. package/dist/app/dist/protocol.js +121 -0
  31. package/dist/app/dist/status-model.js +87 -0
  32. package/dist/app/dist/window.js +133 -0
  33. package/dist/app/package.json +1 -1
  34. package/dist/cli/src/awake-setup.js +314 -0
  35. package/dist/cli/src/bundle.js +9 -5
  36. package/dist/cli/src/direct.js +159 -0
  37. package/dist/cli/src/doctor.js +205 -0
  38. package/dist/cli/src/hooks/agent-title.js +7 -4
  39. package/dist/cli/src/index.js +227 -31
  40. package/dist/cli/src/launchd.js +29 -20
  41. package/dist/cli/src/launcher.js +215 -0
  42. package/dist/cli/src/power.js +182 -0
  43. package/dist/cli/src/registration.js +157 -0
  44. package/dist/cli/src/remote.js +263 -0
  45. package/dist/cli/src/schtasks.js +50 -40
  46. package/dist/cli/src/settings.js +26 -1
  47. package/dist/cli/src/tray.js +5 -2
  48. package/dist/mod/hooks/i18n.ts +473 -227
  49. package/dist/mod/hooks/judge.ts +35 -17
  50. package/dist/mod/hooks/modes.ts +56 -1
  51. package/dist/mod/hooks/register.tsx +237 -32
  52. package/dist/mod/hooks/session.ts +102 -1
  53. package/dist/mod/hooks/settings.js +7 -3
  54. package/dist/mod/hooks/settings.ts +9 -4
  55. package/dist/mod/hooks/stats.ts +25 -1
  56. package/dist/mod/hooks/ui/agents.tsx +903 -79
  57. package/dist/mod/hooks/ui/alerts.tsx +20 -17
  58. package/dist/mod/hooks/ui/art.ts +33 -7
  59. package/dist/mod/hooks/ui/band.tsx +262 -81
  60. package/dist/mod/hooks/ui/flat.ts +265 -0
  61. package/dist/mod/hooks/ui/kit.tsx +70 -55
  62. package/dist/mod/hooks/ui/look.ts +129 -0
  63. package/dist/mod/hooks/ui/settings.tsx +71 -46
  64. package/dist/mod/hooks/ui/setup.tsx +15 -13
  65. package/dist/mod/hooks/ui/theme.ts +3 -0
  66. package/dist/mod/hooks/ui/tools.ts +124 -0
  67. package/dist/mod/hooks/ui/view.ts +120 -4
  68. package/dist/mod/hooks/workers.ts +33 -6
  69. package/dist/mod/tests/register.test.tsx +155 -11
  70. package/dist/mod/tests/ui.test.tsx +408 -27
  71. package/dist/mod/types/index.d.ts +66 -2
  72. package/dist/remote/package.json +28 -0
  73. package/dist/remote/src/away.js +85 -0
  74. package/dist/remote/src/bridge-client.js +336 -0
  75. package/dist/remote/src/composer-menu.js +11 -0
  76. package/dist/remote/src/config.js +102 -0
  77. package/dist/remote/src/desktop.js +1018 -0
  78. package/dist/remote/src/e2ee.js +126 -0
  79. package/dist/remote/src/e2ee.ts +126 -0
  80. package/dist/remote/src/eventlog.js +298 -0
  81. package/dist/remote/src/expo-push.js +127 -0
  82. package/dist/remote/src/folders.js +79 -0
  83. package/dist/remote/src/fsutil.js +39 -0
  84. package/dist/remote/src/handoff.js +38 -0
  85. package/dist/remote/src/host-service.js +168 -0
  86. package/dist/remote/src/images.js +215 -0
  87. package/dist/remote/src/local-control.js +138 -0
  88. package/dist/remote/src/main.js +264 -0
  89. package/dist/remote/src/permissions.js +124 -0
  90. package/dist/remote/src/power.js +76 -0
  91. package/dist/remote/src/processes.js +24 -0
  92. package/dist/remote/src/protocol.js +32 -0
  93. package/dist/remote/src/protocol.ts +284 -0
  94. package/dist/remote/src/push-content.js +32 -0
  95. package/dist/remote/src/push.js +134 -0
  96. package/dist/remote/src/receipts.js +111 -0
  97. package/dist/remote/src/relay.js +438 -0
  98. package/dist/remote/src/router.js +208 -0
  99. package/dist/remote/src/sdk-launcher.js +18 -0
  100. package/dist/remote/src/server.js +706 -0
  101. package/dist/remote/src/service.js +157 -0
  102. package/dist/remote/src/sessions.js +1800 -0
  103. package/dist/remote/src/sidebar-storage.js +211 -0
  104. package/dist/remote/src/sidebar.js +173 -0
  105. package/dist/remote/src/tool-details.js +205 -0
  106. package/dist/remote/src/tool-details.ts +153 -0
  107. package/dist/remote/src/worktree.js +99 -0
  108. package/dist/remote/web/app.ts +746 -0
  109. package/dist/remote/web/conn.ts +193 -0
  110. package/dist/remote/web/demo.ts +134 -0
  111. package/dist/remote/web/dom.ts +124 -0
  112. package/dist/remote/web/esc.ts +4 -0
  113. package/dist/remote/web/i18n.ts +374 -0
  114. package/dist/remote/web/icon.svg +1 -0
  115. package/dist/remote/web/index.html +24 -0
  116. package/dist/remote/web/manifest.webmanifest +10 -0
  117. package/dist/remote/web/mascot.ts +60 -0
  118. package/dist/remote/web/md.ts +78 -0
  119. package/dist/remote/web/notify.ts +55 -0
  120. package/dist/remote/web/pushstate.ts +27 -0
  121. package/dist/remote/web/secure.ts +109 -0
  122. package/dist/remote/web/sidebar.ts +106 -0
  123. package/dist/remote/web/style.css +311 -0
  124. package/dist/remote/web/sw.ts +48 -0
  125. package/dist/remote/web/timeline.ts +224 -0
  126. package/dist/remote/web/views.ts +323 -0
  127. package/dist/router/src/admin.js +331 -5
  128. package/dist/router/src/awake-win.js +590 -0
  129. package/dist/router/src/awake.js +432 -0
  130. package/dist/router/src/config.js +94 -0
  131. package/dist/router/src/health.js +86 -3
  132. package/dist/router/src/hold.js +107 -0
  133. package/dist/router/src/index.js +108 -6
  134. package/dist/router/src/ingress/server.js +26 -4
  135. package/dist/router/src/ingress/translate.js +6 -2
  136. package/dist/router/src/phone.js +82 -0
  137. package/dist/router/src/power.js +107 -0
  138. package/dist/router/src/providers/anthropic-usage.js +33 -1
  139. package/dist/router/src/providers/google/antigravity.js +73 -10
  140. package/dist/router/src/providers/google/index.js +191 -14
  141. package/dist/router/src/providers/google/prompt-refusal.js +29 -0
  142. package/dist/router/src/providers/google/thoughtSignatures.js +13 -0
  143. package/dist/router/src/providers/google/transport.js +1 -1
  144. package/dist/router/src/providers/google/usage.js +302 -0
  145. package/dist/router/src/providers/grok/index.js +18 -0
  146. package/dist/router/src/providers/grok/usage.js +77 -0
  147. package/dist/router/src/providers/openai/translate.js +13 -2
  148. package/dist/router/src/providers/opencode-usage.js +116 -0
  149. package/dist/router/src/providers/retry.js +4 -1
  150. package/dist/router/src/proxy.js +371 -261
  151. package/dist/router/src/remote-host.js +320 -0
  152. package/dist/router/src/remote-tunnel.js +320 -0
  153. package/dist/router/src/requestlog.js +48 -39
  154. package/dist/router/src/tether-win.js +211 -0
  155. package/dist/router/src/tether.js +712 -0
  156. package/dist/router/src/usage-history.js +227 -0
  157. package/dist/router/src/version.js +1 -1
  158. package/dist/ui/app.js +1083 -59
  159. package/dist/ui/i18n.js +670 -69
  160. package/dist/ui/index.html +33 -6
  161. package/dist/ui/style.css +177 -0
  162. package/docs/ARCHITECTURE.md +402 -14
  163. package/package.json +8 -2
@@ -0,0 +1,746 @@
1
+ // The phone app: one screen at a time (pair, home, session), a docked composer, sheets for
2
+ // folder and model. State that must survive closing the app (the token, unsent messages, the
3
+ // last folder and model) lives in localStorage; everything else is rebuilt from the host.
4
+ import type { BlobChunk, BrowseResult, ClientMsg, Effort, FolderEntry, HostMsg, ProviderGroup, ReqOp, SessionEvent, SessionSummary, SubPage } from "../src/protocol.ts";
5
+ import { Link } from "./conn.ts";
6
+ import type { LinkStatus } from "./conn.ts";
7
+ import { esc, icon, patchList, setHTML } from "./dom.ts";
8
+ import { lang, pickLang, setLang, t } from "./i18n.ts";
9
+ import { browserPushEnv, currentSubscription, enablePush, sameKey } from "./notify.ts";
10
+ import { pushAvailability } from "./pushstate.ts";
11
+ import { RelaySocket, browserNacl, relayClientUrl } from "./secure.ts";
12
+ import type { RawSocket, RelayPairing } from "./secure.ts";
13
+ import { PING_MS, defaultEffort } from "../src/protocol.ts";
14
+ import { mergeEvents, reduce, timelineItems } from "./timeline.ts";
15
+ import type { TimelineItem, Outgoing, SessionView } from "./timeline.ts";
16
+ import * as V from "./views.ts";
17
+ import type { UI } from "./views.ts";
18
+
19
+ interface Cache { epoch: string | null; events: SessionEvent[]; hasOlder: boolean; loadingOlder: boolean; attached: boolean }
20
+ interface OutboxEntry extends Outgoing { session: string | null; create?: { cwd: string; model: string; effort: Effort | null } }
21
+ type Screen = { name: "pair" } | { name: "home" } | { name: "session"; id: string };
22
+
23
+ const store = {
24
+ get<T>(key: string, fallback: T): T {
25
+ try { const raw = localStorage.getItem(key); return raw === null ? fallback : (JSON.parse(raw) as T); } catch { return fallback; }
26
+ },
27
+ set(key: string, value: unknown): void {
28
+ try { localStorage.setItem(key, JSON.stringify(value)); } catch { /* private mode: state lives for this visit only */ }
29
+ },
30
+ };
31
+
32
+ /** Keep the pairing a link's fragment carries ("#pair=…"); false when it carries none. */
33
+ function savePairing(fragment: string): boolean {
34
+ if (!fragment.startsWith("#")) return false;
35
+ const hash = new URLSearchParams(fragment.slice(1));
36
+ const paired = hash.get("pair");
37
+ if (!paired) return false;
38
+ store.set("rr.token", paired);
39
+ // A relay pairing names the host (server id) and its key; a direct one does not, and replaces any relay pairing.
40
+ const server = hash.get("server"), hpk = hash.get("hpk");
41
+ store.set("rr.relay", server && hpk ? ({ url: location.origin, server, hpk } satisfies RelayPairing) : null);
42
+ return true;
43
+ }
44
+
45
+ const uid = (): string => crypto.randomUUID();
46
+ const $ = <T extends HTMLElement>(sel: string): T => document.querySelector(sel) as T;
47
+
48
+ export class App {
49
+ ui: UI;
50
+ screen: Screen = { name: "home" };
51
+ caches = new Map<string, Cache>();
52
+ outbox: OutboxEntry[] = store.get<OutboxEntry[]>("rr.outbox", []);
53
+ recentModels: string[] = store.get<string[]>("rr.recentModels", []);
54
+ link: Link | null = null;
55
+ pending = new Map<string, { resolve: (v: unknown) => void; reject: (e: Error) => void }>();
56
+ /** create's clientMsgId → session id, once the host acknowledged it */
57
+ created = new Map<string, string>();
58
+ private renderedScreen = "";
59
+ private composerMode = "";
60
+ private lastOpenPerm = "";
61
+ private pinned = true;
62
+ private tasksOpen = false;
63
+ private timelineSize: ResizeObserver | null = null;
64
+ private imageUrls = new Map<string, Promise<string>>();
65
+
66
+ private clearImages(): void {
67
+ for (const url of this.imageUrls.values()) void url.then(value => URL.revokeObjectURL(value), () => undefined);
68
+ this.imageUrls.clear();
69
+ }
70
+
71
+ private hydrateImages(root: HTMLElement, session: string): void {
72
+ for (const el of root.querySelectorAll<HTMLElement>('.conversation-image')) {
73
+ if (el.dataset.loading) continue;
74
+ el.dataset.loading = 'true';
75
+ const id = el.dataset.blob!, media = el.dataset.media!;
76
+ let loading = this.imageUrls.get(id);
77
+ if (!loading) {
78
+ loading = (async () => {
79
+ let offset = 0, base64 = '';
80
+ for (;;) {
81
+ const chunk = await this.req<BlobChunk>({ op: 'blob', session, id, offset });
82
+ base64 += chunk.text;
83
+ if (chunk.next === null) break;
84
+ offset = chunk.next;
85
+ }
86
+ const bytes = Uint8Array.from(atob(base64), char => char.charCodeAt(0));
87
+ return URL.createObjectURL(new Blob([bytes], { type: media }));
88
+ })();
89
+ this.imageUrls.set(id, loading);
90
+ }
91
+ void loading.then(url => {
92
+ if (!el.isConnected) return;
93
+ const image = el.querySelector('img')!;
94
+ image.onerror = () => { image.hidden = true; el.querySelector('span')!.textContent = t('image.unavailable'); };
95
+ image.onload = () => { el.querySelector('span')!.textContent = ''; };
96
+ image.src = url; image.hidden = false;
97
+ }, error => {
98
+ if (el.isConnected) el.querySelector('span')!.textContent = t('image.unavailable');
99
+ this.toast(t('error.generic', { text: (error as Error).message }));
100
+ });
101
+ }
102
+ }
103
+
104
+ constructor(link: LinkStatus) {
105
+ this.ui = {
106
+ now: Date.now(), link, host: null, home: undefined, sessions: [], models: [], sheet: null,
107
+ draft: store.get("rr.draft", { cwd: null, model: null, effort: null }),
108
+ expanded: new Map(), workersOpen: false, permOpen: new Set(), toast: null,
109
+ push: null, pushLater: store.get<boolean>("rr.pushLater", false),
110
+ };
111
+ }
112
+
113
+ // ---- boot --------------------------------------------------------------------------------
114
+
115
+ start(): void {
116
+ if (savePairing(location.hash)) history.replaceState(null, "", location.pathname);
117
+ const token = store.get<string | null>("rr.token", null);
118
+ if (!token) { this.screen = { name: "pair" }; this.bind(); this.render(); return; }
119
+ this.screen = this.routeFromHash();
120
+ window.addEventListener("hashchange", () => { this.screen = this.routeFromHash(); this.ui.sheet = null; this.render(); this.attachCurrent(); });
121
+ const clientId = store.get<string>("rr.clientId", "") || uid();
122
+ store.set("rr.clientId", clientId);
123
+ // Served by the relay → through the relay, encrypted end to end; served by the host itself → straight to it.
124
+ const relay = store.get<RelayPairing | null>("rr.relay", null);
125
+ const viaRelay = relay !== null && relay.url === location.origin;
126
+ const link = new Link({
127
+ url: viaRelay ? relayClientUrl(relay) : `${location.protocol === "https:" ? "wss" : "ws"}://${location.host}/ws`,
128
+ token, clientId,
129
+ makeSocket: viaRelay
130
+ ? (url) => new RelaySocket(new WebSocket(url) as unknown as RawSocket, relay, browserNacl())
131
+ : (url) => new WebSocket(url) as unknown as import("./conn.ts").SocketLike,
132
+ now: () => Date.now(),
133
+ setTimer: (fn, ms) => setTimeout(fn, ms),
134
+ clearTimer: (id) => clearTimeout(id as number),
135
+ isOnline: () => navigator.onLine,
136
+ });
137
+ this.link = link;
138
+ link.onStatus = (s) => { this.ui.link = s; this.ui.now = Date.now(); this.renderChrome(); };
139
+ link.onMessage = (m) => this.onHost(m);
140
+ link.onReady = () => this.onReady();
141
+ window.addEventListener("online", () => link.nudge());
142
+ window.addEventListener("offline", () => link.nudge());
143
+ document.addEventListener("visibilitychange", () => { this.sendPresence(); if (document.visibilityState === "visible") { link.nudge(); void this.refreshPush(); } });
144
+ setInterval(() => { this.ui.now = Date.now(); this.renderChrome(); }, 1000);
145
+ // While in front, say so every PING_MS: the host pushes nothing to a phone that is looking, and
146
+ // stops counting one whose app froze in the background without saying "hidden".
147
+ setInterval(() => { if (document.visibilityState === "visible") this.sendPresence(); }, PING_MS);
148
+ if (window.isSecureContext && "serviceWorker" in navigator) {
149
+ navigator.serviceWorker.register("sw.ts", { scope: "./" }).catch(() => undefined);
150
+ }
151
+ void this.refreshPush();
152
+ this.bind();
153
+ this.render();
154
+ link.start();
155
+ }
156
+
157
+ private routeFromHash(): Screen {
158
+ const m = /^#\/s\/(.+)$/.exec(location.hash);
159
+ return m ? { name: "session", id: decodeURIComponent(m[1]!) } : { name: "home" };
160
+ }
161
+
162
+ go(screen: Screen): void {
163
+ const hash = screen.name === "session" ? `#/s/${encodeURIComponent(screen.id)}` : "";
164
+ if (location.hash !== hash) {
165
+ if (hash) location.hash = hash; else history.pushState(null, "", location.pathname);
166
+ }
167
+ this.screen = screen;
168
+ this.ui.sheet = null;
169
+ this.render();
170
+ this.attachCurrent();
171
+ }
172
+
173
+ // ---- host messages -----------------------------------------------------------------------
174
+
175
+ private onReady(): void {
176
+ for (const c of this.caches.values()) c.attached = false;
177
+ this.sendPresence();
178
+ this.attachCurrent();
179
+ this.flushOutbox();
180
+ if (!this.ui.models.length) void this.loadModels();
181
+ void this.resyncPush();
182
+ }
183
+
184
+ // ---- presence and notifications ----------------------------------------------------------
185
+
186
+ private sendPresence(): void {
187
+ this.link?.send({ t: "presence", visible: document.visibilityState === "visible" });
188
+ }
189
+
190
+ private async refreshPush(): Promise<void> {
191
+ const sub = await currentSubscription().catch(() => null);
192
+ const next = pushAvailability(browserPushEnv(sub !== null));
193
+ if (next !== this.ui.push) { this.ui.push = next; if (this.screen.name === "home") this.render(); }
194
+ }
195
+
196
+ /** A host may have dropped the subscription (a 410 it saw) or changed its key: tell it again on every connect. */
197
+ private async resyncPush(): Promise<void> {
198
+ try {
199
+ const sub = await currentSubscription();
200
+ if (!sub || Notification.permission !== "granted") return;
201
+ const { publicKey } = await this.req<{ publicKey: string }>({ op: "push_key" });
202
+ if (!sameKey(sub, publicKey)) { await sub.unsubscribe(); await this.refreshPush(); return; }
203
+ await this.registerPush(sub.toJSON());
204
+ } catch { /* tried again on the next connect */ }
205
+ }
206
+
207
+ private async registerPush(json: PushSubscriptionJSON): Promise<void> {
208
+ if (!json.endpoint || !json.keys?.p256dh || !json.keys.auth) throw new Error("subscription without keys");
209
+ await this.req({ op: "push_subscribe", subscription: { endpoint: json.endpoint, keys: { p256dh: json.keys.p256dh, auth: json.keys.auth } }, lang: lang() });
210
+ }
211
+
212
+ private async turnOnPush(): Promise<void> {
213
+ try {
214
+ const r = await enablePush(async () => (await this.req<{ publicKey: string }>({ op: "push_key" })).publicKey, (json) => this.registerPush(json));
215
+ if (r === "on") this.toast(t("push.done"));
216
+ } catch (e) { this.toast(t("push.failed", { text: (e as Error).message })); }
217
+ await this.refreshPush();
218
+ }
219
+
220
+ onHost(m: HostMsg): void {
221
+ switch (m.t) {
222
+ case "ready":
223
+ this.ui.host = m.host; this.ui.sessions = m.sessions; this.ui.groups = m.groups; this.render(); break;
224
+ case "host":
225
+ this.ui.host = m.host; this.renderChrome(); break;
226
+ case "sessions":
227
+ this.ui.sessions = m.sessions; this.ui.groups = m.groups; if (this.screen.name === "home") this.render(); else this.renderChrome(); break;
228
+ case "events": {
229
+ const c = this.cache(m.session);
230
+ if (m.tail || (c.epoch && c.epoch !== m.epoch)) { c.events = []; c.hasOlder = m.tail?.hasOlder ?? false; }
231
+ c.epoch = m.epoch;
232
+ c.events = mergeEvents(c.events, m.events);
233
+ for (const e of m.events) if (e.kind === "user") this.dropOutbox(e.clientMsgId);
234
+ if (this.isCurrent(m.session)) this.renderSession();
235
+ break;
236
+ }
237
+ case "history": {
238
+ const c = this.cache(m.session);
239
+ c.loadingOlder = false;
240
+ c.hasOlder = m.hasOlder;
241
+ const main = $("main");
242
+ const before = main ? main.scrollHeight - main.scrollTop : 0;
243
+ c.events = mergeEvents(c.events, m.events);
244
+ if (this.isCurrent(m.session)) { this.renderSession(); if (main) main.scrollTop = main.scrollHeight - before; }
245
+ break;
246
+ }
247
+ case "ack": {
248
+ const entry = this.outbox.find((o) => o.clientMsgId === m.clientMsgId);
249
+ if (entry && entry.session && entry.session !== m.session && this.isCurrent(entry.session)) this.go({ name: "session", id: m.session });
250
+ if (entry?.create) {
251
+ this.created.set(m.clientMsgId, m.session);
252
+ if (this.screen.name === "session" && this.screen.id === `local:${m.clientMsgId}`) {
253
+ history.replaceState(null, "", `#/s/${encodeURIComponent(m.session)}`);
254
+ this.screen = { name: "session", id: m.session };
255
+ this.renderedScreen = "";
256
+ }
257
+ }
258
+ this.cache(m.session).attached = false;
259
+ this.dropOutbox(m.clientMsgId);
260
+ this.attachCurrent();
261
+ this.render();
262
+ break;
263
+ }
264
+ case "res": {
265
+ const p = this.pending.get(m.reqId);
266
+ this.pending.delete(m.reqId);
267
+ if (p) { if (m.ok) p.resolve(m.data); else p.reject(new Error(m.error)); }
268
+ break;
269
+ }
270
+ case "error":
271
+ // outcome_unknown: the host restarted mid-send and cannot say whether the first attempt
272
+ // ran. Keep the typed text in the outbox (it flushes again on reconnect) and say so.
273
+ if (m.clientMsgId && m.code !== "outcome_unknown") this.dropOutbox(m.clientMsgId);
274
+ this.toast(t("error.generic", { text: m.message }));
275
+ break;
276
+ default:
277
+ break;
278
+ }
279
+ }
280
+
281
+ private isCurrent(session: string): boolean {
282
+ return this.screen.name === "session" && this.screen.id === session;
283
+ }
284
+
285
+ cache(id: string): Cache {
286
+ let c = this.caches.get(id);
287
+ if (!c) { c = { epoch: null, events: [], hasOlder: false, loadingOlder: false, attached: false }; this.caches.set(id, c); }
288
+ return c;
289
+ }
290
+
291
+ private attachCurrent(): void {
292
+ if (this.screen.name !== "session" || this.screen.id.startsWith("local:") || this.ui.link.state !== "open") return;
293
+ const c = this.cache(this.screen.id);
294
+ if (c.attached) return;
295
+ const last = c.events.length ? c.events[c.events.length - 1]!.seq : 0;
296
+ const msg: ClientMsg = c.epoch && last ? { t: "attach", session: this.screen.id, epoch: c.epoch, afterSeq: last } : { t: "attach", session: this.screen.id };
297
+ if (this.link?.send(msg)) c.attached = true;
298
+ }
299
+
300
+ req<T>(op: ReqOp): Promise<T> {
301
+ const reqId = uid();
302
+ return new Promise<T>((resolve, reject) => {
303
+ if (!this.link?.send({ t: "req", reqId, op })) { reject(new Error("offline")); return; }
304
+ this.pending.set(reqId, { resolve: resolve as (v: unknown) => void, reject });
305
+ setTimeout(() => { if (this.pending.delete(reqId)) reject(new Error("timeout")); }, 15_000);
306
+ });
307
+ }
308
+
309
+ private async loadModels(): Promise<void> {
310
+ try {
311
+ this.ui.models = await this.req<ProviderGroup[]>({ op: "models" });
312
+ const d = this.ui.draft;
313
+ const all = this.ui.models.flatMap((g) => g.models);
314
+ if (!d.model || !all.some((m) => m.model === d.model)) {
315
+ const first = all.find((m) => m.usable);
316
+ if (first) { d.model = first.model; d.effort = first.efforts.includes("high") ? "high" : first.efforts[0] ?? null; store.set("rr.draft", d); }
317
+ }
318
+ this.render();
319
+ } catch { /* retried on the next ready */ }
320
+ }
321
+
322
+ // ---- outbox: nothing a person typed is dropped; nothing shows as sent before the host says so ----
323
+
324
+ private saveOutbox(): void { store.set("rr.outbox", this.outbox); }
325
+
326
+ private dropOutbox(id: string): void {
327
+ const before = this.outbox.length;
328
+ this.outbox = this.outbox.filter((o) => o.clientMsgId !== id);
329
+ if (this.outbox.length !== before) this.saveOutbox();
330
+ }
331
+
332
+ private flushOutbox(): void {
333
+ for (const o of this.outbox) {
334
+ const session = o.session ?? null;
335
+ const sent = o.create && !session
336
+ ? this.link?.send({ t: "create", clientMsgId: o.clientMsgId, cwd: o.create.cwd, model: o.create.model, effort: o.create.effort, text: o.text })
337
+ : session ? this.link?.send({ t: "send", session, clientMsgId: o.clientMsgId, text: o.text }) : false;
338
+ o.state = sent ? "sending" : "pending";
339
+ }
340
+ this.saveOutbox();
341
+ this.render();
342
+ }
343
+
344
+ submit(text: string): void {
345
+ const body = text.trim();
346
+ if (!body || (this.screen.name === "session" && this.summary(this.screen.id)?.readOnly)) return;
347
+ const clientMsgId = uid();
348
+ if (this.screen.name === "home" || (this.screen.name === "session" && this.screen.id.startsWith("local:"))) {
349
+ const d = this.ui.draft;
350
+ if (!d.cwd) { this.openFolderSheet(); return; }
351
+ if (!d.model) { this.openModelSheet(null); return; }
352
+ const entry: OutboxEntry = { clientMsgId, text: body, at: Date.now(), state: "pending", session: null, create: { cwd: d.cwd, model: d.model, effort: d.effort } };
353
+ this.outbox.push(entry);
354
+ this.rememberModel(d.model);
355
+ this.flushOutbox();
356
+ this.go({ name: "session", id: `local:${clientMsgId}` });
357
+ return;
358
+ }
359
+ if (this.screen.name !== "session") return;
360
+ const session = this.screen.id;
361
+ this.outbox.push({ clientMsgId, text: body, at: Date.now(), state: "pending", session });
362
+ this.flushOutbox();
363
+ this.scrollToBottom();
364
+ }
365
+
366
+ private rememberModel(id: string): void {
367
+ this.recentModels = [id, ...this.recentModels.filter((m) => m !== id)].slice(0, 5);
368
+ store.set("rr.recentModels", this.recentModels);
369
+ }
370
+
371
+ // ---- rendering -----------------------------------------------------------------------------
372
+
373
+ view(id: string): SessionView {
374
+ const c = this.cache(id);
375
+ const out = this.outbox.filter((o) => o.session === id || (id.startsWith("local:") && o.clientMsgId === id.slice(6)) || (o.create && this.created.get(o.clientMsgId) === id));
376
+ const view = reduce(c.events, out, c.events.length > 0 && !c.hasOlder);
377
+ const summary = this.summary(id);
378
+ if (summary?.desktopBridge) view.state = summary.state;
379
+ return view;
380
+ }
381
+
382
+ summary(id: string): SessionSummary | undefined {
383
+ const s = this.ui.sessions.find((x) => x.id === id);
384
+ if (s || !id.startsWith("local:")) return s?.origin === "desktop" ? { ...s, readOnly: false } : s;
385
+ const o = this.outbox.find((x) => x.clientMsgId === id.slice(6));
386
+ if (!o?.create) return undefined;
387
+ return { id, title: o.text, cwd: o.create.cwd, model: o.create.model, effort: o.create.effort, state: "starting", pending: 0, createdAt: o.at, updatedAt: o.at, lastSeq: 0, epoch: "" };
388
+ }
389
+
390
+ render(): void {
391
+ const root = $("#app");
392
+ const key = this.screen.name === "session" ? `s:${this.screen.id}` : this.screen.name;
393
+ if (key !== this.renderedScreen) {
394
+ this.clearImages();
395
+ this.renderedScreen = key;
396
+ this.pinned = true;
397
+ this.composerMode = "";
398
+ root.dataset.screen = this.screen.name;
399
+ root.innerHTML = this.screen.name === "pair"
400
+ ? `<main class="scroll">${V.pairScreen()}</main>`
401
+ : `<header class="top" id="top"></header><div id="pre"></div><main class="scroll" id="main">${this.screen.name === "session" ? `<div class="loading-older" id="older" hidden>${t("history.loading")}</div><div class="tl" id="tl"></div>` : ""}</main><footer class="dock"><div id="dock-extra"></div><div id="dock-input"></div></footer><div id="sheet"></div><div id="toast"></div>`;
402
+ this.timelineSize?.disconnect();
403
+ if (this.screen.name === "session") {
404
+ this.timelineSize = new ResizeObserver(() => { if (this.pinned) this.scrollToBottom(); });
405
+ this.timelineSize.observe($("#tl"));
406
+ this.timelineSize.observe($("main"));
407
+ requestAnimationFrame(() => { if (this.pinned) this.scrollToBottom(); });
408
+ }
409
+ }
410
+ if (this.screen.name === "pair") return;
411
+ if (this.screen.name === "home") {
412
+ setHTML($("#top"), V.homeTop(this.ui));
413
+ setHTML($("#pre"), "");
414
+ setHTML($("#main"), V.homeMain(this.ui));
415
+ setHTML($("#dock-extra"), V.chips(this.ui));
416
+ this.renderComposer(t("composer.new"), "send");
417
+ } else {
418
+ this.renderSession();
419
+ }
420
+ this.renderSheet();
421
+ this.renderToast();
422
+ }
423
+
424
+ /** Only what changes every second: the link pill, banners, countdowns, "working for". */
425
+ renderChrome(): void {
426
+ if (this.screen.name === "pair") return;
427
+ if (this.screen.name === "home") {
428
+ setHTML($("#top"), V.homeTop(this.ui));
429
+ const main = $("#main");
430
+ if (main) { const y = main.scrollTop; setHTML(main, V.homeMain(this.ui)); main.scrollTop = y; }
431
+ return;
432
+ }
433
+ this.renderSession(true);
434
+ }
435
+
436
+ renderSession(chromeOnly = false): void {
437
+ if (this.screen.name !== "session") return;
438
+ const id = this.screen.id;
439
+ const view = this.view(id);
440
+ const s = this.summary(id);
441
+ const top = $("#top");
442
+ if (!top) return;
443
+ setHTML(top, V.sessionTop(this.ui, s, view));
444
+ setHTML($("#pre"), V.band(this.ui, view) + V.banner(this.ui));
445
+ const perm = s?.readOnly ? "" : V.permCard(view, this.ui);
446
+ setHTML($("#dock-extra"), s?.readOnly ? `<div class="foot">${esc(t("session.readOnly", { host: V.hostName(this.ui) }))}</div>` : perm + (perm ? "" : V.workingLine(view, this.ui, s)) + V.tasks(this.ui, s, this.tasksOpen));
447
+ // Stop only when the host can hear it; offline the button sends (queues) instead.
448
+ const running = view.state === "running" || view.state === "starting" || id.startsWith("local:");
449
+ if (s?.readOnly) {
450
+ setHTML($("#dock-input"), "");
451
+ this.composerMode = "read-only";
452
+ } else this.renderComposer(t("composer.reply"), running && !perm && this.ui.link.state === "open" ? "stop" : "send");
453
+ const openId = view.open[0]?.id ?? "";
454
+ if (openId && openId !== this.lastOpenPerm) navigator.vibrate?.(30);
455
+ this.lastOpenPerm = openId;
456
+ if (chromeOnly) return;
457
+ const main = $("main");
458
+ const stick = this.pinned;
459
+ patchList<TimelineItem>($("#tl"), timelineItems(view), (it) => V.item(it, this.ui));
460
+ this.hydrateImages($("#tl"), id);
461
+ const c = this.cache(id);
462
+ $("#older").hidden = !c.loadingOlder;
463
+ if (stick) this.scrollToBottom();
464
+ }
465
+
466
+ private renderComposer(placeholder: string, mode: "send" | "stop"): void {
467
+ const key = `${placeholder}|${mode}`;
468
+ const host = $("#dock-input");
469
+ if (!host || key === this.composerMode) return;
470
+ const prev = (document.getElementById("input") as HTMLTextAreaElement | null)?.value ?? store.get<string>(`rr.typed.${this.renderedScreen}`, "");
471
+ host.innerHTML = V.composer(placeholder, mode, prev);
472
+ this.composerMode = key;
473
+ this.fitInput();
474
+ }
475
+
476
+ renderSheet(): void {
477
+ const el = $("#sheet");
478
+ setHTML(el, V.sheet(this.ui));
479
+ }
480
+
481
+ private renderToast(): void {
482
+ const el = $("#toast");
483
+ if (el) el.innerHTML = this.ui.toast ? `<div class="toast" role="status">${this.ui.toast.replace(/[<>&]/g, "")}</div>` : "";
484
+ }
485
+
486
+ toast(text: string): void {
487
+ this.ui.toast = text;
488
+ this.renderToast();
489
+ setTimeout(() => { if (this.ui.toast === text) { this.ui.toast = null; this.renderToast(); } }, 3500);
490
+ }
491
+
492
+ scrollToBottom(): void {
493
+ const main = $("main");
494
+ if (main) main.scrollTop = main.scrollHeight;
495
+ }
496
+
497
+ private fitInput(): void {
498
+ const ta = document.getElementById("input") as HTMLTextAreaElement | null;
499
+ if (!ta) return;
500
+ ta.style.height = "auto";
501
+ ta.style.height = `${ta.scrollHeight}px`;
502
+ const send = ta.parentElement?.querySelector<HTMLButtonElement>("button[data-act=send]");
503
+ if (send) send.disabled = !ta.value.trim();
504
+ }
505
+
506
+ // ---- sheets --------------------------------------------------------------------------------
507
+
508
+ async openFolderSheet(): Promise<void> {
509
+ this.ui.sheet = { kind: "folder", recent: [], browse: null, filter: "", loading: true };
510
+ this.renderSheet();
511
+ try {
512
+ const [recent, browse] = await Promise.all([this.req<FolderEntry[]>({ op: "folders" }), this.req<BrowseResult>({ op: "browse", ...(this.ui.draft.cwd ? { path: this.ui.draft.cwd } : {}) })]);
513
+ if (this.ui.sheet?.kind !== "folder") return;
514
+ this.ui.home = browse.home;
515
+ this.ui.sheet = { ...this.ui.sheet, recent, browse, loading: false };
516
+ this.renderSheet();
517
+ } catch (e) { this.toast(t("error.generic", { text: (e as Error).message })); }
518
+ }
519
+
520
+ async browse(path: string): Promise<void> {
521
+ try {
522
+ const browse = await this.req<BrowseResult>({ op: "browse", path });
523
+ if (this.ui.sheet?.kind !== "folder") return;
524
+ this.ui.sheet = { ...this.ui.sheet, browse, filter: "" };
525
+ this.renderSheet();
526
+ } catch { this.toast(t("error.cwd")); }
527
+ }
528
+
529
+ openModelSheet(forSession: string | null): void {
530
+ const s = forSession ? this.summary(forSession) : undefined;
531
+ if (s?.readOnly) return;
532
+ const v = forSession ? this.view(forSession) : null;
533
+ const model = (v?.model ?? s?.model) ?? this.ui.draft.model;
534
+ const effort = (v?.effort ?? s?.effort) ?? this.ui.draft.effort;
535
+ this.ui.sheet = { kind: "model", groups: this.ui.models, recent: this.recentModels, model, effort, forSession };
536
+ this.renderSheet();
537
+ if (!this.ui.models.length) void this.loadModels().then(() => { if (this.ui.sheet?.kind === "model") { this.ui.sheet.groups = this.ui.models; this.renderSheet(); } });
538
+ }
539
+
540
+ // ---- input -----------------------------------------------------------------------------------
541
+
542
+ private bind(): void {
543
+ const root = $("#app");
544
+ root.addEventListener("click", (ev) => {
545
+ const el = (ev.target as HTMLElement).closest<HTMLElement>("[data-act]");
546
+ if (!el) return;
547
+ void this.act(el.dataset.act!, el);
548
+ });
549
+ root.addEventListener("input", (ev) => {
550
+ const el = ev.target as HTMLElement;
551
+ if (el.id === "input") { this.fitInput(); store.set(`rr.typed.${this.renderedScreen}`, (el as HTMLTextAreaElement).value); }
552
+ if (el.id === "folder-filter" && this.ui.sheet?.kind === "folder") {
553
+ this.ui.sheet.filter = (el as HTMLInputElement).value;
554
+ const pos = (el as HTMLInputElement).selectionStart;
555
+ this.renderSheet();
556
+ const again = document.getElementById("folder-filter") as HTMLInputElement | null;
557
+ again?.focus();
558
+ if (again && pos !== null) again.setSelectionRange(pos, pos);
559
+ }
560
+ });
561
+ root.addEventListener("keydown", (ev) => {
562
+ const el = ev.target as HTMLElement;
563
+ // Enter sends on a hardware keyboard; on a phone the return key makes a new line unless enterkeyhint says send.
564
+ if (el.id === "input" && ev.key === "Enter" && !ev.shiftKey && !ev.isComposing && matchMedia("(pointer: fine)").matches) {
565
+ ev.preventDefault();
566
+ void this.act("send", el);
567
+ }
568
+ });
569
+ root.addEventListener("scroll", (ev) => {
570
+ const main = ev.target as HTMLElement;
571
+ if (main.id !== "main" || this.screen.name !== "session") return;
572
+ this.pinned = main.scrollHeight - main.scrollTop - main.clientHeight < 80;
573
+ if (main.scrollTop > 240) return;
574
+ const c = this.cache(this.screen.id);
575
+ if (!c.hasOlder || c.loadingOlder || !c.events.length) return;
576
+ if (this.link?.send({ t: "history", session: this.screen.id, beforeSeq: c.events[0]!.seq, reqId: uid() })) { c.loadingOlder = true; $("#older").hidden = false; }
577
+ }, true);
578
+ }
579
+
580
+ async act(act: string, el: HTMLElement): Promise<void> {
581
+ const id = el.dataset.id ?? "";
582
+ const session = this.screen.name === "session" ? this.screen.id : "";
583
+ if (session && this.summary(session)?.readOnly && ["send", "stop", "allow", "deny", "always", "pick-model-session"].includes(act)) return;
584
+ switch (act) {
585
+ case "image": {
586
+ const image = el.querySelector('img');
587
+ if (!image?.src || image.hidden) break;
588
+ const viewer = document.createElement('div');
589
+ viewer.className = 'image-viewer'; viewer.setAttribute('role', 'dialog'); viewer.setAttribute('aria-modal', 'true');
590
+ const close = document.createElement('button'); close.textContent = t('a11y.close');
591
+ const full = document.createElement('img'); full.src = image.src; full.alt = t('image.view');
592
+ viewer.append(close, full); $("#app").append(viewer);
593
+ const dismiss = () => { viewer.remove(); document.removeEventListener('keydown', key); el.focus(); };
594
+ const key = (event: KeyboardEvent) => { if (event.key === 'Escape') dismiss(); };
595
+ close.onclick = dismiss; document.addEventListener('keydown', key); close.focus();
596
+ break;
597
+ }
598
+ case "retry": this.link?.nudge(); break;
599
+ case "pair-link": {
600
+ // A Home Screen app keeps its own storage, apart from Safari's: the pairing a scanned QR
601
+ // left in Safari never reaches it, so the link is pasted here instead.
602
+ const value = (document.getElementById("pair-link") as HTMLInputElement | null)?.value ?? "";
603
+ if (savePairing(value.slice(value.indexOf("#")))) location.reload();
604
+ else this.toast(t("pair.badLink"));
605
+ break;
606
+ }
607
+ case "home": this.go({ name: "home" }); break;
608
+ case "open": this.go({ name: "session", id }); break;
609
+ case "toggle-tasks": this.tasksOpen = !this.tasksOpen; this.renderSession(true); break;
610
+ case "send": {
611
+ const ta = document.getElementById("input") as HTMLTextAreaElement | null;
612
+ if (!ta) return;
613
+ const text = ta.value;
614
+ ta.value = "";
615
+ store.set(`rr.typed.${this.renderedScreen}`, "");
616
+ this.fitInput();
617
+ this.submit(text);
618
+ break;
619
+ }
620
+ case "stop": if (session && !session.startsWith("local:")) this.link?.send({ t: "interrupt", session }); break;
621
+ case "allow": case "deny": case "always":
622
+ if (session) {
623
+ const decision = act === "always" ? "allow_always" : act;
624
+ const permission = reduce(this.cache(session).events).open.find(p => p.id === id);
625
+ const answers: Record<string, string> = {};
626
+ if (decision !== "deny" && permission?.requestKind === "question") {
627
+ for (const q of permission.questions ?? []) {
628
+ const answer = window.prompt(`${q.question}\n${q.options.map(o => `${o.label}: ${o.description}`).join("\n")}\n${q.multiSelect ? "Separate multiple answers with commas." : "Choose one or type your own answer."}`);
629
+ if (!answer?.trim()) return;
630
+ answers[q.question] = answer.trim();
631
+ }
632
+ }
633
+ if (!this.link?.send({ t: "decide", session, id, decision, ...(permission?.requestKind === "question" && decision !== "deny" ? { answers } : {}) })) this.toast(t("queue.pending"));
634
+ }
635
+ break;
636
+ case "perm-more": this.ui.permOpen.add(id); this.renderSession(true); break;
637
+ case "workers": this.ui.workersOpen = !this.ui.workersOpen; this.renderSession(true); break;
638
+ case "tool-group":
639
+ if (this.ui.expanded.has(id)) this.ui.expanded.delete(id);
640
+ else this.ui.expanded.set(id, null);
641
+ this.rerenderItems();
642
+ break;
643
+ case "tool": await this.toggleTool(id); break;
644
+ case "sub": await this.openSub(id); break;
645
+ case "pick-folder": await this.openFolderSheet(); break;
646
+ case "pick-model": this.openModelSheet(null); break;
647
+ case "pick-model-session": if (session && !session.startsWith("local:")) this.openModelSheet(session); break;
648
+ case "close-sheet": this.ui.sheet = null; this.renderSheet(); break;
649
+ case "browse": await this.browse(el.dataset.path ?? ""); break;
650
+ case "choose-folder":
651
+ this.ui.draft.cwd = el.dataset.path ?? null;
652
+ store.set("rr.draft", this.ui.draft);
653
+ this.ui.sheet = null;
654
+ this.render();
655
+ break;
656
+ case "choose-model": if (this.ui.sheet?.kind === "model") {
657
+ const m = this.ui.models.flatMap((g) => g.models).find((x) => x.model === el.dataset.model);
658
+ this.ui.sheet.model = m?.model ?? null;
659
+ if (m) { const saved = store.get<Effort | null>(`rr.effort.${m.model}`, null); this.ui.sheet.effort = saved && m.efforts.includes(saved) ? saved : defaultEffort(m.efforts); }
660
+ if (m && !m.efforts.length) this.ui.sheet.effort = null;
661
+ this.renderSheet();
662
+ } break;
663
+ case "choose-effort": if (this.ui.sheet?.kind === "model") { this.ui.sheet.effort = (el.dataset.effort as Effort) ?? null; this.renderSheet(); } break;
664
+ case "model-done": if (this.ui.sheet?.kind === "model" && this.ui.sheet.model) {
665
+ const { model, effort, forSession } = this.ui.sheet;
666
+ if (forSession) this.link?.send({ t: "set_model", session: forSession, model, effort });
667
+ else { this.ui.draft.model = model; this.ui.draft.effort = effort; store.set("rr.draft", this.ui.draft); }
668
+ this.rememberModel(model);
669
+ store.set(`rr.effort.${model}`, effort);
670
+ this.ui.sheet = null;
671
+ this.render();
672
+ } break;
673
+ case "settings": this.toggleLang(); break;
674
+ // No await before this: the permission prompt has to come from the tap itself.
675
+ case "push-on": await this.turnOnPush(); break;
676
+ case "push-later": store.set("rr.pushLater", true); this.ui.pushLater = true; this.render(); break;
677
+ }
678
+ }
679
+
680
+ private toggleLang(): void {
681
+ const next = (store.get<string | null>("rr.lang", null) ?? (document.documentElement.lang || "ko")) === "ko" ? "en" : "ko";
682
+ store.set("rr.lang", next);
683
+ setLang(next);
684
+ document.documentElement.lang = next;
685
+ this.renderedScreen = "";
686
+ this.render();
687
+ }
688
+
689
+ private async toggleTool(id: string): Promise<void> {
690
+ if (this.ui.expanded.has(id)) { this.ui.expanded.delete(id); this.rerenderItems(); return; }
691
+ this.ui.expanded.set(id, null);
692
+ this.rerenderItems();
693
+ if (this.screen.name !== "session") return;
694
+ const view = this.view(this.screen.id);
695
+ const tool = view.items.flatMap((i) => (i.type === "assistant" ? i.tools : [])).find((x) => x.id === id);
696
+ const session = this.screen.id;
697
+ const values = [tool?.input, tool?.result?.text].filter((v) => v !== undefined);
698
+ if (!values.some(v => typeof v !== "string")) return;
699
+ try {
700
+ const parts: string[] = [];
701
+ for (const value of values) {
702
+ if (typeof value === "string") { parts.push(value); continue; }
703
+ let offset = 0, text = "";
704
+ for (;;) {
705
+ const chunk = await this.req<BlobChunk>({ op: "blob", session, id: value.blob, offset });
706
+ text += chunk.text;
707
+ if (chunk.next === null) break;
708
+ offset = chunk.next;
709
+ }
710
+ parts.push(text);
711
+ }
712
+ if (this.ui.expanded.has(id)) this.ui.expanded.set(id, parts.filter(Boolean).join("\n\n────\n\n"));
713
+ } catch (e) { this.toast(t("error.generic", { text: (e as Error).message })); }
714
+ if (this.isCurrent(session)) this.rerenderItems();
715
+ }
716
+
717
+ private rerenderItems(): void {
718
+ // Expanded state is UI-only; bump versions by re-rendering the list wholesale.
719
+ const tl = document.getElementById("tl");
720
+ if (tl) for (const child of Array.from(tl.children) as HTMLElement[]) child.dataset.ver = "";
721
+ this.renderSession();
722
+ }
723
+
724
+ private async openSub(sub: string): Promise<void> {
725
+ if (this.screen.name !== "session") return;
726
+ try {
727
+ const page = await this.req<SubPage>({ op: "sub", session: this.screen.id, sub });
728
+ const view = reduce(page.events);
729
+ const html = timelineItems(view).map((i) => V.item(i, this.ui)).join("");
730
+ setHTML($("#sheet"), `<div class="scrim" data-act="close-sheet"></div><div class="sheet" role="dialog"><div class="grab"></div><div class="sh"><b>${esc(t("sub.worker"))}</b><button class="icon-btn" data-act="close-sheet" aria-label="${esc(t("a11y.close"))}">${icon.x(20)}</button></div><div class="sbody"><div class="tl">${html}</div></div></div>`);
731
+ this.hydrateImages($("#sheet"), this.screen.id);
732
+ } catch (e) { this.toast(t("error.generic", { text: (e as Error).message })); }
733
+ }
734
+ }
735
+
736
+ export function boot(): void {
737
+ const lang = pickLang(navigator.languages ?? [navigator.language], store.get<string | null>("rr.lang", null));
738
+ setLang(lang);
739
+ document.documentElement.lang = lang;
740
+ const params = new URLSearchParams(location.search);
741
+ const demo = params.get("demo");
742
+ if (demo) { void import("./demo.ts").then((d) => d.runDemo(demo, params)); return; }
743
+ new App({ state: "connecting", attempt: 0, nextAt: null, lastRx: null, downSince: null }).start();
744
+ }
745
+
746
+ boot();