viveworker 0.7.0-beta.1 → 0.7.0-beta.2

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: "",
@@ -73,10 +109,31 @@ const state = {
73
109
  };
74
110
 
75
111
  let detailLoadSequence = 0;
112
+ let hazbasePasskeyModulePromise = null;
113
+
114
+ async function loadHazbasePasskeyModule() {
115
+ if (!hazbasePasskeyModulePromise) {
116
+ hazbasePasskeyModulePromise = import("./hazbase-passkey.js");
117
+ }
118
+ return hazbasePasskeyModulePromise;
119
+ }
120
+
121
+ function hazbasePasskeyHostSupport() {
122
+ const protocol = normalizeClientText(window.location?.protocol || "");
123
+ const hostname = normalizeClientText(window.location?.hostname || "").toLowerCase();
124
+ const eligible = protocol === "https:" && Boolean(hostname) && hostname.endsWith(".local");
125
+ return {
126
+ eligible,
127
+ hostname,
128
+ detail: eligible ? "" : L("settings.hazbase.passkey.localHostRequired"),
129
+ };
130
+ }
76
131
 
77
132
  const app = document.querySelector("#app");
78
133
  const params = new URLSearchParams(window.location.search);
79
134
  const initialItem = params.get("item") || "";
135
+ const initialTargetTab = params.get("tab") || "";
136
+ const initialTargetSubtab = params.get("subtab") || "";
80
137
  const initialPairToken = params.get("pairToken") || "";
81
138
  const initialFocusPending = params.get("focusPending") || "";
82
139
  let didReloadForServiceWorker = false;
@@ -101,14 +158,19 @@ boot().catch((error) => {
101
158
 
102
159
  async function boot() {
103
160
  updateManifestHref(initialPairToken);
104
- await registerServiceWorker();
161
+ // SW register + update() can take hundreds of ms and does not need to gate
162
+ // first paint. Fire and forget; the `controllerchange` reload handler wired
163
+ // up inside `registerServiceWorker` still picks up new versions.
164
+ registerServiceWorker().catch(() => {});
105
165
  navigator.serviceWorker?.addEventListener("message", handleServiceWorkerMessage);
106
166
  window.addEventListener("resize", handleViewportChange, { passive: true });
107
167
  window.addEventListener("focus", handlePotentialExternalNavigation, { passive: true });
108
168
  window.addEventListener("pageshow", handlePotentialExternalNavigation, { passive: true });
109
169
  document.addEventListener("visibilitychange", handleDocumentVisibilityChange);
110
170
 
111
- await refreshSession();
171
+ // Single round-trip for session + inbox(pending/completed) + timeline +
172
+ // devices. See `refreshBootstrap` for why we collapsed the boot fan-out.
173
+ await refreshBootstrap();
112
174
 
113
175
  if (!state.session?.authenticated && initialPairToken && shouldAutoPairFromBootstrapToken()) {
114
176
  try {
@@ -119,17 +181,19 @@ async function boot() {
119
181
  } catch (error) {
120
182
  state.pairError = error.message || String(error);
121
183
  }
122
- await refreshSession();
184
+ await refreshBootstrap();
123
185
  }
124
186
 
125
187
  syncPairingTokenState(desiredBootstrapPairingToken());
126
188
 
127
189
  const parsedInitialItem = parseItemRef(initialItem);
128
190
  if (parsedInitialItem) {
191
+ const targetTab = sanitizeExternalTargetTab(initialTargetTab) || tabForItemKind(parsedInitialItem.kind, state.currentTab);
192
+ const targetSubtab = sanitizeExternalTargetInboxSubtab(initialTargetSubtab);
129
193
  state.currentItem = parsedInitialItem;
130
- state.currentTab = tabForItemKind(parsedInitialItem.kind, state.currentTab);
194
+ state.currentTab = targetTab;
131
195
  if (state.currentTab === "inbox") {
132
- state.inboxSubtab = inboxSubtabForItemKind(parsedInitialItem.kind);
196
+ state.inboxSubtab = inboxSubtabForItemKind(parsedInitialItem.kind, targetSubtab);
133
197
  }
134
198
  state.detailOpen = true;
135
199
  if (isFastPathItemRef(parsedInitialItem)) {
@@ -146,8 +210,6 @@ async function boot() {
146
210
  }
147
211
 
148
212
  await consumePendingNotificationIntent();
149
- await syncDetectedLocalePreference();
150
- await refreshAuthenticatedState();
151
213
  // `?focusPending=claude` marks this tab as the Claude-hook-opened popup:
152
214
  // auto-navigate to the newest unresolved Claude pending (plan/question)
153
215
  // detail view — but only when the user is not already in the middle of
@@ -156,10 +218,37 @@ async function boot() {
156
218
  if (initialFocusPending === "claude" && !state.currentItem) {
157
219
  state.claudePopupMode = true;
158
220
  }
159
- maybeAutoFocusClaudePending();
221
+
222
+ // Bootstrap already populated session + inbox(pending/completed) +
223
+ // timeline + devices, so the shell renders with real data on first
224
+ // paint. No null-state fallback needed here.
160
225
  ensureCurrentSelection();
226
+ maybeAutoFocusClaudePending();
161
227
  await renderShell();
162
228
 
229
+ // Diff fetch runs as a background phase because `/api/inbox/diff`
230
+ // spawns `git` subprocesses per tracked repo and can stall for several
231
+ // seconds. The Code/diff tab lights up when this resolves.
232
+ refreshInboxDiff()
233
+ .then(async () => {
234
+ if (!shouldDeferRenderForActiveInteraction()) {
235
+ await renderShell();
236
+ }
237
+ })
238
+ .catch(() => {});
239
+
240
+ // Remote status probes (push/moltbook/a2a-relay/a2a-share — the share
241
+ // worker round-trip alone can block up to 10s). Re-renders when it
242
+ // resolves.
243
+ refreshAuthenticatedStateRemote()
244
+ .then(async () => {
245
+ if (!shouldDeferRenderForActiveInteraction()) {
246
+ await renderShell();
247
+ }
248
+ })
249
+ .catch(() => {});
250
+ syncDetectedLocalePreference().catch(() => {});
251
+
163
252
  setInterval(async () => {
164
253
  if (!state.session?.authenticated) {
165
254
  return;
@@ -168,11 +257,41 @@ async function boot() {
168
257
  if (consumedNotificationIntent) {
169
258
  return;
170
259
  }
171
- await refreshAuthenticatedState();
260
+ // Split the poll tick into a fast local fan-out and a slow background
261
+ // fan-out. The fast calls are all in-memory bridge lookups (inbox,
262
+ // timeline, devices, push status, relay status) and typically resolve in
263
+ // under ~100 ms over localhost; rendering immediately after them keeps
264
+ // new user_message entries reaching the timeline within one scan tick.
265
+ // The slow calls — /api/inbox/diff (git subprocesses), moltbook scout
266
+ // status (cross-origin to moltbook.com on cache miss), and especially
267
+ // /api/share/status (10 s upstream timeout to share.viveworker.com) —
268
+ // used to gate the render on every poll, so a single share-worker
269
+ // cache miss stalled timeline updates for up to 10 seconds. Now they
270
+ // run in the background and trigger a second render when they resolve.
271
+ await Promise.all([
272
+ refreshInbox(),
273
+ refreshTimeline(),
274
+ refreshDevices(),
275
+ refreshPushStatus(),
276
+ fetchA2aRelayStatus(),
277
+ ]);
278
+ ensureCurrentSelection();
172
279
  maybeAutoFocusClaudePending();
173
280
  if (!shouldDeferRenderForActiveInteraction()) {
174
281
  await renderShell();
175
282
  }
283
+
284
+ Promise.allSettled([
285
+ refreshInboxDiff(),
286
+ fetchMoltbookScoutStatus(),
287
+ fetchA2aShareStatus(),
288
+ ])
289
+ .then(async () => {
290
+ if (!shouldDeferRenderForActiveInteraction()) {
291
+ await renderShell();
292
+ }
293
+ })
294
+ .catch(() => {});
176
295
  }, 3000);
177
296
  }
178
297
 
@@ -215,22 +334,84 @@ function handleViewportChange() {
215
334
  }
216
335
 
217
336
  async function refreshAuthenticatedState() {
218
- await refreshInbox();
337
+ // Fire the two inbox halves in parallel so the diff's git subprocesses
338
+ // don't serialize behind pending+completed (or vice versa).
339
+ await Promise.all([refreshInbox(), refreshInboxDiff()]);
219
340
  await refreshTimeline();
220
341
  await refreshDevices();
221
342
  await refreshPushStatus();
222
343
  await fetchMoltbookScoutStatus();
223
344
  await fetchA2aRelayStatus();
224
345
  await fetchA2aShareStatus();
346
+ if (state.currentTab === "settings") {
347
+ await fetchHazbaseStatus();
348
+ }
225
349
  ensureCurrentSelection();
226
350
  }
227
351
 
352
+ // Boot-time split: the first paint should not wait on cross-origin status
353
+ // probes. `Local` covers in-memory bridge lookups (fast). `Remote` covers
354
+ // everything that can stall on an external service (push config,
355
+ // moltbook/a2a worker calls — the a2a share worker has a 10s timeout) and
356
+ // runs in the background after the shell renders.
357
+ async function refreshAuthenticatedStateLocal() {
358
+ await Promise.all([refreshInbox(), refreshTimeline(), refreshDevices()]);
359
+ ensureCurrentSelection();
360
+ }
361
+
362
+ async function refreshAuthenticatedStateRemote() {
363
+ await Promise.allSettled([
364
+ refreshPushStatus(),
365
+ fetchMoltbookScoutStatus(),
366
+ fetchA2aRelayStatus(),
367
+ fetchA2aShareStatus(),
368
+ ]);
369
+ }
370
+
228
371
  async function refreshSession() {
229
372
  state.session = await apiGet("/api/session");
230
373
  syncPairingTokenState(desiredBootstrapPairingToken());
231
374
  applyResolvedLocale();
232
375
  }
233
376
 
377
+ // One-shot boot fetch: hits `/api/bootstrap` which bundles session,
378
+ // inbox (pending + completed), timeline, and devices into a single
379
+ // HTTPS round-trip. Saves 3 additional TLS handshakes versus calling
380
+ // the four endpoints in parallel, which is the dominant boot cost on
381
+ // iOS PWAs where connection reuse is aggressive. Leaves the diff and
382
+ // external-status probes as separate background phases in `boot()`.
383
+ async function refreshBootstrap() {
384
+ const bootstrap = await apiGet("/api/bootstrap");
385
+ state.session = bootstrap?.session || null;
386
+ syncPairingTokenState(desiredBootstrapPairingToken());
387
+ applyResolvedLocale();
388
+
389
+ if (!state.session?.authenticated) {
390
+ state.devices = [];
391
+ state.deviceError = "";
392
+ return;
393
+ }
394
+
395
+ const fastInbox = bootstrap?.inbox || {};
396
+ const previousDiff = Array.isArray(state.inbox?.diff) ? state.inbox.diff : [];
397
+ state.inbox = {
398
+ pending: Array.isArray(fastInbox.pending) ? fastInbox.pending : [],
399
+ completed: Array.isArray(fastInbox.completed) ? fastInbox.completed : [],
400
+ diff: previousDiff,
401
+ };
402
+ syncDiffThreadFilter();
403
+ syncCompletedThreadFilter();
404
+ syncInboxSubtab();
405
+
406
+ state.timeline = bootstrap?.timeline || null;
407
+ syncTimelineThreadFilter();
408
+ syncTimelineKindFilter();
409
+
410
+ const devicesPayload = bootstrap?.devices;
411
+ state.devices = Array.isArray(devicesPayload?.devices) ? devicesPayload.devices : [];
412
+ state.deviceError = "";
413
+ }
414
+
234
415
  async function syncDetectedLocalePreference() {
235
416
  if (!state.session?.authenticated || !state.session?.deviceId || !state.detectedLocale) {
236
417
  return;
@@ -248,16 +429,47 @@ async function syncDetectedLocalePreference() {
248
429
  applyResolvedLocale();
249
430
  }
250
431
 
251
- async function setLocaleOverride(nextLocale) {
252
- const result = await apiPost("/api/session/locale", {
253
- detectedLocale: state.detectedLocale,
254
- overrideLocale: nextLocale || null,
432
+ // Two-phase override: flip state.session + applyResolvedLocale synchronously
433
+ // so the caller can renderShell() in the new language before the POST
434
+ // round-trip, then reconcile with the server response. On failure the
435
+ // previous session snapshot is restored so the UI rolls back.
436
+ function applyLocaleOverrideOptimistically(nextLocale) {
437
+ if (!state.session) return null;
438
+ const previousSession = { ...state.session };
439
+ const optimistic = resolveLocalePreference({
440
+ overrideLocale: nextLocale || "",
441
+ detectedLocale: state.session?.deviceDetectedLocale || state.detectedLocale,
442
+ defaultLocale: state.session?.defaultLocale || DEFAULT_LOCALE,
443
+ fallbackLocale: DEFAULT_LOCALE,
255
444
  });
256
445
  state.session = {
257
446
  ...state.session,
258
- ...result,
447
+ deviceOverrideLocale: nextLocale || "",
448
+ locale: optimistic.locale,
449
+ localeSource: optimistic.source,
259
450
  };
260
451
  applyResolvedLocale();
452
+ return previousSession;
453
+ }
454
+
455
+ async function persistLocaleOverride(nextLocale, previousSession) {
456
+ try {
457
+ const result = await apiPost("/api/session/locale", {
458
+ detectedLocale: state.detectedLocale,
459
+ overrideLocale: nextLocale || null,
460
+ });
461
+ state.session = {
462
+ ...state.session,
463
+ ...result,
464
+ };
465
+ applyResolvedLocale();
466
+ } catch (error) {
467
+ if (previousSession) {
468
+ state.session = previousSession;
469
+ applyResolvedLocale();
470
+ }
471
+ throw error;
472
+ }
261
473
  }
262
474
 
263
475
  function applyResolvedLocale() {
@@ -280,6 +492,8 @@ function L(key, vars = {}) {
280
492
  return t(state.locale || DEFAULT_LOCALE, key, vars);
281
493
  }
282
494
 
495
+
496
+
283
497
  function detectBrowserLocale() {
284
498
  if (Array.isArray(navigator.languages) && navigator.languages.length > 0) {
285
499
  for (const value of navigator.languages) {
@@ -363,6 +577,15 @@ async function fetchA2aShareStatus() {
363
577
  }
364
578
  }
365
579
 
580
+
581
+ async function fetchHazbaseStatus() {
582
+ try {
583
+ state.hazbaseStatus = await apiGet("/api/hazbase/status");
584
+ } catch {
585
+ state.hazbaseStatus = null;
586
+ }
587
+ }
588
+
366
589
  async function getClientPushState() {
367
590
  const registration = state.serviceWorkerRegistration || (await navigator.serviceWorker?.ready.catch(() => null));
368
591
  if (registration) {
@@ -385,12 +608,41 @@ async function getClientPushState() {
385
608
  }
386
609
 
387
610
  async function refreshInbox() {
388
- state.inbox = await apiGet("/api/inbox");
611
+ const fast = await apiGet("/api/inbox");
612
+ // `/api/inbox` now returns only `{ pending, completed }`. The `diff`
613
+ // half lives at `/api/inbox/diff` because it spawns `git` subprocesses
614
+ // server-side and was blocking first paint of the completed/pending
615
+ // lists. Preserve whatever diff entries we already have in memory so
616
+ // polling doesn't wipe the diff tab between diff refetches.
617
+ const previousDiff = Array.isArray(state.inbox?.diff) ? state.inbox.diff : [];
618
+ state.inbox = {
619
+ pending: Array.isArray(fast?.pending) ? fast.pending : [],
620
+ completed: Array.isArray(fast?.completed) ? fast.completed : [],
621
+ diff: previousDiff,
622
+ };
389
623
  syncDiffThreadFilter();
390
624
  syncCompletedThreadFilter();
391
625
  syncInboxSubtab();
392
626
  }
393
627
 
628
+ async function refreshInboxDiff() {
629
+ try {
630
+ const response = await apiGet("/api/inbox/diff");
631
+ const previous = state.inbox || { pending: [], completed: [] };
632
+ state.inbox = {
633
+ pending: Array.isArray(previous.pending) ? previous.pending : [],
634
+ completed: Array.isArray(previous.completed) ? previous.completed : [],
635
+ diff: Array.isArray(response?.diff) ? response.diff : [],
636
+ };
637
+ syncDiffThreadFilter();
638
+ syncInboxSubtab();
639
+ } finally {
640
+ // Always flip off the skeleton — a persistent error shouldn't leave the
641
+ // Code tab perpetually shimmering. The next poll cycle will retry.
642
+ state.inboxDiffLoaded = true;
643
+ }
644
+ }
645
+
394
646
  async function refreshTimeline() {
395
647
  state.timeline = await apiGet("/api/timeline");
396
648
  syncTimelineThreadFilter();
@@ -466,9 +718,9 @@ function allInboxEntries() {
466
718
  return [];
467
719
  }
468
720
  return [
469
- ...state.inbox.pending.map((item) => ({ item, status: "pending" })),
721
+ ...(Array.isArray(state.inbox.pending) ? state.inbox.pending.map((item) => ({ item, status: "pending" })) : []),
470
722
  ...(Array.isArray(state.inbox.diff) ? state.inbox.diff.map((item) => ({ item, status: "diff" })) : []),
471
- ...state.inbox.completed.map((item) => ({ item, status: "completed" })),
723
+ ...(Array.isArray(state.inbox.completed) ? state.inbox.completed.map((item) => ({ item, status: "completed" })) : []),
472
724
  ];
473
725
  }
474
726
 
@@ -565,7 +817,7 @@ function listInboxEntries() {
565
817
  if (state.inboxSubtab === "completed") {
566
818
  return filteredCompletedEntries().map((item) => ({ item, status: "completed" }));
567
819
  }
568
- return state.inbox.pending
820
+ return (Array.isArray(state.inbox.pending) ? state.inbox.pending : [])
569
821
  .filter((item) => entryMatchesProviderFilter(item))
570
822
  .map((item) => ({ item, status: "pending" }));
571
823
  }
@@ -914,6 +1166,10 @@ async function logout({ revokeCurrentDeviceTrust = false } = {}) {
914
1166
  function resetAuthenticatedState() {
915
1167
  state.session = null;
916
1168
  state.inbox = null;
1169
+ // Reset the diff-loaded flag so the next sign-in shows the skeleton
1170
+ // again during the fresh /api/inbox/diff fetch instead of flashing the
1171
+ // empty state from the previous session's terminal value.
1172
+ state.inboxDiffLoaded = false;
917
1173
  state.timeline = null;
918
1174
  state.devices = [];
919
1175
  state.currentItem = null;
@@ -985,6 +1241,7 @@ async function renderShell() {
985
1241
  ${renderImageViewerModal()}
986
1242
  ${renderInstallGuideModal()}
987
1243
  ${renderLogoutConfirmModal()}
1244
+ ${renderHazbaseLogoutConfirmModal()}
988
1245
  </div>
989
1246
  `;
990
1247
 
@@ -1086,6 +1343,17 @@ function shouldDeferRenderForActiveInteraction() {
1086
1343
  ) {
1087
1344
  return true;
1088
1345
  }
1346
+ if (
1347
+ activeElement instanceof HTMLInputElement &&
1348
+ activeElement.matches("[data-hazbase-input]")
1349
+ ) {
1350
+ // User is mid-edit on the wallet sign-in email/OTP field. A poll tick
1351
+ // that re-renders here would blur the input and interrupt typing
1352
+ // (state mirroring restores value + caret position, but the blur
1353
+ // itself is still jarring, especially on iOS where it also dismisses
1354
+ // the keyboard).
1355
+ return true;
1356
+ }
1089
1357
  if (
1090
1358
  activeElement instanceof HTMLSelectElement &&
1091
1359
  activeElement.matches("[data-timeline-thread-select], [data-diff-thread-select], [data-completed-thread-select]")
@@ -1937,6 +2205,12 @@ function renderCompletedCompletionCard(entry, sourceTab) {
1937
2205
  const timestampLabel = formatTimelineTimestamp(item.createdAtMs);
1938
2206
  const pillLabel = item.kind === "completion" ? L("common.task") : kindInfo.label;
1939
2207
  const pillTone = item.kind === "completion" ? "completion" : kindInfo.tone;
2208
+ const compactSummary = normalizeClientText(summaryText);
2209
+ const useThreadAsPrimaryTitle = Boolean(threadLabel) && /^\d+$/u.test(compactSummary);
2210
+ const titleText = useThreadAsPrimaryTitle ? threadLabel : (summaryText || L("common.untitledItem"));
2211
+ const secondarySummaryHtml = useThreadAsPrimaryTitle
2212
+ ? `<p class="item-card__summary">${escapeHtml(summaryText)}</p>`
2213
+ : "";
1940
2214
 
1941
2215
  return `
1942
2216
  <button
@@ -1958,7 +2232,8 @@ function renderCompletedCompletionCard(entry, sourceTab) {
1958
2232
  </div>
1959
2233
  <div class="item-card__content">
1960
2234
  ${threadLabel ? `<p class="item-card__thread">${escapeHtml(threadLabel)}</p>` : ""}
1961
- <h3 class="item-card__title">${escapeHtml(summaryText || L("common.untitledItem"))}</h3>
2235
+ <h3 class="item-card__title">${escapeHtml(titleText)}</h3>
2236
+ ${secondarySummaryHtml}
1962
2237
  </div>
1963
2238
  </button>
1964
2239
  `;
@@ -2007,9 +2282,16 @@ function renderTimelinePanel({ entries, desktop }) {
2007
2282
  function renderDiffPanel({ entries, desktop }) {
2008
2283
  const meta = tabMeta("diff");
2009
2284
  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");
2285
+ let bodyHtml;
2286
+ if (entries.length) {
2287
+ bodyHtml = `<div class="${listClassName}">${entries.map((entry) => renderDiffEntry(entry)).join("")}</div>`;
2288
+ } else if (!state.inboxDiffLoaded) {
2289
+ // First /api/inbox/diff still in flight — show shimmer cards so the
2290
+ // user sees something is happening instead of "no entries".
2291
+ bodyHtml = renderDiffSkeleton(listClassName);
2292
+ } else {
2293
+ bodyHtml = renderEmptyList("diff");
2294
+ }
2013
2295
 
2014
2296
  if (!desktop) {
2015
2297
  return `
@@ -2038,6 +2320,31 @@ function renderDiffPanel({ entries, desktop }) {
2038
2320
  `;
2039
2321
  }
2040
2322
 
2323
+ // Placeholder shimmer shown while the first /api/inbox/diff response is
2324
+ // pending. Shape mirrors `.diff-entry` so the tab doesn't visually jump when
2325
+ // real entries land. Three cards with decreasing prominence is enough to
2326
+ // signal activity without pretending to be a specific number of results.
2327
+ function renderDiffSkeleton(listClassName) {
2328
+ const card = `
2329
+ <div class="diff-entry diff-entry--skeleton" aria-hidden="true">
2330
+ <div class="diff-entry__header">
2331
+ <span class="diff-skeleton-line diff-skeleton-line--thread"></span>
2332
+ <span class="diff-skeleton-line diff-skeleton-line--time"></span>
2333
+ </div>
2334
+ <span class="diff-skeleton-line diff-skeleton-line--title"></span>
2335
+ <div class="diff-entry__files">
2336
+ <span class="diff-skeleton-chip"></span>
2337
+ <span class="diff-skeleton-chip diff-skeleton-chip--narrow"></span>
2338
+ </div>
2339
+ </div>
2340
+ `;
2341
+ return `
2342
+ <div class="${listClassName}" role="status" aria-busy="true" aria-label="${escapeHtml(L("common.loading"))}">
2343
+ ${card}${card}${card}
2344
+ </div>
2345
+ `;
2346
+ }
2347
+
2041
2348
  function timelineThreadsForProvider(provider) {
2042
2349
  const entries = Array.isArray(state.timeline?.entries) ? state.timeline.entries : [];
2043
2350
  const byThread = new Map();
@@ -2893,6 +3200,7 @@ function buildSettingsContext() {
2893
3200
  moltbookScout: state.moltbookScoutStatus,
2894
3201
  a2aRelay: state.a2aRelayStatus,
2895
3202
  a2aShare: state.a2aShareStatus,
3203
+ hazbase: state.hazbaseStatus,
2896
3204
  };
2897
3205
  }
2898
3206
 
@@ -3039,6 +3347,13 @@ function settingsPageMeta(page) {
3039
3347
  description: L("settings.awayMode.copy"),
3040
3348
  icon: "settings",
3041
3349
  };
3350
+ case "autoPilot":
3351
+ return {
3352
+ id: "autoPilot",
3353
+ title: L("settings.autoPilot.title"),
3354
+ description: L("settings.autoPilot.copy"),
3355
+ icon: "approval",
3356
+ };
3042
3357
  case "moltbook":
3043
3358
  return {
3044
3359
  id: "moltbook",
@@ -3060,6 +3375,13 @@ function settingsPageMeta(page) {
3060
3375
  description: L("settings.a2aShare.copy"),
3061
3376
  icon: "link",
3062
3377
  };
3378
+ case "wallet":
3379
+ return {
3380
+ id: "wallet",
3381
+ title: L("settings.wallet.title"),
3382
+ description: L("settings.wallet.copy"),
3383
+ icon: "coin",
3384
+ };
3063
3385
  case "a2aExecutor":
3064
3386
  // Executor settings integrated into a2aRelay page — redirect.
3065
3387
  return settingsPageMeta("a2aRelay");
@@ -3097,6 +3419,15 @@ function renderSettingsRoot(context, { mobile }) {
3097
3419
  title: L("settings.awayMode.title"),
3098
3420
  value: state.session?.claudeAwayMode === true ? L("settings.claudeAway.on") : L("settings.claudeAway.off"),
3099
3421
  }),
3422
+ renderSettingsNavRow({
3423
+ page: "autoPilot",
3424
+ icon: "approval",
3425
+ title: L("settings.autoPilot.title"),
3426
+ value:
3427
+ state.session?.autoPilotTrustedReads === true || state.session?.autoPilotTrustedWrites === true
3428
+ ? L("common.enabled")
3429
+ : L("common.disabled"),
3430
+ }),
3100
3431
  ].filter(Boolean);
3101
3432
  const deviceRows = [
3102
3433
  renderSettingsNavRow({
@@ -3132,7 +3463,7 @@ function renderSettingsRoot(context, { mobile }) {
3132
3463
  `
3133
3464
  }
3134
3465
  ${renderSettingsGroup(L("settings.group.general"), generalRows)}
3135
- ${(state.session?.moltbookEnabled || state.session?.a2aRelayEnabled || state.session?.a2aShareEnabled) ? renderSettingsGroup(L("settings.group.integrations"), [
3466
+ ${(state.session?.moltbookEnabled || state.session?.a2aRelayEnabled || state.session?.a2aShareEnabled || context.hazbase?.enabled) ? renderSettingsGroup(L("settings.group.integrations"), [
3136
3467
  state.session?.moltbookEnabled ? renderSettingsNavRow({
3137
3468
  page: "moltbook",
3138
3469
  icon: "item",
@@ -3154,6 +3485,13 @@ function renderSettingsRoot(context, { mobile }) {
3154
3485
  subtitle: L("settings.a2aShare.subtitle"),
3155
3486
  value: context.a2aShare?.enabled ? L("settings.status.enabled") : L("settings.status.disabled"),
3156
3487
  }) : "",
3488
+ context.hazbase?.enabled ? renderSettingsNavRow({
3489
+ page: "wallet",
3490
+ icon: "coin",
3491
+ title: L("settings.wallet.title"),
3492
+ subtitle: L("settings.wallet.subtitle"),
3493
+ value: context.hazbase?.signedIn ? L("settings.hazbase.status.signedIn") : L("settings.hazbase.status.signedOut"),
3494
+ }) : "",
3157
3495
  ].filter(Boolean)) : ""}
3158
3496
  ${renderSettingsGroup(L("settings.pairing.title"), deviceRows)}
3159
3497
  ${renderSettingsGroup(L("settings.group.advanced"), advancedRows)}
@@ -3198,6 +3536,9 @@ function renderSettingsSubpage(context, { mobile }) {
3198
3536
  case "awayMode":
3199
3537
  content = renderSettingsAwayModePage();
3200
3538
  break;
3539
+ case "autoPilot":
3540
+ content = renderSettingsAutoPilotPage();
3541
+ break;
3201
3542
  case "moltbook":
3202
3543
  content = renderSettingsMoltbookPage(context);
3203
3544
  break;
@@ -3208,6 +3549,9 @@ function renderSettingsSubpage(context, { mobile }) {
3208
3549
  case "a2aShare":
3209
3550
  content = renderSettingsA2aSharePage(context);
3210
3551
  break;
3552
+ case "wallet":
3553
+ content = renderSettingsWalletPage(context);
3554
+ break;
3211
3555
  default:
3212
3556
  content = "";
3213
3557
  }
@@ -3394,31 +3738,481 @@ function renderSettingsNavRow({ page, icon, title, subtitle, value }) {
3394
3738
  <span class="settings-row__title">${escapeHtml(title)}</span>
3395
3739
  ${subtitle ? `<span class="settings-row__subtitle">${escapeHtml(subtitle)}</span>` : ""}
3396
3740
  </span>
3397
- <span class="settings-row__value">${escapeHtml(value || "")}</span>
3398
- <span class="settings-row__chevron" aria-hidden="true">${renderIcon("chevron-right")}</span>
3741
+ <span class="settings-row__value">${escapeHtml(value || "")}</span>
3742
+ <span class="settings-row__chevron" aria-hidden="true">${renderIcon("chevron-right")}</span>
3743
+ </button>
3744
+ `;
3745
+ }
3746
+
3747
+ function renderSettingsAwayModePage() {
3748
+ const enabled = state.session?.claudeAwayMode === true;
3749
+ const stateLabel = enabled ? L("settings.claudeAway.on") : L("settings.claudeAway.off");
3750
+ return `
3751
+ <div class="settings-page">
3752
+ ${renderSettingsGroup("", [`
3753
+ <label class="reply-mode-switch" data-claude-away-toggle>
3754
+ <input type="checkbox" class="reply-mode-switch__input" ${enabled ? "checked" : ""} data-claude-away-checkbox />
3755
+ <span class="reply-mode-switch__track" aria-hidden="true"><span class="reply-mode-switch__thumb"></span></span>
3756
+ <span class="reply-mode-switch__copy">
3757
+ <span class="reply-mode-switch__title">
3758
+ <span>${escapeHtml(L("settings.claudeAway.title"))}</span>
3759
+ <span class="reply-mode-switch__state">${escapeHtml(stateLabel)}</span>
3760
+ </span>
3761
+ <span class="reply-mode-switch__hint">${escapeHtml(L("settings.claudeAway.description"))}</span>
3762
+ </span>
3763
+ </label>
3764
+ `])}
3765
+ <p class="settings-page-copy muted">${escapeHtml(L("settings.awayMode.codexNote"))}</p>
3766
+ </div>
3767
+ `;
3768
+ }
3769
+
3770
+ function renderSettingsAutoPilotPage() {
3771
+ const trustedReadsEnabled = state.session?.autoPilotTrustedReads === true;
3772
+ const trustedReadsStateLabel = trustedReadsEnabled ? L("common.enabled") : L("common.disabled");
3773
+ const writeLaneContentEnabled = state.session?.autoPilotWriteLaneContent === true;
3774
+ const writeLaneUiTestsEnabled = state.session?.autoPilotWriteLaneUiTests === true;
3775
+ const writeLaneSourceEnabled = state.session?.autoPilotWriteLaneSource === true;
3776
+ const trustedWritesEnabled =
3777
+ writeLaneContentEnabled || writeLaneUiTestsEnabled || writeLaneSourceEnabled || state.session?.autoPilotTrustedWrites === true;
3778
+ const trustedWritesStateLabel = trustedWritesEnabled ? L("common.enabled") : L("common.disabled");
3779
+ const recentEntries = recentAutoPilotEntries();
3780
+ const suggestions = recentAutoPilotSuggestions();
3781
+ return `
3782
+ <div class="settings-page">
3783
+ ${renderSettingsGroup("", [`
3784
+ <label class="reply-mode-switch reply-mode-switch--grouped" data-auto-pilot-toggle>
3785
+ <input type="checkbox" class="reply-mode-switch__input" ${trustedReadsEnabled ? "checked" : ""} data-auto-pilot-checkbox />
3786
+ <span class="reply-mode-switch__track" aria-hidden="true"><span class="reply-mode-switch__thumb"></span></span>
3787
+ <span class="reply-mode-switch__copy">
3788
+ <span class="reply-mode-switch__title">
3789
+ <span>${escapeHtml(L("settings.autoPilot.trustedReadsTitle"))}</span>
3790
+ <span class="reply-mode-switch__state">${escapeHtml(trustedReadsStateLabel)}</span>
3791
+ </span>
3792
+ <span class="reply-mode-switch__hint">${escapeHtml(L("settings.autoPilot.trustedReadsDescription"))}</span>
3793
+ </span>
3794
+ </label>
3795
+ `, `
3796
+ <div class="settings-toggle-subhead" role="presentation">
3797
+ <span class="settings-toggle-subhead__title">${escapeHtml(L("settings.autoPilot.trustedWritesTitle"))}</span>
3798
+ <span class="settings-toggle-subhead__state">${escapeHtml(trustedWritesStateLabel)}</span>
3799
+ </div>
3800
+ `, `
3801
+ <div class="settings-toggle-subcopy muted">${escapeHtml(L("settings.autoPilot.trustedWritesDescription"))}</div>
3802
+ `, `
3803
+ <label class="reply-mode-switch reply-mode-switch--grouped" data-auto-pilot-write-lane="content">
3804
+ <input type="checkbox" class="reply-mode-switch__input" ${writeLaneContentEnabled ? "checked" : ""} data-auto-pilot-write-lane-checkbox="content" />
3805
+ <span class="reply-mode-switch__track" aria-hidden="true"><span class="reply-mode-switch__thumb"></span></span>
3806
+ <span class="reply-mode-switch__copy">
3807
+ <span class="reply-mode-switch__title">
3808
+ <span>${escapeHtml(L("settings.autoPilot.writeLaneContentTitle"))}</span>
3809
+ <span class="reply-mode-switch__state">${escapeHtml(writeLaneContentEnabled ? L("common.enabled") : L("common.disabled"))}</span>
3810
+ </span>
3811
+ <span class="reply-mode-switch__hint">${escapeHtml(L("settings.autoPilot.writeLaneContentDescription"))}</span>
3812
+ </span>
3813
+ </label>
3814
+ `, `
3815
+ <label class="reply-mode-switch reply-mode-switch--grouped" data-auto-pilot-write-lane="ui-tests">
3816
+ <input type="checkbox" class="reply-mode-switch__input" ${writeLaneUiTestsEnabled ? "checked" : ""} data-auto-pilot-write-lane-checkbox="ui-tests" />
3817
+ <span class="reply-mode-switch__track" aria-hidden="true"><span class="reply-mode-switch__thumb"></span></span>
3818
+ <span class="reply-mode-switch__copy">
3819
+ <span class="reply-mode-switch__title">
3820
+ <span>${escapeHtml(L("settings.autoPilot.writeLaneUiTestsTitle"))}</span>
3821
+ <span class="reply-mode-switch__state">${escapeHtml(writeLaneUiTestsEnabled ? L("common.enabled") : L("common.disabled"))}</span>
3822
+ </span>
3823
+ <span class="reply-mode-switch__hint">${escapeHtml(L("settings.autoPilot.writeLaneUiTestsDescription"))}</span>
3824
+ </span>
3825
+ </label>
3826
+ `, `
3827
+ <label class="reply-mode-switch reply-mode-switch--grouped" data-auto-pilot-write-lane="source">
3828
+ <input type="checkbox" class="reply-mode-switch__input" ${writeLaneSourceEnabled ? "checked" : ""} data-auto-pilot-write-lane-checkbox="source" />
3829
+ <span class="reply-mode-switch__track" aria-hidden="true"><span class="reply-mode-switch__thumb"></span></span>
3830
+ <span class="reply-mode-switch__copy">
3831
+ <span class="reply-mode-switch__title">
3832
+ <span>${escapeHtml(L("settings.autoPilot.writeLaneSourceTitle"))}</span>
3833
+ <span class="reply-mode-switch__state">${escapeHtml(writeLaneSourceEnabled ? L("common.enabled") : L("common.disabled"))}</span>
3834
+ </span>
3835
+ <span class="reply-mode-switch__hint">${escapeHtml(L("settings.autoPilot.writeLaneSourceDescription"))}</span>
3836
+ </span>
3837
+ </label>
3838
+ `], { listClassName: "settings-list settings-list--toggle-group" })}
3839
+ <p class="settings-page-copy muted">${escapeHtml(L("settings.autoPilot.scopeNote"))}</p>
3840
+ ${
3841
+ suggestions.length
3842
+ ? renderSettingsGroup(
3843
+ L("settings.autoPilot.suggestionsTitle"),
3844
+ suggestions.map((suggestion) => renderSettingsAutoPilotSuggestion(suggestion))
3845
+ )
3846
+ : ""
3847
+ }
3848
+ ${
3849
+ recentEntries.length
3850
+ ? renderSettingsGroup(
3851
+ L("settings.autoPilot.recentTitle"),
3852
+ recentEntries.map((item) => renderSettingsAutoPilotRecentEntry(item))
3853
+ )
3854
+ : `
3855
+ <section class="settings-group">
3856
+ <p class="settings-group__title">${escapeHtml(L("settings.autoPilot.recentTitle"))}</p>
3857
+ <div class="settings-copy-block settings-copy-block--compact">
3858
+ <p class="muted">${escapeHtml(L("settings.autoPilot.recentEmpty"))}</p>
3859
+ </div>
3860
+ </section>
3861
+ `
3862
+ }
3863
+ </div>
3864
+ `;
3865
+ }
3866
+
3867
+ function recentAutoPilotEntries(limit = 5) {
3868
+ const entries = Array.isArray(state.timeline?.entries) ? state.timeline.entries : [];
3869
+ return entries
3870
+ .filter((entry) => isAutoPilotApprovalEntry(entry))
3871
+ .sort((a, b) => (Number(b.createdAtMs) || 0) - (Number(a.createdAtMs) || 0))
3872
+ .slice(0, limit);
3873
+ }
3874
+
3875
+ function isAutoPilotApprovalEntry(entry) {
3876
+ const stableId = normalizeClientText(entry?.stableId || "");
3877
+ return (
3878
+ normalizeClientText(entry?.kind || "") === "approval" &&
3879
+ normalizeClientText(entry?.outcome || "") === "approved" &&
3880
+ (stableId.endsWith(":autopilot") || stableId.includes(":autopilot-write"))
3881
+ );
3882
+ }
3883
+
3884
+ function autoPilotEntryMode(item) {
3885
+ const stableId = normalizeClientText(item?.stableId || "");
3886
+ return stableId.includes(":autopilot-write") ? "write" : "read";
3887
+ }
3888
+
3889
+ function autoPilotEntryWriteLane(item) {
3890
+ const stableId = normalizeClientText(item?.stableId || "");
3891
+ const match = stableId.match(/:autopilot-write:([a-z_-]+)$/u);
3892
+ return normalizeClientText(match?.[1] || "");
3893
+ }
3894
+
3895
+ function isManualApprovedWriteEntry(entry) {
3896
+ const stableId = normalizeClientText(entry?.stableId || "");
3897
+ return (
3898
+ normalizeClientText(entry?.kind || "") === "approval" &&
3899
+ normalizeClientText(entry?.outcome || "") === "approved" &&
3900
+ !stableId.includes(":autopilot") &&
3901
+ normalizeClientFileRefs(entry?.fileRefs).length > 0 &&
3902
+ normalizeClientText(entry?.diffText || "").length > 0
3903
+ );
3904
+ }
3905
+
3906
+ function autoPilotDeniedWritePathClient(fileRef) {
3907
+ const normalized = normalizeClientText(fileRef || "");
3908
+ if (!normalized) {
3909
+ return true;
3910
+ }
3911
+ const lower = normalized.toLowerCase();
3912
+ const segments = lower.split(/[\\/]+/u).filter(Boolean);
3913
+ const basename = segments[segments.length - 1] || "";
3914
+ if (
3915
+ segments.some((segment) => [".ssh", ".aws", ".gnupg", ".azure", ".kube", ".github", ".gitlab", ".terraform", ".claude", ".husky", ".vscode"].includes(segment))
3916
+ ) {
3917
+ return true;
3918
+ }
3919
+ if (basename === ".npmrc" || basename === ".netrc" || basename === ".env" || basename.startsWith(".env.")) {
3920
+ return true;
3921
+ }
3922
+ if (basename.endsWith(".pem") || basename.endsWith(".key") || basename.endsWith(".p12") || basename.endsWith(".pfx")) {
3923
+ return true;
3924
+ }
3925
+ if (
3926
+ [
3927
+ "package.json",
3928
+ "package-lock.json",
3929
+ "pnpm-lock.yaml",
3930
+ "yarn.lock",
3931
+ "bun.lockb",
3932
+ "cargo.toml",
3933
+ "cargo.lock",
3934
+ "gemfile",
3935
+ "gemfile.lock",
3936
+ "podfile",
3937
+ "podfile.lock",
3938
+ "composer.json",
3939
+ "composer.lock",
3940
+ "pipfile",
3941
+ "pipfile.lock",
3942
+ "poetry.lock",
3943
+ "requirements.txt",
3944
+ "dockerfile",
3945
+ "wrangler.toml",
3946
+ "tsconfig.json",
3947
+ "tsconfig.tsbuildinfo",
3948
+ ].includes(basename)
3949
+ ) {
3950
+ return true;
3951
+ }
3952
+ return /^id_[a-z0-9._-]+$/iu.test(basename) || basename.includes("secret") || basename.includes("credential");
3953
+ }
3954
+
3955
+ function autoPilotContentWritePathClient(fileRef) {
3956
+ const normalized = normalizeClientText(fileRef || "");
3957
+ if (!normalized || autoPilotDeniedWritePathClient(normalized)) {
3958
+ return false;
3959
+ }
3960
+ const lower = normalized.toLowerCase();
3961
+ const segments = lower.split(/[\\/]+/u).filter(Boolean);
3962
+ const basename = segments[segments.length - 1] || "";
3963
+ const extension = basename.includes(".") ? `.${basename.split(".").pop().toLowerCase()}` : "";
3964
+ const basenameWithoutExtension = extension ? basename.slice(0, -extension.length) : basename;
3965
+ if ([".md", ".mdx", ".txt", ".rst", ".adoc"].includes(extension)) {
3966
+ return true;
3967
+ }
3968
+ if (["license", "notice", "copying", "readme", "changelog", "contributing"].includes(basenameWithoutExtension.toLowerCase())) {
3969
+ return true;
3970
+ }
3971
+ if (segments.includes("i18n") && [".js", ".ts", ".json", ".yaml", ".yml"].includes(extension)) {
3972
+ return true;
3973
+ }
3974
+ if (segments.includes("messages") && extension === ".json") {
3975
+ return true;
3976
+ }
3977
+ return false;
3978
+ }
3979
+
3980
+ function autoPilotUiTestsWritePathClient(fileRef) {
3981
+ const normalized = normalizeClientText(fileRef || "");
3982
+ if (!normalized || autoPilotDeniedWritePathClient(normalized)) {
3983
+ return false;
3984
+ }
3985
+ const lower = normalized.toLowerCase();
3986
+ const segments = lower.split(/[\\/]+/u).filter(Boolean);
3987
+ const basename = segments[segments.length - 1] || "";
3988
+ const extension = basename.includes(".") ? `.${basename.split(".").pop().toLowerCase()}` : "";
3989
+ if ([".css", ".scss", ".sass", ".less", ".styl", ".pcss"].includes(extension)) {
3990
+ return true;
3991
+ }
3992
+ if (segments.includes("__tests__") || /\.(test|spec)\.[cm]?[jt]sx?$/u.test(basename)) {
3993
+ return true;
3994
+ }
3995
+ if ((segments.includes("web") || segments.includes("components")) && [".js", ".jsx", ".ts", ".tsx", ".html"].includes(extension)) {
3996
+ return true;
3997
+ }
3998
+ return false;
3999
+ }
4000
+
4001
+ function autoPilotSourceWritePathClient(fileRef) {
4002
+ const normalized = normalizeClientText(fileRef || "");
4003
+ if (!normalized || autoPilotDeniedWritePathClient(normalized)) {
4004
+ return false;
4005
+ }
4006
+ if (autoPilotContentWritePathClient(normalized) || autoPilotUiTestsWritePathClient(normalized)) {
4007
+ return false;
4008
+ }
4009
+ return /\.(?:[cm]?[jt]sx?)$/u.test(normalized);
4010
+ }
4011
+
4012
+ function diffAddedLinesClient(diffText) {
4013
+ return String(diffText || "")
4014
+ .replace(/\r\n/gu, "\n")
4015
+ .split("\n")
4016
+ .filter((line) => line.startsWith("+") && !line.startsWith("+++"))
4017
+ .map((line) => line.slice(1));
4018
+ }
4019
+
4020
+ function addedDiffLinesContainClient(diffText, pattern) {
4021
+ return diffAddedLinesClient(diffText).some((line) => pattern.test(line));
4022
+ }
4023
+
4024
+ function hasUnsafeUiOrTestWriteDiffClient(diffText) {
4025
+ const patterns = [
4026
+ /\bprocess\.env\b/u,
4027
+ /\b(?:child_process|spawn|exec|execFile|fork)\b/u,
4028
+ /\bfs\.(?:write|append|rm|unlink|rename|chmod|chown|copyFile|cp)\b/u,
4029
+ /\b(?:fetch|axios|XMLHttpRequest)\s*\(/u,
4030
+ /\bcrypto\b/u,
4031
+ /\b(?:secret|token|password|privateKey|credential)\b/iu,
4032
+ ];
4033
+ return (
4034
+ addedDiffLinesContainClient(diffText, /^\s*(?:import\s|export\s+.*\s+from\s)/u) ||
4035
+ addedDiffLinesContainClient(diffText, /\brequire\s*\(/u) ||
4036
+ patterns.some((pattern) => addedDiffLinesContainClient(diffText, pattern))
4037
+ );
4038
+ }
4039
+
4040
+ function hasUnsafeSourceWriteDiffClient(diffText) {
4041
+ const patterns = [
4042
+ /\bprocess\.env\b/u,
4043
+ /\b(?:child_process|spawn|exec|execFile|fork)\b/u,
4044
+ /\bfs\.(?:write|append|rm|unlink|rename|chmod|chown|copyFile|cp)\b/u,
4045
+ /\b(?:fetch|axios|XMLHttpRequest)\s*\(/u,
4046
+ /\b(?:net|tls|dgram|http2?)\b/u,
4047
+ /\bcrypto\b/u,
4048
+ /\b(?:secret|token|password|privateKey|credential)\b/iu,
4049
+ ];
4050
+ return (
4051
+ addedDiffLinesContainClient(diffText, /^\s*(?:import\s|export\s+.*\s+from\s)/u) ||
4052
+ addedDiffLinesContainClient(diffText, /\brequire\s*\(/u) ||
4053
+ patterns.some((pattern) => addedDiffLinesContainClient(diffText, pattern))
4054
+ );
4055
+ }
4056
+
4057
+ function classifyManualWriteLaneSuggestion(entry) {
4058
+ const fileRefs = normalizeClientFileRefs(entry?.fileRefs);
4059
+ const diffText = normalizeClientText(entry?.diffText || "");
4060
+ const added = Math.max(0, Number(entry?.diffAddedLines) || 0);
4061
+ const removed = Math.max(0, Number(entry?.diffRemovedLines) || 0);
4062
+ const totalChangedLines = added + removed;
4063
+ if (!fileRefs.length || !diffText || totalChangedLines === 0) {
4064
+ return "";
4065
+ }
4066
+ 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)) {
4067
+ return "";
4068
+ }
4069
+ if (
4070
+ fileRefs.length >= 1 &&
4071
+ fileRefs.length <= 3 &&
4072
+ totalChangedLines <= 120 &&
4073
+ fileRefs.every((fileRef) => autoPilotContentWritePathClient(fileRef))
4074
+ ) {
4075
+ return "content";
4076
+ }
4077
+ if (
4078
+ fileRefs.length >= 1 &&
4079
+ fileRefs.length <= 2 &&
4080
+ totalChangedLines <= 80 &&
4081
+ fileRefs.every((fileRef) => autoPilotUiTestsWritePathClient(fileRef)) &&
4082
+ !hasUnsafeUiOrTestWriteDiffClient(diffText)
4083
+ ) {
4084
+ return "ui_tests";
4085
+ }
4086
+ if (
4087
+ fileRefs.length === 1 &&
4088
+ totalChangedLines <= 40 &&
4089
+ fileRefs.every((fileRef) => autoPilotSourceWritePathClient(fileRef)) &&
4090
+ !hasUnsafeSourceWriteDiffClient(diffText)
4091
+ ) {
4092
+ return "source";
4093
+ }
4094
+ return "";
4095
+ }
4096
+
4097
+ function isWriteLaneEnabled(lane) {
4098
+ return lane === "content"
4099
+ ? state.session?.autoPilotWriteLaneContent === true
4100
+ : lane === "ui_tests"
4101
+ ? state.session?.autoPilotWriteLaneUiTests === true
4102
+ : state.session?.autoPilotWriteLaneSource === true;
4103
+ }
4104
+
4105
+ function recentAutoPilotSuggestions(limit = 40, minCount = 3) {
4106
+ const entries = Array.isArray(state.timeline?.entries) ? state.timeline.entries : [];
4107
+ const counts = new Map();
4108
+ for (const entry of entries
4109
+ .filter((item) => isManualApprovedWriteEntry(item))
4110
+ .sort((a, b) => (Number(b.createdAtMs) || 0) - (Number(a.createdAtMs) || 0))
4111
+ .slice(0, limit)) {
4112
+ const lane = classifyManualWriteLaneSuggestion(entry);
4113
+ if (!lane || isWriteLaneEnabled(lane)) {
4114
+ continue;
4115
+ }
4116
+ counts.set(lane, (counts.get(lane) || 0) + 1);
4117
+ }
4118
+ return ["content", "ui_tests", "source"]
4119
+ .map((lane) => ({ lane, count: counts.get(lane) || 0 }))
4120
+ .filter((item) => item.count >= minCount);
4121
+ }
4122
+
4123
+ function firstMarkdownCodeFence(text) {
4124
+ const match = String(text || "").match(/```(?:\w+)?\n([\s\S]*?)\n```/u);
4125
+ return normalizeClientText(match?.[1] || "");
4126
+ }
4127
+
4128
+ function truncateUiText(value, maxGlyphs = 92) {
4129
+ const normalized = normalizeClientText(value || "");
4130
+ if (!normalized) {
4131
+ return "";
4132
+ }
4133
+ const glyphs = Array.from(normalized);
4134
+ return glyphs.length > maxGlyphs ? `${glyphs.slice(0, maxGlyphs).join("")}…` : normalized;
4135
+ }
4136
+
4137
+ function autoPilotEntryHeadline(item) {
4138
+ const mode = autoPilotEntryMode(item);
4139
+ if (mode === "read") {
4140
+ return truncateUiText(firstMarkdownCodeFence(item?.messageText || "") || item?.summary || item?.title || "");
4141
+ }
4142
+
4143
+ const fileRefs = normalizeClientFileRefs(item?.fileRefs);
4144
+ const primaryRef = fileRefs[0] || item?.summary || item?.title || "";
4145
+ const extraCount = Math.max(0, fileRefs.length - 1);
4146
+ const stats =
4147
+ Number.isFinite(Number(item?.diffAddedLines)) && Number.isFinite(Number(item?.diffRemovedLines))
4148
+ ? ` (+${Math.max(0, Number(item?.diffAddedLines) || 0)} / -${Math.max(0, Number(item?.diffRemovedLines) || 0)})`
4149
+ : "";
4150
+ return truncateUiText(`${primaryRef}${extraCount > 0 ? ` +${extraCount}` : ""}${stats}`);
4151
+ }
4152
+
4153
+ function renderSettingsAutoPilotRecentEntry(item) {
4154
+ const mode = autoPilotEntryMode(item);
4155
+ const badgeLabel = mode === "write" ? L("settings.autoPilot.recentWrite") : L("settings.autoPilot.recentRead");
4156
+ const badgeClass = mode === "write" ? "settings-compose-badge--write" : "settings-compose-badge--read";
4157
+ const iconName = mode === "write" ? "file-event" : "approval";
4158
+ const iconToneClass = mode === "write" ? "settings-icon-entry__icon--write" : "settings-icon-entry__icon--read";
4159
+ const headline = autoPilotEntryHeadline(item) || L("common.untitledItem");
4160
+ const threadLabel = resolvedThreadLabel(item?.threadId || "", item?.threadLabel || "");
4161
+ const laneLabel = ({
4162
+ content: L("settings.autoPilot.recentContent"),
4163
+ "ui_tests": L("settings.autoPilot.recentUiTests"),
4164
+ source: L("settings.autoPilot.recentSource"),
4165
+ }[autoPilotEntryWriteLane(item)] || "");
4166
+ const metaParts = [providerDisplayName(item?.provider), formatTimelineTimestamp(item?.createdAtMs)].filter(Boolean);
4167
+
4168
+ return `
4169
+ <button
4170
+ type="button"
4171
+ class="settings-compose-entry settings-icon-entry settings-autopilot-entry"
4172
+ data-open-item-kind="${escapeHtml(item.kind)}"
4173
+ data-open-item-token="${escapeHtml(item.token)}"
4174
+ data-source-tab="timeline"
4175
+ >
4176
+ <span class="settings-icon-entry__icon ${iconToneClass}" aria-hidden="true">${renderIcon(iconName)}</span>
4177
+ <span class="settings-icon-entry__body">
4178
+ <span class="settings-icon-entry__title-row">
4179
+ <span class="settings-compose-entry__title">${escapeHtml(headline)}</span>
4180
+ <span class="settings-compose-badge ${badgeClass}">${escapeHtml(badgeLabel)}</span>
4181
+ ${mode === "write" && laneLabel ? `<span class="settings-compose-badge settings-compose-badge--lane">${escapeHtml(laneLabel)}</span>` : ""}
4182
+ </span>
4183
+ <span class="settings-autopilot-entry__meta">${escapeHtml(metaParts.join(" · "))}</span>
4184
+ ${threadLabel ? `<span class="settings-autopilot-entry__thread">${escapeHtml(threadLabel)}</span>` : ""}
4185
+ </span>
3399
4186
  </button>
3400
4187
  `;
3401
4188
  }
3402
4189
 
3403
- function renderSettingsAwayModePage() {
3404
- const enabled = state.session?.claudeAwayMode === true;
3405
- const stateLabel = enabled ? L("settings.claudeAway.on") : L("settings.claudeAway.off");
4190
+ function renderSettingsAutoPilotSuggestion({ lane, count }) {
4191
+ const title =
4192
+ lane === "content"
4193
+ ? L("settings.autoPilot.suggestionContentTitle")
4194
+ : lane === "ui_tests"
4195
+ ? L("settings.autoPilot.suggestionUiTestsTitle")
4196
+ : L("settings.autoPilot.suggestionSourceTitle");
4197
+ const body =
4198
+ lane === "content"
4199
+ ? L("settings.autoPilot.suggestionContentBody", { count })
4200
+ : lane === "ui_tests"
4201
+ ? L("settings.autoPilot.suggestionUiTestsBody", { count })
4202
+ : L("settings.autoPilot.suggestionSourceBody", { count });
3406
4203
  return `
3407
- <div class="settings-page">
3408
- ${renderSettingsGroup("", [`
3409
- <label class="reply-mode-switch" data-claude-away-toggle>
3410
- <input type="checkbox" class="reply-mode-switch__input" ${enabled ? "checked" : ""} data-claude-away-checkbox />
3411
- <span class="reply-mode-switch__track" aria-hidden="true"><span class="reply-mode-switch__thumb"></span></span>
3412
- <span class="reply-mode-switch__copy">
3413
- <span class="reply-mode-switch__title">
3414
- <span>${escapeHtml(L("settings.claudeAway.title"))}</span>
3415
- <span class="reply-mode-switch__state">${escapeHtml(stateLabel)}</span>
3416
- </span>
3417
- <span class="reply-mode-switch__hint">${escapeHtml(L("settings.claudeAway.description"))}</span>
3418
- </span>
3419
- </label>
3420
- `])}
3421
- <p class="settings-page-copy muted">${escapeHtml(L("settings.awayMode.codexNote"))}</p>
4204
+ <div class="settings-suggestion-card">
4205
+ <div class="settings-suggestion-card__header">
4206
+ <div>
4207
+ <p class="settings-suggestion-card__title">${escapeHtml(title)}</p>
4208
+ <p class="settings-suggestion-card__body">${escapeHtml(body)}</p>
4209
+ </div>
4210
+ <button
4211
+ type="button"
4212
+ class="primary settings-suggestion-card__action"
4213
+ data-auto-pilot-suggest-lane="${escapeHtml(lane)}"
4214
+ >${escapeHtml(L("settings.autoPilot.suggestionEnable"))}</button>
4215
+ </div>
3422
4216
  </div>
3423
4217
  `;
3424
4218
  }
@@ -3694,6 +4488,388 @@ function renderSettingsA2aSharePage(context) {
3694
4488
  `;
3695
4489
  }
3696
4490
 
4491
+ function renderSettingsWalletPage(context) {
4492
+ const hazbase = context.hazbase || { enabled: false };
4493
+ if (!hazbase?.enabled) {
4494
+ return `
4495
+ <div class="settings-page">
4496
+ <p class="settings-page-copy muted">${escapeHtml(L("settings.wallet.unavailable"))}</p>
4497
+ </div>
4498
+ `;
4499
+ }
4500
+ const flow = deriveHazbaseWalletFlow(hazbase);
4501
+
4502
+ // Progressive disclosure. Previous layout rendered the full status summary
4503
+ // plus all four full-sized step cards at once; new account flows were
4504
+ // dominated by locked-looking cards and it was hard to tell which step was
4505
+ // actionable. Now we:
4506
+ // - skip `locked` steps entirely (still-unreachable actions add noise),
4507
+ // - collapse `complete` steps to a compact one-line row that keeps the
4508
+ // verified value visible (email / address) without a full card,
4509
+ // - keep the single `current`/`pending` step in the full action card so
4510
+ // the CTA is unambiguous,
4511
+ // - hide the optional mainnet step behind a subtle opt-in link until the
4512
+ // user explicitly requests it (see `state.hazbaseMainnetOptIn`).
4513
+ // The compact rows also replace the former "Current status" summary group
4514
+ // above the flow, so signed-in email / passkey state / addresses are no
4515
+ // longer duplicated.
4516
+ const guideRows = [
4517
+ renderHazbaseWalletBanner(flow),
4518
+ state.hazbaseNotice
4519
+ ? `<div class="settings-copy-block settings-copy-block--compact wallet-flow-message wallet-flow-message--notice"><p>${escapeHtml(state.hazbaseNotice)}</p></div>`
4520
+ : "",
4521
+ state.hazbaseError
4522
+ ? `<div class="settings-copy-block settings-copy-block--compact wallet-flow-message wallet-flow-message--error"><p>${escapeHtml(state.hazbaseError)}</p></div>`
4523
+ : "",
4524
+ renderHazbaseWalletStepList(flow),
4525
+ ].filter(Boolean);
4526
+ const advancedActions = hazbase.signedIn
4527
+ ? `<button class="secondary secondary--wide" type="button" data-hazbase-action="logout">${escapeHtml(L("settings.hazbase.action.signOut"))}</button>`
4528
+ : "";
4529
+ // Render the wallet flow without `renderSettingsGroup`'s `.settings-list`
4530
+ // wrapper. The banner (`.settings-copy-block`), notice/error blocks, and
4531
+ // each step card (`.wallet-step-card`) already have their own rounded
4532
+ // frame — wrapping them in another `.settings-list` produced a visible
4533
+ // "box inside a box" nest. The title (`.settings-group__title`) still
4534
+ // sits above a flat stack of sibling cards via `.wallet-setup-stack`.
4535
+ const flowTitle = L("settings.wallet.flow.title");
4536
+ // Brand attribution. The wallet stack (factory + validator + bundler +
4537
+ // paymaster) is provided by hazBase; the link points to their LP so
4538
+ // curious users can discover what's running the signing path.
4539
+ const poweredBy = `
4540
+ <p class="wallet-powered-by muted">
4541
+ powered by <a href="https://lp.hazbase.com" target="_blank" rel="noopener noreferrer">hazBase</a>
4542
+ </p>
4543
+ `;
4544
+ return `
4545
+ <div class="settings-page">
4546
+ <section class="settings-group">
4547
+ ${flowTitle ? `<p class="settings-group__title">${escapeHtml(flowTitle)}</p>` : ""}
4548
+ <div class="wallet-setup-stack">
4549
+ ${guideRows.join("")}
4550
+ </div>
4551
+ </section>
4552
+ ${advancedActions ? renderSettingsActionPanel(advancedActions, L("settings.wallet.advanced.title")) : ""}
4553
+ ${poweredBy}
4554
+ </div>
4555
+ `;
4556
+ }
4557
+
4558
+ function renderHazbaseWalletStepList(flow) {
4559
+ const rendered = [];
4560
+ for (const step of flow.steps) {
4561
+ // Step 4 (Base mainnet) is hidden while hazbase's closed beta keeps
4562
+ // chainId 8453 out of `WALLET_GATEWAY_CHAINS_JSON` — calling bootstrap
4563
+ // against mainnet currently returns `unsupported_chain`, so there's no
4564
+ // path for the user to complete the step. Step 3 (Base Sepolia) is the
4565
+ // true completion boundary; the ready banner already keys off
4566
+ // `coreReady = signedIn && hasPasskey && hasBaseSepolia`. Drop this
4567
+ // guard (and restore the opt-in reveal) once mainnet is enabled
4568
+ // server-side.
4569
+ if (step.number === 4) {
4570
+ continue;
4571
+ }
4572
+
4573
+ // Locked steps don't render. Revealing them only adds grayed-out
4574
+ // placeholders below the active step; users mistake the placeholder
4575
+ // status chips for inactive buttons.
4576
+ if (step.status === "locked") {
4577
+ continue;
4578
+ }
4579
+
4580
+ const mode = step.status === "complete" ? "compact" : "full";
4581
+ rendered.push(renderHazbaseWalletStepCard(step, { mode }));
4582
+ }
4583
+ return `<div class="wallet-step-list">${rendered.join("")}</div>`;
4584
+ }
4585
+
4586
+ function renderHazbaseWalletMainnetOptIn() {
4587
+ return `
4588
+ <button class="wallet-mainnet-optin" type="button" data-hazbase-action="mainnet-opt-in">
4589
+ <span class="wallet-mainnet-optin__body">
4590
+ <span class="wallet-mainnet-optin__label">${escapeHtml(L("settings.wallet.mainnet.optIn"))}</span>
4591
+ <span class="wallet-mainnet-optin__hint muted">${escapeHtml(L("settings.wallet.mainnet.optInHint"))}</span>
4592
+ </span>
4593
+ <span class="wallet-mainnet-optin__chevron" aria-hidden="true">→</span>
4594
+ </button>
4595
+ `;
4596
+ }
4597
+
4598
+ function deriveHazbaseWalletFlow(hazbase) {
4599
+ const accounts = Array.isArray(hazbase.accounts) ? hazbase.accounts : [];
4600
+ const baseSepolia = accounts.find((entry) => Number(entry.chainId) === 84532) || null;
4601
+ const baseMainnet = accounts.find((entry) => Number(entry.chainId) === 8453) || null;
4602
+ const signedIn = Boolean(hazbase.signedIn);
4603
+ const passkeyHost = hazbasePasskeyHostSupport();
4604
+ const hasPasskey = Boolean(hazbase.credentialId || hazbase.deviceBindingId);
4605
+ const hasBaseSepolia = Boolean(baseSepolia?.smartAccountAddress);
4606
+ const hasBaseMainnet = Boolean(baseMainnet?.smartAccountAddress);
4607
+ const coreReady = signedIn && hasPasskey && hasBaseSepolia;
4608
+
4609
+ const actionButton = (labelKey, action, { primary = false, disabled = false } = {}) => `
4610
+ <button
4611
+ class="${primary ? "primary" : "secondary"} ${primary ? "primary--wide" : "secondary--wide"}"
4612
+ type="button"
4613
+ data-hazbase-action="${action}"
4614
+ ${disabled ? "disabled" : ""}
4615
+ >${escapeHtml(L(labelKey))}</button>
4616
+ `;
4617
+
4618
+ return {
4619
+ hasPasskey,
4620
+ baseSepolia,
4621
+ baseMainnet,
4622
+ coreReady,
4623
+ steps: [
4624
+ {
4625
+ number: 1,
4626
+ icon: "approval",
4627
+ title: L("settings.wallet.step.signIn.title"),
4628
+ copy: L("settings.wallet.step.signIn.copy"),
4629
+ detail: signedIn
4630
+ ? hazbase.email || L("settings.hazbase.status.signedIn")
4631
+ : state.hazbaseOtpRequested
4632
+ ? L("settings.hazbase.status.otpAwaitingVerify")
4633
+ : L("settings.hazbase.status.signedOut"),
4634
+ status: signedIn ? "complete" : "current",
4635
+ // Inline form: email input (always visible pre-sign-in) and the
4636
+ // one-time password input (revealed after a successful send).
4637
+ // Putting the field immediately above its submit button is the
4638
+ // whole point — users don't have to guess "what does this button
4639
+ // ask me?" before clicking.
4640
+ form: signedIn
4641
+ ? ""
4642
+ : renderHazbaseSignInForm({
4643
+ email: state.hazbaseOtpEmail || hazbase.email || "",
4644
+ otpRequested: Boolean(state.hazbaseOtpRequested),
4645
+ code: state.hazbaseOtpCode || "",
4646
+ }),
4647
+ // Sequential flow: before a code is requested, only the primary
4648
+ // "send" action is visible. After a successful send we swap the
4649
+ // primary CTA to "verify" and demote "send" to a quieter "resend"
4650
+ // option in case the email never arrives. This keeps the user's
4651
+ // next move unambiguous.
4652
+ actions: signedIn
4653
+ ? []
4654
+ : state.hazbaseOtpRequested
4655
+ ? [
4656
+ actionButton("settings.hazbase.action.verifyOtp", "verify-otp", { primary: true }),
4657
+ actionButton("settings.hazbase.action.resendOtp", "request-otp"),
4658
+ ]
4659
+ : [
4660
+ actionButton("settings.hazbase.action.requestOtp", "request-otp", { primary: true }),
4661
+ ],
4662
+ },
4663
+ {
4664
+ number: 2,
4665
+ icon: "lock",
4666
+ title: L("settings.wallet.step.passkey.title"),
4667
+ copy: L("settings.wallet.step.passkey.copy"),
4668
+ detail: hasPasskey
4669
+ ? L("settings.hazbase.passkey.ready")
4670
+ : passkeyHost.eligible
4671
+ ? L("settings.hazbase.passkey.missing")
4672
+ : passkeyHost.detail,
4673
+ status: hasPasskey ? "complete" : signedIn ? "current" : "locked",
4674
+ actions: hasPasskey
4675
+ ? []
4676
+ : [
4677
+ actionButton("settings.hazbase.action.registerPasskey", "register-passkey", {
4678
+ primary: signedIn && passkeyHost.eligible,
4679
+ disabled: !signedIn || !passkeyHost.eligible,
4680
+ }),
4681
+ ],
4682
+ },
4683
+ {
4684
+ number: 3,
4685
+ icon: "coin",
4686
+ title: L("settings.wallet.step.baseSepolia.title"),
4687
+ copy: L("settings.wallet.step.baseSepolia.copy"),
4688
+ detail: baseSepolia?.smartAccountAddress || L("settings.hazbase.wallet.missing"),
4689
+ monoDetail: Boolean(baseSepolia?.smartAccountAddress),
4690
+ status: hasBaseSepolia ? "complete" : signedIn && hasPasskey ? "current" : "locked",
4691
+ actions: hasBaseSepolia
4692
+ ? []
4693
+ : [
4694
+ actionButton("settings.hazbase.action.bootstrapBaseSepolia", "bootstrap-base-sepolia", {
4695
+ primary: signedIn && hasPasskey,
4696
+ disabled: !signedIn || !hasPasskey,
4697
+ }),
4698
+ ],
4699
+ },
4700
+ {
4701
+ number: 4,
4702
+ icon: "coin",
4703
+ title: L("settings.wallet.step.base.title"),
4704
+ copy: L("settings.wallet.step.base.copy"),
4705
+ detail: baseMainnet?.smartAccountAddress || L("settings.hazbase.wallet.missing"),
4706
+ monoDetail: Boolean(baseMainnet?.smartAccountAddress),
4707
+ status: hasBaseMainnet ? "complete" : coreReady ? "optional" : "locked",
4708
+ actions: hasBaseMainnet
4709
+ ? []
4710
+ : [
4711
+ actionButton("settings.hazbase.action.bootstrapBase", "bootstrap-base", {
4712
+ disabled: !coreReady,
4713
+ }),
4714
+ ],
4715
+ },
4716
+ ],
4717
+ };
4718
+ }
4719
+
4720
+ function renderHazbaseWalletBanner(flow) {
4721
+ const title = flow.coreReady ? L("settings.wallet.ready.title") : L("settings.wallet.flow.banner.title");
4722
+ const className = flow.coreReady
4723
+ ? "settings-copy-block settings-copy-block--stacked wallet-flow-banner wallet-flow-banner--ready"
4724
+ : "settings-copy-block settings-copy-block--stacked wallet-flow-banner";
4725
+ // Ready state: surface the smart-account address prominently so the user
4726
+ // can see at a glance which wallet agents will use as `--pay-to` when
4727
+ // gating paid shares / A2A payouts. The address is the single most
4728
+ // actionable fact on this page once setup is complete — muted filler
4729
+ // copy buries it.
4730
+ const payoutAddress = flow.baseSepolia?.smartAccountAddress || "";
4731
+ const copyLabel = L("settings.wallet.ready.copyAddress");
4732
+ const body = flow.coreReady && payoutAddress
4733
+ ? `
4734
+ <p class="wallet-flow-banner__copy muted">${escapeHtml(L("settings.wallet.ready.payoutIntro"))}</p>
4735
+ <button
4736
+ class="wallet-flow-banner__address"
4737
+ type="button"
4738
+ data-wallet-address-copy="${escapeHtml(payoutAddress)}"
4739
+ aria-label="${escapeHtml(copyLabel)}: ${escapeHtml(payoutAddress)}"
4740
+ >
4741
+ <span class="wallet-flow-banner__address-text">${escapeHtml(payoutAddress)}</span>
4742
+ <span class="wallet-flow-banner__address-icon-slot">
4743
+ <span class="wallet-flow-banner__address-icon wallet-flow-banner__address-icon--copy" aria-hidden="true">${renderIcon("copy")}</span>
4744
+ <span class="wallet-flow-banner__address-icon wallet-flow-banner__address-icon--check" aria-hidden="true">${renderIcon("check")}</span>
4745
+ </span>
4746
+ </button>
4747
+ `
4748
+ : `<p class="wallet-flow-banner__copy muted">${escapeHtml(
4749
+ flow.coreReady ? L("settings.wallet.ready.copy") : L("settings.wallet.flow.copy"),
4750
+ )}</p>`;
4751
+ return `
4752
+ <div class="${className}">
4753
+ <p class="wallet-flow-banner__eyebrow">${escapeHtml(L("settings.hazbase.title"))}</p>
4754
+ <p class="wallet-flow-banner__title">${escapeHtml(title)}</p>
4755
+ ${body}
4756
+ </div>
4757
+ `;
4758
+ }
4759
+
4760
+ function renderHazbaseWalletStepCard(step, { mode = "full" } = {}) {
4761
+ const statusMeta = {
4762
+ complete: { label: L("settings.wallet.status.complete"), icon: "completed" },
4763
+ current: { label: L("settings.wallet.status.current"), icon: "pending" },
4764
+ locked: { label: L("settings.wallet.status.locked"), icon: "lock" },
4765
+ optional: { label: L("settings.wallet.status.optional"), icon: "coin" },
4766
+ pending: { label: L("settings.wallet.status.pending"), icon: "pending" },
4767
+ }[step.status] || { label: L("settings.wallet.status.pending"), icon: "pending" };
4768
+
4769
+ if (mode === "compact") {
4770
+ // Compact row for finished steps. Keeps the check icon + title + one-line
4771
+ // detail visible (so the user can scan what's done at a glance) but
4772
+ // drops the copy/actions to stop the page from being four blocks tall.
4773
+ const detailClass = step.monoDetail
4774
+ ? "wallet-step-card__compact-detail wallet-step-card__compact-detail--mono"
4775
+ : "wallet-step-card__compact-detail";
4776
+ return `
4777
+ <div class="wallet-step-card wallet-step-card--compact wallet-step-card--${escapeHtml(step.status)}">
4778
+ <span class="wallet-step-card__compact-icon" aria-hidden="true">${renderIcon(statusMeta.icon)}</span>
4779
+ <div class="wallet-step-card__compact-body">
4780
+ <p class="wallet-step-card__compact-title">${escapeHtml(step.title)}</p>
4781
+ ${step.detail ? `<p class="${detailClass}">${escapeHtml(step.detail)}</p>` : ""}
4782
+ </div>
4783
+ <span class="wallet-step-card__compact-status" aria-hidden="true">${escapeHtml(statusMeta.label)}</span>
4784
+ </div>
4785
+ `;
4786
+ }
4787
+
4788
+ return `
4789
+ <article class="wallet-step-card wallet-step-card--${escapeHtml(step.status)}">
4790
+ <div class="wallet-step-card__header">
4791
+ <div class="wallet-step-card__headline">
4792
+ <span class="wallet-step-card__icon" aria-hidden="true">${renderIcon(step.icon)}</span>
4793
+ <div class="wallet-step-card__title-wrap">
4794
+ <p class="wallet-step-card__eyebrow">${escapeHtml(L("settings.wallet.stepNumber", { count: step.number }))}</p>
4795
+ <h3 class="wallet-step-card__title">${escapeHtml(step.title)}</h3>
4796
+ </div>
4797
+ </div>
4798
+ <span class="wallet-step-card__status wallet-step-card__status--${escapeHtml(step.status)}">
4799
+ <span class="wallet-step-card__status-icon" aria-hidden="true">${renderIcon(statusMeta.icon)}</span>
4800
+ <span>${escapeHtml(statusMeta.label)}</span>
4801
+ </span>
4802
+ </div>
4803
+ <p class="wallet-step-card__copy">${escapeHtml(step.copy)}</p>
4804
+ <p class="wallet-step-card__detail ${step.monoDetail ? "wallet-step-card__detail--mono" : ""}">${escapeHtml(step.detail)}</p>
4805
+ ${step.form || ""}
4806
+ ${step.actions.length ? `<div class="wallet-step-card__actions">${step.actions.join("")}</div>` : ""}
4807
+ </article>
4808
+ `;
4809
+ }
4810
+
4811
+ // Sign-in form sits inside step 1 when the user hasn't signed in yet.
4812
+ // Email field is always rendered (editable so users can correct a typo
4813
+ // and resend); OTP field only appears once a code has been issued. Both
4814
+ // inputs carry value="..." sourced from state so a poll-triggered
4815
+ // re-render doesn't wipe what the user was typing mid-edit.
4816
+ function renderHazbaseSignInForm({ email, otpRequested, code }) {
4817
+ // Once the OTP has been sent we lock the email field so accidental edits
4818
+ // can't invalidate the pending code. A small "change email" link is shown
4819
+ // as the intentional escape hatch — clicking it reverts the form to the
4820
+ // pre-sent state (code discarded, email stays for easy typo recovery).
4821
+ const emailLocked = Boolean(otpRequested);
4822
+ const emailLabelRow = emailLocked
4823
+ ? `<span class="wallet-step-card__field-label-row">
4824
+ <span class="wallet-step-card__field-label">${escapeHtml(L("settings.hazbase.field.emailLabel"))}</span>
4825
+ <button
4826
+ type="button"
4827
+ class="wallet-step-card__field-link"
4828
+ data-hazbase-action="change-email"
4829
+ >${escapeHtml(L("settings.hazbase.action.changeEmail"))}</button>
4830
+ </span>`
4831
+ : `<span class="wallet-step-card__field-label">${escapeHtml(L("settings.hazbase.field.emailLabel"))}</span>`;
4832
+ const emailField = `
4833
+ <label class="wallet-step-card__field">
4834
+ ${emailLabelRow}
4835
+ <input
4836
+ type="email"
4837
+ class="wallet-step-card__field-input${emailLocked ? " wallet-step-card__field-input--locked" : ""}"
4838
+ data-hazbase-input="otp-email"
4839
+ value="${escapeHtml(email || "")}"
4840
+ placeholder="${escapeHtml(L("settings.hazbase.field.emailPlaceholder"))}"
4841
+ autocomplete="email"
4842
+ inputmode="email"
4843
+ autocapitalize="off"
4844
+ autocorrect="off"
4845
+ spellcheck="false"
4846
+ ${emailLocked ? "disabled aria-disabled=\"true\"" : ""}
4847
+ />
4848
+ </label>
4849
+ `;
4850
+ const otpField = otpRequested
4851
+ ? `
4852
+ <label class="wallet-step-card__field">
4853
+ <span class="wallet-step-card__field-label">${escapeHtml(L("settings.hazbase.field.otpLabel"))}</span>
4854
+ <input
4855
+ type="text"
4856
+ class="wallet-step-card__field-input wallet-step-card__field-input--mono"
4857
+ data-hazbase-input="otp-code"
4858
+ value="${escapeHtml(code || "")}"
4859
+ placeholder="${escapeHtml(L("settings.hazbase.field.otpPlaceholder"))}"
4860
+ autocomplete="one-time-code"
4861
+ inputmode="numeric"
4862
+ autocapitalize="off"
4863
+ autocorrect="off"
4864
+ spellcheck="false"
4865
+ maxlength="12"
4866
+ />
4867
+ </label>
4868
+ `
4869
+ : "";
4870
+ return `<div class="wallet-step-card__form">${emailField}${otpField}</div>`;
4871
+ }
4872
+
3697
4873
  function formatRelativeAge(ms) {
3698
4874
  if (!Number.isFinite(ms) || ms < 0) return "";
3699
4875
  // Intl.RelativeTimeFormat with numeric:"auto" gives us locale-aware phrasing
@@ -3754,8 +4930,16 @@ function formatUsdcAtomic(atomic) {
3754
4930
  }
3755
4931
 
3756
4932
  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(" ");
4933
+ const rowClassName = [
4934
+ "settings-info-row",
4935
+ options.stacked ? "settings-info-row--stacked" : "",
4936
+ options.rowClassName || "",
4937
+ ].filter(Boolean).join(" ");
4938
+ const valueClassName = [
4939
+ "settings-info-row__value",
4940
+ options.mono ? "settings-info-row__value--mono" : "",
4941
+ options.valueClassName || "",
4942
+ ].filter(Boolean).join(" ");
3759
4943
  const displayValue = options.rawValue ? value : escapeHtml(value);
3760
4944
  return `
3761
4945
  <div class="${rowClassName}">
@@ -3868,6 +5052,7 @@ function renderStandardDetailDesktop(detail) {
3868
5052
  <h2 class="detail-title detail-title--desktop">${renderDetailTitle(detail)}</h2>
3869
5053
  ${detail.readOnly || detail.kind === "approval" || detail.kind === "moltbook_draft" || detail.kind === "moltbook_reply" || detail.kind === "thread_share" ? "" : renderDetailLead(detail, kindInfo)}
3870
5054
  ${renderPreviousContextCard(detail)}
5055
+ ${renderAutoPilotManualReview(detail)}
3871
5056
  ${renderInterruptedDetailNotice(detail)}
3872
5057
  ${renderMoltbookReplyComposer(detail)}
3873
5058
  ${renderMoltbookDraftComposer(detail)}
@@ -3907,6 +5092,7 @@ function renderStandardDetailMobile(detail) {
3907
5092
  <div class="mobile-detail-scroll mobile-detail-scroll--detail">
3908
5093
  ${renderDetailMetaRow(detail, kindInfo, { mobile: true })}
3909
5094
  ${renderPreviousContextCard(detail, { mobile: true })}
5095
+ ${renderAutoPilotManualReview(detail, { mobile: true })}
3910
5096
  ${renderInterruptedDetailNotice(detail, { mobile: true })}
3911
5097
  ${renderMoltbookReplyComposer(detail, { mobile: true })}
3912
5098
  ${renderMoltbookDraftComposer(detail, { mobile: true })}
@@ -4087,6 +5273,25 @@ function renderPreviousContextCard(detail, options = {}) {
4087
5273
  `;
4088
5274
  }
4089
5275
 
5276
+ function renderAutoPilotManualReview(detail, options = {}) {
5277
+ const review = detail?.autoPilotReview;
5278
+ if (!review?.title || !review?.body) {
5279
+ return "";
5280
+ }
5281
+ return `
5282
+ <section class="detail-card detail-card--autopilot ${options.mobile ? "detail-card--mobile" : ""}">
5283
+ <div class="detail-context-card__header">
5284
+ <div class="detail-context-card__eyebrow">
5285
+ <span class="detail-context-card__icon" aria-hidden="true">${renderIcon("settings")}</span>
5286
+ <span>${escapeHtml(L("detail.autoPilotManualEyebrow"))}</span>
5287
+ </div>
5288
+ </div>
5289
+ <p class="detail-context-card__kind">${escapeHtml(review.title)}</p>
5290
+ <p class="detail-autopilot-copy">${escapeHtml(review.body)}</p>
5291
+ </section>
5292
+ `;
5293
+ }
5294
+
4090
5295
  function renderDetailImageGallery(detail, options = {}) {
4091
5296
  const imageUrls = Array.isArray(detail?.imageUrls) ? detail.imageUrls.filter(Boolean) : [];
4092
5297
  if (imageUrls.length === 0) {
@@ -5168,6 +6373,35 @@ function renderImageViewerModal() {
5168
6373
  `;
5169
6374
  }
5170
6375
 
6376
+ function renderHazbaseLogoutConfirmModal() {
6377
+ if (!state.hazbaseLogoutConfirmOpen) {
6378
+ return "";
6379
+ }
6380
+ const email = state.hazbaseStatus?.email || "";
6381
+ // Mirror the session-logout dialog's shape but drop the split-choice
6382
+ // options — wallet sign-out is a single binary (log out or don't), no
6383
+ // "keep this device trusted" toggle applies.
6384
+ // Note: the backdrop uses a dedicated `data-close-hazbase-logout-confirm`
6385
+ // marker (bound in bindSharedUi) rather than a `data-hazbase-action`.
6386
+ // If we reused the action dispatcher here, an inside-card click would
6387
+ // bubble up to the backdrop's handler and run a second async dispatch
6388
+ // in parallel with the button's own handler — which races against the
6389
+ // API call. The mirror approach matches the session-logout modal.
6390
+ return `
6391
+ <div class="modal-backdrop" data-close-hazbase-logout-confirm>
6392
+ <section class="modal-card modal-card--confirm" role="dialog" aria-modal="true" aria-labelledby="hazbase-logout-confirm-title">
6393
+ <div class="helper-copy">
6394
+ <strong id="hazbase-logout-confirm-title">${escapeHtml(L("settings.hazbase.logout.confirm.title"))}</strong>
6395
+ <p class="muted">${escapeHtml(L("settings.hazbase.logout.confirm.copy"))}</p>
6396
+ ${email ? `<p class="muted"><code>${escapeHtml(email)}</code></p>` : ""}
6397
+ </div>
6398
+ <button class="secondary secondary--wide" type="button" data-hazbase-action="logout-confirm">${escapeHtml(L("settings.hazbase.logout.confirm.confirmLabel"))}</button>
6399
+ <button class="ghost ghost--wide" type="button" data-close-hazbase-logout-confirm>${escapeHtml(L("common.cancel"))}</button>
6400
+ </section>
6401
+ </div>
6402
+ `;
6403
+ }
6404
+
5171
6405
  function renderLogoutConfirmModal() {
5172
6406
  if (!state.logoutConfirmOpen || !state.session?.authenticated) {
5173
6407
  return "";
@@ -5283,6 +6517,8 @@ function bindShellInteractions() {
5283
6517
  });
5284
6518
  }
5285
6519
 
6520
+
6521
+
5286
6522
  for (const select of document.querySelectorAll("[data-timeline-thread-select]")) {
5287
6523
  const handleInteractionStart = () => {
5288
6524
  markThreadFilterInteraction();
@@ -5316,6 +6552,7 @@ function bindShellInteractions() {
5316
6552
  state.timelineKindFilter = "all";
5317
6553
  state.timelineKindFilterOpen = false;
5318
6554
  state.completedThreadFilter = "all";
6555
+ state.diffThreadFilter = "all";
5319
6556
  alignCurrentItemToVisibleEntries();
5320
6557
  await renderShell();
5321
6558
  });
@@ -5341,6 +6578,25 @@ function bindShellInteractions() {
5341
6578
  });
5342
6579
  }
5343
6580
 
6581
+ for (const select of document.querySelectorAll("[data-diff-thread-select]")) {
6582
+ const handleInteractionStart = () => {
6583
+ markThreadFilterInteraction();
6584
+ };
6585
+ const handleInteractionEnd = () => {
6586
+ clearThreadFilterInteraction();
6587
+ };
6588
+ select.addEventListener("pointerdown", handleInteractionStart);
6589
+ select.addEventListener("click", handleInteractionStart);
6590
+ select.addEventListener("focus", handleInteractionStart);
6591
+ select.addEventListener("blur", handleInteractionEnd);
6592
+ select.addEventListener("change", async () => {
6593
+ clearThreadFilterInteraction();
6594
+ state.diffThreadFilter = select.value || "all";
6595
+ alignCurrentItemToVisibleEntries();
6596
+ await renderShell();
6597
+ });
6598
+ }
6599
+
5344
6600
  for (const select of document.querySelectorAll("[data-completed-thread-select]")) {
5345
6601
  const handleInteractionStart = () => {
5346
6602
  markThreadFilterInteraction();
@@ -5657,6 +6913,16 @@ function bindShellInteractions() {
5657
6913
  const action = button.dataset.pushAction;
5658
6914
  state.pushError = "";
5659
6915
  state.pushNotice = "";
6916
+ // Immediate visual feedback — enable/disable inherently wait on
6917
+ // Web Push APIs (permission prompt → pushManager.subscribe() →
6918
+ // server POST → refreshPushStatus), which can take 1–3 seconds.
6919
+ // Without this the button just sat there looking inert the whole
6920
+ // time. The .is-loading + aria-busy pair matches the pattern used
6921
+ // by approval action buttons elsewhere in this file.
6922
+ const wasDisabled = button.disabled;
6923
+ button.classList.add("is-loading");
6924
+ button.disabled = true;
6925
+ button.setAttribute("aria-busy", "true");
5660
6926
  try {
5661
6927
  if (action === "enable") {
5662
6928
  await enableNotifications();
@@ -5675,6 +6941,12 @@ function bindShellInteractions() {
5675
6941
  await refreshPushStatus();
5676
6942
  } catch (error) {
5677
6943
  state.pushError = error.message || String(error);
6944
+ // Restore this specific button on failure; renderShell() below
6945
+ // would rebuild it anyway but leaving it disabled between
6946
+ // exception and render flashes a dead button.
6947
+ button.classList.remove("is-loading");
6948
+ button.disabled = wasDisabled;
6949
+ button.removeAttribute("aria-busy");
5678
6950
  }
5679
6951
  await renderShell();
5680
6952
  });
@@ -5683,11 +6955,96 @@ function bindShellInteractions() {
5683
6955
  for (const checkbox of document.querySelectorAll("[data-claude-away-checkbox]")) {
5684
6956
  checkbox.addEventListener("change", async () => {
5685
6957
  const next = checkbox.checked === true;
6958
+ const previous = state.session?.claudeAwayMode === true;
6959
+ // Optimistic flip — the server round-trip and the old post-toggle
6960
+ // refreshAuthenticatedState() (7 endpoints) used to gate the UI
6961
+ // update. Flip state now, POST in background, roll back on error.
6962
+ if (state.session) {
6963
+ state.session.claudeAwayMode = next;
6964
+ }
6965
+ await renderShell();
5686
6966
  try {
5687
6967
  const result = await apiPost("/api/settings/claude-away-mode", { enabled: next });
6968
+ if (state.session && result && Object.prototype.hasOwnProperty.call(result, "enabled")) {
6969
+ const reconciled = result.enabled === true;
6970
+ if (reconciled !== next) {
6971
+ state.session.claudeAwayMode = reconciled;
6972
+ await renderShell();
6973
+ }
6974
+ }
6975
+ } catch (error) {
5688
6976
  if (state.session) {
5689
- state.session.claudeAwayMode = result?.enabled === true;
6977
+ state.session.claudeAwayMode = previous;
5690
6978
  }
6979
+ state.pushError = error.message || String(error);
6980
+ await renderShell();
6981
+ }
6982
+ });
6983
+ }
6984
+
6985
+ function applyAutoPilotSettingsResult(result) {
6986
+ if (!state.session) {
6987
+ return;
6988
+ }
6989
+ state.session.autoPilotTrustedReads = result?.trustedReadsEnabled === true;
6990
+ state.session.autoPilotTrustedWrites = result?.trustedWritesEnabled === true;
6991
+ state.session.autoPilotWriteLaneContent = result?.writeLaneContentEnabled === true;
6992
+ state.session.autoPilotWriteLaneUiTests = result?.writeLaneUiTestsEnabled === true;
6993
+ state.session.autoPilotWriteLaneSource = result?.writeLaneSourceEnabled === true;
6994
+ }
6995
+
6996
+ for (const checkbox of document.querySelectorAll("[data-auto-pilot-checkbox]")) {
6997
+ checkbox.addEventListener("change", async () => {
6998
+ const next = checkbox.checked === true;
6999
+ try {
7000
+ const result = await apiPost("/api/settings/auto-pilot", { trustedReadsEnabled: next });
7001
+ applyAutoPilotSettingsResult(result);
7002
+ await refreshAuthenticatedState();
7003
+ } catch (error) {
7004
+ state.pushError = error.message || String(error);
7005
+ }
7006
+ await renderShell();
7007
+ });
7008
+ }
7009
+
7010
+ for (const checkbox of document.querySelectorAll("[data-auto-pilot-write-lane-checkbox]")) {
7011
+ checkbox.addEventListener("change", async () => {
7012
+ const next = checkbox.checked === true;
7013
+ const lane = normalizeClientText(checkbox.getAttribute("data-auto-pilot-write-lane-checkbox") || "");
7014
+ const payload =
7015
+ lane === "content"
7016
+ ? { writeLaneContentEnabled: next }
7017
+ : lane === "ui-tests"
7018
+ ? { writeLaneUiTestsEnabled: next }
7019
+ : { writeLaneSourceEnabled: next };
7020
+ try {
7021
+ const result = await apiPost("/api/settings/auto-pilot", payload);
7022
+ applyAutoPilotSettingsResult(result);
7023
+ await refreshAuthenticatedState();
7024
+ } catch (error) {
7025
+ state.pushError = error.message || String(error);
7026
+ }
7027
+ await renderShell();
7028
+ });
7029
+ }
7030
+
7031
+ for (const button of document.querySelectorAll("[data-auto-pilot-suggest-lane]")) {
7032
+ button.addEventListener("click", async () => {
7033
+ const lane = normalizeClientText(button.getAttribute("data-auto-pilot-suggest-lane") || "");
7034
+ const payload =
7035
+ lane === "content"
7036
+ ? { writeLaneContentEnabled: true }
7037
+ : lane === "ui_tests"
7038
+ ? { writeLaneUiTestsEnabled: true }
7039
+ : lane === "source"
7040
+ ? { writeLaneSourceEnabled: true }
7041
+ : null;
7042
+ if (!payload) {
7043
+ return;
7044
+ }
7045
+ try {
7046
+ const result = await apiPost("/api/settings/auto-pilot", payload);
7047
+ applyAutoPilotSettingsResult(result);
5691
7048
  await refreshAuthenticatedState();
5692
7049
  } catch (error) {
5693
7050
  state.pushError = error.message || String(error);
@@ -5699,13 +7056,29 @@ function bindShellInteractions() {
5699
7056
  for (const checkbox of document.querySelectorAll("[data-a2a-public-checkbox]")) {
5700
7057
  checkbox.addEventListener("change", async () => {
5701
7058
  const next = checkbox.checked === true;
7059
+ const previous = state.a2aRelayStatus?.acceptPublicTasks === true;
7060
+ // Optimistic flip — POST + a follow-up GET of the (remote-worker)
7061
+ // relay-status endpoint used to gate the visual update. Flip the
7062
+ // local flag, render, then reconcile in background.
7063
+ if (state.a2aRelayStatus) {
7064
+ state.a2aRelayStatus = { ...state.a2aRelayStatus, acceptPublicTasks: next };
7065
+ }
7066
+ await renderShell();
5702
7067
  try {
5703
7068
  await apiPost("/api/a2a/public-tasks", { accept: next });
5704
- state.a2aRelayStatus = await apiGet("/api/a2a/relay-status");
7069
+ apiGet("/api/a2a/relay-status")
7070
+ .then((fresh) => {
7071
+ state.a2aRelayStatus = fresh;
7072
+ renderShell();
7073
+ })
7074
+ .catch(() => {});
5705
7075
  } catch (error) {
7076
+ if (state.a2aRelayStatus) {
7077
+ state.a2aRelayStatus = { ...state.a2aRelayStatus, acceptPublicTasks: previous };
7078
+ }
5706
7079
  state.pushError = error.message || String(error);
7080
+ await renderShell();
5707
7081
  }
5708
- await renderShell();
5709
7082
  });
5710
7083
  }
5711
7084
 
@@ -5713,13 +7086,24 @@ function bindShellInteractions() {
5713
7086
  radio.addEventListener("change", async () => {
5714
7087
  if (!radio.checked) return;
5715
7088
  const preference = radio.value || "auto";
7089
+ const previous = state.session?.a2aExecutorPreference || "ask";
7090
+ // Optimistic flip — the POST + refreshSession() GET used to gate
7091
+ // any re-render that depends on the preference (e.g. downstream
7092
+ // picker defaults). Flip locally and reconcile in background.
7093
+ if (state.session) {
7094
+ state.session.a2aExecutorPreference = preference;
7095
+ }
7096
+ await renderShell();
5716
7097
  try {
5717
7098
  await apiPost("/api/settings/a2a-executor", { preference });
5718
- await refreshSession();
7099
+ refreshSession().catch(() => {});
5719
7100
  } catch (error) {
7101
+ if (state.session) {
7102
+ state.session.a2aExecutorPreference = previous;
7103
+ }
5720
7104
  state.pushError = error.message || String(error);
7105
+ await renderShell();
5721
7106
  }
5722
- await renderShell();
5723
7107
  });
5724
7108
  }
5725
7109
 
@@ -5749,18 +7133,189 @@ function bindShellInteractions() {
5749
7133
  });
5750
7134
  }
5751
7135
 
7136
+
7137
+ for (const button of document.querySelectorAll("[data-hazbase-action]")) {
7138
+ button.addEventListener("click", async () => {
7139
+ state.hazbaseNotice = "";
7140
+ state.hazbaseError = "";
7141
+ const action = button.dataset.hazbaseAction || "";
7142
+ try {
7143
+ if (action === "request-otp") {
7144
+ // Read from the DOM input, not just state. The state mirror is
7145
+ // populated on every keystroke, but a pre-filled value (returning
7146
+ // user whose email came back from hazbase status) never fires
7147
+ // `input`, so state stays empty while the DOM shows the address.
7148
+ // Treat DOM as authoritative and fall back to state.
7149
+ const emailInput = document.querySelector('[data-hazbase-input="otp-email"]');
7150
+ const email = (emailInput?.value || state.hazbaseOtpEmail || "").trim();
7151
+ if (!email) throw new Error(L("error.hazbaseEmailRequired"));
7152
+ const result = await apiPost("/api/hazbase/request-otp", { email });
7153
+ state.hazbaseOtpEmail = email;
7154
+ state.hazbaseOtpRequested = true;
7155
+ state.hazbaseOtpCode = "";
7156
+ state.hazbaseNotice = result?.debugCode
7157
+ ? `${L("settings.hazbase.notice.otpRequested")} (${result.debugCode})`
7158
+ : L("settings.hazbase.notice.otpRequested");
7159
+ } else if (action === "verify-otp") {
7160
+ // Same pattern — DOM wins, state fallback covers the rare case
7161
+ // where the field was removed/re-added between type and click.
7162
+ const emailInput = document.querySelector('[data-hazbase-input="otp-email"]');
7163
+ const codeInput = document.querySelector('[data-hazbase-input="otp-code"]');
7164
+ const email = (emailInput?.value || state.hazbaseOtpEmail || "").trim();
7165
+ const code = (codeInput?.value || state.hazbaseOtpCode || "").trim();
7166
+ if (!email) throw new Error(L("error.hazbaseEmailRequired"));
7167
+ if (!code) throw new Error(L("error.hazbaseOtpRequired"));
7168
+ await apiPost("/api/hazbase/verify-otp", { email, code });
7169
+ state.hazbaseOtpRequested = false;
7170
+ state.hazbaseOtpEmail = "";
7171
+ state.hazbaseOtpCode = "";
7172
+ state.hazbaseNotice = L("settings.hazbase.notice.otpVerified");
7173
+ } else if (action === "register-passkey") {
7174
+ if (!hazbasePasskeyHostSupport().eligible) {
7175
+ throw new Error(L("error.hazbasePasskeyLocalHostRequired"));
7176
+ }
7177
+ const { createPasskeyRegistrationCredential } = await loadHazbasePasskeyModule();
7178
+ const challenge = await apiPost("/api/hazbase/passkey/register/challenge", {});
7179
+ const credential = await createPasskeyRegistrationCredential(challenge);
7180
+ await apiPost("/api/hazbase/passkey/register/complete", {
7181
+ challengeId: challenge.challengeId,
7182
+ credential,
7183
+ });
7184
+ state.hazbaseNotice = L("settings.hazbase.notice.passkeyRegistered");
7185
+ } else if (action === "bootstrap-base-sepolia" || action === "bootstrap-base") {
7186
+ if (!hazbasePasskeyHostSupport().eligible) {
7187
+ throw new Error(L("error.hazbasePasskeyLocalHostRequired"));
7188
+ }
7189
+ const { createPasskeyAssertionCredential } = await loadHazbasePasskeyModule();
7190
+ const chainId = action === "bootstrap-base" ? 8453 : 84532;
7191
+ const challenge = await apiPost("/api/hazbase/passkey/assert/challenge", { purpose: "bootstrap" });
7192
+ const credential = await createPasskeyAssertionCredential(challenge);
7193
+ await apiPost("/api/hazbase/passkey/assert/complete", {
7194
+ challengeId: challenge.challengeId,
7195
+ credential,
7196
+ purpose: "bootstrap",
7197
+ });
7198
+ await apiPost("/api/hazbase/account/bootstrap", { chainId });
7199
+ state.hazbaseNotice = L("settings.hazbase.notice.walletBootstrapped", { chainId });
7200
+ } else if (action === "logout") {
7201
+ // Gate wallet logout behind an explicit confirm — the modal's
7202
+ // "confirm" button dispatches `logout-confirm`, which actually
7203
+ // hits the API. Short-circuit here so the initial click only
7204
+ // opens the dialog.
7205
+ state.hazbaseLogoutConfirmOpen = true;
7206
+ await renderShell();
7207
+ return;
7208
+ } else if (action === "logout-confirm") {
7209
+ state.hazbaseLogoutConfirmOpen = false;
7210
+ await apiPost("/api/hazbase/logout", {});
7211
+ state.hazbaseNotice = L("settings.hazbase.notice.signedOut");
7212
+ } else if (action === "change-email") {
7213
+ // Flip the form back to pre-send mode. We keep the email so typo
7214
+ // recovery ("hoshin" → "hoshino") stays one edit away, but drop
7215
+ // the now-stale OTP code. No server call — hazbase invalidates
7216
+ // the previous OTP automatically when a fresh one is requested.
7217
+ state.hazbaseOtpRequested = false;
7218
+ state.hazbaseOtpCode = "";
7219
+ state.hazbaseNotice = "";
7220
+ state.hazbaseError = "";
7221
+ await renderShell();
7222
+ // Move focus back to the (now re-enabled) email input so the user
7223
+ // can start editing immediately.
7224
+ document.querySelector('[data-hazbase-input="otp-email"]')?.focus();
7225
+ return;
7226
+ } else if (action === "mainnet-opt-in") {
7227
+ // Pure client-side reveal — the mainnet step is always in the flow
7228
+ // data, we just hide it behind an opt-in link to keep the default
7229
+ // path (testnet only) focused. No network call; no status refetch.
7230
+ state.hazbaseMainnetOptIn = true;
7231
+ await renderShell();
7232
+ return;
7233
+ }
7234
+ await fetchHazbaseStatus();
7235
+ } catch (error) {
7236
+ state.hazbaseError = error.message || String(error);
7237
+ }
7238
+ await renderShell();
7239
+ });
7240
+ }
7241
+
7242
+ // Mirror every keystroke into state so a background re-render (poll tick,
7243
+ // notice clear, etc.) can repopulate `value="..."` without wiping what
7244
+ // the user was typing. Reads happen at button-click time against state,
7245
+ // which is why we don't need to also query the DOM in the handler.
7246
+ for (const input of document.querySelectorAll("[data-hazbase-input]")) {
7247
+ const name = input.dataset.hazbaseInput || "";
7248
+ input.addEventListener("input", () => {
7249
+ if (name === "otp-email") state.hazbaseOtpEmail = input.value;
7250
+ else if (name === "otp-code") state.hazbaseOtpCode = input.value;
7251
+ });
7252
+ // Enter key submits the step. In the email field it triggers "send"
7253
+ // (or "verify" once a code was already issued — the email field stays
7254
+ // editable post-send to support typo recovery via resend). In the OTP
7255
+ // field it always submits verify.
7256
+ input.addEventListener("keydown", (event) => {
7257
+ if (event.key !== "Enter" || event.isComposing) return;
7258
+ event.preventDefault();
7259
+ const target = name === "otp-code"
7260
+ ? "verify-otp"
7261
+ : state.hazbaseOtpRequested ? "verify-otp" : "request-otp";
7262
+ const btn = document.querySelector(`[data-hazbase-action="${target}"]`);
7263
+ btn?.click();
7264
+ });
7265
+ }
7266
+
7267
+ // Tap-to-copy the wallet payout address. We swap the clipboard icon to a
7268
+ // check mark for ~1.5s via CSS (toggling `.is-copied`) rather than a full
7269
+ // re-render, so focus stays on the button and the rest of the settings
7270
+ // page doesn't flicker. The address sits inside a banner that doesn't
7271
+ // otherwise re-render on every tick, so an ephemeral class flip is the
7272
+ // cleanest way to acknowledge the copy.
7273
+ for (const button of document.querySelectorAll("[data-wallet-address-copy]")) {
7274
+ button.addEventListener("click", async () => {
7275
+ const text = button.dataset.walletAddressCopy || "";
7276
+ if (!text) return;
7277
+ try {
7278
+ await copyTextToClipboard(text);
7279
+ button.classList.add("is-copied");
7280
+ if (button._copyResetTimer) clearTimeout(button._copyResetTimer);
7281
+ button._copyResetTimer = setTimeout(() => {
7282
+ button.classList.remove("is-copied");
7283
+ button._copyResetTimer = null;
7284
+ }, 1500);
7285
+ } catch {
7286
+ // Copy failed (permissions denied, execCommand unsupported, etc).
7287
+ // We intentionally stay silent — the address is still visible and
7288
+ // `user-select: all` on the text span lets the user long-press to
7289
+ // select manually on iOS.
7290
+ }
7291
+ });
7292
+ }
7293
+
5752
7294
  for (const button of document.querySelectorAll("[data-locale-option]")) {
5753
7295
  button.addEventListener("click", async () => {
5754
7296
  state.pushError = "";
5755
7297
  state.pushNotice = "";
7298
+ const nextLocale = button.dataset.localeOption || "";
7299
+ // Flip language synchronously and render before the POST — the
7300
+ // UI switches in one frame instead of waiting on the round-trip
7301
+ // plus the 7-endpoint refreshAuthenticatedState() that followed.
7302
+ const previousSession = applyLocaleOverrideOptimistically(nextLocale);
7303
+ await renderShell();
5756
7304
  try {
5757
- await setLocaleOverride(button.dataset.localeOption || "");
5758
- await refreshSession();
5759
- await refreshAuthenticatedState();
7305
+ await persistLocaleOverride(nextLocale, previousSession);
7306
+ // Server-rendered inbox/timeline strings (kind labels, summaries)
7307
+ // are localised; refresh them in the background so the user
7308
+ // doesn't wait. Polling would eventually pick this up anyway,
7309
+ // but this makes the switch feel instant.
7310
+ Promise.all([
7311
+ refreshInbox().catch(() => {}),
7312
+ refreshInboxDiff().catch(() => {}),
7313
+ refreshTimeline().catch(() => {}),
7314
+ ]).then(() => renderShell());
5760
7315
  } catch (error) {
5761
7316
  state.pushError = error.message || String(error);
7317
+ await renderShell();
5762
7318
  }
5763
- await renderShell();
5764
7319
  });
5765
7320
  }
5766
7321
 
@@ -6261,6 +7816,21 @@ function bindSharedUi(renderFn) {
6261
7816
  });
6262
7817
  }
6263
7818
 
7819
+ for (const button of document.querySelectorAll("[data-close-hazbase-logout-confirm]")) {
7820
+ // Mirror the session-logout modal: the backdrop swallows outside-clicks
7821
+ // to dismiss, but inside-card clicks bubble up through here too — skip
7822
+ // those so the Confirm button's own handler can run alone.
7823
+ button.addEventListener("click", async (event) => {
7824
+ if (button.classList.contains("modal-backdrop")) {
7825
+ if (event.target.closest(".modal-card")) {
7826
+ return;
7827
+ }
7828
+ }
7829
+ state.hazbaseLogoutConfirmOpen = false;
7830
+ await renderFn();
7831
+ });
7832
+ }
7833
+
6264
7834
  for (const button of document.querySelectorAll("[data-dismiss-install]")) {
6265
7835
  button.addEventListener("click", async () => {
6266
7836
  state.installBannerDismissed = true;
@@ -6319,6 +7889,15 @@ async function switchTab(tab) {
6319
7889
  syncCurrentItemUrl(state.currentItem);
6320
7890
  }
6321
7891
  await renderShell();
7892
+ if (tab === "settings") {
7893
+ void fetchHazbaseStatus()
7894
+ .then(() => {
7895
+ if (state.currentTab === "settings") {
7896
+ renderCurrentSurface();
7897
+ }
7898
+ })
7899
+ .catch(() => {});
7900
+ }
6322
7901
  }
6323
7902
 
6324
7903
  function openItem({ kind, token, sourceTab, sourceSubtab }) {
@@ -6455,6 +8034,9 @@ function pendingInboxCount() {
6455
8034
  }
6456
8035
 
6457
8036
  function tabForItemKind(kind, fallback) {
8037
+ if (kind === "completion" || kind === "plan_ready" || kind === "moltbook_draft" || kind === "moltbook_reply" || kind === "thread_share" || kind === "a2a_task" || kind === "a2a_task_result") {
8038
+ return "inbox";
8039
+ }
6458
8040
  if (kind === "diff_thread") {
6459
8041
  return "diff";
6460
8042
  }
@@ -6464,22 +8046,20 @@ function tabForItemKind(kind, fallback) {
6464
8046
  if (TIMELINE_MESSAGE_KINDS.has(kind)) {
6465
8047
  return "timeline";
6466
8048
  }
6467
- if (kind === "completion") {
6468
- return "inbox";
6469
- }
6470
8049
  if (fallback === "timeline") {
6471
8050
  return "timeline";
6472
8051
  }
6473
8052
  return kind === "approval" || kind === "plan" || kind === "choice"
6474
8053
  ? "inbox"
6475
- : fallback || "inbox";
8054
+ : "inbox";
6476
8055
  }
6477
8056
 
6478
8057
  function inboxSubtabForItemKind(kind, sourceSubtab = "") {
6479
8058
  if (normalizeClientText(sourceSubtab || "") === "completed") {
6480
8059
  return "completed";
6481
8060
  }
6482
- return kind === "completion" || kind === "assistant_final" ? "completed" : "pending";
8061
+ const completedKinds = new Set(["completion", "assistant_final", "plan_ready", "moltbook_reply", "thread_share", "a2a_task_result"]);
8062
+ return completedKinds.has(kind) ? "completed" : "pending";
6483
8063
  }
6484
8064
 
6485
8065
  function kindMeta(kind, item) {
@@ -6893,6 +8473,8 @@ function renderIcon(name) {
6893
8473
  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
8474
  case "check":
6895
8475
  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>`;
8476
+ case "copy":
8477
+ 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
8478
  case "lock":
6897
8479
  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
8480
  case "coin":
@@ -7114,6 +8696,10 @@ function localizeApiError(value) {
7114
8696
  "choice-input-read-only": "error.choiceInputReadOnly",
7115
8697
  "choice-input-already-handled": "error.choiceInputAlreadyHandled",
7116
8698
  "mkcert-root-ca-not-found": "error.mkcertRootCaNotFound",
8699
+ "hazbase-auth-required": "error.hazbaseAuthRequired",
8700
+ "hazbase-passkey-local-host-required": "error.hazbasePasskeyLocalHostRequired",
8701
+ "hazbase-wallet-account-missing": "error.hazbaseWalletAccountMissing",
8702
+ "unsupported-chain": "error.unsupportedChain",
7117
8703
  };
7118
8704
  const key = map[raw];
7119
8705
  return key ? L(key) : raw;
@@ -7209,6 +8795,16 @@ function parseItemRef(value) {
7209
8795
  return kind && token ? { kind, token } : null;
7210
8796
  }
7211
8797
 
8798
+ function sanitizeExternalTargetTab(value) {
8799
+ const normalized = normalizeClientText(value || "");
8800
+ return EXTERNAL_TARGET_TABS.has(normalized) ? normalized : "";
8801
+ }
8802
+
8803
+ function sanitizeExternalTargetInboxSubtab(value) {
8804
+ const normalized = normalizeClientText(value || "");
8805
+ return EXTERNAL_TARGET_INBOX_SUBTABS.has(normalized) ? normalized : "";
8806
+ }
8807
+
7212
8808
  async function applyExternalTargetUrl(urlString, { allowRefresh = true } = {}) {
7213
8809
  if (!state.session?.authenticated) {
7214
8810
  return;
@@ -7225,10 +8821,16 @@ async function applyExternalTargetUrl(urlString, { allowRefresh = true } = {}) {
7225
8821
  if (!itemRef) {
7226
8822
  return;
7227
8823
  }
8824
+ const explicitTargetTab = sanitizeExternalTargetTab(nextUrl.searchParams.get("tab"));
8825
+ const explicitTargetSubtab = sanitizeExternalTargetInboxSubtab(nextUrl.searchParams.get("subtab"));
8826
+ const targetTab = explicitTargetTab || tabForItemKind(itemRef.kind, state.currentTab);
8827
+ const targetSubtab = targetTab === "inbox" ? inboxSubtabForItemKind(itemRef.kind, explicitTargetSubtab) : "";
7228
8828
 
7229
8829
  const sameItem =
7230
8830
  Boolean(state.currentItem) &&
7231
8831
  isSameItemRef(state.currentItem, itemRef) &&
8832
+ state.currentTab === targetTab &&
8833
+ (targetTab !== "inbox" || state.inboxSubtab === targetSubtab) &&
7232
8834
  (isDesktopLayout() || state.detailOpen);
7233
8835
  if (sameItem) {
7234
8836
  if (allowRefresh) {
@@ -7242,7 +8844,8 @@ async function applyExternalTargetUrl(urlString, { allowRefresh = true } = {}) {
7242
8844
  openItem({
7243
8845
  kind: itemRef.kind,
7244
8846
  token: itemRef.token,
7245
- sourceTab: tabForItemKind(itemRef.kind, state.currentTab),
8847
+ sourceTab: targetTab,
8848
+ sourceSubtab: targetSubtab,
7246
8849
  });
7247
8850
  if (isFastPathItemRef(itemRef)) {
7248
8851
  state.launchItemIntent = {