viveworker 0.7.0-beta.2 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/web/app.js CHANGED
@@ -106,6 +106,8 @@ const state = {
106
106
  defaultLocale: DEFAULT_LOCALE,
107
107
  supportedLocales: [...SUPPORTED_LOCALES],
108
108
  appVersion: "",
109
+ versionStatus: null,
110
+ versionStatusError: "",
109
111
  };
110
112
 
111
113
  let detailLoadSequence = 0;
@@ -130,6 +132,23 @@ function hazbasePasskeyHostSupport() {
130
132
  }
131
133
 
132
134
  const app = document.querySelector("#app");
135
+ let bootSplashDismissed = false;
136
+
137
+ function dismissBootSplash() {
138
+ if (bootSplashDismissed || typeof document === "undefined") {
139
+ return;
140
+ }
141
+ bootSplashDismissed = true;
142
+ const splash = document.querySelector("#boot-splash");
143
+ document.body?.classList.add("viveworker-ready");
144
+ if (!splash) {
145
+ return;
146
+ }
147
+ splash.setAttribute("aria-hidden", "true");
148
+ window.setTimeout(() => {
149
+ splash.remove();
150
+ }, 280);
151
+ }
133
152
  const params = new URLSearchParams(window.location.search);
134
153
  const initialItem = params.get("item") || "";
135
154
  const initialTargetTab = params.get("tab") || "";
@@ -144,6 +163,7 @@ boot().catch((error) => {
144
163
  const hint = /Load failed|Failed to fetch|NetworkError|fetch/i.test(message)
145
164
  ? `<p class="muted">${escapeHtml(L("error.networkHint"))}</p>`
146
165
  : "";
166
+ dismissBootSplash();
147
167
  app.innerHTML = `
148
168
  <main class="onboarding-shell">
149
169
  <section class="onboarding-card">
@@ -225,6 +245,7 @@ async function boot() {
225
245
  ensureCurrentSelection();
226
246
  maybeAutoFocusClaudePending();
227
247
  await renderShell();
248
+ refreshVersionStatusForTechnicalPage();
228
249
 
229
250
  // Diff fetch runs as a background phase because `/api/inbox/diff`
230
251
  // spawns `git` subprocesses per tracked repo and can stall for several
@@ -586,6 +607,29 @@ async function fetchHazbaseStatus() {
586
607
  }
587
608
  }
588
609
 
610
+ async function fetchVersionStatus() {
611
+ try {
612
+ state.versionStatus = await apiGet("/api/version/status");
613
+ state.versionStatusError = "";
614
+ } catch (error) {
615
+ state.versionStatus = null;
616
+ state.versionStatusError = error.message || String(error);
617
+ }
618
+ }
619
+
620
+ function refreshVersionStatusForTechnicalPage() {
621
+ if (state.currentTab !== "settings" || state.settingsSubpage !== "advanced") {
622
+ return;
623
+ }
624
+ fetchVersionStatus()
625
+ .then(async () => {
626
+ if (state.currentTab === "settings" && state.settingsSubpage === "advanced" && !shouldDeferRenderForActiveInteraction()) {
627
+ await renderShell();
628
+ }
629
+ })
630
+ .catch(() => {});
631
+ }
632
+
589
633
  async function getClientPushState() {
590
634
  const registration = state.serviceWorkerRegistration || (await navigator.serviceWorker?.ready.catch(() => null));
591
635
  if (registration) {
@@ -827,6 +871,7 @@ function normalizeProviderClient(value) {
827
871
  if (normalized === "claude") return "claude";
828
872
  if (normalized === "moltbook") return "moltbook";
829
873
  if (normalized === "a2a") return "a2a";
874
+ if (normalized === "viveworker") return "viveworker";
830
875
  return "codex";
831
876
  }
832
877
 
@@ -835,6 +880,7 @@ function providerDisplayName(provider) {
835
880
  if (p === "claude") return L("common.claude");
836
881
  if (p === "moltbook") return "Moltbook";
837
882
  if (p === "a2a") return "A2A";
883
+ if (p === "viveworker") return L("common.appName");
838
884
  return L("common.codex");
839
885
  }
840
886
 
@@ -1143,6 +1189,7 @@ function renderPair() {
1143
1189
  });
1144
1190
 
1145
1191
  bindSharedUi(renderPair);
1192
+ requestAnimationFrame(dismissBootSplash);
1146
1193
  }
1147
1194
 
1148
1195
  async function pair(payload) {
@@ -1234,6 +1281,7 @@ async function renderShell() {
1234
1281
  <div class="${shellClassName}">
1235
1282
  ${desktop ? renderDesktopHeader(detail) : renderMobileTopBar(detail)}
1236
1283
  ${renderTopBanner()}
1284
+ ${renderGlobalErrorBanner()}
1237
1285
  <main class="app-main">
1238
1286
  ${desktop ? renderDesktopWorkspace(detail) : renderMobileWorkspace(detail)}
1239
1287
  </main>
@@ -1250,6 +1298,7 @@ async function renderShell() {
1250
1298
  applyPendingListScrollRestore();
1251
1299
  applyPendingSettingsSubpageScrollReset();
1252
1300
  applyPendingSettingsScrollRestore();
1301
+ requestAnimationFrame(dismissBootSplash);
1253
1302
  }
1254
1303
 
1255
1304
  function applyPendingDetailScrollReset() {
@@ -1870,7 +1919,11 @@ function buildActionOutcomeDetail({ kind, title, message }) {
1870
1919
 
1871
1920
  function approvalOutcomeMessage(actionUrl, provider) {
1872
1921
  const vars = { provider: providerDisplayName(provider) };
1873
- return /\/accept$/u.test(String(actionUrl || ""))
1922
+ const normalizedUrl = String(actionUrl || "");
1923
+ if (/\/api\/payments\/x402\/hazbase-wallet\/[^/]+\/pay$/u.test(normalizedUrl)) {
1924
+ return L("server.message.paymentSubmitted");
1925
+ }
1926
+ return /\/accept$/u.test(normalizedUrl)
1874
1927
  ? L("server.message.approvalAccepted", vars)
1875
1928
  : L("server.message.approvalRejected", vars);
1876
1929
  }
@@ -2588,6 +2641,8 @@ function timelineEntryStatusLabel(item, isMessageLike) {
2588
2641
  return L("timeline.status.approved");
2589
2642
  case "rejected":
2590
2643
  return L("timeline.status.rejected");
2644
+ case "failed":
2645
+ return L("timeline.status.failed");
2591
2646
  case "implemented":
2592
2647
  return L("timeline.status.implemented");
2593
2648
  case "dismissed":
@@ -3489,8 +3544,11 @@ function renderSettingsRoot(context, { mobile }) {
3489
3544
  page: "wallet",
3490
3545
  icon: "coin",
3491
3546
  title: L("settings.wallet.title"),
3547
+ badge: "beta",
3492
3548
  subtitle: L("settings.wallet.subtitle"),
3493
- value: context.hazbase?.signedIn ? L("settings.hazbase.status.signedIn") : L("settings.hazbase.status.signedOut"),
3549
+ value: context.hazbase?.sessionInvalid
3550
+ ? L("settings.hazbase.status.sessionExpired")
3551
+ : context.hazbase?.signedIn ? L("settings.hazbase.status.signedIn") : L("settings.hazbase.status.signedOut"),
3494
3552
  }) : "",
3495
3553
  ].filter(Boolean)) : ""}
3496
3554
  ${renderSettingsGroup(L("settings.pairing.title"), deviceRows)}
@@ -3673,6 +3731,7 @@ function renderSettingsDevicePage(context) {
3673
3731
  }
3674
3732
 
3675
3733
  function renderSettingsAdvancedPage(context) {
3734
+ const versionNotice = renderVersionUpdateNotice();
3676
3735
  return `
3677
3736
  <div class="settings-page">
3678
3737
  ${context.diagnostics.map((message) => `<p class="inline-alert">${escapeHtml(message)}</p>`).join("")}
@@ -3691,10 +3750,28 @@ function renderSettingsAdvancedPage(context) {
3691
3750
  : "",
3692
3751
  renderSettingsInfoRow(L("settings.row.version"), state.appVersion || L("common.unavailable")),
3693
3752
  ].filter(Boolean), { listClassName: "settings-list settings-list--compact" })}
3753
+ ${versionNotice}
3694
3754
  </div>
3695
3755
  `;
3696
3756
  }
3697
3757
 
3758
+ function renderVersionUpdateNotice() {
3759
+ const status = state.versionStatus;
3760
+ if (!status?.updateAvailable || !status.latestVersion) {
3761
+ return "";
3762
+ }
3763
+ return `
3764
+ <section class="settings-copy-block settings-copy-block--compact settings-update-notice">
3765
+ <p class="settings-update-notice__title">${escapeHtml(L("settings.updateAvailable.title"))}</p>
3766
+ <p class="muted">${escapeHtml(L("settings.updateAvailable.copy", {
3767
+ current: status.currentVersion || state.appVersion || "",
3768
+ latest: status.latestVersion,
3769
+ }))}</p>
3770
+ <code class="settings-update-notice__command">npx viveworker update</code>
3771
+ </section>
3772
+ `;
3773
+ }
3774
+
3698
3775
  function renderSettingsNotificationActions({ push, canEnable, standalone }) {
3699
3776
  if (push.serverSubscribed) {
3700
3777
  return `
@@ -3730,12 +3807,15 @@ function renderSettingsGroup(title, rows, options = {}) {
3730
3807
  `;
3731
3808
  }
3732
3809
 
3733
- function renderSettingsNavRow({ page, icon, title, subtitle, value }) {
3810
+ function renderSettingsNavRow({ page, icon, title, badge, subtitle, value }) {
3734
3811
  return `
3735
3812
  <button class="settings-nav-row" type="button" data-settings-subpage="${escapeHtml(page)}">
3736
3813
  <span class="settings-row__icon" aria-hidden="true">${renderIcon(icon)}</span>
3737
3814
  <span class="settings-row__body">
3738
- <span class="settings-row__title">${escapeHtml(title)}</span>
3815
+ <span class="settings-row__title-line">
3816
+ <span class="settings-row__title">${escapeHtml(title)}</span>
3817
+ ${badge ? `<span class="settings-row__badge">${escapeHtml(badge)}</span>` : ""}
3818
+ </span>
3739
3819
  ${subtitle ? `<span class="settings-row__subtitle">${escapeHtml(subtitle)}</span>` : ""}
3740
3820
  </span>
3741
3821
  <span class="settings-row__value">${escapeHtml(value || "")}</span>
@@ -4515,6 +4595,7 @@ function renderSettingsWalletPage(context) {
4515
4595
  // longer duplicated.
4516
4596
  const guideRows = [
4517
4597
  renderHazbaseWalletBanner(flow),
4598
+ renderHazbaseWalletBetaNotice(),
4518
4599
  state.hazbaseNotice
4519
4600
  ? `<div class="settings-copy-block settings-copy-block--compact wallet-flow-message wallet-flow-message--notice"><p>${escapeHtml(state.hazbaseNotice)}</p></div>`
4520
4601
  : "",
@@ -4523,8 +4604,16 @@ function renderSettingsWalletPage(context) {
4523
4604
  : "",
4524
4605
  renderHazbaseWalletStepList(flow),
4525
4606
  ].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>`
4607
+ const canRefreshSession = Boolean(hazbase.sessionInvalid);
4608
+ const advancedActions = canRefreshSession || hazbase.signedIn
4609
+ ? [
4610
+ canRefreshSession
4611
+ ? `<button class="secondary secondary--wide" type="button" data-hazbase-action="refresh-session">${escapeHtml(L("settings.hazbase.action.refreshSession"))}</button>`
4612
+ : "",
4613
+ hazbase.signedIn
4614
+ ? `<button class="secondary secondary--wide" type="button" data-hazbase-action="logout">${escapeHtml(L("settings.hazbase.action.signOut"))}</button>`
4615
+ : "",
4616
+ ].filter(Boolean).join("")
4528
4617
  : "";
4529
4618
  // Render the wallet flow without `renderSettingsGroup`'s `.settings-list`
4530
4619
  // wrapper. The banner (`.settings-copy-block`), notice/error blocks, and
@@ -4558,15 +4647,10 @@ function renderSettingsWalletPage(context) {
4558
4647
  function renderHazbaseWalletStepList(flow) {
4559
4648
  const rendered = [];
4560
4649
  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) {
4650
+ // Keep the Base mainnet roadmap visible only after the usable Base Sepolia
4651
+ // wallet is ready. Showing Step 4 during email/passkey setup makes the
4652
+ // sequential flow feel like there is another action competing for focus.
4653
+ if (step.number === 4 && step.status === "comingSoon" && !flow.coreReady) {
4570
4654
  continue;
4571
4655
  }
4572
4656
 
@@ -4599,7 +4683,8 @@ function deriveHazbaseWalletFlow(hazbase) {
4599
4683
  const accounts = Array.isArray(hazbase.accounts) ? hazbase.accounts : [];
4600
4684
  const baseSepolia = accounts.find((entry) => Number(entry.chainId) === 84532) || null;
4601
4685
  const baseMainnet = accounts.find((entry) => Number(entry.chainId) === 8453) || null;
4602
- const signedIn = Boolean(hazbase.signedIn);
4686
+ const sessionInvalid = Boolean(hazbase.sessionInvalid);
4687
+ const signedIn = Boolean(hazbase.signedIn) && !sessionInvalid;
4603
4688
  const passkeyHost = hazbasePasskeyHostSupport();
4604
4689
  const hasPasskey = Boolean(hazbase.credentialId || hazbase.deviceBindingId);
4605
4690
  const hasBaseSepolia = Boolean(baseSepolia?.smartAccountAddress);
@@ -4619,17 +4704,20 @@ function deriveHazbaseWalletFlow(hazbase) {
4619
4704
  hasPasskey,
4620
4705
  baseSepolia,
4621
4706
  baseMainnet,
4707
+ sessionInvalid,
4622
4708
  coreReady,
4623
4709
  steps: [
4624
4710
  {
4625
4711
  number: 1,
4626
4712
  icon: "approval",
4627
- title: L("settings.wallet.step.signIn.title"),
4628
- copy: L("settings.wallet.step.signIn.copy"),
4713
+ title: sessionInvalid ? L("settings.wallet.step.refreshSession.title") : L("settings.wallet.step.signIn.title"),
4714
+ copy: sessionInvalid ? L("settings.wallet.step.refreshSession.copy") : L("settings.wallet.step.signIn.copy"),
4629
4715
  detail: signedIn
4630
4716
  ? hazbase.email || L("settings.hazbase.status.signedIn")
4631
4717
  : state.hazbaseOtpRequested
4632
4718
  ? L("settings.hazbase.status.otpAwaitingVerify")
4719
+ : sessionInvalid
4720
+ ? L("settings.hazbase.status.sessionExpired")
4633
4721
  : L("settings.hazbase.status.signedOut"),
4634
4722
  status: signedIn ? "complete" : "current",
4635
4723
  // Inline form: email input (always visible pre-sign-in) and the
@@ -4657,7 +4745,7 @@ function deriveHazbaseWalletFlow(hazbase) {
4657
4745
  actionButton("settings.hazbase.action.resendOtp", "request-otp"),
4658
4746
  ]
4659
4747
  : [
4660
- actionButton("settings.hazbase.action.requestOtp", "request-otp", { primary: true }),
4748
+ actionButton(sessionInvalid ? "settings.hazbase.action.refreshSession" : "settings.hazbase.action.requestOtp", "request-otp", { primary: true }),
4661
4749
  ],
4662
4750
  },
4663
4751
  {
@@ -4702,25 +4790,23 @@ function deriveHazbaseWalletFlow(hazbase) {
4702
4790
  icon: "coin",
4703
4791
  title: L("settings.wallet.step.base.title"),
4704
4792
  copy: L("settings.wallet.step.base.copy"),
4705
- detail: baseMainnet?.smartAccountAddress || L("settings.hazbase.wallet.missing"),
4793
+ detail: baseMainnet?.smartAccountAddress || L("settings.wallet.step.base.comingSoonDetail"),
4706
4794
  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
- ],
4795
+ status: hasBaseMainnet ? "complete" : "comingSoon",
4796
+ actions: [],
4715
4797
  },
4716
4798
  ],
4717
4799
  };
4718
4800
  }
4719
4801
 
4720
4802
  function renderHazbaseWalletBanner(flow) {
4721
- const title = flow.coreReady ? L("settings.wallet.ready.title") : L("settings.wallet.flow.banner.title");
4803
+ const title = flow.sessionInvalid
4804
+ ? L("settings.wallet.sessionExpired.title")
4805
+ : flow.coreReady ? L("settings.wallet.ready.title") : L("settings.wallet.flow.banner.title");
4722
4806
  const className = flow.coreReady
4723
4807
  ? "settings-copy-block settings-copy-block--stacked wallet-flow-banner wallet-flow-banner--ready"
4808
+ : flow.sessionInvalid
4809
+ ? "settings-copy-block settings-copy-block--stacked wallet-flow-banner wallet-flow-banner--session-expired"
4724
4810
  : "settings-copy-block settings-copy-block--stacked wallet-flow-banner";
4725
4811
  // Ready state: surface the smart-account address prominently so the user
4726
4812
  // can see at a glance which wallet agents will use as `--pay-to` when
@@ -4746,7 +4832,9 @@ function renderHazbaseWalletBanner(flow) {
4746
4832
  </button>
4747
4833
  `
4748
4834
  : `<p class="wallet-flow-banner__copy muted">${escapeHtml(
4749
- flow.coreReady ? L("settings.wallet.ready.copy") : L("settings.wallet.flow.copy"),
4835
+ flow.sessionInvalid
4836
+ ? L("settings.wallet.sessionExpired.copy")
4837
+ : flow.coreReady ? L("settings.wallet.ready.copy") : L("settings.wallet.flow.copy"),
4750
4838
  )}</p>`;
4751
4839
  return `
4752
4840
  <div class="${className}">
@@ -4757,14 +4845,29 @@ function renderHazbaseWalletBanner(flow) {
4757
4845
  `;
4758
4846
  }
4759
4847
 
4848
+ function renderHazbaseWalletBetaNotice() {
4849
+ return `
4850
+ <div class="settings-copy-block settings-copy-block--compact wallet-beta-notice">
4851
+ <p>${escapeHtml(L("settings.wallet.betaNotice"))}</p>
4852
+ </div>
4853
+ `;
4854
+ }
4855
+
4760
4856
  function renderHazbaseWalletStepCard(step, { mode = "full" } = {}) {
4761
4857
  const statusMeta = {
4762
4858
  complete: { label: L("settings.wallet.status.complete"), icon: "completed" },
4763
4859
  current: { label: L("settings.wallet.status.current"), icon: "pending" },
4764
4860
  locked: { label: L("settings.wallet.status.locked"), icon: "lock" },
4765
4861
  optional: { label: L("settings.wallet.status.optional"), icon: "coin" },
4862
+ comingSoon: { label: L("settings.wallet.status.comingSoon"), icon: "coin" },
4766
4863
  pending: { label: L("settings.wallet.status.pending"), icon: "pending" },
4767
4864
  }[step.status] || { label: L("settings.wallet.status.pending"), icon: "pending" };
4865
+ const statusChipHtml = step.status === "current"
4866
+ ? ""
4867
+ : `<span class="wallet-step-card__status wallet-step-card__status--${escapeHtml(step.status)}">
4868
+ <span class="wallet-step-card__status-icon" aria-hidden="true">${renderIcon(statusMeta.icon)}</span>
4869
+ <span>${escapeHtml(statusMeta.label)}</span>
4870
+ </span>`;
4768
4871
 
4769
4872
  if (mode === "compact") {
4770
4873
  // Compact row for finished steps. Keeps the check icon + title + one-line
@@ -4795,10 +4898,7 @@ function renderHazbaseWalletStepCard(step, { mode = "full" } = {}) {
4795
4898
  <h3 class="wallet-step-card__title">${escapeHtml(step.title)}</h3>
4796
4899
  </div>
4797
4900
  </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>
4901
+ ${statusChipHtml}
4802
4902
  </div>
4803
4903
  <p class="wallet-step-card__copy">${escapeHtml(step.copy)}</p>
4804
4904
  <p class="wallet-step-card__detail ${step.monoDetail ? "wallet-step-card__detail--mono" : ""}">${escapeHtml(step.detail)}</p>
@@ -6305,6 +6405,20 @@ function renderTopBanner() {
6305
6405
  return "";
6306
6406
  }
6307
6407
 
6408
+ function renderGlobalErrorBanner() {
6409
+ if (!state.pushError) {
6410
+ return "";
6411
+ }
6412
+ return `
6413
+ <section class="install-banner install-banner--push">
6414
+ <div class="install-banner__copy">
6415
+ <strong>${escapeHtml(state.locale === "ja" ? "エラー" : "Error")}</strong>
6416
+ <p class="muted">${escapeHtml(state.pushError)}</p>
6417
+ </div>
6418
+ </section>
6419
+ `;
6420
+ }
6421
+
6308
6422
  function renderPushBanner() {
6309
6423
  if (!shouldShowPushBanner()) {
6310
6424
  return "";
@@ -6414,20 +6528,7 @@ function renderLogoutConfirmModal() {
6414
6528
  <strong id="logout-confirm-title">${escapeHtml(L("logout.confirm.title"))}</strong>
6415
6529
  <p class="muted">${escapeHtml(L("logout.confirm.copy"))}</p>
6416
6530
  </div>
6417
- <div class="logout-option">
6418
- <div class="logout-option__copy">
6419
- <strong>${escapeHtml(L("logout.confirm.keepTrustedTitle"))}</strong>
6420
- <p class="muted">${escapeHtml(L("logout.confirm.keepTrustedCopy"))}</p>
6421
- </div>
6422
- <button class="primary primary--wide" type="button" data-logout-mode="session">${escapeHtml(L("logout.action.keepTrusted"))}</button>
6423
- </div>
6424
- <div class="logout-option logout-option--danger">
6425
- <div class="logout-option__copy">
6426
- <strong>${escapeHtml(L("logout.confirm.removeTitle"))}</strong>
6427
- <p class="muted">${escapeHtml(L("logout.confirm.removeCopy"))}</p>
6428
- </div>
6429
- <button class="secondary secondary--wide" type="button" data-logout-mode="revoke">${escapeHtml(L("logout.action.removeDevice"))}</button>
6430
- </div>
6531
+ <button class="secondary secondary--wide" type="button" data-logout-mode="session">${escapeHtml(L("logout.action.keepTrusted"))}</button>
6431
6532
  <button class="ghost ghost--wide" type="button" data-close-logout-confirm>${escapeHtml(L("common.cancel"))}</button>
6432
6533
  </section>
6433
6534
  </div>
@@ -6488,6 +6589,7 @@ function bindShellInteractions() {
6488
6589
  openSettingsSubpage(nextPage);
6489
6590
  }
6490
6591
  await renderShell();
6592
+ refreshVersionStatusForTechnicalPage();
6491
6593
  });
6492
6594
  }
6493
6595
 
@@ -6500,6 +6602,7 @@ function bindShellInteractions() {
6500
6602
  syncCurrentItemUrl(null);
6501
6603
  openSettingsSubpage("advanced");
6502
6604
  await renderShell();
6605
+ refreshVersionStatusForTechnicalPage();
6503
6606
  });
6504
6607
  }
6505
6608
 
@@ -6507,6 +6610,7 @@ function bindShellInteractions() {
6507
6610
  button.addEventListener("click", async () => {
6508
6611
  openSettingsSubpage(button.dataset.settingsSubpage || "");
6509
6612
  await renderShell();
6613
+ refreshVersionStatusForTechnicalPage();
6510
6614
  });
6511
6615
  }
6512
6616
 
@@ -6749,7 +6853,23 @@ function bindShellInteractions() {
6749
6853
  button.innerHTML = `<span class="action-spinner" aria-hidden="true"></span><span>${escapeHtml(L("reply.sendSending"))}</span>`;
6750
6854
 
6751
6855
  try {
6752
- await apiPost(actionUrl, body);
6856
+ let postBody = body;
6857
+ if (body?.hazbaseReauth === true) {
6858
+ if (!hazbasePasskeyHostSupport().eligible) {
6859
+ throw new Error(L("error.hazbasePasskeyLocalHostRequired"));
6860
+ }
6861
+ const { createPasskeyAssertionCredential } = await loadHazbasePasskeyModule();
6862
+ const challenge = await apiPost("/api/hazbase/passkey/assert/challenge", { purpose: "reauth" });
6863
+ const credential = await createPasskeyAssertionCredential(challenge);
6864
+ await apiPost("/api/hazbase/passkey/assert/complete", {
6865
+ challengeId: challenge.challengeId,
6866
+ credential,
6867
+ purpose: "reauth",
6868
+ });
6869
+ postBody = { ...body };
6870
+ delete postBody.hazbaseReauth;
6871
+ }
6872
+ await apiPost(actionUrl, postBody);
6753
6873
  if (keepDetailOpen && activeItem?.kind === "approval") {
6754
6874
  pinActionOutcomeDetail(
6755
6875
  activeItem,
@@ -6769,16 +6889,35 @@ function bindShellInteractions() {
6769
6889
  state.pendingActionUrls.delete(actionUrl);
6770
6890
  } catch (error) {
6771
6891
  state.pendingActionUrls.delete(actionUrl);
6772
- // Restore buttons on failure so the user can retry
6773
- for (const sibling of siblingButtons) {
6774
- if (originalLabels.has(sibling)) {
6775
- sibling.innerHTML = originalLabels.get(sibling);
6892
+ const approvalFinalized = Boolean(error?.payload?.approvalFinalized);
6893
+ if (error?.errorKey === "hazbase-session-expired") {
6894
+ await fetchHazbaseStatus();
6895
+ }
6896
+ if (approvalFinalized) {
6897
+ await refreshAuthenticatedState();
6898
+ if (keepDetailOpen && activeItem?.kind === "approval") {
6899
+ pinActionOutcomeDetail(
6900
+ activeItem,
6901
+ buildActionOutcomeDetail({
6902
+ kind: "approval",
6903
+ title: state.currentDetail?.title,
6904
+ message: L("server.message.paymentFailed", { reason: error.message || String(error) }),
6905
+ })
6906
+ );
6907
+ }
6908
+ } else {
6909
+ // Restore buttons on recoverable failure so the user can retry the same action.
6910
+ for (const sibling of siblingButtons) {
6911
+ if (originalLabels.has(sibling)) {
6912
+ sibling.innerHTML = originalLabels.get(sibling);
6913
+ }
6914
+ sibling.disabled = false;
6915
+ sibling.removeAttribute("aria-busy");
6776
6916
  }
6777
- sibling.disabled = false;
6778
- sibling.removeAttribute("aria-busy");
6917
+ button.classList.remove("is-loading");
6779
6918
  }
6780
- button.classList.remove("is-loading");
6781
- throw error;
6919
+ state.pushError = error.message || String(error);
6920
+ await renderShell();
6782
6921
  }
6783
6922
  });
6784
6923
  }
@@ -7209,6 +7348,11 @@ for (const button of document.querySelectorAll("[data-hazbase-action]")) {
7209
7348
  state.hazbaseLogoutConfirmOpen = false;
7210
7349
  await apiPost("/api/hazbase/logout", {});
7211
7350
  state.hazbaseNotice = L("settings.hazbase.notice.signedOut");
7351
+ } else if (action === "refresh-session") {
7352
+ await apiPost("/api/hazbase/session/refresh", {});
7353
+ state.hazbaseOtpRequested = false;
7354
+ state.hazbaseOtpCode = "";
7355
+ state.hazbaseNotice = L("settings.hazbase.notice.sessionRefreshStarted");
7212
7356
  } else if (action === "change-email") {
7213
7357
  // Flip the form back to pre-send mode. We keep the email so typo
7214
7358
  // recovery ("hoshin" → "hoshino") stays one edit away, but drop
@@ -7233,6 +7377,11 @@ for (const button of document.querySelectorAll("[data-hazbase-action]")) {
7233
7377
  }
7234
7378
  await fetchHazbaseStatus();
7235
7379
  } catch (error) {
7380
+ if (error?.errorKey === "hazbase-session-expired") {
7381
+ state.hazbaseOtpRequested = false;
7382
+ state.hazbaseOtpCode = "";
7383
+ await fetchHazbaseStatus();
7384
+ }
7236
7385
  state.hazbaseError = error.message || String(error);
7237
7386
  }
7238
7387
  await renderShell();
@@ -8697,6 +8846,7 @@ function localizeApiError(value) {
8697
8846
  "choice-input-already-handled": "error.choiceInputAlreadyHandled",
8698
8847
  "mkcert-root-ca-not-found": "error.mkcertRootCaNotFound",
8699
8848
  "hazbase-auth-required": "error.hazbaseAuthRequired",
8849
+ "hazbase-session-expired": "error.hazbaseSessionExpired",
8700
8850
  "hazbase-passkey-local-host-required": "error.hazbasePasskeyLocalHostRequired",
8701
8851
  "hazbase-wallet-account-missing": "error.hazbaseWalletAccountMissing",
8702
8852
  "unsupported-chain": "error.unsupportedChain",