viveworker 0.7.0 → 0.8.1

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 (54) hide show
  1. package/.agents/plugins/marketplace.json +20 -0
  2. package/README.md +115 -4
  3. package/package.json +13 -3
  4. package/plugins/viveworker-control-plane/.codex-plugin/plugin.json +49 -0
  5. package/plugins/viveworker-control-plane/.mcp.json +11 -0
  6. package/plugins/viveworker-control-plane/assets/viveworker-logo-v2.png +0 -0
  7. package/plugins/viveworker-control-plane/assets/viveworker-logo-v2.svg +39 -0
  8. package/plugins/viveworker-control-plane/skills/viveworker-control-plane/SKILL.md +83 -0
  9. package/scripts/lib/markdown-render.mjs +128 -1
  10. package/scripts/lib/remote-pairing/README.md +164 -0
  11. package/scripts/lib/remote-pairing/_browser-bundle-entry.mjs +86 -0
  12. package/scripts/lib/remote-pairing/audit.mjs +122 -0
  13. package/scripts/lib/remote-pairing/bridge-relay-client.mjs +737 -0
  14. package/scripts/lib/remote-pairing/control.mjs +156 -0
  15. package/scripts/lib/remote-pairing/envelope.mjs +224 -0
  16. package/scripts/lib/remote-pairing/http-dispatch.mjs +530 -0
  17. package/scripts/lib/remote-pairing/keys-core.mjs +110 -0
  18. package/scripts/lib/remote-pairing/keys.mjs +181 -0
  19. package/scripts/lib/remote-pairing/noise.mjs +436 -0
  20. package/scripts/lib/remote-pairing/orchestrator.mjs +356 -0
  21. package/scripts/lib/remote-pairing/pairings.mjs +446 -0
  22. package/scripts/lib/remote-pairing/rpc.mjs +381 -0
  23. package/scripts/mcp-server.mjs +891 -0
  24. package/scripts/moltbook-scout-auto.sh +16 -8
  25. package/scripts/share-cli.mjs +14 -130
  26. package/scripts/stats-cli.mjs +683 -0
  27. package/scripts/viveworker-bridge.mjs +1676 -127
  28. package/scripts/viveworker.mjs +289 -7
  29. package/skills/viveworker-control-plane/SKILL.md +104 -0
  30. package/skills/viveworker-control-plane/agents/openai.yaml +12 -0
  31. package/templates/CLAUDE.viveworker.md +67 -0
  32. package/web/app.css +683 -9
  33. package/web/app.js +1780 -187
  34. package/web/build-id.js +1 -0
  35. package/web/i18n.js +187 -9
  36. package/web/icons/apple-touch-icon.png +0 -0
  37. package/web/icons/viveworker-icon-192.png +0 -0
  38. package/web/icons/viveworker-icon-512.png +0 -0
  39. package/web/icons/viveworker-v-pulse.svg +16 -1
  40. package/web/index.html +32 -2
  41. package/web/remote-pairing/api-router.js +873 -0
  42. package/web/remote-pairing/keys.js +237 -0
  43. package/web/remote-pairing/pairing-state.js +313 -0
  44. package/web/remote-pairing/rpc-client.js +765 -0
  45. package/web/remote-pairing/transport.js +804 -0
  46. package/web/remote-pairing/wake.js +149 -0
  47. package/web/remote-pairing-test.html +400 -0
  48. package/web/remote-pairing.bundle.js +3 -0
  49. package/web/remote-pairing.bundle.js.LEGAL.txt +15 -0
  50. package/web/remote-pairing.bundle.js.map +7 -0
  51. package/web/sw.js +190 -20
  52. package/web/icons/viveworker-beacon-v.svg +0 -19
  53. package/web/icons/viveworker-icon-1024.png +0 -0
  54. package/web/icons/viveworker-v-check.svg +0 -19
package/web/app.js CHANGED
@@ -1,4 +1,12 @@
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
+ const APP_BUILD_ID = "__VIVEWORKER_APP_BUILD_ID__";
9
+ const { getRoutingTelemetry, routedFetch } = await import(`./remote-pairing/api-router.js?v=${encodeURIComponent(APP_BUILD_ID)}`);
2
10
 
3
11
  const DESKTOP_BREAKPOINT = 980;
4
12
  const INSTALL_BANNER_DISMISS_KEY = "viveworker-install-banner-dismissed-v2";
@@ -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
  };
@@ -133,12 +178,150 @@ function hazbasePasskeyHostSupport() {
133
178
 
134
179
  const app = document.querySelector("#app");
135
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
+ }
136
314
 
137
315
  function dismissBootSplash() {
138
316
  if (bootSplashDismissed || typeof document === "undefined") {
139
317
  return;
140
318
  }
141
319
  bootSplashDismissed = true;
320
+ clearBootSplashHintTimer();
321
+ clearBootSplashDeferredStatusTimer();
322
+ if (typeof window !== "undefined") {
323
+ window.removeEventListener("viveworker:remote-routing-status", handleBootRoutingStatus);
324
+ }
142
325
  const splash = document.querySelector("#boot-splash");
143
326
  document.body?.classList.add("viveworker-ready");
144
327
  if (!splash) {
@@ -149,6 +332,147 @@ function dismissBootSplash() {
149
332
  splash.remove();
150
333
  }, 280);
151
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
+ }
152
476
  const params = new URLSearchParams(window.location.search);
153
477
  const initialItem = params.get("item") || "";
154
478
  const initialTargetTab = params.get("tab") || "";
@@ -159,10 +483,13 @@ let didReloadForServiceWorker = false;
159
483
  let lastViewportMode = isDesktopLayout();
160
484
 
161
485
  boot().catch((error) => {
162
- const message = error.message || String(error);
163
- const hint = /Load failed|Failed to fetch|NetworkError|fetch/i.test(message)
486
+ const message = bootErrorMessage(error);
487
+ const hint = shouldShowNetworkHint(error, message)
164
488
  ? `<p class="muted">${escapeHtml(L("error.networkHint"))}</p>`
165
489
  : "";
490
+ flushBootTrace("boot-error", {
491
+ error: sanitizeBootTraceValue(error?.code || error?.name || message),
492
+ });
166
493
  dismissBootSplash();
167
494
  app.innerHTML = `
168
495
  <main class="onboarding-shell">
@@ -176,13 +503,95 @@ boot().catch((error) => {
176
503
  `;
177
504
  });
178
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
+
179
579
  async function boot() {
580
+ recordBootTraceEvent("boot-start", {
581
+ url: window.location?.pathname || "/app",
582
+ });
180
583
  updateManifestHref(initialPairToken);
584
+ syncVisualViewportMetrics();
585
+ setBootSplashStatus("boot.status.checkingLan", BOOT_SPLASH_STAGE.checking);
181
586
  // SW register + update() can take hundreds of ms and does not need to gate
182
587
  // first paint. Fire and forget; the `controllerchange` reload handler wired
183
588
  // up inside `registerServiceWorker` still picks up new versions.
184
589
  registerServiceWorker().catch(() => {});
590
+ window.addEventListener("viveworker:remote-routing-status", handleBootRoutingStatus);
185
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 });
186
595
  window.addEventListener("resize", handleViewportChange, { passive: true });
187
596
  window.addEventListener("focus", handlePotentialExternalNavigation, { passive: true });
188
597
  window.addEventListener("pageshow", handlePotentialExternalNavigation, { passive: true });
@@ -191,13 +600,24 @@ async function boot() {
191
600
  // Single round-trip for session + inbox(pending/completed) + timeline +
192
601
  // devices. See `refreshBootstrap` for why we collapsed the boot fan-out.
193
602
  await refreshBootstrap();
603
+ if (bootSplashRemoteRouteSeen) {
604
+ setBootRemoteStatusAfterSwitching("boot.status.loadingData", BOOT_SPLASH_STAGE.loading);
605
+ }
606
+ flushBootTrace("bootstrap-complete");
194
607
 
195
608
  if (!state.session?.authenticated && initialPairToken && shouldAutoPairFromBootstrapToken()) {
196
609
  try {
197
- await pair({
610
+ const pairResult = await pair({
198
611
  token: initialPairToken,
199
612
  temporary: shouldUseTemporaryBootstrapPairing(),
200
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
+ }
201
621
  } catch (error) {
202
622
  state.pairError = error.message || String(error);
203
623
  }
@@ -229,6 +649,7 @@ async function boot() {
229
649
  return;
230
650
  }
231
651
 
652
+ await maybeAutoEnrollRemotePairingFromLan();
232
653
  await consumePendingNotificationIntent();
233
654
  // `?focusPending=claude` marks this tab as the Claude-hook-opened popup:
234
655
  // auto-navigate to the newest unresolved Claude pending (plan/question)
@@ -306,6 +727,7 @@ async function boot() {
306
727
  refreshInboxDiff(),
307
728
  fetchMoltbookScoutStatus(),
308
729
  fetchA2aShareStatus(),
730
+ fetchRemotePairingStatus(),
309
731
  ])
310
732
  .then(async () => {
311
733
  if (!shouldDeferRenderForActiveInteraction()) {
@@ -316,6 +738,49 @@ async function boot() {
316
738
  }, 3000);
317
739
  }
318
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
+
319
784
  async function registerServiceWorker() {
320
785
  if (!("serviceWorker" in navigator)) {
321
786
  return;
@@ -335,6 +800,47 @@ async function registerServiceWorker() {
335
800
  }
336
801
  }
337
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
+
338
844
  function handleViewportChange() {
339
845
  const nextViewportMode = isDesktopLayout();
340
846
  if (nextViewportMode === lastViewportMode) {
@@ -364,6 +870,7 @@ async function refreshAuthenticatedState() {
364
870
  await fetchMoltbookScoutStatus();
365
871
  await fetchA2aRelayStatus();
366
872
  await fetchA2aShareStatus();
873
+ await fetchRemotePairingStatus();
367
874
  if (state.currentTab === "settings") {
368
875
  await fetchHazbaseStatus();
369
876
  }
@@ -386,11 +893,13 @@ async function refreshAuthenticatedStateRemote() {
386
893
  fetchMoltbookScoutStatus(),
387
894
  fetchA2aRelayStatus(),
388
895
  fetchA2aShareStatus(),
896
+ fetchRemotePairingStatus(),
389
897
  ]);
390
898
  }
391
899
 
392
900
  async function refreshSession() {
393
901
  state.session = await apiGet("/api/session");
902
+ applyServerAppBuildId(state.session?.webAppBuildId);
394
903
  syncPairingTokenState(desiredBootstrapPairingToken());
395
904
  applyResolvedLocale();
396
905
  }
@@ -402,8 +911,14 @@ async function refreshSession() {
402
911
  // iOS PWAs where connection reuse is aggressive. Leaves the diff and
403
912
  // external-status probes as separate background phases in `boot()`.
404
913
  async function refreshBootstrap() {
405
- 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" });
406
920
  state.session = bootstrap?.session || null;
921
+ applyServerAppBuildId(bootstrap?.appBuildId || state.session?.webAppBuildId);
407
922
  syncPairingTokenState(desiredBootstrapPairingToken());
408
923
  applyResolvedLocale();
409
924
 
@@ -424,7 +939,7 @@ async function refreshBootstrap() {
424
939
  syncCompletedThreadFilter();
425
940
  syncInboxSubtab();
426
941
 
427
- state.timeline = bootstrap?.timeline || null;
942
+ state.timeline = await hydrateTimelinePayloadImages(bootstrap?.timeline || null);
428
943
  syncTimelineThreadFilter();
429
944
  syncTimelineKindFilter();
430
945
 
@@ -433,6 +948,12 @@ async function refreshBootstrap() {
433
948
  state.deviceError = "";
434
949
  }
435
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
+
436
957
  async function syncDetectedLocalePreference() {
437
958
  if (!state.session?.authenticated || !state.session?.deviceId || !state.detectedLocale) {
438
959
  return;
@@ -529,9 +1050,10 @@ function detectBrowserLocale() {
529
1050
 
530
1051
  async function refreshPushStatus() {
531
1052
  const client = await getClientPushState();
1053
+ const { clientSubscription, ...clientStatus } = client;
532
1054
  if (!state.session?.authenticated) {
533
1055
  state.pushStatus = {
534
- ...client,
1056
+ ...clientStatus,
535
1057
  enabled: false,
536
1058
  subscribed: false,
537
1059
  serverSubscribed: false,
@@ -542,16 +1064,34 @@ async function refreshPushStatus() {
542
1064
  }
543
1065
 
544
1066
  try {
545
- 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
+ }
546
1086
  state.pushStatus = {
547
1087
  ...server,
548
- ...client,
1088
+ ...clientStatus,
549
1089
  serverSubscribed: Boolean(server.subscribed),
550
- subscribed: Boolean(server.subscribed || client.clientSubscribed),
1090
+ subscribed: Boolean(server.subscribed || clientStatus.clientSubscribed),
551
1091
  };
552
1092
  } catch (error) {
553
1093
  state.pushStatus = {
554
- ...client,
1094
+ ...clientStatus,
555
1095
  enabled: false,
556
1096
  subscribed: false,
557
1097
  serverSubscribed: false,
@@ -586,6 +1126,18 @@ async function fetchA2aRelayStatus() {
586
1126
  }
587
1127
  }
588
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
+
589
1141
  async function fetchA2aShareStatus() {
590
1142
  if (!state.session?.a2aShareEnabled) {
591
1143
  state.a2aShareStatus = null;
@@ -648,6 +1200,7 @@ async function getClientPushState() {
648
1200
  "PushManager" in window &&
649
1201
  "Notification" in window,
650
1202
  clientSubscribed: Boolean(subscription),
1203
+ clientSubscription: subscription ? subscription.toJSON() : null,
651
1204
  };
652
1205
  }
653
1206
 
@@ -688,7 +1241,7 @@ async function refreshInboxDiff() {
688
1241
  }
689
1242
 
690
1243
  async function refreshTimeline() {
691
- state.timeline = await apiGet("/api/timeline");
1244
+ state.timeline = await hydrateTimelinePayloadImages(await apiGet("/api/timeline"));
692
1245
  syncTimelineThreadFilter();
693
1246
  syncTimelineKindFilter();
694
1247
  }
@@ -872,6 +1425,7 @@ function normalizeProviderClient(value) {
872
1425
  if (normalized === "moltbook") return "moltbook";
873
1426
  if (normalized === "a2a") return "a2a";
874
1427
  if (normalized === "viveworker") return "viveworker";
1428
+ if (normalized === "mcp") return "mcp";
875
1429
  return "codex";
876
1430
  }
877
1431
 
@@ -881,6 +1435,7 @@ function providerDisplayName(provider) {
881
1435
  if (p === "moltbook") return "Moltbook";
882
1436
  if (p === "a2a") return "A2A";
883
1437
  if (p === "viveworker") return L("common.appName");
1438
+ if (p === "mcp") return "MCP";
884
1439
  return L("common.codex");
885
1440
  }
886
1441
 
@@ -1176,9 +1731,16 @@ function renderPair() {
1176
1731
  event.preventDefault();
1177
1732
  const form = new FormData(event.currentTarget);
1178
1733
  try {
1179
- await pair({ code: String(form.get("code") || "") });
1734
+ const pairResult = await pair({ code: String(form.get("code") || "") });
1180
1735
  state.pairError = "";
1181
1736
  state.pairNotice = "";
1737
+ // Best-effort: register this phone's X25519 pubkey with the bridge
1738
+ // so it can reach us via the relay later. Doesn't block the shell
1739
+ // render; failures (older bridge, IndexedDB blocked, …) are swallowed
1740
+ // inside the helper.
1741
+ if (pairResult?.temporaryPairing !== true) {
1742
+ await enrollRemotePairing();
1743
+ }
1182
1744
  await refreshSession();
1183
1745
  await refreshAuthenticatedState();
1184
1746
  await renderShell();
@@ -1200,6 +1762,80 @@ async function pair(payload) {
1200
1762
  return result;
1201
1763
  }
1202
1764
 
1765
+ /**
1766
+ * Best-effort post-pair enrollment: hand the bridge our X25519 static
1767
+ * pubkey + a friendly label, persist the bridge's response in localStorage,
1768
+ * and return the saved record (or null on any failure).
1769
+ *
1770
+ * Always called after a successful LAN pair (regardless of whether the
1771
+ * remote relay is currently ON). Reasoning:
1772
+ *
1773
+ * - Enrollment is idempotent on phonePub server-side; re-pairing the same
1774
+ * phone keeps its existing pairingId and bridge identity.
1775
+ * - Doing it eagerly means flipping the relay toggle later "just works"
1776
+ * without forcing the user to repair-on-LAN to register their key.
1777
+ * - LAN-only basic auth keeps working even if this fails (e.g. older
1778
+ * bridge predating the lan-enroll endpoint, IndexedDB blocked, etc.) —
1779
+ * we explicitly swallow errors and let the caller continue rendering
1780
+ * the authenticated shell.
1781
+ *
1782
+ * Skipped when the surrounding `pair()` returned `temporaryPairing: true`
1783
+ * — temporary sessions are an opt-in shape that doesn't outlive the tab,
1784
+ * so persisting a pairing record would be misleading.
1785
+ *
1786
+ * @param {string} [label] user-visible device label; defaults to a
1787
+ * "LAN paired YYYY-MM-DD" stamp.
1788
+ * @returns {Promise<import("./remote-pairing/pairing-state.js").RemotePairingState | null>}
1789
+ */
1790
+ async function enrollRemotePairing(label) {
1791
+ try {
1792
+ const keypair = await ensureIdentityKeypair();
1793
+ const phonePubHex = bytesToHex(keypair.pub);
1794
+ const effectiveLabel = (label && String(label).trim()) || buildDefaultEnrollLabel();
1795
+ const response = await apiPost("/api/remote-pairing/lan-enroll", {
1796
+ phonePubHex,
1797
+ label: effectiveLabel,
1798
+ });
1799
+ if (!response || response.ok !== true) {
1800
+ return null;
1801
+ }
1802
+ const saved = saveRemotePairingState({
1803
+ pairingId: response.pairingId,
1804
+ relayToken: response.relayToken,
1805
+ phonePub: response.phonePub,
1806
+ phoneFingerprint: response.phoneFingerprint,
1807
+ bridgePubHex: response.bridgePubHex,
1808
+ bridgeFingerprint: response.bridgeFingerprint,
1809
+ relayUrl: response.relayUrl,
1810
+ label: response.label || "",
1811
+ addedAtMs: Number.isFinite(response.addedAtMs) ? response.addedAtMs : Date.now(),
1812
+ relayTokenUpdatedAtMs: Number.isFinite(response.relayTokenUpdatedAtMs)
1813
+ ? response.relayTokenUpdatedAtMs
1814
+ : Date.now(),
1815
+ });
1816
+ if (!saved) {
1817
+ // Storage is full / disabled; the bridge still has the pairing,
1818
+ // so future toggle-on works — just no localStorage cache for the
1819
+ // PWA's own routing decisions.
1820
+ return null;
1821
+ }
1822
+ return loadRemotePairingState();
1823
+ } catch (error) {
1824
+ console.warn("[remote-pairing] lan-enroll skipped:", error?.message || error);
1825
+ return null;
1826
+ }
1827
+ }
1828
+
1829
+ function buildDefaultEnrollLabel() {
1830
+ // YYYY-MM-DD so re-enrolls produce stable, sortable labels in the
1831
+ // settings list. (The user can rename in the settings page later.)
1832
+ const now = new Date();
1833
+ const y = now.getFullYear();
1834
+ const m = String(now.getMonth() + 1).padStart(2, "0");
1835
+ const d = String(now.getDate()).padStart(2, "0");
1836
+ return `LAN paired ${y}-${m}-${d}`;
1837
+ }
1838
+
1203
1839
  async function logout({ revokeCurrentDeviceTrust = false } = {}) {
1204
1840
  await apiPost("/api/session/logout", { revokeCurrentDeviceTrust });
1205
1841
  resetAuthenticatedState();
@@ -1227,6 +1863,7 @@ function resetAuthenticatedState() {
1227
1863
  state.choiceLocalDrafts = {};
1228
1864
  clearAllCompletionReplyDrafts();
1229
1865
  state.completionReplyDrafts = {};
1866
+ state.completionReplySheetToken = "";
1230
1867
  state.settingsSubpage = "";
1231
1868
  state.settingsScrollState = null;
1232
1869
  state.listScrollState = null;
@@ -1238,6 +1875,7 @@ function resetAuthenticatedState() {
1238
1875
  state.deviceError = "";
1239
1876
  state.logoutConfirmOpen = false;
1240
1877
  state.pairError = "";
1878
+ state.remotePairingDetailsOpen = false;
1241
1879
  }
1242
1880
 
1243
1881
  async function revokeTrustedDevice(deviceId) {
@@ -1258,7 +1896,49 @@ async function revokeTrustedDevice(deviceId) {
1258
1896
  await renderShell();
1259
1897
  }
1260
1898
 
1899
+ /**
1900
+ * `renderShell()` rebuilds the entire `#app` subtree by reassigning
1901
+ * `innerHTML`, which destroys every <pre> element in the DOM — including
1902
+ * any horizontal scroll position the user dragged into a wide code block.
1903
+ * On a polling interval, that means a long line of code keeps snapping
1904
+ * back to the start while the reader is mid-line.
1905
+ *
1906
+ * `snapshotCodeBlockScrolls()` records each `.markdown pre`'s scrollLeft
1907
+ * keyed by its trimmed textContent (so the key survives a fresh render
1908
+ * regardless of position in the DOM tree). `restoreCodeBlockScrolls()`
1909
+ * walks the new DOM and restores any scrollLeft we still have a key for.
1910
+ *
1911
+ * Content-keyed matching is intentional: if the underlying code text
1912
+ * changes mid-scroll, the new <pre> is logically different and we let it
1913
+ * start at scrollLeft=0 rather than landing the reader somewhere unrelated.
1914
+ */
1915
+ function snapshotCodeBlockScrolls() {
1916
+ if (typeof document === "undefined") return null;
1917
+ const blocks = document.querySelectorAll(".markdown pre");
1918
+ if (blocks.length === 0) return null;
1919
+ const map = new Map();
1920
+ for (const pre of blocks) {
1921
+ const key = pre.textContent ? pre.textContent.trim() : "";
1922
+ if (!key) continue;
1923
+ if (pre.scrollLeft === 0 && pre.scrollTop === 0) continue;
1924
+ map.set(key, { scrollLeft: pre.scrollLeft, scrollTop: pre.scrollTop });
1925
+ }
1926
+ return map.size > 0 ? map : null;
1927
+ }
1928
+
1929
+ function restoreCodeBlockScrolls(snapshot) {
1930
+ if (!snapshot || typeof document === "undefined") return;
1931
+ for (const pre of document.querySelectorAll(".markdown pre")) {
1932
+ const key = pre.textContent ? pre.textContent.trim() : "";
1933
+ const saved = key ? snapshot.get(key) : null;
1934
+ if (!saved) continue;
1935
+ if (saved.scrollLeft) pre.scrollLeft = saved.scrollLeft;
1936
+ if (saved.scrollTop) pre.scrollTop = saved.scrollTop;
1937
+ }
1938
+ }
1939
+
1261
1940
  async function renderShell() {
1941
+ syncVisualViewportMetrics();
1262
1942
  const desktop = isDesktopLayout();
1263
1943
  const shouldShowDetail = state.currentTab !== "settings" && state.currentItem && (desktop || state.detailOpen);
1264
1944
  let detail = null;
@@ -1277,6 +1957,8 @@ async function renderShell() {
1277
1957
  .filter(Boolean)
1278
1958
  .join(" ");
1279
1959
 
1960
+ const codeBlockScrollSnapshot = snapshotCodeBlockScrolls();
1961
+
1280
1962
  app.innerHTML = `
1281
1963
  <div class="${shellClassName}">
1282
1964
  ${desktop ? renderDesktopHeader(detail) : renderMobileTopBar(detail)}
@@ -1291,13 +1973,21 @@ async function renderShell() {
1291
1973
  ${renderLogoutConfirmModal()}
1292
1974
  ${renderHazbaseLogoutConfirmModal()}
1293
1975
  </div>
1976
+ ${!desktop && detail ? renderCompletionReplySheet(detail) : ""}
1294
1977
  `;
1295
1978
 
1296
1979
  bindShellInteractions();
1980
+ if (state.completionReplySheetToken) {
1981
+ resetHorizontalViewportScroll();
1982
+ }
1297
1983
  applyPendingDetailScrollReset();
1298
1984
  applyPendingListScrollRestore();
1299
1985
  applyPendingSettingsSubpageScrollReset();
1300
1986
  applyPendingSettingsScrollRestore();
1987
+ // Reapply any horizontal scroll the user dragged into a code block before
1988
+ // this re-render. Done after the imperative scroll resets above so they
1989
+ // can't fight each other.
1990
+ restoreCodeBlockScrolls(codeBlockScrollSnapshot);
1301
1991
  requestAnimationFrame(dismissBootSplash);
1302
1992
  }
1303
1993
 
@@ -1361,6 +2051,9 @@ function clearThreadFilterInteraction() {
1361
2051
 
1362
2052
  function shouldDeferRenderForActiveInteraction() {
1363
2053
  const activeElement = document.activeElement;
2054
+ if (state.completionReplySheetToken) {
2055
+ return true;
2056
+ }
1364
2057
  if (
1365
2058
  activeElement instanceof HTMLTextAreaElement &&
1366
2059
  activeElement.matches("[data-completion-reply-textarea]") &&
@@ -1728,7 +2421,6 @@ function renderMobileTopBar(detail) {
1728
2421
  return `
1729
2422
  <header class="mobile-topbar">
1730
2423
  <div class="mobile-topbar__heading">
1731
- <span class="eyebrow-pill eyebrow-pill--quiet">${escapeHtml(L("common.appName"))}</span>
1732
2424
  <h1 class="mobile-topbar__title">${escapeHtml(meta.title)}</h1>
1733
2425
  </div>
1734
2426
  </header>
@@ -1783,7 +2475,9 @@ async function fetchCurrentDetailForItem(itemRef = state.currentItem) {
1783
2475
  return state.detailOverride.detail;
1784
2476
  }
1785
2477
  try {
1786
- const detail = await apiGet(`/api/items/${encodeURIComponent(itemRef.kind)}/${encodeURIComponent(itemRef.token)}`);
2478
+ const detail = await hydrateDetailImages(
2479
+ await apiGet(`/api/items/${encodeURIComponent(itemRef.kind)}/${encodeURIComponent(itemRef.token)}`)
2480
+ );
1787
2481
  if (hasLaunchItemIntent(itemRef)) {
1788
2482
  state.launchItemIntent.status = "loaded";
1789
2483
  }
@@ -1797,7 +2491,9 @@ async function fetchCurrentDetailForItem(itemRef = state.currentItem) {
1797
2491
  }
1798
2492
  await refreshInbox();
1799
2493
  try {
1800
- const detail = await apiGet(`/api/items/${encodeURIComponent(itemRef.kind)}/${encodeURIComponent(itemRef.token)}`);
2494
+ const detail = await hydrateDetailImages(
2495
+ await apiGet(`/api/items/${encodeURIComponent(itemRef.kind)}/${encodeURIComponent(itemRef.token)}`)
2496
+ );
1801
2497
  if (hasLaunchItemIntent(itemRef)) {
1802
2498
  state.launchItemIntent.status = "loaded";
1803
2499
  }
@@ -3230,7 +3926,7 @@ function buildSettingsContext() {
3230
3926
  standalone,
3231
3927
  supportsPushValue,
3232
3928
  permission,
3233
- subscribed: push.serverSubscribed === true,
3929
+ subscribed: push.subscribed === true,
3234
3930
  });
3235
3931
 
3236
3932
  return {
@@ -3256,6 +3952,7 @@ function buildSettingsContext() {
3256
3952
  a2aRelay: state.a2aRelayStatus,
3257
3953
  a2aShare: state.a2aShareStatus,
3258
3954
  hazbase: state.hazbaseStatus,
3955
+ remotePairing: state.remotePairingStatus,
3259
3956
  };
3260
3957
  }
3261
3958
 
@@ -3358,6 +4055,195 @@ function collectSettingsDiagnostics({ permission, secureContext, standalone, sup
3358
4055
  return Array.from(new Set(issues.filter(Boolean)));
3359
4056
  }
3360
4057
 
4058
+ function hasAutoPilotWriteLaneEnabled(session = state.session) {
4059
+ return Boolean(
4060
+ session?.autoPilotTrustedWrites === true ||
4061
+ session?.autoPilotWriteLaneContent === true ||
4062
+ session?.autoPilotWriteLaneUiTests === true ||
4063
+ session?.autoPilotWriteLaneSource === true,
4064
+ );
4065
+ }
4066
+
4067
+ function hasAutoPilotEnabled(session = state.session) {
4068
+ return Boolean(session?.autoPilotTrustedReads === true || hasAutoPilotWriteLaneEnabled(session));
4069
+ }
4070
+
4071
+ function settingsEnabledValue(enabled) {
4072
+ return enabled ? L("common.enabled") : L("common.disabled");
4073
+ }
4074
+
4075
+ function settingsNeedsActionValue() {
4076
+ return L("settings.status.actionNeeded");
4077
+ }
4078
+
4079
+ function settingsDisconnectedValue() {
4080
+ return L("settings.status.disconnected");
4081
+ }
4082
+
4083
+ function settingsSupportedValue(supported) {
4084
+ return supported ? L("settings.status.supported") : L("settings.status.unsupported");
4085
+ }
4086
+
4087
+ function settingsInstalledValue(installed) {
4088
+ return installed ? L("settings.status.installed") : L("settings.status.notInstalled");
4089
+ }
4090
+
4091
+ function settingsNotificationRootValue(context) {
4092
+ if (context.push?.serverSubscribed === true) {
4093
+ return L("common.enabled");
4094
+ }
4095
+ if (context.push?.clientSubscribed === true) {
4096
+ return settingsNeedsActionValue();
4097
+ }
4098
+ if (context.permission === "denied") {
4099
+ return settingsNeedsActionValue();
4100
+ }
4101
+ return L("common.disabled");
4102
+ }
4103
+
4104
+ function notificationPermissionValue(permission) {
4105
+ const key = `settings.permission.${permission || "default"}`;
4106
+ const translated = L(key);
4107
+ return translated === key ? String(permission || "") : translated;
4108
+ }
4109
+
4110
+ function notificationReceiveValue(push) {
4111
+ if (push?.serverSubscribed === true) {
4112
+ return L("common.enabled");
4113
+ }
4114
+ if (push?.clientSubscribed === true) {
4115
+ return L("settings.status.actionNeeded");
4116
+ }
4117
+ return L("common.disabled");
4118
+ }
4119
+
4120
+ function notificationOverallValue(context) {
4121
+ if (context.push?.serverSubscribed === true) {
4122
+ return L("common.enabled");
4123
+ }
4124
+ if (context.permission === "denied") {
4125
+ return L("settings.status.blocked");
4126
+ }
4127
+ if (!context.serverEnabled) {
4128
+ return L("settings.status.notAvailable");
4129
+ }
4130
+ if (!context.supportsPushValue) {
4131
+ return L("settings.status.unsupported");
4132
+ }
4133
+ if (!context.secureContext || !context.standalone || context.permission !== "granted") {
4134
+ return L("settings.status.actionNeeded");
4135
+ }
4136
+ return L("common.disabled");
4137
+ }
4138
+
4139
+ function settingsMoltbookRootValue(context) {
4140
+ return context.moltbookScout?.enabled === true
4141
+ ? L("common.enabled")
4142
+ : settingsNeedsActionValue();
4143
+ }
4144
+
4145
+ function settingsA2aRelayRootValue(context) {
4146
+ const relay = context.a2aRelay;
4147
+ if (relay?.enabled === true && relay?.connected === true) {
4148
+ return L("common.enabled");
4149
+ }
4150
+ return settingsDisconnectedValue();
4151
+ }
4152
+
4153
+ function settingsA2aShareRootValue(context) {
4154
+ const share = context.a2aShare;
4155
+ if (share?.enabled === true && !share?.error) {
4156
+ return L("common.enabled");
4157
+ }
4158
+ return settingsNeedsActionValue();
4159
+ }
4160
+
4161
+ function settingsWalletRootValue(context) {
4162
+ if (context.hazbase?.enabled !== true) {
4163
+ return L("common.disabled");
4164
+ }
4165
+ return deriveHazbaseWalletFlow(context.hazbase).coreReady
4166
+ ? L("common.enabled")
4167
+ : settingsNeedsActionValue();
4168
+ }
4169
+
4170
+ function isRemotePairingSessionConnected(session) {
4171
+ const value = normalizeClientText(session?.state).toLowerCase();
4172
+ return value === "connected" || value === "open" || value === "running";
4173
+ }
4174
+
4175
+ function isRemotePairingSessionReachable(session) {
4176
+ const value = normalizeClientText(session?.state).toLowerCase();
4177
+ return value === "connected" || value === "open" || value === "running" || value === "opening" || value === "handshaking" || value === "resuming";
4178
+ }
4179
+
4180
+ function remotePairingStatusModel(status, opts = {}) {
4181
+ const enabled = status?.enabled === true;
4182
+ const sessions = Array.isArray(status?.sessions) ? status.sessions : [];
4183
+ const pairings = Array.isArray(status?.pairings) ? status.pairings : [];
4184
+ const usingRelay = opts.usingRelay === true;
4185
+ if (!enabled) {
4186
+ return { key: "disabled", tone: "muted", label: L("settings.remotePairing.status.disabled") };
4187
+ }
4188
+ if (usingRelay && sessions.some(isRemotePairingSessionConnected)) {
4189
+ return { key: "connected", tone: "success", label: L("settings.remotePairing.status.connected") };
4190
+ }
4191
+ if (sessions.some(isRemotePairingSessionReachable)) {
4192
+ return { key: "available", tone: "success", label: L("settings.remotePairing.status.available") };
4193
+ }
4194
+ if (pairings.length > 0) {
4195
+ return { key: "waiting", tone: "warning", label: L("settings.remotePairing.status.waiting") };
4196
+ }
4197
+ return { key: "waiting", tone: "muted", label: L("settings.remotePairing.status.waiting") };
4198
+ }
4199
+
4200
+ function remotePairingAuditTypeLabel(type) {
4201
+ const key = `settings.remotePairing.audit.type.${type || "unknown"}`;
4202
+ const translated = L(key);
4203
+ return translated === key ? L("settings.remotePairing.audit.type.unknown") : translated;
4204
+ }
4205
+
4206
+ function remotePairingAuditOutcomeTone(outcome) {
4207
+ if (outcome === "success") return "success";
4208
+ if (outcome === "failure") return "danger";
4209
+ return "muted";
4210
+ }
4211
+
4212
+ function remotePairingAuditMeta(event) {
4213
+ return [
4214
+ event?.label,
4215
+ event?.phoneFingerprint,
4216
+ event?.relayHost,
4217
+ event?.reason,
4218
+ ].filter(Boolean).join(" / ");
4219
+ }
4220
+
4221
+ function renderRemotePairingAudit(events) {
4222
+ const list = Array.isArray(events) ? events.slice(0, 10) : [];
4223
+ if (!list.length) {
4224
+ return `<div class="settings-copy-block"><p class="muted">${escapeHtml(L("settings.remotePairing.audit.empty"))}</p></div>`;
4225
+ }
4226
+ return `
4227
+ <div class="settings-remote-audit-list">
4228
+ ${list.map((event) => {
4229
+ const tone = remotePairingAuditOutcomeTone(event?.outcome);
4230
+ const title = remotePairingAuditTypeLabel(event?.type);
4231
+ const meta = remotePairingAuditMeta(event);
4232
+ return `
4233
+ <article class="settings-remote-audit-item">
4234
+ <span class="settings-remote-audit-dot settings-remote-audit-dot--${escapeHtml(tone)}" aria-hidden="true"></span>
4235
+ <div class="settings-remote-audit-body">
4236
+ <p class="settings-remote-audit-title">${escapeHtml(title)}</p>
4237
+ ${meta ? `<p class="settings-remote-audit-meta">${escapeHtml(meta)}</p>` : ""}
4238
+ </div>
4239
+ <time class="settings-remote-audit-time">${escapeHtml(formatSettingsTimestamp(event?.atMs))}</time>
4240
+ </article>
4241
+ `;
4242
+ }).join("")}
4243
+ </div>
4244
+ `;
4245
+ }
4246
+
3361
4247
  function settingsPageMeta(page) {
3362
4248
  switch (page) {
3363
4249
  case "notifications":
@@ -3421,14 +4307,14 @@ function settingsPageMeta(page) {
3421
4307
  id: "a2aRelay",
3422
4308
  title: L("settings.a2aRelay.title"),
3423
4309
  description: L("settings.a2aRelay.copy"),
3424
- icon: "link",
4310
+ icon: "agent-network",
3425
4311
  };
3426
4312
  case "a2aShare":
3427
4313
  return {
3428
4314
  id: "a2aShare",
3429
4315
  title: L("settings.a2aShare.title"),
3430
4316
  description: L("settings.a2aShare.copy"),
3431
- icon: "link",
4317
+ icon: "file-event",
3432
4318
  };
3433
4319
  case "wallet":
3434
4320
  return {
@@ -3437,6 +4323,13 @@ function settingsPageMeta(page) {
3437
4323
  description: L("settings.wallet.copy"),
3438
4324
  icon: "coin",
3439
4325
  };
4326
+ case "remotePairing":
4327
+ return {
4328
+ id: "remotePairing",
4329
+ title: L("settings.remotePairing.title"),
4330
+ description: L("settings.remotePairing.copy"),
4331
+ icon: "remote-connection",
4332
+ };
3440
4333
  case "a2aExecutor":
3441
4334
  // Executor settings integrated into a2aRelay page — redirect.
3442
4335
  return settingsPageMeta("a2aRelay");
@@ -3452,7 +4345,7 @@ function renderSettingsRoot(context, { mobile }) {
3452
4345
  page: "notifications",
3453
4346
  icon: "notifications",
3454
4347
  title: L("settings.notifications.title"),
3455
- value: L(context.setupState.notifications.labelKey),
4348
+ value: settingsNotificationRootValue(context),
3456
4349
  }),
3457
4350
  renderSettingsNavRow({
3458
4351
  page: "language",
@@ -3465,23 +4358,20 @@ function renderSettingsRoot(context, { mobile }) {
3465
4358
  page: "install",
3466
4359
  icon: "homescreen",
3467
4360
  title: L("settings.install.title"),
3468
- value: L(context.setupState.install.labelKey),
4361
+ value: settingsEnabledValue(context.standalone),
3469
4362
  })
3470
4363
  : "",
3471
4364
  renderSettingsNavRow({
3472
4365
  page: "awayMode",
3473
4366
  icon: "settings",
3474
4367
  title: L("settings.awayMode.title"),
3475
- value: state.session?.claudeAwayMode === true ? L("settings.claudeAway.on") : L("settings.claudeAway.off"),
4368
+ value: settingsEnabledValue(state.session?.claudeAwayMode === true),
3476
4369
  }),
3477
4370
  renderSettingsNavRow({
3478
4371
  page: "autoPilot",
3479
4372
  icon: "approval",
3480
4373
  title: L("settings.autoPilot.title"),
3481
- value:
3482
- state.session?.autoPilotTrustedReads === true || state.session?.autoPilotTrustedWrites === true
3483
- ? L("common.enabled")
3484
- : L("common.disabled"),
4374
+ value: settingsEnabledValue(hasAutoPilotEnabled()),
3485
4375
  }),
3486
4376
  ].filter(Boolean);
3487
4377
  const deviceRows = [
@@ -3493,6 +4383,12 @@ function renderSettingsRoot(context, { mobile }) {
3493
4383
  ? L("settings.device.count", { count: context.devices.length })
3494
4384
  : L("settings.pairing.connected"),
3495
4385
  }),
4386
+ state.session?.remotePairingAvailable ? renderSettingsNavRow({
4387
+ page: "remotePairing",
4388
+ icon: "remote-connection",
4389
+ title: L("settings.remotePairing.title"),
4390
+ value: settingsEnabledValue(context.remotePairing?.enabled === true),
4391
+ }) : "",
3496
4392
  ];
3497
4393
  const advancedRows = [
3498
4394
  renderSettingsNavRow({
@@ -3523,32 +4419,26 @@ function renderSettingsRoot(context, { mobile }) {
3523
4419
  page: "moltbook",
3524
4420
  icon: "item",
3525
4421
  title: L("settings.moltbook.title"),
3526
- subtitle: L("settings.moltbook.subtitle"),
3527
- value: context.moltbookScout?.enabled ? L("settings.status.enabled") : L("settings.status.disabled"),
4422
+ value: settingsMoltbookRootValue(context),
3528
4423
  }) : "",
3529
4424
  state.session?.a2aRelayEnabled ? renderSettingsNavRow({
3530
4425
  page: "a2aRelay",
3531
- icon: "link",
4426
+ icon: "agent-network",
3532
4427
  title: L("settings.a2aRelay.title"),
3533
- subtitle: L("settings.a2aRelay.subtitle"),
3534
- value: context.a2aRelay?.connected ? L("settings.status.connected") : L("settings.a2aRelay.status.disconnected"),
4428
+ value: settingsA2aRelayRootValue(context),
3535
4429
  }) : "",
3536
4430
  state.session?.a2aShareEnabled ? renderSettingsNavRow({
3537
4431
  page: "a2aShare",
3538
- icon: "link",
4432
+ icon: "file-event",
3539
4433
  title: L("settings.a2aShare.title"),
3540
- subtitle: L("settings.a2aShare.subtitle"),
3541
- value: context.a2aShare?.enabled ? L("settings.status.enabled") : L("settings.status.disabled"),
4434
+ value: settingsA2aShareRootValue(context),
3542
4435
  }) : "",
3543
4436
  context.hazbase?.enabled ? renderSettingsNavRow({
3544
4437
  page: "wallet",
3545
4438
  icon: "coin",
3546
4439
  title: L("settings.wallet.title"),
3547
4440
  badge: "beta",
3548
- subtitle: L("settings.wallet.subtitle"),
3549
- value: context.hazbase?.sessionInvalid
3550
- ? L("settings.hazbase.status.sessionExpired")
3551
- : context.hazbase?.signedIn ? L("settings.hazbase.status.signedIn") : L("settings.hazbase.status.signedOut"),
4441
+ value: settingsWalletRootValue(context),
3552
4442
  }) : "",
3553
4443
  ].filter(Boolean)) : ""}
3554
4444
  ${renderSettingsGroup(L("settings.pairing.title"), deviceRows)}
@@ -3610,6 +4500,9 @@ function renderSettingsSubpage(context, { mobile }) {
3610
4500
  case "wallet":
3611
4501
  content = renderSettingsWalletPage(context);
3612
4502
  break;
4503
+ case "remotePairing":
4504
+ content = renderSettingsRemotePairingPage(context);
4505
+ break;
3613
4506
  default:
3614
4507
  content = "";
3615
4508
  }
@@ -3624,11 +4517,14 @@ function renderSettingsSubpage(context, { mobile }) {
3624
4517
  }
3625
4518
 
3626
4519
  function renderSettingsNotificationsPage(context) {
3627
- const { push, permission, secureContext, standalone, supportsPushValue, serverEnabled } = context;
4520
+ const { push, permission, standalone } = context;
4521
+ const notificationEnabled = push.serverSubscribed === true;
3628
4522
  const statusRows = [
3629
- renderSettingsInfoRow(L("settings.row.status"), L(context.setupState.notifications.labelKey)),
3630
- renderSettingsInfoRow(L("settings.row.notificationPermission"), L(`settings.permission.${permission}`) || permission),
3631
- renderSettingsInfoRow(L("settings.row.currentDeviceSubscribed"), push.serverSubscribed ? L("common.enabled") : L("common.disabled")),
4523
+ renderSettingsInfoRow(L("settings.row.status"), notificationOverallValue(context)),
4524
+ renderSettingsInfoRow(L("settings.row.notificationPermission"), notificationPermissionValue(permission), {
4525
+ valueTone: permission === "granted" ? "enabled" : permission === "denied" ? "attention" : "disabled",
4526
+ }),
4527
+ renderSettingsInfoRow(L("settings.row.currentDeviceSubscribed"), notificationReceiveValue(push)),
3632
4528
  push.lastSuccessfulDeliveryAtMs
3633
4529
  ? renderSettingsInfoRow(
3634
4530
  L("settings.row.lastSuccessfulDelivery"),
@@ -3639,12 +4535,6 @@ function renderSettingsNotificationsPage(context) {
3639
4535
  return `
3640
4536
  <div class="settings-page">
3641
4537
  ${renderSettingsGroup("", statusRows)}
3642
- ${renderSettingsGroup(L("settings.group.advanced"), [
3643
- renderSettingsInfoRow(L("settings.row.serverWebPush"), serverEnabled ? L("common.enabled") : L("common.disabled")),
3644
- renderSettingsInfoRow(L("settings.row.secureContext"), secureContext ? L("common.supported") : L("common.notSupported")),
3645
- renderSettingsInfoRow(L("settings.row.homeScreenApp"), standalone ? L("common.supported") : L("common.notSupported")),
3646
- renderSettingsInfoRow(L("settings.row.browserSupport"), supportsPushValue ? L("common.supported") : L("common.notSupported")),
3647
- ])}
3648
4538
  ${state.pushNotice ? `<p class="inline-alert inline-alert--success">${escapeHtml(state.pushNotice)}</p>` : ""}
3649
4539
  ${state.pushError ? `<p class="inline-alert inline-alert--danger">${escapeHtml(state.pushError)}</p>` : ""}
3650
4540
  ${renderSettingsActionPanel(renderSettingsNotificationActions({
@@ -3737,17 +4627,13 @@ function renderSettingsAdvancedPage(context) {
3737
4627
  ${context.diagnostics.map((message) => `<p class="inline-alert">${escapeHtml(message)}</p>`).join("")}
3738
4628
  ${renderSettingsGroup("", [
3739
4629
  renderSettingsInfoRow(L("settings.row.serverWebPush"), context.serverEnabled ? L("common.enabled") : L("common.disabled")),
3740
- renderSettingsInfoRow(L("settings.row.secureContext"), context.secureContext ? L("common.supported") : L("common.notSupported")),
3741
- renderSettingsInfoRow(L("settings.row.homeScreenApp"), context.standalone ? L("common.supported") : L("common.notSupported")),
3742
- renderSettingsInfoRow(L("settings.row.notificationPermission"), L(`settings.permission.${context.permission}`) || context.permission),
3743
- renderSettingsInfoRow(L("settings.row.browserSupport"), context.supportsPushValue ? L("common.supported") : L("common.notSupported")),
3744
- renderSettingsInfoRow(L("settings.row.currentDeviceSubscribed"), context.push.serverSubscribed ? L("common.enabled") : L("common.disabled")),
3745
- context.push.lastSuccessfulDeliveryAtMs
3746
- ? renderSettingsInfoRow(
3747
- L("settings.row.lastSuccessfulDelivery"),
3748
- new Date(context.push.lastSuccessfulDeliveryAtMs).toLocaleString(state.locale)
3749
- )
3750
- : "",
4630
+ renderSettingsInfoRow(L("settings.row.secureContext"), context.secureContext ? L("common.enabled") : L("common.disabled")),
4631
+ renderSettingsInfoRow(L("settings.row.homeScreenApp"), settingsInstalledValue(context.standalone), {
4632
+ valueTone: context.standalone ? "enabled" : "disabled",
4633
+ }),
4634
+ renderSettingsInfoRow(L("settings.row.browserSupport"), settingsSupportedValue(context.supportsPushValue), {
4635
+ valueTone: context.supportsPushValue ? "enabled" : "disabled",
4636
+ }),
3751
4637
  renderSettingsInfoRow(L("settings.row.version"), state.appVersion || L("common.unavailable")),
3752
4638
  ].filter(Boolean), { listClassName: "settings-list settings-list--compact" })}
3753
4639
  ${versionNotice}
@@ -3807,7 +4693,37 @@ function renderSettingsGroup(title, rows, options = {}) {
3807
4693
  `;
3808
4694
  }
3809
4695
 
3810
- function renderSettingsNavRow({ page, icon, title, badge, subtitle, value }) {
4696
+ function settingsNavValueTone(value, explicitTone = "") {
4697
+ if (explicitTone) {
4698
+ return explicitTone;
4699
+ }
4700
+ const text = String(value || "").trim();
4701
+ if (!text) {
4702
+ return "";
4703
+ }
4704
+ if (text === L("common.enabled") || text === L("settings.status.enabled")) {
4705
+ return "enabled";
4706
+ }
4707
+ if (text === L("common.disabled") || text === L("settings.status.disabled")) {
4708
+ return "disabled";
4709
+ }
4710
+ if (text === L("settings.status.actionNeeded") || text === L("settings.status.blocked")) {
4711
+ return "attention";
4712
+ }
4713
+ if (text === L("settings.status.supported") || text === L("settings.status.installed")) {
4714
+ return "enabled";
4715
+ }
4716
+ if (text === L("settings.status.disconnected")) {
4717
+ return "disconnected";
4718
+ }
4719
+ return "";
4720
+ }
4721
+
4722
+ function renderSettingsNavRow({ page, icon, title, badge, subtitle, value, valueTone }) {
4723
+ const tone = settingsNavValueTone(value, valueTone);
4724
+ const valueClass = tone
4725
+ ? `settings-row__value settings-row__value--${escapeHtml(tone)}`
4726
+ : "settings-row__value";
3811
4727
  return `
3812
4728
  <button class="settings-nav-row" type="button" data-settings-subpage="${escapeHtml(page)}">
3813
4729
  <span class="settings-row__icon" aria-hidden="true">${renderIcon(icon)}</span>
@@ -3818,7 +4734,7 @@ function renderSettingsNavRow({ page, icon, title, badge, subtitle, value }) {
3818
4734
  </span>
3819
4735
  ${subtitle ? `<span class="settings-row__subtitle">${escapeHtml(subtitle)}</span>` : ""}
3820
4736
  </span>
3821
- <span class="settings-row__value">${escapeHtml(value || "")}</span>
4737
+ <span class="${valueClass}">${escapeHtml(value || "")}</span>
3822
4738
  <span class="settings-row__chevron" aria-hidden="true">${renderIcon("chevron-right")}</span>
3823
4739
  </button>
3824
4740
  `;
@@ -3830,16 +4746,18 @@ function renderSettingsAwayModePage() {
3830
4746
  return `
3831
4747
  <div class="settings-page">
3832
4748
  ${renderSettingsGroup("", [`
3833
- <label class="reply-mode-switch" data-claude-away-toggle>
4749
+ <label class="reply-mode-switch reply-mode-switch--settings" data-claude-away-toggle>
3834
4750
  <input type="checkbox" class="reply-mode-switch__input" ${enabled ? "checked" : ""} data-claude-away-checkbox />
3835
- <span class="reply-mode-switch__track" aria-hidden="true"><span class="reply-mode-switch__thumb"></span></span>
3836
4751
  <span class="reply-mode-switch__copy">
3837
4752
  <span class="reply-mode-switch__title">
3838
4753
  <span>${escapeHtml(L("settings.claudeAway.title"))}</span>
3839
- <span class="reply-mode-switch__state">${escapeHtml(stateLabel)}</span>
3840
4754
  </span>
3841
4755
  <span class="reply-mode-switch__hint">${escapeHtml(L("settings.claudeAway.description"))}</span>
3842
4756
  </span>
4757
+ <span class="reply-mode-switch--settings__toggle">
4758
+ <span class="reply-mode-switch__track" aria-hidden="true"><span class="reply-mode-switch__thumb"></span></span>
4759
+ <span class="reply-mode-switch__state">${escapeHtml(stateLabel)}</span>
4760
+ </span>
3843
4761
  </label>
3844
4762
  `])}
3845
4763
  <p class="settings-page-copy muted">${escapeHtml(L("settings.awayMode.codexNote"))}</p>
@@ -3853,24 +4771,25 @@ function renderSettingsAutoPilotPage() {
3853
4771
  const writeLaneContentEnabled = state.session?.autoPilotWriteLaneContent === true;
3854
4772
  const writeLaneUiTestsEnabled = state.session?.autoPilotWriteLaneUiTests === true;
3855
4773
  const writeLaneSourceEnabled = state.session?.autoPilotWriteLaneSource === true;
3856
- const trustedWritesEnabled =
3857
- writeLaneContentEnabled || writeLaneUiTestsEnabled || writeLaneSourceEnabled || state.session?.autoPilotTrustedWrites === true;
4774
+ const trustedWritesEnabled = hasAutoPilotWriteLaneEnabled();
3858
4775
  const trustedWritesStateLabel = trustedWritesEnabled ? L("common.enabled") : L("common.disabled");
3859
4776
  const recentEntries = recentAutoPilotEntries();
3860
4777
  const suggestions = recentAutoPilotSuggestions();
3861
4778
  return `
3862
4779
  <div class="settings-page">
3863
4780
  ${renderSettingsGroup("", [`
3864
- <label class="reply-mode-switch reply-mode-switch--grouped" data-auto-pilot-toggle>
4781
+ <label class="reply-mode-switch reply-mode-switch--settings reply-mode-switch--grouped" data-auto-pilot-toggle>
3865
4782
  <input type="checkbox" class="reply-mode-switch__input" ${trustedReadsEnabled ? "checked" : ""} data-auto-pilot-checkbox />
3866
- <span class="reply-mode-switch__track" aria-hidden="true"><span class="reply-mode-switch__thumb"></span></span>
3867
4783
  <span class="reply-mode-switch__copy">
3868
4784
  <span class="reply-mode-switch__title">
3869
4785
  <span>${escapeHtml(L("settings.autoPilot.trustedReadsTitle"))}</span>
3870
- <span class="reply-mode-switch__state">${escapeHtml(trustedReadsStateLabel)}</span>
3871
4786
  </span>
3872
4787
  <span class="reply-mode-switch__hint">${escapeHtml(L("settings.autoPilot.trustedReadsDescription"))}</span>
3873
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(trustedReadsStateLabel)}</span>
4792
+ </span>
3874
4793
  </label>
3875
4794
  `, `
3876
4795
  <div class="settings-toggle-subhead" role="presentation">
@@ -3880,40 +4799,46 @@ function renderSettingsAutoPilotPage() {
3880
4799
  `, `
3881
4800
  <div class="settings-toggle-subcopy muted">${escapeHtml(L("settings.autoPilot.trustedWritesDescription"))}</div>
3882
4801
  `, `
3883
- <label class="reply-mode-switch reply-mode-switch--grouped" data-auto-pilot-write-lane="content">
4802
+ <label class="reply-mode-switch reply-mode-switch--settings reply-mode-switch--grouped" data-auto-pilot-write-lane="content">
3884
4803
  <input type="checkbox" class="reply-mode-switch__input" ${writeLaneContentEnabled ? "checked" : ""} data-auto-pilot-write-lane-checkbox="content" />
3885
- <span class="reply-mode-switch__track" aria-hidden="true"><span class="reply-mode-switch__thumb"></span></span>
3886
4804
  <span class="reply-mode-switch__copy">
3887
4805
  <span class="reply-mode-switch__title">
3888
4806
  <span>${escapeHtml(L("settings.autoPilot.writeLaneContentTitle"))}</span>
3889
- <span class="reply-mode-switch__state">${escapeHtml(writeLaneContentEnabled ? L("common.enabled") : L("common.disabled"))}</span>
3890
4807
  </span>
3891
4808
  <span class="reply-mode-switch__hint">${escapeHtml(L("settings.autoPilot.writeLaneContentDescription"))}</span>
3892
4809
  </span>
4810
+ <span class="reply-mode-switch--settings__toggle">
4811
+ <span class="reply-mode-switch__track" aria-hidden="true"><span class="reply-mode-switch__thumb"></span></span>
4812
+ <span class="reply-mode-switch__state">${escapeHtml(writeLaneContentEnabled ? L("common.enabled") : L("common.disabled"))}</span>
4813
+ </span>
3893
4814
  </label>
3894
4815
  `, `
3895
- <label class="reply-mode-switch reply-mode-switch--grouped" data-auto-pilot-write-lane="ui-tests">
4816
+ <label class="reply-mode-switch reply-mode-switch--settings reply-mode-switch--grouped" data-auto-pilot-write-lane="ui-tests">
3896
4817
  <input type="checkbox" class="reply-mode-switch__input" ${writeLaneUiTestsEnabled ? "checked" : ""} data-auto-pilot-write-lane-checkbox="ui-tests" />
3897
- <span class="reply-mode-switch__track" aria-hidden="true"><span class="reply-mode-switch__thumb"></span></span>
3898
4818
  <span class="reply-mode-switch__copy">
3899
4819
  <span class="reply-mode-switch__title">
3900
4820
  <span>${escapeHtml(L("settings.autoPilot.writeLaneUiTestsTitle"))}</span>
3901
- <span class="reply-mode-switch__state">${escapeHtml(writeLaneUiTestsEnabled ? L("common.enabled") : L("common.disabled"))}</span>
3902
4821
  </span>
3903
4822
  <span class="reply-mode-switch__hint">${escapeHtml(L("settings.autoPilot.writeLaneUiTestsDescription"))}</span>
3904
4823
  </span>
4824
+ <span class="reply-mode-switch--settings__toggle">
4825
+ <span class="reply-mode-switch__track" aria-hidden="true"><span class="reply-mode-switch__thumb"></span></span>
4826
+ <span class="reply-mode-switch__state">${escapeHtml(writeLaneUiTestsEnabled ? L("common.enabled") : L("common.disabled"))}</span>
4827
+ </span>
3905
4828
  </label>
3906
4829
  `, `
3907
- <label class="reply-mode-switch reply-mode-switch--grouped" data-auto-pilot-write-lane="source">
4830
+ <label class="reply-mode-switch reply-mode-switch--settings reply-mode-switch--grouped" data-auto-pilot-write-lane="source">
3908
4831
  <input type="checkbox" class="reply-mode-switch__input" ${writeLaneSourceEnabled ? "checked" : ""} data-auto-pilot-write-lane-checkbox="source" />
3909
- <span class="reply-mode-switch__track" aria-hidden="true"><span class="reply-mode-switch__thumb"></span></span>
3910
4832
  <span class="reply-mode-switch__copy">
3911
4833
  <span class="reply-mode-switch__title">
3912
4834
  <span>${escapeHtml(L("settings.autoPilot.writeLaneSourceTitle"))}</span>
3913
- <span class="reply-mode-switch__state">${escapeHtml(writeLaneSourceEnabled ? L("common.enabled") : L("common.disabled"))}</span>
3914
4835
  </span>
3915
4836
  <span class="reply-mode-switch__hint">${escapeHtml(L("settings.autoPilot.writeLaneSourceDescription"))}</span>
3916
4837
  </span>
4838
+ <span class="reply-mode-switch--settings__toggle">
4839
+ <span class="reply-mode-switch__track" aria-hidden="true"><span class="reply-mode-switch__thumb"></span></span>
4840
+ <span class="reply-mode-switch__state">${escapeHtml(writeLaneSourceEnabled ? L("common.enabled") : L("common.disabled"))}</span>
4841
+ </span>
3917
4842
  </label>
3918
4843
  `], { listClassName: "settings-list settings-list--toggle-group" })}
3919
4844
  <p class="settings-page-copy muted">${escapeHtml(L("settings.autoPilot.scopeNote"))}</p>
@@ -4537,33 +5462,290 @@ function renderSettingsA2aSharePage(context) {
4537
5462
  ${meta ? `<span class="settings-compose-entry__meta muted">${meta}</span>` : ""}
4538
5463
  </span>
4539
5464
  </div>
4540
- `;
4541
- });
4542
- if (hasMore) {
4543
- const remaining = items.length - visibleCount;
4544
- filesList.push(
4545
- `<button type="button" class="settings-compose-more" data-a2a-share-files-more>${escapeHtml(L("settings.a2aShare.showMore", { count: remaining }))}</button>`
4546
- );
4547
- } else if (items.length > PAGE_SIZE) {
4548
- filesList.push(
4549
- `<button type="button" class="settings-compose-more" data-a2a-share-files-collapse>${escapeHtml(L("settings.a2aShare.showLess"))}</button>`
4550
- );
4551
- }
5465
+ `;
5466
+ });
5467
+ if (hasMore) {
5468
+ const remaining = items.length - visibleCount;
5469
+ filesList.push(
5470
+ `<button type="button" class="settings-compose-more" data-a2a-share-files-more>${escapeHtml(L("settings.a2aShare.showMore", { count: remaining }))}</button>`
5471
+ );
5472
+ } else if (items.length > PAGE_SIZE) {
5473
+ filesList.push(
5474
+ `<button type="button" class="settings-compose-more" data-a2a-share-files-collapse>${escapeHtml(L("settings.a2aShare.showLess"))}</button>`
5475
+ );
5476
+ }
5477
+
5478
+ return `
5479
+ <div class="settings-page">
5480
+ ${renderSettingsGroup("", [
5481
+ renderSettingsInfoRow(L("settings.row.a2aShareStatus"), statusLabel),
5482
+ renderSettingsInfoRow(L("settings.row.a2aShareEndpoint"), share.shareHost || share.shareUrl || ""),
5483
+ renderSettingsInfoRow(L("settings.row.a2aShareUserId"), share.userId || ""),
5484
+ ])}
5485
+ ${renderSettingsGroup(L("settings.a2aShare.storage.title"), storageRows)}
5486
+ ${items.length
5487
+ ? renderSettingsGroup(L("settings.a2aShare.files.title"), filesList)
5488
+ : renderSettingsGroup(L("settings.a2aShare.files.title"), [
5489
+ `<p class="settings-group__description muted">${escapeHtml(L("settings.a2aShare.files.empty"))}</p>`,
5490
+ ])}
5491
+ ${share.error ? `<p class="settings-page-copy muted">${escapeHtml(L("settings.a2aShare.error", { reason: share.error }))}</p>` : ""}
5492
+ </div>
5493
+ `;
5494
+ }
5495
+
5496
+ function renderSettingsRemotePairingPage(context) {
5497
+ // Always render — remote pairing is a feature toggle, not a credential-
5498
+ // gated integration. If the bridge hasn't responded with a status payload
5499
+ // yet, fall back to a default-disabled view so the toggle still works.
5500
+ const status = context.remotePairing || {
5501
+ enabled: false,
5502
+ relayUrl: "",
5503
+ configuredRelayUrl: "",
5504
+ identityFingerprint: null,
5505
+ sessions: [],
5506
+ pairings: [],
5507
+ auditEvents: [],
5508
+ };
5509
+
5510
+ const enabled = status.enabled === true;
5511
+ const sessions = Array.isArray(status.sessions) ? status.sessions : [];
5512
+ const pairings = Array.isArray(status.pairings) ? status.pairings : [];
5513
+ const auditEvents = Array.isArray(status.auditEvents) ? status.auditEvents : [];
5514
+ const usingRelay = isRemotePairingUsingRelay();
5515
+ const statusModel = remotePairingStatusModel(status, { usingRelay });
5516
+ const sessionsByPub = new Map(
5517
+ sessions.map((s) => [String(s.phonePub || "").toLowerCase(), s]),
5518
+ );
5519
+ const sessionsByPairingId = new Map(
5520
+ sessions.map((s) => [String(s.pairingId || ""), s]),
5521
+ );
5522
+ const togglePending = state.remotePairingPending === "toggle";
5523
+ const relayPending = state.remotePairingPending === "relayUrl";
5524
+
5525
+ // "This device" — the locally-stored enrollment record (if any). Read
5526
+ // synchronously from localStorage on each render so the badge shows up
5527
+ // immediately after a successful lan-enroll without waiting for state
5528
+ // refresh. Returns null in storage-disabled contexts (Safari private
5529
+ // mode etc.) — the page just renders without the indicator.
5530
+ const localPairing = (() => {
5531
+ try {
5532
+ return loadRemotePairingState();
5533
+ } catch {
5534
+ return null;
5535
+ }
5536
+ })();
5537
+ const localPhonePub = (localPairing?.phonePub || "").toLowerCase();
5538
+ const localRegistered = Boolean(localPairing);
5539
+
5540
+ // Identity fingerprint row — shows the bridge's stable public key short-
5541
+ // form so the human can verify what the phone is pairing against.
5542
+ const fpRow = renderSettingsInfoRow(
5543
+ L("settings.remotePairing.identity.fingerprint"),
5544
+ status.identityFingerprint || L("settings.remotePairing.identity.empty"),
5545
+ );
5546
+
5547
+ const connectionSection = `
5548
+ <section class="settings-remote-connection">
5549
+ <header class="settings-remote-connection__header">
5550
+ <div class="settings-remote-connection__copy">
5551
+ <p class="settings-remote-connection__title">${escapeHtml(L("settings.remotePairing.connection.title"))}</p>
5552
+ </div>
5553
+ <span class="settings-remote-status settings-remote-status--${escapeHtml(statusModel.tone)}">${escapeHtml(statusModel.label)}</span>
5554
+ </header>
5555
+ <label class="reply-mode-switch reply-mode-switch--settings" data-remote-pairing-toggle>
5556
+ <input type="checkbox" class="reply-mode-switch__input"
5557
+ ${enabled ? "checked" : ""}
5558
+ ${togglePending ? "disabled" : ""}
5559
+ data-remote-pairing-toggle-checkbox />
5560
+ <span class="reply-mode-switch--settings__toggle">
5561
+ <span class="reply-mode-switch__track" aria-hidden="true"><span class="reply-mode-switch__thumb"></span></span>
5562
+ <span class="reply-mode-switch__state">${escapeHtml(enabled ? L("settings.claudeAway.on") : L("settings.claudeAway.off"))}</span>
5563
+ </span>
5564
+ <span class="reply-mode-switch__hint">${escapeHtml(L("settings.remotePairing.toggle.description"))}</span>
5565
+ </label>
5566
+ <p class="settings-remote-connection__trust">${escapeHtml(L("settings.remotePairing.security.copy"))}</p>
5567
+ </section>
5568
+ `;
5569
+
5570
+ // Relay URL editor. When empty, the bridge falls back to the compiled-in
5571
+ // default — surfacing that here would require leaking it to the client,
5572
+ // so we just show "" and lean on the placeholder.
5573
+ const relayUrlValue = String(status.configuredRelayUrl || "");
5574
+ const relayUrlSection = `
5575
+ <section class="settings-group">
5576
+ <p class="settings-group__title">${escapeHtml(L("settings.remotePairing.relayUrl.title"))}</p>
5577
+ <div class="settings-input-row">
5578
+ <input type="text"
5579
+ class="settings-input"
5580
+ data-remote-pairing-relay-url-input
5581
+ value="${escapeHtml(relayUrlValue)}"
5582
+ placeholder="${escapeHtml(L("settings.remotePairing.relayUrl.placeholder"))}"
5583
+ ${relayPending ? "disabled" : ""}
5584
+ autocomplete="off"
5585
+ spellcheck="false" />
5586
+ <button type="button"
5587
+ class="secondary"
5588
+ data-remote-pairing-relay-url-save
5589
+ ${relayPending ? "disabled" : ""}>
5590
+ ${escapeHtml(L("settings.remotePairing.relayUrl.save"))}
5591
+ </button>
5592
+ </div>
5593
+ <p class="settings-group__description">${escapeHtml(L("settings.remotePairing.relayUrl.help"))}</p>
5594
+ </section>
5595
+ `;
5596
+
5597
+ // Pairings list — one card per phonePub on disk. "Live" badge if there's
5598
+ // an open session; otherwise show the last-seen timestamp. The phone
5599
+ // currently rendering this page gets an extra "This device" badge so the
5600
+ // user can identify themselves in the list (especially useful when more
5601
+ // than one phone is paired to the same Mac).
5602
+ const otherPairings = localPhonePub
5603
+ ? pairings.filter((p) => String(p.phonePub || "").toLowerCase() !== localPhonePub)
5604
+ : pairings;
5605
+ const pairingsRows = otherPairings.length === 0
5606
+ ? `<div class="settings-copy-block"><p class="muted">${escapeHtml(L("settings.remotePairing.pairings.empty"))}</p></div>`
5607
+ : `<div class="device-list">
5608
+ ${otherPairings.map((p) => {
5609
+ const session = sessionsByPub.get(String(p.phonePub || "").toLowerCase())
5610
+ || sessionsByPairingId.get(String(p.pairingId || ""));
5611
+ const live = isRemotePairingSessionConnected(session);
5612
+ const lastSeenAtMs = Number(session?.lastSeenAtMs || p.lastSeenAtMs) || 0;
5613
+ const lastSeen = lastSeenAtMs
5614
+ ? new Date(lastSeenAtMs).toLocaleString(state.locale)
5615
+ : L("settings.remotePairing.pairings.never");
5616
+ const added = p.addedAtMs
5617
+ ? new Date(p.addedAtMs).toLocaleString(state.locale)
5618
+ : L("settings.remotePairing.pairings.never");
5619
+ const pendingThis = state.remotePairingPending === `revoke:${p.phonePub}`;
5620
+ return `
5621
+ <article class="device-card" data-remote-pairing-row="${escapeHtml(p.phonePub)}">
5622
+ <div class="device-card__header">
5623
+ <div class="device-card__title-wrap">
5624
+ <div class="device-card__headline">
5625
+ <span class="device-card__icon" aria-hidden="true">${renderIcon("iphone")}</span>
5626
+ <h3 class="device-card__title">${escapeHtml(p.label || p.phoneFingerprint || p.pairingId)}</h3>
5627
+ </div>
5628
+ <p class="device-card__subtitle">${escapeHtml(p.phoneFingerprint || p.phonePub.slice(0, 16))}</p>
5629
+ </div>
5630
+ <div class="device-card__badges">
5631
+ <span class="device-card__badge ${live ? "device-card__badge--live" : "device-card__badge--offline"}">
5632
+ ${escapeHtml(live ? L("settings.remotePairing.pairings.live") : L("settings.remotePairing.pairings.offline"))}
5633
+ </span>
5634
+ </div>
5635
+ </div>
5636
+ <div class="device-card__meta">
5637
+ ${renderDeviceMetaRow(L("settings.remotePairing.pairings.added"), added)}
5638
+ ${renderDeviceMetaRow(L("settings.remotePairing.pairings.lastSeen"), lastSeen)}
5639
+ </div>
5640
+ <div class="device-card__actions">
5641
+ <button type="button"
5642
+ class="secondary secondary--wide"
5643
+ data-remote-pairing-revoke="${escapeHtml(p.phonePub)}"
5644
+ ${pendingThis ? "disabled" : ""}>
5645
+ ${escapeHtml(L("settings.remotePairing.pairings.revoke"))}
5646
+ </button>
5647
+ </div>
5648
+ </article>
5649
+ `;
5650
+ }).join("")}
5651
+ </div>`;
5652
+
5653
+ // "This device" group — sits above the pairings list and tells the user
5654
+ // explicitly whether *this phone* (the one rendering the page) is the
5655
+ // enrolled one. Two states:
5656
+ // 1. Enrolled: show the phone fingerprint + a confirmation copy.
5657
+ // 2. Not enrolled: surface a hint that re-pairing on LAN registers
5658
+ // the device. (The hint applies even when other phones are paired
5659
+ // — they're not relevant to this device's relay status.)
5660
+ const thisDeviceSection = (() => {
5661
+ if (localRegistered) {
5662
+ const fingerprint = localPairing.phoneFingerprint
5663
+ || localPairing.phonePub.slice(0, 16);
5664
+ const rotatePending = state.remotePairingPending === "rotateToken";
5665
+ return renderSettingsGroup(L("settings.remotePairing.thisDevice.title"), [
5666
+ `
5667
+ <div class="device-list">
5668
+ <article class="device-card">
5669
+ <div class="device-card__header">
5670
+ <div class="device-card__title-wrap">
5671
+ <div class="device-card__headline">
5672
+ <span class="device-card__icon" aria-hidden="true">${renderIcon("iphone")}</span>
5673
+ <h3 class="device-card__title">${escapeHtml(L("settings.remotePairing.thisDevice.registeredTitle"))}</h3>
5674
+ </div>
5675
+ <p class="device-card__subtitle">${escapeHtml(fingerprint)}</p>
5676
+ </div>
5677
+ <span class="device-card__badge">${escapeHtml(L("settings.remotePairing.pairings.thisDevice"))}</span>
5678
+ </div>
5679
+ <div class="device-card__meta">
5680
+ ${renderDeviceMetaRow(L("settings.remotePairing.status.title"), statusModel.label)}
5681
+ ${renderDeviceMetaRow(L("settings.remotePairing.thisDevice.fingerprint"), fingerprint)}
5682
+ </div>
5683
+ <div class="device-card__actions">
5684
+ <button type="button"
5685
+ class="secondary secondary--wide"
5686
+ data-remote-pairing-rotate-token
5687
+ ${rotatePending || usingRelay ? "disabled" : ""}>
5688
+ ${escapeHtml(L("settings.remotePairing.token.rotate"))}
5689
+ </button>
5690
+ </div>
5691
+ ${usingRelay ? `<p class="settings-page-copy muted settings-remote-device-hint">${escapeHtml(L("settings.remotePairing.token.lanOnly"))}</p>` : ""}
5692
+ </article>
5693
+ </div>
5694
+ `,
5695
+ ]);
5696
+ }
5697
+ return renderSettingsGroup(L("settings.remotePairing.thisDevice.title"), [
5698
+ `
5699
+ <div class="device-list">
5700
+ <article class="device-card">
5701
+ <div class="device-card__header">
5702
+ <div class="device-card__title-wrap">
5703
+ <div class="device-card__headline">
5704
+ <span class="device-card__icon" aria-hidden="true">${renderIcon("iphone")}</span>
5705
+ <h3 class="device-card__title">${escapeHtml(L("settings.remotePairing.thisDevice.notEnrolledTitle"))}</h3>
5706
+ </div>
5707
+ </div>
5708
+ </div>
5709
+ <div class="device-card__meta">
5710
+ ${renderDeviceMetaRow(L("settings.remotePairing.status.title"), L("settings.remotePairing.status.disabled"))}
5711
+ ${renderDeviceMetaRow(L("settings.remotePairing.thisDevice.fingerprint"), L("common.unavailable"))}
5712
+ </div>
5713
+ <p class="settings-page-copy muted settings-remote-device-hint">${escapeHtml(L("settings.remotePairing.thisDevice.notEnrolled"))}</p>
5714
+ </article>
5715
+ </div>
5716
+ `,
5717
+ ]);
5718
+ })();
5719
+
5720
+ const detailsSection = `
5721
+ <details class="settings-disclosure settings-remote-details" data-remote-pairing-details ${state.remotePairingDetailsOpen ? "open" : ""}>
5722
+ <summary>${escapeHtml(L("settings.remotePairing.details.title"))}</summary>
5723
+ <div class="settings-disclosure__body">
5724
+ ${relayUrlSection}
5725
+ ${renderSettingsGroup(L("settings.remotePairing.identity.title"), [fpRow])}
5726
+ </div>
5727
+ </details>
5728
+ `;
5729
+ const auditSection = renderSettingsGroup(L("settings.remotePairing.audit.title"), [
5730
+ renderRemotePairingAudit(auditEvents),
5731
+ ]);
5732
+
5733
+ const noticeBlock = state.remotePairingNotice
5734
+ ? `<p class="settings-page-copy">${escapeHtml(state.remotePairingNotice)}</p>`
5735
+ : "";
5736
+ const errorBlock = state.remotePairingError
5737
+ ? `<p class="settings-page-copy danger">${escapeHtml(state.remotePairingError)}</p>`
5738
+ : "";
4552
5739
 
4553
5740
  return `
4554
5741
  <div class="settings-page">
4555
- ${renderSettingsGroup("", [
4556
- renderSettingsInfoRow(L("settings.row.a2aShareStatus"), statusLabel),
4557
- renderSettingsInfoRow(L("settings.row.a2aShareEndpoint"), share.shareHost || share.shareUrl || ""),
4558
- renderSettingsInfoRow(L("settings.row.a2aShareUserId"), share.userId || ""),
4559
- ])}
4560
- ${renderSettingsGroup(L("settings.a2aShare.storage.title"), storageRows)}
4561
- ${items.length
4562
- ? renderSettingsGroup(L("settings.a2aShare.files.title"), filesList)
4563
- : renderSettingsGroup(L("settings.a2aShare.files.title"), [
4564
- `<p class="settings-group__description muted">${escapeHtml(L("settings.a2aShare.files.empty"))}</p>`,
4565
- ])}
4566
- ${share.error ? `<p class="settings-page-copy muted">${escapeHtml(L("settings.a2aShare.error", { reason: share.error }))}</p>` : ""}
5742
+ ${noticeBlock}
5743
+ ${errorBlock}
5744
+ ${connectionSection}
5745
+ ${thisDeviceSection}
5746
+ ${renderSettingsGroup(L("settings.remotePairing.pairings.otherTitle"), [pairingsRows])}
5747
+ ${auditSection}
5748
+ ${detailsSection}
4567
5749
  </div>
4568
5750
  `;
4569
5751
  }
@@ -5030,6 +6212,7 @@ function formatUsdcAtomic(atomic) {
5030
6212
  }
5031
6213
 
5032
6214
  function renderSettingsInfoRow(label, value, options = {}) {
6215
+ const tone = settingsNavValueTone(value, options.valueTone || "");
5033
6216
  const rowClassName = [
5034
6217
  "settings-info-row",
5035
6218
  options.stacked ? "settings-info-row--stacked" : "",
@@ -5037,6 +6220,7 @@ function renderSettingsInfoRow(label, value, options = {}) {
5037
6220
  ].filter(Boolean).join(" ");
5038
6221
  const valueClassName = [
5039
6222
  "settings-info-row__value",
6223
+ tone ? `settings-info-row__value--${tone}` : "",
5040
6224
  options.mono ? "settings-info-row__value--mono" : "",
5041
6225
  options.valueClassName || "",
5042
6226
  ].filter(Boolean).join(" ");
@@ -5083,9 +6267,17 @@ function renderTrustedDeviceCard(device) {
5083
6267
  const badge = device.currentDevice
5084
6268
  ? `<span class="device-card__badge">${escapeHtml(L("settings.device.thisDevice"))}</span>`
5085
6269
  : "";
5086
- const actionLabel = device.currentDevice
5087
- ? L("settings.action.removeThisDevice")
5088
- : L("settings.action.revokeDevice");
6270
+ const actions = device.currentDevice
6271
+ ? ""
6272
+ : `
6273
+ <div class="device-card__actions">
6274
+ <button
6275
+ class="secondary secondary--wide"
6276
+ type="button"
6277
+ data-device-revoke="${escapeHtml(device.deviceId)}"
6278
+ >${escapeHtml(L("settings.action.revokeDevice"))}</button>
6279
+ </div>
6280
+ `;
5089
6281
 
5090
6282
  return `
5091
6283
  <article class="device-card">
@@ -5106,14 +6298,7 @@ function renderTrustedDeviceCard(device) {
5106
6298
  ${renderDeviceMetaRow(L("settings.row.pushStatus"), pushLabel)}
5107
6299
  ${renderDeviceMetaRow(L("settings.row.currentLanguage"), localeLabel)}
5108
6300
  </div>
5109
- <div class="device-card__actions">
5110
- <button
5111
- class="secondary secondary--wide"
5112
- type="button"
5113
- data-device-revoke="${escapeHtml(device.deviceId)}"
5114
- data-device-current="${device.currentDevice ? "true" : "false"}"
5115
- >${escapeHtml(actionLabel)}</button>
5116
- </div>
6301
+ ${actions}
5117
6302
  </article>
5118
6303
  `;
5119
6304
  }
@@ -5186,8 +6371,9 @@ function renderStandardDetailMobile(detail) {
5186
6371
  const kindInfo = kindMeta(detail.kind, detail);
5187
6372
  const spaciousBodyDetail = TIMELINE_MESSAGE_KINDS.has(detail.kind);
5188
6373
  const plainIntro = renderDetailPlainIntro(detail, { mobile: true });
6374
+ const hasCompletionReply = isCompletionReplyAvailable(detail);
5189
6375
  return `
5190
- <div class="mobile-detail-screen">
6376
+ <div class="mobile-detail-screen ${hasCompletionReply ? "mobile-detail-screen--has-reply-dock" : ""}">
5191
6377
  <div class="detail-shell detail-shell--mobile">
5192
6378
  <div class="mobile-detail-scroll mobile-detail-scroll--detail">
5193
6379
  ${renderDetailMetaRow(detail, kindInfo, { mobile: true })}
@@ -5217,10 +6403,10 @@ function renderStandardDetailMobile(detail) {
5217
6403
  ${renderDetailDiffPanel(detail, { mobile: true })}
5218
6404
  ${renderDetailDiffThreadGroups(detail, { mobile: true })}
5219
6405
  ${renderDetailFileRefs(detail, { mobile: true })}
5220
- ${renderCompletionReplyComposer(detail, { mobile: true })}
5221
6406
  </div>
5222
6407
  ${detail.readOnly ? "" : renderActionButtons(detail.actions || [], { mobileSticky: true })}
5223
6408
  </div>
6409
+ ${renderCompletionReplyDock(detail)}
5224
6410
  </div>
5225
6411
  `;
5226
6412
  }
@@ -5968,7 +7154,7 @@ function renderThreadShareDetail(detail, options = {}) {
5968
7154
  }
5969
7155
 
5970
7156
  function renderCompletionReplyComposer(detail, options = {}) {
5971
- if ((detail.kind !== "completion" && detail.kind !== "assistant_final") || detail.reply?.enabled !== true) {
7157
+ if (!isCompletionReplyAvailable(detail)) {
5972
7158
  return "";
5973
7159
  }
5974
7160
 
@@ -6142,6 +7328,51 @@ function renderCompletionReplyComposer(detail, options = {}) {
6142
7328
  `;
6143
7329
  }
6144
7330
 
7331
+ function isCompletionReplyAvailable(detail) {
7332
+ return Boolean(
7333
+ detail &&
7334
+ (detail.kind === "completion" || detail.kind === "assistant_final") &&
7335
+ detail.reply?.enabled === true &&
7336
+ detail.token
7337
+ );
7338
+ }
7339
+
7340
+ function renderCompletionReplyDock(detail) {
7341
+ if (!isCompletionReplyAvailable(detail)) {
7342
+ return "";
7343
+ }
7344
+ const providerVars = { provider: providerDisplayName(detail?.provider) };
7345
+ return `
7346
+ <div class="reply-dock" role="region" aria-label="${escapeHtml(L("reply.eyebrow"))}">
7347
+ <button
7348
+ class="reply-dock__button"
7349
+ type="button"
7350
+ data-open-completion-reply-sheet
7351
+ data-token="${escapeHtml(detail.token)}"
7352
+ >
7353
+ <span class="reply-dock__label">${escapeHtml(L("reply.title", providerVars))}</span>
7354
+ </button>
7355
+ </div>
7356
+ `;
7357
+ }
7358
+
7359
+ function renderCompletionReplySheet(detail) {
7360
+ if (!isCompletionReplyAvailable(detail) || state.completionReplySheetToken !== detail.token) {
7361
+ return "";
7362
+ }
7363
+ const providerVars = { provider: providerDisplayName(detail?.provider) };
7364
+ return `
7365
+ <div class="reply-sheet-backdrop" data-close-completion-reply-sheet aria-hidden="true"></div>
7366
+ <section class="reply-sheet" role="dialog" aria-modal="true" aria-label="${escapeHtml(L("reply.title", providerVars))}">
7367
+ <div class="reply-sheet__handle" aria-hidden="true"></div>
7368
+ <button class="reply-sheet__close" type="button" data-close-completion-reply-sheet aria-label="${escapeHtml(L("common.close"))}">
7369
+ <span aria-hidden="true">&times;</span>
7370
+ </button>
7371
+ ${renderCompletionReplyComposer(detail, { mobile: true, sheet: true })}
7372
+ </section>
7373
+ `;
7374
+ }
7375
+
6145
7376
  function renderChoiceQuestions(detail) {
6146
7377
  const effectiveAnswers = getEffectiveChoiceDraftAnswers(detail);
6147
7378
  return detail.questions
@@ -6392,10 +7623,27 @@ function renderInstallBanner() {
6392
7623
  `;
6393
7624
  }
6394
7625
 
7626
+ function renderClientUpdateBanner() {
7627
+ return `
7628
+ <section class="install-banner install-banner--push">
7629
+ <div class="install-banner__copy">
7630
+ <strong>${escapeHtml(L("banner.clientUpdate.title"))}</strong>
7631
+ <p class="muted">${escapeHtml(L("banner.clientUpdate.copy"))}</p>
7632
+ </div>
7633
+ <div class="actions install-banner__actions">
7634
+ <button class="secondary" type="button" data-force-app-refresh>${escapeHtml(L("banner.clientUpdate.action"))}</button>
7635
+ </div>
7636
+ </section>
7637
+ `;
7638
+ }
7639
+
6395
7640
  function renderTopBanner() {
6396
7641
  if (!isDesktopLayout() && (state.detailOpen || isSettingsSubpageOpen())) {
6397
7642
  return "";
6398
7643
  }
7644
+ if (state.clientUpdateRequired) {
7645
+ return renderClientUpdateBanner();
7646
+ }
6399
7647
  if (shouldShowInstallBanner()) {
6400
7648
  return renderInstallBanner();
6401
7649
  }
@@ -6922,6 +8170,23 @@ function bindShellInteractions() {
6922
8170
  });
6923
8171
  }
6924
8172
 
8173
+ for (const button of document.querySelectorAll("[data-open-completion-reply-sheet][data-token]")) {
8174
+ button.addEventListener("click", async () => {
8175
+ state.completionReplySheetToken = button.dataset.token || "";
8176
+ resetHorizontalViewportScroll();
8177
+ await renderShell();
8178
+ resetHorizontalViewportScroll();
8179
+ });
8180
+ }
8181
+
8182
+ for (const trigger of document.querySelectorAll("[data-close-completion-reply-sheet]")) {
8183
+ trigger.addEventListener("click", async () => {
8184
+ state.completionReplySheetToken = "";
8185
+ resetHorizontalViewportScroll();
8186
+ await renderShell();
8187
+ });
8188
+ }
8189
+
6925
8190
  for (const input of document.querySelectorAll("[data-reply-mode-toggle][data-reply-token]")) {
6926
8191
  input.addEventListener("change", async () => {
6927
8192
  const token = input.dataset.replyToken || "";
@@ -7132,14 +8397,51 @@ function bindShellInteractions() {
7132
8397
  state.session.autoPilotWriteLaneSource = result?.writeLaneSourceEnabled === true;
7133
8398
  }
7134
8399
 
8400
+ function snapshotAutoPilotSettings() {
8401
+ return {
8402
+ trustedReads: state.session?.autoPilotTrustedReads === true,
8403
+ trustedWrites: state.session?.autoPilotTrustedWrites === true,
8404
+ writeLaneContent: state.session?.autoPilotWriteLaneContent === true,
8405
+ writeLaneUiTests: state.session?.autoPilotWriteLaneUiTests === true,
8406
+ writeLaneSource: state.session?.autoPilotWriteLaneSource === true,
8407
+ };
8408
+ }
8409
+
8410
+ function restoreAutoPilotSettings(snapshot) {
8411
+ if (!state.session || !snapshot) {
8412
+ return;
8413
+ }
8414
+ state.session.autoPilotTrustedReads = snapshot.trustedReads === true;
8415
+ state.session.autoPilotTrustedWrites = snapshot.trustedWrites === true;
8416
+ state.session.autoPilotWriteLaneContent = snapshot.writeLaneContent === true;
8417
+ state.session.autoPilotWriteLaneUiTests = snapshot.writeLaneUiTests === true;
8418
+ state.session.autoPilotWriteLaneSource = snapshot.writeLaneSource === true;
8419
+ }
8420
+
8421
+ function reconcileAutoPilotTrustedWrites() {
8422
+ if (!state.session) {
8423
+ return;
8424
+ }
8425
+ state.session.autoPilotTrustedWrites = Boolean(
8426
+ state.session.autoPilotWriteLaneContent === true ||
8427
+ state.session.autoPilotWriteLaneUiTests === true ||
8428
+ state.session.autoPilotWriteLaneSource === true,
8429
+ );
8430
+ }
8431
+
7135
8432
  for (const checkbox of document.querySelectorAll("[data-auto-pilot-checkbox]")) {
7136
8433
  checkbox.addEventListener("change", async () => {
7137
8434
  const next = checkbox.checked === true;
8435
+ const previous = snapshotAutoPilotSettings();
8436
+ if (state.session) {
8437
+ state.session.autoPilotTrustedReads = next;
8438
+ }
8439
+ await renderShell();
7138
8440
  try {
7139
8441
  const result = await apiPost("/api/settings/auto-pilot", { trustedReadsEnabled: next });
7140
8442
  applyAutoPilotSettingsResult(result);
7141
- await refreshAuthenticatedState();
7142
8443
  } catch (error) {
8444
+ restoreAutoPilotSettings(previous);
7143
8445
  state.pushError = error.message || String(error);
7144
8446
  }
7145
8447
  await renderShell();
@@ -7150,17 +8452,29 @@ function bindShellInteractions() {
7150
8452
  checkbox.addEventListener("change", async () => {
7151
8453
  const next = checkbox.checked === true;
7152
8454
  const lane = normalizeClientText(checkbox.getAttribute("data-auto-pilot-write-lane-checkbox") || "");
8455
+ const previous = snapshotAutoPilotSettings();
7153
8456
  const payload =
7154
8457
  lane === "content"
7155
8458
  ? { writeLaneContentEnabled: next }
7156
8459
  : lane === "ui-tests"
7157
8460
  ? { writeLaneUiTestsEnabled: next }
7158
8461
  : { writeLaneSourceEnabled: next };
8462
+ if (state.session) {
8463
+ if (lane === "content") {
8464
+ state.session.autoPilotWriteLaneContent = next;
8465
+ } else if (lane === "ui-tests") {
8466
+ state.session.autoPilotWriteLaneUiTests = next;
8467
+ } else {
8468
+ state.session.autoPilotWriteLaneSource = next;
8469
+ }
8470
+ reconcileAutoPilotTrustedWrites();
8471
+ }
8472
+ await renderShell();
7159
8473
  try {
7160
8474
  const result = await apiPost("/api/settings/auto-pilot", payload);
7161
8475
  applyAutoPilotSettingsResult(result);
7162
- await refreshAuthenticatedState();
7163
8476
  } catch (error) {
8477
+ restoreAutoPilotSettings(previous);
7164
8478
  state.pushError = error.message || String(error);
7165
8479
  }
7166
8480
  await renderShell();
@@ -7181,11 +8495,23 @@ function bindShellInteractions() {
7181
8495
  if (!payload) {
7182
8496
  return;
7183
8497
  }
8498
+ const previous = snapshotAutoPilotSettings();
8499
+ if (state.session) {
8500
+ if (lane === "content") {
8501
+ state.session.autoPilotWriteLaneContent = true;
8502
+ } else if (lane === "ui_tests") {
8503
+ state.session.autoPilotWriteLaneUiTests = true;
8504
+ } else {
8505
+ state.session.autoPilotWriteLaneSource = true;
8506
+ }
8507
+ reconcileAutoPilotTrustedWrites();
8508
+ }
8509
+ await renderShell();
7184
8510
  try {
7185
8511
  const result = await apiPost("/api/settings/auto-pilot", payload);
7186
8512
  applyAutoPilotSettingsResult(result);
7187
- await refreshAuthenticatedState();
7188
8513
  } catch (error) {
8514
+ restoreAutoPilotSettings(previous);
7189
8515
  state.pushError = error.message || String(error);
7190
8516
  }
7191
8517
  await renderShell();
@@ -7272,6 +8598,166 @@ function bindShellInteractions() {
7272
8598
  });
7273
8599
  }
7274
8600
 
8601
+ // ─── Remote pairing (Phase 5c) ─────────────────────────────────────
8602
+ // Optimistic flips with bridge-side reconciliation. The bridge endpoints
8603
+ // hot-restart the orchestrator, so the post-POST `fetchRemotePairingStatus`
8604
+ // call can briefly observe a transient state — that's fine, the next
8605
+ // render will land on the steady state.
8606
+ for (const details of document.querySelectorAll("[data-remote-pairing-details]")) {
8607
+ details.addEventListener("toggle", () => {
8608
+ state.remotePairingDetailsOpen = details.open === true;
8609
+ });
8610
+ }
8611
+
8612
+ for (const checkbox of document.querySelectorAll("[data-remote-pairing-toggle-checkbox]")) {
8613
+ checkbox.addEventListener("change", async () => {
8614
+ const next = checkbox.checked === true;
8615
+ const previous = state.remotePairingStatus?.enabled === true;
8616
+ if (!next && isRemotePairingUsingRelay() && !window.confirm(L("settings.remotePairing.toggle.offConfirm"))) {
8617
+ checkbox.checked = previous;
8618
+ return;
8619
+ }
8620
+ state.remotePairingNotice = "";
8621
+ state.remotePairingError = "";
8622
+ state.remotePairingPending = "toggle";
8623
+ // Optimistic flip — render once with the new value disabled-during-pending,
8624
+ // then reconcile with the bridge.
8625
+ if (state.remotePairingStatus) {
8626
+ state.remotePairingStatus = { ...state.remotePairingStatus, enabled: next };
8627
+ } else {
8628
+ state.remotePairingStatus = {
8629
+ enabled: next,
8630
+ relayUrl: "",
8631
+ configuredRelayUrl: "",
8632
+ identityFingerprint: null,
8633
+ sessions: [],
8634
+ pairings: [],
8635
+ auditEvents: [],
8636
+ };
8637
+ }
8638
+ await renderShell();
8639
+ try {
8640
+ await apiPost("/api/remote-pairing/toggle", { enabled: next });
8641
+ state.remotePairingNotice = next
8642
+ ? L("settings.remotePairing.notice.toggleOn")
8643
+ : L("settings.remotePairing.notice.toggleOff");
8644
+ await fetchRemotePairingStatus();
8645
+ } catch (error) {
8646
+ // Roll back to the previous value so the checkbox visually reverts.
8647
+ if (state.remotePairingStatus) {
8648
+ state.remotePairingStatus = { ...state.remotePairingStatus, enabled: previous };
8649
+ }
8650
+ state.remotePairingError = L("settings.remotePairing.error.toggleFailed");
8651
+ } finally {
8652
+ state.remotePairingPending = "";
8653
+ await renderShell();
8654
+ }
8655
+ });
8656
+ }
8657
+
8658
+ for (const button of document.querySelectorAll("[data-remote-pairing-relay-url-save]")) {
8659
+ button.addEventListener("click", async () => {
8660
+ // The input lives on the same page; querySelector grabs the (single)
8661
+ // visible field. If we ever add multiple URL editors we'd need to
8662
+ // scope this to a container — but right now there's exactly one.
8663
+ const input = document.querySelector("[data-remote-pairing-relay-url-input]");
8664
+ const trimmed = (input?.value || "").trim();
8665
+ state.remotePairingNotice = "";
8666
+ state.remotePairingError = "";
8667
+ state.remotePairingPending = "relayUrl";
8668
+ await renderShell();
8669
+ try {
8670
+ await apiPost("/api/remote-pairing/relay-url", { relayUrl: trimmed });
8671
+ state.remotePairingNotice = L("settings.remotePairing.notice.relayUrlSaved");
8672
+ await fetchRemotePairingStatus();
8673
+ } catch (error) {
8674
+ // Distinguish the validation error from a generic save failure so
8675
+ // the user knows whether to fix the URL or retry.
8676
+ state.remotePairingError = error?.errorKey === "invalid-relay-url"
8677
+ ? L("settings.remotePairing.error.invalidRelayUrl")
8678
+ : L("settings.remotePairing.error.relayUrlFailed");
8679
+ } finally {
8680
+ state.remotePairingPending = "";
8681
+ await renderShell();
8682
+ }
8683
+ });
8684
+ }
8685
+
8686
+ for (const button of document.querySelectorAll("[data-remote-pairing-rotate-token]")) {
8687
+ button.addEventListener("click", async () => {
8688
+ const local = loadRemotePairingState();
8689
+ if (!local?.phonePub) return;
8690
+ state.remotePairingNotice = "";
8691
+ state.remotePairingError = "";
8692
+ state.remotePairingPending = "rotateToken";
8693
+ await renderShell();
8694
+ try {
8695
+ const response = await apiPost("/api/remote-pairing/rotate-token", { phonePub: local.phonePub });
8696
+ saveRemotePairingState({
8697
+ pairingId: response.pairingId,
8698
+ relayToken: response.relayToken,
8699
+ phonePub: response.phonePub,
8700
+ phoneFingerprint: response.phoneFingerprint,
8701
+ bridgePubHex: response.bridgePubHex,
8702
+ bridgeFingerprint: response.bridgeFingerprint,
8703
+ relayUrl: response.relayUrl,
8704
+ label: response.label || local.label || "",
8705
+ addedAtMs: Number.isFinite(response.addedAtMs) ? response.addedAtMs : local.addedAtMs,
8706
+ relayTokenUpdatedAtMs: Number.isFinite(response.relayTokenUpdatedAtMs)
8707
+ ? response.relayTokenUpdatedAtMs
8708
+ : Date.now(),
8709
+ });
8710
+ state.remotePairingNotice = L("settings.remotePairing.notice.tokenRotated");
8711
+ await fetchRemotePairingStatus();
8712
+ } catch (error) {
8713
+ state.remotePairingError = L("settings.remotePairing.error.tokenRotateFailed");
8714
+ } finally {
8715
+ state.remotePairingPending = "";
8716
+ await renderShell();
8717
+ }
8718
+ });
8719
+ }
8720
+
8721
+ for (const button of document.querySelectorAll("[data-remote-pairing-revoke]")) {
8722
+ button.addEventListener("click", async () => {
8723
+ const phonePub = button.dataset.remotePairingRevoke || "";
8724
+ if (!phonePub) return;
8725
+ if (!window.confirm(L("settings.remotePairing.pairings.revokeConfirm"))) return;
8726
+ state.remotePairingNotice = "";
8727
+ state.remotePairingError = "";
8728
+ // Per-pub pending key so the renderer only disables this card's
8729
+ // button, not every revoke button on the page.
8730
+ state.remotePairingPending = `revoke:${phonePub}`;
8731
+ await renderShell();
8732
+ try {
8733
+ await apiPost("/api/remote-pairing/revoke", { phonePub });
8734
+ // If the revoked phone is THIS device, drop the local enrollment
8735
+ // record too — otherwise the "this device" indicator would keep
8736
+ // claiming the (now-revoked) record is live, and a future fetch
8737
+ // routing layer would try to reuse the stale pairingId. Best
8738
+ // effort: storage failures don't change the revoke outcome.
8739
+ try {
8740
+ const local = loadRemotePairingState();
8741
+ if (
8742
+ local
8743
+ && local.phonePub.toLowerCase() === phonePub.toLowerCase()
8744
+ ) {
8745
+ clearRemotePairingState();
8746
+ }
8747
+ } catch {
8748
+ // ignore — same fall-through as savePairingState
8749
+ }
8750
+ state.remotePairingNotice = L("settings.remotePairing.notice.revoked");
8751
+ await fetchRemotePairingStatus();
8752
+ } catch (error) {
8753
+ state.remotePairingError = L("settings.remotePairing.error.revokeFailed");
8754
+ } finally {
8755
+ state.remotePairingPending = "";
8756
+ await renderShell();
8757
+ }
8758
+ });
8759
+ }
8760
+
7275
8761
 
7276
8762
  for (const button of document.querySelectorAll("[data-hazbase-action]")) {
7277
8763
  button.addEventListener("click", async () => {
@@ -7548,24 +9034,13 @@ for (const button of document.querySelectorAll("[data-wallet-address-copy]")) {
7548
9034
  try {
7549
9035
  const decisionBody = { action: submittedAction, editedText };
7550
9036
  if (editedTitle) decisionBody.editedTitle = editedTitle;
7551
- const res = await fetch(`/api/items/moltbook-draft/${encodeURIComponent(token)}/decision`, {
7552
- method: "POST",
7553
- headers: { "content-type": "application/json" },
7554
- credentials: "same-origin",
7555
- body: JSON.stringify(decisionBody),
7556
- });
7557
- if (!res.ok) {
7558
- const errBody = await res.json().catch(() => ({}));
7559
- alert(`Moltbook draft ${submittedAction} failed: ${errBody.error || res.status}`);
7560
- buttons.forEach((btn) => {
7561
- btn.disabled = false;
7562
- btn.classList.remove("is-loading", "is-dimmed");
7563
- btn.innerHTML = labelCache.get(btn);
7564
- });
7565
- if (textarea) textarea.readOnly = false;
7566
- moltbookDraftForm.dataset.submitting = "";
7567
- return;
7568
- }
9037
+ // apiPost routes through LAN-first / relay-fallback and throws on
9038
+ // !ok — the existing catch below handles both transport and HTTP
9039
+ // failures uniformly.
9040
+ await apiPost(
9041
+ `/api/items/moltbook-draft/${encodeURIComponent(token)}/decision`,
9042
+ decisionBody,
9043
+ );
7569
9044
  // Mark local detail as resolved so re-render shows "already resolved" immediately.
7570
9045
  if (state.currentDetail?.kind === "moltbook_draft") {
7571
9046
  state.currentDetail.moltbookDraftEnabled = false;
@@ -7574,7 +9049,7 @@ for (const button of document.querySelectorAll("[data-wallet-address-copy]")) {
7574
9049
  await refreshAuthenticatedState();
7575
9050
  await renderShell();
7576
9051
  } catch (error) {
7577
- alert(`Moltbook draft error: ${error.message}`);
9052
+ alert(`Moltbook draft ${submittedAction} failed: ${error.message}`);
7578
9053
  buttons.forEach((btn) => {
7579
9054
  btn.disabled = false;
7580
9055
  btn.classList.remove("is-loading", "is-dimmed");
@@ -7625,24 +9100,10 @@ for (const button of document.querySelectorAll("[data-wallet-address-copy]")) {
7625
9100
  try {
7626
9101
  const decisionBody = { action: submittedAction, instruction };
7627
9102
  if (executor) decisionBody.executor = executor;
7628
- const res = await fetch(`/api/items/a2a-task/${encodeURIComponent(token)}/decision`, {
7629
- method: "POST",
7630
- headers: { "content-type": "application/json" },
7631
- credentials: "same-origin",
7632
- body: JSON.stringify(decisionBody),
7633
- });
7634
- if (!res.ok) {
7635
- const errBody = await res.json().catch(() => ({}));
7636
- alert(`A2A task ${submittedAction} failed: ${errBody.error || res.status}`);
7637
- buttons.forEach((btn) => {
7638
- btn.disabled = false;
7639
- btn.classList.remove("is-loading", "is-dimmed");
7640
- btn.innerHTML = labelCache.get(btn);
7641
- });
7642
- if (textarea) textarea.readOnly = false;
7643
- a2aTaskForm.dataset.submitting = "";
7644
- return;
7645
- }
9103
+ await apiPost(
9104
+ `/api/items/a2a-task/${encodeURIComponent(token)}/decision`,
9105
+ decisionBody,
9106
+ );
7646
9107
  if (state.currentDetail?.kind === "a2a_task") {
7647
9108
  state.currentDetail.a2aTaskEnabled = false;
7648
9109
  state.currentDetail.readOnly = true;
@@ -7650,7 +9111,7 @@ for (const button of document.querySelectorAll("[data-wallet-address-copy]")) {
7650
9111
  await refreshAuthenticatedState();
7651
9112
  await renderShell();
7652
9113
  } catch (error) {
7653
- alert(`A2A task error: ${error.message}`);
9114
+ alert(`A2A task ${submittedAction} failed: ${error.message}`);
7654
9115
  buttons.forEach((btn) => {
7655
9116
  btn.disabled = false;
7656
9117
  btn.classList.remove("is-loading", "is-dimmed");
@@ -7691,24 +9152,10 @@ for (const button of document.querySelectorAll("[data-wallet-address-copy]")) {
7691
9152
  if (textarea) textarea.readOnly = true;
7692
9153
  const editedContent = normalizeClientText(new FormData(threadShareForm).get("shareContent"));
7693
9154
  try {
7694
- const res = await fetch(`/api/threads/share/${encodeURIComponent(token)}/decision`, {
7695
- method: "POST",
7696
- headers: { "content-type": "application/json" },
7697
- credentials: "same-origin",
7698
- body: JSON.stringify({ decision: submittedAction, editedContent }),
7699
- });
7700
- if (!res.ok) {
7701
- const errBody = await res.json().catch(() => ({}));
7702
- alert(`Thread share ${submittedAction} failed: ${errBody.error || res.status}`);
7703
- buttons.forEach((btn) => {
7704
- btn.disabled = false;
7705
- btn.classList.remove("is-loading", "is-dimmed");
7706
- btn.innerHTML = labelCache.get(btn);
7707
- });
7708
- if (textarea) textarea.readOnly = false;
7709
- threadShareForm.dataset.submitting = "";
7710
- return;
7711
- }
9155
+ await apiPost(
9156
+ `/api/threads/share/${encodeURIComponent(token)}/decision`,
9157
+ { decision: submittedAction, editedContent },
9158
+ );
7712
9159
  if (state.currentDetail?.kind === "thread_share") {
7713
9160
  state.currentDetail.threadShareEnabled = false;
7714
9161
  state.currentDetail.readOnly = true;
@@ -7716,7 +9163,7 @@ for (const button of document.querySelectorAll("[data-wallet-address-copy]")) {
7716
9163
  await refreshAuthenticatedState();
7717
9164
  await renderShell();
7718
9165
  } catch (error) {
7719
- alert(`Thread share error: ${error.message}`);
9166
+ alert(`Thread share ${submittedAction} failed: ${error.message}`);
7720
9167
  buttons.forEach((btn) => {
7721
9168
  btn.disabled = false;
7722
9169
  btn.classList.remove("is-loading", "is-dimmed");
@@ -7775,14 +9222,22 @@ for (const button of document.querySelectorAll("[data-wallet-address-copy]")) {
7775
9222
  await renderShell();
7776
9223
 
7777
9224
  try {
7778
- const requestBody = new FormData();
7779
- requestBody.set("text", text);
7780
- requestBody.set("planMode", draft.mode === "plan" ? "true" : "false");
7781
- requestBody.set("force", draft.confirmOverride === true ? "true" : "false");
7782
- for (const attachment of draft.attachments || []) {
7783
- if (attachment?.file) {
7784
- requestBody.append("image", attachment.file, attachment.name || attachment.file.name);
7785
- }
9225
+ const attachments = (draft.attachments || []).filter((attachment) => attachment?.file);
9226
+ let requestBody;
9227
+ if (attachments.length > 0) {
9228
+ requestBody = new FormData();
9229
+ requestBody.set("text", text);
9230
+ requestBody.set("planMode", draft.mode === "plan" ? "true" : "false");
9231
+ requestBody.set("force", draft.confirmOverride === true ? "true" : "false");
9232
+ } else {
9233
+ requestBody = {
9234
+ text,
9235
+ planMode: draft.mode === "plan",
9236
+ force: draft.confirmOverride === true,
9237
+ };
9238
+ }
9239
+ for (const attachment of attachments) {
9240
+ requestBody.append("image", attachment.file, attachment.name || attachment.file.name);
7786
9241
  }
7787
9242
  const replyKind = replyForm.dataset.replyKind || "completion";
7788
9243
  await apiPost(`/api/items/${encodeURIComponent(replyKind)}/${encodeURIComponent(token)}/reply`, requestBody);
@@ -7941,6 +9396,13 @@ function bindSharedUi(renderFn) {
7941
9396
  });
7942
9397
  }
7943
9398
 
9399
+ for (const button of document.querySelectorAll("[data-force-app-refresh]")) {
9400
+ button.addEventListener("click", async () => {
9401
+ await forceAppRefreshFromLan();
9402
+ await renderFn();
9403
+ });
9404
+ }
9405
+
7944
9406
  for (const button of document.querySelectorAll("[data-install-guide-close]")) {
7945
9407
  button.addEventListener("click", async (event) => {
7946
9408
  if (button.classList.contains("modal-backdrop")) {
@@ -8608,6 +10070,10 @@ function renderIcon(name) {
8608
10070
  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>`;
8609
10071
  case "language":
8610
10072
  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>`;
10073
+ case "agent-network":
10074
+ 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>`;
10075
+ case "remote-connection":
10076
+ 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>`;
8611
10077
  case "link":
8612
10078
  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>`;
8613
10079
  case "clip":
@@ -8758,13 +10224,138 @@ function handleDocumentVisibilityChange() {
8758
10224
  handlePotentialExternalNavigation();
8759
10225
  }
8760
10226
 
8761
- async function apiGet(url) {
8762
- const response = await fetch(url, {
10227
+ async function hydrateTimelinePayloadImages(payload) {
10228
+ if (!payload || !Array.isArray(payload.entries)) {
10229
+ return payload;
10230
+ }
10231
+
10232
+ const entries = await Promise.all(
10233
+ payload.entries.map(async (entry) => hydrateItemImageUrls(entry))
10234
+ );
10235
+ return { ...payload, entries };
10236
+ }
10237
+
10238
+ async function hydrateDetailImages(detail) {
10239
+ if (!detail || !Array.isArray(detail.imageUrls)) {
10240
+ return detail;
10241
+ }
10242
+ return hydrateItemImageUrls(detail);
10243
+ }
10244
+
10245
+ async function hydrateItemImageUrls(item) {
10246
+ const imageUrls = Array.isArray(item?.imageUrls) ? item.imageUrls.filter(Boolean) : [];
10247
+ if (imageUrls.length === 0) {
10248
+ return item;
10249
+ }
10250
+
10251
+ const hydratedUrls = await Promise.all(imageUrls.map((url) => routedTimelineImageUrl(url)));
10252
+ return {
10253
+ ...item,
10254
+ imageUrls: hydratedUrls.filter(Boolean),
10255
+ };
10256
+ }
10257
+
10258
+ async function routedTimelineImageUrl(imageUrl) {
10259
+ const sourceUrl = normalizeClientText(imageUrl);
10260
+ if (!sourceUrl || /^(?:blob:|data:)/iu.test(sourceUrl)) {
10261
+ return sourceUrl;
10262
+ }
10263
+
10264
+ const existing = timelineImageObjectUrlCache.get(sourceUrl);
10265
+ if (existing?.objectUrl) {
10266
+ existing.lastUsedMs = Date.now();
10267
+ return existing.objectUrl;
10268
+ }
10269
+
10270
+ try {
10271
+ const response = await routedFetch(sourceUrl, {
10272
+ credentials: "same-origin",
10273
+ headers: {
10274
+ Accept: "image/*",
10275
+ },
10276
+ });
10277
+ if (!response.ok) {
10278
+ return unavailableTimelineImageDataUrl();
10279
+ }
10280
+ const arrayBuffer = await response.arrayBuffer();
10281
+ const contentType = responseHeader(response.headers, "content-type") || "application/octet-stream";
10282
+ const objectUrl = URL.createObjectURL(new Blob([arrayBuffer], { type: contentType }));
10283
+ timelineImageObjectUrlCache.set(sourceUrl, {
10284
+ objectUrl,
10285
+ lastUsedMs: Date.now(),
10286
+ });
10287
+ pruneTimelineImageObjectUrlCache();
10288
+ return objectUrl;
10289
+ } catch {
10290
+ return unavailableTimelineImageDataUrl();
10291
+ }
10292
+ }
10293
+
10294
+ function unavailableTimelineImageDataUrl() {
10295
+ const title = L("detail.imageUnavailable");
10296
+ const subtitle = L("detail.imageUnavailableHint");
10297
+ const svg = `
10298
+ <svg xmlns="http://www.w3.org/2000/svg" width="640" height="640" viewBox="0 0 640 640">
10299
+ <defs>
10300
+ <linearGradient id="bg" x1="0" y1="0" x2="1" y2="1">
10301
+ <stop offset="0" stop-color="#132026"/>
10302
+ <stop offset="1" stop-color="#0a1116"/>
10303
+ </linearGradient>
10304
+ </defs>
10305
+ <rect width="640" height="640" rx="34" fill="url(#bg)"/>
10306
+ <rect x="24" y="24" width="592" height="592" rx="28" fill="none" stroke="#9cb5c5" stroke-opacity=".18" stroke-width="2"/>
10307
+ <circle cx="320" cy="266" r="50" fill="#26343d"/>
10308
+ <path d="M294 266h52M320 240v52" stroke="#d7e5ed" stroke-width="12" stroke-linecap="round" opacity=".78"/>
10309
+ <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>
10310
+ <text x="320" y="410" text-anchor="middle" fill="#9cb5c5" font-family="Avenir Next, Helvetica, Arial, sans-serif" font-size="22">${escapeSvgText(subtitle)}</text>
10311
+ </svg>
10312
+ `.trim();
10313
+ return `data:image/svg+xml;charset=UTF-8,${encodeURIComponent(svg)}`;
10314
+ }
10315
+
10316
+ function escapeSvgText(value) {
10317
+ return normalizeClientText(value)
10318
+ .replace(/&/gu, "&amp;")
10319
+ .replace(/</gu, "&lt;")
10320
+ .replace(/>/gu, "&gt;");
10321
+ }
10322
+
10323
+ function responseHeader(headers, name) {
10324
+ const key = String(name || "").toLowerCase();
10325
+ if (!headers || !key) {
10326
+ return "";
10327
+ }
10328
+ if (typeof headers.get === "function") {
10329
+ return headers.get(key) || "";
10330
+ }
10331
+ return headers[key] || headers[name] || "";
10332
+ }
10333
+
10334
+ function pruneTimelineImageObjectUrlCache() {
10335
+ if (timelineImageObjectUrlCache.size <= MAX_TIMELINE_IMAGE_OBJECT_URLS) {
10336
+ return;
10337
+ }
10338
+ const staleEntries = [...timelineImageObjectUrlCache.entries()]
10339
+ .sort((left, right) => Number(left[1]?.lastUsedMs || 0) - Number(right[1]?.lastUsedMs || 0))
10340
+ .slice(0, Math.max(0, timelineImageObjectUrlCache.size - MAX_TIMELINE_IMAGE_OBJECT_URLS));
10341
+ for (const [sourceUrl, entry] of staleEntries) {
10342
+ if (entry?.objectUrl && typeof URL !== "undefined" && typeof URL.revokeObjectURL === "function") {
10343
+ URL.revokeObjectURL(entry.objectUrl);
10344
+ }
10345
+ timelineImageObjectUrlCache.delete(sourceUrl);
10346
+ }
10347
+ }
10348
+
10349
+ async function apiGet(url, opts = {}) {
10350
+ // routedFetch tries LAN first, then falls back to the relay tunnel when
10351
+ // the phone is off-LAN. Returns a fetch-Response-compatible object so the
10352
+ // rest of this function is identical to a plain `fetch()` call.
10353
+ const response = await routedFetch(url, {
8763
10354
  credentials: "same-origin",
8764
10355
  headers: {
8765
10356
  Accept: "application/json",
8766
10357
  },
8767
- });
10358
+ }, opts);
8768
10359
  if (!response.ok) {
8769
10360
  const errorInfo = await readError(response);
8770
10361
  const error = new Error(errorInfo.message);
@@ -8778,7 +10369,9 @@ async function apiGet(url) {
8778
10369
 
8779
10370
  async function apiPost(url, body) {
8780
10371
  const isFormDataBody = typeof FormData !== "undefined" && body instanceof FormData;
8781
- const response = await fetch(url, {
10372
+ // Keep native FormData for LAN; routedFetch serializes it to multipart
10373
+ // bytes only when the request has to travel through the remote relay.
10374
+ const response = await routedFetch(url, {
8782
10375
  method: "POST",
8783
10376
  credentials: "same-origin",
8784
10377
  headers: isFormDataBody