viveworker 0.7.0 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (34) hide show
  1. package/README.md +33 -4
  2. package/package.json +7 -3
  3. package/scripts/lib/remote-pairing/README.md +164 -0
  4. package/scripts/lib/remote-pairing/_browser-bundle-entry.mjs +86 -0
  5. package/scripts/lib/remote-pairing/audit.mjs +122 -0
  6. package/scripts/lib/remote-pairing/bridge-relay-client.mjs +737 -0
  7. package/scripts/lib/remote-pairing/control.mjs +156 -0
  8. package/scripts/lib/remote-pairing/envelope.mjs +224 -0
  9. package/scripts/lib/remote-pairing/http-dispatch.mjs +530 -0
  10. package/scripts/lib/remote-pairing/keys-core.mjs +110 -0
  11. package/scripts/lib/remote-pairing/keys.mjs +181 -0
  12. package/scripts/lib/remote-pairing/noise.mjs +436 -0
  13. package/scripts/lib/remote-pairing/orchestrator.mjs +356 -0
  14. package/scripts/lib/remote-pairing/pairings.mjs +446 -0
  15. package/scripts/lib/remote-pairing/rpc.mjs +381 -0
  16. package/scripts/moltbook-scout-auto.sh +16 -8
  17. package/scripts/share-cli.mjs +14 -130
  18. package/scripts/viveworker-bridge.mjs +1324 -103
  19. package/scripts/viveworker.mjs +27 -6
  20. package/web/app.css +634 -9
  21. package/web/app.js +1731 -187
  22. package/web/i18n.js +187 -9
  23. package/web/index.html +32 -2
  24. package/web/remote-pairing/api-router.js +873 -0
  25. package/web/remote-pairing/keys.js +237 -0
  26. package/web/remote-pairing/pairing-state.js +313 -0
  27. package/web/remote-pairing/rpc-client.js +765 -0
  28. package/web/remote-pairing/transport.js +804 -0
  29. package/web/remote-pairing/wake.js +149 -0
  30. package/web/remote-pairing-test.html +400 -0
  31. package/web/remote-pairing.bundle.js +3 -0
  32. package/web/remote-pairing.bundle.js.LEGAL.txt +15 -0
  33. package/web/remote-pairing.bundle.js.map +7 -0
  34. package/web/sw.js +190 -20
package/web/app.js CHANGED
@@ -1,6 +1,14 @@
1
1
  import { DEFAULT_LOCALE, SUPPORTED_LOCALES, localeDisplayName, normalizeLocale, resolveLocalePreference, t } from "./i18n.js";
2
+ import { ensureIdentityKeypair, bytesToHex } from "./remote-pairing/keys.js";
3
+ import {
4
+ loadPairingState as loadRemotePairingState,
5
+ savePairingState as saveRemotePairingState,
6
+ clearPairingState as clearRemotePairingState,
7
+ } from "./remote-pairing/pairing-state.js";
8
+ import { getRoutingTelemetry, routedFetch } from "./remote-pairing/api-router.js?v=20260428-remote-token-refresh";
2
9
 
3
10
  const DESKTOP_BREAKPOINT = 980;
11
+ const APP_BUILD_ID = "20260428-remote-token-refresh";
4
12
  const INSTALL_BANNER_DISMISS_KEY = "viveworker-install-banner-dismissed-v2";
5
13
  const PUSH_BANNER_DISMISS_KEY = "viveworker-push-banner-dismissed-v1";
6
14
  const INITIAL_DETECTED_LOCALE = detectBrowserLocale();
@@ -12,6 +20,24 @@ const THREAD_FILTER_INTERACTION_DEFER_MS = 8000;
12
20
  const MAX_COMPLETION_REPLY_IMAGE_COUNT = 4;
13
21
  const NOTIFICATION_INTENT_CACHE = "viveworker-notification-intent-v1";
14
22
  const NOTIFICATION_INTENT_PATH = "/__viveworker_notification_intent__";
23
+ const MAX_TIMELINE_IMAGE_OBJECT_URLS = 80;
24
+ const REMOTE_PAIRING_STATE_STORAGE_KEY = "viveworker.remote-pairing.state";
25
+ const REMOTE_PAIRING_STATE_SCHEMA_VERSION = 2;
26
+ const REMOTE_PAIRING_STATE_LEGACY_SCHEMA_VERSION = 1;
27
+ const BOOT_SPLASH_SLOW_HINT_MS = 10000;
28
+ const BOOT_SPLASH_REMOTE_SWITCHING_MIN_MS = 650;
29
+ const BOOTSTRAP_REMOTE_TIMEOUT_MS = 12_000;
30
+ const REMOTE_PAIRING_TOKEN_REFRESH_MS = 30 * 24 * 60 * 60 * 1000;
31
+ const BOOT_TRACE_MAX_EVENTS = 90;
32
+ const BOOT_TRACE_MAX_VALUE_LENGTH = 120;
33
+ const BOOT_SPLASH_STAGE = Object.freeze({
34
+ initial: -1,
35
+ checking: 0,
36
+ switching: 1,
37
+ establishing: 2,
38
+ loading: 3,
39
+ });
40
+ const timelineImageObjectUrlCache = new Map();
15
41
 
16
42
  const state = {
17
43
  session: null,
@@ -51,6 +77,7 @@ const state = {
51
77
  detailDiffExpanded: {},
52
78
  choiceLocalDrafts: {},
53
79
  completionReplyDrafts: {},
80
+ completionReplySheetToken: "",
54
81
  pendingActionUrls: new Set(),
55
82
  pairError: "",
56
83
  pairNotice: "",
@@ -60,6 +87,21 @@ const state = {
60
87
  a2aRelayStatus: null,
61
88
  a2aShareStatus: null,
62
89
  a2aShareRecentExpanded: 0,
90
+ // Remote-pairing relay snapshot — { enabled, relayUrl, configuredRelayUrl,
91
+ // identityFingerprint, sessions: [...], pairings: [...] } | null.
92
+ // Populated by fetchRemotePairingStatus() on settings page open and after
93
+ // toggle/revoke actions. Null until first fetch completes.
94
+ remotePairingStatus: null,
95
+ // Notice / error flashes for the remote-pairing settings page (consumed
96
+ // and cleared after a single render, mirroring `pushNotice` / `pushError`
97
+ // from the push UI).
98
+ remotePairingNotice: "",
99
+ remotePairingError: "",
100
+ // Pending action key for in-flight toggle / revoke / relay-url save —
101
+ // used to disable buttons while the round-trip is in progress so a fast
102
+ // double-click doesn't fire two POSTs.
103
+ remotePairingPending: "",
104
+ remotePairingDetailsOpen: false,
63
105
  hazbaseStatus: null,
64
106
  hazbaseNotice: "",
65
107
  hazbaseError: "",
@@ -106,6 +148,9 @@ const state = {
106
148
  defaultLocale: DEFAULT_LOCALE,
107
149
  supportedLocales: [...SUPPORTED_LOCALES],
108
150
  appVersion: "",
151
+ appBuildId: APP_BUILD_ID,
152
+ serverAppBuildId: "",
153
+ clientUpdateRequired: false,
109
154
  versionStatus: null,
110
155
  versionStatusError: "",
111
156
  };
@@ -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
  }
@@ -1176,9 +1729,16 @@ function renderPair() {
1176
1729
  event.preventDefault();
1177
1730
  const form = new FormData(event.currentTarget);
1178
1731
  try {
1179
- await pair({ code: String(form.get("code") || "") });
1732
+ const pairResult = await pair({ code: String(form.get("code") || "") });
1180
1733
  state.pairError = "";
1181
1734
  state.pairNotice = "";
1735
+ // Best-effort: register this phone's X25519 pubkey with the bridge
1736
+ // so it can reach us via the relay later. Doesn't block the shell
1737
+ // render; failures (older bridge, IndexedDB blocked, …) are swallowed
1738
+ // inside the helper.
1739
+ if (pairResult?.temporaryPairing !== true) {
1740
+ await enrollRemotePairing();
1741
+ }
1182
1742
  await refreshSession();
1183
1743
  await refreshAuthenticatedState();
1184
1744
  await renderShell();
@@ -1200,6 +1760,80 @@ async function pair(payload) {
1200
1760
  return result;
1201
1761
  }
1202
1762
 
1763
+ /**
1764
+ * Best-effort post-pair enrollment: hand the bridge our X25519 static
1765
+ * pubkey + a friendly label, persist the bridge's response in localStorage,
1766
+ * and return the saved record (or null on any failure).
1767
+ *
1768
+ * Always called after a successful LAN pair (regardless of whether the
1769
+ * remote relay is currently ON). Reasoning:
1770
+ *
1771
+ * - Enrollment is idempotent on phonePub server-side; re-pairing the same
1772
+ * phone keeps its existing pairingId and bridge identity.
1773
+ * - Doing it eagerly means flipping the relay toggle later "just works"
1774
+ * without forcing the user to repair-on-LAN to register their key.
1775
+ * - LAN-only basic auth keeps working even if this fails (e.g. older
1776
+ * bridge predating the lan-enroll endpoint, IndexedDB blocked, etc.) —
1777
+ * we explicitly swallow errors and let the caller continue rendering
1778
+ * the authenticated shell.
1779
+ *
1780
+ * Skipped when the surrounding `pair()` returned `temporaryPairing: true`
1781
+ * — temporary sessions are an opt-in shape that doesn't outlive the tab,
1782
+ * so persisting a pairing record would be misleading.
1783
+ *
1784
+ * @param {string} [label] user-visible device label; defaults to a
1785
+ * "LAN paired YYYY-MM-DD" stamp.
1786
+ * @returns {Promise<import("./remote-pairing/pairing-state.js").RemotePairingState | null>}
1787
+ */
1788
+ async function enrollRemotePairing(label) {
1789
+ try {
1790
+ const keypair = await ensureIdentityKeypair();
1791
+ const phonePubHex = bytesToHex(keypair.pub);
1792
+ const effectiveLabel = (label && String(label).trim()) || buildDefaultEnrollLabel();
1793
+ const response = await apiPost("/api/remote-pairing/lan-enroll", {
1794
+ phonePubHex,
1795
+ label: effectiveLabel,
1796
+ });
1797
+ if (!response || response.ok !== true) {
1798
+ return null;
1799
+ }
1800
+ const saved = saveRemotePairingState({
1801
+ pairingId: response.pairingId,
1802
+ relayToken: response.relayToken,
1803
+ phonePub: response.phonePub,
1804
+ phoneFingerprint: response.phoneFingerprint,
1805
+ bridgePubHex: response.bridgePubHex,
1806
+ bridgeFingerprint: response.bridgeFingerprint,
1807
+ relayUrl: response.relayUrl,
1808
+ label: response.label || "",
1809
+ addedAtMs: Number.isFinite(response.addedAtMs) ? response.addedAtMs : Date.now(),
1810
+ relayTokenUpdatedAtMs: Number.isFinite(response.relayTokenUpdatedAtMs)
1811
+ ? response.relayTokenUpdatedAtMs
1812
+ : Date.now(),
1813
+ });
1814
+ if (!saved) {
1815
+ // Storage is full / disabled; the bridge still has the pairing,
1816
+ // so future toggle-on works — just no localStorage cache for the
1817
+ // PWA's own routing decisions.
1818
+ return null;
1819
+ }
1820
+ return loadRemotePairingState();
1821
+ } catch (error) {
1822
+ console.warn("[remote-pairing] lan-enroll skipped:", error?.message || error);
1823
+ return null;
1824
+ }
1825
+ }
1826
+
1827
+ function buildDefaultEnrollLabel() {
1828
+ // YYYY-MM-DD so re-enrolls produce stable, sortable labels in the
1829
+ // settings list. (The user can rename in the settings page later.)
1830
+ const now = new Date();
1831
+ const y = now.getFullYear();
1832
+ const m = String(now.getMonth() + 1).padStart(2, "0");
1833
+ const d = String(now.getDate()).padStart(2, "0");
1834
+ return `LAN paired ${y}-${m}-${d}`;
1835
+ }
1836
+
1203
1837
  async function logout({ revokeCurrentDeviceTrust = false } = {}) {
1204
1838
  await apiPost("/api/session/logout", { revokeCurrentDeviceTrust });
1205
1839
  resetAuthenticatedState();
@@ -1227,6 +1861,7 @@ function resetAuthenticatedState() {
1227
1861
  state.choiceLocalDrafts = {};
1228
1862
  clearAllCompletionReplyDrafts();
1229
1863
  state.completionReplyDrafts = {};
1864
+ state.completionReplySheetToken = "";
1230
1865
  state.settingsSubpage = "";
1231
1866
  state.settingsScrollState = null;
1232
1867
  state.listScrollState = null;
@@ -1238,6 +1873,7 @@ function resetAuthenticatedState() {
1238
1873
  state.deviceError = "";
1239
1874
  state.logoutConfirmOpen = false;
1240
1875
  state.pairError = "";
1876
+ state.remotePairingDetailsOpen = false;
1241
1877
  }
1242
1878
 
1243
1879
  async function revokeTrustedDevice(deviceId) {
@@ -1259,6 +1895,7 @@ async function revokeTrustedDevice(deviceId) {
1259
1895
  }
1260
1896
 
1261
1897
  async function renderShell() {
1898
+ syncVisualViewportMetrics();
1262
1899
  const desktop = isDesktopLayout();
1263
1900
  const shouldShowDetail = state.currentTab !== "settings" && state.currentItem && (desktop || state.detailOpen);
1264
1901
  let detail = null;
@@ -1291,9 +1928,13 @@ async function renderShell() {
1291
1928
  ${renderLogoutConfirmModal()}
1292
1929
  ${renderHazbaseLogoutConfirmModal()}
1293
1930
  </div>
1931
+ ${!desktop && detail ? renderCompletionReplySheet(detail) : ""}
1294
1932
  `;
1295
1933
 
1296
1934
  bindShellInteractions();
1935
+ if (state.completionReplySheetToken) {
1936
+ resetHorizontalViewportScroll();
1937
+ }
1297
1938
  applyPendingDetailScrollReset();
1298
1939
  applyPendingListScrollRestore();
1299
1940
  applyPendingSettingsSubpageScrollReset();
@@ -1361,6 +2002,9 @@ function clearThreadFilterInteraction() {
1361
2002
 
1362
2003
  function shouldDeferRenderForActiveInteraction() {
1363
2004
  const activeElement = document.activeElement;
2005
+ if (state.completionReplySheetToken) {
2006
+ return true;
2007
+ }
1364
2008
  if (
1365
2009
  activeElement instanceof HTMLTextAreaElement &&
1366
2010
  activeElement.matches("[data-completion-reply-textarea]") &&
@@ -1728,7 +2372,6 @@ function renderMobileTopBar(detail) {
1728
2372
  return `
1729
2373
  <header class="mobile-topbar">
1730
2374
  <div class="mobile-topbar__heading">
1731
- <span class="eyebrow-pill eyebrow-pill--quiet">${escapeHtml(L("common.appName"))}</span>
1732
2375
  <h1 class="mobile-topbar__title">${escapeHtml(meta.title)}</h1>
1733
2376
  </div>
1734
2377
  </header>
@@ -1783,7 +2426,9 @@ async function fetchCurrentDetailForItem(itemRef = state.currentItem) {
1783
2426
  return state.detailOverride.detail;
1784
2427
  }
1785
2428
  try {
1786
- const detail = await apiGet(`/api/items/${encodeURIComponent(itemRef.kind)}/${encodeURIComponent(itemRef.token)}`);
2429
+ const detail = await hydrateDetailImages(
2430
+ await apiGet(`/api/items/${encodeURIComponent(itemRef.kind)}/${encodeURIComponent(itemRef.token)}`)
2431
+ );
1787
2432
  if (hasLaunchItemIntent(itemRef)) {
1788
2433
  state.launchItemIntent.status = "loaded";
1789
2434
  }
@@ -1797,7 +2442,9 @@ async function fetchCurrentDetailForItem(itemRef = state.currentItem) {
1797
2442
  }
1798
2443
  await refreshInbox();
1799
2444
  try {
1800
- const detail = await apiGet(`/api/items/${encodeURIComponent(itemRef.kind)}/${encodeURIComponent(itemRef.token)}`);
2445
+ const detail = await hydrateDetailImages(
2446
+ await apiGet(`/api/items/${encodeURIComponent(itemRef.kind)}/${encodeURIComponent(itemRef.token)}`)
2447
+ );
1801
2448
  if (hasLaunchItemIntent(itemRef)) {
1802
2449
  state.launchItemIntent.status = "loaded";
1803
2450
  }
@@ -3230,7 +3877,7 @@ function buildSettingsContext() {
3230
3877
  standalone,
3231
3878
  supportsPushValue,
3232
3879
  permission,
3233
- subscribed: push.serverSubscribed === true,
3880
+ subscribed: push.subscribed === true,
3234
3881
  });
3235
3882
 
3236
3883
  return {
@@ -3256,6 +3903,7 @@ function buildSettingsContext() {
3256
3903
  a2aRelay: state.a2aRelayStatus,
3257
3904
  a2aShare: state.a2aShareStatus,
3258
3905
  hazbase: state.hazbaseStatus,
3906
+ remotePairing: state.remotePairingStatus,
3259
3907
  };
3260
3908
  }
3261
3909
 
@@ -3358,6 +4006,195 @@ function collectSettingsDiagnostics({ permission, secureContext, standalone, sup
3358
4006
  return Array.from(new Set(issues.filter(Boolean)));
3359
4007
  }
3360
4008
 
4009
+ function hasAutoPilotWriteLaneEnabled(session = state.session) {
4010
+ return Boolean(
4011
+ session?.autoPilotTrustedWrites === true ||
4012
+ session?.autoPilotWriteLaneContent === true ||
4013
+ session?.autoPilotWriteLaneUiTests === true ||
4014
+ session?.autoPilotWriteLaneSource === true,
4015
+ );
4016
+ }
4017
+
4018
+ function hasAutoPilotEnabled(session = state.session) {
4019
+ return Boolean(session?.autoPilotTrustedReads === true || hasAutoPilotWriteLaneEnabled(session));
4020
+ }
4021
+
4022
+ function settingsEnabledValue(enabled) {
4023
+ return enabled ? L("common.enabled") : L("common.disabled");
4024
+ }
4025
+
4026
+ function settingsNeedsActionValue() {
4027
+ return L("settings.status.actionNeeded");
4028
+ }
4029
+
4030
+ function settingsDisconnectedValue() {
4031
+ return L("settings.status.disconnected");
4032
+ }
4033
+
4034
+ function settingsSupportedValue(supported) {
4035
+ return supported ? L("settings.status.supported") : L("settings.status.unsupported");
4036
+ }
4037
+
4038
+ function settingsInstalledValue(installed) {
4039
+ return installed ? L("settings.status.installed") : L("settings.status.notInstalled");
4040
+ }
4041
+
4042
+ function settingsNotificationRootValue(context) {
4043
+ if (context.push?.serverSubscribed === true) {
4044
+ return L("common.enabled");
4045
+ }
4046
+ if (context.push?.clientSubscribed === true) {
4047
+ return settingsNeedsActionValue();
4048
+ }
4049
+ if (context.permission === "denied") {
4050
+ return settingsNeedsActionValue();
4051
+ }
4052
+ return L("common.disabled");
4053
+ }
4054
+
4055
+ function notificationPermissionValue(permission) {
4056
+ const key = `settings.permission.${permission || "default"}`;
4057
+ const translated = L(key);
4058
+ return translated === key ? String(permission || "") : translated;
4059
+ }
4060
+
4061
+ function notificationReceiveValue(push) {
4062
+ if (push?.serverSubscribed === true) {
4063
+ return L("common.enabled");
4064
+ }
4065
+ if (push?.clientSubscribed === true) {
4066
+ return L("settings.status.actionNeeded");
4067
+ }
4068
+ return L("common.disabled");
4069
+ }
4070
+
4071
+ function notificationOverallValue(context) {
4072
+ if (context.push?.serverSubscribed === true) {
4073
+ return L("common.enabled");
4074
+ }
4075
+ if (context.permission === "denied") {
4076
+ return L("settings.status.blocked");
4077
+ }
4078
+ if (!context.serverEnabled) {
4079
+ return L("settings.status.notAvailable");
4080
+ }
4081
+ if (!context.supportsPushValue) {
4082
+ return L("settings.status.unsupported");
4083
+ }
4084
+ if (!context.secureContext || !context.standalone || context.permission !== "granted") {
4085
+ return L("settings.status.actionNeeded");
4086
+ }
4087
+ return L("common.disabled");
4088
+ }
4089
+
4090
+ function settingsMoltbookRootValue(context) {
4091
+ return context.moltbookScout?.enabled === true
4092
+ ? L("common.enabled")
4093
+ : settingsNeedsActionValue();
4094
+ }
4095
+
4096
+ function settingsA2aRelayRootValue(context) {
4097
+ const relay = context.a2aRelay;
4098
+ if (relay?.enabled === true && relay?.connected === true) {
4099
+ return L("common.enabled");
4100
+ }
4101
+ return settingsDisconnectedValue();
4102
+ }
4103
+
4104
+ function settingsA2aShareRootValue(context) {
4105
+ const share = context.a2aShare;
4106
+ if (share?.enabled === true && !share?.error) {
4107
+ return L("common.enabled");
4108
+ }
4109
+ return settingsNeedsActionValue();
4110
+ }
4111
+
4112
+ function settingsWalletRootValue(context) {
4113
+ if (context.hazbase?.enabled !== true) {
4114
+ return L("common.disabled");
4115
+ }
4116
+ return deriveHazbaseWalletFlow(context.hazbase).coreReady
4117
+ ? L("common.enabled")
4118
+ : settingsNeedsActionValue();
4119
+ }
4120
+
4121
+ function isRemotePairingSessionConnected(session) {
4122
+ const value = normalizeClientText(session?.state).toLowerCase();
4123
+ return value === "connected" || value === "open" || value === "running";
4124
+ }
4125
+
4126
+ function isRemotePairingSessionReachable(session) {
4127
+ const value = normalizeClientText(session?.state).toLowerCase();
4128
+ return value === "connected" || value === "open" || value === "running" || value === "opening" || value === "handshaking" || value === "resuming";
4129
+ }
4130
+
4131
+ function remotePairingStatusModel(status, opts = {}) {
4132
+ const enabled = status?.enabled === true;
4133
+ const sessions = Array.isArray(status?.sessions) ? status.sessions : [];
4134
+ const pairings = Array.isArray(status?.pairings) ? status.pairings : [];
4135
+ const usingRelay = opts.usingRelay === true;
4136
+ if (!enabled) {
4137
+ return { key: "disabled", tone: "muted", label: L("settings.remotePairing.status.disabled") };
4138
+ }
4139
+ if (usingRelay && sessions.some(isRemotePairingSessionConnected)) {
4140
+ return { key: "connected", tone: "success", label: L("settings.remotePairing.status.connected") };
4141
+ }
4142
+ if (sessions.some(isRemotePairingSessionReachable)) {
4143
+ return { key: "available", tone: "success", label: L("settings.remotePairing.status.available") };
4144
+ }
4145
+ if (pairings.length > 0) {
4146
+ return { key: "waiting", tone: "warning", label: L("settings.remotePairing.status.waiting") };
4147
+ }
4148
+ return { key: "waiting", tone: "muted", label: L("settings.remotePairing.status.waiting") };
4149
+ }
4150
+
4151
+ function remotePairingAuditTypeLabel(type) {
4152
+ const key = `settings.remotePairing.audit.type.${type || "unknown"}`;
4153
+ const translated = L(key);
4154
+ return translated === key ? L("settings.remotePairing.audit.type.unknown") : translated;
4155
+ }
4156
+
4157
+ function remotePairingAuditOutcomeTone(outcome) {
4158
+ if (outcome === "success") return "success";
4159
+ if (outcome === "failure") return "danger";
4160
+ return "muted";
4161
+ }
4162
+
4163
+ function remotePairingAuditMeta(event) {
4164
+ return [
4165
+ event?.label,
4166
+ event?.phoneFingerprint,
4167
+ event?.relayHost,
4168
+ event?.reason,
4169
+ ].filter(Boolean).join(" / ");
4170
+ }
4171
+
4172
+ function renderRemotePairingAudit(events) {
4173
+ const list = Array.isArray(events) ? events.slice(0, 10) : [];
4174
+ if (!list.length) {
4175
+ return `<div class="settings-copy-block"><p class="muted">${escapeHtml(L("settings.remotePairing.audit.empty"))}</p></div>`;
4176
+ }
4177
+ return `
4178
+ <div class="settings-remote-audit-list">
4179
+ ${list.map((event) => {
4180
+ const tone = remotePairingAuditOutcomeTone(event?.outcome);
4181
+ const title = remotePairingAuditTypeLabel(event?.type);
4182
+ const meta = remotePairingAuditMeta(event);
4183
+ return `
4184
+ <article class="settings-remote-audit-item">
4185
+ <span class="settings-remote-audit-dot settings-remote-audit-dot--${escapeHtml(tone)}" aria-hidden="true"></span>
4186
+ <div class="settings-remote-audit-body">
4187
+ <p class="settings-remote-audit-title">${escapeHtml(title)}</p>
4188
+ ${meta ? `<p class="settings-remote-audit-meta">${escapeHtml(meta)}</p>` : ""}
4189
+ </div>
4190
+ <time class="settings-remote-audit-time">${escapeHtml(formatSettingsTimestamp(event?.atMs))}</time>
4191
+ </article>
4192
+ `;
4193
+ }).join("")}
4194
+ </div>
4195
+ `;
4196
+ }
4197
+
3361
4198
  function settingsPageMeta(page) {
3362
4199
  switch (page) {
3363
4200
  case "notifications":
@@ -3421,14 +4258,14 @@ function settingsPageMeta(page) {
3421
4258
  id: "a2aRelay",
3422
4259
  title: L("settings.a2aRelay.title"),
3423
4260
  description: L("settings.a2aRelay.copy"),
3424
- icon: "link",
4261
+ icon: "agent-network",
3425
4262
  };
3426
4263
  case "a2aShare":
3427
4264
  return {
3428
4265
  id: "a2aShare",
3429
4266
  title: L("settings.a2aShare.title"),
3430
4267
  description: L("settings.a2aShare.copy"),
3431
- icon: "link",
4268
+ icon: "file-event",
3432
4269
  };
3433
4270
  case "wallet":
3434
4271
  return {
@@ -3437,6 +4274,13 @@ function settingsPageMeta(page) {
3437
4274
  description: L("settings.wallet.copy"),
3438
4275
  icon: "coin",
3439
4276
  };
4277
+ case "remotePairing":
4278
+ return {
4279
+ id: "remotePairing",
4280
+ title: L("settings.remotePairing.title"),
4281
+ description: L("settings.remotePairing.copy"),
4282
+ icon: "remote-connection",
4283
+ };
3440
4284
  case "a2aExecutor":
3441
4285
  // Executor settings integrated into a2aRelay page — redirect.
3442
4286
  return settingsPageMeta("a2aRelay");
@@ -3452,7 +4296,7 @@ function renderSettingsRoot(context, { mobile }) {
3452
4296
  page: "notifications",
3453
4297
  icon: "notifications",
3454
4298
  title: L("settings.notifications.title"),
3455
- value: L(context.setupState.notifications.labelKey),
4299
+ value: settingsNotificationRootValue(context),
3456
4300
  }),
3457
4301
  renderSettingsNavRow({
3458
4302
  page: "language",
@@ -3465,23 +4309,20 @@ function renderSettingsRoot(context, { mobile }) {
3465
4309
  page: "install",
3466
4310
  icon: "homescreen",
3467
4311
  title: L("settings.install.title"),
3468
- value: L(context.setupState.install.labelKey),
4312
+ value: settingsEnabledValue(context.standalone),
3469
4313
  })
3470
4314
  : "",
3471
4315
  renderSettingsNavRow({
3472
4316
  page: "awayMode",
3473
4317
  icon: "settings",
3474
4318
  title: L("settings.awayMode.title"),
3475
- value: state.session?.claudeAwayMode === true ? L("settings.claudeAway.on") : L("settings.claudeAway.off"),
4319
+ value: settingsEnabledValue(state.session?.claudeAwayMode === true),
3476
4320
  }),
3477
4321
  renderSettingsNavRow({
3478
4322
  page: "autoPilot",
3479
4323
  icon: "approval",
3480
4324
  title: L("settings.autoPilot.title"),
3481
- value:
3482
- state.session?.autoPilotTrustedReads === true || state.session?.autoPilotTrustedWrites === true
3483
- ? L("common.enabled")
3484
- : L("common.disabled"),
4325
+ value: settingsEnabledValue(hasAutoPilotEnabled()),
3485
4326
  }),
3486
4327
  ].filter(Boolean);
3487
4328
  const deviceRows = [
@@ -3493,6 +4334,12 @@ function renderSettingsRoot(context, { mobile }) {
3493
4334
  ? L("settings.device.count", { count: context.devices.length })
3494
4335
  : L("settings.pairing.connected"),
3495
4336
  }),
4337
+ state.session?.remotePairingAvailable ? renderSettingsNavRow({
4338
+ page: "remotePairing",
4339
+ icon: "remote-connection",
4340
+ title: L("settings.remotePairing.title"),
4341
+ value: settingsEnabledValue(context.remotePairing?.enabled === true),
4342
+ }) : "",
3496
4343
  ];
3497
4344
  const advancedRows = [
3498
4345
  renderSettingsNavRow({
@@ -3523,32 +4370,26 @@ function renderSettingsRoot(context, { mobile }) {
3523
4370
  page: "moltbook",
3524
4371
  icon: "item",
3525
4372
  title: L("settings.moltbook.title"),
3526
- subtitle: L("settings.moltbook.subtitle"),
3527
- value: context.moltbookScout?.enabled ? L("settings.status.enabled") : L("settings.status.disabled"),
4373
+ value: settingsMoltbookRootValue(context),
3528
4374
  }) : "",
3529
4375
  state.session?.a2aRelayEnabled ? renderSettingsNavRow({
3530
4376
  page: "a2aRelay",
3531
- icon: "link",
4377
+ icon: "agent-network",
3532
4378
  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"),
4379
+ value: settingsA2aRelayRootValue(context),
3535
4380
  }) : "",
3536
4381
  state.session?.a2aShareEnabled ? renderSettingsNavRow({
3537
4382
  page: "a2aShare",
3538
- icon: "link",
4383
+ icon: "file-event",
3539
4384
  title: L("settings.a2aShare.title"),
3540
- subtitle: L("settings.a2aShare.subtitle"),
3541
- value: context.a2aShare?.enabled ? L("settings.status.enabled") : L("settings.status.disabled"),
4385
+ value: settingsA2aShareRootValue(context),
3542
4386
  }) : "",
3543
4387
  context.hazbase?.enabled ? renderSettingsNavRow({
3544
4388
  page: "wallet",
3545
4389
  icon: "coin",
3546
4390
  title: L("settings.wallet.title"),
3547
4391
  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"),
4392
+ value: settingsWalletRootValue(context),
3552
4393
  }) : "",
3553
4394
  ].filter(Boolean)) : ""}
3554
4395
  ${renderSettingsGroup(L("settings.pairing.title"), deviceRows)}
@@ -3610,6 +4451,9 @@ function renderSettingsSubpage(context, { mobile }) {
3610
4451
  case "wallet":
3611
4452
  content = renderSettingsWalletPage(context);
3612
4453
  break;
4454
+ case "remotePairing":
4455
+ content = renderSettingsRemotePairingPage(context);
4456
+ break;
3613
4457
  default:
3614
4458
  content = "";
3615
4459
  }
@@ -3624,11 +4468,14 @@ function renderSettingsSubpage(context, { mobile }) {
3624
4468
  }
3625
4469
 
3626
4470
  function renderSettingsNotificationsPage(context) {
3627
- const { push, permission, secureContext, standalone, supportsPushValue, serverEnabled } = context;
4471
+ const { push, permission, standalone } = context;
4472
+ const notificationEnabled = push.serverSubscribed === true;
3628
4473
  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")),
4474
+ renderSettingsInfoRow(L("settings.row.status"), notificationOverallValue(context)),
4475
+ renderSettingsInfoRow(L("settings.row.notificationPermission"), notificationPermissionValue(permission), {
4476
+ valueTone: permission === "granted" ? "enabled" : permission === "denied" ? "attention" : "disabled",
4477
+ }),
4478
+ renderSettingsInfoRow(L("settings.row.currentDeviceSubscribed"), notificationReceiveValue(push)),
3632
4479
  push.lastSuccessfulDeliveryAtMs
3633
4480
  ? renderSettingsInfoRow(
3634
4481
  L("settings.row.lastSuccessfulDelivery"),
@@ -3639,12 +4486,6 @@ function renderSettingsNotificationsPage(context) {
3639
4486
  return `
3640
4487
  <div class="settings-page">
3641
4488
  ${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
4489
  ${state.pushNotice ? `<p class="inline-alert inline-alert--success">${escapeHtml(state.pushNotice)}</p>` : ""}
3649
4490
  ${state.pushError ? `<p class="inline-alert inline-alert--danger">${escapeHtml(state.pushError)}</p>` : ""}
3650
4491
  ${renderSettingsActionPanel(renderSettingsNotificationActions({
@@ -3737,17 +4578,13 @@ function renderSettingsAdvancedPage(context) {
3737
4578
  ${context.diagnostics.map((message) => `<p class="inline-alert">${escapeHtml(message)}</p>`).join("")}
3738
4579
  ${renderSettingsGroup("", [
3739
4580
  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
- : "",
4581
+ renderSettingsInfoRow(L("settings.row.secureContext"), context.secureContext ? L("common.enabled") : L("common.disabled")),
4582
+ renderSettingsInfoRow(L("settings.row.homeScreenApp"), settingsInstalledValue(context.standalone), {
4583
+ valueTone: context.standalone ? "enabled" : "disabled",
4584
+ }),
4585
+ renderSettingsInfoRow(L("settings.row.browserSupport"), settingsSupportedValue(context.supportsPushValue), {
4586
+ valueTone: context.supportsPushValue ? "enabled" : "disabled",
4587
+ }),
3751
4588
  renderSettingsInfoRow(L("settings.row.version"), state.appVersion || L("common.unavailable")),
3752
4589
  ].filter(Boolean), { listClassName: "settings-list settings-list--compact" })}
3753
4590
  ${versionNotice}
@@ -3807,7 +4644,37 @@ function renderSettingsGroup(title, rows, options = {}) {
3807
4644
  `;
3808
4645
  }
3809
4646
 
3810
- function renderSettingsNavRow({ page, icon, title, badge, subtitle, value }) {
4647
+ function settingsNavValueTone(value, explicitTone = "") {
4648
+ if (explicitTone) {
4649
+ return explicitTone;
4650
+ }
4651
+ const text = String(value || "").trim();
4652
+ if (!text) {
4653
+ return "";
4654
+ }
4655
+ if (text === L("common.enabled") || text === L("settings.status.enabled")) {
4656
+ return "enabled";
4657
+ }
4658
+ if (text === L("common.disabled") || text === L("settings.status.disabled")) {
4659
+ return "disabled";
4660
+ }
4661
+ if (text === L("settings.status.actionNeeded") || text === L("settings.status.blocked")) {
4662
+ return "attention";
4663
+ }
4664
+ if (text === L("settings.status.supported") || text === L("settings.status.installed")) {
4665
+ return "enabled";
4666
+ }
4667
+ if (text === L("settings.status.disconnected")) {
4668
+ return "disconnected";
4669
+ }
4670
+ return "";
4671
+ }
4672
+
4673
+ function renderSettingsNavRow({ page, icon, title, badge, subtitle, value, valueTone }) {
4674
+ const tone = settingsNavValueTone(value, valueTone);
4675
+ const valueClass = tone
4676
+ ? `settings-row__value settings-row__value--${escapeHtml(tone)}`
4677
+ : "settings-row__value";
3811
4678
  return `
3812
4679
  <button class="settings-nav-row" type="button" data-settings-subpage="${escapeHtml(page)}">
3813
4680
  <span class="settings-row__icon" aria-hidden="true">${renderIcon(icon)}</span>
@@ -3818,7 +4685,7 @@ function renderSettingsNavRow({ page, icon, title, badge, subtitle, value }) {
3818
4685
  </span>
3819
4686
  ${subtitle ? `<span class="settings-row__subtitle">${escapeHtml(subtitle)}</span>` : ""}
3820
4687
  </span>
3821
- <span class="settings-row__value">${escapeHtml(value || "")}</span>
4688
+ <span class="${valueClass}">${escapeHtml(value || "")}</span>
3822
4689
  <span class="settings-row__chevron" aria-hidden="true">${renderIcon("chevron-right")}</span>
3823
4690
  </button>
3824
4691
  `;
@@ -3830,16 +4697,18 @@ function renderSettingsAwayModePage() {
3830
4697
  return `
3831
4698
  <div class="settings-page">
3832
4699
  ${renderSettingsGroup("", [`
3833
- <label class="reply-mode-switch" data-claude-away-toggle>
4700
+ <label class="reply-mode-switch reply-mode-switch--settings" data-claude-away-toggle>
3834
4701
  <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
4702
  <span class="reply-mode-switch__copy">
3837
4703
  <span class="reply-mode-switch__title">
3838
4704
  <span>${escapeHtml(L("settings.claudeAway.title"))}</span>
3839
- <span class="reply-mode-switch__state">${escapeHtml(stateLabel)}</span>
3840
4705
  </span>
3841
4706
  <span class="reply-mode-switch__hint">${escapeHtml(L("settings.claudeAway.description"))}</span>
3842
4707
  </span>
4708
+ <span class="reply-mode-switch--settings__toggle">
4709
+ <span class="reply-mode-switch__track" aria-hidden="true"><span class="reply-mode-switch__thumb"></span></span>
4710
+ <span class="reply-mode-switch__state">${escapeHtml(stateLabel)}</span>
4711
+ </span>
3843
4712
  </label>
3844
4713
  `])}
3845
4714
  <p class="settings-page-copy muted">${escapeHtml(L("settings.awayMode.codexNote"))}</p>
@@ -3853,24 +4722,25 @@ function renderSettingsAutoPilotPage() {
3853
4722
  const writeLaneContentEnabled = state.session?.autoPilotWriteLaneContent === true;
3854
4723
  const writeLaneUiTestsEnabled = state.session?.autoPilotWriteLaneUiTests === true;
3855
4724
  const writeLaneSourceEnabled = state.session?.autoPilotWriteLaneSource === true;
3856
- const trustedWritesEnabled =
3857
- writeLaneContentEnabled || writeLaneUiTestsEnabled || writeLaneSourceEnabled || state.session?.autoPilotTrustedWrites === true;
4725
+ const trustedWritesEnabled = hasAutoPilotWriteLaneEnabled();
3858
4726
  const trustedWritesStateLabel = trustedWritesEnabled ? L("common.enabled") : L("common.disabled");
3859
4727
  const recentEntries = recentAutoPilotEntries();
3860
4728
  const suggestions = recentAutoPilotSuggestions();
3861
4729
  return `
3862
4730
  <div class="settings-page">
3863
4731
  ${renderSettingsGroup("", [`
3864
- <label class="reply-mode-switch reply-mode-switch--grouped" data-auto-pilot-toggle>
4732
+ <label class="reply-mode-switch reply-mode-switch--settings reply-mode-switch--grouped" data-auto-pilot-toggle>
3865
4733
  <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
4734
  <span class="reply-mode-switch__copy">
3868
4735
  <span class="reply-mode-switch__title">
3869
4736
  <span>${escapeHtml(L("settings.autoPilot.trustedReadsTitle"))}</span>
3870
- <span class="reply-mode-switch__state">${escapeHtml(trustedReadsStateLabel)}</span>
3871
4737
  </span>
3872
4738
  <span class="reply-mode-switch__hint">${escapeHtml(L("settings.autoPilot.trustedReadsDescription"))}</span>
3873
4739
  </span>
4740
+ <span class="reply-mode-switch--settings__toggle">
4741
+ <span class="reply-mode-switch__track" aria-hidden="true"><span class="reply-mode-switch__thumb"></span></span>
4742
+ <span class="reply-mode-switch__state">${escapeHtml(trustedReadsStateLabel)}</span>
4743
+ </span>
3874
4744
  </label>
3875
4745
  `, `
3876
4746
  <div class="settings-toggle-subhead" role="presentation">
@@ -3880,40 +4750,46 @@ function renderSettingsAutoPilotPage() {
3880
4750
  `, `
3881
4751
  <div class="settings-toggle-subcopy muted">${escapeHtml(L("settings.autoPilot.trustedWritesDescription"))}</div>
3882
4752
  `, `
3883
- <label class="reply-mode-switch reply-mode-switch--grouped" data-auto-pilot-write-lane="content">
4753
+ <label class="reply-mode-switch reply-mode-switch--settings reply-mode-switch--grouped" data-auto-pilot-write-lane="content">
3884
4754
  <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
4755
  <span class="reply-mode-switch__copy">
3887
4756
  <span class="reply-mode-switch__title">
3888
4757
  <span>${escapeHtml(L("settings.autoPilot.writeLaneContentTitle"))}</span>
3889
- <span class="reply-mode-switch__state">${escapeHtml(writeLaneContentEnabled ? L("common.enabled") : L("common.disabled"))}</span>
3890
4758
  </span>
3891
4759
  <span class="reply-mode-switch__hint">${escapeHtml(L("settings.autoPilot.writeLaneContentDescription"))}</span>
3892
4760
  </span>
4761
+ <span class="reply-mode-switch--settings__toggle">
4762
+ <span class="reply-mode-switch__track" aria-hidden="true"><span class="reply-mode-switch__thumb"></span></span>
4763
+ <span class="reply-mode-switch__state">${escapeHtml(writeLaneContentEnabled ? L("common.enabled") : L("common.disabled"))}</span>
4764
+ </span>
3893
4765
  </label>
3894
4766
  `, `
3895
- <label class="reply-mode-switch reply-mode-switch--grouped" data-auto-pilot-write-lane="ui-tests">
4767
+ <label class="reply-mode-switch reply-mode-switch--settings reply-mode-switch--grouped" data-auto-pilot-write-lane="ui-tests">
3896
4768
  <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
4769
  <span class="reply-mode-switch__copy">
3899
4770
  <span class="reply-mode-switch__title">
3900
4771
  <span>${escapeHtml(L("settings.autoPilot.writeLaneUiTestsTitle"))}</span>
3901
- <span class="reply-mode-switch__state">${escapeHtml(writeLaneUiTestsEnabled ? L("common.enabled") : L("common.disabled"))}</span>
3902
4772
  </span>
3903
4773
  <span class="reply-mode-switch__hint">${escapeHtml(L("settings.autoPilot.writeLaneUiTestsDescription"))}</span>
3904
4774
  </span>
4775
+ <span class="reply-mode-switch--settings__toggle">
4776
+ <span class="reply-mode-switch__track" aria-hidden="true"><span class="reply-mode-switch__thumb"></span></span>
4777
+ <span class="reply-mode-switch__state">${escapeHtml(writeLaneUiTestsEnabled ? L("common.enabled") : L("common.disabled"))}</span>
4778
+ </span>
3905
4779
  </label>
3906
4780
  `, `
3907
- <label class="reply-mode-switch reply-mode-switch--grouped" data-auto-pilot-write-lane="source">
4781
+ <label class="reply-mode-switch reply-mode-switch--settings reply-mode-switch--grouped" data-auto-pilot-write-lane="source">
3908
4782
  <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
4783
  <span class="reply-mode-switch__copy">
3911
4784
  <span class="reply-mode-switch__title">
3912
4785
  <span>${escapeHtml(L("settings.autoPilot.writeLaneSourceTitle"))}</span>
3913
- <span class="reply-mode-switch__state">${escapeHtml(writeLaneSourceEnabled ? L("common.enabled") : L("common.disabled"))}</span>
3914
4786
  </span>
3915
4787
  <span class="reply-mode-switch__hint">${escapeHtml(L("settings.autoPilot.writeLaneSourceDescription"))}</span>
3916
4788
  </span>
4789
+ <span class="reply-mode-switch--settings__toggle">
4790
+ <span class="reply-mode-switch__track" aria-hidden="true"><span class="reply-mode-switch__thumb"></span></span>
4791
+ <span class="reply-mode-switch__state">${escapeHtml(writeLaneSourceEnabled ? L("common.enabled") : L("common.disabled"))}</span>
4792
+ </span>
3917
4793
  </label>
3918
4794
  `], { listClassName: "settings-list settings-list--toggle-group" })}
3919
4795
  <p class="settings-page-copy muted">${escapeHtml(L("settings.autoPilot.scopeNote"))}</p>
@@ -4537,33 +5413,290 @@ function renderSettingsA2aSharePage(context) {
4537
5413
  ${meta ? `<span class="settings-compose-entry__meta muted">${meta}</span>` : ""}
4538
5414
  </span>
4539
5415
  </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
- }
5416
+ `;
5417
+ });
5418
+ if (hasMore) {
5419
+ const remaining = items.length - visibleCount;
5420
+ filesList.push(
5421
+ `<button type="button" class="settings-compose-more" data-a2a-share-files-more>${escapeHtml(L("settings.a2aShare.showMore", { count: remaining }))}</button>`
5422
+ );
5423
+ } else if (items.length > PAGE_SIZE) {
5424
+ filesList.push(
5425
+ `<button type="button" class="settings-compose-more" data-a2a-share-files-collapse>${escapeHtml(L("settings.a2aShare.showLess"))}</button>`
5426
+ );
5427
+ }
5428
+
5429
+ return `
5430
+ <div class="settings-page">
5431
+ ${renderSettingsGroup("", [
5432
+ renderSettingsInfoRow(L("settings.row.a2aShareStatus"), statusLabel),
5433
+ renderSettingsInfoRow(L("settings.row.a2aShareEndpoint"), share.shareHost || share.shareUrl || ""),
5434
+ renderSettingsInfoRow(L("settings.row.a2aShareUserId"), share.userId || ""),
5435
+ ])}
5436
+ ${renderSettingsGroup(L("settings.a2aShare.storage.title"), storageRows)}
5437
+ ${items.length
5438
+ ? renderSettingsGroup(L("settings.a2aShare.files.title"), filesList)
5439
+ : renderSettingsGroup(L("settings.a2aShare.files.title"), [
5440
+ `<p class="settings-group__description muted">${escapeHtml(L("settings.a2aShare.files.empty"))}</p>`,
5441
+ ])}
5442
+ ${share.error ? `<p class="settings-page-copy muted">${escapeHtml(L("settings.a2aShare.error", { reason: share.error }))}</p>` : ""}
5443
+ </div>
5444
+ `;
5445
+ }
5446
+
5447
+ function renderSettingsRemotePairingPage(context) {
5448
+ // Always render — remote pairing is a feature toggle, not a credential-
5449
+ // gated integration. If the bridge hasn't responded with a status payload
5450
+ // yet, fall back to a default-disabled view so the toggle still works.
5451
+ const status = context.remotePairing || {
5452
+ enabled: false,
5453
+ relayUrl: "",
5454
+ configuredRelayUrl: "",
5455
+ identityFingerprint: null,
5456
+ sessions: [],
5457
+ pairings: [],
5458
+ auditEvents: [],
5459
+ };
5460
+
5461
+ const enabled = status.enabled === true;
5462
+ const sessions = Array.isArray(status.sessions) ? status.sessions : [];
5463
+ const pairings = Array.isArray(status.pairings) ? status.pairings : [];
5464
+ const auditEvents = Array.isArray(status.auditEvents) ? status.auditEvents : [];
5465
+ const usingRelay = isRemotePairingUsingRelay();
5466
+ const statusModel = remotePairingStatusModel(status, { usingRelay });
5467
+ const sessionsByPub = new Map(
5468
+ sessions.map((s) => [String(s.phonePub || "").toLowerCase(), s]),
5469
+ );
5470
+ const sessionsByPairingId = new Map(
5471
+ sessions.map((s) => [String(s.pairingId || ""), s]),
5472
+ );
5473
+ const togglePending = state.remotePairingPending === "toggle";
5474
+ const relayPending = state.remotePairingPending === "relayUrl";
5475
+
5476
+ // "This device" — the locally-stored enrollment record (if any). Read
5477
+ // synchronously from localStorage on each render so the badge shows up
5478
+ // immediately after a successful lan-enroll without waiting for state
5479
+ // refresh. Returns null in storage-disabled contexts (Safari private
5480
+ // mode etc.) — the page just renders without the indicator.
5481
+ const localPairing = (() => {
5482
+ try {
5483
+ return loadRemotePairingState();
5484
+ } catch {
5485
+ return null;
5486
+ }
5487
+ })();
5488
+ const localPhonePub = (localPairing?.phonePub || "").toLowerCase();
5489
+ const localRegistered = Boolean(localPairing);
5490
+
5491
+ // Identity fingerprint row — shows the bridge's stable public key short-
5492
+ // form so the human can verify what the phone is pairing against.
5493
+ const fpRow = renderSettingsInfoRow(
5494
+ L("settings.remotePairing.identity.fingerprint"),
5495
+ status.identityFingerprint || L("settings.remotePairing.identity.empty"),
5496
+ );
5497
+
5498
+ const connectionSection = `
5499
+ <section class="settings-remote-connection">
5500
+ <header class="settings-remote-connection__header">
5501
+ <div class="settings-remote-connection__copy">
5502
+ <p class="settings-remote-connection__title">${escapeHtml(L("settings.remotePairing.connection.title"))}</p>
5503
+ </div>
5504
+ <span class="settings-remote-status settings-remote-status--${escapeHtml(statusModel.tone)}">${escapeHtml(statusModel.label)}</span>
5505
+ </header>
5506
+ <label class="reply-mode-switch reply-mode-switch--settings" data-remote-pairing-toggle>
5507
+ <input type="checkbox" class="reply-mode-switch__input"
5508
+ ${enabled ? "checked" : ""}
5509
+ ${togglePending ? "disabled" : ""}
5510
+ data-remote-pairing-toggle-checkbox />
5511
+ <span class="reply-mode-switch--settings__toggle">
5512
+ <span class="reply-mode-switch__track" aria-hidden="true"><span class="reply-mode-switch__thumb"></span></span>
5513
+ <span class="reply-mode-switch__state">${escapeHtml(enabled ? L("settings.claudeAway.on") : L("settings.claudeAway.off"))}</span>
5514
+ </span>
5515
+ <span class="reply-mode-switch__hint">${escapeHtml(L("settings.remotePairing.toggle.description"))}</span>
5516
+ </label>
5517
+ <p class="settings-remote-connection__trust">${escapeHtml(L("settings.remotePairing.security.copy"))}</p>
5518
+ </section>
5519
+ `;
5520
+
5521
+ // Relay URL editor. When empty, the bridge falls back to the compiled-in
5522
+ // default — surfacing that here would require leaking it to the client,
5523
+ // so we just show "" and lean on the placeholder.
5524
+ const relayUrlValue = String(status.configuredRelayUrl || "");
5525
+ const relayUrlSection = `
5526
+ <section class="settings-group">
5527
+ <p class="settings-group__title">${escapeHtml(L("settings.remotePairing.relayUrl.title"))}</p>
5528
+ <div class="settings-input-row">
5529
+ <input type="text"
5530
+ class="settings-input"
5531
+ data-remote-pairing-relay-url-input
5532
+ value="${escapeHtml(relayUrlValue)}"
5533
+ placeholder="${escapeHtml(L("settings.remotePairing.relayUrl.placeholder"))}"
5534
+ ${relayPending ? "disabled" : ""}
5535
+ autocomplete="off"
5536
+ spellcheck="false" />
5537
+ <button type="button"
5538
+ class="secondary"
5539
+ data-remote-pairing-relay-url-save
5540
+ ${relayPending ? "disabled" : ""}>
5541
+ ${escapeHtml(L("settings.remotePairing.relayUrl.save"))}
5542
+ </button>
5543
+ </div>
5544
+ <p class="settings-group__description">${escapeHtml(L("settings.remotePairing.relayUrl.help"))}</p>
5545
+ </section>
5546
+ `;
5547
+
5548
+ // Pairings list — one card per phonePub on disk. "Live" badge if there's
5549
+ // an open session; otherwise show the last-seen timestamp. The phone
5550
+ // currently rendering this page gets an extra "This device" badge so the
5551
+ // user can identify themselves in the list (especially useful when more
5552
+ // than one phone is paired to the same Mac).
5553
+ const otherPairings = localPhonePub
5554
+ ? pairings.filter((p) => String(p.phonePub || "").toLowerCase() !== localPhonePub)
5555
+ : pairings;
5556
+ const pairingsRows = otherPairings.length === 0
5557
+ ? `<div class="settings-copy-block"><p class="muted">${escapeHtml(L("settings.remotePairing.pairings.empty"))}</p></div>`
5558
+ : `<div class="device-list">
5559
+ ${otherPairings.map((p) => {
5560
+ const session = sessionsByPub.get(String(p.phonePub || "").toLowerCase())
5561
+ || sessionsByPairingId.get(String(p.pairingId || ""));
5562
+ const live = isRemotePairingSessionConnected(session);
5563
+ const lastSeenAtMs = Number(session?.lastSeenAtMs || p.lastSeenAtMs) || 0;
5564
+ const lastSeen = lastSeenAtMs
5565
+ ? new Date(lastSeenAtMs).toLocaleString(state.locale)
5566
+ : L("settings.remotePairing.pairings.never");
5567
+ const added = p.addedAtMs
5568
+ ? new Date(p.addedAtMs).toLocaleString(state.locale)
5569
+ : L("settings.remotePairing.pairings.never");
5570
+ const pendingThis = state.remotePairingPending === `revoke:${p.phonePub}`;
5571
+ return `
5572
+ <article class="device-card" data-remote-pairing-row="${escapeHtml(p.phonePub)}">
5573
+ <div class="device-card__header">
5574
+ <div class="device-card__title-wrap">
5575
+ <div class="device-card__headline">
5576
+ <span class="device-card__icon" aria-hidden="true">${renderIcon("iphone")}</span>
5577
+ <h3 class="device-card__title">${escapeHtml(p.label || p.phoneFingerprint || p.pairingId)}</h3>
5578
+ </div>
5579
+ <p class="device-card__subtitle">${escapeHtml(p.phoneFingerprint || p.phonePub.slice(0, 16))}</p>
5580
+ </div>
5581
+ <div class="device-card__badges">
5582
+ <span class="device-card__badge ${live ? "device-card__badge--live" : "device-card__badge--offline"}">
5583
+ ${escapeHtml(live ? L("settings.remotePairing.pairings.live") : L("settings.remotePairing.pairings.offline"))}
5584
+ </span>
5585
+ </div>
5586
+ </div>
5587
+ <div class="device-card__meta">
5588
+ ${renderDeviceMetaRow(L("settings.remotePairing.pairings.added"), added)}
5589
+ ${renderDeviceMetaRow(L("settings.remotePairing.pairings.lastSeen"), lastSeen)}
5590
+ </div>
5591
+ <div class="device-card__actions">
5592
+ <button type="button"
5593
+ class="secondary secondary--wide"
5594
+ data-remote-pairing-revoke="${escapeHtml(p.phonePub)}"
5595
+ ${pendingThis ? "disabled" : ""}>
5596
+ ${escapeHtml(L("settings.remotePairing.pairings.revoke"))}
5597
+ </button>
5598
+ </div>
5599
+ </article>
5600
+ `;
5601
+ }).join("")}
5602
+ </div>`;
5603
+
5604
+ // "This device" group — sits above the pairings list and tells the user
5605
+ // explicitly whether *this phone* (the one rendering the page) is the
5606
+ // enrolled one. Two states:
5607
+ // 1. Enrolled: show the phone fingerprint + a confirmation copy.
5608
+ // 2. Not enrolled: surface a hint that re-pairing on LAN registers
5609
+ // the device. (The hint applies even when other phones are paired
5610
+ // — they're not relevant to this device's relay status.)
5611
+ const thisDeviceSection = (() => {
5612
+ if (localRegistered) {
5613
+ const fingerprint = localPairing.phoneFingerprint
5614
+ || localPairing.phonePub.slice(0, 16);
5615
+ const rotatePending = state.remotePairingPending === "rotateToken";
5616
+ return renderSettingsGroup(L("settings.remotePairing.thisDevice.title"), [
5617
+ `
5618
+ <div class="device-list">
5619
+ <article class="device-card">
5620
+ <div class="device-card__header">
5621
+ <div class="device-card__title-wrap">
5622
+ <div class="device-card__headline">
5623
+ <span class="device-card__icon" aria-hidden="true">${renderIcon("iphone")}</span>
5624
+ <h3 class="device-card__title">${escapeHtml(L("settings.remotePairing.thisDevice.registeredTitle"))}</h3>
5625
+ </div>
5626
+ <p class="device-card__subtitle">${escapeHtml(fingerprint)}</p>
5627
+ </div>
5628
+ <span class="device-card__badge">${escapeHtml(L("settings.remotePairing.pairings.thisDevice"))}</span>
5629
+ </div>
5630
+ <div class="device-card__meta">
5631
+ ${renderDeviceMetaRow(L("settings.remotePairing.status.title"), statusModel.label)}
5632
+ ${renderDeviceMetaRow(L("settings.remotePairing.thisDevice.fingerprint"), fingerprint)}
5633
+ </div>
5634
+ <div class="device-card__actions">
5635
+ <button type="button"
5636
+ class="secondary secondary--wide"
5637
+ data-remote-pairing-rotate-token
5638
+ ${rotatePending || usingRelay ? "disabled" : ""}>
5639
+ ${escapeHtml(L("settings.remotePairing.token.rotate"))}
5640
+ </button>
5641
+ </div>
5642
+ ${usingRelay ? `<p class="settings-page-copy muted settings-remote-device-hint">${escapeHtml(L("settings.remotePairing.token.lanOnly"))}</p>` : ""}
5643
+ </article>
5644
+ </div>
5645
+ `,
5646
+ ]);
5647
+ }
5648
+ return renderSettingsGroup(L("settings.remotePairing.thisDevice.title"), [
5649
+ `
5650
+ <div class="device-list">
5651
+ <article class="device-card">
5652
+ <div class="device-card__header">
5653
+ <div class="device-card__title-wrap">
5654
+ <div class="device-card__headline">
5655
+ <span class="device-card__icon" aria-hidden="true">${renderIcon("iphone")}</span>
5656
+ <h3 class="device-card__title">${escapeHtml(L("settings.remotePairing.thisDevice.notEnrolledTitle"))}</h3>
5657
+ </div>
5658
+ </div>
5659
+ </div>
5660
+ <div class="device-card__meta">
5661
+ ${renderDeviceMetaRow(L("settings.remotePairing.status.title"), L("settings.remotePairing.status.disabled"))}
5662
+ ${renderDeviceMetaRow(L("settings.remotePairing.thisDevice.fingerprint"), L("common.unavailable"))}
5663
+ </div>
5664
+ <p class="settings-page-copy muted settings-remote-device-hint">${escapeHtml(L("settings.remotePairing.thisDevice.notEnrolled"))}</p>
5665
+ </article>
5666
+ </div>
5667
+ `,
5668
+ ]);
5669
+ })();
5670
+
5671
+ const detailsSection = `
5672
+ <details class="settings-disclosure settings-remote-details" data-remote-pairing-details ${state.remotePairingDetailsOpen ? "open" : ""}>
5673
+ <summary>${escapeHtml(L("settings.remotePairing.details.title"))}</summary>
5674
+ <div class="settings-disclosure__body">
5675
+ ${relayUrlSection}
5676
+ ${renderSettingsGroup(L("settings.remotePairing.identity.title"), [fpRow])}
5677
+ </div>
5678
+ </details>
5679
+ `;
5680
+ const auditSection = renderSettingsGroup(L("settings.remotePairing.audit.title"), [
5681
+ renderRemotePairingAudit(auditEvents),
5682
+ ]);
5683
+
5684
+ const noticeBlock = state.remotePairingNotice
5685
+ ? `<p class="settings-page-copy">${escapeHtml(state.remotePairingNotice)}</p>`
5686
+ : "";
5687
+ const errorBlock = state.remotePairingError
5688
+ ? `<p class="settings-page-copy danger">${escapeHtml(state.remotePairingError)}</p>`
5689
+ : "";
4552
5690
 
4553
5691
  return `
4554
5692
  <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>` : ""}
5693
+ ${noticeBlock}
5694
+ ${errorBlock}
5695
+ ${connectionSection}
5696
+ ${thisDeviceSection}
5697
+ ${renderSettingsGroup(L("settings.remotePairing.pairings.otherTitle"), [pairingsRows])}
5698
+ ${auditSection}
5699
+ ${detailsSection}
4567
5700
  </div>
4568
5701
  `;
4569
5702
  }
@@ -5030,6 +6163,7 @@ function formatUsdcAtomic(atomic) {
5030
6163
  }
5031
6164
 
5032
6165
  function renderSettingsInfoRow(label, value, options = {}) {
6166
+ const tone = settingsNavValueTone(value, options.valueTone || "");
5033
6167
  const rowClassName = [
5034
6168
  "settings-info-row",
5035
6169
  options.stacked ? "settings-info-row--stacked" : "",
@@ -5037,6 +6171,7 @@ function renderSettingsInfoRow(label, value, options = {}) {
5037
6171
  ].filter(Boolean).join(" ");
5038
6172
  const valueClassName = [
5039
6173
  "settings-info-row__value",
6174
+ tone ? `settings-info-row__value--${tone}` : "",
5040
6175
  options.mono ? "settings-info-row__value--mono" : "",
5041
6176
  options.valueClassName || "",
5042
6177
  ].filter(Boolean).join(" ");
@@ -5083,9 +6218,17 @@ function renderTrustedDeviceCard(device) {
5083
6218
  const badge = device.currentDevice
5084
6219
  ? `<span class="device-card__badge">${escapeHtml(L("settings.device.thisDevice"))}</span>`
5085
6220
  : "";
5086
- const actionLabel = device.currentDevice
5087
- ? L("settings.action.removeThisDevice")
5088
- : L("settings.action.revokeDevice");
6221
+ const actions = device.currentDevice
6222
+ ? ""
6223
+ : `
6224
+ <div class="device-card__actions">
6225
+ <button
6226
+ class="secondary secondary--wide"
6227
+ type="button"
6228
+ data-device-revoke="${escapeHtml(device.deviceId)}"
6229
+ >${escapeHtml(L("settings.action.revokeDevice"))}</button>
6230
+ </div>
6231
+ `;
5089
6232
 
5090
6233
  return `
5091
6234
  <article class="device-card">
@@ -5106,14 +6249,7 @@ function renderTrustedDeviceCard(device) {
5106
6249
  ${renderDeviceMetaRow(L("settings.row.pushStatus"), pushLabel)}
5107
6250
  ${renderDeviceMetaRow(L("settings.row.currentLanguage"), localeLabel)}
5108
6251
  </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>
6252
+ ${actions}
5117
6253
  </article>
5118
6254
  `;
5119
6255
  }
@@ -5186,8 +6322,9 @@ function renderStandardDetailMobile(detail) {
5186
6322
  const kindInfo = kindMeta(detail.kind, detail);
5187
6323
  const spaciousBodyDetail = TIMELINE_MESSAGE_KINDS.has(detail.kind);
5188
6324
  const plainIntro = renderDetailPlainIntro(detail, { mobile: true });
6325
+ const hasCompletionReply = isCompletionReplyAvailable(detail);
5189
6326
  return `
5190
- <div class="mobile-detail-screen">
6327
+ <div class="mobile-detail-screen ${hasCompletionReply ? "mobile-detail-screen--has-reply-dock" : ""}">
5191
6328
  <div class="detail-shell detail-shell--mobile">
5192
6329
  <div class="mobile-detail-scroll mobile-detail-scroll--detail">
5193
6330
  ${renderDetailMetaRow(detail, kindInfo, { mobile: true })}
@@ -5217,10 +6354,10 @@ function renderStandardDetailMobile(detail) {
5217
6354
  ${renderDetailDiffPanel(detail, { mobile: true })}
5218
6355
  ${renderDetailDiffThreadGroups(detail, { mobile: true })}
5219
6356
  ${renderDetailFileRefs(detail, { mobile: true })}
5220
- ${renderCompletionReplyComposer(detail, { mobile: true })}
5221
6357
  </div>
5222
6358
  ${detail.readOnly ? "" : renderActionButtons(detail.actions || [], { mobileSticky: true })}
5223
6359
  </div>
6360
+ ${renderCompletionReplyDock(detail)}
5224
6361
  </div>
5225
6362
  `;
5226
6363
  }
@@ -5968,7 +7105,7 @@ function renderThreadShareDetail(detail, options = {}) {
5968
7105
  }
5969
7106
 
5970
7107
  function renderCompletionReplyComposer(detail, options = {}) {
5971
- if ((detail.kind !== "completion" && detail.kind !== "assistant_final") || detail.reply?.enabled !== true) {
7108
+ if (!isCompletionReplyAvailable(detail)) {
5972
7109
  return "";
5973
7110
  }
5974
7111
 
@@ -6142,6 +7279,51 @@ function renderCompletionReplyComposer(detail, options = {}) {
6142
7279
  `;
6143
7280
  }
6144
7281
 
7282
+ function isCompletionReplyAvailable(detail) {
7283
+ return Boolean(
7284
+ detail &&
7285
+ (detail.kind === "completion" || detail.kind === "assistant_final") &&
7286
+ detail.reply?.enabled === true &&
7287
+ detail.token
7288
+ );
7289
+ }
7290
+
7291
+ function renderCompletionReplyDock(detail) {
7292
+ if (!isCompletionReplyAvailable(detail)) {
7293
+ return "";
7294
+ }
7295
+ const providerVars = { provider: providerDisplayName(detail?.provider) };
7296
+ return `
7297
+ <div class="reply-dock" role="region" aria-label="${escapeHtml(L("reply.eyebrow"))}">
7298
+ <button
7299
+ class="reply-dock__button"
7300
+ type="button"
7301
+ data-open-completion-reply-sheet
7302
+ data-token="${escapeHtml(detail.token)}"
7303
+ >
7304
+ <span class="reply-dock__label">${escapeHtml(L("reply.title", providerVars))}</span>
7305
+ </button>
7306
+ </div>
7307
+ `;
7308
+ }
7309
+
7310
+ function renderCompletionReplySheet(detail) {
7311
+ if (!isCompletionReplyAvailable(detail) || state.completionReplySheetToken !== detail.token) {
7312
+ return "";
7313
+ }
7314
+ const providerVars = { provider: providerDisplayName(detail?.provider) };
7315
+ return `
7316
+ <div class="reply-sheet-backdrop" data-close-completion-reply-sheet aria-hidden="true"></div>
7317
+ <section class="reply-sheet" role="dialog" aria-modal="true" aria-label="${escapeHtml(L("reply.title", providerVars))}">
7318
+ <div class="reply-sheet__handle" aria-hidden="true"></div>
7319
+ <button class="reply-sheet__close" type="button" data-close-completion-reply-sheet aria-label="${escapeHtml(L("common.close"))}">
7320
+ <span aria-hidden="true">&times;</span>
7321
+ </button>
7322
+ ${renderCompletionReplyComposer(detail, { mobile: true, sheet: true })}
7323
+ </section>
7324
+ `;
7325
+ }
7326
+
6145
7327
  function renderChoiceQuestions(detail) {
6146
7328
  const effectiveAnswers = getEffectiveChoiceDraftAnswers(detail);
6147
7329
  return detail.questions
@@ -6392,10 +7574,27 @@ function renderInstallBanner() {
6392
7574
  `;
6393
7575
  }
6394
7576
 
7577
+ function renderClientUpdateBanner() {
7578
+ return `
7579
+ <section class="install-banner install-banner--push">
7580
+ <div class="install-banner__copy">
7581
+ <strong>${escapeHtml(L("banner.clientUpdate.title"))}</strong>
7582
+ <p class="muted">${escapeHtml(L("banner.clientUpdate.copy"))}</p>
7583
+ </div>
7584
+ <div class="actions install-banner__actions">
7585
+ <button class="secondary" type="button" data-force-app-refresh>${escapeHtml(L("banner.clientUpdate.action"))}</button>
7586
+ </div>
7587
+ </section>
7588
+ `;
7589
+ }
7590
+
6395
7591
  function renderTopBanner() {
6396
7592
  if (!isDesktopLayout() && (state.detailOpen || isSettingsSubpageOpen())) {
6397
7593
  return "";
6398
7594
  }
7595
+ if (state.clientUpdateRequired) {
7596
+ return renderClientUpdateBanner();
7597
+ }
6399
7598
  if (shouldShowInstallBanner()) {
6400
7599
  return renderInstallBanner();
6401
7600
  }
@@ -6922,6 +8121,23 @@ function bindShellInteractions() {
6922
8121
  });
6923
8122
  }
6924
8123
 
8124
+ for (const button of document.querySelectorAll("[data-open-completion-reply-sheet][data-token]")) {
8125
+ button.addEventListener("click", async () => {
8126
+ state.completionReplySheetToken = button.dataset.token || "";
8127
+ resetHorizontalViewportScroll();
8128
+ await renderShell();
8129
+ resetHorizontalViewportScroll();
8130
+ });
8131
+ }
8132
+
8133
+ for (const trigger of document.querySelectorAll("[data-close-completion-reply-sheet]")) {
8134
+ trigger.addEventListener("click", async () => {
8135
+ state.completionReplySheetToken = "";
8136
+ resetHorizontalViewportScroll();
8137
+ await renderShell();
8138
+ });
8139
+ }
8140
+
6925
8141
  for (const input of document.querySelectorAll("[data-reply-mode-toggle][data-reply-token]")) {
6926
8142
  input.addEventListener("change", async () => {
6927
8143
  const token = input.dataset.replyToken || "";
@@ -7132,14 +8348,51 @@ function bindShellInteractions() {
7132
8348
  state.session.autoPilotWriteLaneSource = result?.writeLaneSourceEnabled === true;
7133
8349
  }
7134
8350
 
8351
+ function snapshotAutoPilotSettings() {
8352
+ return {
8353
+ trustedReads: state.session?.autoPilotTrustedReads === true,
8354
+ trustedWrites: state.session?.autoPilotTrustedWrites === true,
8355
+ writeLaneContent: state.session?.autoPilotWriteLaneContent === true,
8356
+ writeLaneUiTests: state.session?.autoPilotWriteLaneUiTests === true,
8357
+ writeLaneSource: state.session?.autoPilotWriteLaneSource === true,
8358
+ };
8359
+ }
8360
+
8361
+ function restoreAutoPilotSettings(snapshot) {
8362
+ if (!state.session || !snapshot) {
8363
+ return;
8364
+ }
8365
+ state.session.autoPilotTrustedReads = snapshot.trustedReads === true;
8366
+ state.session.autoPilotTrustedWrites = snapshot.trustedWrites === true;
8367
+ state.session.autoPilotWriteLaneContent = snapshot.writeLaneContent === true;
8368
+ state.session.autoPilotWriteLaneUiTests = snapshot.writeLaneUiTests === true;
8369
+ state.session.autoPilotWriteLaneSource = snapshot.writeLaneSource === true;
8370
+ }
8371
+
8372
+ function reconcileAutoPilotTrustedWrites() {
8373
+ if (!state.session) {
8374
+ return;
8375
+ }
8376
+ state.session.autoPilotTrustedWrites = Boolean(
8377
+ state.session.autoPilotWriteLaneContent === true ||
8378
+ state.session.autoPilotWriteLaneUiTests === true ||
8379
+ state.session.autoPilotWriteLaneSource === true,
8380
+ );
8381
+ }
8382
+
7135
8383
  for (const checkbox of document.querySelectorAll("[data-auto-pilot-checkbox]")) {
7136
8384
  checkbox.addEventListener("change", async () => {
7137
8385
  const next = checkbox.checked === true;
8386
+ const previous = snapshotAutoPilotSettings();
8387
+ if (state.session) {
8388
+ state.session.autoPilotTrustedReads = next;
8389
+ }
8390
+ await renderShell();
7138
8391
  try {
7139
8392
  const result = await apiPost("/api/settings/auto-pilot", { trustedReadsEnabled: next });
7140
8393
  applyAutoPilotSettingsResult(result);
7141
- await refreshAuthenticatedState();
7142
8394
  } catch (error) {
8395
+ restoreAutoPilotSettings(previous);
7143
8396
  state.pushError = error.message || String(error);
7144
8397
  }
7145
8398
  await renderShell();
@@ -7150,17 +8403,29 @@ function bindShellInteractions() {
7150
8403
  checkbox.addEventListener("change", async () => {
7151
8404
  const next = checkbox.checked === true;
7152
8405
  const lane = normalizeClientText(checkbox.getAttribute("data-auto-pilot-write-lane-checkbox") || "");
8406
+ const previous = snapshotAutoPilotSettings();
7153
8407
  const payload =
7154
8408
  lane === "content"
7155
8409
  ? { writeLaneContentEnabled: next }
7156
8410
  : lane === "ui-tests"
7157
8411
  ? { writeLaneUiTestsEnabled: next }
7158
8412
  : { writeLaneSourceEnabled: next };
8413
+ if (state.session) {
8414
+ if (lane === "content") {
8415
+ state.session.autoPilotWriteLaneContent = next;
8416
+ } else if (lane === "ui-tests") {
8417
+ state.session.autoPilotWriteLaneUiTests = next;
8418
+ } else {
8419
+ state.session.autoPilotWriteLaneSource = next;
8420
+ }
8421
+ reconcileAutoPilotTrustedWrites();
8422
+ }
8423
+ await renderShell();
7159
8424
  try {
7160
8425
  const result = await apiPost("/api/settings/auto-pilot", payload);
7161
8426
  applyAutoPilotSettingsResult(result);
7162
- await refreshAuthenticatedState();
7163
8427
  } catch (error) {
8428
+ restoreAutoPilotSettings(previous);
7164
8429
  state.pushError = error.message || String(error);
7165
8430
  }
7166
8431
  await renderShell();
@@ -7181,11 +8446,23 @@ function bindShellInteractions() {
7181
8446
  if (!payload) {
7182
8447
  return;
7183
8448
  }
8449
+ const previous = snapshotAutoPilotSettings();
8450
+ if (state.session) {
8451
+ if (lane === "content") {
8452
+ state.session.autoPilotWriteLaneContent = true;
8453
+ } else if (lane === "ui_tests") {
8454
+ state.session.autoPilotWriteLaneUiTests = true;
8455
+ } else {
8456
+ state.session.autoPilotWriteLaneSource = true;
8457
+ }
8458
+ reconcileAutoPilotTrustedWrites();
8459
+ }
8460
+ await renderShell();
7184
8461
  try {
7185
8462
  const result = await apiPost("/api/settings/auto-pilot", payload);
7186
8463
  applyAutoPilotSettingsResult(result);
7187
- await refreshAuthenticatedState();
7188
8464
  } catch (error) {
8465
+ restoreAutoPilotSettings(previous);
7189
8466
  state.pushError = error.message || String(error);
7190
8467
  }
7191
8468
  await renderShell();
@@ -7272,6 +8549,166 @@ function bindShellInteractions() {
7272
8549
  });
7273
8550
  }
7274
8551
 
8552
+ // ─── Remote pairing (Phase 5c) ─────────────────────────────────────
8553
+ // Optimistic flips with bridge-side reconciliation. The bridge endpoints
8554
+ // hot-restart the orchestrator, so the post-POST `fetchRemotePairingStatus`
8555
+ // call can briefly observe a transient state — that's fine, the next
8556
+ // render will land on the steady state.
8557
+ for (const details of document.querySelectorAll("[data-remote-pairing-details]")) {
8558
+ details.addEventListener("toggle", () => {
8559
+ state.remotePairingDetailsOpen = details.open === true;
8560
+ });
8561
+ }
8562
+
8563
+ for (const checkbox of document.querySelectorAll("[data-remote-pairing-toggle-checkbox]")) {
8564
+ checkbox.addEventListener("change", async () => {
8565
+ const next = checkbox.checked === true;
8566
+ const previous = state.remotePairingStatus?.enabled === true;
8567
+ if (!next && isRemotePairingUsingRelay() && !window.confirm(L("settings.remotePairing.toggle.offConfirm"))) {
8568
+ checkbox.checked = previous;
8569
+ return;
8570
+ }
8571
+ state.remotePairingNotice = "";
8572
+ state.remotePairingError = "";
8573
+ state.remotePairingPending = "toggle";
8574
+ // Optimistic flip — render once with the new value disabled-during-pending,
8575
+ // then reconcile with the bridge.
8576
+ if (state.remotePairingStatus) {
8577
+ state.remotePairingStatus = { ...state.remotePairingStatus, enabled: next };
8578
+ } else {
8579
+ state.remotePairingStatus = {
8580
+ enabled: next,
8581
+ relayUrl: "",
8582
+ configuredRelayUrl: "",
8583
+ identityFingerprint: null,
8584
+ sessions: [],
8585
+ pairings: [],
8586
+ auditEvents: [],
8587
+ };
8588
+ }
8589
+ await renderShell();
8590
+ try {
8591
+ await apiPost("/api/remote-pairing/toggle", { enabled: next });
8592
+ state.remotePairingNotice = next
8593
+ ? L("settings.remotePairing.notice.toggleOn")
8594
+ : L("settings.remotePairing.notice.toggleOff");
8595
+ await fetchRemotePairingStatus();
8596
+ } catch (error) {
8597
+ // Roll back to the previous value so the checkbox visually reverts.
8598
+ if (state.remotePairingStatus) {
8599
+ state.remotePairingStatus = { ...state.remotePairingStatus, enabled: previous };
8600
+ }
8601
+ state.remotePairingError = L("settings.remotePairing.error.toggleFailed");
8602
+ } finally {
8603
+ state.remotePairingPending = "";
8604
+ await renderShell();
8605
+ }
8606
+ });
8607
+ }
8608
+
8609
+ for (const button of document.querySelectorAll("[data-remote-pairing-relay-url-save]")) {
8610
+ button.addEventListener("click", async () => {
8611
+ // The input lives on the same page; querySelector grabs the (single)
8612
+ // visible field. If we ever add multiple URL editors we'd need to
8613
+ // scope this to a container — but right now there's exactly one.
8614
+ const input = document.querySelector("[data-remote-pairing-relay-url-input]");
8615
+ const trimmed = (input?.value || "").trim();
8616
+ state.remotePairingNotice = "";
8617
+ state.remotePairingError = "";
8618
+ state.remotePairingPending = "relayUrl";
8619
+ await renderShell();
8620
+ try {
8621
+ await apiPost("/api/remote-pairing/relay-url", { relayUrl: trimmed });
8622
+ state.remotePairingNotice = L("settings.remotePairing.notice.relayUrlSaved");
8623
+ await fetchRemotePairingStatus();
8624
+ } catch (error) {
8625
+ // Distinguish the validation error from a generic save failure so
8626
+ // the user knows whether to fix the URL or retry.
8627
+ state.remotePairingError = error?.errorKey === "invalid-relay-url"
8628
+ ? L("settings.remotePairing.error.invalidRelayUrl")
8629
+ : L("settings.remotePairing.error.relayUrlFailed");
8630
+ } finally {
8631
+ state.remotePairingPending = "";
8632
+ await renderShell();
8633
+ }
8634
+ });
8635
+ }
8636
+
8637
+ for (const button of document.querySelectorAll("[data-remote-pairing-rotate-token]")) {
8638
+ button.addEventListener("click", async () => {
8639
+ const local = loadRemotePairingState();
8640
+ if (!local?.phonePub) return;
8641
+ state.remotePairingNotice = "";
8642
+ state.remotePairingError = "";
8643
+ state.remotePairingPending = "rotateToken";
8644
+ await renderShell();
8645
+ try {
8646
+ const response = await apiPost("/api/remote-pairing/rotate-token", { phonePub: local.phonePub });
8647
+ saveRemotePairingState({
8648
+ pairingId: response.pairingId,
8649
+ relayToken: response.relayToken,
8650
+ phonePub: response.phonePub,
8651
+ phoneFingerprint: response.phoneFingerprint,
8652
+ bridgePubHex: response.bridgePubHex,
8653
+ bridgeFingerprint: response.bridgeFingerprint,
8654
+ relayUrl: response.relayUrl,
8655
+ label: response.label || local.label || "",
8656
+ addedAtMs: Number.isFinite(response.addedAtMs) ? response.addedAtMs : local.addedAtMs,
8657
+ relayTokenUpdatedAtMs: Number.isFinite(response.relayTokenUpdatedAtMs)
8658
+ ? response.relayTokenUpdatedAtMs
8659
+ : Date.now(),
8660
+ });
8661
+ state.remotePairingNotice = L("settings.remotePairing.notice.tokenRotated");
8662
+ await fetchRemotePairingStatus();
8663
+ } catch (error) {
8664
+ state.remotePairingError = L("settings.remotePairing.error.tokenRotateFailed");
8665
+ } finally {
8666
+ state.remotePairingPending = "";
8667
+ await renderShell();
8668
+ }
8669
+ });
8670
+ }
8671
+
8672
+ for (const button of document.querySelectorAll("[data-remote-pairing-revoke]")) {
8673
+ button.addEventListener("click", async () => {
8674
+ const phonePub = button.dataset.remotePairingRevoke || "";
8675
+ if (!phonePub) return;
8676
+ if (!window.confirm(L("settings.remotePairing.pairings.revokeConfirm"))) return;
8677
+ state.remotePairingNotice = "";
8678
+ state.remotePairingError = "";
8679
+ // Per-pub pending key so the renderer only disables this card's
8680
+ // button, not every revoke button on the page.
8681
+ state.remotePairingPending = `revoke:${phonePub}`;
8682
+ await renderShell();
8683
+ try {
8684
+ await apiPost("/api/remote-pairing/revoke", { phonePub });
8685
+ // If the revoked phone is THIS device, drop the local enrollment
8686
+ // record too — otherwise the "this device" indicator would keep
8687
+ // claiming the (now-revoked) record is live, and a future fetch
8688
+ // routing layer would try to reuse the stale pairingId. Best
8689
+ // effort: storage failures don't change the revoke outcome.
8690
+ try {
8691
+ const local = loadRemotePairingState();
8692
+ if (
8693
+ local
8694
+ && local.phonePub.toLowerCase() === phonePub.toLowerCase()
8695
+ ) {
8696
+ clearRemotePairingState();
8697
+ }
8698
+ } catch {
8699
+ // ignore — same fall-through as savePairingState
8700
+ }
8701
+ state.remotePairingNotice = L("settings.remotePairing.notice.revoked");
8702
+ await fetchRemotePairingStatus();
8703
+ } catch (error) {
8704
+ state.remotePairingError = L("settings.remotePairing.error.revokeFailed");
8705
+ } finally {
8706
+ state.remotePairingPending = "";
8707
+ await renderShell();
8708
+ }
8709
+ });
8710
+ }
8711
+
7275
8712
 
7276
8713
  for (const button of document.querySelectorAll("[data-hazbase-action]")) {
7277
8714
  button.addEventListener("click", async () => {
@@ -7548,24 +8985,13 @@ for (const button of document.querySelectorAll("[data-wallet-address-copy]")) {
7548
8985
  try {
7549
8986
  const decisionBody = { action: submittedAction, editedText };
7550
8987
  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
- }
8988
+ // apiPost routes through LAN-first / relay-fallback and throws on
8989
+ // !ok — the existing catch below handles both transport and HTTP
8990
+ // failures uniformly.
8991
+ await apiPost(
8992
+ `/api/items/moltbook-draft/${encodeURIComponent(token)}/decision`,
8993
+ decisionBody,
8994
+ );
7569
8995
  // Mark local detail as resolved so re-render shows "already resolved" immediately.
7570
8996
  if (state.currentDetail?.kind === "moltbook_draft") {
7571
8997
  state.currentDetail.moltbookDraftEnabled = false;
@@ -7574,7 +9000,7 @@ for (const button of document.querySelectorAll("[data-wallet-address-copy]")) {
7574
9000
  await refreshAuthenticatedState();
7575
9001
  await renderShell();
7576
9002
  } catch (error) {
7577
- alert(`Moltbook draft error: ${error.message}`);
9003
+ alert(`Moltbook draft ${submittedAction} failed: ${error.message}`);
7578
9004
  buttons.forEach((btn) => {
7579
9005
  btn.disabled = false;
7580
9006
  btn.classList.remove("is-loading", "is-dimmed");
@@ -7625,24 +9051,10 @@ for (const button of document.querySelectorAll("[data-wallet-address-copy]")) {
7625
9051
  try {
7626
9052
  const decisionBody = { action: submittedAction, instruction };
7627
9053
  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
- }
9054
+ await apiPost(
9055
+ `/api/items/a2a-task/${encodeURIComponent(token)}/decision`,
9056
+ decisionBody,
9057
+ );
7646
9058
  if (state.currentDetail?.kind === "a2a_task") {
7647
9059
  state.currentDetail.a2aTaskEnabled = false;
7648
9060
  state.currentDetail.readOnly = true;
@@ -7650,7 +9062,7 @@ for (const button of document.querySelectorAll("[data-wallet-address-copy]")) {
7650
9062
  await refreshAuthenticatedState();
7651
9063
  await renderShell();
7652
9064
  } catch (error) {
7653
- alert(`A2A task error: ${error.message}`);
9065
+ alert(`A2A task ${submittedAction} failed: ${error.message}`);
7654
9066
  buttons.forEach((btn) => {
7655
9067
  btn.disabled = false;
7656
9068
  btn.classList.remove("is-loading", "is-dimmed");
@@ -7691,24 +9103,10 @@ for (const button of document.querySelectorAll("[data-wallet-address-copy]")) {
7691
9103
  if (textarea) textarea.readOnly = true;
7692
9104
  const editedContent = normalizeClientText(new FormData(threadShareForm).get("shareContent"));
7693
9105
  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
- }
9106
+ await apiPost(
9107
+ `/api/threads/share/${encodeURIComponent(token)}/decision`,
9108
+ { decision: submittedAction, editedContent },
9109
+ );
7712
9110
  if (state.currentDetail?.kind === "thread_share") {
7713
9111
  state.currentDetail.threadShareEnabled = false;
7714
9112
  state.currentDetail.readOnly = true;
@@ -7716,7 +9114,7 @@ for (const button of document.querySelectorAll("[data-wallet-address-copy]")) {
7716
9114
  await refreshAuthenticatedState();
7717
9115
  await renderShell();
7718
9116
  } catch (error) {
7719
- alert(`Thread share error: ${error.message}`);
9117
+ alert(`Thread share ${submittedAction} failed: ${error.message}`);
7720
9118
  buttons.forEach((btn) => {
7721
9119
  btn.disabled = false;
7722
9120
  btn.classList.remove("is-loading", "is-dimmed");
@@ -7775,14 +9173,22 @@ for (const button of document.querySelectorAll("[data-wallet-address-copy]")) {
7775
9173
  await renderShell();
7776
9174
 
7777
9175
  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
- }
9176
+ const attachments = (draft.attachments || []).filter((attachment) => attachment?.file);
9177
+ let requestBody;
9178
+ if (attachments.length > 0) {
9179
+ requestBody = new FormData();
9180
+ requestBody.set("text", text);
9181
+ requestBody.set("planMode", draft.mode === "plan" ? "true" : "false");
9182
+ requestBody.set("force", draft.confirmOverride === true ? "true" : "false");
9183
+ } else {
9184
+ requestBody = {
9185
+ text,
9186
+ planMode: draft.mode === "plan",
9187
+ force: draft.confirmOverride === true,
9188
+ };
9189
+ }
9190
+ for (const attachment of attachments) {
9191
+ requestBody.append("image", attachment.file, attachment.name || attachment.file.name);
7786
9192
  }
7787
9193
  const replyKind = replyForm.dataset.replyKind || "completion";
7788
9194
  await apiPost(`/api/items/${encodeURIComponent(replyKind)}/${encodeURIComponent(token)}/reply`, requestBody);
@@ -7941,6 +9347,13 @@ function bindSharedUi(renderFn) {
7941
9347
  });
7942
9348
  }
7943
9349
 
9350
+ for (const button of document.querySelectorAll("[data-force-app-refresh]")) {
9351
+ button.addEventListener("click", async () => {
9352
+ await forceAppRefreshFromLan();
9353
+ await renderFn();
9354
+ });
9355
+ }
9356
+
7944
9357
  for (const button of document.querySelectorAll("[data-install-guide-close]")) {
7945
9358
  button.addEventListener("click", async (event) => {
7946
9359
  if (button.classList.contains("modal-backdrop")) {
@@ -8608,6 +10021,10 @@ function renderIcon(name) {
8608
10021
  return `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.85" stroke-linecap="round" stroke-linejoin="round"><rect x="7.2" y="2.8" width="9.6" height="18.4" rx="2.4"/><path d="M10 6.7h4"/><circle cx="12" cy="17.6" r="0.7" fill="currentColor" stroke="none"/></svg>`;
8609
10022
  case "language":
8610
10023
  return `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.85" stroke-linecap="round" stroke-linejoin="round"><path d="M12 3.5c3.8 0 7 3.8 7 8.5s-3.2 8.5-7 8.5-7-3.8-7-8.5 3.2-8.5 7-8.5Z"/><path d="M5.8 9h12.4"/><path d="M5.8 15h12.4"/><path d="M12 3.8c1.9 2 3 4.9 3 8.2s-1.1 6.2-3 8.2c-1.9-2-3-4.9-3-8.2s1.1-6.2 3-8.2Z"/></svg>`;
10024
+ case "agent-network":
10025
+ return `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.85" stroke-linecap="round" stroke-linejoin="round"><circle cx="6.8" cy="7.2" r="2.7"/><circle cx="17.2" cy="7.2" r="2.7"/><circle cx="12" cy="17" r="3"/><path d="M9.2 8.8 11 14.1"/><path d="m14.8 8.8-1.9 5.3"/><path d="M9.5 7.2h5"/></svg>`;
10026
+ case "remote-connection":
10027
+ return `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.85" stroke-linecap="round" stroke-linejoin="round"><rect x="5" y="12" width="14" height="7" rx="2"/><path d="M9 19h6"/><path d="M12 12v7"/><path d="M8.2 8.8a5.4 5.4 0 0 1 7.6 0"/><path d="M10.2 6.3a8.2 8.2 0 0 1 3.6 0"/><path d="M11.2 9.8a1.2 1.2 0 0 1 1.6 0"/></svg>`;
8611
10028
  case "link":
8612
10029
  return `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round"><path d="M10.4 13.6 8.3 15.7a3 3 0 0 1-4.2-4.2l2.8-2.8a3 3 0 0 1 4.2 0"/><path d="m13.6 10.4 2.1-2.1a3 3 0 1 1 4.2 4.2l-2.8 2.8a3 3 0 0 1-4.2 0"/><path d="m9.5 14.5 5-5"/></svg>`;
8613
10030
  case "clip":
@@ -8758,13 +10175,138 @@ function handleDocumentVisibilityChange() {
8758
10175
  handlePotentialExternalNavigation();
8759
10176
  }
8760
10177
 
8761
- async function apiGet(url) {
8762
- const response = await fetch(url, {
10178
+ async function hydrateTimelinePayloadImages(payload) {
10179
+ if (!payload || !Array.isArray(payload.entries)) {
10180
+ return payload;
10181
+ }
10182
+
10183
+ const entries = await Promise.all(
10184
+ payload.entries.map(async (entry) => hydrateItemImageUrls(entry))
10185
+ );
10186
+ return { ...payload, entries };
10187
+ }
10188
+
10189
+ async function hydrateDetailImages(detail) {
10190
+ if (!detail || !Array.isArray(detail.imageUrls)) {
10191
+ return detail;
10192
+ }
10193
+ return hydrateItemImageUrls(detail);
10194
+ }
10195
+
10196
+ async function hydrateItemImageUrls(item) {
10197
+ const imageUrls = Array.isArray(item?.imageUrls) ? item.imageUrls.filter(Boolean) : [];
10198
+ if (imageUrls.length === 0) {
10199
+ return item;
10200
+ }
10201
+
10202
+ const hydratedUrls = await Promise.all(imageUrls.map((url) => routedTimelineImageUrl(url)));
10203
+ return {
10204
+ ...item,
10205
+ imageUrls: hydratedUrls.filter(Boolean),
10206
+ };
10207
+ }
10208
+
10209
+ async function routedTimelineImageUrl(imageUrl) {
10210
+ const sourceUrl = normalizeClientText(imageUrl);
10211
+ if (!sourceUrl || /^(?:blob:|data:)/iu.test(sourceUrl)) {
10212
+ return sourceUrl;
10213
+ }
10214
+
10215
+ const existing = timelineImageObjectUrlCache.get(sourceUrl);
10216
+ if (existing?.objectUrl) {
10217
+ existing.lastUsedMs = Date.now();
10218
+ return existing.objectUrl;
10219
+ }
10220
+
10221
+ try {
10222
+ const response = await routedFetch(sourceUrl, {
10223
+ credentials: "same-origin",
10224
+ headers: {
10225
+ Accept: "image/*",
10226
+ },
10227
+ });
10228
+ if (!response.ok) {
10229
+ return unavailableTimelineImageDataUrl();
10230
+ }
10231
+ const arrayBuffer = await response.arrayBuffer();
10232
+ const contentType = responseHeader(response.headers, "content-type") || "application/octet-stream";
10233
+ const objectUrl = URL.createObjectURL(new Blob([arrayBuffer], { type: contentType }));
10234
+ timelineImageObjectUrlCache.set(sourceUrl, {
10235
+ objectUrl,
10236
+ lastUsedMs: Date.now(),
10237
+ });
10238
+ pruneTimelineImageObjectUrlCache();
10239
+ return objectUrl;
10240
+ } catch {
10241
+ return unavailableTimelineImageDataUrl();
10242
+ }
10243
+ }
10244
+
10245
+ function unavailableTimelineImageDataUrl() {
10246
+ const title = L("detail.imageUnavailable");
10247
+ const subtitle = L("detail.imageUnavailableHint");
10248
+ const svg = `
10249
+ <svg xmlns="http://www.w3.org/2000/svg" width="640" height="640" viewBox="0 0 640 640">
10250
+ <defs>
10251
+ <linearGradient id="bg" x1="0" y1="0" x2="1" y2="1">
10252
+ <stop offset="0" stop-color="#132026"/>
10253
+ <stop offset="1" stop-color="#0a1116"/>
10254
+ </linearGradient>
10255
+ </defs>
10256
+ <rect width="640" height="640" rx="34" fill="url(#bg)"/>
10257
+ <rect x="24" y="24" width="592" height="592" rx="28" fill="none" stroke="#9cb5c5" stroke-opacity=".18" stroke-width="2"/>
10258
+ <circle cx="320" cy="266" r="50" fill="#26343d"/>
10259
+ <path d="M294 266h52M320 240v52" stroke="#d7e5ed" stroke-width="12" stroke-linecap="round" opacity=".78"/>
10260
+ <text x="320" y="360" text-anchor="middle" fill="#d7e5ed" font-family="Avenir Next, Helvetica, Arial, sans-serif" font-size="31" font-weight="700">${escapeSvgText(title)}</text>
10261
+ <text x="320" y="410" text-anchor="middle" fill="#9cb5c5" font-family="Avenir Next, Helvetica, Arial, sans-serif" font-size="22">${escapeSvgText(subtitle)}</text>
10262
+ </svg>
10263
+ `.trim();
10264
+ return `data:image/svg+xml;charset=UTF-8,${encodeURIComponent(svg)}`;
10265
+ }
10266
+
10267
+ function escapeSvgText(value) {
10268
+ return normalizeClientText(value)
10269
+ .replace(/&/gu, "&amp;")
10270
+ .replace(/</gu, "&lt;")
10271
+ .replace(/>/gu, "&gt;");
10272
+ }
10273
+
10274
+ function responseHeader(headers, name) {
10275
+ const key = String(name || "").toLowerCase();
10276
+ if (!headers || !key) {
10277
+ return "";
10278
+ }
10279
+ if (typeof headers.get === "function") {
10280
+ return headers.get(key) || "";
10281
+ }
10282
+ return headers[key] || headers[name] || "";
10283
+ }
10284
+
10285
+ function pruneTimelineImageObjectUrlCache() {
10286
+ if (timelineImageObjectUrlCache.size <= MAX_TIMELINE_IMAGE_OBJECT_URLS) {
10287
+ return;
10288
+ }
10289
+ const staleEntries = [...timelineImageObjectUrlCache.entries()]
10290
+ .sort((left, right) => Number(left[1]?.lastUsedMs || 0) - Number(right[1]?.lastUsedMs || 0))
10291
+ .slice(0, Math.max(0, timelineImageObjectUrlCache.size - MAX_TIMELINE_IMAGE_OBJECT_URLS));
10292
+ for (const [sourceUrl, entry] of staleEntries) {
10293
+ if (entry?.objectUrl && typeof URL !== "undefined" && typeof URL.revokeObjectURL === "function") {
10294
+ URL.revokeObjectURL(entry.objectUrl);
10295
+ }
10296
+ timelineImageObjectUrlCache.delete(sourceUrl);
10297
+ }
10298
+ }
10299
+
10300
+ async function apiGet(url, opts = {}) {
10301
+ // routedFetch tries LAN first, then falls back to the relay tunnel when
10302
+ // the phone is off-LAN. Returns a fetch-Response-compatible object so the
10303
+ // rest of this function is identical to a plain `fetch()` call.
10304
+ const response = await routedFetch(url, {
8763
10305
  credentials: "same-origin",
8764
10306
  headers: {
8765
10307
  Accept: "application/json",
8766
10308
  },
8767
- });
10309
+ }, opts);
8768
10310
  if (!response.ok) {
8769
10311
  const errorInfo = await readError(response);
8770
10312
  const error = new Error(errorInfo.message);
@@ -8778,7 +10320,9 @@ async function apiGet(url) {
8778
10320
 
8779
10321
  async function apiPost(url, body) {
8780
10322
  const isFormDataBody = typeof FormData !== "undefined" && body instanceof FormData;
8781
- const response = await fetch(url, {
10323
+ // Keep native FormData for LAN; routedFetch serializes it to multipart
10324
+ // bytes only when the request has to travel through the remote relay.
10325
+ const response = await routedFetch(url, {
8782
10326
  method: "POST",
8783
10327
  credentials: "same-origin",
8784
10328
  headers: isFormDataBody