viveworker 0.6.3 → 0.6.4

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
@@ -14,6 +14,12 @@ const NOTIFICATION_INTENT_PATH = "/__viveworker_notification_intent__";
14
14
  const state = {
15
15
  session: null,
16
16
  inbox: null,
17
+ // Flips to true after the first /api/inbox/diff response resolves
18
+ // (success OR failure). Until then the Code/diff tab shows skeleton
19
+ // shimmer cards instead of the "no entries" empty state, because the
20
+ // initial diff scan spawns git subprocesses per tracked repo and can
21
+ // take 1–3 seconds — the empty state was misleading during that window.
22
+ inboxDiffLoaded: false,
17
23
  timeline: null,
18
24
  devices: [],
19
25
  currentTab: "inbox",
@@ -101,14 +107,19 @@ boot().catch((error) => {
101
107
 
102
108
  async function boot() {
103
109
  updateManifestHref(initialPairToken);
104
- await registerServiceWorker();
110
+ // SW register + update() can take hundreds of ms and does not need to gate
111
+ // first paint. Fire and forget; the `controllerchange` reload handler wired
112
+ // up inside `registerServiceWorker` still picks up new versions.
113
+ registerServiceWorker().catch(() => {});
105
114
  navigator.serviceWorker?.addEventListener("message", handleServiceWorkerMessage);
106
115
  window.addEventListener("resize", handleViewportChange, { passive: true });
107
116
  window.addEventListener("focus", handlePotentialExternalNavigation, { passive: true });
108
117
  window.addEventListener("pageshow", handlePotentialExternalNavigation, { passive: true });
109
118
  document.addEventListener("visibilitychange", handleDocumentVisibilityChange);
110
119
 
111
- await refreshSession();
120
+ // Single round-trip for session + inbox(pending/completed) + timeline +
121
+ // devices. See `refreshBootstrap` for why we collapsed the boot fan-out.
122
+ await refreshBootstrap();
112
123
 
113
124
  if (!state.session?.authenticated && initialPairToken && shouldAutoPairFromBootstrapToken()) {
114
125
  try {
@@ -119,7 +130,7 @@ async function boot() {
119
130
  } catch (error) {
120
131
  state.pairError = error.message || String(error);
121
132
  }
122
- await refreshSession();
133
+ await refreshBootstrap();
123
134
  }
124
135
 
125
136
  syncPairingTokenState(desiredBootstrapPairingToken());
@@ -146,8 +157,6 @@ async function boot() {
146
157
  }
147
158
 
148
159
  await consumePendingNotificationIntent();
149
- await syncDetectedLocalePreference();
150
- await refreshAuthenticatedState();
151
160
  // `?focusPending=claude` marks this tab as the Claude-hook-opened popup:
152
161
  // auto-navigate to the newest unresolved Claude pending (plan/question)
153
162
  // detail view — but only when the user is not already in the middle of
@@ -156,10 +165,37 @@ async function boot() {
156
165
  if (initialFocusPending === "claude" && !state.currentItem) {
157
166
  state.claudePopupMode = true;
158
167
  }
159
- maybeAutoFocusClaudePending();
168
+
169
+ // Bootstrap already populated session + inbox(pending/completed) +
170
+ // timeline + devices, so the shell renders with real data on first
171
+ // paint. No null-state fallback needed here.
160
172
  ensureCurrentSelection();
173
+ maybeAutoFocusClaudePending();
161
174
  await renderShell();
162
175
 
176
+ // Diff fetch runs as a background phase because `/api/inbox/diff`
177
+ // spawns `git` subprocesses per tracked repo and can stall for several
178
+ // seconds. The Code/diff tab lights up when this resolves.
179
+ refreshInboxDiff()
180
+ .then(async () => {
181
+ if (!shouldDeferRenderForActiveInteraction()) {
182
+ await renderShell();
183
+ }
184
+ })
185
+ .catch(() => {});
186
+
187
+ // Remote status probes (push/moltbook/a2a-relay/a2a-share — the share
188
+ // worker round-trip alone can block up to 10s). Re-renders when it
189
+ // resolves.
190
+ refreshAuthenticatedStateRemote()
191
+ .then(async () => {
192
+ if (!shouldDeferRenderForActiveInteraction()) {
193
+ await renderShell();
194
+ }
195
+ })
196
+ .catch(() => {});
197
+ syncDetectedLocalePreference().catch(() => {});
198
+
163
199
  setInterval(async () => {
164
200
  if (!state.session?.authenticated) {
165
201
  return;
@@ -168,11 +204,41 @@ async function boot() {
168
204
  if (consumedNotificationIntent) {
169
205
  return;
170
206
  }
171
- await refreshAuthenticatedState();
207
+ // Split the poll tick into a fast local fan-out and a slow background
208
+ // fan-out. The fast calls are all in-memory bridge lookups (inbox,
209
+ // timeline, devices, push status, relay status) and typically resolve in
210
+ // under ~100 ms over localhost; rendering immediately after them keeps
211
+ // new user_message entries reaching the timeline within one scan tick.
212
+ // The slow calls — /api/inbox/diff (git subprocesses), moltbook scout
213
+ // status (cross-origin to moltbook.com on cache miss), and especially
214
+ // /api/share/status (10 s upstream timeout to share.viveworker.com) —
215
+ // used to gate the render on every poll, so a single share-worker
216
+ // cache miss stalled timeline updates for up to 10 seconds. Now they
217
+ // run in the background and trigger a second render when they resolve.
218
+ await Promise.all([
219
+ refreshInbox(),
220
+ refreshTimeline(),
221
+ refreshDevices(),
222
+ refreshPushStatus(),
223
+ fetchA2aRelayStatus(),
224
+ ]);
225
+ ensureCurrentSelection();
172
226
  maybeAutoFocusClaudePending();
173
227
  if (!shouldDeferRenderForActiveInteraction()) {
174
228
  await renderShell();
175
229
  }
230
+
231
+ Promise.allSettled([
232
+ refreshInboxDiff(),
233
+ fetchMoltbookScoutStatus(),
234
+ fetchA2aShareStatus(),
235
+ ])
236
+ .then(async () => {
237
+ if (!shouldDeferRenderForActiveInteraction()) {
238
+ await renderShell();
239
+ }
240
+ })
241
+ .catch(() => {});
176
242
  }, 3000);
177
243
  }
178
244
 
@@ -215,7 +281,9 @@ function handleViewportChange() {
215
281
  }
216
282
 
217
283
  async function refreshAuthenticatedState() {
218
- await refreshInbox();
284
+ // Fire the two inbox halves in parallel so the diff's git subprocesses
285
+ // don't serialize behind pending+completed (or vice versa).
286
+ await Promise.all([refreshInbox(), refreshInboxDiff()]);
219
287
  await refreshTimeline();
220
288
  await refreshDevices();
221
289
  await refreshPushStatus();
@@ -225,12 +293,69 @@ async function refreshAuthenticatedState() {
225
293
  ensureCurrentSelection();
226
294
  }
227
295
 
296
+ // Boot-time split: the first paint should not wait on cross-origin status
297
+ // probes. `Local` covers in-memory bridge lookups (fast). `Remote` covers
298
+ // everything that can stall on an external service (push config,
299
+ // moltbook/a2a worker calls — the a2a share worker has a 10s timeout) and
300
+ // runs in the background after the shell renders.
301
+ async function refreshAuthenticatedStateLocal() {
302
+ await Promise.all([refreshInbox(), refreshTimeline(), refreshDevices()]);
303
+ ensureCurrentSelection();
304
+ }
305
+
306
+ async function refreshAuthenticatedStateRemote() {
307
+ await Promise.allSettled([
308
+ refreshPushStatus(),
309
+ fetchMoltbookScoutStatus(),
310
+ fetchA2aRelayStatus(),
311
+ fetchA2aShareStatus(),
312
+ ]);
313
+ }
314
+
228
315
  async function refreshSession() {
229
316
  state.session = await apiGet("/api/session");
230
317
  syncPairingTokenState(desiredBootstrapPairingToken());
231
318
  applyResolvedLocale();
232
319
  }
233
320
 
321
+ // One-shot boot fetch: hits `/api/bootstrap` which bundles session,
322
+ // inbox (pending + completed), timeline, and devices into a single
323
+ // HTTPS round-trip. Saves 3 additional TLS handshakes versus calling
324
+ // the four endpoints in parallel, which is the dominant boot cost on
325
+ // iOS PWAs where connection reuse is aggressive. Leaves the diff and
326
+ // external-status probes as separate background phases in `boot()`.
327
+ async function refreshBootstrap() {
328
+ const bootstrap = await apiGet("/api/bootstrap");
329
+ state.session = bootstrap?.session || null;
330
+ syncPairingTokenState(desiredBootstrapPairingToken());
331
+ applyResolvedLocale();
332
+
333
+ if (!state.session?.authenticated) {
334
+ state.devices = [];
335
+ state.deviceError = "";
336
+ return;
337
+ }
338
+
339
+ const fastInbox = bootstrap?.inbox || {};
340
+ const previousDiff = Array.isArray(state.inbox?.diff) ? state.inbox.diff : [];
341
+ state.inbox = {
342
+ pending: Array.isArray(fastInbox.pending) ? fastInbox.pending : [],
343
+ completed: Array.isArray(fastInbox.completed) ? fastInbox.completed : [],
344
+ diff: previousDiff,
345
+ };
346
+ syncDiffThreadFilter();
347
+ syncCompletedThreadFilter();
348
+ syncInboxSubtab();
349
+
350
+ state.timeline = bootstrap?.timeline || null;
351
+ syncTimelineThreadFilter();
352
+ syncTimelineKindFilter();
353
+
354
+ const devicesPayload = bootstrap?.devices;
355
+ state.devices = Array.isArray(devicesPayload?.devices) ? devicesPayload.devices : [];
356
+ state.deviceError = "";
357
+ }
358
+
234
359
  async function syncDetectedLocalePreference() {
235
360
  if (!state.session?.authenticated || !state.session?.deviceId || !state.detectedLocale) {
236
361
  return;
@@ -248,16 +373,47 @@ async function syncDetectedLocalePreference() {
248
373
  applyResolvedLocale();
249
374
  }
250
375
 
251
- async function setLocaleOverride(nextLocale) {
252
- const result = await apiPost("/api/session/locale", {
253
- detectedLocale: state.detectedLocale,
254
- overrideLocale: nextLocale || null,
376
+ // Two-phase override: flip state.session + applyResolvedLocale synchronously
377
+ // so the caller can renderShell() in the new language before the POST
378
+ // round-trip, then reconcile with the server response. On failure the
379
+ // previous session snapshot is restored so the UI rolls back.
380
+ function applyLocaleOverrideOptimistically(nextLocale) {
381
+ if (!state.session) return null;
382
+ const previousSession = { ...state.session };
383
+ const optimistic = resolveLocalePreference({
384
+ overrideLocale: nextLocale || "",
385
+ detectedLocale: state.session?.deviceDetectedLocale || state.detectedLocale,
386
+ defaultLocale: state.session?.defaultLocale || DEFAULT_LOCALE,
387
+ fallbackLocale: DEFAULT_LOCALE,
255
388
  });
256
389
  state.session = {
257
390
  ...state.session,
258
- ...result,
391
+ deviceOverrideLocale: nextLocale || "",
392
+ locale: optimistic.locale,
393
+ localeSource: optimistic.source,
259
394
  };
260
395
  applyResolvedLocale();
396
+ return previousSession;
397
+ }
398
+
399
+ async function persistLocaleOverride(nextLocale, previousSession) {
400
+ try {
401
+ const result = await apiPost("/api/session/locale", {
402
+ detectedLocale: state.detectedLocale,
403
+ overrideLocale: nextLocale || null,
404
+ });
405
+ state.session = {
406
+ ...state.session,
407
+ ...result,
408
+ };
409
+ applyResolvedLocale();
410
+ } catch (error) {
411
+ if (previousSession) {
412
+ state.session = previousSession;
413
+ applyResolvedLocale();
414
+ }
415
+ throw error;
416
+ }
261
417
  }
262
418
 
263
419
  function applyResolvedLocale() {
@@ -385,12 +541,41 @@ async function getClientPushState() {
385
541
  }
386
542
 
387
543
  async function refreshInbox() {
388
- state.inbox = await apiGet("/api/inbox");
544
+ const fast = await apiGet("/api/inbox");
545
+ // `/api/inbox` now returns only `{ pending, completed }`. The `diff`
546
+ // half lives at `/api/inbox/diff` because it spawns `git` subprocesses
547
+ // server-side and was blocking first paint of the completed/pending
548
+ // lists. Preserve whatever diff entries we already have in memory so
549
+ // polling doesn't wipe the diff tab between diff refetches.
550
+ const previousDiff = Array.isArray(state.inbox?.diff) ? state.inbox.diff : [];
551
+ state.inbox = {
552
+ pending: Array.isArray(fast?.pending) ? fast.pending : [],
553
+ completed: Array.isArray(fast?.completed) ? fast.completed : [],
554
+ diff: previousDiff,
555
+ };
389
556
  syncDiffThreadFilter();
390
557
  syncCompletedThreadFilter();
391
558
  syncInboxSubtab();
392
559
  }
393
560
 
561
+ async function refreshInboxDiff() {
562
+ try {
563
+ const response = await apiGet("/api/inbox/diff");
564
+ const previous = state.inbox || { pending: [], completed: [] };
565
+ state.inbox = {
566
+ pending: Array.isArray(previous.pending) ? previous.pending : [],
567
+ completed: Array.isArray(previous.completed) ? previous.completed : [],
568
+ diff: Array.isArray(response?.diff) ? response.diff : [],
569
+ };
570
+ syncDiffThreadFilter();
571
+ syncInboxSubtab();
572
+ } finally {
573
+ // Always flip off the skeleton — a persistent error shouldn't leave the
574
+ // Code tab perpetually shimmering. The next poll cycle will retry.
575
+ state.inboxDiffLoaded = true;
576
+ }
577
+ }
578
+
394
579
  async function refreshTimeline() {
395
580
  state.timeline = await apiGet("/api/timeline");
396
581
  syncTimelineThreadFilter();
@@ -914,6 +1099,10 @@ async function logout({ revokeCurrentDeviceTrust = false } = {}) {
914
1099
  function resetAuthenticatedState() {
915
1100
  state.session = null;
916
1101
  state.inbox = null;
1102
+ // Reset the diff-loaded flag so the next sign-in shows the skeleton
1103
+ // again during the fresh /api/inbox/diff fetch instead of flashing the
1104
+ // empty state from the previous session's terminal value.
1105
+ state.inboxDiffLoaded = false;
917
1106
  state.timeline = null;
918
1107
  state.devices = [];
919
1108
  state.currentItem = null;
@@ -2007,9 +2196,16 @@ function renderTimelinePanel({ entries, desktop }) {
2007
2196
  function renderDiffPanel({ entries, desktop }) {
2008
2197
  const meta = tabMeta("diff");
2009
2198
  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");
2199
+ let bodyHtml;
2200
+ if (entries.length) {
2201
+ bodyHtml = `<div class="${listClassName}">${entries.map((entry) => renderDiffEntry(entry)).join("")}</div>`;
2202
+ } else if (!state.inboxDiffLoaded) {
2203
+ // First /api/inbox/diff still in flight — show shimmer cards so the
2204
+ // user sees something is happening instead of "no entries".
2205
+ bodyHtml = renderDiffSkeleton(listClassName);
2206
+ } else {
2207
+ bodyHtml = renderEmptyList("diff");
2208
+ }
2013
2209
 
2014
2210
  if (!desktop) {
2015
2211
  return `
@@ -2038,6 +2234,31 @@ function renderDiffPanel({ entries, desktop }) {
2038
2234
  `;
2039
2235
  }
2040
2236
 
2237
+ // Placeholder shimmer shown while the first /api/inbox/diff response is
2238
+ // pending. Shape mirrors `.diff-entry` so the tab doesn't visually jump when
2239
+ // real entries land. Three cards with decreasing prominence is enough to
2240
+ // signal activity without pretending to be a specific number of results.
2241
+ function renderDiffSkeleton(listClassName) {
2242
+ const card = `
2243
+ <div class="diff-entry diff-entry--skeleton" aria-hidden="true">
2244
+ <div class="diff-entry__header">
2245
+ <span class="diff-skeleton-line diff-skeleton-line--thread"></span>
2246
+ <span class="diff-skeleton-line diff-skeleton-line--time"></span>
2247
+ </div>
2248
+ <span class="diff-skeleton-line diff-skeleton-line--title"></span>
2249
+ <div class="diff-entry__files">
2250
+ <span class="diff-skeleton-chip"></span>
2251
+ <span class="diff-skeleton-chip diff-skeleton-chip--narrow"></span>
2252
+ </div>
2253
+ </div>
2254
+ `;
2255
+ return `
2256
+ <div class="${listClassName}" role="status" aria-busy="true" aria-label="${escapeHtml(L("common.loading"))}">
2257
+ ${card}${card}${card}
2258
+ </div>
2259
+ `;
2260
+ }
2261
+
2041
2262
  function timelineThreadsForProvider(provider) {
2042
2263
  const entries = Array.isArray(state.timeline?.entries) ? state.timeline.entries : [];
2043
2264
  const byThread = new Map();
@@ -3039,6 +3260,13 @@ function settingsPageMeta(page) {
3039
3260
  description: L("settings.awayMode.copy"),
3040
3261
  icon: "settings",
3041
3262
  };
3263
+ case "autoPilot":
3264
+ return {
3265
+ id: "autoPilot",
3266
+ title: L("settings.autoPilot.title"),
3267
+ description: L("settings.autoPilot.copy"),
3268
+ icon: "approval",
3269
+ };
3042
3270
  case "moltbook":
3043
3271
  return {
3044
3272
  id: "moltbook",
@@ -3097,6 +3325,15 @@ function renderSettingsRoot(context, { mobile }) {
3097
3325
  title: L("settings.awayMode.title"),
3098
3326
  value: state.session?.claudeAwayMode === true ? L("settings.claudeAway.on") : L("settings.claudeAway.off"),
3099
3327
  }),
3328
+ renderSettingsNavRow({
3329
+ page: "autoPilot",
3330
+ icon: "approval",
3331
+ title: L("settings.autoPilot.title"),
3332
+ value:
3333
+ state.session?.autoPilotTrustedReads === true || state.session?.autoPilotTrustedWrites === true
3334
+ ? L("common.enabled")
3335
+ : L("common.disabled"),
3336
+ }),
3100
3337
  ].filter(Boolean);
3101
3338
  const deviceRows = [
3102
3339
  renderSettingsNavRow({
@@ -3198,6 +3435,9 @@ function renderSettingsSubpage(context, { mobile }) {
3198
3435
  case "awayMode":
3199
3436
  content = renderSettingsAwayModePage();
3200
3437
  break;
3438
+ case "autoPilot":
3439
+ content = renderSettingsAutoPilotPage();
3440
+ break;
3201
3441
  case "moltbook":
3202
3442
  content = renderSettingsMoltbookPage(context);
3203
3443
  break;
@@ -3423,6 +3663,456 @@ function renderSettingsAwayModePage() {
3423
3663
  `;
3424
3664
  }
3425
3665
 
3666
+ function renderSettingsAutoPilotPage() {
3667
+ const trustedReadsEnabled = state.session?.autoPilotTrustedReads === true;
3668
+ const trustedReadsStateLabel = trustedReadsEnabled ? L("common.enabled") : L("common.disabled");
3669
+ const writeLaneContentEnabled = state.session?.autoPilotWriteLaneContent === true;
3670
+ const writeLaneUiTestsEnabled = state.session?.autoPilotWriteLaneUiTests === true;
3671
+ const writeLaneSourceEnabled = state.session?.autoPilotWriteLaneSource === true;
3672
+ const trustedWritesEnabled =
3673
+ writeLaneContentEnabled || writeLaneUiTestsEnabled || writeLaneSourceEnabled || state.session?.autoPilotTrustedWrites === true;
3674
+ const trustedWritesStateLabel = trustedWritesEnabled ? L("common.enabled") : L("common.disabled");
3675
+ const recentEntries = recentAutoPilotEntries();
3676
+ const suggestions = recentAutoPilotSuggestions();
3677
+ return `
3678
+ <div class="settings-page">
3679
+ ${renderSettingsGroup("", [`
3680
+ <label class="reply-mode-switch reply-mode-switch--grouped" data-auto-pilot-toggle>
3681
+ <input type="checkbox" class="reply-mode-switch__input" ${trustedReadsEnabled ? "checked" : ""} data-auto-pilot-checkbox />
3682
+ <span class="reply-mode-switch__track" aria-hidden="true"><span class="reply-mode-switch__thumb"></span></span>
3683
+ <span class="reply-mode-switch__copy">
3684
+ <span class="reply-mode-switch__title">
3685
+ <span>${escapeHtml(L("settings.autoPilot.trustedReadsTitle"))}</span>
3686
+ <span class="reply-mode-switch__state">${escapeHtml(trustedReadsStateLabel)}</span>
3687
+ </span>
3688
+ <span class="reply-mode-switch__hint">${escapeHtml(L("settings.autoPilot.trustedReadsDescription"))}</span>
3689
+ </span>
3690
+ </label>
3691
+ `, `
3692
+ <div class="settings-toggle-subhead" role="presentation">
3693
+ <span class="settings-toggle-subhead__title">${escapeHtml(L("settings.autoPilot.trustedWritesTitle"))}</span>
3694
+ <span class="settings-toggle-subhead__state">${escapeHtml(trustedWritesStateLabel)}</span>
3695
+ </div>
3696
+ `, `
3697
+ <div class="settings-toggle-subcopy muted">${escapeHtml(L("settings.autoPilot.trustedWritesDescription"))}</div>
3698
+ `, `
3699
+ <label class="reply-mode-switch reply-mode-switch--grouped" data-auto-pilot-write-lane="content">
3700
+ <input type="checkbox" class="reply-mode-switch__input" ${writeLaneContentEnabled ? "checked" : ""} data-auto-pilot-write-lane-checkbox="content" />
3701
+ <span class="reply-mode-switch__track" aria-hidden="true"><span class="reply-mode-switch__thumb"></span></span>
3702
+ <span class="reply-mode-switch__copy">
3703
+ <span class="reply-mode-switch__title">
3704
+ <span>${escapeHtml(L("settings.autoPilot.writeLaneContentTitle"))}</span>
3705
+ <span class="reply-mode-switch__state">${escapeHtml(writeLaneContentEnabled ? L("common.enabled") : L("common.disabled"))}</span>
3706
+ </span>
3707
+ <span class="reply-mode-switch__hint">${escapeHtml(L("settings.autoPilot.writeLaneContentDescription"))}</span>
3708
+ </span>
3709
+ </label>
3710
+ `, `
3711
+ <label class="reply-mode-switch reply-mode-switch--grouped" data-auto-pilot-write-lane="ui-tests">
3712
+ <input type="checkbox" class="reply-mode-switch__input" ${writeLaneUiTestsEnabled ? "checked" : ""} data-auto-pilot-write-lane-checkbox="ui-tests" />
3713
+ <span class="reply-mode-switch__track" aria-hidden="true"><span class="reply-mode-switch__thumb"></span></span>
3714
+ <span class="reply-mode-switch__copy">
3715
+ <span class="reply-mode-switch__title">
3716
+ <span>${escapeHtml(L("settings.autoPilot.writeLaneUiTestsTitle"))}</span>
3717
+ <span class="reply-mode-switch__state">${escapeHtml(writeLaneUiTestsEnabled ? L("common.enabled") : L("common.disabled"))}</span>
3718
+ </span>
3719
+ <span class="reply-mode-switch__hint">${escapeHtml(L("settings.autoPilot.writeLaneUiTestsDescription"))}</span>
3720
+ </span>
3721
+ </label>
3722
+ `, `
3723
+ <label class="reply-mode-switch reply-mode-switch--grouped" data-auto-pilot-write-lane="source">
3724
+ <input type="checkbox" class="reply-mode-switch__input" ${writeLaneSourceEnabled ? "checked" : ""} data-auto-pilot-write-lane-checkbox="source" />
3725
+ <span class="reply-mode-switch__track" aria-hidden="true"><span class="reply-mode-switch__thumb"></span></span>
3726
+ <span class="reply-mode-switch__copy">
3727
+ <span class="reply-mode-switch__title">
3728
+ <span>${escapeHtml(L("settings.autoPilot.writeLaneSourceTitle"))}</span>
3729
+ <span class="reply-mode-switch__state">${escapeHtml(writeLaneSourceEnabled ? L("common.enabled") : L("common.disabled"))}</span>
3730
+ </span>
3731
+ <span class="reply-mode-switch__hint">${escapeHtml(L("settings.autoPilot.writeLaneSourceDescription"))}</span>
3732
+ </span>
3733
+ </label>
3734
+ `], { listClassName: "settings-list settings-list--toggle-group" })}
3735
+ <p class="settings-page-copy muted">${escapeHtml(L("settings.autoPilot.scopeNote"))}</p>
3736
+ ${
3737
+ suggestions.length
3738
+ ? renderSettingsGroup(
3739
+ L("settings.autoPilot.suggestionsTitle"),
3740
+ suggestions.map((suggestion) => renderSettingsAutoPilotSuggestion(suggestion))
3741
+ )
3742
+ : ""
3743
+ }
3744
+ ${
3745
+ recentEntries.length
3746
+ ? renderSettingsGroup(
3747
+ L("settings.autoPilot.recentTitle"),
3748
+ recentEntries.map((item) => renderSettingsAutoPilotRecentEntry(item))
3749
+ )
3750
+ : `
3751
+ <section class="settings-group">
3752
+ <p class="settings-group__title">${escapeHtml(L("settings.autoPilot.recentTitle"))}</p>
3753
+ <div class="settings-copy-block settings-copy-block--compact">
3754
+ <p class="muted">${escapeHtml(L("settings.autoPilot.recentEmpty"))}</p>
3755
+ </div>
3756
+ </section>
3757
+ `
3758
+ }
3759
+ </div>
3760
+ `;
3761
+ }
3762
+
3763
+ function recentAutoPilotEntries(limit = 5) {
3764
+ const entries = Array.isArray(state.timeline?.entries) ? state.timeline.entries : [];
3765
+ return entries
3766
+ .filter((entry) => isAutoPilotApprovalEntry(entry))
3767
+ .sort((a, b) => (Number(b.createdAtMs) || 0) - (Number(a.createdAtMs) || 0))
3768
+ .slice(0, limit);
3769
+ }
3770
+
3771
+ function isAutoPilotApprovalEntry(entry) {
3772
+ const stableId = normalizeClientText(entry?.stableId || "");
3773
+ return (
3774
+ normalizeClientText(entry?.kind || "") === "approval" &&
3775
+ normalizeClientText(entry?.outcome || "") === "approved" &&
3776
+ (stableId.endsWith(":autopilot") || stableId.includes(":autopilot-write"))
3777
+ );
3778
+ }
3779
+
3780
+ function autoPilotEntryMode(item) {
3781
+ const stableId = normalizeClientText(item?.stableId || "");
3782
+ return stableId.includes(":autopilot-write") ? "write" : "read";
3783
+ }
3784
+
3785
+ function autoPilotEntryWriteLane(item) {
3786
+ const stableId = normalizeClientText(item?.stableId || "");
3787
+ const match = stableId.match(/:autopilot-write:([a-z_-]+)$/u);
3788
+ return normalizeClientText(match?.[1] || "");
3789
+ }
3790
+
3791
+ function isManualApprovedWriteEntry(entry) {
3792
+ const stableId = normalizeClientText(entry?.stableId || "");
3793
+ return (
3794
+ normalizeClientText(entry?.kind || "") === "approval" &&
3795
+ normalizeClientText(entry?.outcome || "") === "approved" &&
3796
+ !stableId.includes(":autopilot") &&
3797
+ normalizeClientFileRefs(entry?.fileRefs).length > 0 &&
3798
+ normalizeClientText(entry?.diffText || "").length > 0
3799
+ );
3800
+ }
3801
+
3802
+ function autoPilotDeniedWritePathClient(fileRef) {
3803
+ const normalized = normalizeClientText(fileRef || "");
3804
+ if (!normalized) {
3805
+ return true;
3806
+ }
3807
+ const lower = normalized.toLowerCase();
3808
+ const segments = lower.split(/[\\/]+/u).filter(Boolean);
3809
+ const basename = segments[segments.length - 1] || "";
3810
+ if (
3811
+ segments.some((segment) => [".ssh", ".aws", ".gnupg", ".azure", ".kube", ".github", ".gitlab", ".terraform", ".claude", ".husky", ".vscode"].includes(segment))
3812
+ ) {
3813
+ return true;
3814
+ }
3815
+ if (basename === ".npmrc" || basename === ".netrc" || basename === ".env" || basename.startsWith(".env.")) {
3816
+ return true;
3817
+ }
3818
+ if (basename.endsWith(".pem") || basename.endsWith(".key") || basename.endsWith(".p12") || basename.endsWith(".pfx")) {
3819
+ return true;
3820
+ }
3821
+ if (
3822
+ [
3823
+ "package.json",
3824
+ "package-lock.json",
3825
+ "pnpm-lock.yaml",
3826
+ "yarn.lock",
3827
+ "bun.lockb",
3828
+ "cargo.toml",
3829
+ "cargo.lock",
3830
+ "gemfile",
3831
+ "gemfile.lock",
3832
+ "podfile",
3833
+ "podfile.lock",
3834
+ "composer.json",
3835
+ "composer.lock",
3836
+ "pipfile",
3837
+ "pipfile.lock",
3838
+ "poetry.lock",
3839
+ "requirements.txt",
3840
+ "dockerfile",
3841
+ "wrangler.toml",
3842
+ "tsconfig.json",
3843
+ "tsconfig.tsbuildinfo",
3844
+ ].includes(basename)
3845
+ ) {
3846
+ return true;
3847
+ }
3848
+ return /^id_[a-z0-9._-]+$/iu.test(basename) || basename.includes("secret") || basename.includes("credential");
3849
+ }
3850
+
3851
+ function autoPilotContentWritePathClient(fileRef) {
3852
+ const normalized = normalizeClientText(fileRef || "");
3853
+ if (!normalized || autoPilotDeniedWritePathClient(normalized)) {
3854
+ return false;
3855
+ }
3856
+ const lower = normalized.toLowerCase();
3857
+ const segments = lower.split(/[\\/]+/u).filter(Boolean);
3858
+ const basename = segments[segments.length - 1] || "";
3859
+ const extension = basename.includes(".") ? `.${basename.split(".").pop().toLowerCase()}` : "";
3860
+ const basenameWithoutExtension = extension ? basename.slice(0, -extension.length) : basename;
3861
+ if ([".md", ".mdx", ".txt", ".rst", ".adoc"].includes(extension)) {
3862
+ return true;
3863
+ }
3864
+ if (["license", "notice", "copying", "readme", "changelog", "contributing"].includes(basenameWithoutExtension.toLowerCase())) {
3865
+ return true;
3866
+ }
3867
+ if (segments.includes("i18n") && [".js", ".ts", ".json", ".yaml", ".yml"].includes(extension)) {
3868
+ return true;
3869
+ }
3870
+ if (segments.includes("messages") && extension === ".json") {
3871
+ return true;
3872
+ }
3873
+ return false;
3874
+ }
3875
+
3876
+ function autoPilotUiTestsWritePathClient(fileRef) {
3877
+ const normalized = normalizeClientText(fileRef || "");
3878
+ if (!normalized || autoPilotDeniedWritePathClient(normalized)) {
3879
+ return false;
3880
+ }
3881
+ const lower = normalized.toLowerCase();
3882
+ const segments = lower.split(/[\\/]+/u).filter(Boolean);
3883
+ const basename = segments[segments.length - 1] || "";
3884
+ const extension = basename.includes(".") ? `.${basename.split(".").pop().toLowerCase()}` : "";
3885
+ if ([".css", ".scss", ".sass", ".less", ".styl", ".pcss"].includes(extension)) {
3886
+ return true;
3887
+ }
3888
+ if (segments.includes("__tests__") || /\.(test|spec)\.[cm]?[jt]sx?$/u.test(basename)) {
3889
+ return true;
3890
+ }
3891
+ if ((segments.includes("web") || segments.includes("components")) && [".js", ".jsx", ".ts", ".tsx", ".html"].includes(extension)) {
3892
+ return true;
3893
+ }
3894
+ return false;
3895
+ }
3896
+
3897
+ function autoPilotSourceWritePathClient(fileRef) {
3898
+ const normalized = normalizeClientText(fileRef || "");
3899
+ if (!normalized || autoPilotDeniedWritePathClient(normalized)) {
3900
+ return false;
3901
+ }
3902
+ if (autoPilotContentWritePathClient(normalized) || autoPilotUiTestsWritePathClient(normalized)) {
3903
+ return false;
3904
+ }
3905
+ return /\.(?:[cm]?[jt]sx?)$/u.test(normalized);
3906
+ }
3907
+
3908
+ function diffAddedLinesClient(diffText) {
3909
+ return String(diffText || "")
3910
+ .replace(/\r\n/gu, "\n")
3911
+ .split("\n")
3912
+ .filter((line) => line.startsWith("+") && !line.startsWith("+++"))
3913
+ .map((line) => line.slice(1));
3914
+ }
3915
+
3916
+ function addedDiffLinesContainClient(diffText, pattern) {
3917
+ return diffAddedLinesClient(diffText).some((line) => pattern.test(line));
3918
+ }
3919
+
3920
+ function hasUnsafeUiOrTestWriteDiffClient(diffText) {
3921
+ const patterns = [
3922
+ /\bprocess\.env\b/u,
3923
+ /\b(?:child_process|spawn|exec|execFile|fork)\b/u,
3924
+ /\bfs\.(?:write|append|rm|unlink|rename|chmod|chown|copyFile|cp)\b/u,
3925
+ /\b(?:fetch|axios|XMLHttpRequest)\s*\(/u,
3926
+ /\bcrypto\b/u,
3927
+ /\b(?:secret|token|password|privateKey|credential)\b/iu,
3928
+ ];
3929
+ return (
3930
+ addedDiffLinesContainClient(diffText, /^\s*(?:import\s|export\s+.*\s+from\s)/u) ||
3931
+ addedDiffLinesContainClient(diffText, /\brequire\s*\(/u) ||
3932
+ patterns.some((pattern) => addedDiffLinesContainClient(diffText, pattern))
3933
+ );
3934
+ }
3935
+
3936
+ function hasUnsafeSourceWriteDiffClient(diffText) {
3937
+ const patterns = [
3938
+ /\bprocess\.env\b/u,
3939
+ /\b(?:child_process|spawn|exec|execFile|fork)\b/u,
3940
+ /\bfs\.(?:write|append|rm|unlink|rename|chmod|chown|copyFile|cp)\b/u,
3941
+ /\b(?:fetch|axios|XMLHttpRequest)\s*\(/u,
3942
+ /\b(?:net|tls|dgram|http2?)\b/u,
3943
+ /\bcrypto\b/u,
3944
+ /\b(?:secret|token|password|privateKey|credential)\b/iu,
3945
+ ];
3946
+ return (
3947
+ addedDiffLinesContainClient(diffText, /^\s*(?:import\s|export\s+.*\s+from\s)/u) ||
3948
+ addedDiffLinesContainClient(diffText, /\brequire\s*\(/u) ||
3949
+ patterns.some((pattern) => addedDiffLinesContainClient(diffText, pattern))
3950
+ );
3951
+ }
3952
+
3953
+ function classifyManualWriteLaneSuggestion(entry) {
3954
+ const fileRefs = normalizeClientFileRefs(entry?.fileRefs);
3955
+ const diffText = normalizeClientText(entry?.diffText || "");
3956
+ const added = Math.max(0, Number(entry?.diffAddedLines) || 0);
3957
+ const removed = Math.max(0, Number(entry?.diffRemovedLines) || 0);
3958
+ const totalChangedLines = added + removed;
3959
+ if (!fileRefs.length || !diffText || totalChangedLines === 0) {
3960
+ return "";
3961
+ }
3962
+ 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)) {
3963
+ return "";
3964
+ }
3965
+ if (
3966
+ fileRefs.length >= 1 &&
3967
+ fileRefs.length <= 3 &&
3968
+ totalChangedLines <= 120 &&
3969
+ fileRefs.every((fileRef) => autoPilotContentWritePathClient(fileRef))
3970
+ ) {
3971
+ return "content";
3972
+ }
3973
+ if (
3974
+ fileRefs.length >= 1 &&
3975
+ fileRefs.length <= 2 &&
3976
+ totalChangedLines <= 80 &&
3977
+ fileRefs.every((fileRef) => autoPilotUiTestsWritePathClient(fileRef)) &&
3978
+ !hasUnsafeUiOrTestWriteDiffClient(diffText)
3979
+ ) {
3980
+ return "ui_tests";
3981
+ }
3982
+ if (
3983
+ fileRefs.length === 1 &&
3984
+ totalChangedLines <= 40 &&
3985
+ fileRefs.every((fileRef) => autoPilotSourceWritePathClient(fileRef)) &&
3986
+ !hasUnsafeSourceWriteDiffClient(diffText)
3987
+ ) {
3988
+ return "source";
3989
+ }
3990
+ return "";
3991
+ }
3992
+
3993
+ function isWriteLaneEnabled(lane) {
3994
+ return lane === "content"
3995
+ ? state.session?.autoPilotWriteLaneContent === true
3996
+ : lane === "ui_tests"
3997
+ ? state.session?.autoPilotWriteLaneUiTests === true
3998
+ : state.session?.autoPilotWriteLaneSource === true;
3999
+ }
4000
+
4001
+ function recentAutoPilotSuggestions(limit = 40, minCount = 3) {
4002
+ const entries = Array.isArray(state.timeline?.entries) ? state.timeline.entries : [];
4003
+ const counts = new Map();
4004
+ for (const entry of entries
4005
+ .filter((item) => isManualApprovedWriteEntry(item))
4006
+ .sort((a, b) => (Number(b.createdAtMs) || 0) - (Number(a.createdAtMs) || 0))
4007
+ .slice(0, limit)) {
4008
+ const lane = classifyManualWriteLaneSuggestion(entry);
4009
+ if (!lane || isWriteLaneEnabled(lane)) {
4010
+ continue;
4011
+ }
4012
+ counts.set(lane, (counts.get(lane) || 0) + 1);
4013
+ }
4014
+ return ["content", "ui_tests", "source"]
4015
+ .map((lane) => ({ lane, count: counts.get(lane) || 0 }))
4016
+ .filter((item) => item.count >= minCount);
4017
+ }
4018
+
4019
+ function firstMarkdownCodeFence(text) {
4020
+ const match = String(text || "").match(/```(?:\w+)?\n([\s\S]*?)\n```/u);
4021
+ return normalizeClientText(match?.[1] || "");
4022
+ }
4023
+
4024
+ function truncateUiText(value, maxGlyphs = 92) {
4025
+ const normalized = normalizeClientText(value || "");
4026
+ if (!normalized) {
4027
+ return "";
4028
+ }
4029
+ const glyphs = Array.from(normalized);
4030
+ return glyphs.length > maxGlyphs ? `${glyphs.slice(0, maxGlyphs).join("")}…` : normalized;
4031
+ }
4032
+
4033
+ function autoPilotEntryHeadline(item) {
4034
+ const mode = autoPilotEntryMode(item);
4035
+ if (mode === "read") {
4036
+ return truncateUiText(firstMarkdownCodeFence(item?.messageText || "") || item?.summary || item?.title || "");
4037
+ }
4038
+
4039
+ const fileRefs = normalizeClientFileRefs(item?.fileRefs);
4040
+ const primaryRef = fileRefs[0] || item?.summary || item?.title || "";
4041
+ const extraCount = Math.max(0, fileRefs.length - 1);
4042
+ const stats =
4043
+ Number.isFinite(Number(item?.diffAddedLines)) && Number.isFinite(Number(item?.diffRemovedLines))
4044
+ ? ` (+${Math.max(0, Number(item?.diffAddedLines) || 0)} / -${Math.max(0, Number(item?.diffRemovedLines) || 0)})`
4045
+ : "";
4046
+ return truncateUiText(`${primaryRef}${extraCount > 0 ? ` +${extraCount}` : ""}${stats}`);
4047
+ }
4048
+
4049
+ function renderSettingsAutoPilotRecentEntry(item) {
4050
+ const mode = autoPilotEntryMode(item);
4051
+ const badgeLabel = mode === "write" ? L("settings.autoPilot.recentWrite") : L("settings.autoPilot.recentRead");
4052
+ const badgeClass = mode === "write" ? "settings-compose-badge--write" : "settings-compose-badge--read";
4053
+ const iconName = mode === "write" ? "file-event" : "approval";
4054
+ const iconToneClass = mode === "write" ? "settings-icon-entry__icon--write" : "settings-icon-entry__icon--read";
4055
+ const headline = autoPilotEntryHeadline(item) || L("common.untitledItem");
4056
+ const threadLabel = resolvedThreadLabel(item?.threadId || "", item?.threadLabel || "");
4057
+ const laneLabel = ({
4058
+ content: L("settings.autoPilot.recentContent"),
4059
+ "ui_tests": L("settings.autoPilot.recentUiTests"),
4060
+ source: L("settings.autoPilot.recentSource"),
4061
+ }[autoPilotEntryWriteLane(item)] || "");
4062
+ const metaParts = [providerDisplayName(item?.provider), formatTimelineTimestamp(item?.createdAtMs)].filter(Boolean);
4063
+
4064
+ return `
4065
+ <button
4066
+ type="button"
4067
+ class="settings-compose-entry settings-icon-entry settings-autopilot-entry"
4068
+ data-open-item-kind="${escapeHtml(item.kind)}"
4069
+ data-open-item-token="${escapeHtml(item.token)}"
4070
+ data-source-tab="timeline"
4071
+ >
4072
+ <span class="settings-icon-entry__icon ${iconToneClass}" aria-hidden="true">${renderIcon(iconName)}</span>
4073
+ <span class="settings-icon-entry__body">
4074
+ <span class="settings-icon-entry__title-row">
4075
+ <span class="settings-compose-entry__title">${escapeHtml(headline)}</span>
4076
+ <span class="settings-compose-badge ${badgeClass}">${escapeHtml(badgeLabel)}</span>
4077
+ ${mode === "write" && laneLabel ? `<span class="settings-compose-badge settings-compose-badge--lane">${escapeHtml(laneLabel)}</span>` : ""}
4078
+ </span>
4079
+ <span class="settings-autopilot-entry__meta">${escapeHtml(metaParts.join(" · "))}</span>
4080
+ ${threadLabel ? `<span class="settings-autopilot-entry__thread">${escapeHtml(threadLabel)}</span>` : ""}
4081
+ </span>
4082
+ </button>
4083
+ `;
4084
+ }
4085
+
4086
+ function renderSettingsAutoPilotSuggestion({ lane, count }) {
4087
+ const title =
4088
+ lane === "content"
4089
+ ? L("settings.autoPilot.suggestionContentTitle")
4090
+ : lane === "ui_tests"
4091
+ ? L("settings.autoPilot.suggestionUiTestsTitle")
4092
+ : L("settings.autoPilot.suggestionSourceTitle");
4093
+ const body =
4094
+ lane === "content"
4095
+ ? L("settings.autoPilot.suggestionContentBody", { count })
4096
+ : lane === "ui_tests"
4097
+ ? L("settings.autoPilot.suggestionUiTestsBody", { count })
4098
+ : L("settings.autoPilot.suggestionSourceBody", { count });
4099
+ return `
4100
+ <div class="settings-suggestion-card">
4101
+ <div class="settings-suggestion-card__header">
4102
+ <div>
4103
+ <p class="settings-suggestion-card__title">${escapeHtml(title)}</p>
4104
+ <p class="settings-suggestion-card__body">${escapeHtml(body)}</p>
4105
+ </div>
4106
+ <button
4107
+ type="button"
4108
+ class="primary settings-suggestion-card__action"
4109
+ data-auto-pilot-suggest-lane="${escapeHtml(lane)}"
4110
+ >${escapeHtml(L("settings.autoPilot.suggestionEnable"))}</button>
4111
+ </div>
4112
+ </div>
4113
+ `;
4114
+ }
4115
+
3426
4116
  function renderSettingsMoltbookPage(context) {
3427
4117
  const scout = context.moltbookScout;
3428
4118
  if (!scout?.enabled) {
@@ -3833,6 +4523,7 @@ function renderStandardDetailDesktop(detail) {
3833
4523
  <h2 class="detail-title detail-title--desktop">${renderDetailTitle(detail)}</h2>
3834
4524
  ${detail.readOnly || detail.kind === "approval" || detail.kind === "moltbook_draft" || detail.kind === "moltbook_reply" || detail.kind === "thread_share" ? "" : renderDetailLead(detail, kindInfo)}
3835
4525
  ${renderPreviousContextCard(detail)}
4526
+ ${renderAutoPilotManualReview(detail)}
3836
4527
  ${renderInterruptedDetailNotice(detail)}
3837
4528
  ${renderMoltbookReplyComposer(detail)}
3838
4529
  ${renderMoltbookDraftComposer(detail)}
@@ -3872,6 +4563,7 @@ function renderStandardDetailMobile(detail) {
3872
4563
  <div class="mobile-detail-scroll mobile-detail-scroll--detail">
3873
4564
  ${renderDetailMetaRow(detail, kindInfo, { mobile: true })}
3874
4565
  ${renderPreviousContextCard(detail, { mobile: true })}
4566
+ ${renderAutoPilotManualReview(detail, { mobile: true })}
3875
4567
  ${renderInterruptedDetailNotice(detail, { mobile: true })}
3876
4568
  ${renderMoltbookReplyComposer(detail, { mobile: true })}
3877
4569
  ${renderMoltbookDraftComposer(detail, { mobile: true })}
@@ -4052,6 +4744,25 @@ function renderPreviousContextCard(detail, options = {}) {
4052
4744
  `;
4053
4745
  }
4054
4746
 
4747
+ function renderAutoPilotManualReview(detail, options = {}) {
4748
+ const review = detail?.autoPilotReview;
4749
+ if (!review?.title || !review?.body) {
4750
+ return "";
4751
+ }
4752
+ return `
4753
+ <section class="detail-card detail-card--autopilot ${options.mobile ? "detail-card--mobile" : ""}">
4754
+ <div class="detail-context-card__header">
4755
+ <div class="detail-context-card__eyebrow">
4756
+ <span class="detail-context-card__icon" aria-hidden="true">${renderIcon("settings")}</span>
4757
+ <span>${escapeHtml(L("detail.autoPilotManualEyebrow"))}</span>
4758
+ </div>
4759
+ </div>
4760
+ <p class="detail-context-card__kind">${escapeHtml(review.title)}</p>
4761
+ <p class="detail-autopilot-copy">${escapeHtml(review.body)}</p>
4762
+ </section>
4763
+ `;
4764
+ }
4765
+
4055
4766
  function renderDetailImageGallery(detail, options = {}) {
4056
4767
  const imageUrls = Array.isArray(detail?.imageUrls) ? detail.imageUrls.filter(Boolean) : [];
4057
4768
  if (imageUrls.length === 0) {
@@ -5622,6 +6333,16 @@ function bindShellInteractions() {
5622
6333
  const action = button.dataset.pushAction;
5623
6334
  state.pushError = "";
5624
6335
  state.pushNotice = "";
6336
+ // Immediate visual feedback — enable/disable inherently wait on
6337
+ // Web Push APIs (permission prompt → pushManager.subscribe() →
6338
+ // server POST → refreshPushStatus), which can take 1–3 seconds.
6339
+ // Without this the button just sat there looking inert the whole
6340
+ // time. The .is-loading + aria-busy pair matches the pattern used
6341
+ // by approval action buttons elsewhere in this file.
6342
+ const wasDisabled = button.disabled;
6343
+ button.classList.add("is-loading");
6344
+ button.disabled = true;
6345
+ button.setAttribute("aria-busy", "true");
5625
6346
  try {
5626
6347
  if (action === "enable") {
5627
6348
  await enableNotifications();
@@ -5640,6 +6361,12 @@ function bindShellInteractions() {
5640
6361
  await refreshPushStatus();
5641
6362
  } catch (error) {
5642
6363
  state.pushError = error.message || String(error);
6364
+ // Restore this specific button on failure; renderShell() below
6365
+ // would rebuild it anyway but leaving it disabled between
6366
+ // exception and render flashes a dead button.
6367
+ button.classList.remove("is-loading");
6368
+ button.disabled = wasDisabled;
6369
+ button.removeAttribute("aria-busy");
5643
6370
  }
5644
6371
  await renderShell();
5645
6372
  });
@@ -5648,11 +6375,96 @@ function bindShellInteractions() {
5648
6375
  for (const checkbox of document.querySelectorAll("[data-claude-away-checkbox]")) {
5649
6376
  checkbox.addEventListener("change", async () => {
5650
6377
  const next = checkbox.checked === true;
6378
+ const previous = state.session?.claudeAwayMode === true;
6379
+ // Optimistic flip — the server round-trip and the old post-toggle
6380
+ // refreshAuthenticatedState() (7 endpoints) used to gate the UI
6381
+ // update. Flip state now, POST in background, roll back on error.
6382
+ if (state.session) {
6383
+ state.session.claudeAwayMode = next;
6384
+ }
6385
+ await renderShell();
5651
6386
  try {
5652
6387
  const result = await apiPost("/api/settings/claude-away-mode", { enabled: next });
6388
+ if (state.session && result && Object.prototype.hasOwnProperty.call(result, "enabled")) {
6389
+ const reconciled = result.enabled === true;
6390
+ if (reconciled !== next) {
6391
+ state.session.claudeAwayMode = reconciled;
6392
+ await renderShell();
6393
+ }
6394
+ }
6395
+ } catch (error) {
5653
6396
  if (state.session) {
5654
- state.session.claudeAwayMode = result?.enabled === true;
6397
+ state.session.claudeAwayMode = previous;
5655
6398
  }
6399
+ state.pushError = error.message || String(error);
6400
+ await renderShell();
6401
+ }
6402
+ });
6403
+ }
6404
+
6405
+ function applyAutoPilotSettingsResult(result) {
6406
+ if (!state.session) {
6407
+ return;
6408
+ }
6409
+ state.session.autoPilotTrustedReads = result?.trustedReadsEnabled === true;
6410
+ state.session.autoPilotTrustedWrites = result?.trustedWritesEnabled === true;
6411
+ state.session.autoPilotWriteLaneContent = result?.writeLaneContentEnabled === true;
6412
+ state.session.autoPilotWriteLaneUiTests = result?.writeLaneUiTestsEnabled === true;
6413
+ state.session.autoPilotWriteLaneSource = result?.writeLaneSourceEnabled === true;
6414
+ }
6415
+
6416
+ for (const checkbox of document.querySelectorAll("[data-auto-pilot-checkbox]")) {
6417
+ checkbox.addEventListener("change", async () => {
6418
+ const next = checkbox.checked === true;
6419
+ try {
6420
+ const result = await apiPost("/api/settings/auto-pilot", { trustedReadsEnabled: next });
6421
+ applyAutoPilotSettingsResult(result);
6422
+ await refreshAuthenticatedState();
6423
+ } catch (error) {
6424
+ state.pushError = error.message || String(error);
6425
+ }
6426
+ await renderShell();
6427
+ });
6428
+ }
6429
+
6430
+ for (const checkbox of document.querySelectorAll("[data-auto-pilot-write-lane-checkbox]")) {
6431
+ checkbox.addEventListener("change", async () => {
6432
+ const next = checkbox.checked === true;
6433
+ const lane = normalizeClientText(checkbox.getAttribute("data-auto-pilot-write-lane-checkbox") || "");
6434
+ const payload =
6435
+ lane === "content"
6436
+ ? { writeLaneContentEnabled: next }
6437
+ : lane === "ui-tests"
6438
+ ? { writeLaneUiTestsEnabled: next }
6439
+ : { writeLaneSourceEnabled: next };
6440
+ try {
6441
+ const result = await apiPost("/api/settings/auto-pilot", payload);
6442
+ applyAutoPilotSettingsResult(result);
6443
+ await refreshAuthenticatedState();
6444
+ } catch (error) {
6445
+ state.pushError = error.message || String(error);
6446
+ }
6447
+ await renderShell();
6448
+ });
6449
+ }
6450
+
6451
+ for (const button of document.querySelectorAll("[data-auto-pilot-suggest-lane]")) {
6452
+ button.addEventListener("click", async () => {
6453
+ const lane = normalizeClientText(button.getAttribute("data-auto-pilot-suggest-lane") || "");
6454
+ const payload =
6455
+ lane === "content"
6456
+ ? { writeLaneContentEnabled: true }
6457
+ : lane === "ui_tests"
6458
+ ? { writeLaneUiTestsEnabled: true }
6459
+ : lane === "source"
6460
+ ? { writeLaneSourceEnabled: true }
6461
+ : null;
6462
+ if (!payload) {
6463
+ return;
6464
+ }
6465
+ try {
6466
+ const result = await apiPost("/api/settings/auto-pilot", payload);
6467
+ applyAutoPilotSettingsResult(result);
5656
6468
  await refreshAuthenticatedState();
5657
6469
  } catch (error) {
5658
6470
  state.pushError = error.message || String(error);
@@ -5664,13 +6476,29 @@ function bindShellInteractions() {
5664
6476
  for (const checkbox of document.querySelectorAll("[data-a2a-public-checkbox]")) {
5665
6477
  checkbox.addEventListener("change", async () => {
5666
6478
  const next = checkbox.checked === true;
6479
+ const previous = state.a2aRelayStatus?.acceptPublicTasks === true;
6480
+ // Optimistic flip — POST + a follow-up GET of the (remote-worker)
6481
+ // relay-status endpoint used to gate the visual update. Flip the
6482
+ // local flag, render, then reconcile in background.
6483
+ if (state.a2aRelayStatus) {
6484
+ state.a2aRelayStatus = { ...state.a2aRelayStatus, acceptPublicTasks: next };
6485
+ }
6486
+ await renderShell();
5667
6487
  try {
5668
6488
  await apiPost("/api/a2a/public-tasks", { accept: next });
5669
- state.a2aRelayStatus = await apiGet("/api/a2a/relay-status");
6489
+ apiGet("/api/a2a/relay-status")
6490
+ .then((fresh) => {
6491
+ state.a2aRelayStatus = fresh;
6492
+ renderShell();
6493
+ })
6494
+ .catch(() => {});
5670
6495
  } catch (error) {
6496
+ if (state.a2aRelayStatus) {
6497
+ state.a2aRelayStatus = { ...state.a2aRelayStatus, acceptPublicTasks: previous };
6498
+ }
5671
6499
  state.pushError = error.message || String(error);
6500
+ await renderShell();
5672
6501
  }
5673
- await renderShell();
5674
6502
  });
5675
6503
  }
5676
6504
 
@@ -5678,13 +6506,24 @@ function bindShellInteractions() {
5678
6506
  radio.addEventListener("change", async () => {
5679
6507
  if (!radio.checked) return;
5680
6508
  const preference = radio.value || "auto";
6509
+ const previous = state.session?.a2aExecutorPreference || "ask";
6510
+ // Optimistic flip — the POST + refreshSession() GET used to gate
6511
+ // any re-render that depends on the preference (e.g. downstream
6512
+ // picker defaults). Flip locally and reconcile in background.
6513
+ if (state.session) {
6514
+ state.session.a2aExecutorPreference = preference;
6515
+ }
6516
+ await renderShell();
5681
6517
  try {
5682
6518
  await apiPost("/api/settings/a2a-executor", { preference });
5683
- await refreshSession();
6519
+ refreshSession().catch(() => {});
5684
6520
  } catch (error) {
6521
+ if (state.session) {
6522
+ state.session.a2aExecutorPreference = previous;
6523
+ }
5685
6524
  state.pushError = error.message || String(error);
6525
+ await renderShell();
5686
6526
  }
5687
- await renderShell();
5688
6527
  });
5689
6528
  }
5690
6529
 
@@ -5718,14 +6557,27 @@ function bindShellInteractions() {
5718
6557
  button.addEventListener("click", async () => {
5719
6558
  state.pushError = "";
5720
6559
  state.pushNotice = "";
6560
+ const nextLocale = button.dataset.localeOption || "";
6561
+ // Flip language synchronously and render before the POST — the
6562
+ // UI switches in one frame instead of waiting on the round-trip
6563
+ // plus the 7-endpoint refreshAuthenticatedState() that followed.
6564
+ const previousSession = applyLocaleOverrideOptimistically(nextLocale);
6565
+ await renderShell();
5721
6566
  try {
5722
- await setLocaleOverride(button.dataset.localeOption || "");
5723
- await refreshSession();
5724
- await refreshAuthenticatedState();
6567
+ await persistLocaleOverride(nextLocale, previousSession);
6568
+ // Server-rendered inbox/timeline strings (kind labels, summaries)
6569
+ // are localised; refresh them in the background so the user
6570
+ // doesn't wait. Polling would eventually pick this up anyway,
6571
+ // but this makes the switch feel instant.
6572
+ Promise.all([
6573
+ refreshInbox().catch(() => {}),
6574
+ refreshInboxDiff().catch(() => {}),
6575
+ refreshTimeline().catch(() => {}),
6576
+ ]).then(() => renderShell());
5725
6577
  } catch (error) {
5726
6578
  state.pushError = error.message || String(error);
6579
+ await renderShell();
5727
6580
  }
5728
- await renderShell();
5729
6581
  });
5730
6582
  }
5731
6583