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