viveworker 0.7.0-beta.0 → 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/README.md +38 -0
- package/package.json +2 -1
- package/scripts/moltbook-api.mjs +319 -72
- package/scripts/share-cli.mjs +116 -6
- package/scripts/viveworker-bridge.mjs +2637 -160
- package/scripts/viveworker.mjs +11 -0
- package/viveworker.env.example +4 -0
- package/web/app.css +787 -9
- package/web/app.js +1664 -61
- package/web/hazbase-passkey.js +107 -0
- package/web/i18n.js +254 -0
- package/web/sw.js +33 -15
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
|
-
|
|
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
|
-
|
|
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
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
2011
|
-
|
|
2012
|
-
|
|
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
|
|
3404
|
-
const
|
|
3405
|
-
|
|
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-
|
|
3408
|
-
|
|
3409
|
-
<
|
|
3410
|
-
<
|
|
3411
|
-
<
|
|
3412
|
-
|
|
3413
|
-
|
|
3414
|
-
|
|
3415
|
-
|
|
3416
|
-
|
|
3417
|
-
|
|
3418
|
-
|
|
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 = [
|
|
3758
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
5758
|
-
|
|
5759
|
-
|
|
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
|
-
:
|
|
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
|
-
|
|
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:
|
|
8847
|
+
sourceTab: targetTab,
|
|
8848
|
+
sourceSubtab: targetSubtab,
|
|
7246
8849
|
});
|
|
7247
8850
|
if (isFastPathItemRef(itemRef)) {
|
|
7248
8851
|
state.launchItemIntent = {
|