viveworker 0.7.0-beta.3 → 0.8.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 (34) hide show
  1. package/README.md +33 -4
  2. package/package.json +7 -3
  3. package/scripts/lib/remote-pairing/README.md +164 -0
  4. package/scripts/lib/remote-pairing/_browser-bundle-entry.mjs +86 -0
  5. package/scripts/lib/remote-pairing/audit.mjs +122 -0
  6. package/scripts/lib/remote-pairing/bridge-relay-client.mjs +737 -0
  7. package/scripts/lib/remote-pairing/control.mjs +156 -0
  8. package/scripts/lib/remote-pairing/envelope.mjs +224 -0
  9. package/scripts/lib/remote-pairing/http-dispatch.mjs +530 -0
  10. package/scripts/lib/remote-pairing/keys-core.mjs +110 -0
  11. package/scripts/lib/remote-pairing/keys.mjs +181 -0
  12. package/scripts/lib/remote-pairing/noise.mjs +436 -0
  13. package/scripts/lib/remote-pairing/orchestrator.mjs +356 -0
  14. package/scripts/lib/remote-pairing/pairings.mjs +446 -0
  15. package/scripts/lib/remote-pairing/rpc.mjs +381 -0
  16. package/scripts/moltbook-scout-auto.sh +16 -8
  17. package/scripts/share-cli.mjs +14 -130
  18. package/scripts/viveworker-bridge.mjs +1696 -223
  19. package/scripts/viveworker.mjs +27 -6
  20. package/web/app.css +727 -9
  21. package/web/app.js +1810 -232
  22. package/web/i18n.js +207 -17
  23. package/web/index.html +115 -1
  24. package/web/remote-pairing/api-router.js +873 -0
  25. package/web/remote-pairing/keys.js +237 -0
  26. package/web/remote-pairing/pairing-state.js +313 -0
  27. package/web/remote-pairing/rpc-client.js +765 -0
  28. package/web/remote-pairing/transport.js +804 -0
  29. package/web/remote-pairing/wake.js +149 -0
  30. package/web/remote-pairing-test.html +400 -0
  31. package/web/remote-pairing.bundle.js +3 -0
  32. package/web/remote-pairing.bundle.js.LEGAL.txt +15 -0
  33. package/web/remote-pairing.bundle.js.map +7 -0
  34. package/web/sw.js +190 -20
package/web/app.js CHANGED
@@ -1,6 +1,14 @@
1
1
  import { DEFAULT_LOCALE, SUPPORTED_LOCALES, localeDisplayName, normalizeLocale, resolveLocalePreference, t } from "./i18n.js";
2
+ import { ensureIdentityKeypair, bytesToHex } from "./remote-pairing/keys.js";
3
+ import {
4
+ loadPairingState as loadRemotePairingState,
5
+ savePairingState as saveRemotePairingState,
6
+ clearPairingState as clearRemotePairingState,
7
+ } from "./remote-pairing/pairing-state.js";
8
+ import { getRoutingTelemetry, routedFetch } from "./remote-pairing/api-router.js?v=20260428-remote-token-refresh";
2
9
 
3
10
  const DESKTOP_BREAKPOINT = 980;
11
+ const APP_BUILD_ID = "20260428-remote-token-refresh";
4
12
  const INSTALL_BANNER_DISMISS_KEY = "viveworker-install-banner-dismissed-v2";
5
13
  const PUSH_BANNER_DISMISS_KEY = "viveworker-push-banner-dismissed-v1";
6
14
  const INITIAL_DETECTED_LOCALE = detectBrowserLocale();
@@ -12,6 +20,24 @@ const THREAD_FILTER_INTERACTION_DEFER_MS = 8000;
12
20
  const MAX_COMPLETION_REPLY_IMAGE_COUNT = 4;
13
21
  const NOTIFICATION_INTENT_CACHE = "viveworker-notification-intent-v1";
14
22
  const NOTIFICATION_INTENT_PATH = "/__viveworker_notification_intent__";
23
+ const MAX_TIMELINE_IMAGE_OBJECT_URLS = 80;
24
+ const REMOTE_PAIRING_STATE_STORAGE_KEY = "viveworker.remote-pairing.state";
25
+ const REMOTE_PAIRING_STATE_SCHEMA_VERSION = 2;
26
+ const REMOTE_PAIRING_STATE_LEGACY_SCHEMA_VERSION = 1;
27
+ const BOOT_SPLASH_SLOW_HINT_MS = 10000;
28
+ const BOOT_SPLASH_REMOTE_SWITCHING_MIN_MS = 650;
29
+ const BOOTSTRAP_REMOTE_TIMEOUT_MS = 12_000;
30
+ const REMOTE_PAIRING_TOKEN_REFRESH_MS = 30 * 24 * 60 * 60 * 1000;
31
+ const BOOT_TRACE_MAX_EVENTS = 90;
32
+ const BOOT_TRACE_MAX_VALUE_LENGTH = 120;
33
+ const BOOT_SPLASH_STAGE = Object.freeze({
34
+ initial: -1,
35
+ checking: 0,
36
+ switching: 1,
37
+ establishing: 2,
38
+ loading: 3,
39
+ });
40
+ const timelineImageObjectUrlCache = new Map();
15
41
 
16
42
  const state = {
17
43
  session: null,
@@ -51,6 +77,7 @@ const state = {
51
77
  detailDiffExpanded: {},
52
78
  choiceLocalDrafts: {},
53
79
  completionReplyDrafts: {},
80
+ completionReplySheetToken: "",
54
81
  pendingActionUrls: new Set(),
55
82
  pairError: "",
56
83
  pairNotice: "",
@@ -60,6 +87,21 @@ const state = {
60
87
  a2aRelayStatus: null,
61
88
  a2aShareStatus: null,
62
89
  a2aShareRecentExpanded: 0,
90
+ // Remote-pairing relay snapshot — { enabled, relayUrl, configuredRelayUrl,
91
+ // identityFingerprint, sessions: [...], pairings: [...] } | null.
92
+ // Populated by fetchRemotePairingStatus() on settings page open and after
93
+ // toggle/revoke actions. Null until first fetch completes.
94
+ remotePairingStatus: null,
95
+ // Notice / error flashes for the remote-pairing settings page (consumed
96
+ // and cleared after a single render, mirroring `pushNotice` / `pushError`
97
+ // from the push UI).
98
+ remotePairingNotice: "",
99
+ remotePairingError: "",
100
+ // Pending action key for in-flight toggle / revoke / relay-url save —
101
+ // used to disable buttons while the round-trip is in progress so a fast
102
+ // double-click doesn't fire two POSTs.
103
+ remotePairingPending: "",
104
+ remotePairingDetailsOpen: false,
63
105
  hazbaseStatus: null,
64
106
  hazbaseNotice: "",
65
107
  hazbaseError: "",
@@ -106,6 +148,9 @@ const state = {
106
148
  defaultLocale: DEFAULT_LOCALE,
107
149
  supportedLocales: [...SUPPORTED_LOCALES],
108
150
  appVersion: "",
151
+ appBuildId: APP_BUILD_ID,
152
+ serverAppBuildId: "",
153
+ clientUpdateRequired: false,
109
154
  versionStatus: null,
110
155
  versionStatusError: "",
111
156
  };
@@ -132,6 +177,302 @@ function hazbasePasskeyHostSupport() {
132
177
  }
133
178
 
134
179
  const app = document.querySelector("#app");
180
+ let bootSplashDismissed = false;
181
+ let bootSplashHintTimer = null;
182
+ let bootSplashHintVisible = false;
183
+ let bootSplashDeferredStatusTimer = null;
184
+ let bootSplashRemoteRouteSeen = false;
185
+ let bootSplashRemoteSwitchingShownAtMs = 0;
186
+ let bootSplashStatusStage = BOOT_SPLASH_STAGE.initial;
187
+ let bootSplashPendingStatusStage = BOOT_SPLASH_STAGE.initial;
188
+ const bootTraceStartedAtMs = Date.now();
189
+ const bootTraceStartPerfMs = bootTraceNow();
190
+ const bootTraceId = makeBootTraceId();
191
+ let bootTraceEvents = [];
192
+ let bootTraceClosed = false;
193
+ let bootTraceSent = false;
194
+
195
+ if (typeof window !== "undefined") {
196
+ window.__viveworkerAppBuild = APP_BUILD_ID;
197
+ window.__viveworkerBootTraceId = bootTraceId;
198
+ }
199
+
200
+ function bootTraceNow() {
201
+ return typeof performance !== "undefined" && typeof performance.now === "function"
202
+ ? performance.now()
203
+ : Date.now();
204
+ }
205
+
206
+ function makeBootTraceId() {
207
+ const randomPart = Math.random().toString(36).slice(2, 10);
208
+ return `${Date.now().toString(36)}-${randomPart}`;
209
+ }
210
+
211
+ function sanitizeBootTraceValue(value) {
212
+ if (value === null || value === undefined) {
213
+ return "";
214
+ }
215
+ return String(value).slice(0, BOOT_TRACE_MAX_VALUE_LENGTH);
216
+ }
217
+
218
+ function sanitizeBootTraceUrl(value) {
219
+ const raw = sanitizeBootTraceValue(value);
220
+ if (!raw) {
221
+ return "";
222
+ }
223
+ try {
224
+ const base = window.location?.origin || "https://localhost";
225
+ const url = new URL(raw, base);
226
+ return url.pathname;
227
+ } catch {
228
+ return raw.split("?")[0].slice(0, BOOT_TRACE_MAX_VALUE_LENGTH);
229
+ }
230
+ }
231
+
232
+ function recordBootTraceEvent(type, detail = {}) {
233
+ if (bootTraceClosed) {
234
+ return;
235
+ }
236
+ const event = {
237
+ type: sanitizeBootTraceValue(type),
238
+ tMs: Math.max(0, Math.round(bootTraceNow() - bootTraceStartPerfMs)),
239
+ };
240
+ const phase = sanitizeBootTraceValue(detail.phase);
241
+ const url = sanitizeBootTraceUrl(detail.url);
242
+ const stateValue = sanitizeBootTraceValue(detail.state);
243
+ const previousState = sanitizeBootTraceValue(detail.previousState);
244
+ const reason = sanitizeBootTraceValue(detail.reason);
245
+ if (phase) event.phase = phase;
246
+ if (url) event.url = url;
247
+ if (stateValue) event.state = stateValue;
248
+ if (previousState) event.previousState = previousState;
249
+ if (reason) event.reason = reason;
250
+ if (detail.sticky === true || detail.sticky === false) event.sticky = detail.sticky;
251
+ if (Number.isFinite(Number(detail.code))) event.code = Number(detail.code);
252
+ if (detail.resumed === true || detail.resumed === false) event.resumed = detail.resumed;
253
+ bootTraceEvents.push(event);
254
+ if (bootTraceEvents.length > BOOT_TRACE_MAX_EVENTS) {
255
+ bootTraceEvents = bootTraceEvents.slice(-BOOT_TRACE_MAX_EVENTS);
256
+ }
257
+ }
258
+
259
+ function flushBootTrace(reason, extra = {}) {
260
+ if (bootTraceSent) {
261
+ return;
262
+ }
263
+ bootTraceSent = true;
264
+ bootTraceClosed = true;
265
+ const payload = {
266
+ traceId: bootTraceId,
267
+ reason: sanitizeBootTraceValue(reason),
268
+ appBuildId: APP_BUILD_ID,
269
+ locale: state.locale || DEFAULT_LOCALE,
270
+ startedAtMs: bootTraceStartedAtMs,
271
+ totalMs: Math.max(0, Math.round(bootTraceNow() - bootTraceStartPerfMs)),
272
+ remoteRouteSeen: bootSplashRemoteRouteSeen,
273
+ finalStage: bootSplashStatusStage,
274
+ userAgent: navigator.userAgent || "",
275
+ events: bootTraceEvents,
276
+ ...extra,
277
+ };
278
+ queueMicrotask(() => {
279
+ routedFetch("/api/remote-pairing/boot-trace", {
280
+ method: "POST",
281
+ credentials: "same-origin",
282
+ headers: {
283
+ "Content-Type": "application/json",
284
+ Accept: "application/json",
285
+ },
286
+ body: JSON.stringify(payload),
287
+ }, { suppressRoutingStatus: true }).catch(() => {});
288
+ });
289
+ }
290
+
291
+ function syncVisualViewportMetrics() {
292
+ if (typeof document === "undefined") {
293
+ return;
294
+ }
295
+ const root = document.documentElement;
296
+ const viewport = window.visualViewport;
297
+ const width = viewport?.width || window.innerWidth || root.clientWidth || 0;
298
+ const left = viewport?.offsetLeft || 0;
299
+ root.style.setProperty("--visual-viewport-width", `${Math.max(0, width)}px`);
300
+ root.style.setProperty("--visual-viewport-left", `${Math.max(0, left)}px`);
301
+ }
302
+
303
+ function resetHorizontalViewportScroll() {
304
+ syncVisualViewportMetrics();
305
+ const scrollingElement = document.scrollingElement || document.documentElement;
306
+ if (scrollingElement) {
307
+ scrollingElement.scrollLeft = 0;
308
+ }
309
+ document.documentElement.scrollLeft = 0;
310
+ document.body.scrollLeft = 0;
311
+ syncVisualViewportMetrics();
312
+ window.requestAnimationFrame?.(syncVisualViewportMetrics);
313
+ }
314
+
315
+ function dismissBootSplash() {
316
+ if (bootSplashDismissed || typeof document === "undefined") {
317
+ return;
318
+ }
319
+ bootSplashDismissed = true;
320
+ clearBootSplashHintTimer();
321
+ clearBootSplashDeferredStatusTimer();
322
+ if (typeof window !== "undefined") {
323
+ window.removeEventListener("viveworker:remote-routing-status", handleBootRoutingStatus);
324
+ }
325
+ const splash = document.querySelector("#boot-splash");
326
+ document.body?.classList.add("viveworker-ready");
327
+ if (!splash) {
328
+ return;
329
+ }
330
+ splash.setAttribute("aria-hidden", "true");
331
+ window.setTimeout(() => {
332
+ splash.remove();
333
+ }, 280);
334
+ }
335
+
336
+ function setBootSplashStatus(key, stage = bootSplashStatusStage) {
337
+ if (bootSplashDismissed || typeof document === "undefined") {
338
+ return false;
339
+ }
340
+ if (stage < bootSplashStatusStage) {
341
+ return false;
342
+ }
343
+ const message = L(key);
344
+ const status = document.querySelector("#boot-splash-status");
345
+ const splash = document.querySelector("#boot-splash");
346
+ if (status && status.textContent !== message) {
347
+ status.textContent = message;
348
+ }
349
+ if (splash) {
350
+ splash.setAttribute("aria-label", `${L("common.appName")} ${message}`);
351
+ }
352
+ if (bootSplashRemoteRouteSeen) {
353
+ ensureBootSplashHintTimer();
354
+ }
355
+ bootSplashStatusStage = Math.max(bootSplashStatusStage, stage);
356
+ return true;
357
+ }
358
+
359
+ function ensureBootSplashHintTimer() {
360
+ if (bootSplashHintTimer != null || bootSplashHintVisible || bootSplashDismissed) {
361
+ return;
362
+ }
363
+ bootSplashHintTimer = window.setTimeout(() => {
364
+ bootSplashHintTimer = null;
365
+ showBootSplashHint();
366
+ }, BOOT_SPLASH_SLOW_HINT_MS);
367
+ }
368
+
369
+ function clearBootSplashHintTimer() {
370
+ if (bootSplashHintTimer != null) {
371
+ window.clearTimeout(bootSplashHintTimer);
372
+ bootSplashHintTimer = null;
373
+ }
374
+ }
375
+
376
+ function clearBootSplashDeferredStatusTimer() {
377
+ if (bootSplashDeferredStatusTimer != null) {
378
+ window.clearTimeout(bootSplashDeferredStatusTimer);
379
+ bootSplashDeferredStatusTimer = null;
380
+ }
381
+ bootSplashPendingStatusStage = BOOT_SPLASH_STAGE.initial;
382
+ }
383
+
384
+ function showBootSplashHint() {
385
+ if (bootSplashDismissed || bootSplashHintVisible || typeof document === "undefined") {
386
+ return;
387
+ }
388
+ const hint = document.querySelector("#boot-splash-hint");
389
+ if (!hint) {
390
+ return;
391
+ }
392
+ bootSplashHintVisible = true;
393
+ hint.textContent = L("boot.status.slowHint");
394
+ hint.hidden = false;
395
+ if (typeof window.requestAnimationFrame === "function") {
396
+ window.requestAnimationFrame(() => {
397
+ hint.classList.add("is-visible");
398
+ });
399
+ } else {
400
+ hint.classList.add("is-visible");
401
+ }
402
+ }
403
+
404
+ function bootSplashNow() {
405
+ return typeof performance !== "undefined" && typeof performance.now === "function"
406
+ ? performance.now()
407
+ : Date.now();
408
+ }
409
+
410
+ function showBootRemoteSwitchingStatus() {
411
+ bootSplashRemoteRouteSeen = true;
412
+ const effectiveStage = Math.max(bootSplashStatusStage, bootSplashPendingStatusStage);
413
+ if (effectiveStage >= BOOT_SPLASH_STAGE.switching) {
414
+ ensureBootSplashHintTimer();
415
+ return;
416
+ }
417
+ bootSplashRemoteSwitchingShownAtMs = bootSplashNow();
418
+ clearBootSplashDeferredStatusTimer();
419
+ setBootSplashStatus("boot.status.switchingRemote", BOOT_SPLASH_STAGE.switching);
420
+ }
421
+
422
+ function setBootRemoteStatusAfterSwitching(key, stage) {
423
+ bootSplashRemoteRouteSeen = true;
424
+ const effectiveStage = Math.max(bootSplashStatusStage, bootSplashPendingStatusStage);
425
+ if (stage <= effectiveStage) {
426
+ ensureBootSplashHintTimer();
427
+ return;
428
+ }
429
+ if (bootSplashStatusStage < BOOT_SPLASH_STAGE.switching) {
430
+ showBootRemoteSwitchingStatus();
431
+ }
432
+ const elapsedMs = bootSplashRemoteSwitchingShownAtMs > 0
433
+ ? bootSplashNow() - bootSplashRemoteSwitchingShownAtMs
434
+ : BOOT_SPLASH_REMOTE_SWITCHING_MIN_MS;
435
+ const delayMs = Math.max(0, BOOT_SPLASH_REMOTE_SWITCHING_MIN_MS - elapsedMs);
436
+ clearBootSplashDeferredStatusTimer();
437
+ if (delayMs <= 0) {
438
+ setBootSplashStatus(key, stage);
439
+ return;
440
+ }
441
+ bootSplashDeferredStatusTimer = window.setTimeout(() => {
442
+ bootSplashDeferredStatusTimer = null;
443
+ bootSplashPendingStatusStage = BOOT_SPLASH_STAGE.initial;
444
+ setBootSplashStatus(key, stage);
445
+ }, delayMs);
446
+ bootSplashPendingStatusStage = stage;
447
+ }
448
+
449
+ function handleBootRoutingStatus(event) {
450
+ if (bootSplashDismissed) {
451
+ return;
452
+ }
453
+ recordBootTraceEvent("route", event?.detail || {});
454
+ const phase = event?.detail?.phase || "";
455
+ switch (phase) {
456
+ case "lan-checking":
457
+ setBootSplashStatus("boot.status.checkingLan", BOOT_SPLASH_STAGE.checking);
458
+ break;
459
+ case "lan-failed":
460
+ case "remote-switching":
461
+ showBootRemoteSwitchingStatus();
462
+ break;
463
+ case "remote-connecting":
464
+ setBootRemoteStatusAfterSwitching("boot.status.establishingRemote", BOOT_SPLASH_STAGE.establishing);
465
+ break;
466
+ case "lan-connected":
467
+ // Same-LAN startup is fast and already covered by the checking message.
468
+ break;
469
+ case "remote-connected":
470
+ setBootRemoteStatusAfterSwitching("boot.status.loadingData", BOOT_SPLASH_STAGE.loading);
471
+ break;
472
+ default:
473
+ break;
474
+ }
475
+ }
135
476
  const params = new URLSearchParams(window.location.search);
136
477
  const initialItem = params.get("item") || "";
137
478
  const initialTargetTab = params.get("tab") || "";
@@ -142,10 +483,14 @@ let didReloadForServiceWorker = false;
142
483
  let lastViewportMode = isDesktopLayout();
143
484
 
144
485
  boot().catch((error) => {
145
- const message = error.message || String(error);
146
- const hint = /Load failed|Failed to fetch|NetworkError|fetch/i.test(message)
486
+ const message = bootErrorMessage(error);
487
+ const hint = shouldShowNetworkHint(error, message)
147
488
  ? `<p class="muted">${escapeHtml(L("error.networkHint"))}</p>`
148
489
  : "";
490
+ flushBootTrace("boot-error", {
491
+ error: sanitizeBootTraceValue(error?.code || error?.name || message),
492
+ });
493
+ dismissBootSplash();
149
494
  app.innerHTML = `
150
495
  <main class="onboarding-shell">
151
496
  <section class="onboarding-card">
@@ -158,13 +503,95 @@ boot().catch((error) => {
158
503
  `;
159
504
  });
160
505
 
506
+ function bootErrorMessage(error) {
507
+ if (isRemotePairingEnrollmentRequired(error)) {
508
+ return L("error.remotePairingNeedsLanRefresh");
509
+ }
510
+ if (isRemotePairingUnavailable(error)) {
511
+ return L("error.remotePairingUnavailable");
512
+ }
513
+ return error?.message || String(error);
514
+ }
515
+
516
+ function shouldShowNetworkHint(error, message) {
517
+ if (isRemotePairingEnrollmentRequired(error) || isRemotePairingUnavailable(error)) {
518
+ return false;
519
+ }
520
+ return /Load failed|Failed to fetch|NetworkError|fetch/i.test(message);
521
+ }
522
+
523
+ function isRemotePairingEnrollmentRequired(error) {
524
+ return error?.code === "remote-pairing-enrollment-required" ||
525
+ error?.name === "RemotePairingEnrollmentRequiredError";
526
+ }
527
+
528
+ function isRemotePairingUnavailable(error) {
529
+ return error?.code === "remote-pairing-unavailable" ||
530
+ error?.code === "remote-pairing-unreachable" ||
531
+ error?.name === "RemotePairingUnavailableError" ||
532
+ error?.name === "RpcTimeoutError" ||
533
+ error?.name === "RpcTransportError" ||
534
+ error?.name === "RpcTransportFailedError";
535
+ }
536
+
537
+ function inspectRemotePairingStateForEnrollment() {
538
+ let store;
539
+ try {
540
+ store = globalThis.localStorage ?? null;
541
+ } catch {
542
+ return { status: "storage-unavailable", needsEnrollment: false };
543
+ }
544
+ if (!store) {
545
+ return { status: "storage-unavailable", needsEnrollment: false };
546
+ }
547
+
548
+ let raw;
549
+ try {
550
+ raw = store.getItem(REMOTE_PAIRING_STATE_STORAGE_KEY);
551
+ } catch {
552
+ return { status: "storage-unavailable", needsEnrollment: false };
553
+ }
554
+ if (!raw) {
555
+ return { status: "missing", needsEnrollment: true };
556
+ }
557
+
558
+ let parsed;
559
+ try {
560
+ parsed = JSON.parse(raw);
561
+ } catch {
562
+ return { status: "malformed", needsEnrollment: true };
563
+ }
564
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
565
+ return { status: "malformed", needsEnrollment: true };
566
+ }
567
+ if (parsed.version === REMOTE_PAIRING_STATE_LEGACY_SCHEMA_VERSION) {
568
+ return { status: "legacy-v1", needsEnrollment: true };
569
+ }
570
+ if (parsed.version !== REMOTE_PAIRING_STATE_SCHEMA_VERSION) {
571
+ return { status: "unsupported-version", needsEnrollment: true };
572
+ }
573
+ if (typeof parsed.relayToken !== "string" || parsed.relayToken.length === 0) {
574
+ return { status: "missing-token", needsEnrollment: true };
575
+ }
576
+ return { status: "ready", needsEnrollment: false };
577
+ }
578
+
161
579
  async function boot() {
580
+ recordBootTraceEvent("boot-start", {
581
+ url: window.location?.pathname || "/app",
582
+ });
162
583
  updateManifestHref(initialPairToken);
584
+ syncVisualViewportMetrics();
585
+ setBootSplashStatus("boot.status.checkingLan", BOOT_SPLASH_STAGE.checking);
163
586
  // SW register + update() can take hundreds of ms and does not need to gate
164
587
  // first paint. Fire and forget; the `controllerchange` reload handler wired
165
588
  // up inside `registerServiceWorker` still picks up new versions.
166
589
  registerServiceWorker().catch(() => {});
590
+ window.addEventListener("viveworker:remote-routing-status", handleBootRoutingStatus);
167
591
  navigator.serviceWorker?.addEventListener("message", handleServiceWorkerMessage);
592
+ window.addEventListener("resize", syncVisualViewportMetrics, { passive: true });
593
+ window.visualViewport?.addEventListener("resize", syncVisualViewportMetrics, { passive: true });
594
+ window.visualViewport?.addEventListener("scroll", syncVisualViewportMetrics, { passive: true });
168
595
  window.addEventListener("resize", handleViewportChange, { passive: true });
169
596
  window.addEventListener("focus", handlePotentialExternalNavigation, { passive: true });
170
597
  window.addEventListener("pageshow", handlePotentialExternalNavigation, { passive: true });
@@ -173,13 +600,24 @@ async function boot() {
173
600
  // Single round-trip for session + inbox(pending/completed) + timeline +
174
601
  // devices. See `refreshBootstrap` for why we collapsed the boot fan-out.
175
602
  await refreshBootstrap();
603
+ if (bootSplashRemoteRouteSeen) {
604
+ setBootRemoteStatusAfterSwitching("boot.status.loadingData", BOOT_SPLASH_STAGE.loading);
605
+ }
606
+ flushBootTrace("bootstrap-complete");
176
607
 
177
608
  if (!state.session?.authenticated && initialPairToken && shouldAutoPairFromBootstrapToken()) {
178
609
  try {
179
- await pair({
610
+ const pairResult = await pair({
180
611
  token: initialPairToken,
181
612
  temporary: shouldUseTemporaryBootstrapPairing(),
182
613
  });
614
+ // Mirror the manual #pair-form path: register with the bridge so
615
+ // off-LAN reconnect via the relay works without a second pair step.
616
+ // Skipped when the bridge graced this tab a temporary session
617
+ // (Safari non-PWA bootstrap), since those don't survive the tab.
618
+ if (pairResult?.temporaryPairing !== true) {
619
+ await enrollRemotePairing();
620
+ }
183
621
  } catch (error) {
184
622
  state.pairError = error.message || String(error);
185
623
  }
@@ -211,6 +649,7 @@ async function boot() {
211
649
  return;
212
650
  }
213
651
 
652
+ await maybeAutoEnrollRemotePairingFromLan();
214
653
  await consumePendingNotificationIntent();
215
654
  // `?focusPending=claude` marks this tab as the Claude-hook-opened popup:
216
655
  // auto-navigate to the newest unresolved Claude pending (plan/question)
@@ -288,6 +727,7 @@ async function boot() {
288
727
  refreshInboxDiff(),
289
728
  fetchMoltbookScoutStatus(),
290
729
  fetchA2aShareStatus(),
730
+ fetchRemotePairingStatus(),
291
731
  ])
292
732
  .then(async () => {
293
733
  if (!shouldDeferRenderForActiveInteraction()) {
@@ -298,6 +738,49 @@ async function boot() {
298
738
  }, 3000);
299
739
  }
300
740
 
741
+ async function maybeAutoEnrollRemotePairingFromLan() {
742
+ if (!state.session?.authenticated) {
743
+ return null;
744
+ }
745
+ const pairingState = inspectRemotePairingStateForEnrollment();
746
+ const telemetry = getRoutingTelemetry();
747
+ if (!telemetry || telemetry.lanOk <= 0) {
748
+ return null;
749
+ }
750
+ if (!pairingState.needsEnrollment) {
751
+ const record = pairingState.record || loadRemotePairingState();
752
+ if (!shouldRefreshRemotePairingTokenFromLan(record)) {
753
+ return null;
754
+ }
755
+ }
756
+ return enrollRemotePairing();
757
+ }
758
+
759
+ function shouldRefreshRemotePairingTokenFromLan(record) {
760
+ if (!record) {
761
+ return false;
762
+ }
763
+ const updatedAt = Number(record.relayTokenUpdatedAtMs) || 0;
764
+ if (!updatedAt) {
765
+ return true;
766
+ }
767
+ return Date.now() - updatedAt >= REMOTE_PAIRING_TOKEN_REFRESH_MS;
768
+ }
769
+
770
+ function isRemotePairingUsingRelay() {
771
+ const telemetry = getRoutingTelemetry();
772
+ if (!telemetry) {
773
+ return false;
774
+ }
775
+ if (telemetry.lastRoute === "lan") {
776
+ return false;
777
+ }
778
+ if (telemetry.lastRoute === "relay") {
779
+ return true;
780
+ }
781
+ return telemetry.relayOk > 0 && Number(telemetry.stickyRelayUntilMs || 0) > Date.now();
782
+ }
783
+
301
784
  async function registerServiceWorker() {
302
785
  if (!("serviceWorker" in navigator)) {
303
786
  return;
@@ -317,6 +800,47 @@ async function registerServiceWorker() {
317
800
  }
318
801
  }
319
802
 
803
+ async function forceAppRefreshFromLan() {
804
+ try {
805
+ const healthInit = {
806
+ cache: "no-store",
807
+ credentials: "same-origin",
808
+ headers: { Accept: "application/json" },
809
+ };
810
+ if (typeof AbortSignal !== "undefined" && typeof AbortSignal.timeout === "function") {
811
+ healthInit.signal = AbortSignal.timeout(2500);
812
+ }
813
+ await fetch("/health", healthInit);
814
+ } catch {
815
+ state.pushError = L("error.clientUpdateNeedsLan");
816
+ return;
817
+ }
818
+
819
+ try {
820
+ const registration = state.serviceWorkerRegistration || await navigator.serviceWorker?.getRegistration?.();
821
+ await registration?.update?.();
822
+ } catch {
823
+ // Reload below is still useful; the next boot can retry SW update.
824
+ }
825
+
826
+ try {
827
+ if (typeof caches !== "undefined") {
828
+ const keys = await caches.keys();
829
+ await Promise.all(
830
+ keys
831
+ .filter((key) => /^viveworker-v/.test(key))
832
+ .map((key) => caches.delete(key))
833
+ );
834
+ }
835
+ } catch {
836
+ // Cache deletion is best-effort; network-first app routes still help.
837
+ }
838
+
839
+ const nextUrl = new URL(window.location.href);
840
+ nextUrl.searchParams.set("appRefresh", String(Date.now()));
841
+ window.location.replace(nextUrl.toString());
842
+ }
843
+
320
844
  function handleViewportChange() {
321
845
  const nextViewportMode = isDesktopLayout();
322
846
  if (nextViewportMode === lastViewportMode) {
@@ -346,6 +870,7 @@ async function refreshAuthenticatedState() {
346
870
  await fetchMoltbookScoutStatus();
347
871
  await fetchA2aRelayStatus();
348
872
  await fetchA2aShareStatus();
873
+ await fetchRemotePairingStatus();
349
874
  if (state.currentTab === "settings") {
350
875
  await fetchHazbaseStatus();
351
876
  }
@@ -368,11 +893,13 @@ async function refreshAuthenticatedStateRemote() {
368
893
  fetchMoltbookScoutStatus(),
369
894
  fetchA2aRelayStatus(),
370
895
  fetchA2aShareStatus(),
896
+ fetchRemotePairingStatus(),
371
897
  ]);
372
898
  }
373
899
 
374
900
  async function refreshSession() {
375
901
  state.session = await apiGet("/api/session");
902
+ applyServerAppBuildId(state.session?.webAppBuildId);
376
903
  syncPairingTokenState(desiredBootstrapPairingToken());
377
904
  applyResolvedLocale();
378
905
  }
@@ -384,8 +911,14 @@ async function refreshSession() {
384
911
  // iOS PWAs where connection reuse is aggressive. Leaves the diff and
385
912
  // external-status probes as separate background phases in `boot()`.
386
913
  async function refreshBootstrap() {
387
- const bootstrap = await apiGet("/api/bootstrap");
914
+ recordBootTraceEvent("bootstrap-start", { url: "/api/bootstrap" });
915
+ const bootstrap = await apiGet("/api/bootstrap", {
916
+ timeoutMs: BOOTSTRAP_REMOTE_TIMEOUT_MS,
917
+ preferRelayError: true,
918
+ });
919
+ recordBootTraceEvent("bootstrap-response", { url: "/api/bootstrap" });
388
920
  state.session = bootstrap?.session || null;
921
+ applyServerAppBuildId(bootstrap?.appBuildId || state.session?.webAppBuildId);
389
922
  syncPairingTokenState(desiredBootstrapPairingToken());
390
923
  applyResolvedLocale();
391
924
 
@@ -406,7 +939,7 @@ async function refreshBootstrap() {
406
939
  syncCompletedThreadFilter();
407
940
  syncInboxSubtab();
408
941
 
409
- state.timeline = bootstrap?.timeline || null;
942
+ state.timeline = await hydrateTimelinePayloadImages(bootstrap?.timeline || null);
410
943
  syncTimelineThreadFilter();
411
944
  syncTimelineKindFilter();
412
945
 
@@ -415,6 +948,12 @@ async function refreshBootstrap() {
415
948
  state.deviceError = "";
416
949
  }
417
950
 
951
+ function applyServerAppBuildId(value) {
952
+ const buildId = normalizeClientText(value);
953
+ state.serverAppBuildId = buildId;
954
+ state.clientUpdateRequired = Boolean(buildId && buildId !== APP_BUILD_ID);
955
+ }
956
+
418
957
  async function syncDetectedLocalePreference() {
419
958
  if (!state.session?.authenticated || !state.session?.deviceId || !state.detectedLocale) {
420
959
  return;
@@ -511,9 +1050,10 @@ function detectBrowserLocale() {
511
1050
 
512
1051
  async function refreshPushStatus() {
513
1052
  const client = await getClientPushState();
1053
+ const { clientSubscription, ...clientStatus } = client;
514
1054
  if (!state.session?.authenticated) {
515
1055
  state.pushStatus = {
516
- ...client,
1056
+ ...clientStatus,
517
1057
  enabled: false,
518
1058
  subscribed: false,
519
1059
  serverSubscribed: false,
@@ -524,16 +1064,34 @@ async function refreshPushStatus() {
524
1064
  }
525
1065
 
526
1066
  try {
527
- const server = await apiGet("/api/push/status");
1067
+ let server = await apiGet("/api/push/status");
1068
+ if (
1069
+ server?.enabled === true &&
1070
+ server?.subscribed !== true &&
1071
+ clientSubscription &&
1072
+ clientStatus.notificationPermission === "granted"
1073
+ ) {
1074
+ try {
1075
+ await apiPost("/api/push/subscribe", {
1076
+ subscription: clientSubscription,
1077
+ userAgent: navigator.userAgent,
1078
+ standalone: isStandaloneMode(),
1079
+ });
1080
+ server = await apiGet("/api/push/status");
1081
+ } catch {
1082
+ // Best effort: if the browser still has a local subscription, the
1083
+ // status row can reflect that while the enable action repairs it.
1084
+ }
1085
+ }
528
1086
  state.pushStatus = {
529
1087
  ...server,
530
- ...client,
1088
+ ...clientStatus,
531
1089
  serverSubscribed: Boolean(server.subscribed),
532
- subscribed: Boolean(server.subscribed || client.clientSubscribed),
1090
+ subscribed: Boolean(server.subscribed || clientStatus.clientSubscribed),
533
1091
  };
534
1092
  } catch (error) {
535
1093
  state.pushStatus = {
536
- ...client,
1094
+ ...clientStatus,
537
1095
  enabled: false,
538
1096
  subscribed: false,
539
1097
  serverSubscribed: false,
@@ -568,6 +1126,18 @@ async function fetchA2aRelayStatus() {
568
1126
  }
569
1127
  }
570
1128
 
1129
+ async function fetchRemotePairingStatus() {
1130
+ if (!state.session?.remotePairingAvailable) {
1131
+ state.remotePairingStatus = null;
1132
+ return;
1133
+ }
1134
+ try {
1135
+ state.remotePairingStatus = await apiGet("/api/remote-pairing/status");
1136
+ } catch {
1137
+ state.remotePairingStatus = null;
1138
+ }
1139
+ }
1140
+
571
1141
  async function fetchA2aShareStatus() {
572
1142
  if (!state.session?.a2aShareEnabled) {
573
1143
  state.a2aShareStatus = null;
@@ -630,6 +1200,7 @@ async function getClientPushState() {
630
1200
  "PushManager" in window &&
631
1201
  "Notification" in window,
632
1202
  clientSubscribed: Boolean(subscription),
1203
+ clientSubscription: subscription ? subscription.toJSON() : null,
633
1204
  };
634
1205
  }
635
1206
 
@@ -670,7 +1241,7 @@ async function refreshInboxDiff() {
670
1241
  }
671
1242
 
672
1243
  async function refreshTimeline() {
673
- state.timeline = await apiGet("/api/timeline");
1244
+ state.timeline = await hydrateTimelinePayloadImages(await apiGet("/api/timeline"));
674
1245
  syncTimelineThreadFilter();
675
1246
  syncTimelineKindFilter();
676
1247
  }
@@ -1158,9 +1729,16 @@ function renderPair() {
1158
1729
  event.preventDefault();
1159
1730
  const form = new FormData(event.currentTarget);
1160
1731
  try {
1161
- await pair({ code: String(form.get("code") || "") });
1732
+ const pairResult = await pair({ code: String(form.get("code") || "") });
1162
1733
  state.pairError = "";
1163
1734
  state.pairNotice = "";
1735
+ // Best-effort: register this phone's X25519 pubkey with the bridge
1736
+ // so it can reach us via the relay later. Doesn't block the shell
1737
+ // render; failures (older bridge, IndexedDB blocked, …) are swallowed
1738
+ // inside the helper.
1739
+ if (pairResult?.temporaryPairing !== true) {
1740
+ await enrollRemotePairing();
1741
+ }
1164
1742
  await refreshSession();
1165
1743
  await refreshAuthenticatedState();
1166
1744
  await renderShell();
@@ -1171,6 +1749,7 @@ function renderPair() {
1171
1749
  });
1172
1750
 
1173
1751
  bindSharedUi(renderPair);
1752
+ requestAnimationFrame(dismissBootSplash);
1174
1753
  }
1175
1754
 
1176
1755
  async function pair(payload) {
@@ -1181,6 +1760,80 @@ async function pair(payload) {
1181
1760
  return result;
1182
1761
  }
1183
1762
 
1763
+ /**
1764
+ * Best-effort post-pair enrollment: hand the bridge our X25519 static
1765
+ * pubkey + a friendly label, persist the bridge's response in localStorage,
1766
+ * and return the saved record (or null on any failure).
1767
+ *
1768
+ * Always called after a successful LAN pair (regardless of whether the
1769
+ * remote relay is currently ON). Reasoning:
1770
+ *
1771
+ * - Enrollment is idempotent on phonePub server-side; re-pairing the same
1772
+ * phone keeps its existing pairingId and bridge identity.
1773
+ * - Doing it eagerly means flipping the relay toggle later "just works"
1774
+ * without forcing the user to repair-on-LAN to register their key.
1775
+ * - LAN-only basic auth keeps working even if this fails (e.g. older
1776
+ * bridge predating the lan-enroll endpoint, IndexedDB blocked, etc.) —
1777
+ * we explicitly swallow errors and let the caller continue rendering
1778
+ * the authenticated shell.
1779
+ *
1780
+ * Skipped when the surrounding `pair()` returned `temporaryPairing: true`
1781
+ * — temporary sessions are an opt-in shape that doesn't outlive the tab,
1782
+ * so persisting a pairing record would be misleading.
1783
+ *
1784
+ * @param {string} [label] user-visible device label; defaults to a
1785
+ * "LAN paired YYYY-MM-DD" stamp.
1786
+ * @returns {Promise<import("./remote-pairing/pairing-state.js").RemotePairingState | null>}
1787
+ */
1788
+ async function enrollRemotePairing(label) {
1789
+ try {
1790
+ const keypair = await ensureIdentityKeypair();
1791
+ const phonePubHex = bytesToHex(keypair.pub);
1792
+ const effectiveLabel = (label && String(label).trim()) || buildDefaultEnrollLabel();
1793
+ const response = await apiPost("/api/remote-pairing/lan-enroll", {
1794
+ phonePubHex,
1795
+ label: effectiveLabel,
1796
+ });
1797
+ if (!response || response.ok !== true) {
1798
+ return null;
1799
+ }
1800
+ const saved = saveRemotePairingState({
1801
+ pairingId: response.pairingId,
1802
+ relayToken: response.relayToken,
1803
+ phonePub: response.phonePub,
1804
+ phoneFingerprint: response.phoneFingerprint,
1805
+ bridgePubHex: response.bridgePubHex,
1806
+ bridgeFingerprint: response.bridgeFingerprint,
1807
+ relayUrl: response.relayUrl,
1808
+ label: response.label || "",
1809
+ addedAtMs: Number.isFinite(response.addedAtMs) ? response.addedAtMs : Date.now(),
1810
+ relayTokenUpdatedAtMs: Number.isFinite(response.relayTokenUpdatedAtMs)
1811
+ ? response.relayTokenUpdatedAtMs
1812
+ : Date.now(),
1813
+ });
1814
+ if (!saved) {
1815
+ // Storage is full / disabled; the bridge still has the pairing,
1816
+ // so future toggle-on works — just no localStorage cache for the
1817
+ // PWA's own routing decisions.
1818
+ return null;
1819
+ }
1820
+ return loadRemotePairingState();
1821
+ } catch (error) {
1822
+ console.warn("[remote-pairing] lan-enroll skipped:", error?.message || error);
1823
+ return null;
1824
+ }
1825
+ }
1826
+
1827
+ function buildDefaultEnrollLabel() {
1828
+ // YYYY-MM-DD so re-enrolls produce stable, sortable labels in the
1829
+ // settings list. (The user can rename in the settings page later.)
1830
+ const now = new Date();
1831
+ const y = now.getFullYear();
1832
+ const m = String(now.getMonth() + 1).padStart(2, "0");
1833
+ const d = String(now.getDate()).padStart(2, "0");
1834
+ return `LAN paired ${y}-${m}-${d}`;
1835
+ }
1836
+
1184
1837
  async function logout({ revokeCurrentDeviceTrust = false } = {}) {
1185
1838
  await apiPost("/api/session/logout", { revokeCurrentDeviceTrust });
1186
1839
  resetAuthenticatedState();
@@ -1208,6 +1861,7 @@ function resetAuthenticatedState() {
1208
1861
  state.choiceLocalDrafts = {};
1209
1862
  clearAllCompletionReplyDrafts();
1210
1863
  state.completionReplyDrafts = {};
1864
+ state.completionReplySheetToken = "";
1211
1865
  state.settingsSubpage = "";
1212
1866
  state.settingsScrollState = null;
1213
1867
  state.listScrollState = null;
@@ -1219,6 +1873,7 @@ function resetAuthenticatedState() {
1219
1873
  state.deviceError = "";
1220
1874
  state.logoutConfirmOpen = false;
1221
1875
  state.pairError = "";
1876
+ state.remotePairingDetailsOpen = false;
1222
1877
  }
1223
1878
 
1224
1879
  async function revokeTrustedDevice(deviceId) {
@@ -1240,6 +1895,7 @@ async function revokeTrustedDevice(deviceId) {
1240
1895
  }
1241
1896
 
1242
1897
  async function renderShell() {
1898
+ syncVisualViewportMetrics();
1243
1899
  const desktop = isDesktopLayout();
1244
1900
  const shouldShowDetail = state.currentTab !== "settings" && state.currentItem && (desktop || state.detailOpen);
1245
1901
  let detail = null;
@@ -1272,13 +1928,18 @@ async function renderShell() {
1272
1928
  ${renderLogoutConfirmModal()}
1273
1929
  ${renderHazbaseLogoutConfirmModal()}
1274
1930
  </div>
1931
+ ${!desktop && detail ? renderCompletionReplySheet(detail) : ""}
1275
1932
  `;
1276
1933
 
1277
1934
  bindShellInteractions();
1935
+ if (state.completionReplySheetToken) {
1936
+ resetHorizontalViewportScroll();
1937
+ }
1278
1938
  applyPendingDetailScrollReset();
1279
1939
  applyPendingListScrollRestore();
1280
1940
  applyPendingSettingsSubpageScrollReset();
1281
1941
  applyPendingSettingsScrollRestore();
1942
+ requestAnimationFrame(dismissBootSplash);
1282
1943
  }
1283
1944
 
1284
1945
  function applyPendingDetailScrollReset() {
@@ -1341,6 +2002,9 @@ function clearThreadFilterInteraction() {
1341
2002
 
1342
2003
  function shouldDeferRenderForActiveInteraction() {
1343
2004
  const activeElement = document.activeElement;
2005
+ if (state.completionReplySheetToken) {
2006
+ return true;
2007
+ }
1344
2008
  if (
1345
2009
  activeElement instanceof HTMLTextAreaElement &&
1346
2010
  activeElement.matches("[data-completion-reply-textarea]") &&
@@ -1708,7 +2372,6 @@ function renderMobileTopBar(detail) {
1708
2372
  return `
1709
2373
  <header class="mobile-topbar">
1710
2374
  <div class="mobile-topbar__heading">
1711
- <span class="eyebrow-pill eyebrow-pill--quiet">${escapeHtml(L("common.appName"))}</span>
1712
2375
  <h1 class="mobile-topbar__title">${escapeHtml(meta.title)}</h1>
1713
2376
  </div>
1714
2377
  </header>
@@ -1763,7 +2426,9 @@ async function fetchCurrentDetailForItem(itemRef = state.currentItem) {
1763
2426
  return state.detailOverride.detail;
1764
2427
  }
1765
2428
  try {
1766
- const detail = await apiGet(`/api/items/${encodeURIComponent(itemRef.kind)}/${encodeURIComponent(itemRef.token)}`);
2429
+ const detail = await hydrateDetailImages(
2430
+ await apiGet(`/api/items/${encodeURIComponent(itemRef.kind)}/${encodeURIComponent(itemRef.token)}`)
2431
+ );
1767
2432
  if (hasLaunchItemIntent(itemRef)) {
1768
2433
  state.launchItemIntent.status = "loaded";
1769
2434
  }
@@ -1777,7 +2442,9 @@ async function fetchCurrentDetailForItem(itemRef = state.currentItem) {
1777
2442
  }
1778
2443
  await refreshInbox();
1779
2444
  try {
1780
- const detail = await apiGet(`/api/items/${encodeURIComponent(itemRef.kind)}/${encodeURIComponent(itemRef.token)}`);
2445
+ const detail = await hydrateDetailImages(
2446
+ await apiGet(`/api/items/${encodeURIComponent(itemRef.kind)}/${encodeURIComponent(itemRef.token)}`)
2447
+ );
1781
2448
  if (hasLaunchItemIntent(itemRef)) {
1782
2449
  state.launchItemIntent.status = "loaded";
1783
2450
  }
@@ -1899,7 +2566,11 @@ function buildActionOutcomeDetail({ kind, title, message }) {
1899
2566
 
1900
2567
  function approvalOutcomeMessage(actionUrl, provider) {
1901
2568
  const vars = { provider: providerDisplayName(provider) };
1902
- return /\/accept$/u.test(String(actionUrl || ""))
2569
+ const normalizedUrl = String(actionUrl || "");
2570
+ if (/\/api\/payments\/x402\/hazbase-wallet\/[^/]+\/pay$/u.test(normalizedUrl)) {
2571
+ return L("server.message.paymentSubmitted");
2572
+ }
2573
+ return /\/accept$/u.test(normalizedUrl)
1903
2574
  ? L("server.message.approvalAccepted", vars)
1904
2575
  : L("server.message.approvalRejected", vars);
1905
2576
  }
@@ -2617,6 +3288,8 @@ function timelineEntryStatusLabel(item, isMessageLike) {
2617
3288
  return L("timeline.status.approved");
2618
3289
  case "rejected":
2619
3290
  return L("timeline.status.rejected");
3291
+ case "failed":
3292
+ return L("timeline.status.failed");
2620
3293
  case "implemented":
2621
3294
  return L("timeline.status.implemented");
2622
3295
  case "dismissed":
@@ -3204,7 +3877,7 @@ function buildSettingsContext() {
3204
3877
  standalone,
3205
3878
  supportsPushValue,
3206
3879
  permission,
3207
- subscribed: push.serverSubscribed === true,
3880
+ subscribed: push.subscribed === true,
3208
3881
  });
3209
3882
 
3210
3883
  return {
@@ -3230,6 +3903,7 @@ function buildSettingsContext() {
3230
3903
  a2aRelay: state.a2aRelayStatus,
3231
3904
  a2aShare: state.a2aShareStatus,
3232
3905
  hazbase: state.hazbaseStatus,
3906
+ remotePairing: state.remotePairingStatus,
3233
3907
  };
3234
3908
  }
3235
3909
 
@@ -3332,6 +4006,195 @@ function collectSettingsDiagnostics({ permission, secureContext, standalone, sup
3332
4006
  return Array.from(new Set(issues.filter(Boolean)));
3333
4007
  }
3334
4008
 
4009
+ function hasAutoPilotWriteLaneEnabled(session = state.session) {
4010
+ return Boolean(
4011
+ session?.autoPilotTrustedWrites === true ||
4012
+ session?.autoPilotWriteLaneContent === true ||
4013
+ session?.autoPilotWriteLaneUiTests === true ||
4014
+ session?.autoPilotWriteLaneSource === true,
4015
+ );
4016
+ }
4017
+
4018
+ function hasAutoPilotEnabled(session = state.session) {
4019
+ return Boolean(session?.autoPilotTrustedReads === true || hasAutoPilotWriteLaneEnabled(session));
4020
+ }
4021
+
4022
+ function settingsEnabledValue(enabled) {
4023
+ return enabled ? L("common.enabled") : L("common.disabled");
4024
+ }
4025
+
4026
+ function settingsNeedsActionValue() {
4027
+ return L("settings.status.actionNeeded");
4028
+ }
4029
+
4030
+ function settingsDisconnectedValue() {
4031
+ return L("settings.status.disconnected");
4032
+ }
4033
+
4034
+ function settingsSupportedValue(supported) {
4035
+ return supported ? L("settings.status.supported") : L("settings.status.unsupported");
4036
+ }
4037
+
4038
+ function settingsInstalledValue(installed) {
4039
+ return installed ? L("settings.status.installed") : L("settings.status.notInstalled");
4040
+ }
4041
+
4042
+ function settingsNotificationRootValue(context) {
4043
+ if (context.push?.serverSubscribed === true) {
4044
+ return L("common.enabled");
4045
+ }
4046
+ if (context.push?.clientSubscribed === true) {
4047
+ return settingsNeedsActionValue();
4048
+ }
4049
+ if (context.permission === "denied") {
4050
+ return settingsNeedsActionValue();
4051
+ }
4052
+ return L("common.disabled");
4053
+ }
4054
+
4055
+ function notificationPermissionValue(permission) {
4056
+ const key = `settings.permission.${permission || "default"}`;
4057
+ const translated = L(key);
4058
+ return translated === key ? String(permission || "") : translated;
4059
+ }
4060
+
4061
+ function notificationReceiveValue(push) {
4062
+ if (push?.serverSubscribed === true) {
4063
+ return L("common.enabled");
4064
+ }
4065
+ if (push?.clientSubscribed === true) {
4066
+ return L("settings.status.actionNeeded");
4067
+ }
4068
+ return L("common.disabled");
4069
+ }
4070
+
4071
+ function notificationOverallValue(context) {
4072
+ if (context.push?.serverSubscribed === true) {
4073
+ return L("common.enabled");
4074
+ }
4075
+ if (context.permission === "denied") {
4076
+ return L("settings.status.blocked");
4077
+ }
4078
+ if (!context.serverEnabled) {
4079
+ return L("settings.status.notAvailable");
4080
+ }
4081
+ if (!context.supportsPushValue) {
4082
+ return L("settings.status.unsupported");
4083
+ }
4084
+ if (!context.secureContext || !context.standalone || context.permission !== "granted") {
4085
+ return L("settings.status.actionNeeded");
4086
+ }
4087
+ return L("common.disabled");
4088
+ }
4089
+
4090
+ function settingsMoltbookRootValue(context) {
4091
+ return context.moltbookScout?.enabled === true
4092
+ ? L("common.enabled")
4093
+ : settingsNeedsActionValue();
4094
+ }
4095
+
4096
+ function settingsA2aRelayRootValue(context) {
4097
+ const relay = context.a2aRelay;
4098
+ if (relay?.enabled === true && relay?.connected === true) {
4099
+ return L("common.enabled");
4100
+ }
4101
+ return settingsDisconnectedValue();
4102
+ }
4103
+
4104
+ function settingsA2aShareRootValue(context) {
4105
+ const share = context.a2aShare;
4106
+ if (share?.enabled === true && !share?.error) {
4107
+ return L("common.enabled");
4108
+ }
4109
+ return settingsNeedsActionValue();
4110
+ }
4111
+
4112
+ function settingsWalletRootValue(context) {
4113
+ if (context.hazbase?.enabled !== true) {
4114
+ return L("common.disabled");
4115
+ }
4116
+ return deriveHazbaseWalletFlow(context.hazbase).coreReady
4117
+ ? L("common.enabled")
4118
+ : settingsNeedsActionValue();
4119
+ }
4120
+
4121
+ function isRemotePairingSessionConnected(session) {
4122
+ const value = normalizeClientText(session?.state).toLowerCase();
4123
+ return value === "connected" || value === "open" || value === "running";
4124
+ }
4125
+
4126
+ function isRemotePairingSessionReachable(session) {
4127
+ const value = normalizeClientText(session?.state).toLowerCase();
4128
+ return value === "connected" || value === "open" || value === "running" || value === "opening" || value === "handshaking" || value === "resuming";
4129
+ }
4130
+
4131
+ function remotePairingStatusModel(status, opts = {}) {
4132
+ const enabled = status?.enabled === true;
4133
+ const sessions = Array.isArray(status?.sessions) ? status.sessions : [];
4134
+ const pairings = Array.isArray(status?.pairings) ? status.pairings : [];
4135
+ const usingRelay = opts.usingRelay === true;
4136
+ if (!enabled) {
4137
+ return { key: "disabled", tone: "muted", label: L("settings.remotePairing.status.disabled") };
4138
+ }
4139
+ if (usingRelay && sessions.some(isRemotePairingSessionConnected)) {
4140
+ return { key: "connected", tone: "success", label: L("settings.remotePairing.status.connected") };
4141
+ }
4142
+ if (sessions.some(isRemotePairingSessionReachable)) {
4143
+ return { key: "available", tone: "success", label: L("settings.remotePairing.status.available") };
4144
+ }
4145
+ if (pairings.length > 0) {
4146
+ return { key: "waiting", tone: "warning", label: L("settings.remotePairing.status.waiting") };
4147
+ }
4148
+ return { key: "waiting", tone: "muted", label: L("settings.remotePairing.status.waiting") };
4149
+ }
4150
+
4151
+ function remotePairingAuditTypeLabel(type) {
4152
+ const key = `settings.remotePairing.audit.type.${type || "unknown"}`;
4153
+ const translated = L(key);
4154
+ return translated === key ? L("settings.remotePairing.audit.type.unknown") : translated;
4155
+ }
4156
+
4157
+ function remotePairingAuditOutcomeTone(outcome) {
4158
+ if (outcome === "success") return "success";
4159
+ if (outcome === "failure") return "danger";
4160
+ return "muted";
4161
+ }
4162
+
4163
+ function remotePairingAuditMeta(event) {
4164
+ return [
4165
+ event?.label,
4166
+ event?.phoneFingerprint,
4167
+ event?.relayHost,
4168
+ event?.reason,
4169
+ ].filter(Boolean).join(" / ");
4170
+ }
4171
+
4172
+ function renderRemotePairingAudit(events) {
4173
+ const list = Array.isArray(events) ? events.slice(0, 10) : [];
4174
+ if (!list.length) {
4175
+ return `<div class="settings-copy-block"><p class="muted">${escapeHtml(L("settings.remotePairing.audit.empty"))}</p></div>`;
4176
+ }
4177
+ return `
4178
+ <div class="settings-remote-audit-list">
4179
+ ${list.map((event) => {
4180
+ const tone = remotePairingAuditOutcomeTone(event?.outcome);
4181
+ const title = remotePairingAuditTypeLabel(event?.type);
4182
+ const meta = remotePairingAuditMeta(event);
4183
+ return `
4184
+ <article class="settings-remote-audit-item">
4185
+ <span class="settings-remote-audit-dot settings-remote-audit-dot--${escapeHtml(tone)}" aria-hidden="true"></span>
4186
+ <div class="settings-remote-audit-body">
4187
+ <p class="settings-remote-audit-title">${escapeHtml(title)}</p>
4188
+ ${meta ? `<p class="settings-remote-audit-meta">${escapeHtml(meta)}</p>` : ""}
4189
+ </div>
4190
+ <time class="settings-remote-audit-time">${escapeHtml(formatSettingsTimestamp(event?.atMs))}</time>
4191
+ </article>
4192
+ `;
4193
+ }).join("")}
4194
+ </div>
4195
+ `;
4196
+ }
4197
+
3335
4198
  function settingsPageMeta(page) {
3336
4199
  switch (page) {
3337
4200
  case "notifications":
@@ -3395,14 +4258,14 @@ function settingsPageMeta(page) {
3395
4258
  id: "a2aRelay",
3396
4259
  title: L("settings.a2aRelay.title"),
3397
4260
  description: L("settings.a2aRelay.copy"),
3398
- icon: "link",
4261
+ icon: "agent-network",
3399
4262
  };
3400
4263
  case "a2aShare":
3401
4264
  return {
3402
4265
  id: "a2aShare",
3403
4266
  title: L("settings.a2aShare.title"),
3404
4267
  description: L("settings.a2aShare.copy"),
3405
- icon: "link",
4268
+ icon: "file-event",
3406
4269
  };
3407
4270
  case "wallet":
3408
4271
  return {
@@ -3411,6 +4274,13 @@ function settingsPageMeta(page) {
3411
4274
  description: L("settings.wallet.copy"),
3412
4275
  icon: "coin",
3413
4276
  };
4277
+ case "remotePairing":
4278
+ return {
4279
+ id: "remotePairing",
4280
+ title: L("settings.remotePairing.title"),
4281
+ description: L("settings.remotePairing.copy"),
4282
+ icon: "remote-connection",
4283
+ };
3414
4284
  case "a2aExecutor":
3415
4285
  // Executor settings integrated into a2aRelay page — redirect.
3416
4286
  return settingsPageMeta("a2aRelay");
@@ -3426,7 +4296,7 @@ function renderSettingsRoot(context, { mobile }) {
3426
4296
  page: "notifications",
3427
4297
  icon: "notifications",
3428
4298
  title: L("settings.notifications.title"),
3429
- value: L(context.setupState.notifications.labelKey),
4299
+ value: settingsNotificationRootValue(context),
3430
4300
  }),
3431
4301
  renderSettingsNavRow({
3432
4302
  page: "language",
@@ -3439,23 +4309,20 @@ function renderSettingsRoot(context, { mobile }) {
3439
4309
  page: "install",
3440
4310
  icon: "homescreen",
3441
4311
  title: L("settings.install.title"),
3442
- value: L(context.setupState.install.labelKey),
4312
+ value: settingsEnabledValue(context.standalone),
3443
4313
  })
3444
4314
  : "",
3445
4315
  renderSettingsNavRow({
3446
4316
  page: "awayMode",
3447
4317
  icon: "settings",
3448
4318
  title: L("settings.awayMode.title"),
3449
- value: state.session?.claudeAwayMode === true ? L("settings.claudeAway.on") : L("settings.claudeAway.off"),
4319
+ value: settingsEnabledValue(state.session?.claudeAwayMode === true),
3450
4320
  }),
3451
4321
  renderSettingsNavRow({
3452
4322
  page: "autoPilot",
3453
4323
  icon: "approval",
3454
4324
  title: L("settings.autoPilot.title"),
3455
- value:
3456
- state.session?.autoPilotTrustedReads === true || state.session?.autoPilotTrustedWrites === true
3457
- ? L("common.enabled")
3458
- : L("common.disabled"),
4325
+ value: settingsEnabledValue(hasAutoPilotEnabled()),
3459
4326
  }),
3460
4327
  ].filter(Boolean);
3461
4328
  const deviceRows = [
@@ -3467,6 +4334,12 @@ function renderSettingsRoot(context, { mobile }) {
3467
4334
  ? L("settings.device.count", { count: context.devices.length })
3468
4335
  : L("settings.pairing.connected"),
3469
4336
  }),
4337
+ state.session?.remotePairingAvailable ? renderSettingsNavRow({
4338
+ page: "remotePairing",
4339
+ icon: "remote-connection",
4340
+ title: L("settings.remotePairing.title"),
4341
+ value: settingsEnabledValue(context.remotePairing?.enabled === true),
4342
+ }) : "",
3470
4343
  ];
3471
4344
  const advancedRows = [
3472
4345
  renderSettingsNavRow({
@@ -3497,31 +4370,26 @@ function renderSettingsRoot(context, { mobile }) {
3497
4370
  page: "moltbook",
3498
4371
  icon: "item",
3499
4372
  title: L("settings.moltbook.title"),
3500
- subtitle: L("settings.moltbook.subtitle"),
3501
- value: context.moltbookScout?.enabled ? L("settings.status.enabled") : L("settings.status.disabled"),
4373
+ value: settingsMoltbookRootValue(context),
3502
4374
  }) : "",
3503
4375
  state.session?.a2aRelayEnabled ? renderSettingsNavRow({
3504
4376
  page: "a2aRelay",
3505
- icon: "link",
4377
+ icon: "agent-network",
3506
4378
  title: L("settings.a2aRelay.title"),
3507
- subtitle: L("settings.a2aRelay.subtitle"),
3508
- value: context.a2aRelay?.connected ? L("settings.status.connected") : L("settings.a2aRelay.status.disconnected"),
4379
+ value: settingsA2aRelayRootValue(context),
3509
4380
  }) : "",
3510
4381
  state.session?.a2aShareEnabled ? renderSettingsNavRow({
3511
4382
  page: "a2aShare",
3512
- icon: "link",
4383
+ icon: "file-event",
3513
4384
  title: L("settings.a2aShare.title"),
3514
- subtitle: L("settings.a2aShare.subtitle"),
3515
- value: context.a2aShare?.enabled ? L("settings.status.enabled") : L("settings.status.disabled"),
4385
+ value: settingsA2aShareRootValue(context),
3516
4386
  }) : "",
3517
4387
  context.hazbase?.enabled ? renderSettingsNavRow({
3518
4388
  page: "wallet",
3519
4389
  icon: "coin",
3520
4390
  title: L("settings.wallet.title"),
3521
- subtitle: L("settings.wallet.subtitle"),
3522
- value: context.hazbase?.sessionInvalid
3523
- ? L("settings.hazbase.status.sessionExpired")
3524
- : context.hazbase?.signedIn ? L("settings.hazbase.status.signedIn") : L("settings.hazbase.status.signedOut"),
4391
+ badge: "beta",
4392
+ value: settingsWalletRootValue(context),
3525
4393
  }) : "",
3526
4394
  ].filter(Boolean)) : ""}
3527
4395
  ${renderSettingsGroup(L("settings.pairing.title"), deviceRows)}
@@ -3583,6 +4451,9 @@ function renderSettingsSubpage(context, { mobile }) {
3583
4451
  case "wallet":
3584
4452
  content = renderSettingsWalletPage(context);
3585
4453
  break;
4454
+ case "remotePairing":
4455
+ content = renderSettingsRemotePairingPage(context);
4456
+ break;
3586
4457
  default:
3587
4458
  content = "";
3588
4459
  }
@@ -3597,11 +4468,14 @@ function renderSettingsSubpage(context, { mobile }) {
3597
4468
  }
3598
4469
 
3599
4470
  function renderSettingsNotificationsPage(context) {
3600
- const { push, permission, secureContext, standalone, supportsPushValue, serverEnabled } = context;
4471
+ const { push, permission, standalone } = context;
4472
+ const notificationEnabled = push.serverSubscribed === true;
3601
4473
  const statusRows = [
3602
- renderSettingsInfoRow(L("settings.row.status"), L(context.setupState.notifications.labelKey)),
3603
- renderSettingsInfoRow(L("settings.row.notificationPermission"), L(`settings.permission.${permission}`) || permission),
3604
- renderSettingsInfoRow(L("settings.row.currentDeviceSubscribed"), push.serverSubscribed ? L("common.enabled") : L("common.disabled")),
4474
+ renderSettingsInfoRow(L("settings.row.status"), notificationOverallValue(context)),
4475
+ renderSettingsInfoRow(L("settings.row.notificationPermission"), notificationPermissionValue(permission), {
4476
+ valueTone: permission === "granted" ? "enabled" : permission === "denied" ? "attention" : "disabled",
4477
+ }),
4478
+ renderSettingsInfoRow(L("settings.row.currentDeviceSubscribed"), notificationReceiveValue(push)),
3605
4479
  push.lastSuccessfulDeliveryAtMs
3606
4480
  ? renderSettingsInfoRow(
3607
4481
  L("settings.row.lastSuccessfulDelivery"),
@@ -3612,12 +4486,6 @@ function renderSettingsNotificationsPage(context) {
3612
4486
  return `
3613
4487
  <div class="settings-page">
3614
4488
  ${renderSettingsGroup("", statusRows)}
3615
- ${renderSettingsGroup(L("settings.group.advanced"), [
3616
- renderSettingsInfoRow(L("settings.row.serverWebPush"), serverEnabled ? L("common.enabled") : L("common.disabled")),
3617
- renderSettingsInfoRow(L("settings.row.secureContext"), secureContext ? L("common.supported") : L("common.notSupported")),
3618
- renderSettingsInfoRow(L("settings.row.homeScreenApp"), standalone ? L("common.supported") : L("common.notSupported")),
3619
- renderSettingsInfoRow(L("settings.row.browserSupport"), supportsPushValue ? L("common.supported") : L("common.notSupported")),
3620
- ])}
3621
4489
  ${state.pushNotice ? `<p class="inline-alert inline-alert--success">${escapeHtml(state.pushNotice)}</p>` : ""}
3622
4490
  ${state.pushError ? `<p class="inline-alert inline-alert--danger">${escapeHtml(state.pushError)}</p>` : ""}
3623
4491
  ${renderSettingsActionPanel(renderSettingsNotificationActions({
@@ -3710,17 +4578,13 @@ function renderSettingsAdvancedPage(context) {
3710
4578
  ${context.diagnostics.map((message) => `<p class="inline-alert">${escapeHtml(message)}</p>`).join("")}
3711
4579
  ${renderSettingsGroup("", [
3712
4580
  renderSettingsInfoRow(L("settings.row.serverWebPush"), context.serverEnabled ? L("common.enabled") : L("common.disabled")),
3713
- renderSettingsInfoRow(L("settings.row.secureContext"), context.secureContext ? L("common.supported") : L("common.notSupported")),
3714
- renderSettingsInfoRow(L("settings.row.homeScreenApp"), context.standalone ? L("common.supported") : L("common.notSupported")),
3715
- renderSettingsInfoRow(L("settings.row.notificationPermission"), L(`settings.permission.${context.permission}`) || context.permission),
3716
- renderSettingsInfoRow(L("settings.row.browserSupport"), context.supportsPushValue ? L("common.supported") : L("common.notSupported")),
3717
- renderSettingsInfoRow(L("settings.row.currentDeviceSubscribed"), context.push.serverSubscribed ? L("common.enabled") : L("common.disabled")),
3718
- context.push.lastSuccessfulDeliveryAtMs
3719
- ? renderSettingsInfoRow(
3720
- L("settings.row.lastSuccessfulDelivery"),
3721
- new Date(context.push.lastSuccessfulDeliveryAtMs).toLocaleString(state.locale)
3722
- )
3723
- : "",
4581
+ renderSettingsInfoRow(L("settings.row.secureContext"), context.secureContext ? L("common.enabled") : L("common.disabled")),
4582
+ renderSettingsInfoRow(L("settings.row.homeScreenApp"), settingsInstalledValue(context.standalone), {
4583
+ valueTone: context.standalone ? "enabled" : "disabled",
4584
+ }),
4585
+ renderSettingsInfoRow(L("settings.row.browserSupport"), settingsSupportedValue(context.supportsPushValue), {
4586
+ valueTone: context.supportsPushValue ? "enabled" : "disabled",
4587
+ }),
3724
4588
  renderSettingsInfoRow(L("settings.row.version"), state.appVersion || L("common.unavailable")),
3725
4589
  ].filter(Boolean), { listClassName: "settings-list settings-list--compact" })}
3726
4590
  ${versionNotice}
@@ -3780,15 +4644,48 @@ function renderSettingsGroup(title, rows, options = {}) {
3780
4644
  `;
3781
4645
  }
3782
4646
 
3783
- function renderSettingsNavRow({ page, icon, title, subtitle, value }) {
4647
+ function settingsNavValueTone(value, explicitTone = "") {
4648
+ if (explicitTone) {
4649
+ return explicitTone;
4650
+ }
4651
+ const text = String(value || "").trim();
4652
+ if (!text) {
4653
+ return "";
4654
+ }
4655
+ if (text === L("common.enabled") || text === L("settings.status.enabled")) {
4656
+ return "enabled";
4657
+ }
4658
+ if (text === L("common.disabled") || text === L("settings.status.disabled")) {
4659
+ return "disabled";
4660
+ }
4661
+ if (text === L("settings.status.actionNeeded") || text === L("settings.status.blocked")) {
4662
+ return "attention";
4663
+ }
4664
+ if (text === L("settings.status.supported") || text === L("settings.status.installed")) {
4665
+ return "enabled";
4666
+ }
4667
+ if (text === L("settings.status.disconnected")) {
4668
+ return "disconnected";
4669
+ }
4670
+ return "";
4671
+ }
4672
+
4673
+ function renderSettingsNavRow({ page, icon, title, badge, subtitle, value, valueTone }) {
4674
+ const tone = settingsNavValueTone(value, valueTone);
4675
+ const valueClass = tone
4676
+ ? `settings-row__value settings-row__value--${escapeHtml(tone)}`
4677
+ : "settings-row__value";
3784
4678
  return `
3785
4679
  <button class="settings-nav-row" type="button" data-settings-subpage="${escapeHtml(page)}">
3786
4680
  <span class="settings-row__icon" aria-hidden="true">${renderIcon(icon)}</span>
3787
4681
  <span class="settings-row__body">
3788
- <span class="settings-row__title">${escapeHtml(title)}</span>
4682
+ <span class="settings-row__title-line">
4683
+ <span class="settings-row__title">${escapeHtml(title)}</span>
4684
+ ${badge ? `<span class="settings-row__badge">${escapeHtml(badge)}</span>` : ""}
4685
+ </span>
3789
4686
  ${subtitle ? `<span class="settings-row__subtitle">${escapeHtml(subtitle)}</span>` : ""}
3790
4687
  </span>
3791
- <span class="settings-row__value">${escapeHtml(value || "")}</span>
4688
+ <span class="${valueClass}">${escapeHtml(value || "")}</span>
3792
4689
  <span class="settings-row__chevron" aria-hidden="true">${renderIcon("chevron-right")}</span>
3793
4690
  </button>
3794
4691
  `;
@@ -3800,16 +4697,18 @@ function renderSettingsAwayModePage() {
3800
4697
  return `
3801
4698
  <div class="settings-page">
3802
4699
  ${renderSettingsGroup("", [`
3803
- <label class="reply-mode-switch" data-claude-away-toggle>
4700
+ <label class="reply-mode-switch reply-mode-switch--settings" data-claude-away-toggle>
3804
4701
  <input type="checkbox" class="reply-mode-switch__input" ${enabled ? "checked" : ""} data-claude-away-checkbox />
3805
- <span class="reply-mode-switch__track" aria-hidden="true"><span class="reply-mode-switch__thumb"></span></span>
3806
4702
  <span class="reply-mode-switch__copy">
3807
4703
  <span class="reply-mode-switch__title">
3808
4704
  <span>${escapeHtml(L("settings.claudeAway.title"))}</span>
3809
- <span class="reply-mode-switch__state">${escapeHtml(stateLabel)}</span>
3810
4705
  </span>
3811
4706
  <span class="reply-mode-switch__hint">${escapeHtml(L("settings.claudeAway.description"))}</span>
3812
4707
  </span>
4708
+ <span class="reply-mode-switch--settings__toggle">
4709
+ <span class="reply-mode-switch__track" aria-hidden="true"><span class="reply-mode-switch__thumb"></span></span>
4710
+ <span class="reply-mode-switch__state">${escapeHtml(stateLabel)}</span>
4711
+ </span>
3813
4712
  </label>
3814
4713
  `])}
3815
4714
  <p class="settings-page-copy muted">${escapeHtml(L("settings.awayMode.codexNote"))}</p>
@@ -3823,24 +4722,25 @@ function renderSettingsAutoPilotPage() {
3823
4722
  const writeLaneContentEnabled = state.session?.autoPilotWriteLaneContent === true;
3824
4723
  const writeLaneUiTestsEnabled = state.session?.autoPilotWriteLaneUiTests === true;
3825
4724
  const writeLaneSourceEnabled = state.session?.autoPilotWriteLaneSource === true;
3826
- const trustedWritesEnabled =
3827
- writeLaneContentEnabled || writeLaneUiTestsEnabled || writeLaneSourceEnabled || state.session?.autoPilotTrustedWrites === true;
4725
+ const trustedWritesEnabled = hasAutoPilotWriteLaneEnabled();
3828
4726
  const trustedWritesStateLabel = trustedWritesEnabled ? L("common.enabled") : L("common.disabled");
3829
4727
  const recentEntries = recentAutoPilotEntries();
3830
4728
  const suggestions = recentAutoPilotSuggestions();
3831
4729
  return `
3832
4730
  <div class="settings-page">
3833
4731
  ${renderSettingsGroup("", [`
3834
- <label class="reply-mode-switch reply-mode-switch--grouped" data-auto-pilot-toggle>
4732
+ <label class="reply-mode-switch reply-mode-switch--settings reply-mode-switch--grouped" data-auto-pilot-toggle>
3835
4733
  <input type="checkbox" class="reply-mode-switch__input" ${trustedReadsEnabled ? "checked" : ""} data-auto-pilot-checkbox />
3836
- <span class="reply-mode-switch__track" aria-hidden="true"><span class="reply-mode-switch__thumb"></span></span>
3837
4734
  <span class="reply-mode-switch__copy">
3838
4735
  <span class="reply-mode-switch__title">
3839
4736
  <span>${escapeHtml(L("settings.autoPilot.trustedReadsTitle"))}</span>
3840
- <span class="reply-mode-switch__state">${escapeHtml(trustedReadsStateLabel)}</span>
3841
4737
  </span>
3842
4738
  <span class="reply-mode-switch__hint">${escapeHtml(L("settings.autoPilot.trustedReadsDescription"))}</span>
3843
4739
  </span>
4740
+ <span class="reply-mode-switch--settings__toggle">
4741
+ <span class="reply-mode-switch__track" aria-hidden="true"><span class="reply-mode-switch__thumb"></span></span>
4742
+ <span class="reply-mode-switch__state">${escapeHtml(trustedReadsStateLabel)}</span>
4743
+ </span>
3844
4744
  </label>
3845
4745
  `, `
3846
4746
  <div class="settings-toggle-subhead" role="presentation">
@@ -3850,40 +4750,46 @@ function renderSettingsAutoPilotPage() {
3850
4750
  `, `
3851
4751
  <div class="settings-toggle-subcopy muted">${escapeHtml(L("settings.autoPilot.trustedWritesDescription"))}</div>
3852
4752
  `, `
3853
- <label class="reply-mode-switch reply-mode-switch--grouped" data-auto-pilot-write-lane="content">
4753
+ <label class="reply-mode-switch reply-mode-switch--settings reply-mode-switch--grouped" data-auto-pilot-write-lane="content">
3854
4754
  <input type="checkbox" class="reply-mode-switch__input" ${writeLaneContentEnabled ? "checked" : ""} data-auto-pilot-write-lane-checkbox="content" />
3855
- <span class="reply-mode-switch__track" aria-hidden="true"><span class="reply-mode-switch__thumb"></span></span>
3856
4755
  <span class="reply-mode-switch__copy">
3857
4756
  <span class="reply-mode-switch__title">
3858
4757
  <span>${escapeHtml(L("settings.autoPilot.writeLaneContentTitle"))}</span>
3859
- <span class="reply-mode-switch__state">${escapeHtml(writeLaneContentEnabled ? L("common.enabled") : L("common.disabled"))}</span>
3860
4758
  </span>
3861
4759
  <span class="reply-mode-switch__hint">${escapeHtml(L("settings.autoPilot.writeLaneContentDescription"))}</span>
3862
4760
  </span>
4761
+ <span class="reply-mode-switch--settings__toggle">
4762
+ <span class="reply-mode-switch__track" aria-hidden="true"><span class="reply-mode-switch__thumb"></span></span>
4763
+ <span class="reply-mode-switch__state">${escapeHtml(writeLaneContentEnabled ? L("common.enabled") : L("common.disabled"))}</span>
4764
+ </span>
3863
4765
  </label>
3864
4766
  `, `
3865
- <label class="reply-mode-switch reply-mode-switch--grouped" data-auto-pilot-write-lane="ui-tests">
4767
+ <label class="reply-mode-switch reply-mode-switch--settings reply-mode-switch--grouped" data-auto-pilot-write-lane="ui-tests">
3866
4768
  <input type="checkbox" class="reply-mode-switch__input" ${writeLaneUiTestsEnabled ? "checked" : ""} data-auto-pilot-write-lane-checkbox="ui-tests" />
3867
- <span class="reply-mode-switch__track" aria-hidden="true"><span class="reply-mode-switch__thumb"></span></span>
3868
4769
  <span class="reply-mode-switch__copy">
3869
4770
  <span class="reply-mode-switch__title">
3870
4771
  <span>${escapeHtml(L("settings.autoPilot.writeLaneUiTestsTitle"))}</span>
3871
- <span class="reply-mode-switch__state">${escapeHtml(writeLaneUiTestsEnabled ? L("common.enabled") : L("common.disabled"))}</span>
3872
4772
  </span>
3873
4773
  <span class="reply-mode-switch__hint">${escapeHtml(L("settings.autoPilot.writeLaneUiTestsDescription"))}</span>
3874
4774
  </span>
4775
+ <span class="reply-mode-switch--settings__toggle">
4776
+ <span class="reply-mode-switch__track" aria-hidden="true"><span class="reply-mode-switch__thumb"></span></span>
4777
+ <span class="reply-mode-switch__state">${escapeHtml(writeLaneUiTestsEnabled ? L("common.enabled") : L("common.disabled"))}</span>
4778
+ </span>
3875
4779
  </label>
3876
4780
  `, `
3877
- <label class="reply-mode-switch reply-mode-switch--grouped" data-auto-pilot-write-lane="source">
4781
+ <label class="reply-mode-switch reply-mode-switch--settings reply-mode-switch--grouped" data-auto-pilot-write-lane="source">
3878
4782
  <input type="checkbox" class="reply-mode-switch__input" ${writeLaneSourceEnabled ? "checked" : ""} data-auto-pilot-write-lane-checkbox="source" />
3879
- <span class="reply-mode-switch__track" aria-hidden="true"><span class="reply-mode-switch__thumb"></span></span>
3880
4783
  <span class="reply-mode-switch__copy">
3881
4784
  <span class="reply-mode-switch__title">
3882
4785
  <span>${escapeHtml(L("settings.autoPilot.writeLaneSourceTitle"))}</span>
3883
- <span class="reply-mode-switch__state">${escapeHtml(writeLaneSourceEnabled ? L("common.enabled") : L("common.disabled"))}</span>
3884
4786
  </span>
3885
4787
  <span class="reply-mode-switch__hint">${escapeHtml(L("settings.autoPilot.writeLaneSourceDescription"))}</span>
3886
4788
  </span>
4789
+ <span class="reply-mode-switch--settings__toggle">
4790
+ <span class="reply-mode-switch__track" aria-hidden="true"><span class="reply-mode-switch__thumb"></span></span>
4791
+ <span class="reply-mode-switch__state">${escapeHtml(writeLaneSourceEnabled ? L("common.enabled") : L("common.disabled"))}</span>
4792
+ </span>
3887
4793
  </label>
3888
4794
  `], { listClassName: "settings-list settings-list--toggle-group" })}
3889
4795
  <p class="settings-page-copy muted">${escapeHtml(L("settings.autoPilot.scopeNote"))}</p>
@@ -4507,33 +5413,290 @@ function renderSettingsA2aSharePage(context) {
4507
5413
  ${meta ? `<span class="settings-compose-entry__meta muted">${meta}</span>` : ""}
4508
5414
  </span>
4509
5415
  </div>
4510
- `;
4511
- });
4512
- if (hasMore) {
4513
- const remaining = items.length - visibleCount;
4514
- filesList.push(
4515
- `<button type="button" class="settings-compose-more" data-a2a-share-files-more>${escapeHtml(L("settings.a2aShare.showMore", { count: remaining }))}</button>`
4516
- );
4517
- } else if (items.length > PAGE_SIZE) {
4518
- filesList.push(
4519
- `<button type="button" class="settings-compose-more" data-a2a-share-files-collapse>${escapeHtml(L("settings.a2aShare.showLess"))}</button>`
4520
- );
4521
- }
5416
+ `;
5417
+ });
5418
+ if (hasMore) {
5419
+ const remaining = items.length - visibleCount;
5420
+ filesList.push(
5421
+ `<button type="button" class="settings-compose-more" data-a2a-share-files-more>${escapeHtml(L("settings.a2aShare.showMore", { count: remaining }))}</button>`
5422
+ );
5423
+ } else if (items.length > PAGE_SIZE) {
5424
+ filesList.push(
5425
+ `<button type="button" class="settings-compose-more" data-a2a-share-files-collapse>${escapeHtml(L("settings.a2aShare.showLess"))}</button>`
5426
+ );
5427
+ }
5428
+
5429
+ return `
5430
+ <div class="settings-page">
5431
+ ${renderSettingsGroup("", [
5432
+ renderSettingsInfoRow(L("settings.row.a2aShareStatus"), statusLabel),
5433
+ renderSettingsInfoRow(L("settings.row.a2aShareEndpoint"), share.shareHost || share.shareUrl || ""),
5434
+ renderSettingsInfoRow(L("settings.row.a2aShareUserId"), share.userId || ""),
5435
+ ])}
5436
+ ${renderSettingsGroup(L("settings.a2aShare.storage.title"), storageRows)}
5437
+ ${items.length
5438
+ ? renderSettingsGroup(L("settings.a2aShare.files.title"), filesList)
5439
+ : renderSettingsGroup(L("settings.a2aShare.files.title"), [
5440
+ `<p class="settings-group__description muted">${escapeHtml(L("settings.a2aShare.files.empty"))}</p>`,
5441
+ ])}
5442
+ ${share.error ? `<p class="settings-page-copy muted">${escapeHtml(L("settings.a2aShare.error", { reason: share.error }))}</p>` : ""}
5443
+ </div>
5444
+ `;
5445
+ }
5446
+
5447
+ function renderSettingsRemotePairingPage(context) {
5448
+ // Always render — remote pairing is a feature toggle, not a credential-
5449
+ // gated integration. If the bridge hasn't responded with a status payload
5450
+ // yet, fall back to a default-disabled view so the toggle still works.
5451
+ const status = context.remotePairing || {
5452
+ enabled: false,
5453
+ relayUrl: "",
5454
+ configuredRelayUrl: "",
5455
+ identityFingerprint: null,
5456
+ sessions: [],
5457
+ pairings: [],
5458
+ auditEvents: [],
5459
+ };
5460
+
5461
+ const enabled = status.enabled === true;
5462
+ const sessions = Array.isArray(status.sessions) ? status.sessions : [];
5463
+ const pairings = Array.isArray(status.pairings) ? status.pairings : [];
5464
+ const auditEvents = Array.isArray(status.auditEvents) ? status.auditEvents : [];
5465
+ const usingRelay = isRemotePairingUsingRelay();
5466
+ const statusModel = remotePairingStatusModel(status, { usingRelay });
5467
+ const sessionsByPub = new Map(
5468
+ sessions.map((s) => [String(s.phonePub || "").toLowerCase(), s]),
5469
+ );
5470
+ const sessionsByPairingId = new Map(
5471
+ sessions.map((s) => [String(s.pairingId || ""), s]),
5472
+ );
5473
+ const togglePending = state.remotePairingPending === "toggle";
5474
+ const relayPending = state.remotePairingPending === "relayUrl";
5475
+
5476
+ // "This device" — the locally-stored enrollment record (if any). Read
5477
+ // synchronously from localStorage on each render so the badge shows up
5478
+ // immediately after a successful lan-enroll without waiting for state
5479
+ // refresh. Returns null in storage-disabled contexts (Safari private
5480
+ // mode etc.) — the page just renders without the indicator.
5481
+ const localPairing = (() => {
5482
+ try {
5483
+ return loadRemotePairingState();
5484
+ } catch {
5485
+ return null;
5486
+ }
5487
+ })();
5488
+ const localPhonePub = (localPairing?.phonePub || "").toLowerCase();
5489
+ const localRegistered = Boolean(localPairing);
5490
+
5491
+ // Identity fingerprint row — shows the bridge's stable public key short-
5492
+ // form so the human can verify what the phone is pairing against.
5493
+ const fpRow = renderSettingsInfoRow(
5494
+ L("settings.remotePairing.identity.fingerprint"),
5495
+ status.identityFingerprint || L("settings.remotePairing.identity.empty"),
5496
+ );
5497
+
5498
+ const connectionSection = `
5499
+ <section class="settings-remote-connection">
5500
+ <header class="settings-remote-connection__header">
5501
+ <div class="settings-remote-connection__copy">
5502
+ <p class="settings-remote-connection__title">${escapeHtml(L("settings.remotePairing.connection.title"))}</p>
5503
+ </div>
5504
+ <span class="settings-remote-status settings-remote-status--${escapeHtml(statusModel.tone)}">${escapeHtml(statusModel.label)}</span>
5505
+ </header>
5506
+ <label class="reply-mode-switch reply-mode-switch--settings" data-remote-pairing-toggle>
5507
+ <input type="checkbox" class="reply-mode-switch__input"
5508
+ ${enabled ? "checked" : ""}
5509
+ ${togglePending ? "disabled" : ""}
5510
+ data-remote-pairing-toggle-checkbox />
5511
+ <span class="reply-mode-switch--settings__toggle">
5512
+ <span class="reply-mode-switch__track" aria-hidden="true"><span class="reply-mode-switch__thumb"></span></span>
5513
+ <span class="reply-mode-switch__state">${escapeHtml(enabled ? L("settings.claudeAway.on") : L("settings.claudeAway.off"))}</span>
5514
+ </span>
5515
+ <span class="reply-mode-switch__hint">${escapeHtml(L("settings.remotePairing.toggle.description"))}</span>
5516
+ </label>
5517
+ <p class="settings-remote-connection__trust">${escapeHtml(L("settings.remotePairing.security.copy"))}</p>
5518
+ </section>
5519
+ `;
5520
+
5521
+ // Relay URL editor. When empty, the bridge falls back to the compiled-in
5522
+ // default — surfacing that here would require leaking it to the client,
5523
+ // so we just show "" and lean on the placeholder.
5524
+ const relayUrlValue = String(status.configuredRelayUrl || "");
5525
+ const relayUrlSection = `
5526
+ <section class="settings-group">
5527
+ <p class="settings-group__title">${escapeHtml(L("settings.remotePairing.relayUrl.title"))}</p>
5528
+ <div class="settings-input-row">
5529
+ <input type="text"
5530
+ class="settings-input"
5531
+ data-remote-pairing-relay-url-input
5532
+ value="${escapeHtml(relayUrlValue)}"
5533
+ placeholder="${escapeHtml(L("settings.remotePairing.relayUrl.placeholder"))}"
5534
+ ${relayPending ? "disabled" : ""}
5535
+ autocomplete="off"
5536
+ spellcheck="false" />
5537
+ <button type="button"
5538
+ class="secondary"
5539
+ data-remote-pairing-relay-url-save
5540
+ ${relayPending ? "disabled" : ""}>
5541
+ ${escapeHtml(L("settings.remotePairing.relayUrl.save"))}
5542
+ </button>
5543
+ </div>
5544
+ <p class="settings-group__description">${escapeHtml(L("settings.remotePairing.relayUrl.help"))}</p>
5545
+ </section>
5546
+ `;
5547
+
5548
+ // Pairings list — one card per phonePub on disk. "Live" badge if there's
5549
+ // an open session; otherwise show the last-seen timestamp. The phone
5550
+ // currently rendering this page gets an extra "This device" badge so the
5551
+ // user can identify themselves in the list (especially useful when more
5552
+ // than one phone is paired to the same Mac).
5553
+ const otherPairings = localPhonePub
5554
+ ? pairings.filter((p) => String(p.phonePub || "").toLowerCase() !== localPhonePub)
5555
+ : pairings;
5556
+ const pairingsRows = otherPairings.length === 0
5557
+ ? `<div class="settings-copy-block"><p class="muted">${escapeHtml(L("settings.remotePairing.pairings.empty"))}</p></div>`
5558
+ : `<div class="device-list">
5559
+ ${otherPairings.map((p) => {
5560
+ const session = sessionsByPub.get(String(p.phonePub || "").toLowerCase())
5561
+ || sessionsByPairingId.get(String(p.pairingId || ""));
5562
+ const live = isRemotePairingSessionConnected(session);
5563
+ const lastSeenAtMs = Number(session?.lastSeenAtMs || p.lastSeenAtMs) || 0;
5564
+ const lastSeen = lastSeenAtMs
5565
+ ? new Date(lastSeenAtMs).toLocaleString(state.locale)
5566
+ : L("settings.remotePairing.pairings.never");
5567
+ const added = p.addedAtMs
5568
+ ? new Date(p.addedAtMs).toLocaleString(state.locale)
5569
+ : L("settings.remotePairing.pairings.never");
5570
+ const pendingThis = state.remotePairingPending === `revoke:${p.phonePub}`;
5571
+ return `
5572
+ <article class="device-card" data-remote-pairing-row="${escapeHtml(p.phonePub)}">
5573
+ <div class="device-card__header">
5574
+ <div class="device-card__title-wrap">
5575
+ <div class="device-card__headline">
5576
+ <span class="device-card__icon" aria-hidden="true">${renderIcon("iphone")}</span>
5577
+ <h3 class="device-card__title">${escapeHtml(p.label || p.phoneFingerprint || p.pairingId)}</h3>
5578
+ </div>
5579
+ <p class="device-card__subtitle">${escapeHtml(p.phoneFingerprint || p.phonePub.slice(0, 16))}</p>
5580
+ </div>
5581
+ <div class="device-card__badges">
5582
+ <span class="device-card__badge ${live ? "device-card__badge--live" : "device-card__badge--offline"}">
5583
+ ${escapeHtml(live ? L("settings.remotePairing.pairings.live") : L("settings.remotePairing.pairings.offline"))}
5584
+ </span>
5585
+ </div>
5586
+ </div>
5587
+ <div class="device-card__meta">
5588
+ ${renderDeviceMetaRow(L("settings.remotePairing.pairings.added"), added)}
5589
+ ${renderDeviceMetaRow(L("settings.remotePairing.pairings.lastSeen"), lastSeen)}
5590
+ </div>
5591
+ <div class="device-card__actions">
5592
+ <button type="button"
5593
+ class="secondary secondary--wide"
5594
+ data-remote-pairing-revoke="${escapeHtml(p.phonePub)}"
5595
+ ${pendingThis ? "disabled" : ""}>
5596
+ ${escapeHtml(L("settings.remotePairing.pairings.revoke"))}
5597
+ </button>
5598
+ </div>
5599
+ </article>
5600
+ `;
5601
+ }).join("")}
5602
+ </div>`;
5603
+
5604
+ // "This device" group — sits above the pairings list and tells the user
5605
+ // explicitly whether *this phone* (the one rendering the page) is the
5606
+ // enrolled one. Two states:
5607
+ // 1. Enrolled: show the phone fingerprint + a confirmation copy.
5608
+ // 2. Not enrolled: surface a hint that re-pairing on LAN registers
5609
+ // the device. (The hint applies even when other phones are paired
5610
+ // — they're not relevant to this device's relay status.)
5611
+ const thisDeviceSection = (() => {
5612
+ if (localRegistered) {
5613
+ const fingerprint = localPairing.phoneFingerprint
5614
+ || localPairing.phonePub.slice(0, 16);
5615
+ const rotatePending = state.remotePairingPending === "rotateToken";
5616
+ return renderSettingsGroup(L("settings.remotePairing.thisDevice.title"), [
5617
+ `
5618
+ <div class="device-list">
5619
+ <article class="device-card">
5620
+ <div class="device-card__header">
5621
+ <div class="device-card__title-wrap">
5622
+ <div class="device-card__headline">
5623
+ <span class="device-card__icon" aria-hidden="true">${renderIcon("iphone")}</span>
5624
+ <h3 class="device-card__title">${escapeHtml(L("settings.remotePairing.thisDevice.registeredTitle"))}</h3>
5625
+ </div>
5626
+ <p class="device-card__subtitle">${escapeHtml(fingerprint)}</p>
5627
+ </div>
5628
+ <span class="device-card__badge">${escapeHtml(L("settings.remotePairing.pairings.thisDevice"))}</span>
5629
+ </div>
5630
+ <div class="device-card__meta">
5631
+ ${renderDeviceMetaRow(L("settings.remotePairing.status.title"), statusModel.label)}
5632
+ ${renderDeviceMetaRow(L("settings.remotePairing.thisDevice.fingerprint"), fingerprint)}
5633
+ </div>
5634
+ <div class="device-card__actions">
5635
+ <button type="button"
5636
+ class="secondary secondary--wide"
5637
+ data-remote-pairing-rotate-token
5638
+ ${rotatePending || usingRelay ? "disabled" : ""}>
5639
+ ${escapeHtml(L("settings.remotePairing.token.rotate"))}
5640
+ </button>
5641
+ </div>
5642
+ ${usingRelay ? `<p class="settings-page-copy muted settings-remote-device-hint">${escapeHtml(L("settings.remotePairing.token.lanOnly"))}</p>` : ""}
5643
+ </article>
5644
+ </div>
5645
+ `,
5646
+ ]);
5647
+ }
5648
+ return renderSettingsGroup(L("settings.remotePairing.thisDevice.title"), [
5649
+ `
5650
+ <div class="device-list">
5651
+ <article class="device-card">
5652
+ <div class="device-card__header">
5653
+ <div class="device-card__title-wrap">
5654
+ <div class="device-card__headline">
5655
+ <span class="device-card__icon" aria-hidden="true">${renderIcon("iphone")}</span>
5656
+ <h3 class="device-card__title">${escapeHtml(L("settings.remotePairing.thisDevice.notEnrolledTitle"))}</h3>
5657
+ </div>
5658
+ </div>
5659
+ </div>
5660
+ <div class="device-card__meta">
5661
+ ${renderDeviceMetaRow(L("settings.remotePairing.status.title"), L("settings.remotePairing.status.disabled"))}
5662
+ ${renderDeviceMetaRow(L("settings.remotePairing.thisDevice.fingerprint"), L("common.unavailable"))}
5663
+ </div>
5664
+ <p class="settings-page-copy muted settings-remote-device-hint">${escapeHtml(L("settings.remotePairing.thisDevice.notEnrolled"))}</p>
5665
+ </article>
5666
+ </div>
5667
+ `,
5668
+ ]);
5669
+ })();
5670
+
5671
+ const detailsSection = `
5672
+ <details class="settings-disclosure settings-remote-details" data-remote-pairing-details ${state.remotePairingDetailsOpen ? "open" : ""}>
5673
+ <summary>${escapeHtml(L("settings.remotePairing.details.title"))}</summary>
5674
+ <div class="settings-disclosure__body">
5675
+ ${relayUrlSection}
5676
+ ${renderSettingsGroup(L("settings.remotePairing.identity.title"), [fpRow])}
5677
+ </div>
5678
+ </details>
5679
+ `;
5680
+ const auditSection = renderSettingsGroup(L("settings.remotePairing.audit.title"), [
5681
+ renderRemotePairingAudit(auditEvents),
5682
+ ]);
5683
+
5684
+ const noticeBlock = state.remotePairingNotice
5685
+ ? `<p class="settings-page-copy">${escapeHtml(state.remotePairingNotice)}</p>`
5686
+ : "";
5687
+ const errorBlock = state.remotePairingError
5688
+ ? `<p class="settings-page-copy danger">${escapeHtml(state.remotePairingError)}</p>`
5689
+ : "";
4522
5690
 
4523
5691
  return `
4524
5692
  <div class="settings-page">
4525
- ${renderSettingsGroup("", [
4526
- renderSettingsInfoRow(L("settings.row.a2aShareStatus"), statusLabel),
4527
- renderSettingsInfoRow(L("settings.row.a2aShareEndpoint"), share.shareHost || share.shareUrl || ""),
4528
- renderSettingsInfoRow(L("settings.row.a2aShareUserId"), share.userId || ""),
4529
- ])}
4530
- ${renderSettingsGroup(L("settings.a2aShare.storage.title"), storageRows)}
4531
- ${items.length
4532
- ? renderSettingsGroup(L("settings.a2aShare.files.title"), filesList)
4533
- : renderSettingsGroup(L("settings.a2aShare.files.title"), [
4534
- `<p class="settings-group__description muted">${escapeHtml(L("settings.a2aShare.files.empty"))}</p>`,
4535
- ])}
4536
- ${share.error ? `<p class="settings-page-copy muted">${escapeHtml(L("settings.a2aShare.error", { reason: share.error }))}</p>` : ""}
5693
+ ${noticeBlock}
5694
+ ${errorBlock}
5695
+ ${connectionSection}
5696
+ ${thisDeviceSection}
5697
+ ${renderSettingsGroup(L("settings.remotePairing.pairings.otherTitle"), [pairingsRows])}
5698
+ ${auditSection}
5699
+ ${detailsSection}
4537
5700
  </div>
4538
5701
  `;
4539
5702
  }
@@ -4565,6 +5728,7 @@ function renderSettingsWalletPage(context) {
4565
5728
  // longer duplicated.
4566
5729
  const guideRows = [
4567
5730
  renderHazbaseWalletBanner(flow),
5731
+ renderHazbaseWalletBetaNotice(),
4568
5732
  state.hazbaseNotice
4569
5733
  ? `<div class="settings-copy-block settings-copy-block--compact wallet-flow-message wallet-flow-message--notice"><p>${escapeHtml(state.hazbaseNotice)}</p></div>`
4570
5734
  : "",
@@ -4616,15 +5780,10 @@ function renderSettingsWalletPage(context) {
4616
5780
  function renderHazbaseWalletStepList(flow) {
4617
5781
  const rendered = [];
4618
5782
  for (const step of flow.steps) {
4619
- // Step 4 (Base mainnet) is hidden while hazbase's closed beta keeps
4620
- // chainId 8453 out of `WALLET_GATEWAY_CHAINS_JSON` calling bootstrap
4621
- // against mainnet currently returns `unsupported_chain`, so there's no
4622
- // path for the user to complete the step. Step 3 (Base Sepolia) is the
4623
- // true completion boundary; the ready banner already keys off
4624
- // `coreReady = signedIn && hasPasskey && hasBaseSepolia`. Drop this
4625
- // guard (and restore the opt-in reveal) once mainnet is enabled
4626
- // server-side.
4627
- if (step.number === 4) {
5783
+ // Keep the Base mainnet roadmap visible only after the usable Base Sepolia
5784
+ // wallet is ready. Showing Step 4 during email/passkey setup makes the
5785
+ // sequential flow feel like there is another action competing for focus.
5786
+ if (step.number === 4 && step.status === "comingSoon" && !flow.coreReady) {
4628
5787
  continue;
4629
5788
  }
4630
5789
 
@@ -4764,16 +5923,10 @@ function deriveHazbaseWalletFlow(hazbase) {
4764
5923
  icon: "coin",
4765
5924
  title: L("settings.wallet.step.base.title"),
4766
5925
  copy: L("settings.wallet.step.base.copy"),
4767
- detail: baseMainnet?.smartAccountAddress || L("settings.hazbase.wallet.missing"),
5926
+ detail: baseMainnet?.smartAccountAddress || L("settings.wallet.step.base.comingSoonDetail"),
4768
5927
  monoDetail: Boolean(baseMainnet?.smartAccountAddress),
4769
- status: hasBaseMainnet ? "complete" : coreReady ? "optional" : "locked",
4770
- actions: hasBaseMainnet
4771
- ? []
4772
- : [
4773
- actionButton("settings.hazbase.action.bootstrapBase", "bootstrap-base", {
4774
- disabled: !coreReady,
4775
- }),
4776
- ],
5928
+ status: hasBaseMainnet ? "complete" : "comingSoon",
5929
+ actions: [],
4777
5930
  },
4778
5931
  ],
4779
5932
  };
@@ -4825,14 +5978,29 @@ function renderHazbaseWalletBanner(flow) {
4825
5978
  `;
4826
5979
  }
4827
5980
 
5981
+ function renderHazbaseWalletBetaNotice() {
5982
+ return `
5983
+ <div class="settings-copy-block settings-copy-block--compact wallet-beta-notice">
5984
+ <p>${escapeHtml(L("settings.wallet.betaNotice"))}</p>
5985
+ </div>
5986
+ `;
5987
+ }
5988
+
4828
5989
  function renderHazbaseWalletStepCard(step, { mode = "full" } = {}) {
4829
5990
  const statusMeta = {
4830
5991
  complete: { label: L("settings.wallet.status.complete"), icon: "completed" },
4831
5992
  current: { label: L("settings.wallet.status.current"), icon: "pending" },
4832
5993
  locked: { label: L("settings.wallet.status.locked"), icon: "lock" },
4833
5994
  optional: { label: L("settings.wallet.status.optional"), icon: "coin" },
5995
+ comingSoon: { label: L("settings.wallet.status.comingSoon"), icon: "coin" },
4834
5996
  pending: { label: L("settings.wallet.status.pending"), icon: "pending" },
4835
5997
  }[step.status] || { label: L("settings.wallet.status.pending"), icon: "pending" };
5998
+ const statusChipHtml = step.status === "current"
5999
+ ? ""
6000
+ : `<span class="wallet-step-card__status wallet-step-card__status--${escapeHtml(step.status)}">
6001
+ <span class="wallet-step-card__status-icon" aria-hidden="true">${renderIcon(statusMeta.icon)}</span>
6002
+ <span>${escapeHtml(statusMeta.label)}</span>
6003
+ </span>`;
4836
6004
 
4837
6005
  if (mode === "compact") {
4838
6006
  // Compact row for finished steps. Keeps the check icon + title + one-line
@@ -4863,10 +6031,7 @@ function renderHazbaseWalletStepCard(step, { mode = "full" } = {}) {
4863
6031
  <h3 class="wallet-step-card__title">${escapeHtml(step.title)}</h3>
4864
6032
  </div>
4865
6033
  </div>
4866
- <span class="wallet-step-card__status wallet-step-card__status--${escapeHtml(step.status)}">
4867
- <span class="wallet-step-card__status-icon" aria-hidden="true">${renderIcon(statusMeta.icon)}</span>
4868
- <span>${escapeHtml(statusMeta.label)}</span>
4869
- </span>
6034
+ ${statusChipHtml}
4870
6035
  </div>
4871
6036
  <p class="wallet-step-card__copy">${escapeHtml(step.copy)}</p>
4872
6037
  <p class="wallet-step-card__detail ${step.monoDetail ? "wallet-step-card__detail--mono" : ""}">${escapeHtml(step.detail)}</p>
@@ -4998,6 +6163,7 @@ function formatUsdcAtomic(atomic) {
4998
6163
  }
4999
6164
 
5000
6165
  function renderSettingsInfoRow(label, value, options = {}) {
6166
+ const tone = settingsNavValueTone(value, options.valueTone || "");
5001
6167
  const rowClassName = [
5002
6168
  "settings-info-row",
5003
6169
  options.stacked ? "settings-info-row--stacked" : "",
@@ -5005,6 +6171,7 @@ function renderSettingsInfoRow(label, value, options = {}) {
5005
6171
  ].filter(Boolean).join(" ");
5006
6172
  const valueClassName = [
5007
6173
  "settings-info-row__value",
6174
+ tone ? `settings-info-row__value--${tone}` : "",
5008
6175
  options.mono ? "settings-info-row__value--mono" : "",
5009
6176
  options.valueClassName || "",
5010
6177
  ].filter(Boolean).join(" ");
@@ -5051,9 +6218,17 @@ function renderTrustedDeviceCard(device) {
5051
6218
  const badge = device.currentDevice
5052
6219
  ? `<span class="device-card__badge">${escapeHtml(L("settings.device.thisDevice"))}</span>`
5053
6220
  : "";
5054
- const actionLabel = device.currentDevice
5055
- ? L("settings.action.removeThisDevice")
5056
- : L("settings.action.revokeDevice");
6221
+ const actions = device.currentDevice
6222
+ ? ""
6223
+ : `
6224
+ <div class="device-card__actions">
6225
+ <button
6226
+ class="secondary secondary--wide"
6227
+ type="button"
6228
+ data-device-revoke="${escapeHtml(device.deviceId)}"
6229
+ >${escapeHtml(L("settings.action.revokeDevice"))}</button>
6230
+ </div>
6231
+ `;
5057
6232
 
5058
6233
  return `
5059
6234
  <article class="device-card">
@@ -5074,14 +6249,7 @@ function renderTrustedDeviceCard(device) {
5074
6249
  ${renderDeviceMetaRow(L("settings.row.pushStatus"), pushLabel)}
5075
6250
  ${renderDeviceMetaRow(L("settings.row.currentLanguage"), localeLabel)}
5076
6251
  </div>
5077
- <div class="device-card__actions">
5078
- <button
5079
- class="secondary secondary--wide"
5080
- type="button"
5081
- data-device-revoke="${escapeHtml(device.deviceId)}"
5082
- data-device-current="${device.currentDevice ? "true" : "false"}"
5083
- >${escapeHtml(actionLabel)}</button>
5084
- </div>
6252
+ ${actions}
5085
6253
  </article>
5086
6254
  `;
5087
6255
  }
@@ -5154,8 +6322,9 @@ function renderStandardDetailMobile(detail) {
5154
6322
  const kindInfo = kindMeta(detail.kind, detail);
5155
6323
  const spaciousBodyDetail = TIMELINE_MESSAGE_KINDS.has(detail.kind);
5156
6324
  const plainIntro = renderDetailPlainIntro(detail, { mobile: true });
6325
+ const hasCompletionReply = isCompletionReplyAvailable(detail);
5157
6326
  return `
5158
- <div class="mobile-detail-screen">
6327
+ <div class="mobile-detail-screen ${hasCompletionReply ? "mobile-detail-screen--has-reply-dock" : ""}">
5159
6328
  <div class="detail-shell detail-shell--mobile">
5160
6329
  <div class="mobile-detail-scroll mobile-detail-scroll--detail">
5161
6330
  ${renderDetailMetaRow(detail, kindInfo, { mobile: true })}
@@ -5185,10 +6354,10 @@ function renderStandardDetailMobile(detail) {
5185
6354
  ${renderDetailDiffPanel(detail, { mobile: true })}
5186
6355
  ${renderDetailDiffThreadGroups(detail, { mobile: true })}
5187
6356
  ${renderDetailFileRefs(detail, { mobile: true })}
5188
- ${renderCompletionReplyComposer(detail, { mobile: true })}
5189
6357
  </div>
5190
6358
  ${detail.readOnly ? "" : renderActionButtons(detail.actions || [], { mobileSticky: true })}
5191
6359
  </div>
6360
+ ${renderCompletionReplyDock(detail)}
5192
6361
  </div>
5193
6362
  `;
5194
6363
  }
@@ -5936,7 +7105,7 @@ function renderThreadShareDetail(detail, options = {}) {
5936
7105
  }
5937
7106
 
5938
7107
  function renderCompletionReplyComposer(detail, options = {}) {
5939
- if ((detail.kind !== "completion" && detail.kind !== "assistant_final") || detail.reply?.enabled !== true) {
7108
+ if (!isCompletionReplyAvailable(detail)) {
5940
7109
  return "";
5941
7110
  }
5942
7111
 
@@ -6110,6 +7279,51 @@ function renderCompletionReplyComposer(detail, options = {}) {
6110
7279
  `;
6111
7280
  }
6112
7281
 
7282
+ function isCompletionReplyAvailable(detail) {
7283
+ return Boolean(
7284
+ detail &&
7285
+ (detail.kind === "completion" || detail.kind === "assistant_final") &&
7286
+ detail.reply?.enabled === true &&
7287
+ detail.token
7288
+ );
7289
+ }
7290
+
7291
+ function renderCompletionReplyDock(detail) {
7292
+ if (!isCompletionReplyAvailable(detail)) {
7293
+ return "";
7294
+ }
7295
+ const providerVars = { provider: providerDisplayName(detail?.provider) };
7296
+ return `
7297
+ <div class="reply-dock" role="region" aria-label="${escapeHtml(L("reply.eyebrow"))}">
7298
+ <button
7299
+ class="reply-dock__button"
7300
+ type="button"
7301
+ data-open-completion-reply-sheet
7302
+ data-token="${escapeHtml(detail.token)}"
7303
+ >
7304
+ <span class="reply-dock__label">${escapeHtml(L("reply.title", providerVars))}</span>
7305
+ </button>
7306
+ </div>
7307
+ `;
7308
+ }
7309
+
7310
+ function renderCompletionReplySheet(detail) {
7311
+ if (!isCompletionReplyAvailable(detail) || state.completionReplySheetToken !== detail.token) {
7312
+ return "";
7313
+ }
7314
+ const providerVars = { provider: providerDisplayName(detail?.provider) };
7315
+ return `
7316
+ <div class="reply-sheet-backdrop" data-close-completion-reply-sheet aria-hidden="true"></div>
7317
+ <section class="reply-sheet" role="dialog" aria-modal="true" aria-label="${escapeHtml(L("reply.title", providerVars))}">
7318
+ <div class="reply-sheet__handle" aria-hidden="true"></div>
7319
+ <button class="reply-sheet__close" type="button" data-close-completion-reply-sheet aria-label="${escapeHtml(L("common.close"))}">
7320
+ <span aria-hidden="true">&times;</span>
7321
+ </button>
7322
+ ${renderCompletionReplyComposer(detail, { mobile: true, sheet: true })}
7323
+ </section>
7324
+ `;
7325
+ }
7326
+
6113
7327
  function renderChoiceQuestions(detail) {
6114
7328
  const effectiveAnswers = getEffectiveChoiceDraftAnswers(detail);
6115
7329
  return detail.questions
@@ -6360,10 +7574,27 @@ function renderInstallBanner() {
6360
7574
  `;
6361
7575
  }
6362
7576
 
7577
+ function renderClientUpdateBanner() {
7578
+ return `
7579
+ <section class="install-banner install-banner--push">
7580
+ <div class="install-banner__copy">
7581
+ <strong>${escapeHtml(L("banner.clientUpdate.title"))}</strong>
7582
+ <p class="muted">${escapeHtml(L("banner.clientUpdate.copy"))}</p>
7583
+ </div>
7584
+ <div class="actions install-banner__actions">
7585
+ <button class="secondary" type="button" data-force-app-refresh>${escapeHtml(L("banner.clientUpdate.action"))}</button>
7586
+ </div>
7587
+ </section>
7588
+ `;
7589
+ }
7590
+
6363
7591
  function renderTopBanner() {
6364
7592
  if (!isDesktopLayout() && (state.detailOpen || isSettingsSubpageOpen())) {
6365
7593
  return "";
6366
7594
  }
7595
+ if (state.clientUpdateRequired) {
7596
+ return renderClientUpdateBanner();
7597
+ }
6367
7598
  if (shouldShowInstallBanner()) {
6368
7599
  return renderInstallBanner();
6369
7600
  }
@@ -6496,20 +7727,7 @@ function renderLogoutConfirmModal() {
6496
7727
  <strong id="logout-confirm-title">${escapeHtml(L("logout.confirm.title"))}</strong>
6497
7728
  <p class="muted">${escapeHtml(L("logout.confirm.copy"))}</p>
6498
7729
  </div>
6499
- <div class="logout-option">
6500
- <div class="logout-option__copy">
6501
- <strong>${escapeHtml(L("logout.confirm.keepTrustedTitle"))}</strong>
6502
- <p class="muted">${escapeHtml(L("logout.confirm.keepTrustedCopy"))}</p>
6503
- </div>
6504
- <button class="primary primary--wide" type="button" data-logout-mode="session">${escapeHtml(L("logout.action.keepTrusted"))}</button>
6505
- </div>
6506
- <div class="logout-option logout-option--danger">
6507
- <div class="logout-option__copy">
6508
- <strong>${escapeHtml(L("logout.confirm.removeTitle"))}</strong>
6509
- <p class="muted">${escapeHtml(L("logout.confirm.removeCopy"))}</p>
6510
- </div>
6511
- <button class="secondary secondary--wide" type="button" data-logout-mode="revoke">${escapeHtml(L("logout.action.removeDevice"))}</button>
6512
- </div>
7730
+ <button class="secondary secondary--wide" type="button" data-logout-mode="session">${escapeHtml(L("logout.action.keepTrusted"))}</button>
6513
7731
  <button class="ghost ghost--wide" type="button" data-close-logout-confirm>${escapeHtml(L("common.cancel"))}</button>
6514
7732
  </section>
6515
7733
  </div>
@@ -6870,24 +8088,56 @@ function bindShellInteractions() {
6870
8088
  state.pendingActionUrls.delete(actionUrl);
6871
8089
  } catch (error) {
6872
8090
  state.pendingActionUrls.delete(actionUrl);
8091
+ const approvalFinalized = Boolean(error?.payload?.approvalFinalized);
6873
8092
  if (error?.errorKey === "hazbase-session-expired") {
6874
8093
  await fetchHazbaseStatus();
6875
8094
  }
6876
- // Restore buttons on failure so the user can retry
6877
- for (const sibling of siblingButtons) {
6878
- if (originalLabels.has(sibling)) {
6879
- sibling.innerHTML = originalLabels.get(sibling);
8095
+ if (approvalFinalized) {
8096
+ await refreshAuthenticatedState();
8097
+ if (keepDetailOpen && activeItem?.kind === "approval") {
8098
+ pinActionOutcomeDetail(
8099
+ activeItem,
8100
+ buildActionOutcomeDetail({
8101
+ kind: "approval",
8102
+ title: state.currentDetail?.title,
8103
+ message: L("server.message.paymentFailed", { reason: error.message || String(error) }),
8104
+ })
8105
+ );
8106
+ }
8107
+ } else {
8108
+ // Restore buttons on recoverable failure so the user can retry the same action.
8109
+ for (const sibling of siblingButtons) {
8110
+ if (originalLabels.has(sibling)) {
8111
+ sibling.innerHTML = originalLabels.get(sibling);
8112
+ }
8113
+ sibling.disabled = false;
8114
+ sibling.removeAttribute("aria-busy");
6880
8115
  }
6881
- sibling.disabled = false;
6882
- sibling.removeAttribute("aria-busy");
8116
+ button.classList.remove("is-loading");
6883
8117
  }
6884
- button.classList.remove("is-loading");
6885
8118
  state.pushError = error.message || String(error);
6886
8119
  await renderShell();
6887
8120
  }
6888
8121
  });
6889
8122
  }
6890
8123
 
8124
+ for (const button of document.querySelectorAll("[data-open-completion-reply-sheet][data-token]")) {
8125
+ button.addEventListener("click", async () => {
8126
+ state.completionReplySheetToken = button.dataset.token || "";
8127
+ resetHorizontalViewportScroll();
8128
+ await renderShell();
8129
+ resetHorizontalViewportScroll();
8130
+ });
8131
+ }
8132
+
8133
+ for (const trigger of document.querySelectorAll("[data-close-completion-reply-sheet]")) {
8134
+ trigger.addEventListener("click", async () => {
8135
+ state.completionReplySheetToken = "";
8136
+ resetHorizontalViewportScroll();
8137
+ await renderShell();
8138
+ });
8139
+ }
8140
+
6891
8141
  for (const input of document.querySelectorAll("[data-reply-mode-toggle][data-reply-token]")) {
6892
8142
  input.addEventListener("change", async () => {
6893
8143
  const token = input.dataset.replyToken || "";
@@ -7098,14 +8348,51 @@ function bindShellInteractions() {
7098
8348
  state.session.autoPilotWriteLaneSource = result?.writeLaneSourceEnabled === true;
7099
8349
  }
7100
8350
 
8351
+ function snapshotAutoPilotSettings() {
8352
+ return {
8353
+ trustedReads: state.session?.autoPilotTrustedReads === true,
8354
+ trustedWrites: state.session?.autoPilotTrustedWrites === true,
8355
+ writeLaneContent: state.session?.autoPilotWriteLaneContent === true,
8356
+ writeLaneUiTests: state.session?.autoPilotWriteLaneUiTests === true,
8357
+ writeLaneSource: state.session?.autoPilotWriteLaneSource === true,
8358
+ };
8359
+ }
8360
+
8361
+ function restoreAutoPilotSettings(snapshot) {
8362
+ if (!state.session || !snapshot) {
8363
+ return;
8364
+ }
8365
+ state.session.autoPilotTrustedReads = snapshot.trustedReads === true;
8366
+ state.session.autoPilotTrustedWrites = snapshot.trustedWrites === true;
8367
+ state.session.autoPilotWriteLaneContent = snapshot.writeLaneContent === true;
8368
+ state.session.autoPilotWriteLaneUiTests = snapshot.writeLaneUiTests === true;
8369
+ state.session.autoPilotWriteLaneSource = snapshot.writeLaneSource === true;
8370
+ }
8371
+
8372
+ function reconcileAutoPilotTrustedWrites() {
8373
+ if (!state.session) {
8374
+ return;
8375
+ }
8376
+ state.session.autoPilotTrustedWrites = Boolean(
8377
+ state.session.autoPilotWriteLaneContent === true ||
8378
+ state.session.autoPilotWriteLaneUiTests === true ||
8379
+ state.session.autoPilotWriteLaneSource === true,
8380
+ );
8381
+ }
8382
+
7101
8383
  for (const checkbox of document.querySelectorAll("[data-auto-pilot-checkbox]")) {
7102
8384
  checkbox.addEventListener("change", async () => {
7103
8385
  const next = checkbox.checked === true;
8386
+ const previous = snapshotAutoPilotSettings();
8387
+ if (state.session) {
8388
+ state.session.autoPilotTrustedReads = next;
8389
+ }
8390
+ await renderShell();
7104
8391
  try {
7105
8392
  const result = await apiPost("/api/settings/auto-pilot", { trustedReadsEnabled: next });
7106
8393
  applyAutoPilotSettingsResult(result);
7107
- await refreshAuthenticatedState();
7108
8394
  } catch (error) {
8395
+ restoreAutoPilotSettings(previous);
7109
8396
  state.pushError = error.message || String(error);
7110
8397
  }
7111
8398
  await renderShell();
@@ -7116,17 +8403,29 @@ function bindShellInteractions() {
7116
8403
  checkbox.addEventListener("change", async () => {
7117
8404
  const next = checkbox.checked === true;
7118
8405
  const lane = normalizeClientText(checkbox.getAttribute("data-auto-pilot-write-lane-checkbox") || "");
8406
+ const previous = snapshotAutoPilotSettings();
7119
8407
  const payload =
7120
8408
  lane === "content"
7121
8409
  ? { writeLaneContentEnabled: next }
7122
8410
  : lane === "ui-tests"
7123
8411
  ? { writeLaneUiTestsEnabled: next }
7124
8412
  : { writeLaneSourceEnabled: next };
8413
+ if (state.session) {
8414
+ if (lane === "content") {
8415
+ state.session.autoPilotWriteLaneContent = next;
8416
+ } else if (lane === "ui-tests") {
8417
+ state.session.autoPilotWriteLaneUiTests = next;
8418
+ } else {
8419
+ state.session.autoPilotWriteLaneSource = next;
8420
+ }
8421
+ reconcileAutoPilotTrustedWrites();
8422
+ }
8423
+ await renderShell();
7125
8424
  try {
7126
8425
  const result = await apiPost("/api/settings/auto-pilot", payload);
7127
8426
  applyAutoPilotSettingsResult(result);
7128
- await refreshAuthenticatedState();
7129
8427
  } catch (error) {
8428
+ restoreAutoPilotSettings(previous);
7130
8429
  state.pushError = error.message || String(error);
7131
8430
  }
7132
8431
  await renderShell();
@@ -7147,11 +8446,23 @@ function bindShellInteractions() {
7147
8446
  if (!payload) {
7148
8447
  return;
7149
8448
  }
8449
+ const previous = snapshotAutoPilotSettings();
8450
+ if (state.session) {
8451
+ if (lane === "content") {
8452
+ state.session.autoPilotWriteLaneContent = true;
8453
+ } else if (lane === "ui_tests") {
8454
+ state.session.autoPilotWriteLaneUiTests = true;
8455
+ } else {
8456
+ state.session.autoPilotWriteLaneSource = true;
8457
+ }
8458
+ reconcileAutoPilotTrustedWrites();
8459
+ }
8460
+ await renderShell();
7150
8461
  try {
7151
8462
  const result = await apiPost("/api/settings/auto-pilot", payload);
7152
8463
  applyAutoPilotSettingsResult(result);
7153
- await refreshAuthenticatedState();
7154
8464
  } catch (error) {
8465
+ restoreAutoPilotSettings(previous);
7155
8466
  state.pushError = error.message || String(error);
7156
8467
  }
7157
8468
  await renderShell();
@@ -7238,6 +8549,166 @@ function bindShellInteractions() {
7238
8549
  });
7239
8550
  }
7240
8551
 
8552
+ // ─── Remote pairing (Phase 5c) ─────────────────────────────────────
8553
+ // Optimistic flips with bridge-side reconciliation. The bridge endpoints
8554
+ // hot-restart the orchestrator, so the post-POST `fetchRemotePairingStatus`
8555
+ // call can briefly observe a transient state — that's fine, the next
8556
+ // render will land on the steady state.
8557
+ for (const details of document.querySelectorAll("[data-remote-pairing-details]")) {
8558
+ details.addEventListener("toggle", () => {
8559
+ state.remotePairingDetailsOpen = details.open === true;
8560
+ });
8561
+ }
8562
+
8563
+ for (const checkbox of document.querySelectorAll("[data-remote-pairing-toggle-checkbox]")) {
8564
+ checkbox.addEventListener("change", async () => {
8565
+ const next = checkbox.checked === true;
8566
+ const previous = state.remotePairingStatus?.enabled === true;
8567
+ if (!next && isRemotePairingUsingRelay() && !window.confirm(L("settings.remotePairing.toggle.offConfirm"))) {
8568
+ checkbox.checked = previous;
8569
+ return;
8570
+ }
8571
+ state.remotePairingNotice = "";
8572
+ state.remotePairingError = "";
8573
+ state.remotePairingPending = "toggle";
8574
+ // Optimistic flip — render once with the new value disabled-during-pending,
8575
+ // then reconcile with the bridge.
8576
+ if (state.remotePairingStatus) {
8577
+ state.remotePairingStatus = { ...state.remotePairingStatus, enabled: next };
8578
+ } else {
8579
+ state.remotePairingStatus = {
8580
+ enabled: next,
8581
+ relayUrl: "",
8582
+ configuredRelayUrl: "",
8583
+ identityFingerprint: null,
8584
+ sessions: [],
8585
+ pairings: [],
8586
+ auditEvents: [],
8587
+ };
8588
+ }
8589
+ await renderShell();
8590
+ try {
8591
+ await apiPost("/api/remote-pairing/toggle", { enabled: next });
8592
+ state.remotePairingNotice = next
8593
+ ? L("settings.remotePairing.notice.toggleOn")
8594
+ : L("settings.remotePairing.notice.toggleOff");
8595
+ await fetchRemotePairingStatus();
8596
+ } catch (error) {
8597
+ // Roll back to the previous value so the checkbox visually reverts.
8598
+ if (state.remotePairingStatus) {
8599
+ state.remotePairingStatus = { ...state.remotePairingStatus, enabled: previous };
8600
+ }
8601
+ state.remotePairingError = L("settings.remotePairing.error.toggleFailed");
8602
+ } finally {
8603
+ state.remotePairingPending = "";
8604
+ await renderShell();
8605
+ }
8606
+ });
8607
+ }
8608
+
8609
+ for (const button of document.querySelectorAll("[data-remote-pairing-relay-url-save]")) {
8610
+ button.addEventListener("click", async () => {
8611
+ // The input lives on the same page; querySelector grabs the (single)
8612
+ // visible field. If we ever add multiple URL editors we'd need to
8613
+ // scope this to a container — but right now there's exactly one.
8614
+ const input = document.querySelector("[data-remote-pairing-relay-url-input]");
8615
+ const trimmed = (input?.value || "").trim();
8616
+ state.remotePairingNotice = "";
8617
+ state.remotePairingError = "";
8618
+ state.remotePairingPending = "relayUrl";
8619
+ await renderShell();
8620
+ try {
8621
+ await apiPost("/api/remote-pairing/relay-url", { relayUrl: trimmed });
8622
+ state.remotePairingNotice = L("settings.remotePairing.notice.relayUrlSaved");
8623
+ await fetchRemotePairingStatus();
8624
+ } catch (error) {
8625
+ // Distinguish the validation error from a generic save failure so
8626
+ // the user knows whether to fix the URL or retry.
8627
+ state.remotePairingError = error?.errorKey === "invalid-relay-url"
8628
+ ? L("settings.remotePairing.error.invalidRelayUrl")
8629
+ : L("settings.remotePairing.error.relayUrlFailed");
8630
+ } finally {
8631
+ state.remotePairingPending = "";
8632
+ await renderShell();
8633
+ }
8634
+ });
8635
+ }
8636
+
8637
+ for (const button of document.querySelectorAll("[data-remote-pairing-rotate-token]")) {
8638
+ button.addEventListener("click", async () => {
8639
+ const local = loadRemotePairingState();
8640
+ if (!local?.phonePub) return;
8641
+ state.remotePairingNotice = "";
8642
+ state.remotePairingError = "";
8643
+ state.remotePairingPending = "rotateToken";
8644
+ await renderShell();
8645
+ try {
8646
+ const response = await apiPost("/api/remote-pairing/rotate-token", { phonePub: local.phonePub });
8647
+ saveRemotePairingState({
8648
+ pairingId: response.pairingId,
8649
+ relayToken: response.relayToken,
8650
+ phonePub: response.phonePub,
8651
+ phoneFingerprint: response.phoneFingerprint,
8652
+ bridgePubHex: response.bridgePubHex,
8653
+ bridgeFingerprint: response.bridgeFingerprint,
8654
+ relayUrl: response.relayUrl,
8655
+ label: response.label || local.label || "",
8656
+ addedAtMs: Number.isFinite(response.addedAtMs) ? response.addedAtMs : local.addedAtMs,
8657
+ relayTokenUpdatedAtMs: Number.isFinite(response.relayTokenUpdatedAtMs)
8658
+ ? response.relayTokenUpdatedAtMs
8659
+ : Date.now(),
8660
+ });
8661
+ state.remotePairingNotice = L("settings.remotePairing.notice.tokenRotated");
8662
+ await fetchRemotePairingStatus();
8663
+ } catch (error) {
8664
+ state.remotePairingError = L("settings.remotePairing.error.tokenRotateFailed");
8665
+ } finally {
8666
+ state.remotePairingPending = "";
8667
+ await renderShell();
8668
+ }
8669
+ });
8670
+ }
8671
+
8672
+ for (const button of document.querySelectorAll("[data-remote-pairing-revoke]")) {
8673
+ button.addEventListener("click", async () => {
8674
+ const phonePub = button.dataset.remotePairingRevoke || "";
8675
+ if (!phonePub) return;
8676
+ if (!window.confirm(L("settings.remotePairing.pairings.revokeConfirm"))) return;
8677
+ state.remotePairingNotice = "";
8678
+ state.remotePairingError = "";
8679
+ // Per-pub pending key so the renderer only disables this card's
8680
+ // button, not every revoke button on the page.
8681
+ state.remotePairingPending = `revoke:${phonePub}`;
8682
+ await renderShell();
8683
+ try {
8684
+ await apiPost("/api/remote-pairing/revoke", { phonePub });
8685
+ // If the revoked phone is THIS device, drop the local enrollment
8686
+ // record too — otherwise the "this device" indicator would keep
8687
+ // claiming the (now-revoked) record is live, and a future fetch
8688
+ // routing layer would try to reuse the stale pairingId. Best
8689
+ // effort: storage failures don't change the revoke outcome.
8690
+ try {
8691
+ const local = loadRemotePairingState();
8692
+ if (
8693
+ local
8694
+ && local.phonePub.toLowerCase() === phonePub.toLowerCase()
8695
+ ) {
8696
+ clearRemotePairingState();
8697
+ }
8698
+ } catch {
8699
+ // ignore — same fall-through as savePairingState
8700
+ }
8701
+ state.remotePairingNotice = L("settings.remotePairing.notice.revoked");
8702
+ await fetchRemotePairingStatus();
8703
+ } catch (error) {
8704
+ state.remotePairingError = L("settings.remotePairing.error.revokeFailed");
8705
+ } finally {
8706
+ state.remotePairingPending = "";
8707
+ await renderShell();
8708
+ }
8709
+ });
8710
+ }
8711
+
7241
8712
 
7242
8713
  for (const button of document.querySelectorAll("[data-hazbase-action]")) {
7243
8714
  button.addEventListener("click", async () => {
@@ -7514,24 +8985,13 @@ for (const button of document.querySelectorAll("[data-wallet-address-copy]")) {
7514
8985
  try {
7515
8986
  const decisionBody = { action: submittedAction, editedText };
7516
8987
  if (editedTitle) decisionBody.editedTitle = editedTitle;
7517
- const res = await fetch(`/api/items/moltbook-draft/${encodeURIComponent(token)}/decision`, {
7518
- method: "POST",
7519
- headers: { "content-type": "application/json" },
7520
- credentials: "same-origin",
7521
- body: JSON.stringify(decisionBody),
7522
- });
7523
- if (!res.ok) {
7524
- const errBody = await res.json().catch(() => ({}));
7525
- alert(`Moltbook draft ${submittedAction} failed: ${errBody.error || res.status}`);
7526
- buttons.forEach((btn) => {
7527
- btn.disabled = false;
7528
- btn.classList.remove("is-loading", "is-dimmed");
7529
- btn.innerHTML = labelCache.get(btn);
7530
- });
7531
- if (textarea) textarea.readOnly = false;
7532
- moltbookDraftForm.dataset.submitting = "";
7533
- return;
7534
- }
8988
+ // apiPost routes through LAN-first / relay-fallback and throws on
8989
+ // !ok — the existing catch below handles both transport and HTTP
8990
+ // failures uniformly.
8991
+ await apiPost(
8992
+ `/api/items/moltbook-draft/${encodeURIComponent(token)}/decision`,
8993
+ decisionBody,
8994
+ );
7535
8995
  // Mark local detail as resolved so re-render shows "already resolved" immediately.
7536
8996
  if (state.currentDetail?.kind === "moltbook_draft") {
7537
8997
  state.currentDetail.moltbookDraftEnabled = false;
@@ -7540,7 +9000,7 @@ for (const button of document.querySelectorAll("[data-wallet-address-copy]")) {
7540
9000
  await refreshAuthenticatedState();
7541
9001
  await renderShell();
7542
9002
  } catch (error) {
7543
- alert(`Moltbook draft error: ${error.message}`);
9003
+ alert(`Moltbook draft ${submittedAction} failed: ${error.message}`);
7544
9004
  buttons.forEach((btn) => {
7545
9005
  btn.disabled = false;
7546
9006
  btn.classList.remove("is-loading", "is-dimmed");
@@ -7591,24 +9051,10 @@ for (const button of document.querySelectorAll("[data-wallet-address-copy]")) {
7591
9051
  try {
7592
9052
  const decisionBody = { action: submittedAction, instruction };
7593
9053
  if (executor) decisionBody.executor = executor;
7594
- const res = await fetch(`/api/items/a2a-task/${encodeURIComponent(token)}/decision`, {
7595
- method: "POST",
7596
- headers: { "content-type": "application/json" },
7597
- credentials: "same-origin",
7598
- body: JSON.stringify(decisionBody),
7599
- });
7600
- if (!res.ok) {
7601
- const errBody = await res.json().catch(() => ({}));
7602
- alert(`A2A task ${submittedAction} failed: ${errBody.error || res.status}`);
7603
- buttons.forEach((btn) => {
7604
- btn.disabled = false;
7605
- btn.classList.remove("is-loading", "is-dimmed");
7606
- btn.innerHTML = labelCache.get(btn);
7607
- });
7608
- if (textarea) textarea.readOnly = false;
7609
- a2aTaskForm.dataset.submitting = "";
7610
- return;
7611
- }
9054
+ await apiPost(
9055
+ `/api/items/a2a-task/${encodeURIComponent(token)}/decision`,
9056
+ decisionBody,
9057
+ );
7612
9058
  if (state.currentDetail?.kind === "a2a_task") {
7613
9059
  state.currentDetail.a2aTaskEnabled = false;
7614
9060
  state.currentDetail.readOnly = true;
@@ -7616,7 +9062,7 @@ for (const button of document.querySelectorAll("[data-wallet-address-copy]")) {
7616
9062
  await refreshAuthenticatedState();
7617
9063
  await renderShell();
7618
9064
  } catch (error) {
7619
- alert(`A2A task error: ${error.message}`);
9065
+ alert(`A2A task ${submittedAction} failed: ${error.message}`);
7620
9066
  buttons.forEach((btn) => {
7621
9067
  btn.disabled = false;
7622
9068
  btn.classList.remove("is-loading", "is-dimmed");
@@ -7657,24 +9103,10 @@ for (const button of document.querySelectorAll("[data-wallet-address-copy]")) {
7657
9103
  if (textarea) textarea.readOnly = true;
7658
9104
  const editedContent = normalizeClientText(new FormData(threadShareForm).get("shareContent"));
7659
9105
  try {
7660
- const res = await fetch(`/api/threads/share/${encodeURIComponent(token)}/decision`, {
7661
- method: "POST",
7662
- headers: { "content-type": "application/json" },
7663
- credentials: "same-origin",
7664
- body: JSON.stringify({ decision: submittedAction, editedContent }),
7665
- });
7666
- if (!res.ok) {
7667
- const errBody = await res.json().catch(() => ({}));
7668
- alert(`Thread share ${submittedAction} failed: ${errBody.error || res.status}`);
7669
- buttons.forEach((btn) => {
7670
- btn.disabled = false;
7671
- btn.classList.remove("is-loading", "is-dimmed");
7672
- btn.innerHTML = labelCache.get(btn);
7673
- });
7674
- if (textarea) textarea.readOnly = false;
7675
- threadShareForm.dataset.submitting = "";
7676
- return;
7677
- }
9106
+ await apiPost(
9107
+ `/api/threads/share/${encodeURIComponent(token)}/decision`,
9108
+ { decision: submittedAction, editedContent },
9109
+ );
7678
9110
  if (state.currentDetail?.kind === "thread_share") {
7679
9111
  state.currentDetail.threadShareEnabled = false;
7680
9112
  state.currentDetail.readOnly = true;
@@ -7682,7 +9114,7 @@ for (const button of document.querySelectorAll("[data-wallet-address-copy]")) {
7682
9114
  await refreshAuthenticatedState();
7683
9115
  await renderShell();
7684
9116
  } catch (error) {
7685
- alert(`Thread share error: ${error.message}`);
9117
+ alert(`Thread share ${submittedAction} failed: ${error.message}`);
7686
9118
  buttons.forEach((btn) => {
7687
9119
  btn.disabled = false;
7688
9120
  btn.classList.remove("is-loading", "is-dimmed");
@@ -7741,14 +9173,22 @@ for (const button of document.querySelectorAll("[data-wallet-address-copy]")) {
7741
9173
  await renderShell();
7742
9174
 
7743
9175
  try {
7744
- const requestBody = new FormData();
7745
- requestBody.set("text", text);
7746
- requestBody.set("planMode", draft.mode === "plan" ? "true" : "false");
7747
- requestBody.set("force", draft.confirmOverride === true ? "true" : "false");
7748
- for (const attachment of draft.attachments || []) {
7749
- if (attachment?.file) {
7750
- requestBody.append("image", attachment.file, attachment.name || attachment.file.name);
7751
- }
9176
+ const attachments = (draft.attachments || []).filter((attachment) => attachment?.file);
9177
+ let requestBody;
9178
+ if (attachments.length > 0) {
9179
+ requestBody = new FormData();
9180
+ requestBody.set("text", text);
9181
+ requestBody.set("planMode", draft.mode === "plan" ? "true" : "false");
9182
+ requestBody.set("force", draft.confirmOverride === true ? "true" : "false");
9183
+ } else {
9184
+ requestBody = {
9185
+ text,
9186
+ planMode: draft.mode === "plan",
9187
+ force: draft.confirmOverride === true,
9188
+ };
9189
+ }
9190
+ for (const attachment of attachments) {
9191
+ requestBody.append("image", attachment.file, attachment.name || attachment.file.name);
7752
9192
  }
7753
9193
  const replyKind = replyForm.dataset.replyKind || "completion";
7754
9194
  await apiPost(`/api/items/${encodeURIComponent(replyKind)}/${encodeURIComponent(token)}/reply`, requestBody);
@@ -7907,6 +9347,13 @@ function bindSharedUi(renderFn) {
7907
9347
  });
7908
9348
  }
7909
9349
 
9350
+ for (const button of document.querySelectorAll("[data-force-app-refresh]")) {
9351
+ button.addEventListener("click", async () => {
9352
+ await forceAppRefreshFromLan();
9353
+ await renderFn();
9354
+ });
9355
+ }
9356
+
7910
9357
  for (const button of document.querySelectorAll("[data-install-guide-close]")) {
7911
9358
  button.addEventListener("click", async (event) => {
7912
9359
  if (button.classList.contains("modal-backdrop")) {
@@ -8574,6 +10021,10 @@ function renderIcon(name) {
8574
10021
  return `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.85" stroke-linecap="round" stroke-linejoin="round"><rect x="7.2" y="2.8" width="9.6" height="18.4" rx="2.4"/><path d="M10 6.7h4"/><circle cx="12" cy="17.6" r="0.7" fill="currentColor" stroke="none"/></svg>`;
8575
10022
  case "language":
8576
10023
  return `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.85" stroke-linecap="round" stroke-linejoin="round"><path d="M12 3.5c3.8 0 7 3.8 7 8.5s-3.2 8.5-7 8.5-7-3.8-7-8.5 3.2-8.5 7-8.5Z"/><path d="M5.8 9h12.4"/><path d="M5.8 15h12.4"/><path d="M12 3.8c1.9 2 3 4.9 3 8.2s-1.1 6.2-3 8.2c-1.9-2-3-4.9-3-8.2s1.1-6.2 3-8.2Z"/></svg>`;
10024
+ case "agent-network":
10025
+ return `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.85" stroke-linecap="round" stroke-linejoin="round"><circle cx="6.8" cy="7.2" r="2.7"/><circle cx="17.2" cy="7.2" r="2.7"/><circle cx="12" cy="17" r="3"/><path d="M9.2 8.8 11 14.1"/><path d="m14.8 8.8-1.9 5.3"/><path d="M9.5 7.2h5"/></svg>`;
10026
+ case "remote-connection":
10027
+ return `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.85" stroke-linecap="round" stroke-linejoin="round"><rect x="5" y="12" width="14" height="7" rx="2"/><path d="M9 19h6"/><path d="M12 12v7"/><path d="M8.2 8.8a5.4 5.4 0 0 1 7.6 0"/><path d="M10.2 6.3a8.2 8.2 0 0 1 3.6 0"/><path d="M11.2 9.8a1.2 1.2 0 0 1 1.6 0"/></svg>`;
8577
10028
  case "link":
8578
10029
  return `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round"><path d="M10.4 13.6 8.3 15.7a3 3 0 0 1-4.2-4.2l2.8-2.8a3 3 0 0 1 4.2 0"/><path d="m13.6 10.4 2.1-2.1a3 3 0 1 1 4.2 4.2l-2.8 2.8a3 3 0 0 1-4.2 0"/><path d="m9.5 14.5 5-5"/></svg>`;
8579
10030
  case "clip":
@@ -8724,13 +10175,138 @@ function handleDocumentVisibilityChange() {
8724
10175
  handlePotentialExternalNavigation();
8725
10176
  }
8726
10177
 
8727
- async function apiGet(url) {
8728
- const response = await fetch(url, {
10178
+ async function hydrateTimelinePayloadImages(payload) {
10179
+ if (!payload || !Array.isArray(payload.entries)) {
10180
+ return payload;
10181
+ }
10182
+
10183
+ const entries = await Promise.all(
10184
+ payload.entries.map(async (entry) => hydrateItemImageUrls(entry))
10185
+ );
10186
+ return { ...payload, entries };
10187
+ }
10188
+
10189
+ async function hydrateDetailImages(detail) {
10190
+ if (!detail || !Array.isArray(detail.imageUrls)) {
10191
+ return detail;
10192
+ }
10193
+ return hydrateItemImageUrls(detail);
10194
+ }
10195
+
10196
+ async function hydrateItemImageUrls(item) {
10197
+ const imageUrls = Array.isArray(item?.imageUrls) ? item.imageUrls.filter(Boolean) : [];
10198
+ if (imageUrls.length === 0) {
10199
+ return item;
10200
+ }
10201
+
10202
+ const hydratedUrls = await Promise.all(imageUrls.map((url) => routedTimelineImageUrl(url)));
10203
+ return {
10204
+ ...item,
10205
+ imageUrls: hydratedUrls.filter(Boolean),
10206
+ };
10207
+ }
10208
+
10209
+ async function routedTimelineImageUrl(imageUrl) {
10210
+ const sourceUrl = normalizeClientText(imageUrl);
10211
+ if (!sourceUrl || /^(?:blob:|data:)/iu.test(sourceUrl)) {
10212
+ return sourceUrl;
10213
+ }
10214
+
10215
+ const existing = timelineImageObjectUrlCache.get(sourceUrl);
10216
+ if (existing?.objectUrl) {
10217
+ existing.lastUsedMs = Date.now();
10218
+ return existing.objectUrl;
10219
+ }
10220
+
10221
+ try {
10222
+ const response = await routedFetch(sourceUrl, {
10223
+ credentials: "same-origin",
10224
+ headers: {
10225
+ Accept: "image/*",
10226
+ },
10227
+ });
10228
+ if (!response.ok) {
10229
+ return unavailableTimelineImageDataUrl();
10230
+ }
10231
+ const arrayBuffer = await response.arrayBuffer();
10232
+ const contentType = responseHeader(response.headers, "content-type") || "application/octet-stream";
10233
+ const objectUrl = URL.createObjectURL(new Blob([arrayBuffer], { type: contentType }));
10234
+ timelineImageObjectUrlCache.set(sourceUrl, {
10235
+ objectUrl,
10236
+ lastUsedMs: Date.now(),
10237
+ });
10238
+ pruneTimelineImageObjectUrlCache();
10239
+ return objectUrl;
10240
+ } catch {
10241
+ return unavailableTimelineImageDataUrl();
10242
+ }
10243
+ }
10244
+
10245
+ function unavailableTimelineImageDataUrl() {
10246
+ const title = L("detail.imageUnavailable");
10247
+ const subtitle = L("detail.imageUnavailableHint");
10248
+ const svg = `
10249
+ <svg xmlns="http://www.w3.org/2000/svg" width="640" height="640" viewBox="0 0 640 640">
10250
+ <defs>
10251
+ <linearGradient id="bg" x1="0" y1="0" x2="1" y2="1">
10252
+ <stop offset="0" stop-color="#132026"/>
10253
+ <stop offset="1" stop-color="#0a1116"/>
10254
+ </linearGradient>
10255
+ </defs>
10256
+ <rect width="640" height="640" rx="34" fill="url(#bg)"/>
10257
+ <rect x="24" y="24" width="592" height="592" rx="28" fill="none" stroke="#9cb5c5" stroke-opacity=".18" stroke-width="2"/>
10258
+ <circle cx="320" cy="266" r="50" fill="#26343d"/>
10259
+ <path d="M294 266h52M320 240v52" stroke="#d7e5ed" stroke-width="12" stroke-linecap="round" opacity=".78"/>
10260
+ <text x="320" y="360" text-anchor="middle" fill="#d7e5ed" font-family="Avenir Next, Helvetica, Arial, sans-serif" font-size="31" font-weight="700">${escapeSvgText(title)}</text>
10261
+ <text x="320" y="410" text-anchor="middle" fill="#9cb5c5" font-family="Avenir Next, Helvetica, Arial, sans-serif" font-size="22">${escapeSvgText(subtitle)}</text>
10262
+ </svg>
10263
+ `.trim();
10264
+ return `data:image/svg+xml;charset=UTF-8,${encodeURIComponent(svg)}`;
10265
+ }
10266
+
10267
+ function escapeSvgText(value) {
10268
+ return normalizeClientText(value)
10269
+ .replace(/&/gu, "&amp;")
10270
+ .replace(/</gu, "&lt;")
10271
+ .replace(/>/gu, "&gt;");
10272
+ }
10273
+
10274
+ function responseHeader(headers, name) {
10275
+ const key = String(name || "").toLowerCase();
10276
+ if (!headers || !key) {
10277
+ return "";
10278
+ }
10279
+ if (typeof headers.get === "function") {
10280
+ return headers.get(key) || "";
10281
+ }
10282
+ return headers[key] || headers[name] || "";
10283
+ }
10284
+
10285
+ function pruneTimelineImageObjectUrlCache() {
10286
+ if (timelineImageObjectUrlCache.size <= MAX_TIMELINE_IMAGE_OBJECT_URLS) {
10287
+ return;
10288
+ }
10289
+ const staleEntries = [...timelineImageObjectUrlCache.entries()]
10290
+ .sort((left, right) => Number(left[1]?.lastUsedMs || 0) - Number(right[1]?.lastUsedMs || 0))
10291
+ .slice(0, Math.max(0, timelineImageObjectUrlCache.size - MAX_TIMELINE_IMAGE_OBJECT_URLS));
10292
+ for (const [sourceUrl, entry] of staleEntries) {
10293
+ if (entry?.objectUrl && typeof URL !== "undefined" && typeof URL.revokeObjectURL === "function") {
10294
+ URL.revokeObjectURL(entry.objectUrl);
10295
+ }
10296
+ timelineImageObjectUrlCache.delete(sourceUrl);
10297
+ }
10298
+ }
10299
+
10300
+ async function apiGet(url, opts = {}) {
10301
+ // routedFetch tries LAN first, then falls back to the relay tunnel when
10302
+ // the phone is off-LAN. Returns a fetch-Response-compatible object so the
10303
+ // rest of this function is identical to a plain `fetch()` call.
10304
+ const response = await routedFetch(url, {
8729
10305
  credentials: "same-origin",
8730
10306
  headers: {
8731
10307
  Accept: "application/json",
8732
10308
  },
8733
- });
10309
+ }, opts);
8734
10310
  if (!response.ok) {
8735
10311
  const errorInfo = await readError(response);
8736
10312
  const error = new Error(errorInfo.message);
@@ -8744,7 +10320,9 @@ async function apiGet(url) {
8744
10320
 
8745
10321
  async function apiPost(url, body) {
8746
10322
  const isFormDataBody = typeof FormData !== "undefined" && body instanceof FormData;
8747
- const response = await fetch(url, {
10323
+ // Keep native FormData for LAN; routedFetch serializes it to multipart
10324
+ // bytes only when the request has to travel through the remote relay.
10325
+ const response = await routedFetch(url, {
8748
10326
  method: "POST",
8749
10327
  credentials: "same-origin",
8750
10328
  headers: isFormDataBody