viveworker 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +178 -0
- package/launchd/io.viveworker.app.plist.example +39 -0
- package/ntfy/docker-compose.yml.example +10 -0
- package/ntfy/server.yml.example +28 -0
- package/package.json +24 -0
- package/scripts/lib/markdown-render.mjs +274 -0
- package/scripts/lib/pairing.mjs +83 -0
- package/scripts/viveworker-bridge.mjs +8892 -0
- package/scripts/viveworker.mjs +1353 -0
- package/viveworker.env.example +99 -0
- package/web/app.css +2303 -0
- package/web/app.js +3867 -0
- package/web/i18n.js +937 -0
- package/web/icons/apple-touch-icon.png +0 -0
- package/web/icons/viveworker-beacon-v.svg +19 -0
- package/web/icons/viveworker-icon-1024.png +0 -0
- package/web/icons/viveworker-icon-192.png +0 -0
- package/web/icons/viveworker-icon-512.png +0 -0
- package/web/icons/viveworker-v-check.svg +19 -0
- package/web/icons/viveworker-v-pulse.svg +24 -0
- package/web/index.html +17 -0
- package/web/manifest.webmanifest +22 -0
- package/web/sw.js +153 -0
package/web/app.js
ADDED
|
@@ -0,0 +1,3867 @@
|
|
|
1
|
+
import { DEFAULT_LOCALE, SUPPORTED_LOCALES, localeDisplayName, normalizeLocale, resolveLocalePreference, t } from "./i18n.js";
|
|
2
|
+
|
|
3
|
+
const DESKTOP_BREAKPOINT = 980;
|
|
4
|
+
const INSTALL_BANNER_DISMISS_KEY = "viveworker-install-banner-dismissed-v2";
|
|
5
|
+
const PUSH_BANNER_DISMISS_KEY = "viveworker-push-banner-dismissed-v1";
|
|
6
|
+
const INITIAL_DETECTED_LOCALE = detectBrowserLocale();
|
|
7
|
+
const TIMELINE_MESSAGE_KINDS = new Set(["user_message", "assistant_commentary", "assistant_final"]);
|
|
8
|
+
const TIMELINE_OPERATIONAL_KINDS = new Set(["approval", "plan", "plan_ready", "choice", "completion"]);
|
|
9
|
+
|
|
10
|
+
const state = {
|
|
11
|
+
session: null,
|
|
12
|
+
inbox: null,
|
|
13
|
+
timeline: null,
|
|
14
|
+
devices: [],
|
|
15
|
+
currentTab: "pending",
|
|
16
|
+
currentItem: null,
|
|
17
|
+
currentDetail: null,
|
|
18
|
+
currentDetailLoading: false,
|
|
19
|
+
detailLoadingItem: null,
|
|
20
|
+
detailOpen: false,
|
|
21
|
+
timelineThreadFilter: "all",
|
|
22
|
+
completedThreadFilter: "all",
|
|
23
|
+
settingsSubpage: "",
|
|
24
|
+
settingsScrollState: null,
|
|
25
|
+
pendingSettingsSubpageScrollReset: false,
|
|
26
|
+
pendingSettingsScrollRestore: false,
|
|
27
|
+
launchItemIntent: null,
|
|
28
|
+
detailOverride: null,
|
|
29
|
+
pendingDetailScrollReset: false,
|
|
30
|
+
listScrollState: null,
|
|
31
|
+
pendingListScrollRestore: false,
|
|
32
|
+
choiceLocalDrafts: {},
|
|
33
|
+
completionReplyDrafts: {},
|
|
34
|
+
pairError: "",
|
|
35
|
+
pairNotice: "",
|
|
36
|
+
pushStatus: null,
|
|
37
|
+
pushNotice: "",
|
|
38
|
+
pushError: "",
|
|
39
|
+
deviceNotice: "",
|
|
40
|
+
deviceError: "",
|
|
41
|
+
serviceWorkerRegistration: null,
|
|
42
|
+
installGuideOpen: false,
|
|
43
|
+
logoutConfirmOpen: false,
|
|
44
|
+
installBannerDismissed: readInstallBannerDismissed(),
|
|
45
|
+
pushBannerDismissed: readPushBannerDismissed(),
|
|
46
|
+
detectedLocale: INITIAL_DETECTED_LOCALE,
|
|
47
|
+
locale: INITIAL_DETECTED_LOCALE || DEFAULT_LOCALE,
|
|
48
|
+
localeSource: "fallback",
|
|
49
|
+
defaultLocale: DEFAULT_LOCALE,
|
|
50
|
+
supportedLocales: [...SUPPORTED_LOCALES],
|
|
51
|
+
appVersion: "",
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
let detailLoadSequence = 0;
|
|
55
|
+
|
|
56
|
+
const app = document.querySelector("#app");
|
|
57
|
+
const params = new URLSearchParams(window.location.search);
|
|
58
|
+
const initialItem = params.get("item") || "";
|
|
59
|
+
const initialPairToken = params.get("pairToken") || "";
|
|
60
|
+
let didReloadForServiceWorker = false;
|
|
61
|
+
let lastViewportMode = isDesktopLayout();
|
|
62
|
+
|
|
63
|
+
boot().catch((error) => {
|
|
64
|
+
const message = error.message || String(error);
|
|
65
|
+
const hint = /Load failed|Failed to fetch|NetworkError|fetch/i.test(message)
|
|
66
|
+
? `<p class="muted">${escapeHtml(L("error.networkHint"))}</p>`
|
|
67
|
+
: "";
|
|
68
|
+
app.innerHTML = `
|
|
69
|
+
<main class="onboarding-shell">
|
|
70
|
+
<section class="onboarding-card">
|
|
71
|
+
<span class="eyebrow-pill">${escapeHtml(L("common.codex"))}</span>
|
|
72
|
+
<h1 class="hero-title">${escapeHtml(L("common.appName"))}</h1>
|
|
73
|
+
<p class="hero-copy">${escapeHtml(message)}</p>
|
|
74
|
+
${hint}
|
|
75
|
+
</section>
|
|
76
|
+
</main>
|
|
77
|
+
`;
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
async function boot() {
|
|
81
|
+
updateManifestHref(initialPairToken);
|
|
82
|
+
await registerServiceWorker();
|
|
83
|
+
navigator.serviceWorker?.addEventListener("message", handleServiceWorkerMessage);
|
|
84
|
+
window.addEventListener("resize", handleViewportChange, { passive: true });
|
|
85
|
+
|
|
86
|
+
await refreshSession();
|
|
87
|
+
|
|
88
|
+
if (!state.session?.authenticated && initialPairToken) {
|
|
89
|
+
try {
|
|
90
|
+
await pair({ token: initialPairToken });
|
|
91
|
+
} catch (error) {
|
|
92
|
+
state.pairError = error.message || String(error);
|
|
93
|
+
}
|
|
94
|
+
await refreshSession();
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
syncPairingTokenState(desiredBootstrapPairingToken());
|
|
98
|
+
|
|
99
|
+
const parsedInitialItem = parseItemRef(initialItem);
|
|
100
|
+
if (parsedInitialItem) {
|
|
101
|
+
state.currentItem = parsedInitialItem;
|
|
102
|
+
state.currentTab = tabForItemKind(parsedInitialItem.kind, state.currentTab);
|
|
103
|
+
state.detailOpen = true;
|
|
104
|
+
if (isFastPathItemRef(parsedInitialItem)) {
|
|
105
|
+
state.launchItemIntent = {
|
|
106
|
+
...parsedInitialItem,
|
|
107
|
+
status: "pending",
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
if (!state.session?.authenticated) {
|
|
113
|
+
renderPair();
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
await syncDetectedLocalePreference();
|
|
118
|
+
await refreshAuthenticatedState();
|
|
119
|
+
ensureCurrentSelection();
|
|
120
|
+
await renderShell();
|
|
121
|
+
|
|
122
|
+
setInterval(async () => {
|
|
123
|
+
if (!state.session?.authenticated) {
|
|
124
|
+
return;
|
|
125
|
+
}
|
|
126
|
+
await refreshAuthenticatedState();
|
|
127
|
+
if (!shouldDeferRenderForActiveReplyComposer()) {
|
|
128
|
+
await renderShell();
|
|
129
|
+
}
|
|
130
|
+
}, 3000);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
async function registerServiceWorker() {
|
|
134
|
+
if (!("serviceWorker" in navigator)) {
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
137
|
+
try {
|
|
138
|
+
state.serviceWorkerRegistration = await navigator.serviceWorker.register("/sw.js");
|
|
139
|
+
await state.serviceWorkerRegistration.update().catch(() => {});
|
|
140
|
+
navigator.serviceWorker?.addEventListener("controllerchange", () => {
|
|
141
|
+
if (didReloadForServiceWorker) {
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
didReloadForServiceWorker = true;
|
|
145
|
+
window.location.reload();
|
|
146
|
+
});
|
|
147
|
+
} catch {
|
|
148
|
+
state.serviceWorkerRegistration = null;
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function handleViewportChange() {
|
|
153
|
+
const nextViewportMode = isDesktopLayout();
|
|
154
|
+
if (nextViewportMode === lastViewportMode) {
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
157
|
+
lastViewportMode = nextViewportMode;
|
|
158
|
+
if (nextViewportMode) {
|
|
159
|
+
state.detailOpen = false;
|
|
160
|
+
ensureCurrentSelection();
|
|
161
|
+
if (state.currentTab !== "settings") {
|
|
162
|
+
syncCurrentItemUrl(state.currentItem);
|
|
163
|
+
}
|
|
164
|
+
} else if (!parseItemRef(new URLSearchParams(window.location.search).get("item"))) {
|
|
165
|
+
state.detailOpen = false;
|
|
166
|
+
syncCurrentItemUrl(null);
|
|
167
|
+
}
|
|
168
|
+
renderCurrentSurface();
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
async function refreshAuthenticatedState() {
|
|
172
|
+
await refreshInbox();
|
|
173
|
+
await refreshTimeline();
|
|
174
|
+
await refreshDevices();
|
|
175
|
+
await refreshPushStatus();
|
|
176
|
+
ensureCurrentSelection();
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
async function refreshSession() {
|
|
180
|
+
state.session = await apiGet("/api/session");
|
|
181
|
+
syncPairingTokenState(desiredBootstrapPairingToken());
|
|
182
|
+
applyResolvedLocale();
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
async function syncDetectedLocalePreference() {
|
|
186
|
+
if (!state.session?.authenticated || !state.session?.deviceId || !state.detectedLocale) {
|
|
187
|
+
return;
|
|
188
|
+
}
|
|
189
|
+
if (normalizeLocale(state.session?.deviceDetectedLocale || "") === state.detectedLocale) {
|
|
190
|
+
return;
|
|
191
|
+
}
|
|
192
|
+
const result = await apiPost("/api/session/locale", {
|
|
193
|
+
detectedLocale: state.detectedLocale,
|
|
194
|
+
});
|
|
195
|
+
state.session = {
|
|
196
|
+
...state.session,
|
|
197
|
+
...result,
|
|
198
|
+
};
|
|
199
|
+
applyResolvedLocale();
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
async function setLocaleOverride(nextLocale) {
|
|
203
|
+
const result = await apiPost("/api/session/locale", {
|
|
204
|
+
detectedLocale: state.detectedLocale,
|
|
205
|
+
overrideLocale: nextLocale || null,
|
|
206
|
+
});
|
|
207
|
+
state.session = {
|
|
208
|
+
...state.session,
|
|
209
|
+
...result,
|
|
210
|
+
};
|
|
211
|
+
applyResolvedLocale();
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function applyResolvedLocale() {
|
|
215
|
+
state.defaultLocale = normalizeLocale(state.session?.defaultLocale || "") || DEFAULT_LOCALE;
|
|
216
|
+
state.supportedLocales = Array.isArray(state.session?.supportedLocales)
|
|
217
|
+
? state.session.supportedLocales.map((value) => normalizeLocale(value)).filter(Boolean)
|
|
218
|
+
: [...SUPPORTED_LOCALES];
|
|
219
|
+
state.appVersion = normalizeClientText(state.session?.appVersion || "");
|
|
220
|
+
const resolved = resolveLocalePreference({
|
|
221
|
+
overrideLocale: state.session?.deviceOverrideLocale,
|
|
222
|
+
detectedLocale: state.session?.deviceDetectedLocale || state.detectedLocale,
|
|
223
|
+
defaultLocale: state.defaultLocale,
|
|
224
|
+
fallbackLocale: DEFAULT_LOCALE,
|
|
225
|
+
});
|
|
226
|
+
state.locale = normalizeLocale(state.session?.locale || "") || resolved.locale;
|
|
227
|
+
state.localeSource = state.session?.localeSource || resolved.source;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
function L(key, vars = {}) {
|
|
231
|
+
return t(state.locale || DEFAULT_LOCALE, key, vars);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
function detectBrowserLocale() {
|
|
235
|
+
if (Array.isArray(navigator.languages) && navigator.languages.length > 0) {
|
|
236
|
+
for (const value of navigator.languages) {
|
|
237
|
+
const normalized = normalizeLocale(value);
|
|
238
|
+
if (normalized) {
|
|
239
|
+
return normalized;
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
return normalizeLocale(navigator.language || "") || DEFAULT_LOCALE;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
async function refreshPushStatus() {
|
|
247
|
+
const client = await getClientPushState();
|
|
248
|
+
if (!state.session?.authenticated) {
|
|
249
|
+
state.pushStatus = {
|
|
250
|
+
...client,
|
|
251
|
+
enabled: false,
|
|
252
|
+
subscribed: false,
|
|
253
|
+
serverSubscribed: false,
|
|
254
|
+
lastSuccessfulDeliveryAtMs: 0,
|
|
255
|
+
vapidPublicKey: "",
|
|
256
|
+
};
|
|
257
|
+
return;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
try {
|
|
261
|
+
const server = await apiGet("/api/push/status");
|
|
262
|
+
state.pushStatus = {
|
|
263
|
+
...server,
|
|
264
|
+
...client,
|
|
265
|
+
serverSubscribed: Boolean(server.subscribed),
|
|
266
|
+
subscribed: Boolean(server.subscribed || client.clientSubscribed),
|
|
267
|
+
};
|
|
268
|
+
} catch (error) {
|
|
269
|
+
state.pushStatus = {
|
|
270
|
+
...client,
|
|
271
|
+
enabled: false,
|
|
272
|
+
subscribed: false,
|
|
273
|
+
serverSubscribed: false,
|
|
274
|
+
lastSuccessfulDeliveryAtMs: 0,
|
|
275
|
+
vapidPublicKey: "",
|
|
276
|
+
error: error.message || String(error),
|
|
277
|
+
};
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
async function getClientPushState() {
|
|
282
|
+
const registration = state.serviceWorkerRegistration || (await navigator.serviceWorker?.ready.catch(() => null));
|
|
283
|
+
if (registration) {
|
|
284
|
+
state.serviceWorkerRegistration = registration;
|
|
285
|
+
}
|
|
286
|
+
const subscription =
|
|
287
|
+
registration && "pushManager" in registration
|
|
288
|
+
? await registration.pushManager.getSubscription().catch(() => null)
|
|
289
|
+
: null;
|
|
290
|
+
return {
|
|
291
|
+
secureContext: window.isSecureContext === true,
|
|
292
|
+
standalone: isStandaloneMode(),
|
|
293
|
+
notificationPermission: "Notification" in window ? Notification.permission : "unsupported",
|
|
294
|
+
supportsPush:
|
|
295
|
+
"serviceWorker" in navigator &&
|
|
296
|
+
"PushManager" in window &&
|
|
297
|
+
"Notification" in window,
|
|
298
|
+
clientSubscribed: Boolean(subscription),
|
|
299
|
+
};
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
async function refreshInbox() {
|
|
303
|
+
state.inbox = await apiGet("/api/inbox");
|
|
304
|
+
syncCompletedThreadFilter();
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
async function refreshTimeline() {
|
|
308
|
+
state.timeline = await apiGet("/api/timeline");
|
|
309
|
+
syncTimelineThreadFilter();
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
async function refreshDevices() {
|
|
313
|
+
if (!state.session?.authenticated) {
|
|
314
|
+
state.devices = [];
|
|
315
|
+
state.deviceError = "";
|
|
316
|
+
return;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
try {
|
|
320
|
+
const payload = await apiGet("/api/devices");
|
|
321
|
+
state.devices = Array.isArray(payload?.devices) ? payload.devices : [];
|
|
322
|
+
state.deviceError = "";
|
|
323
|
+
} catch (error) {
|
|
324
|
+
state.deviceError = error.message || String(error);
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
function ensureCurrentSelection() {
|
|
329
|
+
if ((!state.inbox && !state.timeline) || state.currentTab === "settings") {
|
|
330
|
+
return;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
const allEntries = allSelectableEntries();
|
|
334
|
+
const preferredEntries = listEntriesForCurrentTab();
|
|
335
|
+
const previousItem = state.currentItem ? { ...state.currentItem } : null;
|
|
336
|
+
const hasCurrent = state.currentItem
|
|
337
|
+
? allEntries.some((entry) => isSameItemRef(state.currentItem, entry.item))
|
|
338
|
+
: false;
|
|
339
|
+
const hasCurrentInPreferred = state.currentItem
|
|
340
|
+
? preferredEntries.some((entry) => isSameItemRef(state.currentItem, entry.item))
|
|
341
|
+
: false;
|
|
342
|
+
|
|
343
|
+
if (!hasCurrent) {
|
|
344
|
+
if (!shouldPreserveCurrentItem()) {
|
|
345
|
+
clearChoiceLocalDraftForItem(previousItem);
|
|
346
|
+
state.currentItem = null;
|
|
347
|
+
state.currentDetail = null;
|
|
348
|
+
clearDetailOverride();
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
if (isDesktopLayout()) {
|
|
353
|
+
const fallback = preferredEntries[0] || allEntries[0] || null;
|
|
354
|
+
if (!state.currentItem && fallback) {
|
|
355
|
+
state.currentItem = toItemRef(fallback.item);
|
|
356
|
+
} else if (state.currentItem && !hasCurrentInPreferred && fallback && !shouldPreserveCurrentItem()) {
|
|
357
|
+
state.currentItem = toItemRef(fallback.item);
|
|
358
|
+
state.currentDetail = null;
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
if (state.detailOpen && !state.currentItem) {
|
|
363
|
+
state.detailOpen = false;
|
|
364
|
+
syncCurrentItemUrl(null);
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
function allInboxEntries() {
|
|
369
|
+
if (!state.inbox) {
|
|
370
|
+
return [];
|
|
371
|
+
}
|
|
372
|
+
return [
|
|
373
|
+
...state.inbox.pending.map((item) => ({ item, status: "pending" })),
|
|
374
|
+
...state.inbox.completed.map((item) => ({ item, status: "completed" })),
|
|
375
|
+
];
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
function allTimelineEntries() {
|
|
379
|
+
if (!state.timeline?.entries) {
|
|
380
|
+
return [];
|
|
381
|
+
}
|
|
382
|
+
return state.timeline.entries.map((item) => ({ item, status: "timeline" }));
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
function allSelectableEntries() {
|
|
386
|
+
return [...allInboxEntries(), ...allTimelineEntries()];
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
function listEntriesForTab(tab) {
|
|
390
|
+
if (!state.inbox) {
|
|
391
|
+
if (tab !== "timeline") {
|
|
392
|
+
return [];
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
if (tab === "pending") {
|
|
396
|
+
return state.inbox.pending.map((item) => ({ item, status: "pending" }));
|
|
397
|
+
}
|
|
398
|
+
if (tab === "timeline") {
|
|
399
|
+
return filteredTimelineEntries().map((item) => ({ item, status: "timeline" }));
|
|
400
|
+
}
|
|
401
|
+
if (tab === "completed") {
|
|
402
|
+
return filteredCompletedEntries().map((item) => ({ item, status: "completed" }));
|
|
403
|
+
}
|
|
404
|
+
return [];
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
function listEntriesForCurrentTab() {
|
|
408
|
+
return listEntriesForTab(state.currentTab);
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
function filteredTimelineEntries() {
|
|
412
|
+
const entries = Array.isArray(state.timeline?.entries) ? state.timeline.entries : [];
|
|
413
|
+
if (!entries.length) {
|
|
414
|
+
return [];
|
|
415
|
+
}
|
|
416
|
+
if (!state.timelineThreadFilter || state.timelineThreadFilter === "all") {
|
|
417
|
+
return entries;
|
|
418
|
+
}
|
|
419
|
+
return entries.filter((entry) => entry.threadId === state.timelineThreadFilter);
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
function filteredCompletedEntries() {
|
|
423
|
+
const entries = Array.isArray(state.inbox?.completed) ? state.inbox.completed : [];
|
|
424
|
+
if (!entries.length) {
|
|
425
|
+
return [];
|
|
426
|
+
}
|
|
427
|
+
if (!state.completedThreadFilter || state.completedThreadFilter === "all") {
|
|
428
|
+
return entries;
|
|
429
|
+
}
|
|
430
|
+
return entries.filter((entry) => entry.threadId === state.completedThreadFilter);
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
function syncTimelineThreadFilter() {
|
|
434
|
+
const threads = Array.isArray(state.timeline?.threads) ? state.timeline.threads : [];
|
|
435
|
+
if (!state.timelineThreadFilter || state.timelineThreadFilter === "all") {
|
|
436
|
+
state.timelineThreadFilter = "all";
|
|
437
|
+
return;
|
|
438
|
+
}
|
|
439
|
+
if (!threads.some((thread) => thread.id === state.timelineThreadFilter)) {
|
|
440
|
+
state.timelineThreadFilter = "all";
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
function completedThreads() {
|
|
445
|
+
const items = Array.isArray(state.inbox?.completed) ? state.inbox.completed : [];
|
|
446
|
+
if (!items.length) {
|
|
447
|
+
return [];
|
|
448
|
+
}
|
|
449
|
+
const byThread = new Map();
|
|
450
|
+
for (const item of items) {
|
|
451
|
+
const threadId = normalizeClientText(item.threadId || "");
|
|
452
|
+
if (!threadId) {
|
|
453
|
+
continue;
|
|
454
|
+
}
|
|
455
|
+
const latestAtMs = Number(item.createdAtMs || 0);
|
|
456
|
+
const label = resolvedThreadLabel(threadId, item.threadLabel || "");
|
|
457
|
+
const previous = byThread.get(threadId);
|
|
458
|
+
if (!previous || latestAtMs >= previous.latestAtMs) {
|
|
459
|
+
byThread.set(threadId, {
|
|
460
|
+
id: threadId,
|
|
461
|
+
label,
|
|
462
|
+
latestAtMs,
|
|
463
|
+
});
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
return [...byThread.values()].sort((left, right) => right.latestAtMs - left.latestAtMs);
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
function syncCompletedThreadFilter() {
|
|
470
|
+
const threads = completedThreads();
|
|
471
|
+
if (!state.completedThreadFilter || state.completedThreadFilter === "all") {
|
|
472
|
+
state.completedThreadFilter = "all";
|
|
473
|
+
return;
|
|
474
|
+
}
|
|
475
|
+
if (!threads.some((thread) => thread.id === state.completedThreadFilter)) {
|
|
476
|
+
state.completedThreadFilter = "all";
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
function renderPair() {
|
|
481
|
+
app.innerHTML = `
|
|
482
|
+
<main class="onboarding-shell">
|
|
483
|
+
<section class="onboarding-card">
|
|
484
|
+
<span class="eyebrow-pill">${escapeHtml(L("common.codex"))}</span>
|
|
485
|
+
<h1 class="hero-title">${escapeHtml(L("common.appName"))}</h1>
|
|
486
|
+
<p class="hero-copy">${escapeHtml(L("pair.copy"))}</p>
|
|
487
|
+
${state.pairNotice ? `<p class="inline-alert inline-alert--success">${escapeHtml(state.pairNotice)}</p>` : ""}
|
|
488
|
+
${state.pairError ? `<p class="inline-alert inline-alert--danger">${escapeHtml(state.pairError)}</p>` : ""}
|
|
489
|
+
<form id="pair-form" class="pair-form">
|
|
490
|
+
<label class="field">
|
|
491
|
+
<span class="field-label">${escapeHtml(L("pair.codeLabel"))}</span>
|
|
492
|
+
<input name="code" placeholder="${escapeHtml(L("pair.codePlaceholder"))}" autocomplete="one-time-code">
|
|
493
|
+
</label>
|
|
494
|
+
<button class="primary primary--wide" type="submit">${escapeHtml(L("pair.connect"))}</button>
|
|
495
|
+
</form>
|
|
496
|
+
<section class="helper-card">
|
|
497
|
+
<div class="helper-copy">
|
|
498
|
+
<strong>${escapeHtml(L("pair.helperTitle"))}</strong>
|
|
499
|
+
<p class="muted">${escapeHtml(L("pair.helperCopy"))}</p>
|
|
500
|
+
</div>
|
|
501
|
+
<div class="actions">
|
|
502
|
+
<button class="secondary secondary--wide" type="button" data-install-guide-open>${escapeHtml(L("common.addToHomeScreen"))}</button>
|
|
503
|
+
</div>
|
|
504
|
+
</section>
|
|
505
|
+
</section>
|
|
506
|
+
${renderInstallGuideModal()}
|
|
507
|
+
</main>
|
|
508
|
+
`;
|
|
509
|
+
|
|
510
|
+
document.querySelector("#pair-form").addEventListener("submit", async (event) => {
|
|
511
|
+
event.preventDefault();
|
|
512
|
+
const form = new FormData(event.currentTarget);
|
|
513
|
+
try {
|
|
514
|
+
await pair({ code: String(form.get("code") || "") });
|
|
515
|
+
state.pairError = "";
|
|
516
|
+
state.pairNotice = "";
|
|
517
|
+
await refreshSession();
|
|
518
|
+
await refreshAuthenticatedState();
|
|
519
|
+
await renderShell();
|
|
520
|
+
} catch (error) {
|
|
521
|
+
state.pairError = error.message || String(error);
|
|
522
|
+
renderPair();
|
|
523
|
+
}
|
|
524
|
+
});
|
|
525
|
+
|
|
526
|
+
bindSharedUi(renderPair);
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
async function pair(payload) {
|
|
530
|
+
const result = await apiPost("/api/session/pair", payload);
|
|
531
|
+
syncPairingTokenState("");
|
|
532
|
+
return result;
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
async function logout({ revokeCurrentDeviceTrust = false } = {}) {
|
|
536
|
+
await apiPost("/api/session/logout", { revokeCurrentDeviceTrust });
|
|
537
|
+
resetAuthenticatedState();
|
|
538
|
+
state.pairNotice = revokeCurrentDeviceTrust
|
|
539
|
+
? L("notice.loggedOutDeviceRemoved")
|
|
540
|
+
: L("notice.loggedOutKeepTrusted");
|
|
541
|
+
syncPairingTokenState("");
|
|
542
|
+
renderPair();
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
function resetAuthenticatedState() {
|
|
546
|
+
state.session = null;
|
|
547
|
+
state.inbox = null;
|
|
548
|
+
state.timeline = null;
|
|
549
|
+
state.devices = [];
|
|
550
|
+
state.currentItem = null;
|
|
551
|
+
state.currentDetail = null;
|
|
552
|
+
state.currentDetailLoading = false;
|
|
553
|
+
state.detailLoadingItem = null;
|
|
554
|
+
state.detailOpen = false;
|
|
555
|
+
state.choiceLocalDrafts = {};
|
|
556
|
+
state.completionReplyDrafts = {};
|
|
557
|
+
state.settingsSubpage = "";
|
|
558
|
+
state.settingsScrollState = null;
|
|
559
|
+
state.listScrollState = null;
|
|
560
|
+
clearPinnedDetailState();
|
|
561
|
+
state.pushStatus = null;
|
|
562
|
+
state.pushNotice = "";
|
|
563
|
+
state.pushError = "";
|
|
564
|
+
state.deviceNotice = "";
|
|
565
|
+
state.deviceError = "";
|
|
566
|
+
state.logoutConfirmOpen = false;
|
|
567
|
+
state.pairError = "";
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
async function revokeTrustedDevice(deviceId) {
|
|
571
|
+
if (!deviceId) {
|
|
572
|
+
return;
|
|
573
|
+
}
|
|
574
|
+
const result = await apiPost(`/api/devices/${encodeURIComponent(deviceId)}/revoke`, {});
|
|
575
|
+
if (result?.currentDeviceRevoked) {
|
|
576
|
+
resetAuthenticatedState();
|
|
577
|
+
state.pairNotice = L("notice.loggedOutDeviceRemoved");
|
|
578
|
+
syncPairingTokenState("");
|
|
579
|
+
renderPair();
|
|
580
|
+
return;
|
|
581
|
+
}
|
|
582
|
+
state.deviceNotice = L("notice.deviceRevoked");
|
|
583
|
+
state.deviceError = "";
|
|
584
|
+
await refreshAuthenticatedState();
|
|
585
|
+
await renderShell();
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
async function renderShell() {
|
|
589
|
+
const desktop = isDesktopLayout();
|
|
590
|
+
const shouldShowDetail = state.currentTab !== "settings" && state.currentItem && (desktop || state.detailOpen);
|
|
591
|
+
let detail = null;
|
|
592
|
+
if (shouldShowDetail) {
|
|
593
|
+
detail = renderableCurrentDetail();
|
|
594
|
+
if (!detail) {
|
|
595
|
+
queueCurrentDetailLoad();
|
|
596
|
+
}
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
const shellClassName = [
|
|
600
|
+
"app-shell",
|
|
601
|
+
desktop ? "app-shell--desktop" : "app-shell--mobile",
|
|
602
|
+
!desktop && (state.detailOpen || isSettingsSubpageOpen()) ? "app-shell--detail" : "",
|
|
603
|
+
]
|
|
604
|
+
.filter(Boolean)
|
|
605
|
+
.join(" ");
|
|
606
|
+
|
|
607
|
+
app.innerHTML = `
|
|
608
|
+
<div class="${shellClassName}">
|
|
609
|
+
${desktop ? renderDesktopHeader(detail) : renderMobileTopBar(detail)}
|
|
610
|
+
${renderTopBanner()}
|
|
611
|
+
<main class="app-main">
|
|
612
|
+
${desktop ? renderDesktopWorkspace(detail) : renderMobileWorkspace(detail)}
|
|
613
|
+
</main>
|
|
614
|
+
${desktop || state.detailOpen || isSettingsSubpageOpen() ? "" : renderBottomTabs()}
|
|
615
|
+
${renderInstallGuideModal()}
|
|
616
|
+
${renderLogoutConfirmModal()}
|
|
617
|
+
</div>
|
|
618
|
+
`;
|
|
619
|
+
|
|
620
|
+
bindShellInteractions();
|
|
621
|
+
applyPendingDetailScrollReset();
|
|
622
|
+
applyPendingListScrollRestore();
|
|
623
|
+
applyPendingSettingsSubpageScrollReset();
|
|
624
|
+
applyPendingSettingsScrollRestore();
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
function applyPendingDetailScrollReset() {
|
|
628
|
+
if (!state.pendingDetailScrollReset || isDesktopLayout() || !state.detailOpen) {
|
|
629
|
+
return;
|
|
630
|
+
}
|
|
631
|
+
state.pendingDetailScrollReset = false;
|
|
632
|
+
requestAnimationFrame(() => {
|
|
633
|
+
window.scrollTo({ top: 0, left: 0, behavior: "auto" });
|
|
634
|
+
const detailScroll = document.querySelector(".mobile-detail-scroll");
|
|
635
|
+
if (detailScroll) {
|
|
636
|
+
detailScroll.scrollTop = 0;
|
|
637
|
+
}
|
|
638
|
+
});
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
function applyPendingListScrollRestore() {
|
|
642
|
+
if (!state.pendingListScrollRestore || isDesktopLayout() || state.detailOpen || !state.listScrollState) {
|
|
643
|
+
return;
|
|
644
|
+
}
|
|
645
|
+
state.pendingListScrollRestore = false;
|
|
646
|
+
const targetY = Number.isFinite(state.listScrollState.y) ? state.listScrollState.y : 0;
|
|
647
|
+
requestAnimationFrame(() => {
|
|
648
|
+
window.scrollTo({ top: targetY, left: 0, behavior: "auto" });
|
|
649
|
+
});
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
function applyPendingSettingsSubpageScrollReset() {
|
|
653
|
+
if (!state.pendingSettingsSubpageScrollReset || isDesktopLayout() || !isSettingsSubpageOpen()) {
|
|
654
|
+
return;
|
|
655
|
+
}
|
|
656
|
+
state.pendingSettingsSubpageScrollReset = false;
|
|
657
|
+
requestAnimationFrame(() => {
|
|
658
|
+
window.scrollTo({ top: 0, left: 0, behavior: "auto" });
|
|
659
|
+
});
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
function applyPendingSettingsScrollRestore() {
|
|
663
|
+
if (!state.pendingSettingsScrollRestore || isDesktopLayout() || isSettingsSubpageOpen() || !state.settingsScrollState) {
|
|
664
|
+
return;
|
|
665
|
+
}
|
|
666
|
+
state.pendingSettingsScrollRestore = false;
|
|
667
|
+
const targetY = Number.isFinite(state.settingsScrollState.y) ? state.settingsScrollState.y : 0;
|
|
668
|
+
requestAnimationFrame(() => {
|
|
669
|
+
window.scrollTo({ top: targetY, left: 0, behavior: "auto" });
|
|
670
|
+
});
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
function currentViewportScrollY() {
|
|
674
|
+
return window.scrollY || window.pageYOffset || document.documentElement?.scrollTop || 0;
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
function shouldDeferRenderForActiveReplyComposer() {
|
|
678
|
+
const activeElement = document.activeElement;
|
|
679
|
+
if (!(activeElement instanceof HTMLTextAreaElement)) {
|
|
680
|
+
return false;
|
|
681
|
+
}
|
|
682
|
+
if (!activeElement.matches("[data-completion-reply-textarea]")) {
|
|
683
|
+
return false;
|
|
684
|
+
}
|
|
685
|
+
return normalizeClientText(activeElement.dataset.replyToken) === normalizeClientText(state.currentItem?.token);
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
function normalizeChoiceAnswersMap(value) {
|
|
689
|
+
if (!value || typeof value !== "object") {
|
|
690
|
+
return {};
|
|
691
|
+
}
|
|
692
|
+
const output = {};
|
|
693
|
+
for (const [key, rawValue] of Object.entries(value)) {
|
|
694
|
+
const normalizedKey = String(key || "").trim();
|
|
695
|
+
const normalizedValue = String(rawValue ?? "").trim();
|
|
696
|
+
if (!normalizedKey || !normalizedValue) {
|
|
697
|
+
continue;
|
|
698
|
+
}
|
|
699
|
+
output[normalizedKey] = normalizedValue;
|
|
700
|
+
}
|
|
701
|
+
return output;
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
function getChoiceLocalDraft(token) {
|
|
705
|
+
if (!token) {
|
|
706
|
+
return {};
|
|
707
|
+
}
|
|
708
|
+
return normalizeChoiceAnswersMap(state.choiceLocalDrafts?.[token]);
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
function mergeChoiceLocalDraft(token, answers) {
|
|
712
|
+
if (!token) {
|
|
713
|
+
return;
|
|
714
|
+
}
|
|
715
|
+
const nextDraft = {
|
|
716
|
+
...getChoiceLocalDraft(token),
|
|
717
|
+
...normalizeChoiceAnswersMap(answers),
|
|
718
|
+
};
|
|
719
|
+
if (Object.keys(nextDraft).length === 0) {
|
|
720
|
+
clearChoiceLocalDraft(token);
|
|
721
|
+
return;
|
|
722
|
+
}
|
|
723
|
+
state.choiceLocalDrafts[token] = nextDraft;
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
function clearChoiceLocalDraft(token) {
|
|
727
|
+
if (!token || !state.choiceLocalDrafts?.[token]) {
|
|
728
|
+
return;
|
|
729
|
+
}
|
|
730
|
+
delete state.choiceLocalDrafts[token];
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
function clearChoiceLocalDraftForItem(itemRef) {
|
|
734
|
+
if (itemRef?.kind !== "choice") {
|
|
735
|
+
return;
|
|
736
|
+
}
|
|
737
|
+
clearChoiceLocalDraft(itemRef.token);
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
function getEffectiveChoiceDraftAnswers(detail) {
|
|
741
|
+
return {
|
|
742
|
+
...normalizeChoiceAnswersMap(detail?.draftAnswers),
|
|
743
|
+
...getChoiceLocalDraft(detail?.token),
|
|
744
|
+
};
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
function normalizeReplyMode(value) {
|
|
748
|
+
return normalizeClientText(value).toLowerCase() === "plan" ? "plan" : "default";
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
function getCompletionReplyDraft(token) {
|
|
752
|
+
if (!token) {
|
|
753
|
+
return {
|
|
754
|
+
text: "",
|
|
755
|
+
sentText: "",
|
|
756
|
+
mode: "default",
|
|
757
|
+
notice: "",
|
|
758
|
+
error: "",
|
|
759
|
+
warning: null,
|
|
760
|
+
confirmOverride: false,
|
|
761
|
+
collapsedAfterSend: false,
|
|
762
|
+
sending: false,
|
|
763
|
+
};
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
const draft = state.completionReplyDrafts?.[token] || {};
|
|
767
|
+
return {
|
|
768
|
+
text: String(draft.text ?? ""),
|
|
769
|
+
sentText: normalizeClientText(draft.sentText ?? ""),
|
|
770
|
+
mode: normalizeReplyMode(draft.mode),
|
|
771
|
+
notice: normalizeClientText(draft.notice),
|
|
772
|
+
error: normalizeClientText(draft.error),
|
|
773
|
+
warning: normalizeCompletionReplyWarning(draft.warning),
|
|
774
|
+
confirmOverride: draft.confirmOverride === true,
|
|
775
|
+
collapsedAfterSend: draft.collapsedAfterSend === true,
|
|
776
|
+
sending: draft.sending === true,
|
|
777
|
+
};
|
|
778
|
+
}
|
|
779
|
+
|
|
780
|
+
function normalizeCompletionReplyWarning(value) {
|
|
781
|
+
if (!value || typeof value !== "object") {
|
|
782
|
+
return null;
|
|
783
|
+
}
|
|
784
|
+
const createdAtMs = Number(value.createdAtMs) || 0;
|
|
785
|
+
const summary = normalizeClientText(value.summary || "");
|
|
786
|
+
const kind = normalizeClientText(value.kind || "");
|
|
787
|
+
if (!createdAtMs && !summary && !kind) {
|
|
788
|
+
return null;
|
|
789
|
+
}
|
|
790
|
+
return {
|
|
791
|
+
createdAtMs,
|
|
792
|
+
summary,
|
|
793
|
+
kind,
|
|
794
|
+
};
|
|
795
|
+
}
|
|
796
|
+
|
|
797
|
+
function setCompletionReplyDraft(token, partialDraft) {
|
|
798
|
+
if (!token) {
|
|
799
|
+
return;
|
|
800
|
+
}
|
|
801
|
+
const nextDraft = {
|
|
802
|
+
...getCompletionReplyDraft(token),
|
|
803
|
+
...(partialDraft || {}),
|
|
804
|
+
};
|
|
805
|
+
state.completionReplyDrafts[token] = {
|
|
806
|
+
text: String(nextDraft.text ?? ""),
|
|
807
|
+
sentText: normalizeClientText(nextDraft.sentText ?? ""),
|
|
808
|
+
mode: normalizeReplyMode(nextDraft.mode),
|
|
809
|
+
notice: normalizeClientText(nextDraft.notice),
|
|
810
|
+
error: normalizeClientText(nextDraft.error),
|
|
811
|
+
warning: normalizeCompletionReplyWarning(nextDraft.warning),
|
|
812
|
+
confirmOverride: nextDraft.confirmOverride === true,
|
|
813
|
+
collapsedAfterSend: nextDraft.collapsedAfterSend === true,
|
|
814
|
+
sending: nextDraft.sending === true,
|
|
815
|
+
};
|
|
816
|
+
}
|
|
817
|
+
|
|
818
|
+
function clearCompletionReplyDraft(token) {
|
|
819
|
+
if (!token || !state.completionReplyDrafts?.[token]) {
|
|
820
|
+
return;
|
|
821
|
+
}
|
|
822
|
+
delete state.completionReplyDrafts[token];
|
|
823
|
+
}
|
|
824
|
+
|
|
825
|
+
function syncCompletionReplyComposerLiveState(replyForm, draft) {
|
|
826
|
+
if (!replyForm) {
|
|
827
|
+
return;
|
|
828
|
+
}
|
|
829
|
+
const normalizedDraft = draft || {
|
|
830
|
+
text: "",
|
|
831
|
+
confirmOverride: false,
|
|
832
|
+
sending: false,
|
|
833
|
+
};
|
|
834
|
+
const submitButton = replyForm.querySelector('button[type="submit"]');
|
|
835
|
+
if (submitButton) {
|
|
836
|
+
submitButton.disabled = normalizedDraft.sending === true || !normalizeClientText(normalizedDraft.text);
|
|
837
|
+
if (!normalizedDraft.sending) {
|
|
838
|
+
submitButton.textContent = L(normalizedDraft.confirmOverride ? "reply.sendConfirm" : "reply.send");
|
|
839
|
+
}
|
|
840
|
+
}
|
|
841
|
+
|
|
842
|
+
const composer = replyForm.closest(".reply-composer");
|
|
843
|
+
if (!composer) {
|
|
844
|
+
return;
|
|
845
|
+
}
|
|
846
|
+
for (const alert of composer.querySelectorAll(".inline-alert--success, .inline-alert--danger, .inline-alert--warning")) {
|
|
847
|
+
alert.remove();
|
|
848
|
+
}
|
|
849
|
+
}
|
|
850
|
+
|
|
851
|
+
function renderDesktopHeader(detail) {
|
|
852
|
+
return `
|
|
853
|
+
<header class="app-header">
|
|
854
|
+
<div class="brand-lockup">
|
|
855
|
+
<span class="eyebrow-pill">${escapeHtml(L("common.codex"))}</span>
|
|
856
|
+
<div class="brand-copy">
|
|
857
|
+
<h1 class="brand-title">${escapeHtml(L("common.appName"))}</h1>
|
|
858
|
+
<p class="brand-subtitle">${escapeHtml(subtitleForCurrentView(detail))}</p>
|
|
859
|
+
</div>
|
|
860
|
+
</div>
|
|
861
|
+
${renderDesktopTabs()}
|
|
862
|
+
</header>
|
|
863
|
+
`;
|
|
864
|
+
}
|
|
865
|
+
|
|
866
|
+
function renderMobileTopBar(detail) {
|
|
867
|
+
if (isSettingsSubpageOpen()) {
|
|
868
|
+
const page = settingsPageMeta(state.settingsSubpage);
|
|
869
|
+
return `
|
|
870
|
+
<header class="mobile-topbar mobile-topbar--detail">
|
|
871
|
+
<button class="mobile-topbar__back" type="button" data-settings-back>
|
|
872
|
+
<span class="mobile-topbar__back-icon" aria-hidden="true">${renderIcon("back")}</span>
|
|
873
|
+
<span class="mobile-topbar__back-label">${escapeHtml(L("common.back"))}</span>
|
|
874
|
+
</button>
|
|
875
|
+
<div class="mobile-topbar__heading mobile-topbar__heading--detail">
|
|
876
|
+
<span class="mobile-topbar__eyebrow">${escapeHtml(L("common.settings"))}</span>
|
|
877
|
+
<h1 class="mobile-topbar__title mobile-topbar__title--detail">${escapeHtml(page.title)}</h1>
|
|
878
|
+
</div>
|
|
879
|
+
</header>
|
|
880
|
+
`;
|
|
881
|
+
}
|
|
882
|
+
|
|
883
|
+
if (state.detailOpen && (detail || state.currentItem)) {
|
|
884
|
+
const loadingDetail = detail || buildDetailLoadingSnapshot();
|
|
885
|
+
const detailKind = kindMeta(loadingDetail.kind);
|
|
886
|
+
return `
|
|
887
|
+
<header class="mobile-topbar mobile-topbar--detail">
|
|
888
|
+
<button class="mobile-topbar__back" type="button" data-back-to-list>
|
|
889
|
+
<span class="mobile-topbar__back-icon" aria-hidden="true">${renderIcon("back")}</span>
|
|
890
|
+
<span class="mobile-topbar__back-label">${escapeHtml(L("common.back"))}</span>
|
|
891
|
+
</button>
|
|
892
|
+
<div class="mobile-topbar__heading mobile-topbar__heading--detail">
|
|
893
|
+
<span class="mobile-topbar__eyebrow mobile-topbar__eyebrow--kind">
|
|
894
|
+
<span class="mobile-topbar__eyebrow-icon" aria-hidden="true">${renderIcon(detailKind.icon)}</span>
|
|
895
|
+
<span>${escapeHtml(detailKind.label)}</span>
|
|
896
|
+
</span>
|
|
897
|
+
<h1 class="mobile-topbar__title mobile-topbar__title--detail">${escapeHtml(detailDisplayTitle(loadingDetail))}</h1>
|
|
898
|
+
</div>
|
|
899
|
+
</header>
|
|
900
|
+
`;
|
|
901
|
+
}
|
|
902
|
+
|
|
903
|
+
const meta = tabMeta(state.currentTab);
|
|
904
|
+
return `
|
|
905
|
+
<header class="mobile-topbar">
|
|
906
|
+
<div class="mobile-topbar__heading">
|
|
907
|
+
<span class="eyebrow-pill eyebrow-pill--quiet">${escapeHtml(L("common.appName"))}</span>
|
|
908
|
+
<h1 class="mobile-topbar__title">${escapeHtml(meta.title)}</h1>
|
|
909
|
+
</div>
|
|
910
|
+
</header>
|
|
911
|
+
`;
|
|
912
|
+
}
|
|
913
|
+
|
|
914
|
+
function renderableCurrentDetail(itemRef = state.currentItem) {
|
|
915
|
+
if (!itemRef) {
|
|
916
|
+
return null;
|
|
917
|
+
}
|
|
918
|
+
if (hasDetailOverride(itemRef)) {
|
|
919
|
+
return state.detailOverride.detail;
|
|
920
|
+
}
|
|
921
|
+
if (state.currentDetail && isSameItemRef(state.currentDetail, itemRef)) {
|
|
922
|
+
return state.currentDetail;
|
|
923
|
+
}
|
|
924
|
+
return null;
|
|
925
|
+
}
|
|
926
|
+
|
|
927
|
+
function selectedEntryForItem(itemRef = state.currentItem) {
|
|
928
|
+
if (!itemRef) {
|
|
929
|
+
return null;
|
|
930
|
+
}
|
|
931
|
+
return allSelectableEntries().find((entry) => isSameItemRef(entry.item, itemRef)) || null;
|
|
932
|
+
}
|
|
933
|
+
|
|
934
|
+
function buildDetailLoadingSnapshot(itemRef = state.currentItem) {
|
|
935
|
+
if (!itemRef) {
|
|
936
|
+
return null;
|
|
937
|
+
}
|
|
938
|
+
const entry = selectedEntryForItem(itemRef);
|
|
939
|
+
const item = entry?.item || {};
|
|
940
|
+
return {
|
|
941
|
+
kind: itemRef.kind,
|
|
942
|
+
token: itemRef.token,
|
|
943
|
+
title: item.title || kindMeta(itemRef.kind).label,
|
|
944
|
+
threadLabel: item.threadLabel || "",
|
|
945
|
+
createdAtMs: Number(item.createdAtMs) || 0,
|
|
946
|
+
readOnly:
|
|
947
|
+
entry?.status === "completed" ||
|
|
948
|
+
TIMELINE_MESSAGE_KINDS.has(itemRef.kind) ||
|
|
949
|
+
itemRef.kind === "completion" ||
|
|
950
|
+
(itemRef.kind === "choice" && item.supported === false),
|
|
951
|
+
loading: true,
|
|
952
|
+
};
|
|
953
|
+
}
|
|
954
|
+
|
|
955
|
+
async function fetchCurrentDetailForItem(itemRef = state.currentItem) {
|
|
956
|
+
if (!itemRef) {
|
|
957
|
+
return null;
|
|
958
|
+
}
|
|
959
|
+
if (hasDetailOverride(itemRef)) {
|
|
960
|
+
return state.detailOverride.detail;
|
|
961
|
+
}
|
|
962
|
+
try {
|
|
963
|
+
const detail = await apiGet(`/api/items/${encodeURIComponent(itemRef.kind)}/${encodeURIComponent(itemRef.token)}`);
|
|
964
|
+
if (hasLaunchItemIntent(itemRef)) {
|
|
965
|
+
state.launchItemIntent.status = "loaded";
|
|
966
|
+
}
|
|
967
|
+
return detail;
|
|
968
|
+
} catch (error) {
|
|
969
|
+
if (error.status === 401) {
|
|
970
|
+
state.session = null;
|
|
971
|
+
state.currentDetail = null;
|
|
972
|
+
renderPair();
|
|
973
|
+
return null;
|
|
974
|
+
}
|
|
975
|
+
await refreshInbox();
|
|
976
|
+
try {
|
|
977
|
+
const detail = await apiGet(`/api/items/${encodeURIComponent(itemRef.kind)}/${encodeURIComponent(itemRef.token)}`);
|
|
978
|
+
if (hasLaunchItemIntent(itemRef)) {
|
|
979
|
+
state.launchItemIntent.status = "loaded";
|
|
980
|
+
}
|
|
981
|
+
return detail;
|
|
982
|
+
} catch {
|
|
983
|
+
if (hasLaunchItemIntent(itemRef)) {
|
|
984
|
+
clearChoiceLocalDraftForItem(itemRef);
|
|
985
|
+
const fallbackDetail = buildLaunchItemFallbackDetail(itemRef);
|
|
986
|
+
state.detailOverride = {
|
|
987
|
+
...itemRef,
|
|
988
|
+
detail: fallbackDetail,
|
|
989
|
+
};
|
|
990
|
+
state.launchItemIntent.status = "resolved";
|
|
991
|
+
return fallbackDetail;
|
|
992
|
+
}
|
|
993
|
+
ensureCurrentSelection();
|
|
994
|
+
if (!state.currentItem) {
|
|
995
|
+
return null;
|
|
996
|
+
}
|
|
997
|
+
return null;
|
|
998
|
+
}
|
|
999
|
+
}
|
|
1000
|
+
}
|
|
1001
|
+
|
|
1002
|
+
function queueCurrentDetailLoad(itemRef = state.currentItem) {
|
|
1003
|
+
if (!itemRef || hasDetailOverride(itemRef)) {
|
|
1004
|
+
return;
|
|
1005
|
+
}
|
|
1006
|
+
if (state.currentDetailLoading && isSameItemRef(state.detailLoadingItem, itemRef)) {
|
|
1007
|
+
return;
|
|
1008
|
+
}
|
|
1009
|
+
|
|
1010
|
+
const requestedItem = { ...itemRef };
|
|
1011
|
+
const requestId = ++detailLoadSequence;
|
|
1012
|
+
state.currentDetailLoading = true;
|
|
1013
|
+
state.detailLoadingItem = requestedItem;
|
|
1014
|
+
|
|
1015
|
+
fetchCurrentDetailForItem(requestedItem)
|
|
1016
|
+
.then((detail) => {
|
|
1017
|
+
if (requestId !== detailLoadSequence) {
|
|
1018
|
+
return;
|
|
1019
|
+
}
|
|
1020
|
+
if (!detail) {
|
|
1021
|
+
if (!state.currentItem || !isSameItemRef(state.currentItem, requestedItem)) {
|
|
1022
|
+
return;
|
|
1023
|
+
}
|
|
1024
|
+
state.currentDetail = null;
|
|
1025
|
+
return;
|
|
1026
|
+
}
|
|
1027
|
+
if (!state.currentItem || !isSameItemRef(state.currentItem, requestedItem)) {
|
|
1028
|
+
return;
|
|
1029
|
+
}
|
|
1030
|
+
state.currentDetail = detail;
|
|
1031
|
+
})
|
|
1032
|
+
.finally(() => {
|
|
1033
|
+
if (requestId !== detailLoadSequence) {
|
|
1034
|
+
return;
|
|
1035
|
+
}
|
|
1036
|
+
state.currentDetailLoading = false;
|
|
1037
|
+
state.detailLoadingItem = null;
|
|
1038
|
+
renderCurrentSurface();
|
|
1039
|
+
});
|
|
1040
|
+
}
|
|
1041
|
+
|
|
1042
|
+
function buildLaunchItemFallbackDetail(itemRef) {
|
|
1043
|
+
const itemStillVisible = allInboxEntries().some((entry) => isSameItemRef(itemRef, entry.item));
|
|
1044
|
+
const isHandled = !itemStillVisible;
|
|
1045
|
+
const body = resolveLaunchFallbackMessage(itemRef.kind, isHandled);
|
|
1046
|
+
return {
|
|
1047
|
+
kind: itemRef.kind,
|
|
1048
|
+
token: itemRef.token,
|
|
1049
|
+
title: state.currentDetail?.title || kindMeta(itemRef.kind).label,
|
|
1050
|
+
messageHtml: `<p>${escapeHtml(body)}</p><p>${escapeHtml(L("server.page.notFoundHint"))}</p>`,
|
|
1051
|
+
readOnly: true,
|
|
1052
|
+
actions: [],
|
|
1053
|
+
};
|
|
1054
|
+
}
|
|
1055
|
+
|
|
1056
|
+
function resolveLaunchFallbackMessage(kind, isHandled) {
|
|
1057
|
+
if (kind === "approval") {
|
|
1058
|
+
return isHandled ? L("error.approvalAlreadyHandled") : L("error.approvalNotFound");
|
|
1059
|
+
}
|
|
1060
|
+
if (kind === "choice") {
|
|
1061
|
+
return isHandled ? L("error.choiceInputAlreadyHandled") : L("error.choiceInputNotFound");
|
|
1062
|
+
}
|
|
1063
|
+
return L("error.itemNotFound");
|
|
1064
|
+
}
|
|
1065
|
+
|
|
1066
|
+
function shouldKeepDetailAfterAction(itemRef = state.currentItem) {
|
|
1067
|
+
return Boolean(itemRef && hasLaunchItemIntent(itemRef) && isFastPathItemRef(itemRef));
|
|
1068
|
+
}
|
|
1069
|
+
|
|
1070
|
+
function pinActionOutcomeDetail(itemRef, detail) {
|
|
1071
|
+
if (!itemRef || !detail) {
|
|
1072
|
+
return;
|
|
1073
|
+
}
|
|
1074
|
+
state.currentItem = { ...itemRef };
|
|
1075
|
+
state.detailOverride = {
|
|
1076
|
+
...itemRef,
|
|
1077
|
+
detail,
|
|
1078
|
+
};
|
|
1079
|
+
state.currentDetail = detail;
|
|
1080
|
+
state.detailOpen = true;
|
|
1081
|
+
if (hasLaunchItemIntent(itemRef)) {
|
|
1082
|
+
state.launchItemIntent.status = "resolved";
|
|
1083
|
+
}
|
|
1084
|
+
}
|
|
1085
|
+
|
|
1086
|
+
function buildActionOutcomeDetail({ kind, title, message }) {
|
|
1087
|
+
return {
|
|
1088
|
+
kind,
|
|
1089
|
+
token: state.currentItem?.token || "",
|
|
1090
|
+
title: title || kindMeta(kind).label,
|
|
1091
|
+
messageHtml: `<p>${escapeHtml(message)}</p>`,
|
|
1092
|
+
readOnly: true,
|
|
1093
|
+
actions: [],
|
|
1094
|
+
};
|
|
1095
|
+
}
|
|
1096
|
+
|
|
1097
|
+
function approvalOutcomeMessage(actionUrl) {
|
|
1098
|
+
return /\/accept$/u.test(String(actionUrl || ""))
|
|
1099
|
+
? L("server.message.approvalAccepted")
|
|
1100
|
+
: L("server.message.approvalRejected");
|
|
1101
|
+
}
|
|
1102
|
+
|
|
1103
|
+
function renderDesktopWorkspace(detail) {
|
|
1104
|
+
if (state.currentTab === "settings") {
|
|
1105
|
+
return `<section class="screen-block">${renderSettingsDetail({ mobile: false })}</section>`;
|
|
1106
|
+
}
|
|
1107
|
+
|
|
1108
|
+
const entries = listEntriesForTab(state.currentTab);
|
|
1109
|
+
const shouldShowLoading =
|
|
1110
|
+
Boolean(state.currentItem) &&
|
|
1111
|
+
!detail &&
|
|
1112
|
+
(state.currentDetailLoading || !renderableCurrentDetail());
|
|
1113
|
+
return `
|
|
1114
|
+
<section class="desktop-workspace">
|
|
1115
|
+
<aside class="surface surface--list">
|
|
1116
|
+
${renderListPanel({
|
|
1117
|
+
tab: state.currentTab,
|
|
1118
|
+
entries,
|
|
1119
|
+
desktop: true,
|
|
1120
|
+
})}
|
|
1121
|
+
</aside>
|
|
1122
|
+
<section class="surface surface--detail">
|
|
1123
|
+
${detail ? renderDetailContent(detail, { mobile: false }) : shouldShowLoading ? renderDetailLoading({ mobile: false }) : renderDetailEmpty()}
|
|
1124
|
+
</section>
|
|
1125
|
+
</section>
|
|
1126
|
+
`;
|
|
1127
|
+
}
|
|
1128
|
+
|
|
1129
|
+
function renderMobileWorkspace(detail) {
|
|
1130
|
+
if (state.currentTab === "settings") {
|
|
1131
|
+
return `<section class="screen-block ${isSettingsSubpageOpen() ? "screen-block--detail" : ""}">${renderSettingsDetail({ mobile: true })}</section>`;
|
|
1132
|
+
}
|
|
1133
|
+
|
|
1134
|
+
if (state.detailOpen && detail) {
|
|
1135
|
+
return `<section class="screen-block screen-block--detail">${renderDetailContent(detail, { mobile: true })}</section>`;
|
|
1136
|
+
}
|
|
1137
|
+
|
|
1138
|
+
if (state.detailOpen && state.currentItem) {
|
|
1139
|
+
return `<section class="screen-block screen-block--detail">${renderDetailLoading({ mobile: true })}</section>`;
|
|
1140
|
+
}
|
|
1141
|
+
|
|
1142
|
+
return `
|
|
1143
|
+
<section class="screen-block">
|
|
1144
|
+
${renderListPanel({
|
|
1145
|
+
tab: state.currentTab,
|
|
1146
|
+
entries: listEntriesForTab(state.currentTab),
|
|
1147
|
+
desktop: false,
|
|
1148
|
+
})}
|
|
1149
|
+
</section>
|
|
1150
|
+
`;
|
|
1151
|
+
}
|
|
1152
|
+
|
|
1153
|
+
function renderListPanel({ tab, entries, desktop }) {
|
|
1154
|
+
if (tab === "timeline") {
|
|
1155
|
+
return renderTimelinePanel({ entries, desktop });
|
|
1156
|
+
}
|
|
1157
|
+
const meta = tabMeta(tab);
|
|
1158
|
+
const threadFilterHtml = tab === "completed" ? renderCompletedThreadDropdown() : "";
|
|
1159
|
+
if (!desktop) {
|
|
1160
|
+
return `
|
|
1161
|
+
<div class="screen-shell screen-shell--mobile">
|
|
1162
|
+
<div class="screen-header screen-header--mobile">
|
|
1163
|
+
<p class="screen-copy">${escapeHtml(meta.description)}</p>
|
|
1164
|
+
<span class="count-chip">${entries.length}</span>
|
|
1165
|
+
</div>
|
|
1166
|
+
${threadFilterHtml}
|
|
1167
|
+
${
|
|
1168
|
+
entries.length
|
|
1169
|
+
? `<div class="card-list">
|
|
1170
|
+
${entries.map((entry) => renderItemCard(entry, tab, false)).join("")}
|
|
1171
|
+
</div>`
|
|
1172
|
+
: renderEmptyList(tab)
|
|
1173
|
+
}
|
|
1174
|
+
</div>
|
|
1175
|
+
`;
|
|
1176
|
+
}
|
|
1177
|
+
|
|
1178
|
+
return `
|
|
1179
|
+
<div class="screen-shell">
|
|
1180
|
+
<div class="screen-header">
|
|
1181
|
+
<div>
|
|
1182
|
+
<p class="screen-eyebrow">${escapeHtml(meta.eyebrow)}</p>
|
|
1183
|
+
<h2 class="screen-title">${escapeHtml(meta.title)}</h2>
|
|
1184
|
+
</div>
|
|
1185
|
+
<span class="count-chip">${entries.length}</span>
|
|
1186
|
+
</div>
|
|
1187
|
+
<p class="screen-copy">${escapeHtml(meta.description)}</p>
|
|
1188
|
+
${threadFilterHtml}
|
|
1189
|
+
${
|
|
1190
|
+
entries.length
|
|
1191
|
+
? `<div class="card-list ${desktop ? "card-list--desktop" : ""}">
|
|
1192
|
+
${entries.map((entry) => renderItemCard(entry, tab, true)).join("")}
|
|
1193
|
+
</div>`
|
|
1194
|
+
: renderEmptyList(tab)
|
|
1195
|
+
}
|
|
1196
|
+
</div>
|
|
1197
|
+
`;
|
|
1198
|
+
}
|
|
1199
|
+
|
|
1200
|
+
function renderItemCard(entry, sourceTab, desktop) {
|
|
1201
|
+
if (entry.status === "completed" && entry.item.kind === "completion") {
|
|
1202
|
+
return renderCompletedCompletionCard(entry, sourceTab);
|
|
1203
|
+
}
|
|
1204
|
+
const kindInfo = kindMeta(entry.item.kind);
|
|
1205
|
+
const cardTitle = cardTitleForEntry(entry);
|
|
1206
|
+
const statusText = entry.status === "completed" ? L("common.completed") : L("common.actionNeeded");
|
|
1207
|
+
const intentText = itemIntentText(entry.item.kind, entry.status);
|
|
1208
|
+
const showCompletedTimestamp = entry.status === "completed" && sourceTab === "completed";
|
|
1209
|
+
const timestampLabel = showCompletedTimestamp ? formatTimelineTimestamp(entry.item.createdAtMs) : "";
|
|
1210
|
+
return `
|
|
1211
|
+
<button
|
|
1212
|
+
class="item-card item-card--${escapeHtml(kindInfo.tone)}"
|
|
1213
|
+
data-open-item-kind="${escapeHtml(entry.item.kind)}"
|
|
1214
|
+
data-open-item-token="${escapeHtml(entry.item.token)}"
|
|
1215
|
+
data-source-tab="${escapeHtml(sourceTab)}"
|
|
1216
|
+
>
|
|
1217
|
+
<div class="item-card__header">
|
|
1218
|
+
<div class="item-card__meta">
|
|
1219
|
+
<span class="type-pill type-pill--${escapeHtml(kindInfo.tone)}">${escapeHtml(kindInfo.label)}</span>
|
|
1220
|
+
${
|
|
1221
|
+
desktop && sourceTab === "inbox"
|
|
1222
|
+
? `<span class="status-pill status-pill--${escapeHtml(entry.status)}">${escapeHtml(statusText)}</span>`
|
|
1223
|
+
: ""
|
|
1224
|
+
}
|
|
1225
|
+
</div>
|
|
1226
|
+
<div class="item-card__header-right">
|
|
1227
|
+
${timestampLabel ? `<span class="item-card__timestamp">${escapeHtml(timestampLabel)}</span>` : ""}
|
|
1228
|
+
<span class="item-card__chevron" aria-hidden="true">${renderIcon("chevron-right")}</span>
|
|
1229
|
+
</div>
|
|
1230
|
+
</div>
|
|
1231
|
+
<div class="item-card__content">
|
|
1232
|
+
<h3 class="item-card__title">${escapeHtml(cardTitle || L("common.untitledItem"))}</h3>
|
|
1233
|
+
<p class="item-card__intent">
|
|
1234
|
+
<span class="item-card__intent-icon" aria-hidden="true">${renderIcon(kindInfo.icon)}</span>
|
|
1235
|
+
<span>${escapeHtml(intentText)}</span>
|
|
1236
|
+
</p>
|
|
1237
|
+
<p class="item-card__summary">${escapeHtml(entry.item.summary || fallbackSummaryForKind(entry.item.kind, entry.status))}</p>
|
|
1238
|
+
${
|
|
1239
|
+
!desktop && sourceTab === "inbox"
|
|
1240
|
+
? `<p class="item-card__status-note">${escapeHtml(statusText)}</p>`
|
|
1241
|
+
: ""
|
|
1242
|
+
}
|
|
1243
|
+
</div>
|
|
1244
|
+
</button>
|
|
1245
|
+
`;
|
|
1246
|
+
}
|
|
1247
|
+
|
|
1248
|
+
function cardTitleForEntry(entry) {
|
|
1249
|
+
const item = entry?.item || {};
|
|
1250
|
+
const rawTitle = normalizeClientText(item.title || "");
|
|
1251
|
+
if (!rawTitle) {
|
|
1252
|
+
return "";
|
|
1253
|
+
}
|
|
1254
|
+
if (item.kind !== "approval") {
|
|
1255
|
+
return rawTitle;
|
|
1256
|
+
}
|
|
1257
|
+
|
|
1258
|
+
const threadLabel = resolvedThreadLabel(item.threadId || "", item.threadLabel || "");
|
|
1259
|
+
if (threadLabel) {
|
|
1260
|
+
return threadLabel;
|
|
1261
|
+
}
|
|
1262
|
+
|
|
1263
|
+
const approvalPrefix = `${normalizeClientText(kindMeta("approval").label)} | `;
|
|
1264
|
+
if (approvalPrefix.trim() && rawTitle.startsWith(approvalPrefix)) {
|
|
1265
|
+
return normalizeClientText(rawTitle.slice(approvalPrefix.length)) || rawTitle;
|
|
1266
|
+
}
|
|
1267
|
+
return rawTitle;
|
|
1268
|
+
}
|
|
1269
|
+
|
|
1270
|
+
function renderCompletedCompletionCard(entry, sourceTab) {
|
|
1271
|
+
const item = entry.item;
|
|
1272
|
+
const kindInfo = kindMeta(item.kind);
|
|
1273
|
+
const summaryText = item.summary || fallbackSummaryForKind(item.kind, entry.status);
|
|
1274
|
+
const threadLabel = timelineEntryThreadLabel(item, true);
|
|
1275
|
+
const timestampLabel = formatTimelineTimestamp(item.createdAtMs);
|
|
1276
|
+
|
|
1277
|
+
return `
|
|
1278
|
+
<button
|
|
1279
|
+
class="item-card item-card--${escapeHtml(kindInfo.tone)} item-card--completion-readonly"
|
|
1280
|
+
data-open-item-kind="${escapeHtml(item.kind)}"
|
|
1281
|
+
data-open-item-token="${escapeHtml(item.token)}"
|
|
1282
|
+
data-source-tab="${escapeHtml(sourceTab)}"
|
|
1283
|
+
>
|
|
1284
|
+
<div class="item-card__header">
|
|
1285
|
+
<div class="item-card__meta">
|
|
1286
|
+
<span class="type-pill type-pill--completion">${escapeHtml(L("common.task"))}</span>
|
|
1287
|
+
</div>
|
|
1288
|
+
<div class="item-card__header-right">
|
|
1289
|
+
${timestampLabel ? `<span class="item-card__timestamp">${escapeHtml(timestampLabel)}</span>` : ""}
|
|
1290
|
+
<span class="item-card__chevron" aria-hidden="true">${renderIcon("chevron-right")}</span>
|
|
1291
|
+
</div>
|
|
1292
|
+
</div>
|
|
1293
|
+
<div class="item-card__content">
|
|
1294
|
+
${threadLabel ? `<p class="item-card__thread">${escapeHtml(threadLabel)}</p>` : ""}
|
|
1295
|
+
<h3 class="item-card__title">${escapeHtml(summaryText || L("common.untitledItem"))}</h3>
|
|
1296
|
+
</div>
|
|
1297
|
+
</button>
|
|
1298
|
+
`;
|
|
1299
|
+
}
|
|
1300
|
+
|
|
1301
|
+
function renderTimelinePanel({ entries, desktop }) {
|
|
1302
|
+
const meta = tabMeta("timeline");
|
|
1303
|
+
const listClassName = desktop ? "timeline-list timeline-list--desktop" : "timeline-list";
|
|
1304
|
+
const threadsHtml = renderTimelineThreadDropdown();
|
|
1305
|
+
const bodyHtml = entries.length
|
|
1306
|
+
? `<div class="${listClassName}">${entries.map((entry) => renderTimelineEntry(entry, { desktop })).join("")}</div>`
|
|
1307
|
+
: renderEmptyList("timeline");
|
|
1308
|
+
|
|
1309
|
+
if (!desktop) {
|
|
1310
|
+
return `
|
|
1311
|
+
<div class="screen-shell screen-shell--mobile timeline-shell timeline-shell--mobile">
|
|
1312
|
+
<div class="screen-header screen-header--mobile">
|
|
1313
|
+
<p class="screen-copy">${escapeHtml(meta.description)}</p>
|
|
1314
|
+
<span class="count-chip">${entries.length}</span>
|
|
1315
|
+
</div>
|
|
1316
|
+
${threadsHtml}
|
|
1317
|
+
${bodyHtml}
|
|
1318
|
+
</div>
|
|
1319
|
+
`;
|
|
1320
|
+
}
|
|
1321
|
+
|
|
1322
|
+
return `
|
|
1323
|
+
<div class="screen-shell timeline-shell">
|
|
1324
|
+
<div class="screen-header">
|
|
1325
|
+
<div>
|
|
1326
|
+
<p class="screen-eyebrow">${escapeHtml(meta.eyebrow)}</p>
|
|
1327
|
+
<h2 class="screen-title">${escapeHtml(meta.title)}</h2>
|
|
1328
|
+
</div>
|
|
1329
|
+
<span class="count-chip">${entries.length}</span>
|
|
1330
|
+
</div>
|
|
1331
|
+
<p class="screen-copy">${escapeHtml(meta.description)}</p>
|
|
1332
|
+
${threadsHtml}
|
|
1333
|
+
${bodyHtml}
|
|
1334
|
+
</div>
|
|
1335
|
+
`;
|
|
1336
|
+
}
|
|
1337
|
+
|
|
1338
|
+
function renderTimelineThreadDropdown() {
|
|
1339
|
+
const threads = Array.isArray(state.timeline?.threads) ? state.timeline.threads : [];
|
|
1340
|
+
return renderThreadDropdown({
|
|
1341
|
+
inputId: "timeline-thread-select",
|
|
1342
|
+
dataAttribute: "data-timeline-thread-select",
|
|
1343
|
+
selectedThreadId: state.timelineThreadFilter,
|
|
1344
|
+
threads: threads.map((thread) => ({
|
|
1345
|
+
id: thread.id,
|
|
1346
|
+
label: dropdownThreadLabel(thread.id, thread.label || ""),
|
|
1347
|
+
})),
|
|
1348
|
+
});
|
|
1349
|
+
}
|
|
1350
|
+
|
|
1351
|
+
function renderCompletedThreadDropdown() {
|
|
1352
|
+
return renderThreadDropdown({
|
|
1353
|
+
inputId: "completed-thread-select",
|
|
1354
|
+
dataAttribute: "data-completed-thread-select",
|
|
1355
|
+
selectedThreadId: state.completedThreadFilter,
|
|
1356
|
+
threads: completedThreads(),
|
|
1357
|
+
});
|
|
1358
|
+
}
|
|
1359
|
+
|
|
1360
|
+
function renderThreadDropdown({ inputId, dataAttribute, selectedThreadId, threads }) {
|
|
1361
|
+
const options = [
|
|
1362
|
+
{
|
|
1363
|
+
id: "all",
|
|
1364
|
+
label: L("timeline.allThreads"),
|
|
1365
|
+
},
|
|
1366
|
+
...threads.map((thread) => ({
|
|
1367
|
+
id: thread.id,
|
|
1368
|
+
label: dropdownThreadLabel(thread.id, thread.label || ""),
|
|
1369
|
+
})),
|
|
1370
|
+
];
|
|
1371
|
+
|
|
1372
|
+
return `
|
|
1373
|
+
<div class="timeline-thread-filter">
|
|
1374
|
+
<label class="timeline-thread-filter__label" for="${escapeHtml(inputId)}">${escapeHtml(L("timeline.filterLabel"))}</label>
|
|
1375
|
+
<div class="timeline-thread-select-wrap">
|
|
1376
|
+
<select id="${escapeHtml(inputId)}" class="timeline-thread-select" ${dataAttribute}>
|
|
1377
|
+
${options
|
|
1378
|
+
.map(
|
|
1379
|
+
(thread) => `
|
|
1380
|
+
<option value="${escapeHtml(thread.id)}" ${selectedThreadId === thread.id ? "selected" : ""}>
|
|
1381
|
+
${escapeHtml(thread.label)}
|
|
1382
|
+
</option>
|
|
1383
|
+
`
|
|
1384
|
+
)
|
|
1385
|
+
.join("")}
|
|
1386
|
+
</select>
|
|
1387
|
+
<span class="timeline-thread-select__chevron" aria-hidden="true">${renderIcon("chevron-down")}</span>
|
|
1388
|
+
</div>
|
|
1389
|
+
</div>
|
|
1390
|
+
`;
|
|
1391
|
+
}
|
|
1392
|
+
|
|
1393
|
+
function renderTimelineEntry(entry, { desktop }) {
|
|
1394
|
+
const item = entry.item;
|
|
1395
|
+
const kindInfo = kindMeta(item.kind);
|
|
1396
|
+
const kindClassName = escapeHtml(kindInfo.tone || "neutral");
|
|
1397
|
+
const kindNameClass = escapeHtml(String(item.kind || "item").replace(/_/gu, "-"));
|
|
1398
|
+
const isMessageLike = TIMELINE_MESSAGE_KINDS.has(item.kind) || item.kind === "completion";
|
|
1399
|
+
const primaryText = isMessageLike
|
|
1400
|
+
? item.summary || fallbackSummaryForKind(item.kind, entry.status)
|
|
1401
|
+
: item.title || L("common.untitledItem");
|
|
1402
|
+
const secondaryText = isMessageLike ? "" : item.summary || fallbackSummaryForKind(item.kind, entry.status);
|
|
1403
|
+
const threadLabel = timelineEntryThreadLabel(item, isMessageLike);
|
|
1404
|
+
const timestampLabel = formatTimelineTimestamp(item.createdAtMs);
|
|
1405
|
+
const statusLabel = isMessageLike ? "" : L("common.actionNeeded");
|
|
1406
|
+
|
|
1407
|
+
return `
|
|
1408
|
+
<button
|
|
1409
|
+
class="timeline-entry timeline-entry--${kindClassName} timeline-entry--kind-${kindNameClass} ${isMessageLike ? "timeline-entry--message" : "timeline-entry--operational"}"
|
|
1410
|
+
data-open-item-kind="${escapeHtml(item.kind)}"
|
|
1411
|
+
data-open-item-token="${escapeHtml(item.token)}"
|
|
1412
|
+
data-source-tab="timeline"
|
|
1413
|
+
>
|
|
1414
|
+
<div class="timeline-entry__meta">
|
|
1415
|
+
<span class="timeline-entry__kind">
|
|
1416
|
+
<span class="timeline-entry__kind-icon" aria-hidden="true">${renderIcon(kindInfo.icon)}</span>
|
|
1417
|
+
<span>${escapeHtml(kindInfo.label)}</span>
|
|
1418
|
+
</span>
|
|
1419
|
+
<span class="timeline-entry__meta-right">
|
|
1420
|
+
<span class="timeline-entry__time">${escapeHtml(timestampLabel)}</span>
|
|
1421
|
+
<span class="timeline-entry__chevron" aria-hidden="true">${renderIcon("chevron-right")}</span>
|
|
1422
|
+
</span>
|
|
1423
|
+
</div>
|
|
1424
|
+
${threadLabel ? `<p class="timeline-entry__thread">${escapeHtml(threadLabel)}</p>` : ""}
|
|
1425
|
+
<div class="timeline-entry__body">
|
|
1426
|
+
<p class="timeline-entry__title">${escapeHtml(primaryText)}</p>
|
|
1427
|
+
${secondaryText ? `<p class="timeline-entry__summary">${escapeHtml(secondaryText)}</p>` : ""}
|
|
1428
|
+
</div>
|
|
1429
|
+
${statusLabel ? `<div class="timeline-entry__footer"><span class="timeline-entry__status">${escapeHtml(statusLabel)}</span></div>` : ""}
|
|
1430
|
+
</button>
|
|
1431
|
+
`;
|
|
1432
|
+
}
|
|
1433
|
+
|
|
1434
|
+
function timelineEntryThreadLabel(item, isMessage) {
|
|
1435
|
+
const threadLabel = resolvedThreadLabel(item.threadId || "", item.threadLabel || "");
|
|
1436
|
+
if (!threadLabel) {
|
|
1437
|
+
return "";
|
|
1438
|
+
}
|
|
1439
|
+
if (isMessage) {
|
|
1440
|
+
return threadLabel;
|
|
1441
|
+
}
|
|
1442
|
+
const title = normalizeClientText(item.title || "");
|
|
1443
|
+
return title.includes(threadLabel) ? "" : threadLabel;
|
|
1444
|
+
}
|
|
1445
|
+
|
|
1446
|
+
function resolvedThreadLabel(threadId, explicitLabel = "") {
|
|
1447
|
+
const normalizedLabel = normalizeClientText(explicitLabel || "");
|
|
1448
|
+
if (normalizedLabel) {
|
|
1449
|
+
return normalizedLabel;
|
|
1450
|
+
}
|
|
1451
|
+
const normalizedThreadId = normalizeClientText(threadId || "");
|
|
1452
|
+
if (!normalizedThreadId) {
|
|
1453
|
+
return "";
|
|
1454
|
+
}
|
|
1455
|
+
const timelineThreads = Array.isArray(state.timeline?.threads) ? state.timeline.threads : [];
|
|
1456
|
+
const matchingThread = timelineThreads.find((thread) => thread.id === normalizedThreadId);
|
|
1457
|
+
const fallbackLabel = normalizeClientText(matchingThread?.label || "");
|
|
1458
|
+
return fallbackLabel || "";
|
|
1459
|
+
}
|
|
1460
|
+
|
|
1461
|
+
function dropdownThreadLabel(threadId, explicitLabel = "") {
|
|
1462
|
+
return resolvedThreadLabel(threadId, explicitLabel) || L("timeline.unknownThread");
|
|
1463
|
+
}
|
|
1464
|
+
|
|
1465
|
+
function formatTimelineTimestamp(value) {
|
|
1466
|
+
const createdAtMs = Number(value) || 0;
|
|
1467
|
+
if (!createdAtMs) {
|
|
1468
|
+
return "";
|
|
1469
|
+
}
|
|
1470
|
+
const date = new Date(createdAtMs);
|
|
1471
|
+
const now = new Date();
|
|
1472
|
+
const sameDay =
|
|
1473
|
+
date.getFullYear() === now.getFullYear() &&
|
|
1474
|
+
date.getMonth() === now.getMonth() &&
|
|
1475
|
+
date.getDate() === now.getDate();
|
|
1476
|
+
const options = sameDay
|
|
1477
|
+
? { hour: "numeric", minute: "2-digit" }
|
|
1478
|
+
: { month: "short", day: "numeric", hour: "numeric", minute: "2-digit" };
|
|
1479
|
+
try {
|
|
1480
|
+
return new Intl.DateTimeFormat(state.locale || DEFAULT_LOCALE, options).format(date);
|
|
1481
|
+
} catch {
|
|
1482
|
+
return sameDay ? date.toLocaleTimeString() : date.toLocaleString();
|
|
1483
|
+
}
|
|
1484
|
+
}
|
|
1485
|
+
|
|
1486
|
+
function formatSettingsTimestamp(value) {
|
|
1487
|
+
const timestamp = Number(value) || 0;
|
|
1488
|
+
if (!timestamp) {
|
|
1489
|
+
return L("common.unavailable");
|
|
1490
|
+
}
|
|
1491
|
+
try {
|
|
1492
|
+
return new Intl.DateTimeFormat(state.locale || DEFAULT_LOCALE, {
|
|
1493
|
+
month: "short",
|
|
1494
|
+
day: "numeric",
|
|
1495
|
+
hour: "numeric",
|
|
1496
|
+
minute: "2-digit",
|
|
1497
|
+
}).format(new Date(timestamp));
|
|
1498
|
+
} catch {
|
|
1499
|
+
return new Date(timestamp).toLocaleString();
|
|
1500
|
+
}
|
|
1501
|
+
}
|
|
1502
|
+
|
|
1503
|
+
function renderSettingsDetail({ mobile }) {
|
|
1504
|
+
const context = buildSettingsContext();
|
|
1505
|
+
if (state.settingsSubpage) {
|
|
1506
|
+
return renderSettingsSubpage(context, { mobile });
|
|
1507
|
+
}
|
|
1508
|
+
return renderSettingsRoot(context, { mobile });
|
|
1509
|
+
}
|
|
1510
|
+
|
|
1511
|
+
function buildSettingsContext() {
|
|
1512
|
+
const push = state.pushStatus || {};
|
|
1513
|
+
const permission = push.notificationPermission || "default";
|
|
1514
|
+
const secureContext = push.secureContext === true;
|
|
1515
|
+
const standalone = push.standalone === true;
|
|
1516
|
+
const supportsPushValue = push.supportsPush === true;
|
|
1517
|
+
const serverEnabled = push.enabled === true;
|
|
1518
|
+
const canEnable =
|
|
1519
|
+
serverEnabled &&
|
|
1520
|
+
supportsPushValue &&
|
|
1521
|
+
secureContext &&
|
|
1522
|
+
standalone &&
|
|
1523
|
+
permission !== "denied" &&
|
|
1524
|
+
push.serverSubscribed !== true;
|
|
1525
|
+
const setupState = buildSettingsSetupState({
|
|
1526
|
+
serverEnabled,
|
|
1527
|
+
secureContext,
|
|
1528
|
+
standalone,
|
|
1529
|
+
supportsPushValue,
|
|
1530
|
+
permission,
|
|
1531
|
+
subscribed: push.serverSubscribed === true,
|
|
1532
|
+
});
|
|
1533
|
+
|
|
1534
|
+
return {
|
|
1535
|
+
push,
|
|
1536
|
+
permission,
|
|
1537
|
+
secureContext,
|
|
1538
|
+
standalone,
|
|
1539
|
+
supportsPushValue,
|
|
1540
|
+
serverEnabled,
|
|
1541
|
+
canEnable,
|
|
1542
|
+
setupState,
|
|
1543
|
+
devices: Array.isArray(state.devices) ? state.devices : [],
|
|
1544
|
+
devicesError: state.deviceError,
|
|
1545
|
+
diagnostics: collectSettingsDiagnostics({
|
|
1546
|
+
push,
|
|
1547
|
+
permission,
|
|
1548
|
+
secureContext,
|
|
1549
|
+
standalone,
|
|
1550
|
+
supportsPushValue,
|
|
1551
|
+
serverEnabled,
|
|
1552
|
+
}),
|
|
1553
|
+
};
|
|
1554
|
+
}
|
|
1555
|
+
|
|
1556
|
+
function buildSettingsSetupState({ serverEnabled, secureContext, standalone, supportsPushValue, permission, subscribed }) {
|
|
1557
|
+
const notifications = (() => {
|
|
1558
|
+
if (!serverEnabled) {
|
|
1559
|
+
return { tone: "muted", labelKey: "settings.status.notAvailable", copyKey: "settings.notifications.serverDisabled" };
|
|
1560
|
+
}
|
|
1561
|
+
if (!supportsPushValue) {
|
|
1562
|
+
return { tone: "muted", labelKey: "settings.status.unsupported", copyKey: "error.pushUnsupported" };
|
|
1563
|
+
}
|
|
1564
|
+
if (!secureContext) {
|
|
1565
|
+
return { tone: "warning", labelKey: "settings.status.needsHttps", copyKey: "settings.notifications.openHttps" };
|
|
1566
|
+
}
|
|
1567
|
+
if (!standalone) {
|
|
1568
|
+
return { tone: "warning", labelKey: "settings.status.needsHomeScreen", copyKey: "settings.notifications.openHomeScreen" };
|
|
1569
|
+
}
|
|
1570
|
+
if (permission === "denied") {
|
|
1571
|
+
return { tone: "danger", labelKey: "settings.status.blocked", copyKey: "banner.push.copy.denied" };
|
|
1572
|
+
}
|
|
1573
|
+
if (subscribed) {
|
|
1574
|
+
return { tone: "success", labelKey: "settings.status.ready", copyKey: "notice.notificationsEnabled" };
|
|
1575
|
+
}
|
|
1576
|
+
return { tone: "warning", labelKey: "settings.status.actionNeeded", copyKey: "banner.push.copy.default" };
|
|
1577
|
+
})();
|
|
1578
|
+
|
|
1579
|
+
const install = standalone
|
|
1580
|
+
? { tone: "success", labelKey: "settings.status.installed" }
|
|
1581
|
+
: { tone: "warning", labelKey: "settings.status.notInstalled" };
|
|
1582
|
+
|
|
1583
|
+
const pairing = { tone: "success", labelKey: "settings.status.connected" };
|
|
1584
|
+
|
|
1585
|
+
let nextStep = {
|
|
1586
|
+
titleKey: "settings.nextStep.enableNotifications.title",
|
|
1587
|
+
copyKey: "settings.nextStep.enableNotifications.copy",
|
|
1588
|
+
};
|
|
1589
|
+
let primaryAction = { kind: "push-enable", disabled: false };
|
|
1590
|
+
|
|
1591
|
+
if (!serverEnabled) {
|
|
1592
|
+
nextStep = {
|
|
1593
|
+
titleKey: "settings.nextStep.serverDisabled.title",
|
|
1594
|
+
copyKey: "settings.nextStep.serverDisabled.copy",
|
|
1595
|
+
};
|
|
1596
|
+
primaryAction = { kind: "open-technical" };
|
|
1597
|
+
} else if (!secureContext) {
|
|
1598
|
+
nextStep = {
|
|
1599
|
+
titleKey: "settings.nextStep.openHttps.title",
|
|
1600
|
+
copyKey: "settings.nextStep.openHttps.copy",
|
|
1601
|
+
};
|
|
1602
|
+
primaryAction = { kind: "none" };
|
|
1603
|
+
} else if (!standalone) {
|
|
1604
|
+
nextStep = {
|
|
1605
|
+
titleKey: "settings.nextStep.install.title",
|
|
1606
|
+
copyKey: "settings.nextStep.install.copy",
|
|
1607
|
+
};
|
|
1608
|
+
primaryAction = { kind: "install-guide" };
|
|
1609
|
+
} else if (permission === "denied") {
|
|
1610
|
+
nextStep = {
|
|
1611
|
+
titleKey: "settings.nextStep.permissionBlocked.title",
|
|
1612
|
+
copyKey: "settings.nextStep.permissionBlocked.copy",
|
|
1613
|
+
};
|
|
1614
|
+
primaryAction = { kind: "none" };
|
|
1615
|
+
} else if (subscribed) {
|
|
1616
|
+
nextStep = {
|
|
1617
|
+
titleKey: "settings.nextStep.test.title",
|
|
1618
|
+
copyKey: "settings.nextStep.test.copy",
|
|
1619
|
+
};
|
|
1620
|
+
primaryAction = { kind: "push-test" };
|
|
1621
|
+
}
|
|
1622
|
+
|
|
1623
|
+
return {
|
|
1624
|
+
notifications,
|
|
1625
|
+
install,
|
|
1626
|
+
pairing,
|
|
1627
|
+
nextStep,
|
|
1628
|
+
primaryAction,
|
|
1629
|
+
};
|
|
1630
|
+
}
|
|
1631
|
+
|
|
1632
|
+
function collectSettingsDiagnostics({ permission, secureContext, standalone, supportsPushValue, serverEnabled }) {
|
|
1633
|
+
const issues = [];
|
|
1634
|
+
if (!serverEnabled) {
|
|
1635
|
+
issues.push(L("settings.notifications.serverDisabled"));
|
|
1636
|
+
}
|
|
1637
|
+
if (!supportsPushValue) {
|
|
1638
|
+
issues.push(L("error.pushUnsupported"));
|
|
1639
|
+
}
|
|
1640
|
+
if (!secureContext) {
|
|
1641
|
+
issues.push(L("settings.notifications.openHttps"));
|
|
1642
|
+
}
|
|
1643
|
+
if (secureContext && !standalone) {
|
|
1644
|
+
issues.push(L("settings.notifications.openHomeScreen"));
|
|
1645
|
+
}
|
|
1646
|
+
if (permission === "denied") {
|
|
1647
|
+
issues.push(L("banner.push.copy.denied"));
|
|
1648
|
+
}
|
|
1649
|
+
if (state.pushError) {
|
|
1650
|
+
issues.push(state.pushError);
|
|
1651
|
+
}
|
|
1652
|
+
return Array.from(new Set(issues.filter(Boolean)));
|
|
1653
|
+
}
|
|
1654
|
+
|
|
1655
|
+
function settingsPageMeta(page) {
|
|
1656
|
+
switch (page) {
|
|
1657
|
+
case "notifications":
|
|
1658
|
+
return {
|
|
1659
|
+
id: "notifications",
|
|
1660
|
+
title: L("settings.notifications.title"),
|
|
1661
|
+
description: L("settings.notifications.copy"),
|
|
1662
|
+
icon: "notifications",
|
|
1663
|
+
};
|
|
1664
|
+
case "language":
|
|
1665
|
+
return {
|
|
1666
|
+
id: "language",
|
|
1667
|
+
title: L("settings.language.title"),
|
|
1668
|
+
description: L("settings.language.copy"),
|
|
1669
|
+
icon: "language",
|
|
1670
|
+
};
|
|
1671
|
+
case "install":
|
|
1672
|
+
return {
|
|
1673
|
+
id: "install",
|
|
1674
|
+
title: L("settings.install.title"),
|
|
1675
|
+
description: L("settings.install.copy"),
|
|
1676
|
+
icon: "homescreen",
|
|
1677
|
+
};
|
|
1678
|
+
case "device":
|
|
1679
|
+
return {
|
|
1680
|
+
id: "device",
|
|
1681
|
+
title: L("settings.device.title"),
|
|
1682
|
+
description: L("settings.device.copy"),
|
|
1683
|
+
icon: "iphone",
|
|
1684
|
+
};
|
|
1685
|
+
case "advanced":
|
|
1686
|
+
return {
|
|
1687
|
+
id: "advanced",
|
|
1688
|
+
title: L("settings.technical.title"),
|
|
1689
|
+
description: L("settings.technical.copy"),
|
|
1690
|
+
icon: "settings",
|
|
1691
|
+
};
|
|
1692
|
+
default:
|
|
1693
|
+
return settingsPageMeta("notifications");
|
|
1694
|
+
}
|
|
1695
|
+
}
|
|
1696
|
+
|
|
1697
|
+
function renderSettingsRoot(context, { mobile }) {
|
|
1698
|
+
const languageValue = localeDisplayName(state.locale, state.locale) || state.locale;
|
|
1699
|
+
const generalRows = [
|
|
1700
|
+
renderSettingsNavRow({
|
|
1701
|
+
page: "notifications",
|
|
1702
|
+
icon: "notifications",
|
|
1703
|
+
title: L("settings.notifications.title"),
|
|
1704
|
+
value: L(context.setupState.notifications.labelKey),
|
|
1705
|
+
}),
|
|
1706
|
+
renderSettingsNavRow({
|
|
1707
|
+
page: "language",
|
|
1708
|
+
icon: "language",
|
|
1709
|
+
title: L("settings.language.title"),
|
|
1710
|
+
value: languageValue,
|
|
1711
|
+
}),
|
|
1712
|
+
!context.standalone
|
|
1713
|
+
? renderSettingsNavRow({
|
|
1714
|
+
page: "install",
|
|
1715
|
+
icon: "homescreen",
|
|
1716
|
+
title: L("settings.install.title"),
|
|
1717
|
+
value: L(context.setupState.install.labelKey),
|
|
1718
|
+
})
|
|
1719
|
+
: "",
|
|
1720
|
+
].filter(Boolean);
|
|
1721
|
+
const deviceRows = [
|
|
1722
|
+
renderSettingsNavRow({
|
|
1723
|
+
page: "device",
|
|
1724
|
+
icon: "iphone",
|
|
1725
|
+
title: L("settings.device.title"),
|
|
1726
|
+
value: context.devices.length
|
|
1727
|
+
? L("settings.device.count", { count: context.devices.length })
|
|
1728
|
+
: L("settings.pairing.connected"),
|
|
1729
|
+
}),
|
|
1730
|
+
];
|
|
1731
|
+
const advancedRows = [
|
|
1732
|
+
renderSettingsNavRow({
|
|
1733
|
+
page: "advanced",
|
|
1734
|
+
icon: "settings",
|
|
1735
|
+
title: L("settings.technical.title"),
|
|
1736
|
+
value: context.diagnostics.length ? L("settings.status.actionNeeded") : L("settings.status.info"),
|
|
1737
|
+
}),
|
|
1738
|
+
];
|
|
1739
|
+
|
|
1740
|
+
return `
|
|
1741
|
+
<div class="settings-screen">
|
|
1742
|
+
${
|
|
1743
|
+
mobile
|
|
1744
|
+
? ""
|
|
1745
|
+
: `
|
|
1746
|
+
<div class="screen-header">
|
|
1747
|
+
<div>
|
|
1748
|
+
<p class="screen-eyebrow">${escapeHtml(L("tab.settings.eyebrow"))}</p>
|
|
1749
|
+
<h2 class="screen-title">${escapeHtml(L("tab.settings.title"))}</h2>
|
|
1750
|
+
</div>
|
|
1751
|
+
</div>
|
|
1752
|
+
`
|
|
1753
|
+
}
|
|
1754
|
+
${renderSettingsGroup(L("settings.group.general"), generalRows)}
|
|
1755
|
+
${renderSettingsGroup(L("settings.pairing.title"), deviceRows)}
|
|
1756
|
+
${renderSettingsGroup(L("settings.group.advanced"), advancedRows)}
|
|
1757
|
+
</div>
|
|
1758
|
+
`;
|
|
1759
|
+
}
|
|
1760
|
+
|
|
1761
|
+
function renderSettingsSubpage(context, { mobile }) {
|
|
1762
|
+
const page = settingsPageMeta(state.settingsSubpage);
|
|
1763
|
+
const desktopHeader = !mobile
|
|
1764
|
+
? `
|
|
1765
|
+
<div class="settings-page-header">
|
|
1766
|
+
<button class="secondary settings-inline-back" type="button" data-settings-back>
|
|
1767
|
+
<span aria-hidden="true">${renderIcon("back")}</span>
|
|
1768
|
+
<span>${escapeHtml(L("common.back"))}</span>
|
|
1769
|
+
</button>
|
|
1770
|
+
<div>
|
|
1771
|
+
<p class="screen-eyebrow">${escapeHtml(L("common.settings"))}</p>
|
|
1772
|
+
<h2 class="screen-title">${escapeHtml(page.title)}</h2>
|
|
1773
|
+
</div>
|
|
1774
|
+
</div>
|
|
1775
|
+
`
|
|
1776
|
+
: "";
|
|
1777
|
+
|
|
1778
|
+
let content = "";
|
|
1779
|
+
switch (state.settingsSubpage) {
|
|
1780
|
+
case "notifications":
|
|
1781
|
+
content = renderSettingsNotificationsPage(context);
|
|
1782
|
+
break;
|
|
1783
|
+
case "language":
|
|
1784
|
+
content = renderSettingsLanguagePage();
|
|
1785
|
+
break;
|
|
1786
|
+
case "install":
|
|
1787
|
+
content = renderSettingsInstallPage();
|
|
1788
|
+
break;
|
|
1789
|
+
case "device":
|
|
1790
|
+
content = renderSettingsDevicePage(context);
|
|
1791
|
+
break;
|
|
1792
|
+
case "advanced":
|
|
1793
|
+
content = renderSettingsAdvancedPage(context);
|
|
1794
|
+
break;
|
|
1795
|
+
default:
|
|
1796
|
+
content = "";
|
|
1797
|
+
}
|
|
1798
|
+
|
|
1799
|
+
return `
|
|
1800
|
+
<div class="settings-screen settings-screen--subpage">
|
|
1801
|
+
${desktopHeader}
|
|
1802
|
+
<p class="settings-page-copy muted">${escapeHtml(page.description)}</p>
|
|
1803
|
+
${content}
|
|
1804
|
+
</div>
|
|
1805
|
+
`;
|
|
1806
|
+
}
|
|
1807
|
+
|
|
1808
|
+
function renderSettingsNotificationsPage(context) {
|
|
1809
|
+
const { push, permission, secureContext, standalone, supportsPushValue, serverEnabled } = context;
|
|
1810
|
+
const statusRows = [
|
|
1811
|
+
renderSettingsInfoRow(L("settings.row.status"), L(context.setupState.notifications.labelKey)),
|
|
1812
|
+
renderSettingsInfoRow(L("settings.row.notificationPermission"), permission),
|
|
1813
|
+
renderSettingsInfoRow(L("settings.row.currentDeviceSubscribed"), push.serverSubscribed ? L("common.yes") : L("common.no")),
|
|
1814
|
+
push.lastSuccessfulDeliveryAtMs
|
|
1815
|
+
? renderSettingsInfoRow(
|
|
1816
|
+
L("settings.row.lastSuccessfulDelivery"),
|
|
1817
|
+
new Date(push.lastSuccessfulDeliveryAtMs).toLocaleString(state.locale)
|
|
1818
|
+
)
|
|
1819
|
+
: "",
|
|
1820
|
+
].filter(Boolean);
|
|
1821
|
+
return `
|
|
1822
|
+
<div class="settings-page">
|
|
1823
|
+
${renderSettingsGroup("", statusRows)}
|
|
1824
|
+
${renderSettingsGroup(L("settings.group.advanced"), [
|
|
1825
|
+
renderSettingsInfoRow(L("settings.row.serverWebPush"), serverEnabled ? L("common.yes") : L("common.no")),
|
|
1826
|
+
renderSettingsInfoRow(L("settings.row.secureContext"), secureContext ? L("common.yes") : L("common.no")),
|
|
1827
|
+
renderSettingsInfoRow(L("settings.row.homeScreenApp"), standalone ? L("common.yes") : L("common.no")),
|
|
1828
|
+
renderSettingsInfoRow(L("settings.row.browserSupport"), supportsPushValue ? L("common.yes") : L("common.no")),
|
|
1829
|
+
])}
|
|
1830
|
+
${state.pushNotice ? `<p class="inline-alert inline-alert--success">${escapeHtml(state.pushNotice)}</p>` : ""}
|
|
1831
|
+
${state.pushError ? `<p class="inline-alert inline-alert--danger">${escapeHtml(state.pushError)}</p>` : ""}
|
|
1832
|
+
${renderSettingsActionPanel(renderSettingsNotificationActions({
|
|
1833
|
+
push,
|
|
1834
|
+
canEnable: context.canEnable,
|
|
1835
|
+
standalone,
|
|
1836
|
+
}), L("settings.group.actions"))}
|
|
1837
|
+
</div>
|
|
1838
|
+
`;
|
|
1839
|
+
}
|
|
1840
|
+
|
|
1841
|
+
function renderSettingsLanguagePage() {
|
|
1842
|
+
const overrideLocale = normalizeLocale(state.session?.deviceOverrideLocale || "");
|
|
1843
|
+
const options = [
|
|
1844
|
+
{ value: "", label: L("common.useDeviceLanguage") },
|
|
1845
|
+
{ value: "en", label: localeDisplayName("en", state.locale) },
|
|
1846
|
+
{ value: "ja", label: localeDisplayName("ja", state.locale) },
|
|
1847
|
+
];
|
|
1848
|
+
|
|
1849
|
+
return `
|
|
1850
|
+
<div class="settings-page">
|
|
1851
|
+
${renderSettingsGroup("", options.map(({ value, label }) => {
|
|
1852
|
+
const isSelected = (value || "") === overrideLocale;
|
|
1853
|
+
return `
|
|
1854
|
+
<button class="settings-choice-row" type="button" data-locale-option="${escapeHtml(value)}" aria-pressed="${isSelected ? "true" : "false"}">
|
|
1855
|
+
<span class="settings-row__body">
|
|
1856
|
+
<span class="settings-row__title">${escapeHtml(label)}</span>
|
|
1857
|
+
</span>
|
|
1858
|
+
<span class="settings-choice-row__check" aria-hidden="true">${isSelected ? renderIcon("check") : ""}</span>
|
|
1859
|
+
</button>
|
|
1860
|
+
`;
|
|
1861
|
+
}), { listClassName: "settings-list settings-list--compact" })}
|
|
1862
|
+
${renderSettingsGroup(L("settings.group.values"), [
|
|
1863
|
+
renderSettingsInfoRow(L("settings.row.currentLanguage"), localeDisplayName(state.locale, state.locale) || state.locale),
|
|
1864
|
+
renderSettingsInfoRow(L("settings.row.languageSource"), L(`language.source.${state.localeSource}`)),
|
|
1865
|
+
renderSettingsInfoRow(L("settings.row.defaultLanguage"), localeDisplayName(state.defaultLocale, state.locale) || state.defaultLocale),
|
|
1866
|
+
], { listClassName: "settings-list settings-list--compact" })}
|
|
1867
|
+
</div>
|
|
1868
|
+
`;
|
|
1869
|
+
}
|
|
1870
|
+
|
|
1871
|
+
function renderSettingsInstallPage() {
|
|
1872
|
+
return `
|
|
1873
|
+
<div class="settings-page">
|
|
1874
|
+
<section class="settings-copy-block">
|
|
1875
|
+
<p class="muted">${escapeHtml(L("settings.install.copy"))}</p>
|
|
1876
|
+
</section>
|
|
1877
|
+
${renderSettingsActionPanel(
|
|
1878
|
+
`<button class="primary primary--wide" type="button" data-install-guide-open>${escapeHtml(L("common.addToHomeScreen"))}</button>`
|
|
1879
|
+
, L("settings.group.actions"))}
|
|
1880
|
+
</div>
|
|
1881
|
+
`;
|
|
1882
|
+
}
|
|
1883
|
+
|
|
1884
|
+
function renderSettingsDevicePage(context) {
|
|
1885
|
+
const devices = Array.isArray(context.devices) ? context.devices : [];
|
|
1886
|
+
const currentDevice = devices.find((device) => device.currentDevice) || null;
|
|
1887
|
+
const otherDevices = devices.filter((device) => !device.currentDevice);
|
|
1888
|
+
return `
|
|
1889
|
+
<div class="settings-page">
|
|
1890
|
+
${state.deviceNotice ? `<p class="inline-alert inline-alert--success">${escapeHtml(state.deviceNotice)}</p>` : ""}
|
|
1891
|
+
${(state.deviceError || context.devicesError) ? `<p class="inline-alert inline-alert--danger">${escapeHtml(state.deviceError || context.devicesError)}</p>` : ""}
|
|
1892
|
+
${renderDeviceSection(L("settings.device.section.current"), currentDevice ? [currentDevice] : [], L("settings.device.emptyCurrent"))}
|
|
1893
|
+
${renderDeviceSection(L("settings.device.section.other"), otherDevices, L("settings.device.emptyOther"))}
|
|
1894
|
+
<section class="settings-group">
|
|
1895
|
+
<p class="settings-group__title">${escapeHtml(L("settings.device.addAnother.title"))}</p>
|
|
1896
|
+
<div class="settings-copy-block settings-copy-block--stacked">
|
|
1897
|
+
<div class="helper-copy">
|
|
1898
|
+
<strong>${escapeHtml(L("settings.device.addAnother.heading"))}</strong>
|
|
1899
|
+
<p class="muted">${escapeHtml(L("settings.device.addAnother.copy"))}</p>
|
|
1900
|
+
</div>
|
|
1901
|
+
<div class="settings-command-card">
|
|
1902
|
+
<span class="settings-command-card__label">${escapeHtml(L("settings.device.addAnother.commandLabel"))}</span>
|
|
1903
|
+
<code class="settings-command-card__value">npx viveworker setup --pair</code>
|
|
1904
|
+
</div>
|
|
1905
|
+
</div>
|
|
1906
|
+
</section>
|
|
1907
|
+
${renderSettingsActionPanel(
|
|
1908
|
+
`<button class="secondary secondary--wide" type="button" data-open-logout-confirm>${escapeHtml(L("common.logOut"))}</button>`,
|
|
1909
|
+
L("settings.group.actions")
|
|
1910
|
+
)}
|
|
1911
|
+
</div>
|
|
1912
|
+
`;
|
|
1913
|
+
}
|
|
1914
|
+
|
|
1915
|
+
function renderSettingsAdvancedPage(context) {
|
|
1916
|
+
return `
|
|
1917
|
+
<div class="settings-page">
|
|
1918
|
+
${context.diagnostics.map((message) => `<p class="inline-alert">${escapeHtml(message)}</p>`).join("")}
|
|
1919
|
+
${renderSettingsGroup("", [
|
|
1920
|
+
renderSettingsInfoRow(L("settings.row.serverWebPush"), context.serverEnabled ? L("common.yes") : L("common.no")),
|
|
1921
|
+
renderSettingsInfoRow(L("settings.row.secureContext"), context.secureContext ? L("common.yes") : L("common.no")),
|
|
1922
|
+
renderSettingsInfoRow(L("settings.row.homeScreenApp"), context.standalone ? L("common.yes") : L("common.no")),
|
|
1923
|
+
renderSettingsInfoRow(L("settings.row.notificationPermission"), context.permission),
|
|
1924
|
+
renderSettingsInfoRow(L("settings.row.browserSupport"), context.supportsPushValue ? L("common.yes") : L("common.no")),
|
|
1925
|
+
renderSettingsInfoRow(L("settings.row.currentDeviceSubscribed"), context.push.serverSubscribed ? L("common.yes") : L("common.no")),
|
|
1926
|
+
context.push.lastSuccessfulDeliveryAtMs
|
|
1927
|
+
? renderSettingsInfoRow(
|
|
1928
|
+
L("settings.row.lastSuccessfulDelivery"),
|
|
1929
|
+
new Date(context.push.lastSuccessfulDeliveryAtMs).toLocaleString(state.locale)
|
|
1930
|
+
)
|
|
1931
|
+
: "",
|
|
1932
|
+
renderSettingsInfoRow(L("settings.row.version"), state.appVersion || L("common.unavailable")),
|
|
1933
|
+
].filter(Boolean), { listClassName: "settings-list settings-list--compact" })}
|
|
1934
|
+
</div>
|
|
1935
|
+
`;
|
|
1936
|
+
}
|
|
1937
|
+
|
|
1938
|
+
function renderSettingsNotificationActions({ push, canEnable, standalone }) {
|
|
1939
|
+
if (push.serverSubscribed) {
|
|
1940
|
+
return `
|
|
1941
|
+
<button class="primary primary--wide" data-push-action="test">${escapeHtml(L("settings.action.sendTest"))}</button>
|
|
1942
|
+
<button class="secondary secondary--wide" data-push-action="disable">${escapeHtml(L("settings.action.disableNotifications"))}</button>
|
|
1943
|
+
`;
|
|
1944
|
+
}
|
|
1945
|
+
|
|
1946
|
+
if (!push.enabled || push.supportsPush === false || push.secureContext === false) {
|
|
1947
|
+
return `<button class="secondary secondary--wide" type="button" data-open-technical>${escapeHtml(L("settings.action.reviewTechnical"))}</button>`;
|
|
1948
|
+
}
|
|
1949
|
+
|
|
1950
|
+
if (push.notificationPermission === "denied") {
|
|
1951
|
+
return `<button class="secondary secondary--wide" type="button" data-open-technical>${escapeHtml(L("settings.action.reviewTechnical"))}</button>`;
|
|
1952
|
+
}
|
|
1953
|
+
|
|
1954
|
+
if (!standalone) {
|
|
1955
|
+
return `<button class="secondary secondary--wide" type="button" data-install-guide-open>${escapeHtml(L("common.addToHomeScreen"))}</button>`;
|
|
1956
|
+
}
|
|
1957
|
+
|
|
1958
|
+
return `<button class="primary primary--wide" data-push-action="enable" ${canEnable ? "" : "disabled"}>${escapeHtml(L("settings.action.enableNotifications"))}</button>`;
|
|
1959
|
+
}
|
|
1960
|
+
|
|
1961
|
+
function renderSettingsGroup(title, rows, options = {}) {
|
|
1962
|
+
const listClassName = options.listClassName || "settings-list";
|
|
1963
|
+
return `
|
|
1964
|
+
<section class="settings-group">
|
|
1965
|
+
${title ? `<p class="settings-group__title">${escapeHtml(title)}</p>` : ""}
|
|
1966
|
+
<div class="${escapeHtml(listClassName)}">
|
|
1967
|
+
${rows.join("")}
|
|
1968
|
+
</div>
|
|
1969
|
+
</section>
|
|
1970
|
+
`;
|
|
1971
|
+
}
|
|
1972
|
+
|
|
1973
|
+
function renderSettingsNavRow({ page, icon, title, subtitle, value }) {
|
|
1974
|
+
return `
|
|
1975
|
+
<button class="settings-nav-row" type="button" data-settings-subpage="${escapeHtml(page)}">
|
|
1976
|
+
<span class="settings-row__icon" aria-hidden="true">${renderIcon(icon)}</span>
|
|
1977
|
+
<span class="settings-row__body">
|
|
1978
|
+
<span class="settings-row__title">${escapeHtml(title)}</span>
|
|
1979
|
+
${subtitle ? `<span class="settings-row__subtitle">${escapeHtml(subtitle)}</span>` : ""}
|
|
1980
|
+
</span>
|
|
1981
|
+
<span class="settings-row__value">${escapeHtml(value || "")}</span>
|
|
1982
|
+
<span class="settings-row__chevron" aria-hidden="true">${renderIcon("chevron-right")}</span>
|
|
1983
|
+
</button>
|
|
1984
|
+
`;
|
|
1985
|
+
}
|
|
1986
|
+
|
|
1987
|
+
function renderSettingsInfoRow(label, value, options = {}) {
|
|
1988
|
+
const rowClassName = ["settings-info-row", options.rowClassName || ""].filter(Boolean).join(" ");
|
|
1989
|
+
const valueClassName = ["settings-info-row__value", options.valueClassName || ""].filter(Boolean).join(" ");
|
|
1990
|
+
return `
|
|
1991
|
+
<div class="${rowClassName}">
|
|
1992
|
+
<span class="settings-info-row__label">${escapeHtml(label)}</span>
|
|
1993
|
+
<span class="${valueClassName}">${escapeHtml(value)}</span>
|
|
1994
|
+
</div>
|
|
1995
|
+
`;
|
|
1996
|
+
}
|
|
1997
|
+
|
|
1998
|
+
function renderSettingsActionPanel(content, title = "") {
|
|
1999
|
+
return `
|
|
2000
|
+
<section class="settings-group">
|
|
2001
|
+
${title ? `<p class="settings-group__title">${escapeHtml(title)}</p>` : ""}
|
|
2002
|
+
<div class="settings-action-panel">
|
|
2003
|
+
<div class="actions actions--stack">
|
|
2004
|
+
${content}
|
|
2005
|
+
</div>
|
|
2006
|
+
</div>
|
|
2007
|
+
</section>
|
|
2008
|
+
`;
|
|
2009
|
+
}
|
|
2010
|
+
|
|
2011
|
+
function renderDeviceSection(title, devices, emptyMessage) {
|
|
2012
|
+
return `
|
|
2013
|
+
<section class="settings-group">
|
|
2014
|
+
<p class="settings-group__title">${escapeHtml(title)}</p>
|
|
2015
|
+
${
|
|
2016
|
+
devices.length
|
|
2017
|
+
? `<div class="device-list">
|
|
2018
|
+
${devices.map((device) => renderTrustedDeviceCard(device)).join("")}
|
|
2019
|
+
</div>`
|
|
2020
|
+
: `<div class="settings-copy-block"><p class="muted">${escapeHtml(emptyMessage)}</p></div>`
|
|
2021
|
+
}
|
|
2022
|
+
</section>
|
|
2023
|
+
`;
|
|
2024
|
+
}
|
|
2025
|
+
|
|
2026
|
+
function renderTrustedDeviceCard(device) {
|
|
2027
|
+
const localeLabel = localeDisplayName(device.locale, state.locale) || device.locale || L("common.unavailable");
|
|
2028
|
+
const pushLabel = device.pushSubscribed ? L("common.yes") : L("common.no");
|
|
2029
|
+
const badge = device.currentDevice
|
|
2030
|
+
? `<span class="device-card__badge">${escapeHtml(L("settings.device.thisDevice"))}</span>`
|
|
2031
|
+
: "";
|
|
2032
|
+
const actionLabel = device.currentDevice
|
|
2033
|
+
? L("settings.action.removeThisDevice")
|
|
2034
|
+
: L("settings.action.revokeDevice");
|
|
2035
|
+
|
|
2036
|
+
return `
|
|
2037
|
+
<article class="device-card">
|
|
2038
|
+
<div class="device-card__header">
|
|
2039
|
+
<div class="device-card__title-wrap">
|
|
2040
|
+
<div class="device-card__headline">
|
|
2041
|
+
<span class="device-card__icon" aria-hidden="true">${renderIcon(device.standalone ? "homescreen" : "iphone")}</span>
|
|
2042
|
+
<h3 class="device-card__title">${escapeHtml(device.displayName || L("settings.device.fallbackName"))}</h3>
|
|
2043
|
+
</div>
|
|
2044
|
+
<p class="device-card__subtitle">${escapeHtml(device.deviceId || "")}</p>
|
|
2045
|
+
</div>
|
|
2046
|
+
${badge}
|
|
2047
|
+
</div>
|
|
2048
|
+
<div class="device-card__meta">
|
|
2049
|
+
${renderDeviceMetaRow(L("settings.row.lastUsed"), formatSettingsTimestamp(device.lastAuthenticatedAtMs))}
|
|
2050
|
+
${renderDeviceMetaRow(L("settings.row.pairedAt"), formatSettingsTimestamp(device.pairedAtMs))}
|
|
2051
|
+
${renderDeviceMetaRow(L("settings.row.trustedUntil"), formatSettingsTimestamp(device.trustedUntilMs))}
|
|
2052
|
+
${renderDeviceMetaRow(L("settings.row.pushStatus"), pushLabel)}
|
|
2053
|
+
${renderDeviceMetaRow(L("settings.row.currentLanguage"), localeLabel)}
|
|
2054
|
+
</div>
|
|
2055
|
+
<div class="device-card__actions">
|
|
2056
|
+
<button
|
|
2057
|
+
class="secondary secondary--wide"
|
|
2058
|
+
type="button"
|
|
2059
|
+
data-device-revoke="${escapeHtml(device.deviceId)}"
|
|
2060
|
+
data-device-current="${device.currentDevice ? "true" : "false"}"
|
|
2061
|
+
>${escapeHtml(actionLabel)}</button>
|
|
2062
|
+
</div>
|
|
2063
|
+
</article>
|
|
2064
|
+
`;
|
|
2065
|
+
}
|
|
2066
|
+
|
|
2067
|
+
function renderDeviceMetaRow(label, value) {
|
|
2068
|
+
return `
|
|
2069
|
+
<div class="device-card__meta-row">
|
|
2070
|
+
<span class="device-card__meta-label">${escapeHtml(label)}</span>
|
|
2071
|
+
<span class="device-card__meta-value">${escapeHtml(value)}</span>
|
|
2072
|
+
</div>
|
|
2073
|
+
`;
|
|
2074
|
+
}
|
|
2075
|
+
|
|
2076
|
+
function renderDetailContent(detail, { mobile }) {
|
|
2077
|
+
if (mobile) {
|
|
2078
|
+
if (detail.kind === "choice" && detail.supported) {
|
|
2079
|
+
return renderChoiceDetailMobile(detail);
|
|
2080
|
+
}
|
|
2081
|
+
return renderStandardDetailMobile(detail);
|
|
2082
|
+
}
|
|
2083
|
+
|
|
2084
|
+
if (detail.kind === "choice" && detail.supported) {
|
|
2085
|
+
return renderChoiceDetailDesktop(detail);
|
|
2086
|
+
}
|
|
2087
|
+
|
|
2088
|
+
return renderStandardDetailDesktop(detail);
|
|
2089
|
+
}
|
|
2090
|
+
|
|
2091
|
+
function renderStandardDetailDesktop(detail) {
|
|
2092
|
+
const kindInfo = kindMeta(detail.kind);
|
|
2093
|
+
const spaciousBodyDetail = TIMELINE_MESSAGE_KINDS.has(detail.kind) || detail.kind === "completion";
|
|
2094
|
+
return `
|
|
2095
|
+
<div class="detail-shell">
|
|
2096
|
+
${renderDetailMetaRow(detail, kindInfo)}
|
|
2097
|
+
<h2 class="detail-title detail-title--desktop">${escapeHtml(detailDisplayTitle(detail))}</h2>
|
|
2098
|
+
${detail.readOnly ? "" : renderDetailLead(detail, kindInfo)}
|
|
2099
|
+
${renderPreviousContextCard(detail)}
|
|
2100
|
+
<section class="detail-card detail-card--body ${spaciousBodyDetail ? "detail-card--message-body" : ""}">
|
|
2101
|
+
<div class="detail-body ${spaciousBodyDetail ? "detail-body--message " : ""}markdown">${detail.messageHtml || ""}</div>
|
|
2102
|
+
</section>
|
|
2103
|
+
${renderCompletionReplyComposer(detail)}
|
|
2104
|
+
${detail.readOnly ? "" : renderActionButtons(detail.actions || [])}
|
|
2105
|
+
</div>
|
|
2106
|
+
`;
|
|
2107
|
+
}
|
|
2108
|
+
|
|
2109
|
+
function renderStandardDetailMobile(detail) {
|
|
2110
|
+
const kindInfo = kindMeta(detail.kind);
|
|
2111
|
+
const spaciousBodyDetail = TIMELINE_MESSAGE_KINDS.has(detail.kind) || detail.kind === "completion";
|
|
2112
|
+
return `
|
|
2113
|
+
<div class="mobile-detail-screen">
|
|
2114
|
+
<div class="detail-shell detail-shell--mobile">
|
|
2115
|
+
<div class="mobile-detail-scroll mobile-detail-scroll--detail">
|
|
2116
|
+
${renderDetailMetaRow(detail, kindInfo, { mobile: true })}
|
|
2117
|
+
${renderPreviousContextCard(detail, { mobile: true })}
|
|
2118
|
+
<section class="detail-card detail-card--body detail-card--mobile ${spaciousBodyDetail ? "detail-card--message-body" : ""}">
|
|
2119
|
+
${detail.readOnly ? "" : renderDetailLead(detail, kindInfo, { mobile: true })}
|
|
2120
|
+
<div class="detail-body ${spaciousBodyDetail ? "detail-body--message " : ""}markdown">${detail.messageHtml || ""}</div>
|
|
2121
|
+
</section>
|
|
2122
|
+
${renderCompletionReplyComposer(detail, { mobile: true })}
|
|
2123
|
+
</div>
|
|
2124
|
+
${detail.readOnly ? "" : renderActionButtons(detail.actions || [], { mobileSticky: true })}
|
|
2125
|
+
</div>
|
|
2126
|
+
</div>
|
|
2127
|
+
`;
|
|
2128
|
+
}
|
|
2129
|
+
|
|
2130
|
+
function renderDetailMetaRow(detail, kindInfo, options = {}) {
|
|
2131
|
+
const timestampLabel = detail.createdAtMs ? formatTimelineTimestamp(detail.createdAtMs) : "";
|
|
2132
|
+
const progressPill = options.progressLabel
|
|
2133
|
+
? `<span class="status-pill status-pill--pending">${escapeHtml(options.progressLabel)}</span>`
|
|
2134
|
+
: detail.readOnly
|
|
2135
|
+
? ""
|
|
2136
|
+
: `<span class="status-pill status-pill--pending">${escapeHtml(L("common.actionable"))}</span>`;
|
|
2137
|
+
return `
|
|
2138
|
+
<section class="detail-meta-row ${options.mobile ? "detail-meta-row--mobile" : ""}">
|
|
2139
|
+
<div class="detail-meta-row__left">
|
|
2140
|
+
<span class="type-pill type-pill--${escapeHtml(kindInfo.tone)}">${renderTypePillContent(kindInfo)}</span>
|
|
2141
|
+
${progressPill}
|
|
2142
|
+
</div>
|
|
2143
|
+
${timestampLabel ? `<span class="detail-meta-row__time">${escapeHtml(timestampLabel)}</span>` : ""}
|
|
2144
|
+
</section>
|
|
2145
|
+
`;
|
|
2146
|
+
}
|
|
2147
|
+
|
|
2148
|
+
function renderDetailLead(detail, kindInfo, options = {}) {
|
|
2149
|
+
return `
|
|
2150
|
+
<p class="detail-lead ${options.mobile ? "detail-lead--mobile" : ""}">
|
|
2151
|
+
<span class="detail-lead__icon" aria-hidden="true">${renderIcon(kindInfo.icon)}</span>
|
|
2152
|
+
<span>${escapeHtml(detailIntentText(detail))}</span>
|
|
2153
|
+
</p>
|
|
2154
|
+
`;
|
|
2155
|
+
}
|
|
2156
|
+
|
|
2157
|
+
function renderPreviousContextCard(detail, options = {}) {
|
|
2158
|
+
const context = detail?.previousContext;
|
|
2159
|
+
if (!context?.messageHtml || detail.kind !== "approval") {
|
|
2160
|
+
return "";
|
|
2161
|
+
}
|
|
2162
|
+
|
|
2163
|
+
const contextKind = kindMeta(context.kind || "assistant_commentary");
|
|
2164
|
+
const timestampLabel = context.createdAtMs ? formatTimelineTimestamp(context.createdAtMs) : "";
|
|
2165
|
+
return `
|
|
2166
|
+
<section class="detail-card detail-card--context ${options.mobile ? "detail-card--mobile" : ""}">
|
|
2167
|
+
<div class="detail-context-card__header">
|
|
2168
|
+
<div class="detail-context-card__eyebrow">
|
|
2169
|
+
<span class="detail-context-card__icon" aria-hidden="true">${renderIcon(contextKind.icon)}</span>
|
|
2170
|
+
<span>${escapeHtml(L("detail.previousMessage"))}</span>
|
|
2171
|
+
</div>
|
|
2172
|
+
${timestampLabel ? `<span class="detail-context-card__time">${escapeHtml(timestampLabel)}</span>` : ""}
|
|
2173
|
+
</div>
|
|
2174
|
+
<p class="detail-context-card__kind">${escapeHtml(contextKind.label)}</p>
|
|
2175
|
+
<div class="detail-body detail-body--context markdown">${context.messageHtml}</div>
|
|
2176
|
+
</section>
|
|
2177
|
+
`;
|
|
2178
|
+
}
|
|
2179
|
+
|
|
2180
|
+
function renderCompletionReplyComposer(detail, options = {}) {
|
|
2181
|
+
if (detail.kind !== "completion" || detail.reply?.enabled !== true) {
|
|
2182
|
+
return "";
|
|
2183
|
+
}
|
|
2184
|
+
|
|
2185
|
+
const draft = getCompletionReplyDraft(detail.token);
|
|
2186
|
+
const planMode = draft.mode === "plan";
|
|
2187
|
+
const sendLabel = draft.sending
|
|
2188
|
+
? L("reply.sendSending")
|
|
2189
|
+
: draft.confirmOverride
|
|
2190
|
+
? L("reply.sendConfirm")
|
|
2191
|
+
: L("reply.send");
|
|
2192
|
+
const disabled = draft.sending || !normalizeClientText(draft.text);
|
|
2193
|
+
const warningTimestamp = draft.warning?.createdAtMs ? formatTimelineTimestamp(draft.warning.createdAtMs) : "";
|
|
2194
|
+
const showCollapsedState =
|
|
2195
|
+
draft.collapsedAfterSend && Boolean(draft.notice) && !draft.error && !draft.warning && !draft.sending;
|
|
2196
|
+
|
|
2197
|
+
return `
|
|
2198
|
+
<section class="detail-card detail-card--reply ${options.mobile ? "detail-card--mobile" : ""}">
|
|
2199
|
+
<div class="reply-composer">
|
|
2200
|
+
<div class="reply-composer__copy">
|
|
2201
|
+
<span class="eyebrow-pill eyebrow-pill--quiet">${escapeHtml(L("reply.eyebrow"))}</span>
|
|
2202
|
+
<h3 class="reply-composer__title">${escapeHtml(L("reply.title"))}</h3>
|
|
2203
|
+
<p class="muted reply-composer__description">${escapeHtml(L("reply.copy"))}</p>
|
|
2204
|
+
</div>
|
|
2205
|
+
${draft.notice ? `<p class="inline-alert inline-alert--success">${escapeHtml(draft.notice)}</p>` : ""}
|
|
2206
|
+
${draft.error ? `<p class="inline-alert inline-alert--danger">${escapeHtml(draft.error)}</p>` : ""}
|
|
2207
|
+
${
|
|
2208
|
+
draft.warning
|
|
2209
|
+
? `
|
|
2210
|
+
<div class="inline-alert inline-alert--warning reply-warning">
|
|
2211
|
+
<p class="reply-warning__title">${escapeHtml(L("reply.warning.title"))}</p>
|
|
2212
|
+
<p class="reply-warning__copy">${escapeHtml(L("reply.warning.copy"))}</p>
|
|
2213
|
+
${
|
|
2214
|
+
draft.warning.summary || warningTimestamp
|
|
2215
|
+
? `
|
|
2216
|
+
<p class="reply-warning__meta">
|
|
2217
|
+
${warningTimestamp ? `<span>${escapeHtml(warningTimestamp)}</span>` : ""}
|
|
2218
|
+
${draft.warning.summary ? `<span>${escapeHtml(draft.warning.summary)}</span>` : ""}
|
|
2219
|
+
</p>
|
|
2220
|
+
`
|
|
2221
|
+
: ""
|
|
2222
|
+
}
|
|
2223
|
+
</div>
|
|
2224
|
+
`
|
|
2225
|
+
: ""
|
|
2226
|
+
}
|
|
2227
|
+
${
|
|
2228
|
+
showCollapsedState
|
|
2229
|
+
? `
|
|
2230
|
+
<div class="reply-sent-summary">
|
|
2231
|
+
${
|
|
2232
|
+
draft.sentText
|
|
2233
|
+
? `
|
|
2234
|
+
<div class="reply-sent-summary__preview">
|
|
2235
|
+
<p class="reply-sent-summary__label">${escapeHtml(L("reply.sentPreviewLabel"))}</p>
|
|
2236
|
+
<p class="reply-sent-summary__text">${escapeHtml(draft.sentText)}</p>
|
|
2237
|
+
</div>
|
|
2238
|
+
`
|
|
2239
|
+
: ""
|
|
2240
|
+
}
|
|
2241
|
+
<div class="actions actions--stack">
|
|
2242
|
+
<button class="secondary secondary--wide" type="button" data-reopen-completion-reply data-token="${escapeHtml(detail.token)}">
|
|
2243
|
+
${escapeHtml(L("reply.sendAnother"))}
|
|
2244
|
+
</button>
|
|
2245
|
+
</div>
|
|
2246
|
+
</div>
|
|
2247
|
+
`
|
|
2248
|
+
: `
|
|
2249
|
+
<form class="reply-composer__form" data-completion-reply-form data-token="${escapeHtml(detail.token)}">
|
|
2250
|
+
<label class="field reply-field">
|
|
2251
|
+
<span class="field-label">${escapeHtml(L("reply.fieldLabel"))}</span>
|
|
2252
|
+
<textarea
|
|
2253
|
+
class="reply-field__input"
|
|
2254
|
+
name="text"
|
|
2255
|
+
rows="4"
|
|
2256
|
+
placeholder="${escapeHtml(L("reply.placeholder"))}"
|
|
2257
|
+
data-completion-reply-textarea
|
|
2258
|
+
data-reply-token="${escapeHtml(detail.token)}"
|
|
2259
|
+
>${escapeHtml(draft.text)}</textarea>
|
|
2260
|
+
</label>
|
|
2261
|
+
${
|
|
2262
|
+
detail.reply?.supportsPlanMode
|
|
2263
|
+
? `
|
|
2264
|
+
<label class="reply-mode-switch" data-reply-mode-switch>
|
|
2265
|
+
<input
|
|
2266
|
+
class="reply-mode-switch__input"
|
|
2267
|
+
type="checkbox"
|
|
2268
|
+
${planMode ? "checked" : ""}
|
|
2269
|
+
data-reply-mode-toggle
|
|
2270
|
+
data-reply-token="${escapeHtml(detail.token)}"
|
|
2271
|
+
>
|
|
2272
|
+
<span class="reply-mode-switch__track" aria-hidden="true">
|
|
2273
|
+
<span class="reply-mode-switch__thumb"></span>
|
|
2274
|
+
</span>
|
|
2275
|
+
<span class="reply-mode-switch__copy">
|
|
2276
|
+
<span class="reply-mode-switch__title">
|
|
2277
|
+
<span>${escapeHtml(L("reply.mode.planLabel"))}</span>
|
|
2278
|
+
<span class="reply-mode-switch__state">${escapeHtml(L(planMode ? "reply.mode.on" : "reply.mode.off"))}</span>
|
|
2279
|
+
</span>
|
|
2280
|
+
<span class="reply-mode-switch__hint">${escapeHtml(L(planMode ? "reply.mode.planHint" : "reply.mode.defaultHint"))}</span>
|
|
2281
|
+
</span>
|
|
2282
|
+
</label>
|
|
2283
|
+
`
|
|
2284
|
+
: ""
|
|
2285
|
+
}
|
|
2286
|
+
<div class="actions actions--stack">
|
|
2287
|
+
<button class="primary primary--wide" type="submit" ${disabled ? "disabled" : ""}>${escapeHtml(sendLabel)}</button>
|
|
2288
|
+
</div>
|
|
2289
|
+
</form>
|
|
2290
|
+
`
|
|
2291
|
+
}
|
|
2292
|
+
</div>
|
|
2293
|
+
</section>
|
|
2294
|
+
`;
|
|
2295
|
+
}
|
|
2296
|
+
|
|
2297
|
+
function renderChoiceQuestions(detail) {
|
|
2298
|
+
const effectiveAnswers = getEffectiveChoiceDraftAnswers(detail);
|
|
2299
|
+
return detail.questions
|
|
2300
|
+
.map((question) => {
|
|
2301
|
+
const questionTitle = question.header || question.prompt;
|
|
2302
|
+
const promptCopy = question.prompt && question.prompt !== questionTitle ? question.prompt : "";
|
|
2303
|
+
const questionHint = choiceQuestionHintText(question);
|
|
2304
|
+
return `
|
|
2305
|
+
<fieldset class="choice-question">
|
|
2306
|
+
<legend>${escapeHtml(questionTitle)}</legend>
|
|
2307
|
+
${promptCopy ? `<p class="muted choice-question__prompt">${escapeHtml(promptCopy)}</p>` : ""}
|
|
2308
|
+
${questionHint ? `<p class="choice-question__hint">${escapeHtml(questionHint)}</p>` : ""}
|
|
2309
|
+
<div class="choice-options">
|
|
2310
|
+
${question.options
|
|
2311
|
+
.map((option) => {
|
|
2312
|
+
const value = option.id || option.label;
|
|
2313
|
+
const checked = effectiveAnswers?.[question.id] === value ? "checked" : "";
|
|
2314
|
+
const optionDescription = choiceOptionHintText(option);
|
|
2315
|
+
return `
|
|
2316
|
+
<label class="choice-option">
|
|
2317
|
+
<input type="radio" name="${escapeHtml(question.id)}" value="${escapeHtml(value)}" ${checked} required>
|
|
2318
|
+
<span class="choice-option__content">
|
|
2319
|
+
<span class="choice-option__label">${escapeHtml(option.label)}</span>
|
|
2320
|
+
${optionDescription ? `<span class="choice-option__description">${escapeHtml(optionDescription)}</span>` : ""}
|
|
2321
|
+
</span>
|
|
2322
|
+
</label>
|
|
2323
|
+
`;
|
|
2324
|
+
})
|
|
2325
|
+
.join("")}
|
|
2326
|
+
</div>
|
|
2327
|
+
</fieldset>
|
|
2328
|
+
`;
|
|
2329
|
+
})
|
|
2330
|
+
.join("");
|
|
2331
|
+
}
|
|
2332
|
+
|
|
2333
|
+
function choiceQuestionHintText(question) {
|
|
2334
|
+
if (!question || typeof question !== "object") {
|
|
2335
|
+
return "";
|
|
2336
|
+
}
|
|
2337
|
+
const title = normalizeClientText(question.header || question.prompt || "");
|
|
2338
|
+
const prompt = normalizeClientText(question.prompt || question.header || "");
|
|
2339
|
+
const hint =
|
|
2340
|
+
[
|
|
2341
|
+
question.tooltip,
|
|
2342
|
+
question.toolTip,
|
|
2343
|
+
question.hint,
|
|
2344
|
+
question.hintText,
|
|
2345
|
+
question.helpText,
|
|
2346
|
+
question.description,
|
|
2347
|
+
question.subtitle,
|
|
2348
|
+
question.detail,
|
|
2349
|
+
]
|
|
2350
|
+
.map((value) => normalizeClientText(value))
|
|
2351
|
+
.find(Boolean) || "";
|
|
2352
|
+
|
|
2353
|
+
if (!hint || hint === title || hint === prompt) {
|
|
2354
|
+
return "";
|
|
2355
|
+
}
|
|
2356
|
+
|
|
2357
|
+
return hint;
|
|
2358
|
+
}
|
|
2359
|
+
|
|
2360
|
+
function choiceOptionHintText(option) {
|
|
2361
|
+
if (!option || typeof option !== "object") {
|
|
2362
|
+
return "";
|
|
2363
|
+
}
|
|
2364
|
+
return [
|
|
2365
|
+
option.description,
|
|
2366
|
+
option.hint,
|
|
2367
|
+
option.hintText,
|
|
2368
|
+
option.helpText,
|
|
2369
|
+
option.subtitle,
|
|
2370
|
+
option.detail,
|
|
2371
|
+
]
|
|
2372
|
+
.map((value) => normalizeClientText(value))
|
|
2373
|
+
.find(Boolean) || "";
|
|
2374
|
+
}
|
|
2375
|
+
|
|
2376
|
+
function renderChoiceActionBar(detail) {
|
|
2377
|
+
return `
|
|
2378
|
+
<div class="detail-action-bar">
|
|
2379
|
+
<div class="actions actions--stack actions--sticky">
|
|
2380
|
+
${detail.page > 1 ? `<button class="secondary secondary--wide" type="submit" data-flow="prev">${escapeHtml(L("common.back"))}</button>` : ""}
|
|
2381
|
+
${
|
|
2382
|
+
detail.page < detail.totalPages
|
|
2383
|
+
? `<button class="primary primary--wide" type="submit" data-flow="next">${escapeHtml(L("common.next"))}</button>`
|
|
2384
|
+
: `<button class="primary primary--wide" type="submit" data-flow="submit">${escapeHtml(L("choice.submit"))}</button>`
|
|
2385
|
+
}
|
|
2386
|
+
</div>
|
|
2387
|
+
</div>
|
|
2388
|
+
`;
|
|
2389
|
+
}
|
|
2390
|
+
|
|
2391
|
+
function renderChoiceDetailDesktop(detail) {
|
|
2392
|
+
const kindInfo = kindMeta("choice");
|
|
2393
|
+
return `
|
|
2394
|
+
<div class="detail-shell">
|
|
2395
|
+
${renderDetailMetaRow(detail, kindInfo, {
|
|
2396
|
+
progressLabel: L("detail.pageProgress", { page: detail.page, totalPages: detail.totalPages }),
|
|
2397
|
+
})}
|
|
2398
|
+
<h2 class="detail-title detail-title--desktop">${escapeHtml(detailDisplayTitle(detail))}</h2>
|
|
2399
|
+
${renderDetailLead(detail, kindInfo)}
|
|
2400
|
+
<form class="choice-form" data-choice-form data-token="${escapeHtml(detail.token)}" data-page="${detail.page}" data-total-pages="${detail.totalPages}">
|
|
2401
|
+
<section class="detail-card detail-card--choice">
|
|
2402
|
+
<div class="choice-stack">
|
|
2403
|
+
${renderChoiceQuestions(detail)}
|
|
2404
|
+
</div>
|
|
2405
|
+
</section>
|
|
2406
|
+
<div class="actions actions--stack">
|
|
2407
|
+
${detail.page > 1 ? `<button class="secondary secondary--wide" type="submit" data-flow="prev">${escapeHtml(L("common.back"))}</button>` : ""}
|
|
2408
|
+
${
|
|
2409
|
+
detail.page < detail.totalPages
|
|
2410
|
+
? `<button class="primary primary--wide" type="submit" data-flow="next">${escapeHtml(L("common.next"))}</button>`
|
|
2411
|
+
: `<button class="primary primary--wide" type="submit" data-flow="submit">${escapeHtml(L("choice.submit"))}</button>`
|
|
2412
|
+
}
|
|
2413
|
+
</div>
|
|
2414
|
+
</form>
|
|
2415
|
+
</div>
|
|
2416
|
+
`;
|
|
2417
|
+
}
|
|
2418
|
+
|
|
2419
|
+
function renderChoiceDetailMobile(detail) {
|
|
2420
|
+
const kindInfo = kindMeta("choice");
|
|
2421
|
+
return `
|
|
2422
|
+
<form class="choice-form choice-form--mobile" data-choice-form data-token="${escapeHtml(detail.token)}" data-page="${detail.page}" data-total-pages="${detail.totalPages}">
|
|
2423
|
+
<div class="mobile-detail-screen">
|
|
2424
|
+
<div class="detail-shell detail-shell--mobile">
|
|
2425
|
+
<div class="mobile-detail-scroll mobile-detail-scroll--detail">
|
|
2426
|
+
${renderDetailMetaRow(detail, kindInfo, {
|
|
2427
|
+
mobile: true,
|
|
2428
|
+
progressLabel: L("detail.pageProgress", { page: detail.page, totalPages: detail.totalPages }),
|
|
2429
|
+
})}
|
|
2430
|
+
<section class="detail-card detail-card--choice detail-card--mobile">
|
|
2431
|
+
${renderDetailLead(detail, kindInfo, { mobile: true })}
|
|
2432
|
+
<div class="choice-stack">
|
|
2433
|
+
${renderChoiceQuestions(detail)}
|
|
2434
|
+
</div>
|
|
2435
|
+
</section>
|
|
2436
|
+
</div>
|
|
2437
|
+
${renderChoiceActionBar(detail)}
|
|
2438
|
+
</div>
|
|
2439
|
+
</div>
|
|
2440
|
+
</form>
|
|
2441
|
+
`;
|
|
2442
|
+
}
|
|
2443
|
+
|
|
2444
|
+
function renderActionButtons(actions, options = {}) {
|
|
2445
|
+
if (!actions.length) {
|
|
2446
|
+
return "";
|
|
2447
|
+
}
|
|
2448
|
+
const actionsHtml = `
|
|
2449
|
+
<div class="actions actions--stack ${options.mobileSticky ? "actions--sticky" : ""}">
|
|
2450
|
+
${actions
|
|
2451
|
+
.map(
|
|
2452
|
+
(action) => `
|
|
2453
|
+
<button
|
|
2454
|
+
class="${escapeHtml(actionClassForTone(action.tone))}"
|
|
2455
|
+
data-action-url="${escapeHtml(action.url)}"
|
|
2456
|
+
data-action-body='${escapeHtml(JSON.stringify(action.body || {}))}'
|
|
2457
|
+
>
|
|
2458
|
+
${escapeHtml(action.label)}
|
|
2459
|
+
</button>
|
|
2460
|
+
`
|
|
2461
|
+
)
|
|
2462
|
+
.join("")}
|
|
2463
|
+
</div>
|
|
2464
|
+
`;
|
|
2465
|
+
|
|
2466
|
+
if (options.mobileSticky) {
|
|
2467
|
+
return `<div class="detail-action-bar">${actionsHtml}</div>`;
|
|
2468
|
+
}
|
|
2469
|
+
|
|
2470
|
+
return actionsHtml;
|
|
2471
|
+
}
|
|
2472
|
+
|
|
2473
|
+
function renderDetailLoading({ mobile }) {
|
|
2474
|
+
const snapshot = buildDetailLoadingSnapshot();
|
|
2475
|
+
if (!snapshot) {
|
|
2476
|
+
return renderDetailEmpty();
|
|
2477
|
+
}
|
|
2478
|
+
const kindInfo = kindMeta(snapshot.kind);
|
|
2479
|
+
const content = `
|
|
2480
|
+
${renderDetailMetaRow(snapshot, kindInfo, {
|
|
2481
|
+
mobile,
|
|
2482
|
+
progressLabel: L("common.loading"),
|
|
2483
|
+
})}
|
|
2484
|
+
<section class="detail-card detail-card--body ${mobile ? "detail-card--mobile" : ""}">
|
|
2485
|
+
<div class="detail-loading">
|
|
2486
|
+
<p class="detail-loading__copy">${escapeHtml(L("detail.loadingCopy"))}</p>
|
|
2487
|
+
<div class="detail-loading__lines" aria-hidden="true">
|
|
2488
|
+
<span class="detail-loading__line detail-loading__line--long"></span>
|
|
2489
|
+
<span class="detail-loading__line detail-loading__line--mid"></span>
|
|
2490
|
+
<span class="detail-loading__line detail-loading__line--short"></span>
|
|
2491
|
+
</div>
|
|
2492
|
+
</div>
|
|
2493
|
+
</section>
|
|
2494
|
+
`;
|
|
2495
|
+
|
|
2496
|
+
if (mobile) {
|
|
2497
|
+
return `
|
|
2498
|
+
<div class="mobile-detail-screen">
|
|
2499
|
+
<div class="detail-shell detail-shell--mobile">
|
|
2500
|
+
<div class="mobile-detail-scroll mobile-detail-scroll--detail">
|
|
2501
|
+
${content}
|
|
2502
|
+
</div>
|
|
2503
|
+
</div>
|
|
2504
|
+
</div>
|
|
2505
|
+
`;
|
|
2506
|
+
}
|
|
2507
|
+
|
|
2508
|
+
return `
|
|
2509
|
+
<div class="detail-shell">
|
|
2510
|
+
${content}
|
|
2511
|
+
</div>
|
|
2512
|
+
`;
|
|
2513
|
+
}
|
|
2514
|
+
|
|
2515
|
+
function renderDetailEmpty() {
|
|
2516
|
+
return `
|
|
2517
|
+
<div class="detail-empty">
|
|
2518
|
+
<span class="eyebrow-pill">${escapeHtml(L("common.select"))}</span>
|
|
2519
|
+
<h2 class="detail-title">${escapeHtml(L("detail.selectTitle"))}</h2>
|
|
2520
|
+
<p class="muted">${escapeHtml(L("detail.selectCopy"))}</p>
|
|
2521
|
+
</div>
|
|
2522
|
+
`;
|
|
2523
|
+
}
|
|
2524
|
+
|
|
2525
|
+
function renderInstallBanner() {
|
|
2526
|
+
if (!shouldShowInstallBanner()) {
|
|
2527
|
+
return "";
|
|
2528
|
+
}
|
|
2529
|
+
return `
|
|
2530
|
+
<section class="install-banner">
|
|
2531
|
+
<div class="install-banner__copy">
|
|
2532
|
+
<strong>${escapeHtml(L("banner.install.title"))}</strong>
|
|
2533
|
+
<p class="muted">${escapeHtml(installBannerCopy())}</p>
|
|
2534
|
+
</div>
|
|
2535
|
+
<div class="actions install-banner__actions">
|
|
2536
|
+
<button class="secondary" type="button" data-install-guide-open>${escapeHtml(L("common.addToHomeScreen"))}</button>
|
|
2537
|
+
<button class="ghost" type="button" data-dismiss-install>${escapeHtml(L("common.notNow"))}</button>
|
|
2538
|
+
</div>
|
|
2539
|
+
</section>
|
|
2540
|
+
`;
|
|
2541
|
+
}
|
|
2542
|
+
|
|
2543
|
+
function renderTopBanner() {
|
|
2544
|
+
if (!isDesktopLayout() && (state.detailOpen || isSettingsSubpageOpen())) {
|
|
2545
|
+
return "";
|
|
2546
|
+
}
|
|
2547
|
+
if (shouldShowInstallBanner()) {
|
|
2548
|
+
return renderInstallBanner();
|
|
2549
|
+
}
|
|
2550
|
+
if (shouldShowPushBanner()) {
|
|
2551
|
+
return renderPushBanner();
|
|
2552
|
+
}
|
|
2553
|
+
return "";
|
|
2554
|
+
}
|
|
2555
|
+
|
|
2556
|
+
function renderPushBanner() {
|
|
2557
|
+
if (!shouldShowPushBanner()) {
|
|
2558
|
+
return "";
|
|
2559
|
+
}
|
|
2560
|
+
const canEnable = canEnableNotificationsFromCurrentContext();
|
|
2561
|
+
return `
|
|
2562
|
+
<section class="install-banner install-banner--push">
|
|
2563
|
+
<div class="install-banner__copy">
|
|
2564
|
+
<strong>${escapeHtml(L("banner.push.title"))}</strong>
|
|
2565
|
+
<p class="muted">${escapeHtml(pushBannerCopy())}</p>
|
|
2566
|
+
</div>
|
|
2567
|
+
<div class="actions install-banner__actions">
|
|
2568
|
+
${
|
|
2569
|
+
canEnable
|
|
2570
|
+
? `<button class="primary" type="button" data-push-action="enable">${escapeHtml(L("common.enableNow"))}</button>`
|
|
2571
|
+
: `<button class="secondary" type="button" data-open-settings-page="notifications">${escapeHtml(L("common.notificationSettings"))}</button>`
|
|
2572
|
+
}
|
|
2573
|
+
<button class="ghost" type="button" data-dismiss-push-banner>${escapeHtml(L("common.notNow"))}</button>
|
|
2574
|
+
</div>
|
|
2575
|
+
</section>
|
|
2576
|
+
`;
|
|
2577
|
+
}
|
|
2578
|
+
|
|
2579
|
+
function renderInstallGuideModal() {
|
|
2580
|
+
if (!state.installGuideOpen) {
|
|
2581
|
+
return "";
|
|
2582
|
+
}
|
|
2583
|
+
return `
|
|
2584
|
+
<div class="modal-backdrop" data-install-guide-close>
|
|
2585
|
+
<section class="modal-card" role="dialog" aria-modal="true" aria-labelledby="install-guide-title">
|
|
2586
|
+
<div class="stack">
|
|
2587
|
+
<span class="eyebrow-pill">${escapeHtml(L("common.appName"))}</span>
|
|
2588
|
+
<h2 id="install-guide-title" class="detail-title">${escapeHtml(L("install.guide.title"))}</h2>
|
|
2589
|
+
<p class="muted">${escapeHtml(installGuideIntro())}</p>
|
|
2590
|
+
<ol class="install-steps">
|
|
2591
|
+
${installGuideSteps()
|
|
2592
|
+
.map((step) => `<li>${escapeHtml(step)}</li>`)
|
|
2593
|
+
.join("")}
|
|
2594
|
+
</ol>
|
|
2595
|
+
<div class="actions actions--stack">
|
|
2596
|
+
<button class="primary primary--wide" type="button" data-install-guide-close>${escapeHtml(L("common.gotIt"))}</button>
|
|
2597
|
+
</div>
|
|
2598
|
+
</div>
|
|
2599
|
+
</section>
|
|
2600
|
+
</div>
|
|
2601
|
+
`;
|
|
2602
|
+
}
|
|
2603
|
+
|
|
2604
|
+
function renderLogoutConfirmModal() {
|
|
2605
|
+
if (!state.logoutConfirmOpen || !state.session?.authenticated) {
|
|
2606
|
+
return "";
|
|
2607
|
+
}
|
|
2608
|
+
|
|
2609
|
+
return `
|
|
2610
|
+
<div class="modal-backdrop" data-close-logout-confirm>
|
|
2611
|
+
<section class="modal-card modal-card--confirm" role="dialog" aria-modal="true" aria-labelledby="logout-confirm-title">
|
|
2612
|
+
<div class="helper-copy">
|
|
2613
|
+
<strong id="logout-confirm-title">${escapeHtml(L("logout.confirm.title"))}</strong>
|
|
2614
|
+
<p class="muted">${escapeHtml(L("logout.confirm.copy"))}</p>
|
|
2615
|
+
</div>
|
|
2616
|
+
<div class="logout-option">
|
|
2617
|
+
<div class="logout-option__copy">
|
|
2618
|
+
<strong>${escapeHtml(L("logout.confirm.keepTrustedTitle"))}</strong>
|
|
2619
|
+
<p class="muted">${escapeHtml(L("logout.confirm.keepTrustedCopy"))}</p>
|
|
2620
|
+
</div>
|
|
2621
|
+
<button class="primary primary--wide" type="button" data-logout-mode="session">${escapeHtml(L("logout.action.keepTrusted"))}</button>
|
|
2622
|
+
</div>
|
|
2623
|
+
<div class="logout-option logout-option--danger">
|
|
2624
|
+
<div class="logout-option__copy">
|
|
2625
|
+
<strong>${escapeHtml(L("logout.confirm.removeTitle"))}</strong>
|
|
2626
|
+
<p class="muted">${escapeHtml(L("logout.confirm.removeCopy"))}</p>
|
|
2627
|
+
</div>
|
|
2628
|
+
<button class="secondary secondary--wide" type="button" data-logout-mode="revoke">${escapeHtml(L("logout.action.removeDevice"))}</button>
|
|
2629
|
+
</div>
|
|
2630
|
+
<button class="ghost ghost--wide" type="button" data-close-logout-confirm>${escapeHtml(L("common.cancel"))}</button>
|
|
2631
|
+
</section>
|
|
2632
|
+
</div>
|
|
2633
|
+
`;
|
|
2634
|
+
}
|
|
2635
|
+
|
|
2636
|
+
function renderDesktopTabs() {
|
|
2637
|
+
return `
|
|
2638
|
+
<nav class="segmented-nav" aria-label="Sections">
|
|
2639
|
+
${renderTabButtons({ buttonClass: "segmented-nav__button", withIcons: false })}
|
|
2640
|
+
</nav>
|
|
2641
|
+
`;
|
|
2642
|
+
}
|
|
2643
|
+
|
|
2644
|
+
function renderBottomTabs() {
|
|
2645
|
+
return `
|
|
2646
|
+
<nav class="bottom-nav" aria-label="Sections">
|
|
2647
|
+
${renderTabButtons({ buttonClass: "bottom-nav__button", withIcons: true })}
|
|
2648
|
+
</nav>
|
|
2649
|
+
`;
|
|
2650
|
+
}
|
|
2651
|
+
|
|
2652
|
+
function renderTabButtons({ buttonClass, withIcons }) {
|
|
2653
|
+
return tabs()
|
|
2654
|
+
.map(
|
|
2655
|
+
(tab) => `
|
|
2656
|
+
<button class="${buttonClass} ${state.currentTab === tab.id ? "is-active" : ""}" data-tab="${escapeHtml(tab.id)}">
|
|
2657
|
+
${withIcons ? `<span class="tab-icon" aria-hidden="true">${renderIcon(tab.icon)}</span>` : ""}
|
|
2658
|
+
<span class="tab-label">${escapeHtml(tab.label)}</span>
|
|
2659
|
+
</button>
|
|
2660
|
+
`
|
|
2661
|
+
)
|
|
2662
|
+
.join("");
|
|
2663
|
+
}
|
|
2664
|
+
|
|
2665
|
+
function bindShellInteractions() {
|
|
2666
|
+
for (const button of document.querySelectorAll("[data-tab]")) {
|
|
2667
|
+
button.addEventListener("click", async () => {
|
|
2668
|
+
await switchTab(button.dataset.tab);
|
|
2669
|
+
});
|
|
2670
|
+
}
|
|
2671
|
+
|
|
2672
|
+
for (const button of document.querySelectorAll("[data-open-settings], [data-open-settings-page]")) {
|
|
2673
|
+
button.addEventListener("click", async () => {
|
|
2674
|
+
clearChoiceLocalDraftForItem(state.currentItem);
|
|
2675
|
+
state.currentTab = "settings";
|
|
2676
|
+
state.detailOpen = false;
|
|
2677
|
+
state.settingsSubpage = "";
|
|
2678
|
+
clearPinnedDetailState();
|
|
2679
|
+
syncCurrentItemUrl(null);
|
|
2680
|
+
const nextPage = button.dataset.openSettingsPage || "";
|
|
2681
|
+
if (nextPage) {
|
|
2682
|
+
openSettingsSubpage(nextPage);
|
|
2683
|
+
}
|
|
2684
|
+
await renderShell();
|
|
2685
|
+
});
|
|
2686
|
+
}
|
|
2687
|
+
|
|
2688
|
+
for (const button of document.querySelectorAll("[data-open-technical]")) {
|
|
2689
|
+
button.addEventListener("click", async () => {
|
|
2690
|
+
clearChoiceLocalDraftForItem(state.currentItem);
|
|
2691
|
+
state.currentTab = "settings";
|
|
2692
|
+
state.detailOpen = false;
|
|
2693
|
+
clearPinnedDetailState();
|
|
2694
|
+
syncCurrentItemUrl(null);
|
|
2695
|
+
openSettingsSubpage("advanced");
|
|
2696
|
+
await renderShell();
|
|
2697
|
+
});
|
|
2698
|
+
}
|
|
2699
|
+
|
|
2700
|
+
for (const button of document.querySelectorAll("[data-settings-subpage]")) {
|
|
2701
|
+
button.addEventListener("click", async () => {
|
|
2702
|
+
openSettingsSubpage(button.dataset.settingsSubpage || "");
|
|
2703
|
+
await renderShell();
|
|
2704
|
+
});
|
|
2705
|
+
}
|
|
2706
|
+
|
|
2707
|
+
for (const button of document.querySelectorAll("[data-settings-back]")) {
|
|
2708
|
+
button.addEventListener("click", async () => {
|
|
2709
|
+
closeSettingsSubpage();
|
|
2710
|
+
await renderShell();
|
|
2711
|
+
});
|
|
2712
|
+
}
|
|
2713
|
+
|
|
2714
|
+
for (const select of document.querySelectorAll("[data-timeline-thread-select]")) {
|
|
2715
|
+
select.addEventListener("change", async () => {
|
|
2716
|
+
state.timelineThreadFilter = select.value || "all";
|
|
2717
|
+
alignCurrentItemToVisibleEntries();
|
|
2718
|
+
await renderShell();
|
|
2719
|
+
});
|
|
2720
|
+
}
|
|
2721
|
+
|
|
2722
|
+
for (const select of document.querySelectorAll("[data-completed-thread-select]")) {
|
|
2723
|
+
select.addEventListener("change", async () => {
|
|
2724
|
+
state.completedThreadFilter = select.value || "all";
|
|
2725
|
+
alignCurrentItemToVisibleEntries();
|
|
2726
|
+
await renderShell();
|
|
2727
|
+
});
|
|
2728
|
+
}
|
|
2729
|
+
|
|
2730
|
+
for (const button of document.querySelectorAll("[data-open-item-kind][data-open-item-token]")) {
|
|
2731
|
+
button.addEventListener("click", async () => {
|
|
2732
|
+
openItem({
|
|
2733
|
+
kind: button.dataset.openItemKind,
|
|
2734
|
+
token: button.dataset.openItemToken,
|
|
2735
|
+
sourceTab: button.dataset.sourceTab,
|
|
2736
|
+
});
|
|
2737
|
+
await renderShell();
|
|
2738
|
+
});
|
|
2739
|
+
}
|
|
2740
|
+
|
|
2741
|
+
for (const button of document.querySelectorAll("[data-back-to-list]")) {
|
|
2742
|
+
button.addEventListener("click", async () => {
|
|
2743
|
+
clearChoiceLocalDraftForItem(state.currentItem);
|
|
2744
|
+
state.detailOpen = false;
|
|
2745
|
+
state.pendingListScrollRestore = !isDesktopLayout() && Boolean(state.listScrollState);
|
|
2746
|
+
clearPinnedDetailState();
|
|
2747
|
+
syncCurrentItemUrl(null);
|
|
2748
|
+
await renderShell();
|
|
2749
|
+
});
|
|
2750
|
+
}
|
|
2751
|
+
|
|
2752
|
+
for (const button of document.querySelectorAll("[data-action-url]")) {
|
|
2753
|
+
button.addEventListener("click", async () => {
|
|
2754
|
+
const body = button.dataset.actionBody ? JSON.parse(button.dataset.actionBody) : {};
|
|
2755
|
+
const activeItem = state.currentItem ? { ...state.currentItem } : null;
|
|
2756
|
+
const keepDetailOpen = shouldKeepDetailAfterAction(activeItem);
|
|
2757
|
+
await apiPost(button.dataset.actionUrl, body);
|
|
2758
|
+
if (keepDetailOpen && activeItem?.kind === "approval") {
|
|
2759
|
+
pinActionOutcomeDetail(
|
|
2760
|
+
activeItem,
|
|
2761
|
+
buildActionOutcomeDetail({
|
|
2762
|
+
kind: "approval",
|
|
2763
|
+
title: state.currentDetail?.title,
|
|
2764
|
+
message: approvalOutcomeMessage(button.dataset.actionUrl),
|
|
2765
|
+
})
|
|
2766
|
+
);
|
|
2767
|
+
}
|
|
2768
|
+
await refreshAuthenticatedState();
|
|
2769
|
+
if (!keepDetailOpen && !isDesktopLayout()) {
|
|
2770
|
+
state.detailOpen = false;
|
|
2771
|
+
syncCurrentItemUrl(null);
|
|
2772
|
+
}
|
|
2773
|
+
await renderShell();
|
|
2774
|
+
});
|
|
2775
|
+
}
|
|
2776
|
+
|
|
2777
|
+
for (const input of document.querySelectorAll("[data-reply-mode-toggle][data-reply-token]")) {
|
|
2778
|
+
input.addEventListener("change", async () => {
|
|
2779
|
+
const token = input.dataset.replyToken || "";
|
|
2780
|
+
setCompletionReplyDraft(token, {
|
|
2781
|
+
mode: input.checked ? "plan" : "default",
|
|
2782
|
+
notice: "",
|
|
2783
|
+
error: "",
|
|
2784
|
+
warning: null,
|
|
2785
|
+
confirmOverride: false,
|
|
2786
|
+
});
|
|
2787
|
+
await renderShell();
|
|
2788
|
+
});
|
|
2789
|
+
}
|
|
2790
|
+
|
|
2791
|
+
for (const button of document.querySelectorAll("[data-reopen-completion-reply][data-token]")) {
|
|
2792
|
+
button.addEventListener("click", async () => {
|
|
2793
|
+
const token = button.dataset.token || "";
|
|
2794
|
+
setCompletionReplyDraft(token, {
|
|
2795
|
+
notice: "",
|
|
2796
|
+
error: "",
|
|
2797
|
+
warning: null,
|
|
2798
|
+
confirmOverride: false,
|
|
2799
|
+
collapsedAfterSend: false,
|
|
2800
|
+
});
|
|
2801
|
+
await renderShell();
|
|
2802
|
+
});
|
|
2803
|
+
}
|
|
2804
|
+
|
|
2805
|
+
for (const button of document.querySelectorAll("[data-open-logout-confirm]")) {
|
|
2806
|
+
button.addEventListener("click", async () => {
|
|
2807
|
+
state.logoutConfirmOpen = true;
|
|
2808
|
+
await renderShell();
|
|
2809
|
+
});
|
|
2810
|
+
}
|
|
2811
|
+
|
|
2812
|
+
for (const button of document.querySelectorAll("[data-logout-mode]")) {
|
|
2813
|
+
button.addEventListener("click", async () => {
|
|
2814
|
+
try {
|
|
2815
|
+
await logout({ revokeCurrentDeviceTrust: button.dataset.logoutMode === "revoke" });
|
|
2816
|
+
} catch (error) {
|
|
2817
|
+
state.deviceError = error.message || String(error);
|
|
2818
|
+
state.logoutConfirmOpen = false;
|
|
2819
|
+
await renderShell();
|
|
2820
|
+
}
|
|
2821
|
+
});
|
|
2822
|
+
}
|
|
2823
|
+
|
|
2824
|
+
for (const button of document.querySelectorAll("[data-device-revoke]")) {
|
|
2825
|
+
button.addEventListener("click", async () => {
|
|
2826
|
+
state.deviceNotice = "";
|
|
2827
|
+
state.deviceError = "";
|
|
2828
|
+
state.logoutConfirmOpen = false;
|
|
2829
|
+
try {
|
|
2830
|
+
await revokeTrustedDevice(button.dataset.deviceRevoke || "");
|
|
2831
|
+
} catch (error) {
|
|
2832
|
+
state.deviceError = error.message || String(error);
|
|
2833
|
+
await renderShell();
|
|
2834
|
+
}
|
|
2835
|
+
});
|
|
2836
|
+
}
|
|
2837
|
+
|
|
2838
|
+
for (const button of document.querySelectorAll("[data-push-action]")) {
|
|
2839
|
+
button.addEventListener("click", async () => {
|
|
2840
|
+
const action = button.dataset.pushAction;
|
|
2841
|
+
state.pushError = "";
|
|
2842
|
+
state.pushNotice = "";
|
|
2843
|
+
try {
|
|
2844
|
+
if (action === "enable") {
|
|
2845
|
+
await enableNotifications();
|
|
2846
|
+
state.pushBannerDismissed = false;
|
|
2847
|
+
writePushBannerDismissed(false);
|
|
2848
|
+
state.pushNotice = L("notice.notificationsEnabled");
|
|
2849
|
+
} else if (action === "disable") {
|
|
2850
|
+
await disableNotifications();
|
|
2851
|
+
state.pushBannerDismissed = false;
|
|
2852
|
+
writePushBannerDismissed(false);
|
|
2853
|
+
state.pushNotice = L("notice.notificationsDisabled");
|
|
2854
|
+
} else if (action === "test") {
|
|
2855
|
+
await apiPost("/api/push/test", {});
|
|
2856
|
+
state.pushNotice = L("notice.testNotificationSent");
|
|
2857
|
+
}
|
|
2858
|
+
await refreshPushStatus();
|
|
2859
|
+
} catch (error) {
|
|
2860
|
+
state.pushError = error.message || String(error);
|
|
2861
|
+
}
|
|
2862
|
+
await renderShell();
|
|
2863
|
+
});
|
|
2864
|
+
}
|
|
2865
|
+
|
|
2866
|
+
for (const button of document.querySelectorAll("[data-locale-option]")) {
|
|
2867
|
+
button.addEventListener("click", async () => {
|
|
2868
|
+
state.pushError = "";
|
|
2869
|
+
state.pushNotice = "";
|
|
2870
|
+
try {
|
|
2871
|
+
await setLocaleOverride(button.dataset.localeOption || "");
|
|
2872
|
+
await refreshSession();
|
|
2873
|
+
await refreshAuthenticatedState();
|
|
2874
|
+
} catch (error) {
|
|
2875
|
+
state.pushError = error.message || String(error);
|
|
2876
|
+
}
|
|
2877
|
+
await renderShell();
|
|
2878
|
+
});
|
|
2879
|
+
}
|
|
2880
|
+
|
|
2881
|
+
const draftForm = document.querySelector("[data-choice-form]");
|
|
2882
|
+
if (draftForm) {
|
|
2883
|
+
draftForm.addEventListener("change", () => {
|
|
2884
|
+
const token = draftForm.dataset.token;
|
|
2885
|
+
const form = new FormData(draftForm);
|
|
2886
|
+
mergeChoiceLocalDraft(token, Object.fromEntries(form.entries()));
|
|
2887
|
+
});
|
|
2888
|
+
|
|
2889
|
+
draftForm.addEventListener("submit", async (event) => {
|
|
2890
|
+
event.preventDefault();
|
|
2891
|
+
const form = new FormData(draftForm);
|
|
2892
|
+
const answers = Object.fromEntries(form.entries());
|
|
2893
|
+
const token = draftForm.dataset.token;
|
|
2894
|
+
const page = Number(draftForm.dataset.page || "1");
|
|
2895
|
+
const totalPages = Number(draftForm.dataset.totalPages || "1");
|
|
2896
|
+
const action = event.submitter?.dataset.flow || "submit";
|
|
2897
|
+
mergeChoiceLocalDraft(token, answers);
|
|
2898
|
+
if (action === "next" || action === "prev") {
|
|
2899
|
+
const delta = action === "next" ? 1 : -1;
|
|
2900
|
+
await apiPost(`/api/items/choice/${encodeURIComponent(token)}/draft`, {
|
|
2901
|
+
answers,
|
|
2902
|
+
page: Math.max(1, Math.min(totalPages, page + delta)),
|
|
2903
|
+
});
|
|
2904
|
+
} else {
|
|
2905
|
+
const activeItem = state.currentItem ? { ...state.currentItem } : null;
|
|
2906
|
+
const keepDetailOpen = shouldKeepDetailAfterAction(activeItem);
|
|
2907
|
+
await apiPost(`/api/items/choice/${encodeURIComponent(token)}/submit`, { answers });
|
|
2908
|
+
clearChoiceLocalDraft(token);
|
|
2909
|
+
if (keepDetailOpen && activeItem?.kind === "choice") {
|
|
2910
|
+
pinActionOutcomeDetail(
|
|
2911
|
+
activeItem,
|
|
2912
|
+
buildActionOutcomeDetail({
|
|
2913
|
+
kind: "choice",
|
|
2914
|
+
title: state.currentDetail?.title,
|
|
2915
|
+
message: L("server.message.choiceSubmitted"),
|
|
2916
|
+
})
|
|
2917
|
+
);
|
|
2918
|
+
} else if (!isDesktopLayout()) {
|
|
2919
|
+
state.detailOpen = false;
|
|
2920
|
+
syncCurrentItemUrl(null);
|
|
2921
|
+
}
|
|
2922
|
+
}
|
|
2923
|
+
await refreshAuthenticatedState();
|
|
2924
|
+
await renderShell();
|
|
2925
|
+
});
|
|
2926
|
+
}
|
|
2927
|
+
|
|
2928
|
+
const replyForm = document.querySelector("[data-completion-reply-form]");
|
|
2929
|
+
if (replyForm) {
|
|
2930
|
+
const token = replyForm.dataset.token || "";
|
|
2931
|
+
const textarea = replyForm.querySelector("[data-completion-reply-textarea]");
|
|
2932
|
+
textarea?.addEventListener("input", () => {
|
|
2933
|
+
const nextDraft = {
|
|
2934
|
+
text: textarea.value,
|
|
2935
|
+
notice: "",
|
|
2936
|
+
error: "",
|
|
2937
|
+
warning: null,
|
|
2938
|
+
confirmOverride: false,
|
|
2939
|
+
};
|
|
2940
|
+
setCompletionReplyDraft(token, nextDraft);
|
|
2941
|
+
syncCompletionReplyComposerLiveState(replyForm, {
|
|
2942
|
+
...getCompletionReplyDraft(token),
|
|
2943
|
+
...nextDraft,
|
|
2944
|
+
});
|
|
2945
|
+
});
|
|
2946
|
+
|
|
2947
|
+
replyForm.addEventListener("submit", async (event) => {
|
|
2948
|
+
event.preventDefault();
|
|
2949
|
+
const draft = getCompletionReplyDraft(token);
|
|
2950
|
+
const text = normalizeClientText(new FormData(replyForm).get("text"));
|
|
2951
|
+
if (!text) {
|
|
2952
|
+
setCompletionReplyDraft(token, {
|
|
2953
|
+
text,
|
|
2954
|
+
error: L("error.completionReplyEmpty"),
|
|
2955
|
+
notice: "",
|
|
2956
|
+
warning: null,
|
|
2957
|
+
confirmOverride: false,
|
|
2958
|
+
sending: false,
|
|
2959
|
+
});
|
|
2960
|
+
await renderShell();
|
|
2961
|
+
return;
|
|
2962
|
+
}
|
|
2963
|
+
|
|
2964
|
+
setCompletionReplyDraft(token, {
|
|
2965
|
+
text,
|
|
2966
|
+
error: "",
|
|
2967
|
+
notice: "",
|
|
2968
|
+
warning: null,
|
|
2969
|
+
sending: true,
|
|
2970
|
+
});
|
|
2971
|
+
await renderShell();
|
|
2972
|
+
|
|
2973
|
+
try {
|
|
2974
|
+
await apiPost(`/api/items/completion/${encodeURIComponent(token)}/reply`, {
|
|
2975
|
+
text,
|
|
2976
|
+
planMode: draft.mode === "plan",
|
|
2977
|
+
force: draft.confirmOverride === true,
|
|
2978
|
+
});
|
|
2979
|
+
setCompletionReplyDraft(token, {
|
|
2980
|
+
text: "",
|
|
2981
|
+
sentText: text,
|
|
2982
|
+
mode: draft.mode,
|
|
2983
|
+
sending: false,
|
|
2984
|
+
error: "",
|
|
2985
|
+
notice: L(draft.mode === "plan" ? "reply.notice.sentPlan" : "reply.notice.sentDefault"),
|
|
2986
|
+
warning: null,
|
|
2987
|
+
confirmOverride: false,
|
|
2988
|
+
collapsedAfterSend: true,
|
|
2989
|
+
});
|
|
2990
|
+
await refreshAuthenticatedState();
|
|
2991
|
+
} catch (error) {
|
|
2992
|
+
if (error.errorKey === "completion-reply-thread-advanced") {
|
|
2993
|
+
setCompletionReplyDraft(token, {
|
|
2994
|
+
text,
|
|
2995
|
+
sentText: "",
|
|
2996
|
+
mode: draft.mode,
|
|
2997
|
+
sending: false,
|
|
2998
|
+
notice: "",
|
|
2999
|
+
error: "",
|
|
3000
|
+
warning: error.payload?.warning ?? null,
|
|
3001
|
+
confirmOverride: true,
|
|
3002
|
+
collapsedAfterSend: false,
|
|
3003
|
+
});
|
|
3004
|
+
await renderShell();
|
|
3005
|
+
return;
|
|
3006
|
+
}
|
|
3007
|
+
setCompletionReplyDraft(token, {
|
|
3008
|
+
text,
|
|
3009
|
+
sentText: "",
|
|
3010
|
+
mode: draft.mode,
|
|
3011
|
+
sending: false,
|
|
3012
|
+
notice: "",
|
|
3013
|
+
error: error.message || String(error),
|
|
3014
|
+
warning: null,
|
|
3015
|
+
confirmOverride: false,
|
|
3016
|
+
collapsedAfterSend: false,
|
|
3017
|
+
});
|
|
3018
|
+
}
|
|
3019
|
+
|
|
3020
|
+
await renderShell();
|
|
3021
|
+
});
|
|
3022
|
+
}
|
|
3023
|
+
|
|
3024
|
+
bindSharedUi(renderShell);
|
|
3025
|
+
}
|
|
3026
|
+
|
|
3027
|
+
function bindSharedUi(renderFn) {
|
|
3028
|
+
for (const button of document.querySelectorAll("[data-install-guide-open]")) {
|
|
3029
|
+
button.addEventListener("click", async () => {
|
|
3030
|
+
state.installGuideOpen = true;
|
|
3031
|
+
await renderFn();
|
|
3032
|
+
});
|
|
3033
|
+
}
|
|
3034
|
+
|
|
3035
|
+
for (const button of document.querySelectorAll("[data-install-guide-close]")) {
|
|
3036
|
+
button.addEventListener("click", async (event) => {
|
|
3037
|
+
if (button.classList.contains("modal-backdrop")) {
|
|
3038
|
+
if (event.target.closest(".modal-card")) {
|
|
3039
|
+
return;
|
|
3040
|
+
}
|
|
3041
|
+
}
|
|
3042
|
+
state.installGuideOpen = false;
|
|
3043
|
+
await renderFn();
|
|
3044
|
+
});
|
|
3045
|
+
}
|
|
3046
|
+
|
|
3047
|
+
for (const button of document.querySelectorAll("[data-close-logout-confirm]")) {
|
|
3048
|
+
button.addEventListener("click", async (event) => {
|
|
3049
|
+
if (button.classList.contains("modal-backdrop")) {
|
|
3050
|
+
if (event.target.closest(".modal-card")) {
|
|
3051
|
+
return;
|
|
3052
|
+
}
|
|
3053
|
+
}
|
|
3054
|
+
state.logoutConfirmOpen = false;
|
|
3055
|
+
await renderFn();
|
|
3056
|
+
});
|
|
3057
|
+
}
|
|
3058
|
+
|
|
3059
|
+
for (const button of document.querySelectorAll("[data-dismiss-install]")) {
|
|
3060
|
+
button.addEventListener("click", async () => {
|
|
3061
|
+
state.installBannerDismissed = true;
|
|
3062
|
+
writeInstallBannerDismissed(true);
|
|
3063
|
+
await renderFn();
|
|
3064
|
+
});
|
|
3065
|
+
}
|
|
3066
|
+
|
|
3067
|
+
for (const button of document.querySelectorAll("[data-dismiss-push-banner]")) {
|
|
3068
|
+
button.addEventListener("click", async () => {
|
|
3069
|
+
state.pushBannerDismissed = true;
|
|
3070
|
+
writePushBannerDismissed(true);
|
|
3071
|
+
await renderFn();
|
|
3072
|
+
});
|
|
3073
|
+
}
|
|
3074
|
+
}
|
|
3075
|
+
|
|
3076
|
+
function openSettingsSubpage(page) {
|
|
3077
|
+
if (!page) {
|
|
3078
|
+
return;
|
|
3079
|
+
}
|
|
3080
|
+
if (!isDesktopLayout()) {
|
|
3081
|
+
state.settingsScrollState = {
|
|
3082
|
+
y: currentViewportScrollY(),
|
|
3083
|
+
};
|
|
3084
|
+
state.pendingSettingsScrollRestore = false;
|
|
3085
|
+
state.pendingSettingsSubpageScrollReset = true;
|
|
3086
|
+
}
|
|
3087
|
+
state.settingsSubpage = page;
|
|
3088
|
+
}
|
|
3089
|
+
|
|
3090
|
+
function closeSettingsSubpage() {
|
|
3091
|
+
if (!state.settingsSubpage) {
|
|
3092
|
+
return;
|
|
3093
|
+
}
|
|
3094
|
+
state.settingsSubpage = "";
|
|
3095
|
+
if (!isDesktopLayout() && state.settingsScrollState) {
|
|
3096
|
+
state.pendingSettingsScrollRestore = true;
|
|
3097
|
+
}
|
|
3098
|
+
}
|
|
3099
|
+
|
|
3100
|
+
async function switchTab(tab) {
|
|
3101
|
+
state.currentTab = tab;
|
|
3102
|
+
state.pushNotice = "";
|
|
3103
|
+
state.pushError = "";
|
|
3104
|
+
state.settingsSubpage = "";
|
|
3105
|
+
if (tab === "settings" || !isDesktopLayout()) {
|
|
3106
|
+
clearChoiceLocalDraftForItem(state.currentItem);
|
|
3107
|
+
state.detailOpen = false;
|
|
3108
|
+
clearPinnedDetailState();
|
|
3109
|
+
syncCurrentItemUrl(null);
|
|
3110
|
+
} else {
|
|
3111
|
+
ensureCurrentSelection();
|
|
3112
|
+
alignCurrentItemToVisibleEntries();
|
|
3113
|
+
syncCurrentItemUrl(state.currentItem);
|
|
3114
|
+
}
|
|
3115
|
+
await renderShell();
|
|
3116
|
+
}
|
|
3117
|
+
|
|
3118
|
+
function openItem({ kind, token, sourceTab }) {
|
|
3119
|
+
const previousItem = state.currentItem ? { ...state.currentItem } : null;
|
|
3120
|
+
clearPinnedDetailState();
|
|
3121
|
+
const nextTab = sourceTab || tabForItemKind(kind, state.currentTab);
|
|
3122
|
+
if (previousItem && (previousItem.kind !== kind || previousItem.token !== token)) {
|
|
3123
|
+
clearChoiceLocalDraftForItem(previousItem);
|
|
3124
|
+
}
|
|
3125
|
+
if (!isDesktopLayout()) {
|
|
3126
|
+
state.listScrollState = {
|
|
3127
|
+
tab: nextTab,
|
|
3128
|
+
y: currentViewportScrollY(),
|
|
3129
|
+
};
|
|
3130
|
+
state.pendingListScrollRestore = false;
|
|
3131
|
+
}
|
|
3132
|
+
state.currentItem = { kind, token };
|
|
3133
|
+
state.currentTab = nextTab;
|
|
3134
|
+
state.detailOpen = !isDesktopLayout();
|
|
3135
|
+
state.pendingDetailScrollReset = state.detailOpen;
|
|
3136
|
+
syncCurrentItemUrl(state.currentItem);
|
|
3137
|
+
}
|
|
3138
|
+
|
|
3139
|
+
function subtitleForCurrentView(detail) {
|
|
3140
|
+
if (state.currentTab === "settings") {
|
|
3141
|
+
if (state.settingsSubpage) {
|
|
3142
|
+
return settingsPageMeta(state.settingsSubpage).description;
|
|
3143
|
+
}
|
|
3144
|
+
return L("shell.subtitle.settings");
|
|
3145
|
+
}
|
|
3146
|
+
if (detail && state.detailOpen && !isDesktopLayout()) {
|
|
3147
|
+
return L("shell.subtitle.detail");
|
|
3148
|
+
}
|
|
3149
|
+
return tabMeta(state.currentTab).description;
|
|
3150
|
+
}
|
|
3151
|
+
|
|
3152
|
+
function alignCurrentItemToVisibleEntries() {
|
|
3153
|
+
if (!isDesktopLayout() || state.currentTab === "settings") {
|
|
3154
|
+
return;
|
|
3155
|
+
}
|
|
3156
|
+
const preferredEntries = listEntriesForCurrentTab();
|
|
3157
|
+
if (!preferredEntries.length) {
|
|
3158
|
+
state.currentItem = null;
|
|
3159
|
+
state.currentDetail = null;
|
|
3160
|
+
syncCurrentItemUrl(null);
|
|
3161
|
+
return;
|
|
3162
|
+
}
|
|
3163
|
+
if (!state.currentItem || !preferredEntries.some((entry) => isSameItemRef(state.currentItem, entry.item))) {
|
|
3164
|
+
state.currentItem = toItemRef(preferredEntries[0].item);
|
|
3165
|
+
state.currentDetail = null;
|
|
3166
|
+
}
|
|
3167
|
+
}
|
|
3168
|
+
|
|
3169
|
+
function renderStatusRow(label, value) {
|
|
3170
|
+
return `
|
|
3171
|
+
<div class="status-row">
|
|
3172
|
+
<span class="status-row__label">${escapeHtml(label)}</span>
|
|
3173
|
+
<span class="status-row__value">${escapeHtml(value)}</span>
|
|
3174
|
+
</div>
|
|
3175
|
+
`;
|
|
3176
|
+
}
|
|
3177
|
+
|
|
3178
|
+
function renderEmptyList(tab) {
|
|
3179
|
+
return `
|
|
3180
|
+
<div class="empty-state">
|
|
3181
|
+
<p class="empty-state__title">${escapeHtml(tabMeta(tab).title)}</p>
|
|
3182
|
+
<p class="muted">${escapeHtml(L(`empty.${tab}`))}</p>
|
|
3183
|
+
</div>
|
|
3184
|
+
`;
|
|
3185
|
+
}
|
|
3186
|
+
|
|
3187
|
+
function isSettingsSubpageOpen() {
|
|
3188
|
+
return state.currentTab === "settings" && Boolean(state.settingsSubpage) && !isDesktopLayout();
|
|
3189
|
+
}
|
|
3190
|
+
|
|
3191
|
+
function tabMeta(tab) {
|
|
3192
|
+
switch (tab) {
|
|
3193
|
+
case "pending":
|
|
3194
|
+
return {
|
|
3195
|
+
id: "pending",
|
|
3196
|
+
title: L("tab.pending.title"),
|
|
3197
|
+
label: L("tab.pending.label"),
|
|
3198
|
+
icon: "pending",
|
|
3199
|
+
eyebrow: L("tab.pending.eyebrow"),
|
|
3200
|
+
description: L("tab.pending.description"),
|
|
3201
|
+
};
|
|
3202
|
+
case "timeline":
|
|
3203
|
+
return {
|
|
3204
|
+
id: "timeline",
|
|
3205
|
+
title: L("tab.timeline.title"),
|
|
3206
|
+
label: L("tab.timeline.label"),
|
|
3207
|
+
icon: "timeline",
|
|
3208
|
+
eyebrow: L("tab.timeline.eyebrow"),
|
|
3209
|
+
description: L("tab.timeline.description"),
|
|
3210
|
+
};
|
|
3211
|
+
case "completed":
|
|
3212
|
+
return {
|
|
3213
|
+
id: "completed",
|
|
3214
|
+
title: L("tab.completed.title"),
|
|
3215
|
+
label: L("tab.completed.label"),
|
|
3216
|
+
icon: "completed",
|
|
3217
|
+
eyebrow: L("tab.completed.eyebrow"),
|
|
3218
|
+
description: L("tab.completed.description"),
|
|
3219
|
+
};
|
|
3220
|
+
case "settings":
|
|
3221
|
+
return {
|
|
3222
|
+
id: "settings",
|
|
3223
|
+
title: L("tab.settings.title"),
|
|
3224
|
+
label: L("tab.settings.label"),
|
|
3225
|
+
icon: "settings",
|
|
3226
|
+
eyebrow: L("tab.settings.eyebrow"),
|
|
3227
|
+
description: L("tab.settings.description"),
|
|
3228
|
+
};
|
|
3229
|
+
default:
|
|
3230
|
+
return tabMeta("timeline");
|
|
3231
|
+
}
|
|
3232
|
+
}
|
|
3233
|
+
|
|
3234
|
+
function tabs() {
|
|
3235
|
+
return [
|
|
3236
|
+
tabMeta("pending"),
|
|
3237
|
+
tabMeta("timeline"),
|
|
3238
|
+
tabMeta("completed"),
|
|
3239
|
+
tabMeta("settings"),
|
|
3240
|
+
];
|
|
3241
|
+
}
|
|
3242
|
+
|
|
3243
|
+
function tabForItemKind(kind, fallback) {
|
|
3244
|
+
if (TIMELINE_MESSAGE_KINDS.has(kind)) {
|
|
3245
|
+
return "timeline";
|
|
3246
|
+
}
|
|
3247
|
+
if (kind === "completion") {
|
|
3248
|
+
return "completed";
|
|
3249
|
+
}
|
|
3250
|
+
if (fallback === "timeline") {
|
|
3251
|
+
return "timeline";
|
|
3252
|
+
}
|
|
3253
|
+
return kind === "approval" || kind === "plan" || kind === "choice"
|
|
3254
|
+
? "pending"
|
|
3255
|
+
: fallback || "pending";
|
|
3256
|
+
}
|
|
3257
|
+
|
|
3258
|
+
function kindMeta(kind) {
|
|
3259
|
+
switch (kind) {
|
|
3260
|
+
case "user_message":
|
|
3261
|
+
return { label: L("common.userMessage"), tone: "neutral", icon: "user-message" };
|
|
3262
|
+
case "assistant_commentary":
|
|
3263
|
+
return { label: L("common.assistantCommentary"), tone: "plan", icon: "assistant-commentary" };
|
|
3264
|
+
case "assistant_final":
|
|
3265
|
+
return { label: L("common.assistantFinal"), tone: "completion", icon: "assistant-final" };
|
|
3266
|
+
case "approval":
|
|
3267
|
+
return { label: L("common.approval"), tone: "approval", icon: "approval" };
|
|
3268
|
+
case "plan":
|
|
3269
|
+
case "plan_ready":
|
|
3270
|
+
return { label: L("common.plan"), tone: "plan", icon: "plan" };
|
|
3271
|
+
case "choice":
|
|
3272
|
+
return { label: L("common.choice"), tone: "choice", icon: "choice" };
|
|
3273
|
+
case "completion":
|
|
3274
|
+
return { label: L("common.completion"), tone: "completion", icon: "completion-item" };
|
|
3275
|
+
default:
|
|
3276
|
+
return { label: L("common.item"), tone: "neutral", icon: "item" };
|
|
3277
|
+
}
|
|
3278
|
+
}
|
|
3279
|
+
|
|
3280
|
+
function renderTypePillContent(kindInfo) {
|
|
3281
|
+
return `
|
|
3282
|
+
<span class="type-pill__icon" aria-hidden="true">${renderIcon(kindInfo.icon)}</span>
|
|
3283
|
+
<span>${escapeHtml(kindInfo.label)}</span>
|
|
3284
|
+
`;
|
|
3285
|
+
}
|
|
3286
|
+
|
|
3287
|
+
function itemIntentText(kind, status = "pending") {
|
|
3288
|
+
if (kind === "user_message") {
|
|
3289
|
+
return L("intent.userMessage");
|
|
3290
|
+
}
|
|
3291
|
+
if (kind === "assistant_commentary") {
|
|
3292
|
+
return L("intent.assistantCommentary");
|
|
3293
|
+
}
|
|
3294
|
+
if (kind === "assistant_final") {
|
|
3295
|
+
return L("intent.assistantFinal");
|
|
3296
|
+
}
|
|
3297
|
+
if (status === "completed") {
|
|
3298
|
+
return L("intent.completed");
|
|
3299
|
+
}
|
|
3300
|
+
switch (kind) {
|
|
3301
|
+
case "approval":
|
|
3302
|
+
return L("intent.approval");
|
|
3303
|
+
case "plan":
|
|
3304
|
+
return L("intent.plan");
|
|
3305
|
+
case "choice":
|
|
3306
|
+
return L("intent.choice");
|
|
3307
|
+
case "completion":
|
|
3308
|
+
return L("intent.completed");
|
|
3309
|
+
default:
|
|
3310
|
+
return L("summary.default");
|
|
3311
|
+
}
|
|
3312
|
+
}
|
|
3313
|
+
|
|
3314
|
+
function detailIntentText(detail) {
|
|
3315
|
+
if (TIMELINE_MESSAGE_KINDS.has(detail.kind)) {
|
|
3316
|
+
return itemIntentText(detail.kind, "timeline");
|
|
3317
|
+
}
|
|
3318
|
+
if (detail.readOnly) {
|
|
3319
|
+
return L("intent.completed");
|
|
3320
|
+
}
|
|
3321
|
+
return itemIntentText(detail.kind, "pending");
|
|
3322
|
+
}
|
|
3323
|
+
|
|
3324
|
+
function detailDisplayTitle(detail) {
|
|
3325
|
+
const threadLabel = normalizeClientText(detail?.threadLabel || "");
|
|
3326
|
+
if (threadLabel) {
|
|
3327
|
+
return threadLabel;
|
|
3328
|
+
}
|
|
3329
|
+
const title = normalizeClientText(detail?.title || "");
|
|
3330
|
+
if (!title) {
|
|
3331
|
+
return L("common.untitledItem");
|
|
3332
|
+
}
|
|
3333
|
+
const [prefix, ...rest] = title.split(" | ");
|
|
3334
|
+
const knownPrefixes = new Set([
|
|
3335
|
+
L("common.approval"),
|
|
3336
|
+
L("common.plan"),
|
|
3337
|
+
L("common.choice"),
|
|
3338
|
+
L("common.completion"),
|
|
3339
|
+
L("common.userMessage"),
|
|
3340
|
+
L("common.assistantCommentary"),
|
|
3341
|
+
L("common.assistantFinal"),
|
|
3342
|
+
"Approval",
|
|
3343
|
+
"Plan",
|
|
3344
|
+
"Choice",
|
|
3345
|
+
"Completed",
|
|
3346
|
+
"User message",
|
|
3347
|
+
"Commentary",
|
|
3348
|
+
"Final answer",
|
|
3349
|
+
"完了",
|
|
3350
|
+
"承認",
|
|
3351
|
+
"プラン",
|
|
3352
|
+
"選択",
|
|
3353
|
+
"メッセージ",
|
|
3354
|
+
"途中経過",
|
|
3355
|
+
"最終回答",
|
|
3356
|
+
]);
|
|
3357
|
+
if (rest.length > 0 && knownPrefixes.has(prefix)) {
|
|
3358
|
+
return rest.join(" | ");
|
|
3359
|
+
}
|
|
3360
|
+
return title;
|
|
3361
|
+
}
|
|
3362
|
+
|
|
3363
|
+
function fallbackSummaryForKind(kind, status) {
|
|
3364
|
+
if (status === "completed") {
|
|
3365
|
+
return L("summary.completed");
|
|
3366
|
+
}
|
|
3367
|
+
switch (kind) {
|
|
3368
|
+
case "user_message":
|
|
3369
|
+
return L("summary.userMessage");
|
|
3370
|
+
case "assistant_commentary":
|
|
3371
|
+
return L("summary.assistantCommentary");
|
|
3372
|
+
case "assistant_final":
|
|
3373
|
+
return L("summary.assistantFinal");
|
|
3374
|
+
case "approval":
|
|
3375
|
+
return L("summary.approval");
|
|
3376
|
+
case "plan":
|
|
3377
|
+
case "plan_ready":
|
|
3378
|
+
return L("summary.plan");
|
|
3379
|
+
case "choice":
|
|
3380
|
+
return L("summary.choice");
|
|
3381
|
+
default:
|
|
3382
|
+
return L("summary.default");
|
|
3383
|
+
}
|
|
3384
|
+
}
|
|
3385
|
+
|
|
3386
|
+
function actionClassForTone(tone) {
|
|
3387
|
+
if (tone === "danger" || tone === "warn" || tone === "reject") {
|
|
3388
|
+
return "danger danger--wide";
|
|
3389
|
+
}
|
|
3390
|
+
if (tone === "primary" || tone === "ok" || tone === "approve") {
|
|
3391
|
+
return "primary primary--wide";
|
|
3392
|
+
}
|
|
3393
|
+
return "secondary secondary--wide";
|
|
3394
|
+
}
|
|
3395
|
+
|
|
3396
|
+
function shouldShowInstallBanner() {
|
|
3397
|
+
if (state.installBannerDismissed || isStandaloneMode()) {
|
|
3398
|
+
return false;
|
|
3399
|
+
}
|
|
3400
|
+
return !isDesktopLayout();
|
|
3401
|
+
}
|
|
3402
|
+
|
|
3403
|
+
function shouldShowPushBanner() {
|
|
3404
|
+
if (!state.session?.authenticated || state.currentTab === "settings") {
|
|
3405
|
+
return false;
|
|
3406
|
+
}
|
|
3407
|
+
const push = state.pushStatus || {};
|
|
3408
|
+
if (!push.enabled || !push.standalone || push.serverSubscribed || state.pushBannerDismissed) {
|
|
3409
|
+
return false;
|
|
3410
|
+
}
|
|
3411
|
+
return true;
|
|
3412
|
+
}
|
|
3413
|
+
|
|
3414
|
+
function installBannerCopy() {
|
|
3415
|
+
if (isProbablySafari()) {
|
|
3416
|
+
return L("banner.install.copy.safari");
|
|
3417
|
+
}
|
|
3418
|
+
return L("banner.install.copy.other");
|
|
3419
|
+
}
|
|
3420
|
+
|
|
3421
|
+
function installGuideIntro() {
|
|
3422
|
+
if (isProbablySafari()) {
|
|
3423
|
+
return L("install.guide.intro.safari");
|
|
3424
|
+
}
|
|
3425
|
+
return L("install.guide.intro.other");
|
|
3426
|
+
}
|
|
3427
|
+
|
|
3428
|
+
function installGuideSteps() {
|
|
3429
|
+
const steps = [];
|
|
3430
|
+
if (!isProbablySafari()) {
|
|
3431
|
+
steps.push(L("install.guide.step.openSafari"));
|
|
3432
|
+
}
|
|
3433
|
+
steps.push(L("install.guide.step.tapShare"));
|
|
3434
|
+
steps.push(L("install.guide.step.chooseAdd"));
|
|
3435
|
+
steps.push(L("install.guide.step.tapAdd"));
|
|
3436
|
+
return steps;
|
|
3437
|
+
}
|
|
3438
|
+
|
|
3439
|
+
function pushBannerCopy() {
|
|
3440
|
+
const push = state.pushStatus || {};
|
|
3441
|
+
if (!push.secureContext) {
|
|
3442
|
+
return L("banner.push.copy.https");
|
|
3443
|
+
}
|
|
3444
|
+
if (!push.standalone) {
|
|
3445
|
+
return L("banner.push.copy.standalone");
|
|
3446
|
+
}
|
|
3447
|
+
if (push.notificationPermission === "denied") {
|
|
3448
|
+
return L("banner.push.copy.denied");
|
|
3449
|
+
}
|
|
3450
|
+
return L("banner.push.copy.default");
|
|
3451
|
+
}
|
|
3452
|
+
|
|
3453
|
+
function canEnableNotificationsFromCurrentContext() {
|
|
3454
|
+
const push = state.pushStatus || {};
|
|
3455
|
+
return (
|
|
3456
|
+
push.enabled === true &&
|
|
3457
|
+
push.supportsPush === true &&
|
|
3458
|
+
push.secureContext === true &&
|
|
3459
|
+
push.standalone === true &&
|
|
3460
|
+
push.notificationPermission !== "denied" &&
|
|
3461
|
+
push.serverSubscribed !== true
|
|
3462
|
+
);
|
|
3463
|
+
}
|
|
3464
|
+
|
|
3465
|
+
function readInstallBannerDismissed() {
|
|
3466
|
+
try {
|
|
3467
|
+
return window.localStorage.getItem(INSTALL_BANNER_DISMISS_KEY) === "1";
|
|
3468
|
+
} catch {
|
|
3469
|
+
return false;
|
|
3470
|
+
}
|
|
3471
|
+
}
|
|
3472
|
+
|
|
3473
|
+
function writeInstallBannerDismissed(value) {
|
|
3474
|
+
try {
|
|
3475
|
+
if (value) {
|
|
3476
|
+
window.localStorage.setItem(INSTALL_BANNER_DISMISS_KEY, "1");
|
|
3477
|
+
} else {
|
|
3478
|
+
window.localStorage.removeItem(INSTALL_BANNER_DISMISS_KEY);
|
|
3479
|
+
}
|
|
3480
|
+
} catch {
|
|
3481
|
+
// Ignore storage errors on private browsing or restricted environments.
|
|
3482
|
+
}
|
|
3483
|
+
}
|
|
3484
|
+
|
|
3485
|
+
function readPushBannerDismissed() {
|
|
3486
|
+
try {
|
|
3487
|
+
return window.localStorage.getItem(PUSH_BANNER_DISMISS_KEY) === "1";
|
|
3488
|
+
} catch {
|
|
3489
|
+
return false;
|
|
3490
|
+
}
|
|
3491
|
+
}
|
|
3492
|
+
|
|
3493
|
+
function writePushBannerDismissed(value) {
|
|
3494
|
+
try {
|
|
3495
|
+
if (value) {
|
|
3496
|
+
window.localStorage.setItem(PUSH_BANNER_DISMISS_KEY, "1");
|
|
3497
|
+
} else {
|
|
3498
|
+
window.localStorage.removeItem(PUSH_BANNER_DISMISS_KEY);
|
|
3499
|
+
}
|
|
3500
|
+
} catch {
|
|
3501
|
+
// Ignore storage errors on private browsing or restricted environments.
|
|
3502
|
+
}
|
|
3503
|
+
}
|
|
3504
|
+
|
|
3505
|
+
function isProbablySafari() {
|
|
3506
|
+
const userAgent = navigator.userAgent || "";
|
|
3507
|
+
return /Safari/iu.test(userAgent) && !/CriOS|FxiOS|EdgiOS|OPiOS/iu.test(userAgent);
|
|
3508
|
+
}
|
|
3509
|
+
|
|
3510
|
+
function isDesktopLayout() {
|
|
3511
|
+
return window.innerWidth >= DESKTOP_BREAKPOINT;
|
|
3512
|
+
}
|
|
3513
|
+
|
|
3514
|
+
function toItemRef(item) {
|
|
3515
|
+
return {
|
|
3516
|
+
kind: item.kind,
|
|
3517
|
+
token: item.token,
|
|
3518
|
+
};
|
|
3519
|
+
}
|
|
3520
|
+
|
|
3521
|
+
function isSameItemRef(left, right) {
|
|
3522
|
+
return left?.kind === right?.kind && left?.token === right?.token;
|
|
3523
|
+
}
|
|
3524
|
+
|
|
3525
|
+
function isFastPathItemRef(itemRef) {
|
|
3526
|
+
return itemRef?.kind === "approval" || itemRef?.kind === "choice";
|
|
3527
|
+
}
|
|
3528
|
+
|
|
3529
|
+
function hasLaunchItemIntent(itemRef = state.currentItem) {
|
|
3530
|
+
return Boolean(state.launchItemIntent && isSameItemRef(state.launchItemIntent, itemRef));
|
|
3531
|
+
}
|
|
3532
|
+
|
|
3533
|
+
function hasDetailOverride(itemRef = state.currentItem) {
|
|
3534
|
+
return Boolean(state.detailOverride && isSameItemRef(state.detailOverride, itemRef));
|
|
3535
|
+
}
|
|
3536
|
+
|
|
3537
|
+
function shouldPreserveCurrentItem(itemRef = state.currentItem) {
|
|
3538
|
+
return Boolean(itemRef && (hasLaunchItemIntent(itemRef) || hasDetailOverride(itemRef)));
|
|
3539
|
+
}
|
|
3540
|
+
|
|
3541
|
+
function clearLaunchItemIntent() {
|
|
3542
|
+
state.launchItemIntent = null;
|
|
3543
|
+
}
|
|
3544
|
+
|
|
3545
|
+
function clearDetailOverride() {
|
|
3546
|
+
state.detailOverride = null;
|
|
3547
|
+
}
|
|
3548
|
+
|
|
3549
|
+
function clearPinnedDetailState() {
|
|
3550
|
+
detailLoadSequence += 1;
|
|
3551
|
+
clearLaunchItemIntent();
|
|
3552
|
+
clearDetailOverride();
|
|
3553
|
+
state.currentDetail = null;
|
|
3554
|
+
state.currentDetailLoading = false;
|
|
3555
|
+
state.detailLoadingItem = null;
|
|
3556
|
+
}
|
|
3557
|
+
|
|
3558
|
+
function renderIcon(name) {
|
|
3559
|
+
switch (name) {
|
|
3560
|
+
case "approval":
|
|
3561
|
+
return `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round"><path d="M12 3.5 18.5 6v5.4c0 4-2.7 7.6-6.5 9.1-3.8-1.5-6.5-5.1-6.5-9.1V6z"/><path d="m8.9 12 2.1 2.1 4.1-4.4"/></svg>`;
|
|
3562
|
+
case "plan":
|
|
3563
|
+
return `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="7.5"/><path d="m12 12 3.6-2.2"/><path d="M12 4.5v1.7"/><path d="M19.5 12h-1.7"/><path d="M12 19.5v-1.7"/><path d="M4.5 12h1.7"/></svg>`;
|
|
3564
|
+
case "choice":
|
|
3565
|
+
return `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round"><rect x="4.5" y="5.5" width="15" height="13" rx="2.5"/><path d="m8.2 12 1.6 1.6 3-3.2"/><path d="M13.8 10.2h2.2"/><path d="M13.8 13.8h2.2"/></svg>`;
|
|
3566
|
+
case "completion-item":
|
|
3567
|
+
return `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="8"/><path d="m8.7 12.1 2 2.1 4.7-4.9"/></svg>`;
|
|
3568
|
+
case "item":
|
|
3569
|
+
return `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round"><rect x="5" y="4.5" width="14" height="15" rx="2.5"/><path d="M8.5 9h7"/><path d="M8.5 12h7"/><path d="M8.5 15h4.5"/></svg>`;
|
|
3570
|
+
case "pending":
|
|
3571
|
+
return `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M12 3v5"/><path d="M12 16v5"/><path d="M4.8 6.8l3.5 3.5"/><path d="M15.7 15.7l3.5 3.5"/><path d="M3 12h5"/><path d="M16 12h5"/><path d="M4.8 17.2l3.5-3.5"/><path d="M15.7 8.3l3.5-3.5"/></svg>`;
|
|
3572
|
+
case "timeline":
|
|
3573
|
+
return `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.85" stroke-linecap="round" stroke-linejoin="round"><path d="M4.5 6.5a2 2 0 0 1 2-2h11a2 2 0 0 1 2 2v6a2 2 0 0 1-2 2H11l-3.5 3.1v-3.1H6.5a2 2 0 0 1-2-2z"/><path d="M8 8.8h8"/><path d="M8 11.8h5.5"/></svg>`;
|
|
3574
|
+
case "user-message":
|
|
3575
|
+
return `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.85" stroke-linecap="round" stroke-linejoin="round"><path d="M12 5.2a3.1 3.1 0 1 1 0 6.2 3.1 3.1 0 0 1 0-6.2Z"/><path d="M6.5 18.2a5.8 5.8 0 0 1 11 0"/></svg>`;
|
|
3576
|
+
case "assistant-commentary":
|
|
3577
|
+
return `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.85" stroke-linecap="round" stroke-linejoin="round"><path d="M12 6.2v5.6"/><path d="M9.2 9h5.6"/><path d="M6 14.8a6.7 6.7 0 0 0 12 0"/><path d="M8 4.8a7.6 7.6 0 0 1 8 0"/></svg>`;
|
|
3578
|
+
case "assistant-final":
|
|
3579
|
+
return `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.85" stroke-linecap="round" stroke-linejoin="round"><path d="M6 6.5h12a1.8 1.8 0 0 1 1.8 1.8v6.1A1.8 1.8 0 0 1 18 16.2h-5.3L9 19.4v-3.2H6a1.8 1.8 0 0 1-1.8-1.8V8.3A1.8 1.8 0 0 1 6 6.5Z"/><path d="m9.2 11.3 1.7 1.7 3.6-3.8"/></svg>`;
|
|
3580
|
+
case "completed":
|
|
3581
|
+
return `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="8.5"/><path d="m8.7 12.2 2.1 2.1 4.6-4.8"/></svg>`;
|
|
3582
|
+
case "settings":
|
|
3583
|
+
return `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M12 3.5 13.4 6a1 1 0 0 0 .82.5l2.84.28 1.16 2.02-1.8 2.22a1 1 0 0 0-.2.95l.62 2.78-2.04 1.18-2.58-1.1a1 1 0 0 0-.78 0l-2.58 1.1-2.04-1.18.62-2.78a1 1 0 0 0-.2-.95L5.78 8.8l1.16-2.02 2.84-.28a1 1 0 0 0 .82-.5L12 3.5Z"/><circle cx="12" cy="12" r="2.7"/></svg>`;
|
|
3584
|
+
case "notifications":
|
|
3585
|
+
return `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.85" stroke-linecap="round" stroke-linejoin="round"><path d="M12 4.5a4 4 0 0 0-4 4v2.1c0 .9-.28 1.79-.8 2.52L6 15.2h12l-1.2-2.08a4.9 4.9 0 0 1-.8-2.52V8.5a4 4 0 0 0-4-4Z"/><path d="M10.2 18a2 2 0 0 0 3.6 0"/></svg>`;
|
|
3586
|
+
case "homescreen":
|
|
3587
|
+
return `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.85" stroke-linecap="round" stroke-linejoin="round"><rect x="7" y="2.8" width="10" height="18.4" rx="2.6"/><path d="M10 6.8h4"/><path d="M10.7 17.2h2.6"/></svg>`;
|
|
3588
|
+
case "iphone":
|
|
3589
|
+
return `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.85" stroke-linecap="round" stroke-linejoin="round"><rect x="7.2" y="2.8" width="9.6" height="18.4" rx="2.4"/><path d="M10 6.7h4"/><circle cx="12" cy="17.6" r="0.7" fill="currentColor" stroke="none"/></svg>`;
|
|
3590
|
+
case "language":
|
|
3591
|
+
return `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.85" stroke-linecap="round" stroke-linejoin="round"><path d="M12 3.5c3.8 0 7 3.8 7 8.5s-3.2 8.5-7 8.5-7-3.8-7-8.5 3.2-8.5 7-8.5Z"/><path d="M5.8 9h12.4"/><path d="M5.8 15h12.4"/><path d="M12 3.8c1.9 2 3 4.9 3 8.2s-1.1 6.2-3 8.2c-1.9-2-3-4.9-3-8.2s1.1-6.2 3-8.2Z"/></svg>`;
|
|
3592
|
+
case "link":
|
|
3593
|
+
return `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round"><path d="M10.4 13.6 8.3 15.7a3 3 0 0 1-4.2-4.2l2.8-2.8a3 3 0 0 1 4.2 0"/><path d="m13.6 10.4 2.1-2.1a3 3 0 1 1 4.2 4.2l-2.8 2.8a3 3 0 0 1-4.2 0"/><path d="m9.5 14.5 5-5"/></svg>`;
|
|
3594
|
+
case "check":
|
|
3595
|
+
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>`;
|
|
3596
|
+
case "back":
|
|
3597
|
+
return `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m15 18-6-6 6-6"/></svg>`;
|
|
3598
|
+
case "chevron-down":
|
|
3599
|
+
return `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m6 9 6 6 6-6"/></svg>`;
|
|
3600
|
+
case "chevron-right":
|
|
3601
|
+
return `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m9 6 6 6-6 6"/></svg>`;
|
|
3602
|
+
default:
|
|
3603
|
+
return "";
|
|
3604
|
+
}
|
|
3605
|
+
}
|
|
3606
|
+
|
|
3607
|
+
function renderCurrentSurface() {
|
|
3608
|
+
if (!state.session?.authenticated) {
|
|
3609
|
+
renderPair();
|
|
3610
|
+
return;
|
|
3611
|
+
}
|
|
3612
|
+
renderShell().catch((error) => {
|
|
3613
|
+
const message = error.message || String(error);
|
|
3614
|
+
app.innerHTML = `
|
|
3615
|
+
<main class="onboarding-shell">
|
|
3616
|
+
<section class="onboarding-card">
|
|
3617
|
+
<span class="eyebrow-pill">${escapeHtml(L("common.codex"))}</span>
|
|
3618
|
+
<h1 class="hero-title">${escapeHtml(L("common.appName"))}</h1>
|
|
3619
|
+
<p class="hero-copy">${escapeHtml(message)}</p>
|
|
3620
|
+
</section>
|
|
3621
|
+
</main>
|
|
3622
|
+
`;
|
|
3623
|
+
});
|
|
3624
|
+
}
|
|
3625
|
+
|
|
3626
|
+
async function enableNotifications() {
|
|
3627
|
+
if (!state.session?.webPushEnabled) {
|
|
3628
|
+
throw new Error(L("error.webPushDisabled"));
|
|
3629
|
+
}
|
|
3630
|
+
if (!window.isSecureContext) {
|
|
3631
|
+
throw new Error(L("error.notificationsRequireHttps"));
|
|
3632
|
+
}
|
|
3633
|
+
if (!supportsPush()) {
|
|
3634
|
+
throw new Error(L("error.pushUnsupported"));
|
|
3635
|
+
}
|
|
3636
|
+
if (!isStandaloneMode()) {
|
|
3637
|
+
throw new Error(L("error.openHomeScreen"));
|
|
3638
|
+
}
|
|
3639
|
+
|
|
3640
|
+
const registration = await ensureServiceWorkerReady();
|
|
3641
|
+
const permission = await Notification.requestPermission();
|
|
3642
|
+
if (permission !== "granted") {
|
|
3643
|
+
throw new Error(L("error.notificationPermission", { status: permission }));
|
|
3644
|
+
}
|
|
3645
|
+
|
|
3646
|
+
const status = await apiGet("/api/push/status");
|
|
3647
|
+
if (!status.enabled || !status.vapidPublicKey) {
|
|
3648
|
+
throw new Error(L("error.pushServerNotReady"));
|
|
3649
|
+
}
|
|
3650
|
+
|
|
3651
|
+
const subscription = await registration.pushManager.subscribe({
|
|
3652
|
+
userVisibleOnly: true,
|
|
3653
|
+
applicationServerKey: urlBase64ToUint8Array(status.vapidPublicKey),
|
|
3654
|
+
});
|
|
3655
|
+
|
|
3656
|
+
await apiPost("/api/push/subscribe", {
|
|
3657
|
+
subscription: subscription.toJSON(),
|
|
3658
|
+
userAgent: navigator.userAgent,
|
|
3659
|
+
standalone: isStandaloneMode(),
|
|
3660
|
+
});
|
|
3661
|
+
}
|
|
3662
|
+
|
|
3663
|
+
async function disableNotifications() {
|
|
3664
|
+
const registration = await ensureServiceWorkerReady();
|
|
3665
|
+
const subscription = await registration.pushManager.getSubscription();
|
|
3666
|
+
if (subscription) {
|
|
3667
|
+
await subscription.unsubscribe().catch(() => {});
|
|
3668
|
+
await apiPost("/api/push/unsubscribe", { endpoint: subscription.endpoint });
|
|
3669
|
+
return;
|
|
3670
|
+
}
|
|
3671
|
+
await apiPost("/api/push/unsubscribe", {});
|
|
3672
|
+
}
|
|
3673
|
+
|
|
3674
|
+
async function ensureServiceWorkerReady() {
|
|
3675
|
+
if (state.serviceWorkerRegistration) {
|
|
3676
|
+
return state.serviceWorkerRegistration;
|
|
3677
|
+
}
|
|
3678
|
+
if (!("serviceWorker" in navigator)) {
|
|
3679
|
+
throw new Error(L("error.serviceWorkerUnavailable"));
|
|
3680
|
+
}
|
|
3681
|
+
state.serviceWorkerRegistration = await navigator.serviceWorker.ready;
|
|
3682
|
+
return state.serviceWorkerRegistration;
|
|
3683
|
+
}
|
|
3684
|
+
|
|
3685
|
+
function supportsPush() {
|
|
3686
|
+
return (
|
|
3687
|
+
"serviceWorker" in navigator &&
|
|
3688
|
+
"PushManager" in window &&
|
|
3689
|
+
"Notification" in window
|
|
3690
|
+
);
|
|
3691
|
+
}
|
|
3692
|
+
|
|
3693
|
+
function isStandaloneMode() {
|
|
3694
|
+
return window.matchMedia?.("(display-mode: standalone)")?.matches || window.navigator.standalone === true;
|
|
3695
|
+
}
|
|
3696
|
+
|
|
3697
|
+
function handleServiceWorkerMessage(event) {
|
|
3698
|
+
const type = event?.data?.type || "";
|
|
3699
|
+
if (type === "pushsubscriptionchange") {
|
|
3700
|
+
refreshPushStatus().then(renderCurrentSurface).catch(() => {});
|
|
3701
|
+
}
|
|
3702
|
+
}
|
|
3703
|
+
|
|
3704
|
+
async function apiGet(url) {
|
|
3705
|
+
const response = await fetch(url, {
|
|
3706
|
+
credentials: "same-origin",
|
|
3707
|
+
headers: {
|
|
3708
|
+
Accept: "application/json",
|
|
3709
|
+
},
|
|
3710
|
+
});
|
|
3711
|
+
if (!response.ok) {
|
|
3712
|
+
const errorInfo = await readError(response);
|
|
3713
|
+
const error = new Error(errorInfo.message);
|
|
3714
|
+
error.code = response.status;
|
|
3715
|
+
error.status = response.status;
|
|
3716
|
+
error.errorKey = errorInfo.errorKey || "";
|
|
3717
|
+
throw error;
|
|
3718
|
+
}
|
|
3719
|
+
return response.json();
|
|
3720
|
+
}
|
|
3721
|
+
|
|
3722
|
+
async function apiPost(url, body) {
|
|
3723
|
+
const response = await fetch(url, {
|
|
3724
|
+
method: "POST",
|
|
3725
|
+
credentials: "same-origin",
|
|
3726
|
+
headers: {
|
|
3727
|
+
"Content-Type": "application/json",
|
|
3728
|
+
Accept: "application/json",
|
|
3729
|
+
},
|
|
3730
|
+
body: JSON.stringify(body || {}),
|
|
3731
|
+
});
|
|
3732
|
+
if (!response.ok) {
|
|
3733
|
+
const errorInfo = await readError(response);
|
|
3734
|
+
const error = new Error(errorInfo.message);
|
|
3735
|
+
error.code = response.status;
|
|
3736
|
+
error.status = response.status;
|
|
3737
|
+
error.errorKey = errorInfo.errorKey || "";
|
|
3738
|
+
error.payload = errorInfo.payload ?? null;
|
|
3739
|
+
throw error;
|
|
3740
|
+
}
|
|
3741
|
+
return response.json();
|
|
3742
|
+
}
|
|
3743
|
+
|
|
3744
|
+
async function readError(response) {
|
|
3745
|
+
try {
|
|
3746
|
+
const payload = await response.json();
|
|
3747
|
+
const errorKey = typeof payload.error === "string" ? payload.error : "";
|
|
3748
|
+
const message = localizeApiError(errorKey || payload.message || response.statusText);
|
|
3749
|
+
return { message, errorKey, payload };
|
|
3750
|
+
} catch {
|
|
3751
|
+
return { message: localizeApiError(response.statusText), errorKey: "", payload: null };
|
|
3752
|
+
}
|
|
3753
|
+
}
|
|
3754
|
+
|
|
3755
|
+
function localizeApiError(value) {
|
|
3756
|
+
const raw = normalizeClientText(value);
|
|
3757
|
+
if (!raw) {
|
|
3758
|
+
return "";
|
|
3759
|
+
}
|
|
3760
|
+
const map = {
|
|
3761
|
+
"pairing-unavailable": "error.pairingUnavailable",
|
|
3762
|
+
"invalid-pairing-credentials": "error.invalidPairingCredentials",
|
|
3763
|
+
"pairing-rate-limited": "error.pairingRateLimited",
|
|
3764
|
+
"authentication-required": "error.authenticationRequired",
|
|
3765
|
+
"origin-not-allowed": "error.originNotAllowed",
|
|
3766
|
+
"device-not-found": "error.deviceNotFound",
|
|
3767
|
+
"web-push-disabled": "error.webPushDisabled",
|
|
3768
|
+
"push-subscription-expired": "error.pushSubscriptionExpired",
|
|
3769
|
+
"item-not-found": "error.itemNotFound",
|
|
3770
|
+
"completion-reply-unavailable": "error.completionReplyUnavailable",
|
|
3771
|
+
"completion-reply-thread-advanced": "error.completionReplyThreadAdvanced",
|
|
3772
|
+
"completion-reply-empty": "error.completionReplyEmpty",
|
|
3773
|
+
"codex-ipc-not-connected": "error.codexIpcNotConnected",
|
|
3774
|
+
"approval-not-found": "error.approvalNotFound",
|
|
3775
|
+
"approval-already-handled": "error.approvalAlreadyHandled",
|
|
3776
|
+
"plan-request-not-found": "error.planRequestNotFound",
|
|
3777
|
+
"plan-request-already-handled": "error.planRequestAlreadyHandled",
|
|
3778
|
+
"choice-input-not-found": "error.choiceInputNotFound",
|
|
3779
|
+
"choice-input-read-only": "error.choiceInputReadOnly",
|
|
3780
|
+
"choice-input-already-handled": "error.choiceInputAlreadyHandled",
|
|
3781
|
+
"mkcert-root-ca-not-found": "error.mkcertRootCaNotFound",
|
|
3782
|
+
};
|
|
3783
|
+
const key = map[raw];
|
|
3784
|
+
return key ? L(key) : raw;
|
|
3785
|
+
}
|
|
3786
|
+
|
|
3787
|
+
function normalizeClientText(value) {
|
|
3788
|
+
return String(value ?? "").trim();
|
|
3789
|
+
}
|
|
3790
|
+
|
|
3791
|
+
function parseItemRef(value) {
|
|
3792
|
+
const [kind, token] = String(value || "").split(":");
|
|
3793
|
+
return kind && token ? { kind, token } : null;
|
|
3794
|
+
}
|
|
3795
|
+
|
|
3796
|
+
function buildAppUrl(nextParams) {
|
|
3797
|
+
const query = nextParams.toString();
|
|
3798
|
+
return `/app${query ? `?${query}` : ""}`;
|
|
3799
|
+
}
|
|
3800
|
+
|
|
3801
|
+
function syncCurrentItemUrl(itemRef) {
|
|
3802
|
+
const nextParams = new URLSearchParams(window.location.search);
|
|
3803
|
+
if (itemRef?.kind && itemRef?.token) {
|
|
3804
|
+
nextParams.set("item", `${itemRef.kind}:${itemRef.token}`);
|
|
3805
|
+
} else {
|
|
3806
|
+
nextParams.delete("item");
|
|
3807
|
+
}
|
|
3808
|
+
const nextUrl = buildAppUrl(nextParams);
|
|
3809
|
+
if (`${window.location.pathname}${window.location.search}` !== nextUrl) {
|
|
3810
|
+
history.replaceState({}, "", nextUrl);
|
|
3811
|
+
}
|
|
3812
|
+
}
|
|
3813
|
+
|
|
3814
|
+
function updateManifestHref(pairToken) {
|
|
3815
|
+
const manifestLink = document.querySelector('link[rel="manifest"]');
|
|
3816
|
+
if (!manifestLink) {
|
|
3817
|
+
return;
|
|
3818
|
+
}
|
|
3819
|
+
const token = String(pairToken || "");
|
|
3820
|
+
const href = token
|
|
3821
|
+
? `/manifest.webmanifest?pairToken=${encodeURIComponent(token)}`
|
|
3822
|
+
: "/manifest.webmanifest";
|
|
3823
|
+
if (manifestLink.getAttribute("href") === href) {
|
|
3824
|
+
return;
|
|
3825
|
+
}
|
|
3826
|
+
manifestLink.setAttribute("href", href);
|
|
3827
|
+
}
|
|
3828
|
+
|
|
3829
|
+
function syncPairingTokenState(pairToken) {
|
|
3830
|
+
const token = String(pairToken || "");
|
|
3831
|
+
updateManifestHref(token);
|
|
3832
|
+
|
|
3833
|
+
const nextParams = new URLSearchParams(window.location.search);
|
|
3834
|
+
if (token) {
|
|
3835
|
+
nextParams.set("pairToken", token);
|
|
3836
|
+
} else {
|
|
3837
|
+
nextParams.delete("pairToken");
|
|
3838
|
+
}
|
|
3839
|
+
const nextUrl = buildAppUrl(nextParams);
|
|
3840
|
+
if (`${window.location.pathname}${window.location.search}` === nextUrl) {
|
|
3841
|
+
return;
|
|
3842
|
+
}
|
|
3843
|
+
history.replaceState({}, "", nextUrl);
|
|
3844
|
+
}
|
|
3845
|
+
|
|
3846
|
+
function desiredBootstrapPairingToken() {
|
|
3847
|
+
if (state.session?.authenticated) {
|
|
3848
|
+
return "";
|
|
3849
|
+
}
|
|
3850
|
+
return initialPairToken;
|
|
3851
|
+
}
|
|
3852
|
+
|
|
3853
|
+
function urlBase64ToUint8Array(base64String) {
|
|
3854
|
+
const padding = "=".repeat((4 - (base64String.length % 4)) % 4);
|
|
3855
|
+
const normalized = `${base64String}${padding}`.replace(/-/gu, "+").replace(/_/gu, "/");
|
|
3856
|
+
const rawData = window.atob(normalized);
|
|
3857
|
+
return Uint8Array.from([...rawData].map((char) => char.charCodeAt(0)));
|
|
3858
|
+
}
|
|
3859
|
+
|
|
3860
|
+
function escapeHtml(value) {
|
|
3861
|
+
return String(value ?? "")
|
|
3862
|
+
.replace(/&/gu, "&")
|
|
3863
|
+
.replace(/</gu, "<")
|
|
3864
|
+
.replace(/>/gu, ">")
|
|
3865
|
+
.replace(/"/gu, """)
|
|
3866
|
+
.replace(/'/gu, "'");
|
|
3867
|
+
}
|