viveworker 0.7.0-beta.1 → 0.7.0-beta.3

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.
package/web/app.js CHANGED
@@ -6,6 +6,8 @@ const PUSH_BANNER_DISMISS_KEY = "viveworker-push-banner-dismissed-v1";
6
6
  const INITIAL_DETECTED_LOCALE = detectBrowserLocale();
7
7
  const TIMELINE_MESSAGE_KINDS = new Set(["user_message", "assistant_commentary", "assistant_final"]);
8
8
  const TIMELINE_OPERATIONAL_KINDS = new Set(["approval", "plan", "plan_ready", "choice"]);
9
+ const EXTERNAL_TARGET_TABS = new Set(["inbox", "timeline", "diff"]);
10
+ const EXTERNAL_TARGET_INBOX_SUBTABS = new Set(["pending", "completed"]);
9
11
  const THREAD_FILTER_INTERACTION_DEFER_MS = 8000;
10
12
  const MAX_COMPLETION_REPLY_IMAGE_COUNT = 4;
11
13
  const NOTIFICATION_INTENT_CACHE = "viveworker-notification-intent-v1";
@@ -14,6 +16,12 @@ const NOTIFICATION_INTENT_PATH = "/__viveworker_notification_intent__";
14
16
  const state = {
15
17
  session: null,
16
18
  inbox: null,
19
+ // Flips to true after the first /api/inbox/diff response resolves
20
+ // (success OR failure). Until then the Code/diff tab shows skeleton
21
+ // shimmer cards instead of the "no entries" empty state, because the
22
+ // initial diff scan spawns git subprocesses per tracked repo and can
23
+ // take 1–3 seconds — the empty state was misleading during that window.
24
+ inboxDiffLoaded: false,
17
25
  timeline: null,
18
26
  devices: [],
19
27
  currentTab: "inbox",
@@ -52,6 +60,34 @@ const state = {
52
60
  a2aRelayStatus: null,
53
61
  a2aShareStatus: null,
54
62
  a2aShareRecentExpanded: 0,
63
+ hazbaseStatus: null,
64
+ hazbaseNotice: "",
65
+ hazbaseError: "",
66
+ // Session-only flag: once the Sepolia wallet is ready, the mainnet step
67
+ // is hidden behind a subtle opt-in link (most beta users won't activate
68
+ // it). Flipping this reveals the full mainnet step card for the rest of
69
+ // the session. Not persisted — reopening Wallet next visit starts hidden
70
+ // again, which is the desired default for the closed beta.
71
+ hazbaseMainnetOptIn: false,
72
+ // Sign-in OTP flow is a two-step send→verify dance. Showing both
73
+ // buttons side-by-side confused users; they clicked "Verify" before
74
+ // ever requesting a code. We gate the verify button behind a
75
+ // successful send by tracking that a code was just issued in this
76
+ // session (plus the email it was sent to, so verify doesn't re-prompt).
77
+ // Both reset on successful verify (or on page reload — losing the flag
78
+ // just means the user re-clicks "send", which is idempotent on the
79
+ // server side). Email + code are also mirrored here so re-renders
80
+ // triggered by polls/notices don't wipe what the user was typing —
81
+ // the <input value="..."> attributes read these back.
82
+ hazbaseOtpRequested: false,
83
+ hazbaseOtpEmail: "",
84
+ hazbaseOtpCode: "",
85
+ // Wallet logout is reversible in principle (same account + same passkey
86
+ // re-derives the same smart account address), but it still wipes the
87
+ // current session + any in-flight approvals and forces an OTP round-trip
88
+ // to recover — cheap to gate behind an explicit confirm. Session-only
89
+ // flag; flipped by the signOut button and cleared on confirm/cancel.
90
+ hazbaseLogoutConfirmOpen: false,
55
91
  a2aTaskExecutorPick: "codex",
56
92
  pushNotice: "",
57
93
  pushError: "",
@@ -70,13 +106,36 @@ const state = {
70
106
  defaultLocale: DEFAULT_LOCALE,
71
107
  supportedLocales: [...SUPPORTED_LOCALES],
72
108
  appVersion: "",
109
+ versionStatus: null,
110
+ versionStatusError: "",
73
111
  };
74
112
 
75
113
  let detailLoadSequence = 0;
114
+ let hazbasePasskeyModulePromise = null;
115
+
116
+ async function loadHazbasePasskeyModule() {
117
+ if (!hazbasePasskeyModulePromise) {
118
+ hazbasePasskeyModulePromise = import("./hazbase-passkey.js");
119
+ }
120
+ return hazbasePasskeyModulePromise;
121
+ }
122
+
123
+ function hazbasePasskeyHostSupport() {
124
+ const protocol = normalizeClientText(window.location?.protocol || "");
125
+ const hostname = normalizeClientText(window.location?.hostname || "").toLowerCase();
126
+ const eligible = protocol === "https:" && Boolean(hostname) && hostname.endsWith(".local");
127
+ return {
128
+ eligible,
129
+ hostname,
130
+ detail: eligible ? "" : L("settings.hazbase.passkey.localHostRequired"),
131
+ };
132
+ }
76
133
 
77
134
  const app = document.querySelector("#app");
78
135
  const params = new URLSearchParams(window.location.search);
79
136
  const initialItem = params.get("item") || "";
137
+ const initialTargetTab = params.get("tab") || "";
138
+ const initialTargetSubtab = params.get("subtab") || "";
80
139
  const initialPairToken = params.get("pairToken") || "";
81
140
  const initialFocusPending = params.get("focusPending") || "";
82
141
  let didReloadForServiceWorker = false;
@@ -101,14 +160,19 @@ boot().catch((error) => {
101
160
 
102
161
  async function boot() {
103
162
  updateManifestHref(initialPairToken);
104
- await registerServiceWorker();
163
+ // SW register + update() can take hundreds of ms and does not need to gate
164
+ // first paint. Fire and forget; the `controllerchange` reload handler wired
165
+ // up inside `registerServiceWorker` still picks up new versions.
166
+ registerServiceWorker().catch(() => {});
105
167
  navigator.serviceWorker?.addEventListener("message", handleServiceWorkerMessage);
106
168
  window.addEventListener("resize", handleViewportChange, { passive: true });
107
169
  window.addEventListener("focus", handlePotentialExternalNavigation, { passive: true });
108
170
  window.addEventListener("pageshow", handlePotentialExternalNavigation, { passive: true });
109
171
  document.addEventListener("visibilitychange", handleDocumentVisibilityChange);
110
172
 
111
- await refreshSession();
173
+ // Single round-trip for session + inbox(pending/completed) + timeline +
174
+ // devices. See `refreshBootstrap` for why we collapsed the boot fan-out.
175
+ await refreshBootstrap();
112
176
 
113
177
  if (!state.session?.authenticated && initialPairToken && shouldAutoPairFromBootstrapToken()) {
114
178
  try {
@@ -119,17 +183,19 @@ async function boot() {
119
183
  } catch (error) {
120
184
  state.pairError = error.message || String(error);
121
185
  }
122
- await refreshSession();
186
+ await refreshBootstrap();
123
187
  }
124
188
 
125
189
  syncPairingTokenState(desiredBootstrapPairingToken());
126
190
 
127
191
  const parsedInitialItem = parseItemRef(initialItem);
128
192
  if (parsedInitialItem) {
193
+ const targetTab = sanitizeExternalTargetTab(initialTargetTab) || tabForItemKind(parsedInitialItem.kind, state.currentTab);
194
+ const targetSubtab = sanitizeExternalTargetInboxSubtab(initialTargetSubtab);
129
195
  state.currentItem = parsedInitialItem;
130
- state.currentTab = tabForItemKind(parsedInitialItem.kind, state.currentTab);
196
+ state.currentTab = targetTab;
131
197
  if (state.currentTab === "inbox") {
132
- state.inboxSubtab = inboxSubtabForItemKind(parsedInitialItem.kind);
198
+ state.inboxSubtab = inboxSubtabForItemKind(parsedInitialItem.kind, targetSubtab);
133
199
  }
134
200
  state.detailOpen = true;
135
201
  if (isFastPathItemRef(parsedInitialItem)) {
@@ -146,8 +212,6 @@ async function boot() {
146
212
  }
147
213
 
148
214
  await consumePendingNotificationIntent();
149
- await syncDetectedLocalePreference();
150
- await refreshAuthenticatedState();
151
215
  // `?focusPending=claude` marks this tab as the Claude-hook-opened popup:
152
216
  // auto-navigate to the newest unresolved Claude pending (plan/question)
153
217
  // detail view — but only when the user is not already in the middle of
@@ -156,9 +220,37 @@ async function boot() {
156
220
  if (initialFocusPending === "claude" && !state.currentItem) {
157
221
  state.claudePopupMode = true;
158
222
  }
159
- maybeAutoFocusClaudePending();
223
+
224
+ // Bootstrap already populated session + inbox(pending/completed) +
225
+ // timeline + devices, so the shell renders with real data on first
226
+ // paint. No null-state fallback needed here.
160
227
  ensureCurrentSelection();
228
+ maybeAutoFocusClaudePending();
161
229
  await renderShell();
230
+ refreshVersionStatusForTechnicalPage();
231
+
232
+ // Diff fetch runs as a background phase because `/api/inbox/diff`
233
+ // spawns `git` subprocesses per tracked repo and can stall for several
234
+ // seconds. The Code/diff tab lights up when this resolves.
235
+ refreshInboxDiff()
236
+ .then(async () => {
237
+ if (!shouldDeferRenderForActiveInteraction()) {
238
+ await renderShell();
239
+ }
240
+ })
241
+ .catch(() => {});
242
+
243
+ // Remote status probes (push/moltbook/a2a-relay/a2a-share — the share
244
+ // worker round-trip alone can block up to 10s). Re-renders when it
245
+ // resolves.
246
+ refreshAuthenticatedStateRemote()
247
+ .then(async () => {
248
+ if (!shouldDeferRenderForActiveInteraction()) {
249
+ await renderShell();
250
+ }
251
+ })
252
+ .catch(() => {});
253
+ syncDetectedLocalePreference().catch(() => {});
162
254
 
163
255
  setInterval(async () => {
164
256
  if (!state.session?.authenticated) {
@@ -168,11 +260,41 @@ async function boot() {
168
260
  if (consumedNotificationIntent) {
169
261
  return;
170
262
  }
171
- await refreshAuthenticatedState();
263
+ // Split the poll tick into a fast local fan-out and a slow background
264
+ // fan-out. The fast calls are all in-memory bridge lookups (inbox,
265
+ // timeline, devices, push status, relay status) and typically resolve in
266
+ // under ~100 ms over localhost; rendering immediately after them keeps
267
+ // new user_message entries reaching the timeline within one scan tick.
268
+ // The slow calls — /api/inbox/diff (git subprocesses), moltbook scout
269
+ // status (cross-origin to moltbook.com on cache miss), and especially
270
+ // /api/share/status (10 s upstream timeout to share.viveworker.com) —
271
+ // used to gate the render on every poll, so a single share-worker
272
+ // cache miss stalled timeline updates for up to 10 seconds. Now they
273
+ // run in the background and trigger a second render when they resolve.
274
+ await Promise.all([
275
+ refreshInbox(),
276
+ refreshTimeline(),
277
+ refreshDevices(),
278
+ refreshPushStatus(),
279
+ fetchA2aRelayStatus(),
280
+ ]);
281
+ ensureCurrentSelection();
172
282
  maybeAutoFocusClaudePending();
173
283
  if (!shouldDeferRenderForActiveInteraction()) {
174
284
  await renderShell();
175
285
  }
286
+
287
+ Promise.allSettled([
288
+ refreshInboxDiff(),
289
+ fetchMoltbookScoutStatus(),
290
+ fetchA2aShareStatus(),
291
+ ])
292
+ .then(async () => {
293
+ if (!shouldDeferRenderForActiveInteraction()) {
294
+ await renderShell();
295
+ }
296
+ })
297
+ .catch(() => {});
176
298
  }, 3000);
177
299
  }
178
300
 
@@ -215,22 +337,84 @@ function handleViewportChange() {
215
337
  }
216
338
 
217
339
  async function refreshAuthenticatedState() {
218
- await refreshInbox();
340
+ // Fire the two inbox halves in parallel so the diff's git subprocesses
341
+ // don't serialize behind pending+completed (or vice versa).
342
+ await Promise.all([refreshInbox(), refreshInboxDiff()]);
219
343
  await refreshTimeline();
220
344
  await refreshDevices();
221
345
  await refreshPushStatus();
222
346
  await fetchMoltbookScoutStatus();
223
347
  await fetchA2aRelayStatus();
224
348
  await fetchA2aShareStatus();
349
+ if (state.currentTab === "settings") {
350
+ await fetchHazbaseStatus();
351
+ }
352
+ ensureCurrentSelection();
353
+ }
354
+
355
+ // Boot-time split: the first paint should not wait on cross-origin status
356
+ // probes. `Local` covers in-memory bridge lookups (fast). `Remote` covers
357
+ // everything that can stall on an external service (push config,
358
+ // moltbook/a2a worker calls — the a2a share worker has a 10s timeout) and
359
+ // runs in the background after the shell renders.
360
+ async function refreshAuthenticatedStateLocal() {
361
+ await Promise.all([refreshInbox(), refreshTimeline(), refreshDevices()]);
225
362
  ensureCurrentSelection();
226
363
  }
227
364
 
365
+ async function refreshAuthenticatedStateRemote() {
366
+ await Promise.allSettled([
367
+ refreshPushStatus(),
368
+ fetchMoltbookScoutStatus(),
369
+ fetchA2aRelayStatus(),
370
+ fetchA2aShareStatus(),
371
+ ]);
372
+ }
373
+
228
374
  async function refreshSession() {
229
375
  state.session = await apiGet("/api/session");
230
376
  syncPairingTokenState(desiredBootstrapPairingToken());
231
377
  applyResolvedLocale();
232
378
  }
233
379
 
380
+ // One-shot boot fetch: hits `/api/bootstrap` which bundles session,
381
+ // inbox (pending + completed), timeline, and devices into a single
382
+ // HTTPS round-trip. Saves 3 additional TLS handshakes versus calling
383
+ // the four endpoints in parallel, which is the dominant boot cost on
384
+ // iOS PWAs where connection reuse is aggressive. Leaves the diff and
385
+ // external-status probes as separate background phases in `boot()`.
386
+ async function refreshBootstrap() {
387
+ const bootstrap = await apiGet("/api/bootstrap");
388
+ state.session = bootstrap?.session || null;
389
+ syncPairingTokenState(desiredBootstrapPairingToken());
390
+ applyResolvedLocale();
391
+
392
+ if (!state.session?.authenticated) {
393
+ state.devices = [];
394
+ state.deviceError = "";
395
+ return;
396
+ }
397
+
398
+ const fastInbox = bootstrap?.inbox || {};
399
+ const previousDiff = Array.isArray(state.inbox?.diff) ? state.inbox.diff : [];
400
+ state.inbox = {
401
+ pending: Array.isArray(fastInbox.pending) ? fastInbox.pending : [],
402
+ completed: Array.isArray(fastInbox.completed) ? fastInbox.completed : [],
403
+ diff: previousDiff,
404
+ };
405
+ syncDiffThreadFilter();
406
+ syncCompletedThreadFilter();
407
+ syncInboxSubtab();
408
+
409
+ state.timeline = bootstrap?.timeline || null;
410
+ syncTimelineThreadFilter();
411
+ syncTimelineKindFilter();
412
+
413
+ const devicesPayload = bootstrap?.devices;
414
+ state.devices = Array.isArray(devicesPayload?.devices) ? devicesPayload.devices : [];
415
+ state.deviceError = "";
416
+ }
417
+
234
418
  async function syncDetectedLocalePreference() {
235
419
  if (!state.session?.authenticated || !state.session?.deviceId || !state.detectedLocale) {
236
420
  return;
@@ -248,16 +432,47 @@ async function syncDetectedLocalePreference() {
248
432
  applyResolvedLocale();
249
433
  }
250
434
 
251
- async function setLocaleOverride(nextLocale) {
252
- const result = await apiPost("/api/session/locale", {
253
- detectedLocale: state.detectedLocale,
254
- overrideLocale: nextLocale || null,
435
+ // Two-phase override: flip state.session + applyResolvedLocale synchronously
436
+ // so the caller can renderShell() in the new language before the POST
437
+ // round-trip, then reconcile with the server response. On failure the
438
+ // previous session snapshot is restored so the UI rolls back.
439
+ function applyLocaleOverrideOptimistically(nextLocale) {
440
+ if (!state.session) return null;
441
+ const previousSession = { ...state.session };
442
+ const optimistic = resolveLocalePreference({
443
+ overrideLocale: nextLocale || "",
444
+ detectedLocale: state.session?.deviceDetectedLocale || state.detectedLocale,
445
+ defaultLocale: state.session?.defaultLocale || DEFAULT_LOCALE,
446
+ fallbackLocale: DEFAULT_LOCALE,
255
447
  });
256
448
  state.session = {
257
449
  ...state.session,
258
- ...result,
450
+ deviceOverrideLocale: nextLocale || "",
451
+ locale: optimistic.locale,
452
+ localeSource: optimistic.source,
259
453
  };
260
454
  applyResolvedLocale();
455
+ return previousSession;
456
+ }
457
+
458
+ async function persistLocaleOverride(nextLocale, previousSession) {
459
+ try {
460
+ const result = await apiPost("/api/session/locale", {
461
+ detectedLocale: state.detectedLocale,
462
+ overrideLocale: nextLocale || null,
463
+ });
464
+ state.session = {
465
+ ...state.session,
466
+ ...result,
467
+ };
468
+ applyResolvedLocale();
469
+ } catch (error) {
470
+ if (previousSession) {
471
+ state.session = previousSession;
472
+ applyResolvedLocale();
473
+ }
474
+ throw error;
475
+ }
261
476
  }
262
477
 
263
478
  function applyResolvedLocale() {
@@ -280,6 +495,8 @@ function L(key, vars = {}) {
280
495
  return t(state.locale || DEFAULT_LOCALE, key, vars);
281
496
  }
282
497
 
498
+
499
+
283
500
  function detectBrowserLocale() {
284
501
  if (Array.isArray(navigator.languages) && navigator.languages.length > 0) {
285
502
  for (const value of navigator.languages) {
@@ -363,6 +580,38 @@ async function fetchA2aShareStatus() {
363
580
  }
364
581
  }
365
582
 
583
+
584
+ async function fetchHazbaseStatus() {
585
+ try {
586
+ state.hazbaseStatus = await apiGet("/api/hazbase/status");
587
+ } catch {
588
+ state.hazbaseStatus = null;
589
+ }
590
+ }
591
+
592
+ async function fetchVersionStatus() {
593
+ try {
594
+ state.versionStatus = await apiGet("/api/version/status");
595
+ state.versionStatusError = "";
596
+ } catch (error) {
597
+ state.versionStatus = null;
598
+ state.versionStatusError = error.message || String(error);
599
+ }
600
+ }
601
+
602
+ function refreshVersionStatusForTechnicalPage() {
603
+ if (state.currentTab !== "settings" || state.settingsSubpage !== "advanced") {
604
+ return;
605
+ }
606
+ fetchVersionStatus()
607
+ .then(async () => {
608
+ if (state.currentTab === "settings" && state.settingsSubpage === "advanced" && !shouldDeferRenderForActiveInteraction()) {
609
+ await renderShell();
610
+ }
611
+ })
612
+ .catch(() => {});
613
+ }
614
+
366
615
  async function getClientPushState() {
367
616
  const registration = state.serviceWorkerRegistration || (await navigator.serviceWorker?.ready.catch(() => null));
368
617
  if (registration) {
@@ -385,12 +634,41 @@ async function getClientPushState() {
385
634
  }
386
635
 
387
636
  async function refreshInbox() {
388
- state.inbox = await apiGet("/api/inbox");
637
+ const fast = await apiGet("/api/inbox");
638
+ // `/api/inbox` now returns only `{ pending, completed }`. The `diff`
639
+ // half lives at `/api/inbox/diff` because it spawns `git` subprocesses
640
+ // server-side and was blocking first paint of the completed/pending
641
+ // lists. Preserve whatever diff entries we already have in memory so
642
+ // polling doesn't wipe the diff tab between diff refetches.
643
+ const previousDiff = Array.isArray(state.inbox?.diff) ? state.inbox.diff : [];
644
+ state.inbox = {
645
+ pending: Array.isArray(fast?.pending) ? fast.pending : [],
646
+ completed: Array.isArray(fast?.completed) ? fast.completed : [],
647
+ diff: previousDiff,
648
+ };
389
649
  syncDiffThreadFilter();
390
650
  syncCompletedThreadFilter();
391
651
  syncInboxSubtab();
392
652
  }
393
653
 
654
+ async function refreshInboxDiff() {
655
+ try {
656
+ const response = await apiGet("/api/inbox/diff");
657
+ const previous = state.inbox || { pending: [], completed: [] };
658
+ state.inbox = {
659
+ pending: Array.isArray(previous.pending) ? previous.pending : [],
660
+ completed: Array.isArray(previous.completed) ? previous.completed : [],
661
+ diff: Array.isArray(response?.diff) ? response.diff : [],
662
+ };
663
+ syncDiffThreadFilter();
664
+ syncInboxSubtab();
665
+ } finally {
666
+ // Always flip off the skeleton — a persistent error shouldn't leave the
667
+ // Code tab perpetually shimmering. The next poll cycle will retry.
668
+ state.inboxDiffLoaded = true;
669
+ }
670
+ }
671
+
394
672
  async function refreshTimeline() {
395
673
  state.timeline = await apiGet("/api/timeline");
396
674
  syncTimelineThreadFilter();
@@ -466,9 +744,9 @@ function allInboxEntries() {
466
744
  return [];
467
745
  }
468
746
  return [
469
- ...state.inbox.pending.map((item) => ({ item, status: "pending" })),
747
+ ...(Array.isArray(state.inbox.pending) ? state.inbox.pending.map((item) => ({ item, status: "pending" })) : []),
470
748
  ...(Array.isArray(state.inbox.diff) ? state.inbox.diff.map((item) => ({ item, status: "diff" })) : []),
471
- ...state.inbox.completed.map((item) => ({ item, status: "completed" })),
749
+ ...(Array.isArray(state.inbox.completed) ? state.inbox.completed.map((item) => ({ item, status: "completed" })) : []),
472
750
  ];
473
751
  }
474
752
 
@@ -565,7 +843,7 @@ function listInboxEntries() {
565
843
  if (state.inboxSubtab === "completed") {
566
844
  return filteredCompletedEntries().map((item) => ({ item, status: "completed" }));
567
845
  }
568
- return state.inbox.pending
846
+ return (Array.isArray(state.inbox.pending) ? state.inbox.pending : [])
569
847
  .filter((item) => entryMatchesProviderFilter(item))
570
848
  .map((item) => ({ item, status: "pending" }));
571
849
  }
@@ -575,6 +853,7 @@ function normalizeProviderClient(value) {
575
853
  if (normalized === "claude") return "claude";
576
854
  if (normalized === "moltbook") return "moltbook";
577
855
  if (normalized === "a2a") return "a2a";
856
+ if (normalized === "viveworker") return "viveworker";
578
857
  return "codex";
579
858
  }
580
859
 
@@ -583,6 +862,7 @@ function providerDisplayName(provider) {
583
862
  if (p === "claude") return L("common.claude");
584
863
  if (p === "moltbook") return "Moltbook";
585
864
  if (p === "a2a") return "A2A";
865
+ if (p === "viveworker") return L("common.appName");
586
866
  return L("common.codex");
587
867
  }
588
868
 
@@ -914,6 +1194,10 @@ async function logout({ revokeCurrentDeviceTrust = false } = {}) {
914
1194
  function resetAuthenticatedState() {
915
1195
  state.session = null;
916
1196
  state.inbox = null;
1197
+ // Reset the diff-loaded flag so the next sign-in shows the skeleton
1198
+ // again during the fresh /api/inbox/diff fetch instead of flashing the
1199
+ // empty state from the previous session's terminal value.
1200
+ state.inboxDiffLoaded = false;
917
1201
  state.timeline = null;
918
1202
  state.devices = [];
919
1203
  state.currentItem = null;
@@ -978,6 +1262,7 @@ async function renderShell() {
978
1262
  <div class="${shellClassName}">
979
1263
  ${desktop ? renderDesktopHeader(detail) : renderMobileTopBar(detail)}
980
1264
  ${renderTopBanner()}
1265
+ ${renderGlobalErrorBanner()}
981
1266
  <main class="app-main">
982
1267
  ${desktop ? renderDesktopWorkspace(detail) : renderMobileWorkspace(detail)}
983
1268
  </main>
@@ -985,6 +1270,7 @@ async function renderShell() {
985
1270
  ${renderImageViewerModal()}
986
1271
  ${renderInstallGuideModal()}
987
1272
  ${renderLogoutConfirmModal()}
1273
+ ${renderHazbaseLogoutConfirmModal()}
988
1274
  </div>
989
1275
  `;
990
1276
 
@@ -1086,6 +1372,17 @@ function shouldDeferRenderForActiveInteraction() {
1086
1372
  ) {
1087
1373
  return true;
1088
1374
  }
1375
+ if (
1376
+ activeElement instanceof HTMLInputElement &&
1377
+ activeElement.matches("[data-hazbase-input]")
1378
+ ) {
1379
+ // User is mid-edit on the wallet sign-in email/OTP field. A poll tick
1380
+ // that re-renders here would blur the input and interrupt typing
1381
+ // (state mirroring restores value + caret position, but the blur
1382
+ // itself is still jarring, especially on iOS where it also dismisses
1383
+ // the keyboard).
1384
+ return true;
1385
+ }
1089
1386
  if (
1090
1387
  activeElement instanceof HTMLSelectElement &&
1091
1388
  activeElement.matches("[data-timeline-thread-select], [data-diff-thread-select], [data-completed-thread-select]")
@@ -1937,6 +2234,12 @@ function renderCompletedCompletionCard(entry, sourceTab) {
1937
2234
  const timestampLabel = formatTimelineTimestamp(item.createdAtMs);
1938
2235
  const pillLabel = item.kind === "completion" ? L("common.task") : kindInfo.label;
1939
2236
  const pillTone = item.kind === "completion" ? "completion" : kindInfo.tone;
2237
+ const compactSummary = normalizeClientText(summaryText);
2238
+ const useThreadAsPrimaryTitle = Boolean(threadLabel) && /^\d+$/u.test(compactSummary);
2239
+ const titleText = useThreadAsPrimaryTitle ? threadLabel : (summaryText || L("common.untitledItem"));
2240
+ const secondarySummaryHtml = useThreadAsPrimaryTitle
2241
+ ? `<p class="item-card__summary">${escapeHtml(summaryText)}</p>`
2242
+ : "";
1940
2243
 
1941
2244
  return `
1942
2245
  <button
@@ -1958,7 +2261,8 @@ function renderCompletedCompletionCard(entry, sourceTab) {
1958
2261
  </div>
1959
2262
  <div class="item-card__content">
1960
2263
  ${threadLabel ? `<p class="item-card__thread">${escapeHtml(threadLabel)}</p>` : ""}
1961
- <h3 class="item-card__title">${escapeHtml(summaryText || L("common.untitledItem"))}</h3>
2264
+ <h3 class="item-card__title">${escapeHtml(titleText)}</h3>
2265
+ ${secondarySummaryHtml}
1962
2266
  </div>
1963
2267
  </button>
1964
2268
  `;
@@ -2007,9 +2311,16 @@ function renderTimelinePanel({ entries, desktop }) {
2007
2311
  function renderDiffPanel({ entries, desktop }) {
2008
2312
  const meta = tabMeta("diff");
2009
2313
  const listClassName = desktop ? "diff-list diff-list--desktop" : "diff-list";
2010
- const bodyHtml = entries.length
2011
- ? `<div class="${listClassName}">${entries.map((entry) => renderDiffEntry(entry)).join("")}</div>`
2012
- : renderEmptyList("diff");
2314
+ let bodyHtml;
2315
+ if (entries.length) {
2316
+ bodyHtml = `<div class="${listClassName}">${entries.map((entry) => renderDiffEntry(entry)).join("")}</div>`;
2317
+ } else if (!state.inboxDiffLoaded) {
2318
+ // First /api/inbox/diff still in flight — show shimmer cards so the
2319
+ // user sees something is happening instead of "no entries".
2320
+ bodyHtml = renderDiffSkeleton(listClassName);
2321
+ } else {
2322
+ bodyHtml = renderEmptyList("diff");
2323
+ }
2013
2324
 
2014
2325
  if (!desktop) {
2015
2326
  return `
@@ -2038,6 +2349,31 @@ function renderDiffPanel({ entries, desktop }) {
2038
2349
  `;
2039
2350
  }
2040
2351
 
2352
+ // Placeholder shimmer shown while the first /api/inbox/diff response is
2353
+ // pending. Shape mirrors `.diff-entry` so the tab doesn't visually jump when
2354
+ // real entries land. Three cards with decreasing prominence is enough to
2355
+ // signal activity without pretending to be a specific number of results.
2356
+ function renderDiffSkeleton(listClassName) {
2357
+ const card = `
2358
+ <div class="diff-entry diff-entry--skeleton" aria-hidden="true">
2359
+ <div class="diff-entry__header">
2360
+ <span class="diff-skeleton-line diff-skeleton-line--thread"></span>
2361
+ <span class="diff-skeleton-line diff-skeleton-line--time"></span>
2362
+ </div>
2363
+ <span class="diff-skeleton-line diff-skeleton-line--title"></span>
2364
+ <div class="diff-entry__files">
2365
+ <span class="diff-skeleton-chip"></span>
2366
+ <span class="diff-skeleton-chip diff-skeleton-chip--narrow"></span>
2367
+ </div>
2368
+ </div>
2369
+ `;
2370
+ return `
2371
+ <div class="${listClassName}" role="status" aria-busy="true" aria-label="${escapeHtml(L("common.loading"))}">
2372
+ ${card}${card}${card}
2373
+ </div>
2374
+ `;
2375
+ }
2376
+
2041
2377
  function timelineThreadsForProvider(provider) {
2042
2378
  const entries = Array.isArray(state.timeline?.entries) ? state.timeline.entries : [];
2043
2379
  const byThread = new Map();
@@ -2893,6 +3229,7 @@ function buildSettingsContext() {
2893
3229
  moltbookScout: state.moltbookScoutStatus,
2894
3230
  a2aRelay: state.a2aRelayStatus,
2895
3231
  a2aShare: state.a2aShareStatus,
3232
+ hazbase: state.hazbaseStatus,
2896
3233
  };
2897
3234
  }
2898
3235
 
@@ -3039,6 +3376,13 @@ function settingsPageMeta(page) {
3039
3376
  description: L("settings.awayMode.copy"),
3040
3377
  icon: "settings",
3041
3378
  };
3379
+ case "autoPilot":
3380
+ return {
3381
+ id: "autoPilot",
3382
+ title: L("settings.autoPilot.title"),
3383
+ description: L("settings.autoPilot.copy"),
3384
+ icon: "approval",
3385
+ };
3042
3386
  case "moltbook":
3043
3387
  return {
3044
3388
  id: "moltbook",
@@ -3060,6 +3404,13 @@ function settingsPageMeta(page) {
3060
3404
  description: L("settings.a2aShare.copy"),
3061
3405
  icon: "link",
3062
3406
  };
3407
+ case "wallet":
3408
+ return {
3409
+ id: "wallet",
3410
+ title: L("settings.wallet.title"),
3411
+ description: L("settings.wallet.copy"),
3412
+ icon: "coin",
3413
+ };
3063
3414
  case "a2aExecutor":
3064
3415
  // Executor settings integrated into a2aRelay page — redirect.
3065
3416
  return settingsPageMeta("a2aRelay");
@@ -3097,6 +3448,15 @@ function renderSettingsRoot(context, { mobile }) {
3097
3448
  title: L("settings.awayMode.title"),
3098
3449
  value: state.session?.claudeAwayMode === true ? L("settings.claudeAway.on") : L("settings.claudeAway.off"),
3099
3450
  }),
3451
+ renderSettingsNavRow({
3452
+ page: "autoPilot",
3453
+ icon: "approval",
3454
+ title: L("settings.autoPilot.title"),
3455
+ value:
3456
+ state.session?.autoPilotTrustedReads === true || state.session?.autoPilotTrustedWrites === true
3457
+ ? L("common.enabled")
3458
+ : L("common.disabled"),
3459
+ }),
3100
3460
  ].filter(Boolean);
3101
3461
  const deviceRows = [
3102
3462
  renderSettingsNavRow({
@@ -3132,7 +3492,7 @@ function renderSettingsRoot(context, { mobile }) {
3132
3492
  `
3133
3493
  }
3134
3494
  ${renderSettingsGroup(L("settings.group.general"), generalRows)}
3135
- ${(state.session?.moltbookEnabled || state.session?.a2aRelayEnabled || state.session?.a2aShareEnabled) ? renderSettingsGroup(L("settings.group.integrations"), [
3495
+ ${(state.session?.moltbookEnabled || state.session?.a2aRelayEnabled || state.session?.a2aShareEnabled || context.hazbase?.enabled) ? renderSettingsGroup(L("settings.group.integrations"), [
3136
3496
  state.session?.moltbookEnabled ? renderSettingsNavRow({
3137
3497
  page: "moltbook",
3138
3498
  icon: "item",
@@ -3154,6 +3514,15 @@ function renderSettingsRoot(context, { mobile }) {
3154
3514
  subtitle: L("settings.a2aShare.subtitle"),
3155
3515
  value: context.a2aShare?.enabled ? L("settings.status.enabled") : L("settings.status.disabled"),
3156
3516
  }) : "",
3517
+ context.hazbase?.enabled ? renderSettingsNavRow({
3518
+ page: "wallet",
3519
+ icon: "coin",
3520
+ title: L("settings.wallet.title"),
3521
+ subtitle: L("settings.wallet.subtitle"),
3522
+ value: context.hazbase?.sessionInvalid
3523
+ ? L("settings.hazbase.status.sessionExpired")
3524
+ : context.hazbase?.signedIn ? L("settings.hazbase.status.signedIn") : L("settings.hazbase.status.signedOut"),
3525
+ }) : "",
3157
3526
  ].filter(Boolean)) : ""}
3158
3527
  ${renderSettingsGroup(L("settings.pairing.title"), deviceRows)}
3159
3528
  ${renderSettingsGroup(L("settings.group.advanced"), advancedRows)}
@@ -3198,6 +3567,9 @@ function renderSettingsSubpage(context, { mobile }) {
3198
3567
  case "awayMode":
3199
3568
  content = renderSettingsAwayModePage();
3200
3569
  break;
3570
+ case "autoPilot":
3571
+ content = renderSettingsAutoPilotPage();
3572
+ break;
3201
3573
  case "moltbook":
3202
3574
  content = renderSettingsMoltbookPage(context);
3203
3575
  break;
@@ -3208,6 +3580,9 @@ function renderSettingsSubpage(context, { mobile }) {
3208
3580
  case "a2aShare":
3209
3581
  content = renderSettingsA2aSharePage(context);
3210
3582
  break;
3583
+ case "wallet":
3584
+ content = renderSettingsWalletPage(context);
3585
+ break;
3211
3586
  default:
3212
3587
  content = "";
3213
3588
  }
@@ -3329,6 +3704,7 @@ function renderSettingsDevicePage(context) {
3329
3704
  }
3330
3705
 
3331
3706
  function renderSettingsAdvancedPage(context) {
3707
+ const versionNotice = renderVersionUpdateNotice();
3332
3708
  return `
3333
3709
  <div class="settings-page">
3334
3710
  ${context.diagnostics.map((message) => `<p class="inline-alert">${escapeHtml(message)}</p>`).join("")}
@@ -3347,10 +3723,28 @@ function renderSettingsAdvancedPage(context) {
3347
3723
  : "",
3348
3724
  renderSettingsInfoRow(L("settings.row.version"), state.appVersion || L("common.unavailable")),
3349
3725
  ].filter(Boolean), { listClassName: "settings-list settings-list--compact" })}
3726
+ ${versionNotice}
3350
3727
  </div>
3351
3728
  `;
3352
3729
  }
3353
3730
 
3731
+ function renderVersionUpdateNotice() {
3732
+ const status = state.versionStatus;
3733
+ if (!status?.updateAvailable || !status.latestVersion) {
3734
+ return "";
3735
+ }
3736
+ return `
3737
+ <section class="settings-copy-block settings-copy-block--compact settings-update-notice">
3738
+ <p class="settings-update-notice__title">${escapeHtml(L("settings.updateAvailable.title"))}</p>
3739
+ <p class="muted">${escapeHtml(L("settings.updateAvailable.copy", {
3740
+ current: status.currentVersion || state.appVersion || "",
3741
+ latest: status.latestVersion,
3742
+ }))}</p>
3743
+ <code class="settings-update-notice__command">npx viveworker update</code>
3744
+ </section>
3745
+ `;
3746
+ }
3747
+
3354
3748
  function renderSettingsNotificationActions({ push, canEnable, standalone }) {
3355
3749
  if (push.serverSubscribed) {
3356
3750
  return `
@@ -3423,6 +3817,456 @@ function renderSettingsAwayModePage() {
3423
3817
  `;
3424
3818
  }
3425
3819
 
3820
+ function renderSettingsAutoPilotPage() {
3821
+ const trustedReadsEnabled = state.session?.autoPilotTrustedReads === true;
3822
+ const trustedReadsStateLabel = trustedReadsEnabled ? L("common.enabled") : L("common.disabled");
3823
+ const writeLaneContentEnabled = state.session?.autoPilotWriteLaneContent === true;
3824
+ const writeLaneUiTestsEnabled = state.session?.autoPilotWriteLaneUiTests === true;
3825
+ const writeLaneSourceEnabled = state.session?.autoPilotWriteLaneSource === true;
3826
+ const trustedWritesEnabled =
3827
+ writeLaneContentEnabled || writeLaneUiTestsEnabled || writeLaneSourceEnabled || state.session?.autoPilotTrustedWrites === true;
3828
+ const trustedWritesStateLabel = trustedWritesEnabled ? L("common.enabled") : L("common.disabled");
3829
+ const recentEntries = recentAutoPilotEntries();
3830
+ const suggestions = recentAutoPilotSuggestions();
3831
+ return `
3832
+ <div class="settings-page">
3833
+ ${renderSettingsGroup("", [`
3834
+ <label class="reply-mode-switch reply-mode-switch--grouped" data-auto-pilot-toggle>
3835
+ <input type="checkbox" class="reply-mode-switch__input" ${trustedReadsEnabled ? "checked" : ""} data-auto-pilot-checkbox />
3836
+ <span class="reply-mode-switch__track" aria-hidden="true"><span class="reply-mode-switch__thumb"></span></span>
3837
+ <span class="reply-mode-switch__copy">
3838
+ <span class="reply-mode-switch__title">
3839
+ <span>${escapeHtml(L("settings.autoPilot.trustedReadsTitle"))}</span>
3840
+ <span class="reply-mode-switch__state">${escapeHtml(trustedReadsStateLabel)}</span>
3841
+ </span>
3842
+ <span class="reply-mode-switch__hint">${escapeHtml(L("settings.autoPilot.trustedReadsDescription"))}</span>
3843
+ </span>
3844
+ </label>
3845
+ `, `
3846
+ <div class="settings-toggle-subhead" role="presentation">
3847
+ <span class="settings-toggle-subhead__title">${escapeHtml(L("settings.autoPilot.trustedWritesTitle"))}</span>
3848
+ <span class="settings-toggle-subhead__state">${escapeHtml(trustedWritesStateLabel)}</span>
3849
+ </div>
3850
+ `, `
3851
+ <div class="settings-toggle-subcopy muted">${escapeHtml(L("settings.autoPilot.trustedWritesDescription"))}</div>
3852
+ `, `
3853
+ <label class="reply-mode-switch reply-mode-switch--grouped" data-auto-pilot-write-lane="content">
3854
+ <input type="checkbox" class="reply-mode-switch__input" ${writeLaneContentEnabled ? "checked" : ""} data-auto-pilot-write-lane-checkbox="content" />
3855
+ <span class="reply-mode-switch__track" aria-hidden="true"><span class="reply-mode-switch__thumb"></span></span>
3856
+ <span class="reply-mode-switch__copy">
3857
+ <span class="reply-mode-switch__title">
3858
+ <span>${escapeHtml(L("settings.autoPilot.writeLaneContentTitle"))}</span>
3859
+ <span class="reply-mode-switch__state">${escapeHtml(writeLaneContentEnabled ? L("common.enabled") : L("common.disabled"))}</span>
3860
+ </span>
3861
+ <span class="reply-mode-switch__hint">${escapeHtml(L("settings.autoPilot.writeLaneContentDescription"))}</span>
3862
+ </span>
3863
+ </label>
3864
+ `, `
3865
+ <label class="reply-mode-switch reply-mode-switch--grouped" data-auto-pilot-write-lane="ui-tests">
3866
+ <input type="checkbox" class="reply-mode-switch__input" ${writeLaneUiTestsEnabled ? "checked" : ""} data-auto-pilot-write-lane-checkbox="ui-tests" />
3867
+ <span class="reply-mode-switch__track" aria-hidden="true"><span class="reply-mode-switch__thumb"></span></span>
3868
+ <span class="reply-mode-switch__copy">
3869
+ <span class="reply-mode-switch__title">
3870
+ <span>${escapeHtml(L("settings.autoPilot.writeLaneUiTestsTitle"))}</span>
3871
+ <span class="reply-mode-switch__state">${escapeHtml(writeLaneUiTestsEnabled ? L("common.enabled") : L("common.disabled"))}</span>
3872
+ </span>
3873
+ <span class="reply-mode-switch__hint">${escapeHtml(L("settings.autoPilot.writeLaneUiTestsDescription"))}</span>
3874
+ </span>
3875
+ </label>
3876
+ `, `
3877
+ <label class="reply-mode-switch reply-mode-switch--grouped" data-auto-pilot-write-lane="source">
3878
+ <input type="checkbox" class="reply-mode-switch__input" ${writeLaneSourceEnabled ? "checked" : ""} data-auto-pilot-write-lane-checkbox="source" />
3879
+ <span class="reply-mode-switch__track" aria-hidden="true"><span class="reply-mode-switch__thumb"></span></span>
3880
+ <span class="reply-mode-switch__copy">
3881
+ <span class="reply-mode-switch__title">
3882
+ <span>${escapeHtml(L("settings.autoPilot.writeLaneSourceTitle"))}</span>
3883
+ <span class="reply-mode-switch__state">${escapeHtml(writeLaneSourceEnabled ? L("common.enabled") : L("common.disabled"))}</span>
3884
+ </span>
3885
+ <span class="reply-mode-switch__hint">${escapeHtml(L("settings.autoPilot.writeLaneSourceDescription"))}</span>
3886
+ </span>
3887
+ </label>
3888
+ `], { listClassName: "settings-list settings-list--toggle-group" })}
3889
+ <p class="settings-page-copy muted">${escapeHtml(L("settings.autoPilot.scopeNote"))}</p>
3890
+ ${
3891
+ suggestions.length
3892
+ ? renderSettingsGroup(
3893
+ L("settings.autoPilot.suggestionsTitle"),
3894
+ suggestions.map((suggestion) => renderSettingsAutoPilotSuggestion(suggestion))
3895
+ )
3896
+ : ""
3897
+ }
3898
+ ${
3899
+ recentEntries.length
3900
+ ? renderSettingsGroup(
3901
+ L("settings.autoPilot.recentTitle"),
3902
+ recentEntries.map((item) => renderSettingsAutoPilotRecentEntry(item))
3903
+ )
3904
+ : `
3905
+ <section class="settings-group">
3906
+ <p class="settings-group__title">${escapeHtml(L("settings.autoPilot.recentTitle"))}</p>
3907
+ <div class="settings-copy-block settings-copy-block--compact">
3908
+ <p class="muted">${escapeHtml(L("settings.autoPilot.recentEmpty"))}</p>
3909
+ </div>
3910
+ </section>
3911
+ `
3912
+ }
3913
+ </div>
3914
+ `;
3915
+ }
3916
+
3917
+ function recentAutoPilotEntries(limit = 5) {
3918
+ const entries = Array.isArray(state.timeline?.entries) ? state.timeline.entries : [];
3919
+ return entries
3920
+ .filter((entry) => isAutoPilotApprovalEntry(entry))
3921
+ .sort((a, b) => (Number(b.createdAtMs) || 0) - (Number(a.createdAtMs) || 0))
3922
+ .slice(0, limit);
3923
+ }
3924
+
3925
+ function isAutoPilotApprovalEntry(entry) {
3926
+ const stableId = normalizeClientText(entry?.stableId || "");
3927
+ return (
3928
+ normalizeClientText(entry?.kind || "") === "approval" &&
3929
+ normalizeClientText(entry?.outcome || "") === "approved" &&
3930
+ (stableId.endsWith(":autopilot") || stableId.includes(":autopilot-write"))
3931
+ );
3932
+ }
3933
+
3934
+ function autoPilotEntryMode(item) {
3935
+ const stableId = normalizeClientText(item?.stableId || "");
3936
+ return stableId.includes(":autopilot-write") ? "write" : "read";
3937
+ }
3938
+
3939
+ function autoPilotEntryWriteLane(item) {
3940
+ const stableId = normalizeClientText(item?.stableId || "");
3941
+ const match = stableId.match(/:autopilot-write:([a-z_-]+)$/u);
3942
+ return normalizeClientText(match?.[1] || "");
3943
+ }
3944
+
3945
+ function isManualApprovedWriteEntry(entry) {
3946
+ const stableId = normalizeClientText(entry?.stableId || "");
3947
+ return (
3948
+ normalizeClientText(entry?.kind || "") === "approval" &&
3949
+ normalizeClientText(entry?.outcome || "") === "approved" &&
3950
+ !stableId.includes(":autopilot") &&
3951
+ normalizeClientFileRefs(entry?.fileRefs).length > 0 &&
3952
+ normalizeClientText(entry?.diffText || "").length > 0
3953
+ );
3954
+ }
3955
+
3956
+ function autoPilotDeniedWritePathClient(fileRef) {
3957
+ const normalized = normalizeClientText(fileRef || "");
3958
+ if (!normalized) {
3959
+ return true;
3960
+ }
3961
+ const lower = normalized.toLowerCase();
3962
+ const segments = lower.split(/[\\/]+/u).filter(Boolean);
3963
+ const basename = segments[segments.length - 1] || "";
3964
+ if (
3965
+ segments.some((segment) => [".ssh", ".aws", ".gnupg", ".azure", ".kube", ".github", ".gitlab", ".terraform", ".claude", ".husky", ".vscode"].includes(segment))
3966
+ ) {
3967
+ return true;
3968
+ }
3969
+ if (basename === ".npmrc" || basename === ".netrc" || basename === ".env" || basename.startsWith(".env.")) {
3970
+ return true;
3971
+ }
3972
+ if (basename.endsWith(".pem") || basename.endsWith(".key") || basename.endsWith(".p12") || basename.endsWith(".pfx")) {
3973
+ return true;
3974
+ }
3975
+ if (
3976
+ [
3977
+ "package.json",
3978
+ "package-lock.json",
3979
+ "pnpm-lock.yaml",
3980
+ "yarn.lock",
3981
+ "bun.lockb",
3982
+ "cargo.toml",
3983
+ "cargo.lock",
3984
+ "gemfile",
3985
+ "gemfile.lock",
3986
+ "podfile",
3987
+ "podfile.lock",
3988
+ "composer.json",
3989
+ "composer.lock",
3990
+ "pipfile",
3991
+ "pipfile.lock",
3992
+ "poetry.lock",
3993
+ "requirements.txt",
3994
+ "dockerfile",
3995
+ "wrangler.toml",
3996
+ "tsconfig.json",
3997
+ "tsconfig.tsbuildinfo",
3998
+ ].includes(basename)
3999
+ ) {
4000
+ return true;
4001
+ }
4002
+ return /^id_[a-z0-9._-]+$/iu.test(basename) || basename.includes("secret") || basename.includes("credential");
4003
+ }
4004
+
4005
+ function autoPilotContentWritePathClient(fileRef) {
4006
+ const normalized = normalizeClientText(fileRef || "");
4007
+ if (!normalized || autoPilotDeniedWritePathClient(normalized)) {
4008
+ return false;
4009
+ }
4010
+ const lower = normalized.toLowerCase();
4011
+ const segments = lower.split(/[\\/]+/u).filter(Boolean);
4012
+ const basename = segments[segments.length - 1] || "";
4013
+ const extension = basename.includes(".") ? `.${basename.split(".").pop().toLowerCase()}` : "";
4014
+ const basenameWithoutExtension = extension ? basename.slice(0, -extension.length) : basename;
4015
+ if ([".md", ".mdx", ".txt", ".rst", ".adoc"].includes(extension)) {
4016
+ return true;
4017
+ }
4018
+ if (["license", "notice", "copying", "readme", "changelog", "contributing"].includes(basenameWithoutExtension.toLowerCase())) {
4019
+ return true;
4020
+ }
4021
+ if (segments.includes("i18n") && [".js", ".ts", ".json", ".yaml", ".yml"].includes(extension)) {
4022
+ return true;
4023
+ }
4024
+ if (segments.includes("messages") && extension === ".json") {
4025
+ return true;
4026
+ }
4027
+ return false;
4028
+ }
4029
+
4030
+ function autoPilotUiTestsWritePathClient(fileRef) {
4031
+ const normalized = normalizeClientText(fileRef || "");
4032
+ if (!normalized || autoPilotDeniedWritePathClient(normalized)) {
4033
+ return false;
4034
+ }
4035
+ const lower = normalized.toLowerCase();
4036
+ const segments = lower.split(/[\\/]+/u).filter(Boolean);
4037
+ const basename = segments[segments.length - 1] || "";
4038
+ const extension = basename.includes(".") ? `.${basename.split(".").pop().toLowerCase()}` : "";
4039
+ if ([".css", ".scss", ".sass", ".less", ".styl", ".pcss"].includes(extension)) {
4040
+ return true;
4041
+ }
4042
+ if (segments.includes("__tests__") || /\.(test|spec)\.[cm]?[jt]sx?$/u.test(basename)) {
4043
+ return true;
4044
+ }
4045
+ if ((segments.includes("web") || segments.includes("components")) && [".js", ".jsx", ".ts", ".tsx", ".html"].includes(extension)) {
4046
+ return true;
4047
+ }
4048
+ return false;
4049
+ }
4050
+
4051
+ function autoPilotSourceWritePathClient(fileRef) {
4052
+ const normalized = normalizeClientText(fileRef || "");
4053
+ if (!normalized || autoPilotDeniedWritePathClient(normalized)) {
4054
+ return false;
4055
+ }
4056
+ if (autoPilotContentWritePathClient(normalized) || autoPilotUiTestsWritePathClient(normalized)) {
4057
+ return false;
4058
+ }
4059
+ return /\.(?:[cm]?[jt]sx?)$/u.test(normalized);
4060
+ }
4061
+
4062
+ function diffAddedLinesClient(diffText) {
4063
+ return String(diffText || "")
4064
+ .replace(/\r\n/gu, "\n")
4065
+ .split("\n")
4066
+ .filter((line) => line.startsWith("+") && !line.startsWith("+++"))
4067
+ .map((line) => line.slice(1));
4068
+ }
4069
+
4070
+ function addedDiffLinesContainClient(diffText, pattern) {
4071
+ return diffAddedLinesClient(diffText).some((line) => pattern.test(line));
4072
+ }
4073
+
4074
+ function hasUnsafeUiOrTestWriteDiffClient(diffText) {
4075
+ const patterns = [
4076
+ /\bprocess\.env\b/u,
4077
+ /\b(?:child_process|spawn|exec|execFile|fork)\b/u,
4078
+ /\bfs\.(?:write|append|rm|unlink|rename|chmod|chown|copyFile|cp)\b/u,
4079
+ /\b(?:fetch|axios|XMLHttpRequest)\s*\(/u,
4080
+ /\bcrypto\b/u,
4081
+ /\b(?:secret|token|password|privateKey|credential)\b/iu,
4082
+ ];
4083
+ return (
4084
+ addedDiffLinesContainClient(diffText, /^\s*(?:import\s|export\s+.*\s+from\s)/u) ||
4085
+ addedDiffLinesContainClient(diffText, /\brequire\s*\(/u) ||
4086
+ patterns.some((pattern) => addedDiffLinesContainClient(diffText, pattern))
4087
+ );
4088
+ }
4089
+
4090
+ function hasUnsafeSourceWriteDiffClient(diffText) {
4091
+ const patterns = [
4092
+ /\bprocess\.env\b/u,
4093
+ /\b(?:child_process|spawn|exec|execFile|fork)\b/u,
4094
+ /\bfs\.(?:write|append|rm|unlink|rename|chmod|chown|copyFile|cp)\b/u,
4095
+ /\b(?:fetch|axios|XMLHttpRequest)\s*\(/u,
4096
+ /\b(?:net|tls|dgram|http2?)\b/u,
4097
+ /\bcrypto\b/u,
4098
+ /\b(?:secret|token|password|privateKey|credential)\b/iu,
4099
+ ];
4100
+ return (
4101
+ addedDiffLinesContainClient(diffText, /^\s*(?:import\s|export\s+.*\s+from\s)/u) ||
4102
+ addedDiffLinesContainClient(diffText, /\brequire\s*\(/u) ||
4103
+ patterns.some((pattern) => addedDiffLinesContainClient(diffText, pattern))
4104
+ );
4105
+ }
4106
+
4107
+ function classifyManualWriteLaneSuggestion(entry) {
4108
+ const fileRefs = normalizeClientFileRefs(entry?.fileRefs);
4109
+ const diffText = normalizeClientText(entry?.diffText || "");
4110
+ const added = Math.max(0, Number(entry?.diffAddedLines) || 0);
4111
+ const removed = Math.max(0, Number(entry?.diffRemovedLines) || 0);
4112
+ const totalChangedLines = added + removed;
4113
+ if (!fileRefs.length || !diffText || totalChangedLines === 0) {
4114
+ return "";
4115
+ }
4116
+ if (/^(?:new file mode|deleted file mode|rename from|rename to|old mode|new mode|similarity index|dissimilarity index|GIT binary patch|Binary files )/mu.test(diffText)) {
4117
+ return "";
4118
+ }
4119
+ if (
4120
+ fileRefs.length >= 1 &&
4121
+ fileRefs.length <= 3 &&
4122
+ totalChangedLines <= 120 &&
4123
+ fileRefs.every((fileRef) => autoPilotContentWritePathClient(fileRef))
4124
+ ) {
4125
+ return "content";
4126
+ }
4127
+ if (
4128
+ fileRefs.length >= 1 &&
4129
+ fileRefs.length <= 2 &&
4130
+ totalChangedLines <= 80 &&
4131
+ fileRefs.every((fileRef) => autoPilotUiTestsWritePathClient(fileRef)) &&
4132
+ !hasUnsafeUiOrTestWriteDiffClient(diffText)
4133
+ ) {
4134
+ return "ui_tests";
4135
+ }
4136
+ if (
4137
+ fileRefs.length === 1 &&
4138
+ totalChangedLines <= 40 &&
4139
+ fileRefs.every((fileRef) => autoPilotSourceWritePathClient(fileRef)) &&
4140
+ !hasUnsafeSourceWriteDiffClient(diffText)
4141
+ ) {
4142
+ return "source";
4143
+ }
4144
+ return "";
4145
+ }
4146
+
4147
+ function isWriteLaneEnabled(lane) {
4148
+ return lane === "content"
4149
+ ? state.session?.autoPilotWriteLaneContent === true
4150
+ : lane === "ui_tests"
4151
+ ? state.session?.autoPilotWriteLaneUiTests === true
4152
+ : state.session?.autoPilotWriteLaneSource === true;
4153
+ }
4154
+
4155
+ function recentAutoPilotSuggestions(limit = 40, minCount = 3) {
4156
+ const entries = Array.isArray(state.timeline?.entries) ? state.timeline.entries : [];
4157
+ const counts = new Map();
4158
+ for (const entry of entries
4159
+ .filter((item) => isManualApprovedWriteEntry(item))
4160
+ .sort((a, b) => (Number(b.createdAtMs) || 0) - (Number(a.createdAtMs) || 0))
4161
+ .slice(0, limit)) {
4162
+ const lane = classifyManualWriteLaneSuggestion(entry);
4163
+ if (!lane || isWriteLaneEnabled(lane)) {
4164
+ continue;
4165
+ }
4166
+ counts.set(lane, (counts.get(lane) || 0) + 1);
4167
+ }
4168
+ return ["content", "ui_tests", "source"]
4169
+ .map((lane) => ({ lane, count: counts.get(lane) || 0 }))
4170
+ .filter((item) => item.count >= minCount);
4171
+ }
4172
+
4173
+ function firstMarkdownCodeFence(text) {
4174
+ const match = String(text || "").match(/```(?:\w+)?\n([\s\S]*?)\n```/u);
4175
+ return normalizeClientText(match?.[1] || "");
4176
+ }
4177
+
4178
+ function truncateUiText(value, maxGlyphs = 92) {
4179
+ const normalized = normalizeClientText(value || "");
4180
+ if (!normalized) {
4181
+ return "";
4182
+ }
4183
+ const glyphs = Array.from(normalized);
4184
+ return glyphs.length > maxGlyphs ? `${glyphs.slice(0, maxGlyphs).join("")}…` : normalized;
4185
+ }
4186
+
4187
+ function autoPilotEntryHeadline(item) {
4188
+ const mode = autoPilotEntryMode(item);
4189
+ if (mode === "read") {
4190
+ return truncateUiText(firstMarkdownCodeFence(item?.messageText || "") || item?.summary || item?.title || "");
4191
+ }
4192
+
4193
+ const fileRefs = normalizeClientFileRefs(item?.fileRefs);
4194
+ const primaryRef = fileRefs[0] || item?.summary || item?.title || "";
4195
+ const extraCount = Math.max(0, fileRefs.length - 1);
4196
+ const stats =
4197
+ Number.isFinite(Number(item?.diffAddedLines)) && Number.isFinite(Number(item?.diffRemovedLines))
4198
+ ? ` (+${Math.max(0, Number(item?.diffAddedLines) || 0)} / -${Math.max(0, Number(item?.diffRemovedLines) || 0)})`
4199
+ : "";
4200
+ return truncateUiText(`${primaryRef}${extraCount > 0 ? ` +${extraCount}` : ""}${stats}`);
4201
+ }
4202
+
4203
+ function renderSettingsAutoPilotRecentEntry(item) {
4204
+ const mode = autoPilotEntryMode(item);
4205
+ const badgeLabel = mode === "write" ? L("settings.autoPilot.recentWrite") : L("settings.autoPilot.recentRead");
4206
+ const badgeClass = mode === "write" ? "settings-compose-badge--write" : "settings-compose-badge--read";
4207
+ const iconName = mode === "write" ? "file-event" : "approval";
4208
+ const iconToneClass = mode === "write" ? "settings-icon-entry__icon--write" : "settings-icon-entry__icon--read";
4209
+ const headline = autoPilotEntryHeadline(item) || L("common.untitledItem");
4210
+ const threadLabel = resolvedThreadLabel(item?.threadId || "", item?.threadLabel || "");
4211
+ const laneLabel = ({
4212
+ content: L("settings.autoPilot.recentContent"),
4213
+ "ui_tests": L("settings.autoPilot.recentUiTests"),
4214
+ source: L("settings.autoPilot.recentSource"),
4215
+ }[autoPilotEntryWriteLane(item)] || "");
4216
+ const metaParts = [providerDisplayName(item?.provider), formatTimelineTimestamp(item?.createdAtMs)].filter(Boolean);
4217
+
4218
+ return `
4219
+ <button
4220
+ type="button"
4221
+ class="settings-compose-entry settings-icon-entry settings-autopilot-entry"
4222
+ data-open-item-kind="${escapeHtml(item.kind)}"
4223
+ data-open-item-token="${escapeHtml(item.token)}"
4224
+ data-source-tab="timeline"
4225
+ >
4226
+ <span class="settings-icon-entry__icon ${iconToneClass}" aria-hidden="true">${renderIcon(iconName)}</span>
4227
+ <span class="settings-icon-entry__body">
4228
+ <span class="settings-icon-entry__title-row">
4229
+ <span class="settings-compose-entry__title">${escapeHtml(headline)}</span>
4230
+ <span class="settings-compose-badge ${badgeClass}">${escapeHtml(badgeLabel)}</span>
4231
+ ${mode === "write" && laneLabel ? `<span class="settings-compose-badge settings-compose-badge--lane">${escapeHtml(laneLabel)}</span>` : ""}
4232
+ </span>
4233
+ <span class="settings-autopilot-entry__meta">${escapeHtml(metaParts.join(" · "))}</span>
4234
+ ${threadLabel ? `<span class="settings-autopilot-entry__thread">${escapeHtml(threadLabel)}</span>` : ""}
4235
+ </span>
4236
+ </button>
4237
+ `;
4238
+ }
4239
+
4240
+ function renderSettingsAutoPilotSuggestion({ lane, count }) {
4241
+ const title =
4242
+ lane === "content"
4243
+ ? L("settings.autoPilot.suggestionContentTitle")
4244
+ : lane === "ui_tests"
4245
+ ? L("settings.autoPilot.suggestionUiTestsTitle")
4246
+ : L("settings.autoPilot.suggestionSourceTitle");
4247
+ const body =
4248
+ lane === "content"
4249
+ ? L("settings.autoPilot.suggestionContentBody", { count })
4250
+ : lane === "ui_tests"
4251
+ ? L("settings.autoPilot.suggestionUiTestsBody", { count })
4252
+ : L("settings.autoPilot.suggestionSourceBody", { count });
4253
+ return `
4254
+ <div class="settings-suggestion-card">
4255
+ <div class="settings-suggestion-card__header">
4256
+ <div>
4257
+ <p class="settings-suggestion-card__title">${escapeHtml(title)}</p>
4258
+ <p class="settings-suggestion-card__body">${escapeHtml(body)}</p>
4259
+ </div>
4260
+ <button
4261
+ type="button"
4262
+ class="primary settings-suggestion-card__action"
4263
+ data-auto-pilot-suggest-lane="${escapeHtml(lane)}"
4264
+ >${escapeHtml(L("settings.autoPilot.suggestionEnable"))}</button>
4265
+ </div>
4266
+ </div>
4267
+ `;
4268
+ }
4269
+
3426
4270
  function renderSettingsMoltbookPage(context) {
3427
4271
  const scout = context.moltbookScout;
3428
4272
  if (!scout?.enabled) {
@@ -3694,6 +4538,406 @@ function renderSettingsA2aSharePage(context) {
3694
4538
  `;
3695
4539
  }
3696
4540
 
4541
+ function renderSettingsWalletPage(context) {
4542
+ const hazbase = context.hazbase || { enabled: false };
4543
+ if (!hazbase?.enabled) {
4544
+ return `
4545
+ <div class="settings-page">
4546
+ <p class="settings-page-copy muted">${escapeHtml(L("settings.wallet.unavailable"))}</p>
4547
+ </div>
4548
+ `;
4549
+ }
4550
+ const flow = deriveHazbaseWalletFlow(hazbase);
4551
+
4552
+ // Progressive disclosure. Previous layout rendered the full status summary
4553
+ // plus all four full-sized step cards at once; new account flows were
4554
+ // dominated by locked-looking cards and it was hard to tell which step was
4555
+ // actionable. Now we:
4556
+ // - skip `locked` steps entirely (still-unreachable actions add noise),
4557
+ // - collapse `complete` steps to a compact one-line row that keeps the
4558
+ // verified value visible (email / address) without a full card,
4559
+ // - keep the single `current`/`pending` step in the full action card so
4560
+ // the CTA is unambiguous,
4561
+ // - hide the optional mainnet step behind a subtle opt-in link until the
4562
+ // user explicitly requests it (see `state.hazbaseMainnetOptIn`).
4563
+ // The compact rows also replace the former "Current status" summary group
4564
+ // above the flow, so signed-in email / passkey state / addresses are no
4565
+ // longer duplicated.
4566
+ const guideRows = [
4567
+ renderHazbaseWalletBanner(flow),
4568
+ state.hazbaseNotice
4569
+ ? `<div class="settings-copy-block settings-copy-block--compact wallet-flow-message wallet-flow-message--notice"><p>${escapeHtml(state.hazbaseNotice)}</p></div>`
4570
+ : "",
4571
+ state.hazbaseError
4572
+ ? `<div class="settings-copy-block settings-copy-block--compact wallet-flow-message wallet-flow-message--error"><p>${escapeHtml(state.hazbaseError)}</p></div>`
4573
+ : "",
4574
+ renderHazbaseWalletStepList(flow),
4575
+ ].filter(Boolean);
4576
+ const canRefreshSession = Boolean(hazbase.sessionInvalid);
4577
+ const advancedActions = canRefreshSession || hazbase.signedIn
4578
+ ? [
4579
+ canRefreshSession
4580
+ ? `<button class="secondary secondary--wide" type="button" data-hazbase-action="refresh-session">${escapeHtml(L("settings.hazbase.action.refreshSession"))}</button>`
4581
+ : "",
4582
+ hazbase.signedIn
4583
+ ? `<button class="secondary secondary--wide" type="button" data-hazbase-action="logout">${escapeHtml(L("settings.hazbase.action.signOut"))}</button>`
4584
+ : "",
4585
+ ].filter(Boolean).join("")
4586
+ : "";
4587
+ // Render the wallet flow without `renderSettingsGroup`'s `.settings-list`
4588
+ // wrapper. The banner (`.settings-copy-block`), notice/error blocks, and
4589
+ // each step card (`.wallet-step-card`) already have their own rounded
4590
+ // frame — wrapping them in another `.settings-list` produced a visible
4591
+ // "box inside a box" nest. The title (`.settings-group__title`) still
4592
+ // sits above a flat stack of sibling cards via `.wallet-setup-stack`.
4593
+ const flowTitle = L("settings.wallet.flow.title");
4594
+ // Brand attribution. The wallet stack (factory + validator + bundler +
4595
+ // paymaster) is provided by hazBase; the link points to their LP so
4596
+ // curious users can discover what's running the signing path.
4597
+ const poweredBy = `
4598
+ <p class="wallet-powered-by muted">
4599
+ powered by <a href="https://lp.hazbase.com" target="_blank" rel="noopener noreferrer">hazBase</a>
4600
+ </p>
4601
+ `;
4602
+ return `
4603
+ <div class="settings-page">
4604
+ <section class="settings-group">
4605
+ ${flowTitle ? `<p class="settings-group__title">${escapeHtml(flowTitle)}</p>` : ""}
4606
+ <div class="wallet-setup-stack">
4607
+ ${guideRows.join("")}
4608
+ </div>
4609
+ </section>
4610
+ ${advancedActions ? renderSettingsActionPanel(advancedActions, L("settings.wallet.advanced.title")) : ""}
4611
+ ${poweredBy}
4612
+ </div>
4613
+ `;
4614
+ }
4615
+
4616
+ function renderHazbaseWalletStepList(flow) {
4617
+ const rendered = [];
4618
+ for (const step of flow.steps) {
4619
+ // Step 4 (Base mainnet) is hidden while hazbase's closed beta keeps
4620
+ // chainId 8453 out of `WALLET_GATEWAY_CHAINS_JSON` — calling bootstrap
4621
+ // against mainnet currently returns `unsupported_chain`, so there's no
4622
+ // path for the user to complete the step. Step 3 (Base Sepolia) is the
4623
+ // true completion boundary; the ready banner already keys off
4624
+ // `coreReady = signedIn && hasPasskey && hasBaseSepolia`. Drop this
4625
+ // guard (and restore the opt-in reveal) once mainnet is enabled
4626
+ // server-side.
4627
+ if (step.number === 4) {
4628
+ continue;
4629
+ }
4630
+
4631
+ // Locked steps don't render. Revealing them only adds grayed-out
4632
+ // placeholders below the active step; users mistake the placeholder
4633
+ // status chips for inactive buttons.
4634
+ if (step.status === "locked") {
4635
+ continue;
4636
+ }
4637
+
4638
+ const mode = step.status === "complete" ? "compact" : "full";
4639
+ rendered.push(renderHazbaseWalletStepCard(step, { mode }));
4640
+ }
4641
+ return `<div class="wallet-step-list">${rendered.join("")}</div>`;
4642
+ }
4643
+
4644
+ function renderHazbaseWalletMainnetOptIn() {
4645
+ return `
4646
+ <button class="wallet-mainnet-optin" type="button" data-hazbase-action="mainnet-opt-in">
4647
+ <span class="wallet-mainnet-optin__body">
4648
+ <span class="wallet-mainnet-optin__label">${escapeHtml(L("settings.wallet.mainnet.optIn"))}</span>
4649
+ <span class="wallet-mainnet-optin__hint muted">${escapeHtml(L("settings.wallet.mainnet.optInHint"))}</span>
4650
+ </span>
4651
+ <span class="wallet-mainnet-optin__chevron" aria-hidden="true">→</span>
4652
+ </button>
4653
+ `;
4654
+ }
4655
+
4656
+ function deriveHazbaseWalletFlow(hazbase) {
4657
+ const accounts = Array.isArray(hazbase.accounts) ? hazbase.accounts : [];
4658
+ const baseSepolia = accounts.find((entry) => Number(entry.chainId) === 84532) || null;
4659
+ const baseMainnet = accounts.find((entry) => Number(entry.chainId) === 8453) || null;
4660
+ const sessionInvalid = Boolean(hazbase.sessionInvalid);
4661
+ const signedIn = Boolean(hazbase.signedIn) && !sessionInvalid;
4662
+ const passkeyHost = hazbasePasskeyHostSupport();
4663
+ const hasPasskey = Boolean(hazbase.credentialId || hazbase.deviceBindingId);
4664
+ const hasBaseSepolia = Boolean(baseSepolia?.smartAccountAddress);
4665
+ const hasBaseMainnet = Boolean(baseMainnet?.smartAccountAddress);
4666
+ const coreReady = signedIn && hasPasskey && hasBaseSepolia;
4667
+
4668
+ const actionButton = (labelKey, action, { primary = false, disabled = false } = {}) => `
4669
+ <button
4670
+ class="${primary ? "primary" : "secondary"} ${primary ? "primary--wide" : "secondary--wide"}"
4671
+ type="button"
4672
+ data-hazbase-action="${action}"
4673
+ ${disabled ? "disabled" : ""}
4674
+ >${escapeHtml(L(labelKey))}</button>
4675
+ `;
4676
+
4677
+ return {
4678
+ hasPasskey,
4679
+ baseSepolia,
4680
+ baseMainnet,
4681
+ sessionInvalid,
4682
+ coreReady,
4683
+ steps: [
4684
+ {
4685
+ number: 1,
4686
+ icon: "approval",
4687
+ title: sessionInvalid ? L("settings.wallet.step.refreshSession.title") : L("settings.wallet.step.signIn.title"),
4688
+ copy: sessionInvalid ? L("settings.wallet.step.refreshSession.copy") : L("settings.wallet.step.signIn.copy"),
4689
+ detail: signedIn
4690
+ ? hazbase.email || L("settings.hazbase.status.signedIn")
4691
+ : state.hazbaseOtpRequested
4692
+ ? L("settings.hazbase.status.otpAwaitingVerify")
4693
+ : sessionInvalid
4694
+ ? L("settings.hazbase.status.sessionExpired")
4695
+ : L("settings.hazbase.status.signedOut"),
4696
+ status: signedIn ? "complete" : "current",
4697
+ // Inline form: email input (always visible pre-sign-in) and the
4698
+ // one-time password input (revealed after a successful send).
4699
+ // Putting the field immediately above its submit button is the
4700
+ // whole point — users don't have to guess "what does this button
4701
+ // ask me?" before clicking.
4702
+ form: signedIn
4703
+ ? ""
4704
+ : renderHazbaseSignInForm({
4705
+ email: state.hazbaseOtpEmail || hazbase.email || "",
4706
+ otpRequested: Boolean(state.hazbaseOtpRequested),
4707
+ code: state.hazbaseOtpCode || "",
4708
+ }),
4709
+ // Sequential flow: before a code is requested, only the primary
4710
+ // "send" action is visible. After a successful send we swap the
4711
+ // primary CTA to "verify" and demote "send" to a quieter "resend"
4712
+ // option in case the email never arrives. This keeps the user's
4713
+ // next move unambiguous.
4714
+ actions: signedIn
4715
+ ? []
4716
+ : state.hazbaseOtpRequested
4717
+ ? [
4718
+ actionButton("settings.hazbase.action.verifyOtp", "verify-otp", { primary: true }),
4719
+ actionButton("settings.hazbase.action.resendOtp", "request-otp"),
4720
+ ]
4721
+ : [
4722
+ actionButton(sessionInvalid ? "settings.hazbase.action.refreshSession" : "settings.hazbase.action.requestOtp", "request-otp", { primary: true }),
4723
+ ],
4724
+ },
4725
+ {
4726
+ number: 2,
4727
+ icon: "lock",
4728
+ title: L("settings.wallet.step.passkey.title"),
4729
+ copy: L("settings.wallet.step.passkey.copy"),
4730
+ detail: hasPasskey
4731
+ ? L("settings.hazbase.passkey.ready")
4732
+ : passkeyHost.eligible
4733
+ ? L("settings.hazbase.passkey.missing")
4734
+ : passkeyHost.detail,
4735
+ status: hasPasskey ? "complete" : signedIn ? "current" : "locked",
4736
+ actions: hasPasskey
4737
+ ? []
4738
+ : [
4739
+ actionButton("settings.hazbase.action.registerPasskey", "register-passkey", {
4740
+ primary: signedIn && passkeyHost.eligible,
4741
+ disabled: !signedIn || !passkeyHost.eligible,
4742
+ }),
4743
+ ],
4744
+ },
4745
+ {
4746
+ number: 3,
4747
+ icon: "coin",
4748
+ title: L("settings.wallet.step.baseSepolia.title"),
4749
+ copy: L("settings.wallet.step.baseSepolia.copy"),
4750
+ detail: baseSepolia?.smartAccountAddress || L("settings.hazbase.wallet.missing"),
4751
+ monoDetail: Boolean(baseSepolia?.smartAccountAddress),
4752
+ status: hasBaseSepolia ? "complete" : signedIn && hasPasskey ? "current" : "locked",
4753
+ actions: hasBaseSepolia
4754
+ ? []
4755
+ : [
4756
+ actionButton("settings.hazbase.action.bootstrapBaseSepolia", "bootstrap-base-sepolia", {
4757
+ primary: signedIn && hasPasskey,
4758
+ disabled: !signedIn || !hasPasskey,
4759
+ }),
4760
+ ],
4761
+ },
4762
+ {
4763
+ number: 4,
4764
+ icon: "coin",
4765
+ title: L("settings.wallet.step.base.title"),
4766
+ copy: L("settings.wallet.step.base.copy"),
4767
+ detail: baseMainnet?.smartAccountAddress || L("settings.hazbase.wallet.missing"),
4768
+ monoDetail: Boolean(baseMainnet?.smartAccountAddress),
4769
+ status: hasBaseMainnet ? "complete" : coreReady ? "optional" : "locked",
4770
+ actions: hasBaseMainnet
4771
+ ? []
4772
+ : [
4773
+ actionButton("settings.hazbase.action.bootstrapBase", "bootstrap-base", {
4774
+ disabled: !coreReady,
4775
+ }),
4776
+ ],
4777
+ },
4778
+ ],
4779
+ };
4780
+ }
4781
+
4782
+ function renderHazbaseWalletBanner(flow) {
4783
+ const title = flow.sessionInvalid
4784
+ ? L("settings.wallet.sessionExpired.title")
4785
+ : flow.coreReady ? L("settings.wallet.ready.title") : L("settings.wallet.flow.banner.title");
4786
+ const className = flow.coreReady
4787
+ ? "settings-copy-block settings-copy-block--stacked wallet-flow-banner wallet-flow-banner--ready"
4788
+ : flow.sessionInvalid
4789
+ ? "settings-copy-block settings-copy-block--stacked wallet-flow-banner wallet-flow-banner--session-expired"
4790
+ : "settings-copy-block settings-copy-block--stacked wallet-flow-banner";
4791
+ // Ready state: surface the smart-account address prominently so the user
4792
+ // can see at a glance which wallet agents will use as `--pay-to` when
4793
+ // gating paid shares / A2A payouts. The address is the single most
4794
+ // actionable fact on this page once setup is complete — muted filler
4795
+ // copy buries it.
4796
+ const payoutAddress = flow.baseSepolia?.smartAccountAddress || "";
4797
+ const copyLabel = L("settings.wallet.ready.copyAddress");
4798
+ const body = flow.coreReady && payoutAddress
4799
+ ? `
4800
+ <p class="wallet-flow-banner__copy muted">${escapeHtml(L("settings.wallet.ready.payoutIntro"))}</p>
4801
+ <button
4802
+ class="wallet-flow-banner__address"
4803
+ type="button"
4804
+ data-wallet-address-copy="${escapeHtml(payoutAddress)}"
4805
+ aria-label="${escapeHtml(copyLabel)}: ${escapeHtml(payoutAddress)}"
4806
+ >
4807
+ <span class="wallet-flow-banner__address-text">${escapeHtml(payoutAddress)}</span>
4808
+ <span class="wallet-flow-banner__address-icon-slot">
4809
+ <span class="wallet-flow-banner__address-icon wallet-flow-banner__address-icon--copy" aria-hidden="true">${renderIcon("copy")}</span>
4810
+ <span class="wallet-flow-banner__address-icon wallet-flow-banner__address-icon--check" aria-hidden="true">${renderIcon("check")}</span>
4811
+ </span>
4812
+ </button>
4813
+ `
4814
+ : `<p class="wallet-flow-banner__copy muted">${escapeHtml(
4815
+ flow.sessionInvalid
4816
+ ? L("settings.wallet.sessionExpired.copy")
4817
+ : flow.coreReady ? L("settings.wallet.ready.copy") : L("settings.wallet.flow.copy"),
4818
+ )}</p>`;
4819
+ return `
4820
+ <div class="${className}">
4821
+ <p class="wallet-flow-banner__eyebrow">${escapeHtml(L("settings.hazbase.title"))}</p>
4822
+ <p class="wallet-flow-banner__title">${escapeHtml(title)}</p>
4823
+ ${body}
4824
+ </div>
4825
+ `;
4826
+ }
4827
+
4828
+ function renderHazbaseWalletStepCard(step, { mode = "full" } = {}) {
4829
+ const statusMeta = {
4830
+ complete: { label: L("settings.wallet.status.complete"), icon: "completed" },
4831
+ current: { label: L("settings.wallet.status.current"), icon: "pending" },
4832
+ locked: { label: L("settings.wallet.status.locked"), icon: "lock" },
4833
+ optional: { label: L("settings.wallet.status.optional"), icon: "coin" },
4834
+ pending: { label: L("settings.wallet.status.pending"), icon: "pending" },
4835
+ }[step.status] || { label: L("settings.wallet.status.pending"), icon: "pending" };
4836
+
4837
+ if (mode === "compact") {
4838
+ // Compact row for finished steps. Keeps the check icon + title + one-line
4839
+ // detail visible (so the user can scan what's done at a glance) but
4840
+ // drops the copy/actions to stop the page from being four blocks tall.
4841
+ const detailClass = step.monoDetail
4842
+ ? "wallet-step-card__compact-detail wallet-step-card__compact-detail--mono"
4843
+ : "wallet-step-card__compact-detail";
4844
+ return `
4845
+ <div class="wallet-step-card wallet-step-card--compact wallet-step-card--${escapeHtml(step.status)}">
4846
+ <span class="wallet-step-card__compact-icon" aria-hidden="true">${renderIcon(statusMeta.icon)}</span>
4847
+ <div class="wallet-step-card__compact-body">
4848
+ <p class="wallet-step-card__compact-title">${escapeHtml(step.title)}</p>
4849
+ ${step.detail ? `<p class="${detailClass}">${escapeHtml(step.detail)}</p>` : ""}
4850
+ </div>
4851
+ <span class="wallet-step-card__compact-status" aria-hidden="true">${escapeHtml(statusMeta.label)}</span>
4852
+ </div>
4853
+ `;
4854
+ }
4855
+
4856
+ return `
4857
+ <article class="wallet-step-card wallet-step-card--${escapeHtml(step.status)}">
4858
+ <div class="wallet-step-card__header">
4859
+ <div class="wallet-step-card__headline">
4860
+ <span class="wallet-step-card__icon" aria-hidden="true">${renderIcon(step.icon)}</span>
4861
+ <div class="wallet-step-card__title-wrap">
4862
+ <p class="wallet-step-card__eyebrow">${escapeHtml(L("settings.wallet.stepNumber", { count: step.number }))}</p>
4863
+ <h3 class="wallet-step-card__title">${escapeHtml(step.title)}</h3>
4864
+ </div>
4865
+ </div>
4866
+ <span class="wallet-step-card__status wallet-step-card__status--${escapeHtml(step.status)}">
4867
+ <span class="wallet-step-card__status-icon" aria-hidden="true">${renderIcon(statusMeta.icon)}</span>
4868
+ <span>${escapeHtml(statusMeta.label)}</span>
4869
+ </span>
4870
+ </div>
4871
+ <p class="wallet-step-card__copy">${escapeHtml(step.copy)}</p>
4872
+ <p class="wallet-step-card__detail ${step.monoDetail ? "wallet-step-card__detail--mono" : ""}">${escapeHtml(step.detail)}</p>
4873
+ ${step.form || ""}
4874
+ ${step.actions.length ? `<div class="wallet-step-card__actions">${step.actions.join("")}</div>` : ""}
4875
+ </article>
4876
+ `;
4877
+ }
4878
+
4879
+ // Sign-in form sits inside step 1 when the user hasn't signed in yet.
4880
+ // Email field is always rendered (editable so users can correct a typo
4881
+ // and resend); OTP field only appears once a code has been issued. Both
4882
+ // inputs carry value="..." sourced from state so a poll-triggered
4883
+ // re-render doesn't wipe what the user was typing mid-edit.
4884
+ function renderHazbaseSignInForm({ email, otpRequested, code }) {
4885
+ // Once the OTP has been sent we lock the email field so accidental edits
4886
+ // can't invalidate the pending code. A small "change email" link is shown
4887
+ // as the intentional escape hatch — clicking it reverts the form to the
4888
+ // pre-sent state (code discarded, email stays for easy typo recovery).
4889
+ const emailLocked = Boolean(otpRequested);
4890
+ const emailLabelRow = emailLocked
4891
+ ? `<span class="wallet-step-card__field-label-row">
4892
+ <span class="wallet-step-card__field-label">${escapeHtml(L("settings.hazbase.field.emailLabel"))}</span>
4893
+ <button
4894
+ type="button"
4895
+ class="wallet-step-card__field-link"
4896
+ data-hazbase-action="change-email"
4897
+ >${escapeHtml(L("settings.hazbase.action.changeEmail"))}</button>
4898
+ </span>`
4899
+ : `<span class="wallet-step-card__field-label">${escapeHtml(L("settings.hazbase.field.emailLabel"))}</span>`;
4900
+ const emailField = `
4901
+ <label class="wallet-step-card__field">
4902
+ ${emailLabelRow}
4903
+ <input
4904
+ type="email"
4905
+ class="wallet-step-card__field-input${emailLocked ? " wallet-step-card__field-input--locked" : ""}"
4906
+ data-hazbase-input="otp-email"
4907
+ value="${escapeHtml(email || "")}"
4908
+ placeholder="${escapeHtml(L("settings.hazbase.field.emailPlaceholder"))}"
4909
+ autocomplete="email"
4910
+ inputmode="email"
4911
+ autocapitalize="off"
4912
+ autocorrect="off"
4913
+ spellcheck="false"
4914
+ ${emailLocked ? "disabled aria-disabled=\"true\"" : ""}
4915
+ />
4916
+ </label>
4917
+ `;
4918
+ const otpField = otpRequested
4919
+ ? `
4920
+ <label class="wallet-step-card__field">
4921
+ <span class="wallet-step-card__field-label">${escapeHtml(L("settings.hazbase.field.otpLabel"))}</span>
4922
+ <input
4923
+ type="text"
4924
+ class="wallet-step-card__field-input wallet-step-card__field-input--mono"
4925
+ data-hazbase-input="otp-code"
4926
+ value="${escapeHtml(code || "")}"
4927
+ placeholder="${escapeHtml(L("settings.hazbase.field.otpPlaceholder"))}"
4928
+ autocomplete="one-time-code"
4929
+ inputmode="numeric"
4930
+ autocapitalize="off"
4931
+ autocorrect="off"
4932
+ spellcheck="false"
4933
+ maxlength="12"
4934
+ />
4935
+ </label>
4936
+ `
4937
+ : "";
4938
+ return `<div class="wallet-step-card__form">${emailField}${otpField}</div>`;
4939
+ }
4940
+
3697
4941
  function formatRelativeAge(ms) {
3698
4942
  if (!Number.isFinite(ms) || ms < 0) return "";
3699
4943
  // Intl.RelativeTimeFormat with numeric:"auto" gives us locale-aware phrasing
@@ -3754,8 +4998,16 @@ function formatUsdcAtomic(atomic) {
3754
4998
  }
3755
4999
 
3756
5000
  function renderSettingsInfoRow(label, value, options = {}) {
3757
- const rowClassName = ["settings-info-row", options.rowClassName || ""].filter(Boolean).join(" ");
3758
- const valueClassName = ["settings-info-row__value", options.valueClassName || ""].filter(Boolean).join(" ");
5001
+ const rowClassName = [
5002
+ "settings-info-row",
5003
+ options.stacked ? "settings-info-row--stacked" : "",
5004
+ options.rowClassName || "",
5005
+ ].filter(Boolean).join(" ");
5006
+ const valueClassName = [
5007
+ "settings-info-row__value",
5008
+ options.mono ? "settings-info-row__value--mono" : "",
5009
+ options.valueClassName || "",
5010
+ ].filter(Boolean).join(" ");
3759
5011
  const displayValue = options.rawValue ? value : escapeHtml(value);
3760
5012
  return `
3761
5013
  <div class="${rowClassName}">
@@ -3868,6 +5120,7 @@ function renderStandardDetailDesktop(detail) {
3868
5120
  <h2 class="detail-title detail-title--desktop">${renderDetailTitle(detail)}</h2>
3869
5121
  ${detail.readOnly || detail.kind === "approval" || detail.kind === "moltbook_draft" || detail.kind === "moltbook_reply" || detail.kind === "thread_share" ? "" : renderDetailLead(detail, kindInfo)}
3870
5122
  ${renderPreviousContextCard(detail)}
5123
+ ${renderAutoPilotManualReview(detail)}
3871
5124
  ${renderInterruptedDetailNotice(detail)}
3872
5125
  ${renderMoltbookReplyComposer(detail)}
3873
5126
  ${renderMoltbookDraftComposer(detail)}
@@ -3907,6 +5160,7 @@ function renderStandardDetailMobile(detail) {
3907
5160
  <div class="mobile-detail-scroll mobile-detail-scroll--detail">
3908
5161
  ${renderDetailMetaRow(detail, kindInfo, { mobile: true })}
3909
5162
  ${renderPreviousContextCard(detail, { mobile: true })}
5163
+ ${renderAutoPilotManualReview(detail, { mobile: true })}
3910
5164
  ${renderInterruptedDetailNotice(detail, { mobile: true })}
3911
5165
  ${renderMoltbookReplyComposer(detail, { mobile: true })}
3912
5166
  ${renderMoltbookDraftComposer(detail, { mobile: true })}
@@ -4087,6 +5341,25 @@ function renderPreviousContextCard(detail, options = {}) {
4087
5341
  `;
4088
5342
  }
4089
5343
 
5344
+ function renderAutoPilotManualReview(detail, options = {}) {
5345
+ const review = detail?.autoPilotReview;
5346
+ if (!review?.title || !review?.body) {
5347
+ return "";
5348
+ }
5349
+ return `
5350
+ <section class="detail-card detail-card--autopilot ${options.mobile ? "detail-card--mobile" : ""}">
5351
+ <div class="detail-context-card__header">
5352
+ <div class="detail-context-card__eyebrow">
5353
+ <span class="detail-context-card__icon" aria-hidden="true">${renderIcon("settings")}</span>
5354
+ <span>${escapeHtml(L("detail.autoPilotManualEyebrow"))}</span>
5355
+ </div>
5356
+ </div>
5357
+ <p class="detail-context-card__kind">${escapeHtml(review.title)}</p>
5358
+ <p class="detail-autopilot-copy">${escapeHtml(review.body)}</p>
5359
+ </section>
5360
+ `;
5361
+ }
5362
+
4090
5363
  function renderDetailImageGallery(detail, options = {}) {
4091
5364
  const imageUrls = Array.isArray(detail?.imageUrls) ? detail.imageUrls.filter(Boolean) : [];
4092
5365
  if (imageUrls.length === 0) {
@@ -5100,6 +6373,20 @@ function renderTopBanner() {
5100
6373
  return "";
5101
6374
  }
5102
6375
 
6376
+ function renderGlobalErrorBanner() {
6377
+ if (!state.pushError) {
6378
+ return "";
6379
+ }
6380
+ return `
6381
+ <section class="install-banner install-banner--push">
6382
+ <div class="install-banner__copy">
6383
+ <strong>${escapeHtml(state.locale === "ja" ? "エラー" : "Error")}</strong>
6384
+ <p class="muted">${escapeHtml(state.pushError)}</p>
6385
+ </div>
6386
+ </section>
6387
+ `;
6388
+ }
6389
+
5103
6390
  function renderPushBanner() {
5104
6391
  if (!shouldShowPushBanner()) {
5105
6392
  return "";
@@ -5168,6 +6455,35 @@ function renderImageViewerModal() {
5168
6455
  `;
5169
6456
  }
5170
6457
 
6458
+ function renderHazbaseLogoutConfirmModal() {
6459
+ if (!state.hazbaseLogoutConfirmOpen) {
6460
+ return "";
6461
+ }
6462
+ const email = state.hazbaseStatus?.email || "";
6463
+ // Mirror the session-logout dialog's shape but drop the split-choice
6464
+ // options — wallet sign-out is a single binary (log out or don't), no
6465
+ // "keep this device trusted" toggle applies.
6466
+ // Note: the backdrop uses a dedicated `data-close-hazbase-logout-confirm`
6467
+ // marker (bound in bindSharedUi) rather than a `data-hazbase-action`.
6468
+ // If we reused the action dispatcher here, an inside-card click would
6469
+ // bubble up to the backdrop's handler and run a second async dispatch
6470
+ // in parallel with the button's own handler — which races against the
6471
+ // API call. The mirror approach matches the session-logout modal.
6472
+ return `
6473
+ <div class="modal-backdrop" data-close-hazbase-logout-confirm>
6474
+ <section class="modal-card modal-card--confirm" role="dialog" aria-modal="true" aria-labelledby="hazbase-logout-confirm-title">
6475
+ <div class="helper-copy">
6476
+ <strong id="hazbase-logout-confirm-title">${escapeHtml(L("settings.hazbase.logout.confirm.title"))}</strong>
6477
+ <p class="muted">${escapeHtml(L("settings.hazbase.logout.confirm.copy"))}</p>
6478
+ ${email ? `<p class="muted"><code>${escapeHtml(email)}</code></p>` : ""}
6479
+ </div>
6480
+ <button class="secondary secondary--wide" type="button" data-hazbase-action="logout-confirm">${escapeHtml(L("settings.hazbase.logout.confirm.confirmLabel"))}</button>
6481
+ <button class="ghost ghost--wide" type="button" data-close-hazbase-logout-confirm>${escapeHtml(L("common.cancel"))}</button>
6482
+ </section>
6483
+ </div>
6484
+ `;
6485
+ }
6486
+
5171
6487
  function renderLogoutConfirmModal() {
5172
6488
  if (!state.logoutConfirmOpen || !state.session?.authenticated) {
5173
6489
  return "";
@@ -5254,6 +6570,7 @@ function bindShellInteractions() {
5254
6570
  openSettingsSubpage(nextPage);
5255
6571
  }
5256
6572
  await renderShell();
6573
+ refreshVersionStatusForTechnicalPage();
5257
6574
  });
5258
6575
  }
5259
6576
 
@@ -5266,6 +6583,7 @@ function bindShellInteractions() {
5266
6583
  syncCurrentItemUrl(null);
5267
6584
  openSettingsSubpage("advanced");
5268
6585
  await renderShell();
6586
+ refreshVersionStatusForTechnicalPage();
5269
6587
  });
5270
6588
  }
5271
6589
 
@@ -5273,6 +6591,7 @@ function bindShellInteractions() {
5273
6591
  button.addEventListener("click", async () => {
5274
6592
  openSettingsSubpage(button.dataset.settingsSubpage || "");
5275
6593
  await renderShell();
6594
+ refreshVersionStatusForTechnicalPage();
5276
6595
  });
5277
6596
  }
5278
6597
 
@@ -5283,6 +6602,8 @@ function bindShellInteractions() {
5283
6602
  });
5284
6603
  }
5285
6604
 
6605
+
6606
+
5286
6607
  for (const select of document.querySelectorAll("[data-timeline-thread-select]")) {
5287
6608
  const handleInteractionStart = () => {
5288
6609
  markThreadFilterInteraction();
@@ -5316,6 +6637,7 @@ function bindShellInteractions() {
5316
6637
  state.timelineKindFilter = "all";
5317
6638
  state.timelineKindFilterOpen = false;
5318
6639
  state.completedThreadFilter = "all";
6640
+ state.diffThreadFilter = "all";
5319
6641
  alignCurrentItemToVisibleEntries();
5320
6642
  await renderShell();
5321
6643
  });
@@ -5341,6 +6663,25 @@ function bindShellInteractions() {
5341
6663
  });
5342
6664
  }
5343
6665
 
6666
+ for (const select of document.querySelectorAll("[data-diff-thread-select]")) {
6667
+ const handleInteractionStart = () => {
6668
+ markThreadFilterInteraction();
6669
+ };
6670
+ const handleInteractionEnd = () => {
6671
+ clearThreadFilterInteraction();
6672
+ };
6673
+ select.addEventListener("pointerdown", handleInteractionStart);
6674
+ select.addEventListener("click", handleInteractionStart);
6675
+ select.addEventListener("focus", handleInteractionStart);
6676
+ select.addEventListener("blur", handleInteractionEnd);
6677
+ select.addEventListener("change", async () => {
6678
+ clearThreadFilterInteraction();
6679
+ state.diffThreadFilter = select.value || "all";
6680
+ alignCurrentItemToVisibleEntries();
6681
+ await renderShell();
6682
+ });
6683
+ }
6684
+
5344
6685
  for (const select of document.querySelectorAll("[data-completed-thread-select]")) {
5345
6686
  const handleInteractionStart = () => {
5346
6687
  markThreadFilterInteraction();
@@ -5493,7 +6834,23 @@ function bindShellInteractions() {
5493
6834
  button.innerHTML = `<span class="action-spinner" aria-hidden="true"></span><span>${escapeHtml(L("reply.sendSending"))}</span>`;
5494
6835
 
5495
6836
  try {
5496
- await apiPost(actionUrl, body);
6837
+ let postBody = body;
6838
+ if (body?.hazbaseReauth === true) {
6839
+ if (!hazbasePasskeyHostSupport().eligible) {
6840
+ throw new Error(L("error.hazbasePasskeyLocalHostRequired"));
6841
+ }
6842
+ const { createPasskeyAssertionCredential } = await loadHazbasePasskeyModule();
6843
+ const challenge = await apiPost("/api/hazbase/passkey/assert/challenge", { purpose: "reauth" });
6844
+ const credential = await createPasskeyAssertionCredential(challenge);
6845
+ await apiPost("/api/hazbase/passkey/assert/complete", {
6846
+ challengeId: challenge.challengeId,
6847
+ credential,
6848
+ purpose: "reauth",
6849
+ });
6850
+ postBody = { ...body };
6851
+ delete postBody.hazbaseReauth;
6852
+ }
6853
+ await apiPost(actionUrl, postBody);
5497
6854
  if (keepDetailOpen && activeItem?.kind === "approval") {
5498
6855
  pinActionOutcomeDetail(
5499
6856
  activeItem,
@@ -5513,6 +6870,9 @@ function bindShellInteractions() {
5513
6870
  state.pendingActionUrls.delete(actionUrl);
5514
6871
  } catch (error) {
5515
6872
  state.pendingActionUrls.delete(actionUrl);
6873
+ if (error?.errorKey === "hazbase-session-expired") {
6874
+ await fetchHazbaseStatus();
6875
+ }
5516
6876
  // Restore buttons on failure so the user can retry
5517
6877
  for (const sibling of siblingButtons) {
5518
6878
  if (originalLabels.has(sibling)) {
@@ -5522,7 +6882,8 @@ function bindShellInteractions() {
5522
6882
  sibling.removeAttribute("aria-busy");
5523
6883
  }
5524
6884
  button.classList.remove("is-loading");
5525
- throw error;
6885
+ state.pushError = error.message || String(error);
6886
+ await renderShell();
5526
6887
  }
5527
6888
  });
5528
6889
  }
@@ -5657,6 +7018,16 @@ function bindShellInteractions() {
5657
7018
  const action = button.dataset.pushAction;
5658
7019
  state.pushError = "";
5659
7020
  state.pushNotice = "";
7021
+ // Immediate visual feedback — enable/disable inherently wait on
7022
+ // Web Push APIs (permission prompt → pushManager.subscribe() →
7023
+ // server POST → refreshPushStatus), which can take 1–3 seconds.
7024
+ // Without this the button just sat there looking inert the whole
7025
+ // time. The .is-loading + aria-busy pair matches the pattern used
7026
+ // by approval action buttons elsewhere in this file.
7027
+ const wasDisabled = button.disabled;
7028
+ button.classList.add("is-loading");
7029
+ button.disabled = true;
7030
+ button.setAttribute("aria-busy", "true");
5660
7031
  try {
5661
7032
  if (action === "enable") {
5662
7033
  await enableNotifications();
@@ -5675,6 +7046,12 @@ function bindShellInteractions() {
5675
7046
  await refreshPushStatus();
5676
7047
  } catch (error) {
5677
7048
  state.pushError = error.message || String(error);
7049
+ // Restore this specific button on failure; renderShell() below
7050
+ // would rebuild it anyway but leaving it disabled between
7051
+ // exception and render flashes a dead button.
7052
+ button.classList.remove("is-loading");
7053
+ button.disabled = wasDisabled;
7054
+ button.removeAttribute("aria-busy");
5678
7055
  }
5679
7056
  await renderShell();
5680
7057
  });
@@ -5683,11 +7060,96 @@ function bindShellInteractions() {
5683
7060
  for (const checkbox of document.querySelectorAll("[data-claude-away-checkbox]")) {
5684
7061
  checkbox.addEventListener("change", async () => {
5685
7062
  const next = checkbox.checked === true;
7063
+ const previous = state.session?.claudeAwayMode === true;
7064
+ // Optimistic flip — the server round-trip and the old post-toggle
7065
+ // refreshAuthenticatedState() (7 endpoints) used to gate the UI
7066
+ // update. Flip state now, POST in background, roll back on error.
7067
+ if (state.session) {
7068
+ state.session.claudeAwayMode = next;
7069
+ }
7070
+ await renderShell();
5686
7071
  try {
5687
7072
  const result = await apiPost("/api/settings/claude-away-mode", { enabled: next });
7073
+ if (state.session && result && Object.prototype.hasOwnProperty.call(result, "enabled")) {
7074
+ const reconciled = result.enabled === true;
7075
+ if (reconciled !== next) {
7076
+ state.session.claudeAwayMode = reconciled;
7077
+ await renderShell();
7078
+ }
7079
+ }
7080
+ } catch (error) {
5688
7081
  if (state.session) {
5689
- state.session.claudeAwayMode = result?.enabled === true;
7082
+ state.session.claudeAwayMode = previous;
5690
7083
  }
7084
+ state.pushError = error.message || String(error);
7085
+ await renderShell();
7086
+ }
7087
+ });
7088
+ }
7089
+
7090
+ function applyAutoPilotSettingsResult(result) {
7091
+ if (!state.session) {
7092
+ return;
7093
+ }
7094
+ state.session.autoPilotTrustedReads = result?.trustedReadsEnabled === true;
7095
+ state.session.autoPilotTrustedWrites = result?.trustedWritesEnabled === true;
7096
+ state.session.autoPilotWriteLaneContent = result?.writeLaneContentEnabled === true;
7097
+ state.session.autoPilotWriteLaneUiTests = result?.writeLaneUiTestsEnabled === true;
7098
+ state.session.autoPilotWriteLaneSource = result?.writeLaneSourceEnabled === true;
7099
+ }
7100
+
7101
+ for (const checkbox of document.querySelectorAll("[data-auto-pilot-checkbox]")) {
7102
+ checkbox.addEventListener("change", async () => {
7103
+ const next = checkbox.checked === true;
7104
+ try {
7105
+ const result = await apiPost("/api/settings/auto-pilot", { trustedReadsEnabled: next });
7106
+ applyAutoPilotSettingsResult(result);
7107
+ await refreshAuthenticatedState();
7108
+ } catch (error) {
7109
+ state.pushError = error.message || String(error);
7110
+ }
7111
+ await renderShell();
7112
+ });
7113
+ }
7114
+
7115
+ for (const checkbox of document.querySelectorAll("[data-auto-pilot-write-lane-checkbox]")) {
7116
+ checkbox.addEventListener("change", async () => {
7117
+ const next = checkbox.checked === true;
7118
+ const lane = normalizeClientText(checkbox.getAttribute("data-auto-pilot-write-lane-checkbox") || "");
7119
+ const payload =
7120
+ lane === "content"
7121
+ ? { writeLaneContentEnabled: next }
7122
+ : lane === "ui-tests"
7123
+ ? { writeLaneUiTestsEnabled: next }
7124
+ : { writeLaneSourceEnabled: next };
7125
+ try {
7126
+ const result = await apiPost("/api/settings/auto-pilot", payload);
7127
+ applyAutoPilotSettingsResult(result);
7128
+ await refreshAuthenticatedState();
7129
+ } catch (error) {
7130
+ state.pushError = error.message || String(error);
7131
+ }
7132
+ await renderShell();
7133
+ });
7134
+ }
7135
+
7136
+ for (const button of document.querySelectorAll("[data-auto-pilot-suggest-lane]")) {
7137
+ button.addEventListener("click", async () => {
7138
+ const lane = normalizeClientText(button.getAttribute("data-auto-pilot-suggest-lane") || "");
7139
+ const payload =
7140
+ lane === "content"
7141
+ ? { writeLaneContentEnabled: true }
7142
+ : lane === "ui_tests"
7143
+ ? { writeLaneUiTestsEnabled: true }
7144
+ : lane === "source"
7145
+ ? { writeLaneSourceEnabled: true }
7146
+ : null;
7147
+ if (!payload) {
7148
+ return;
7149
+ }
7150
+ try {
7151
+ const result = await apiPost("/api/settings/auto-pilot", payload);
7152
+ applyAutoPilotSettingsResult(result);
5691
7153
  await refreshAuthenticatedState();
5692
7154
  } catch (error) {
5693
7155
  state.pushError = error.message || String(error);
@@ -5699,13 +7161,29 @@ function bindShellInteractions() {
5699
7161
  for (const checkbox of document.querySelectorAll("[data-a2a-public-checkbox]")) {
5700
7162
  checkbox.addEventListener("change", async () => {
5701
7163
  const next = checkbox.checked === true;
7164
+ const previous = state.a2aRelayStatus?.acceptPublicTasks === true;
7165
+ // Optimistic flip — POST + a follow-up GET of the (remote-worker)
7166
+ // relay-status endpoint used to gate the visual update. Flip the
7167
+ // local flag, render, then reconcile in background.
7168
+ if (state.a2aRelayStatus) {
7169
+ state.a2aRelayStatus = { ...state.a2aRelayStatus, acceptPublicTasks: next };
7170
+ }
7171
+ await renderShell();
5702
7172
  try {
5703
7173
  await apiPost("/api/a2a/public-tasks", { accept: next });
5704
- state.a2aRelayStatus = await apiGet("/api/a2a/relay-status");
7174
+ apiGet("/api/a2a/relay-status")
7175
+ .then((fresh) => {
7176
+ state.a2aRelayStatus = fresh;
7177
+ renderShell();
7178
+ })
7179
+ .catch(() => {});
5705
7180
  } catch (error) {
7181
+ if (state.a2aRelayStatus) {
7182
+ state.a2aRelayStatus = { ...state.a2aRelayStatus, acceptPublicTasks: previous };
7183
+ }
5706
7184
  state.pushError = error.message || String(error);
7185
+ await renderShell();
5707
7186
  }
5708
- await renderShell();
5709
7187
  });
5710
7188
  }
5711
7189
 
@@ -5713,13 +7191,24 @@ function bindShellInteractions() {
5713
7191
  radio.addEventListener("change", async () => {
5714
7192
  if (!radio.checked) return;
5715
7193
  const preference = radio.value || "auto";
7194
+ const previous = state.session?.a2aExecutorPreference || "ask";
7195
+ // Optimistic flip — the POST + refreshSession() GET used to gate
7196
+ // any re-render that depends on the preference (e.g. downstream
7197
+ // picker defaults). Flip locally and reconcile in background.
7198
+ if (state.session) {
7199
+ state.session.a2aExecutorPreference = preference;
7200
+ }
7201
+ await renderShell();
5716
7202
  try {
5717
7203
  await apiPost("/api/settings/a2a-executor", { preference });
5718
- await refreshSession();
7204
+ refreshSession().catch(() => {});
5719
7205
  } catch (error) {
7206
+ if (state.session) {
7207
+ state.session.a2aExecutorPreference = previous;
7208
+ }
5720
7209
  state.pushError = error.message || String(error);
7210
+ await renderShell();
5721
7211
  }
5722
- await renderShell();
5723
7212
  });
5724
7213
  }
5725
7214
 
@@ -5749,18 +7238,199 @@ function bindShellInteractions() {
5749
7238
  });
5750
7239
  }
5751
7240
 
7241
+
7242
+ for (const button of document.querySelectorAll("[data-hazbase-action]")) {
7243
+ button.addEventListener("click", async () => {
7244
+ state.hazbaseNotice = "";
7245
+ state.hazbaseError = "";
7246
+ const action = button.dataset.hazbaseAction || "";
7247
+ try {
7248
+ if (action === "request-otp") {
7249
+ // Read from the DOM input, not just state. The state mirror is
7250
+ // populated on every keystroke, but a pre-filled value (returning
7251
+ // user whose email came back from hazbase status) never fires
7252
+ // `input`, so state stays empty while the DOM shows the address.
7253
+ // Treat DOM as authoritative and fall back to state.
7254
+ const emailInput = document.querySelector('[data-hazbase-input="otp-email"]');
7255
+ const email = (emailInput?.value || state.hazbaseOtpEmail || "").trim();
7256
+ if (!email) throw new Error(L("error.hazbaseEmailRequired"));
7257
+ const result = await apiPost("/api/hazbase/request-otp", { email });
7258
+ state.hazbaseOtpEmail = email;
7259
+ state.hazbaseOtpRequested = true;
7260
+ state.hazbaseOtpCode = "";
7261
+ state.hazbaseNotice = result?.debugCode
7262
+ ? `${L("settings.hazbase.notice.otpRequested")} (${result.debugCode})`
7263
+ : L("settings.hazbase.notice.otpRequested");
7264
+ } else if (action === "verify-otp") {
7265
+ // Same pattern — DOM wins, state fallback covers the rare case
7266
+ // where the field was removed/re-added between type and click.
7267
+ const emailInput = document.querySelector('[data-hazbase-input="otp-email"]');
7268
+ const codeInput = document.querySelector('[data-hazbase-input="otp-code"]');
7269
+ const email = (emailInput?.value || state.hazbaseOtpEmail || "").trim();
7270
+ const code = (codeInput?.value || state.hazbaseOtpCode || "").trim();
7271
+ if (!email) throw new Error(L("error.hazbaseEmailRequired"));
7272
+ if (!code) throw new Error(L("error.hazbaseOtpRequired"));
7273
+ await apiPost("/api/hazbase/verify-otp", { email, code });
7274
+ state.hazbaseOtpRequested = false;
7275
+ state.hazbaseOtpEmail = "";
7276
+ state.hazbaseOtpCode = "";
7277
+ state.hazbaseNotice = L("settings.hazbase.notice.otpVerified");
7278
+ } else if (action === "register-passkey") {
7279
+ if (!hazbasePasskeyHostSupport().eligible) {
7280
+ throw new Error(L("error.hazbasePasskeyLocalHostRequired"));
7281
+ }
7282
+ const { createPasskeyRegistrationCredential } = await loadHazbasePasskeyModule();
7283
+ const challenge = await apiPost("/api/hazbase/passkey/register/challenge", {});
7284
+ const credential = await createPasskeyRegistrationCredential(challenge);
7285
+ await apiPost("/api/hazbase/passkey/register/complete", {
7286
+ challengeId: challenge.challengeId,
7287
+ credential,
7288
+ });
7289
+ state.hazbaseNotice = L("settings.hazbase.notice.passkeyRegistered");
7290
+ } else if (action === "bootstrap-base-sepolia" || action === "bootstrap-base") {
7291
+ if (!hazbasePasskeyHostSupport().eligible) {
7292
+ throw new Error(L("error.hazbasePasskeyLocalHostRequired"));
7293
+ }
7294
+ const { createPasskeyAssertionCredential } = await loadHazbasePasskeyModule();
7295
+ const chainId = action === "bootstrap-base" ? 8453 : 84532;
7296
+ const challenge = await apiPost("/api/hazbase/passkey/assert/challenge", { purpose: "bootstrap" });
7297
+ const credential = await createPasskeyAssertionCredential(challenge);
7298
+ await apiPost("/api/hazbase/passkey/assert/complete", {
7299
+ challengeId: challenge.challengeId,
7300
+ credential,
7301
+ purpose: "bootstrap",
7302
+ });
7303
+ await apiPost("/api/hazbase/account/bootstrap", { chainId });
7304
+ state.hazbaseNotice = L("settings.hazbase.notice.walletBootstrapped", { chainId });
7305
+ } else if (action === "logout") {
7306
+ // Gate wallet logout behind an explicit confirm — the modal's
7307
+ // "confirm" button dispatches `logout-confirm`, which actually
7308
+ // hits the API. Short-circuit here so the initial click only
7309
+ // opens the dialog.
7310
+ state.hazbaseLogoutConfirmOpen = true;
7311
+ await renderShell();
7312
+ return;
7313
+ } else if (action === "logout-confirm") {
7314
+ state.hazbaseLogoutConfirmOpen = false;
7315
+ await apiPost("/api/hazbase/logout", {});
7316
+ state.hazbaseNotice = L("settings.hazbase.notice.signedOut");
7317
+ } else if (action === "refresh-session") {
7318
+ await apiPost("/api/hazbase/session/refresh", {});
7319
+ state.hazbaseOtpRequested = false;
7320
+ state.hazbaseOtpCode = "";
7321
+ state.hazbaseNotice = L("settings.hazbase.notice.sessionRefreshStarted");
7322
+ } else if (action === "change-email") {
7323
+ // Flip the form back to pre-send mode. We keep the email so typo
7324
+ // recovery ("hoshin" → "hoshino") stays one edit away, but drop
7325
+ // the now-stale OTP code. No server call — hazbase invalidates
7326
+ // the previous OTP automatically when a fresh one is requested.
7327
+ state.hazbaseOtpRequested = false;
7328
+ state.hazbaseOtpCode = "";
7329
+ state.hazbaseNotice = "";
7330
+ state.hazbaseError = "";
7331
+ await renderShell();
7332
+ // Move focus back to the (now re-enabled) email input so the user
7333
+ // can start editing immediately.
7334
+ document.querySelector('[data-hazbase-input="otp-email"]')?.focus();
7335
+ return;
7336
+ } else if (action === "mainnet-opt-in") {
7337
+ // Pure client-side reveal — the mainnet step is always in the flow
7338
+ // data, we just hide it behind an opt-in link to keep the default
7339
+ // path (testnet only) focused. No network call; no status refetch.
7340
+ state.hazbaseMainnetOptIn = true;
7341
+ await renderShell();
7342
+ return;
7343
+ }
7344
+ await fetchHazbaseStatus();
7345
+ } catch (error) {
7346
+ if (error?.errorKey === "hazbase-session-expired") {
7347
+ state.hazbaseOtpRequested = false;
7348
+ state.hazbaseOtpCode = "";
7349
+ await fetchHazbaseStatus();
7350
+ }
7351
+ state.hazbaseError = error.message || String(error);
7352
+ }
7353
+ await renderShell();
7354
+ });
7355
+ }
7356
+
7357
+ // Mirror every keystroke into state so a background re-render (poll tick,
7358
+ // notice clear, etc.) can repopulate `value="..."` without wiping what
7359
+ // the user was typing. Reads happen at button-click time against state,
7360
+ // which is why we don't need to also query the DOM in the handler.
7361
+ for (const input of document.querySelectorAll("[data-hazbase-input]")) {
7362
+ const name = input.dataset.hazbaseInput || "";
7363
+ input.addEventListener("input", () => {
7364
+ if (name === "otp-email") state.hazbaseOtpEmail = input.value;
7365
+ else if (name === "otp-code") state.hazbaseOtpCode = input.value;
7366
+ });
7367
+ // Enter key submits the step. In the email field it triggers "send"
7368
+ // (or "verify" once a code was already issued — the email field stays
7369
+ // editable post-send to support typo recovery via resend). In the OTP
7370
+ // field it always submits verify.
7371
+ input.addEventListener("keydown", (event) => {
7372
+ if (event.key !== "Enter" || event.isComposing) return;
7373
+ event.preventDefault();
7374
+ const target = name === "otp-code"
7375
+ ? "verify-otp"
7376
+ : state.hazbaseOtpRequested ? "verify-otp" : "request-otp";
7377
+ const btn = document.querySelector(`[data-hazbase-action="${target}"]`);
7378
+ btn?.click();
7379
+ });
7380
+ }
7381
+
7382
+ // Tap-to-copy the wallet payout address. We swap the clipboard icon to a
7383
+ // check mark for ~1.5s via CSS (toggling `.is-copied`) rather than a full
7384
+ // re-render, so focus stays on the button and the rest of the settings
7385
+ // page doesn't flicker. The address sits inside a banner that doesn't
7386
+ // otherwise re-render on every tick, so an ephemeral class flip is the
7387
+ // cleanest way to acknowledge the copy.
7388
+ for (const button of document.querySelectorAll("[data-wallet-address-copy]")) {
7389
+ button.addEventListener("click", async () => {
7390
+ const text = button.dataset.walletAddressCopy || "";
7391
+ if (!text) return;
7392
+ try {
7393
+ await copyTextToClipboard(text);
7394
+ button.classList.add("is-copied");
7395
+ if (button._copyResetTimer) clearTimeout(button._copyResetTimer);
7396
+ button._copyResetTimer = setTimeout(() => {
7397
+ button.classList.remove("is-copied");
7398
+ button._copyResetTimer = null;
7399
+ }, 1500);
7400
+ } catch {
7401
+ // Copy failed (permissions denied, execCommand unsupported, etc).
7402
+ // We intentionally stay silent — the address is still visible and
7403
+ // `user-select: all` on the text span lets the user long-press to
7404
+ // select manually on iOS.
7405
+ }
7406
+ });
7407
+ }
7408
+
5752
7409
  for (const button of document.querySelectorAll("[data-locale-option]")) {
5753
7410
  button.addEventListener("click", async () => {
5754
7411
  state.pushError = "";
5755
7412
  state.pushNotice = "";
7413
+ const nextLocale = button.dataset.localeOption || "";
7414
+ // Flip language synchronously and render before the POST — the
7415
+ // UI switches in one frame instead of waiting on the round-trip
7416
+ // plus the 7-endpoint refreshAuthenticatedState() that followed.
7417
+ const previousSession = applyLocaleOverrideOptimistically(nextLocale);
7418
+ await renderShell();
5756
7419
  try {
5757
- await setLocaleOverride(button.dataset.localeOption || "");
5758
- await refreshSession();
5759
- await refreshAuthenticatedState();
7420
+ await persistLocaleOverride(nextLocale, previousSession);
7421
+ // Server-rendered inbox/timeline strings (kind labels, summaries)
7422
+ // are localised; refresh them in the background so the user
7423
+ // doesn't wait. Polling would eventually pick this up anyway,
7424
+ // but this makes the switch feel instant.
7425
+ Promise.all([
7426
+ refreshInbox().catch(() => {}),
7427
+ refreshInboxDiff().catch(() => {}),
7428
+ refreshTimeline().catch(() => {}),
7429
+ ]).then(() => renderShell());
5760
7430
  } catch (error) {
5761
7431
  state.pushError = error.message || String(error);
7432
+ await renderShell();
5762
7433
  }
5763
- await renderShell();
5764
7434
  });
5765
7435
  }
5766
7436
 
@@ -6261,6 +7931,21 @@ function bindSharedUi(renderFn) {
6261
7931
  });
6262
7932
  }
6263
7933
 
7934
+ for (const button of document.querySelectorAll("[data-close-hazbase-logout-confirm]")) {
7935
+ // Mirror the session-logout modal: the backdrop swallows outside-clicks
7936
+ // to dismiss, but inside-card clicks bubble up through here too — skip
7937
+ // those so the Confirm button's own handler can run alone.
7938
+ button.addEventListener("click", async (event) => {
7939
+ if (button.classList.contains("modal-backdrop")) {
7940
+ if (event.target.closest(".modal-card")) {
7941
+ return;
7942
+ }
7943
+ }
7944
+ state.hazbaseLogoutConfirmOpen = false;
7945
+ await renderFn();
7946
+ });
7947
+ }
7948
+
6264
7949
  for (const button of document.querySelectorAll("[data-dismiss-install]")) {
6265
7950
  button.addEventListener("click", async () => {
6266
7951
  state.installBannerDismissed = true;
@@ -6319,6 +8004,15 @@ async function switchTab(tab) {
6319
8004
  syncCurrentItemUrl(state.currentItem);
6320
8005
  }
6321
8006
  await renderShell();
8007
+ if (tab === "settings") {
8008
+ void fetchHazbaseStatus()
8009
+ .then(() => {
8010
+ if (state.currentTab === "settings") {
8011
+ renderCurrentSurface();
8012
+ }
8013
+ })
8014
+ .catch(() => {});
8015
+ }
6322
8016
  }
6323
8017
 
6324
8018
  function openItem({ kind, token, sourceTab, sourceSubtab }) {
@@ -6455,6 +8149,9 @@ function pendingInboxCount() {
6455
8149
  }
6456
8150
 
6457
8151
  function tabForItemKind(kind, fallback) {
8152
+ if (kind === "completion" || kind === "plan_ready" || kind === "moltbook_draft" || kind === "moltbook_reply" || kind === "thread_share" || kind === "a2a_task" || kind === "a2a_task_result") {
8153
+ return "inbox";
8154
+ }
6458
8155
  if (kind === "diff_thread") {
6459
8156
  return "diff";
6460
8157
  }
@@ -6464,22 +8161,20 @@ function tabForItemKind(kind, fallback) {
6464
8161
  if (TIMELINE_MESSAGE_KINDS.has(kind)) {
6465
8162
  return "timeline";
6466
8163
  }
6467
- if (kind === "completion") {
6468
- return "inbox";
6469
- }
6470
8164
  if (fallback === "timeline") {
6471
8165
  return "timeline";
6472
8166
  }
6473
8167
  return kind === "approval" || kind === "plan" || kind === "choice"
6474
8168
  ? "inbox"
6475
- : fallback || "inbox";
8169
+ : "inbox";
6476
8170
  }
6477
8171
 
6478
8172
  function inboxSubtabForItemKind(kind, sourceSubtab = "") {
6479
8173
  if (normalizeClientText(sourceSubtab || "") === "completed") {
6480
8174
  return "completed";
6481
8175
  }
6482
- return kind === "completion" || kind === "assistant_final" ? "completed" : "pending";
8176
+ const completedKinds = new Set(["completion", "assistant_final", "plan_ready", "moltbook_reply", "thread_share", "a2a_task_result"]);
8177
+ return completedKinds.has(kind) ? "completed" : "pending";
6483
8178
  }
6484
8179
 
6485
8180
  function kindMeta(kind, item) {
@@ -6893,6 +8588,8 @@ function renderIcon(name) {
6893
8588
  return `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round"><path d="M5 7h14"/><path d="M8 12h8"/><path d="M10.5 17h3"/></svg>`;
6894
8589
  case "check":
6895
8590
  return `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="m6.8 12.5 3.2 3.2 7.2-7.4"/></svg>`;
8591
+ case "copy":
8592
+ return `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="3.5" width="11" height="14" rx="2"/><path d="M6.5 7.5H6a2 2 0 0 0-2 2v9a2 2 0 0 0 2 2h9a2 2 0 0 0 2-2v-.5"/></svg>`;
6896
8593
  case "lock":
6897
8594
  return `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round"><rect x="5.5" y="10.5" width="13" height="9" rx="2"/><path d="M8 10.5V7.5a4 4 0 0 1 8 0v3"/></svg>`;
6898
8595
  case "coin":
@@ -7114,6 +8811,11 @@ function localizeApiError(value) {
7114
8811
  "choice-input-read-only": "error.choiceInputReadOnly",
7115
8812
  "choice-input-already-handled": "error.choiceInputAlreadyHandled",
7116
8813
  "mkcert-root-ca-not-found": "error.mkcertRootCaNotFound",
8814
+ "hazbase-auth-required": "error.hazbaseAuthRequired",
8815
+ "hazbase-session-expired": "error.hazbaseSessionExpired",
8816
+ "hazbase-passkey-local-host-required": "error.hazbasePasskeyLocalHostRequired",
8817
+ "hazbase-wallet-account-missing": "error.hazbaseWalletAccountMissing",
8818
+ "unsupported-chain": "error.unsupportedChain",
7117
8819
  };
7118
8820
  const key = map[raw];
7119
8821
  return key ? L(key) : raw;
@@ -7209,6 +8911,16 @@ function parseItemRef(value) {
7209
8911
  return kind && token ? { kind, token } : null;
7210
8912
  }
7211
8913
 
8914
+ function sanitizeExternalTargetTab(value) {
8915
+ const normalized = normalizeClientText(value || "");
8916
+ return EXTERNAL_TARGET_TABS.has(normalized) ? normalized : "";
8917
+ }
8918
+
8919
+ function sanitizeExternalTargetInboxSubtab(value) {
8920
+ const normalized = normalizeClientText(value || "");
8921
+ return EXTERNAL_TARGET_INBOX_SUBTABS.has(normalized) ? normalized : "";
8922
+ }
8923
+
7212
8924
  async function applyExternalTargetUrl(urlString, { allowRefresh = true } = {}) {
7213
8925
  if (!state.session?.authenticated) {
7214
8926
  return;
@@ -7225,10 +8937,16 @@ async function applyExternalTargetUrl(urlString, { allowRefresh = true } = {}) {
7225
8937
  if (!itemRef) {
7226
8938
  return;
7227
8939
  }
8940
+ const explicitTargetTab = sanitizeExternalTargetTab(nextUrl.searchParams.get("tab"));
8941
+ const explicitTargetSubtab = sanitizeExternalTargetInboxSubtab(nextUrl.searchParams.get("subtab"));
8942
+ const targetTab = explicitTargetTab || tabForItemKind(itemRef.kind, state.currentTab);
8943
+ const targetSubtab = targetTab === "inbox" ? inboxSubtabForItemKind(itemRef.kind, explicitTargetSubtab) : "";
7228
8944
 
7229
8945
  const sameItem =
7230
8946
  Boolean(state.currentItem) &&
7231
8947
  isSameItemRef(state.currentItem, itemRef) &&
8948
+ state.currentTab === targetTab &&
8949
+ (targetTab !== "inbox" || state.inboxSubtab === targetSubtab) &&
7232
8950
  (isDesktopLayout() || state.detailOpen);
7233
8951
  if (sameItem) {
7234
8952
  if (allowRefresh) {
@@ -7242,7 +8960,8 @@ async function applyExternalTargetUrl(urlString, { allowRefresh = true } = {}) {
7242
8960
  openItem({
7243
8961
  kind: itemRef.kind,
7244
8962
  token: itemRef.token,
7245
- sourceTab: tabForItemKind(itemRef.kind, state.currentTab),
8963
+ sourceTab: targetTab,
8964
+ sourceSubtab: targetSubtab,
7246
8965
  });
7247
8966
  if (isFastPathItemRef(itemRef)) {
7248
8967
  state.launchItemIntent = {