viveworker 0.8.1 → 0.8.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/web/app.js CHANGED
@@ -17,6 +17,7 @@ const TIMELINE_OPERATIONAL_KINDS = new Set(["approval", "plan", "plan_ready", "c
17
17
  const EXTERNAL_TARGET_TABS = new Set(["inbox", "timeline", "diff"]);
18
18
  const EXTERNAL_TARGET_INBOX_SUBTABS = new Set(["pending", "completed"]);
19
19
  const THREAD_FILTER_INTERACTION_DEFER_MS = 8000;
20
+ const SCROLLABLE_CONTENT_INTERACTION_DEFER_MS = 8000;
20
21
  const MAX_COMPLETION_REPLY_IMAGE_COUNT = 4;
21
22
  const NOTIFICATION_INTENT_CACHE = "viveworker-notification-intent-v1";
22
23
  const NOTIFICATION_INTENT_PATH = "/__viveworker_notification_intent__";
@@ -37,8 +38,41 @@ const BOOT_SPLASH_STAGE = Object.freeze({
37
38
  establishing: 2,
38
39
  loading: 3,
39
40
  });
41
+ const DETAIL_FETCH_TIMEOUT_MS = 12_000;
42
+ const DETAIL_REFRESH_FALLBACK_TIMEOUT_MS = 2_500;
43
+ const DETAIL_STICKY_LAN_PROBE_TIMEOUT_MS = 350;
44
+ const COMPLETION_REPLY_SEND_TIMEOUT_MS = 22_000;
45
+ const COMPLETION_REPLY_OPTIMISTIC_SENT_MS = 1_600;
46
+ const TIMELINE_REFRESH_TIMEOUT_MS = 8_000;
47
+ const TIMELINE_POLL_TIMEOUT_MS = 4_500;
48
+ const FAST_POLL_STEP_TIMEOUT_MS = 4_500;
49
+ const TIMELINE_STICKY_LAN_PROBE_TIMEOUT_MS = 350;
50
+ const CLIENT_EVENT_REPORT_TIMEOUT_MS = 1_800;
51
+ const TIMELINE_LIVE_REFRESH_TIMEOUT_MS = 2_000;
52
+ const TIMELINE_LIVE_RETRY_MS = 5_000;
40
53
  const timelineImageObjectUrlCache = new Map();
41
54
 
55
+ function wait(ms) {
56
+ return new Promise((resolve) => setTimeout(resolve, Math.max(0, Number(ms) || 0)));
57
+ }
58
+
59
+ async function runFastPollStep(label, fn, timeoutMs = FAST_POLL_STEP_TIMEOUT_MS) {
60
+ try {
61
+ await Promise.race([
62
+ Promise.resolve().then(fn),
63
+ wait(timeoutMs).then(() => {
64
+ const error = new Error(`${label}-poll-timeout`);
65
+ error.code = "poll-timeout";
66
+ throw error;
67
+ }),
68
+ ]);
69
+ return { label, ok: true };
70
+ } catch (error) {
71
+ console.warn(`[poll:${label}]`, error?.message || error);
72
+ return { label, ok: false };
73
+ }
74
+ }
75
+
42
76
  const state = {
43
77
  session: null,
44
78
  inbox: null,
@@ -73,6 +107,7 @@ const state = {
73
107
  listScrollState: null,
74
108
  pendingListScrollRestore: false,
75
109
  threadFilterInteractionUntilMs: 0,
110
+ scrollableContentInteractionUntilMs: 0,
76
111
  diffThreadExpandedFiles: {},
77
112
  detailDiffExpanded: {},
78
113
  choiceLocalDrafts: {},
@@ -156,7 +191,16 @@ const state = {
156
191
  };
157
192
 
158
193
  let detailLoadSequence = 0;
194
+ let timelineHydrationSequence = 0;
195
+ let authenticatedPollInFlight = false;
159
196
  let hazbasePasskeyModulePromise = null;
197
+ let lastTimelineRenderReportKey = "";
198
+ let timelineLiveStream = null;
199
+ let timelineLiveStreamRetryTimer = 0;
200
+ let timelineLiveRefreshInFlight = false;
201
+ let timelineLiveRefreshPending = false;
202
+ let lastTimelineLiveRevision = 0;
203
+ const reportedTimelineRenderTokens = new Set();
160
204
 
161
205
  async function loadHazbasePasskeyModule() {
162
206
  if (!hazbasePasskeyModulePromise) {
@@ -675,6 +719,8 @@ async function boot() {
675
719
  .then(async () => {
676
720
  if (!shouldDeferRenderForActiveInteraction()) {
677
721
  await renderShell();
722
+ } else {
723
+ renderDeferredInteractionShellUpdates();
678
724
  }
679
725
  })
680
726
  .catch(() => {});
@@ -686,55 +732,78 @@ async function boot() {
686
732
  .then(async () => {
687
733
  if (!shouldDeferRenderForActiveInteraction()) {
688
734
  await renderShell();
735
+ } else {
736
+ renderDeferredInteractionShellUpdates();
689
737
  }
690
738
  })
691
739
  .catch(() => {});
692
740
  syncDetectedLocalePreference().catch(() => {});
693
741
 
694
742
  setInterval(async () => {
695
- if (!state.session?.authenticated) {
696
- return;
697
- }
698
- const consumedNotificationIntent = await consumePendingNotificationIntent();
699
- if (consumedNotificationIntent) {
743
+ if (!state.session?.authenticated || authenticatedPollInFlight) {
700
744
  return;
701
745
  }
702
- // Split the poll tick into a fast local fan-out and a slow background
703
- // fan-out. The fast calls are all in-memory bridge lookups (inbox,
704
- // timeline, devices, push status, relay status) and typically resolve in
705
- // under ~100 ms over localhost; rendering immediately after them keeps
706
- // new user_message entries reaching the timeline within one scan tick.
707
- // The slow calls — /api/inbox/diff (git subprocesses), moltbook scout
708
- // status (cross-origin to moltbook.com on cache miss), and especially
709
- // /api/share/status (10 s upstream timeout to share.viveworker.com)
710
- // used to gate the render on every poll, so a single share-worker
711
- // cache miss stalled timeline updates for up to 10 seconds. Now they
712
- // run in the background and trigger a second render when they resolve.
713
- await Promise.all([
714
- refreshInbox(),
715
- refreshTimeline(),
716
- refreshDevices(),
717
- refreshPushStatus(),
718
- fetchA2aRelayStatus(),
719
- ]);
720
- ensureCurrentSelection();
721
- maybeAutoFocusClaudePending();
722
- if (!shouldDeferRenderForActiveInteraction()) {
723
- await renderShell();
724
- }
746
+ authenticatedPollInFlight = true;
747
+ try {
748
+ const consumedNotificationIntent = await consumePendingNotificationIntent();
749
+ if (consumedNotificationIntent) {
750
+ return;
751
+ }
752
+ // Keep timeline freshness independent from secondary status probes.
753
+ // Remote relay reconnects can make one small API call wait for a full
754
+ // transport timeout; the timeline should still render on the next tick.
755
+ await Promise.all([
756
+ runFastPollStep(
757
+ "timeline",
758
+ () => refreshTimeline({ timeoutMs: TIMELINE_POLL_TIMEOUT_MS }),
759
+ TIMELINE_POLL_TIMEOUT_MS + 500,
760
+ ),
761
+ runFastPollStep(
762
+ "inbox",
763
+ () => refreshInbox({ timeoutMs: FAST_POLL_STEP_TIMEOUT_MS }),
764
+ FAST_POLL_STEP_TIMEOUT_MS + 500,
765
+ ),
766
+ runFastPollStep(
767
+ "devices",
768
+ () => refreshDevices({ timeoutMs: FAST_POLL_STEP_TIMEOUT_MS }),
769
+ FAST_POLL_STEP_TIMEOUT_MS + 500,
770
+ ),
771
+ runFastPollStep(
772
+ "push",
773
+ () => refreshPushStatus({ timeoutMs: FAST_POLL_STEP_TIMEOUT_MS }),
774
+ FAST_POLL_STEP_TIMEOUT_MS + 500,
775
+ ),
776
+ runFastPollStep(
777
+ "a2a-relay",
778
+ () => fetchA2aRelayStatus({ timeoutMs: FAST_POLL_STEP_TIMEOUT_MS }),
779
+ FAST_POLL_STEP_TIMEOUT_MS + 500,
780
+ ),
781
+ ]);
782
+ ensureCurrentSelection();
783
+ maybeAutoFocusClaudePending();
784
+ if (!shouldDeferRenderForActiveInteraction()) {
785
+ await renderShell();
786
+ } else {
787
+ renderDeferredInteractionShellUpdates();
788
+ }
725
789
 
726
- Promise.allSettled([
727
- refreshInboxDiff(),
728
- fetchMoltbookScoutStatus(),
729
- fetchA2aShareStatus(),
730
- fetchRemotePairingStatus(),
731
- ])
732
- .then(async () => {
733
- if (!shouldDeferRenderForActiveInteraction()) {
734
- await renderShell();
735
- }
736
- })
737
- .catch(() => {});
790
+ Promise.allSettled([
791
+ refreshInboxDiff(),
792
+ fetchMoltbookScoutStatus(),
793
+ fetchA2aShareStatus(),
794
+ fetchRemotePairingStatus(),
795
+ ])
796
+ .then(async () => {
797
+ if (!shouldDeferRenderForActiveInteraction()) {
798
+ await renderShell();
799
+ } else {
800
+ renderDeferredInteractionShellUpdates();
801
+ }
802
+ })
803
+ .catch(() => {});
804
+ } finally {
805
+ authenticatedPollInFlight = false;
806
+ }
738
807
  }, 3000);
739
808
  }
740
809
 
@@ -828,7 +897,7 @@ async function forceAppRefreshFromLan() {
828
897
  const keys = await caches.keys();
829
898
  await Promise.all(
830
899
  keys
831
- .filter((key) => /^viveworker-v/.test(key))
900
+ .filter((key) => /^viveworker-/u.test(key))
832
901
  .map((key) => caches.delete(key))
833
902
  );
834
903
  }
@@ -883,7 +952,11 @@ async function refreshAuthenticatedState() {
883
952
  // moltbook/a2a worker calls — the a2a share worker has a 10s timeout) and
884
953
  // runs in the background after the shell renders.
885
954
  async function refreshAuthenticatedStateLocal() {
886
- await Promise.all([refreshInbox(), refreshTimeline(), refreshDevices()]);
955
+ await Promise.all([
956
+ refreshInbox({ timeoutMs: FAST_POLL_STEP_TIMEOUT_MS }),
957
+ refreshTimeline({ timeoutMs: TIMELINE_POLL_TIMEOUT_MS }),
958
+ refreshDevices({ timeoutMs: FAST_POLL_STEP_TIMEOUT_MS }),
959
+ ]);
887
960
  ensureCurrentSelection();
888
961
  }
889
962
 
@@ -939,9 +1012,7 @@ async function refreshBootstrap() {
939
1012
  syncCompletedThreadFilter();
940
1013
  syncInboxSubtab();
941
1014
 
942
- state.timeline = await hydrateTimelinePayloadImages(bootstrap?.timeline || null);
943
- syncTimelineThreadFilter();
944
- syncTimelineKindFilter();
1015
+ setTimelinePayload(bootstrap?.timeline || null, { hydrateImages: true, renderOnHydrate: false });
945
1016
 
946
1017
  const devicesPayload = bootstrap?.devices;
947
1018
  state.devices = Array.isArray(devicesPayload?.devices) ? devicesPayload.devices : [];
@@ -1048,7 +1119,7 @@ function detectBrowserLocale() {
1048
1119
  return normalizeLocale(navigator.language || "") || DEFAULT_LOCALE;
1049
1120
  }
1050
1121
 
1051
- async function refreshPushStatus() {
1122
+ async function refreshPushStatus(opts = {}) {
1052
1123
  const client = await getClientPushState();
1053
1124
  const { clientSubscription, ...clientStatus } = client;
1054
1125
  if (!state.session?.authenticated) {
@@ -1064,7 +1135,7 @@ async function refreshPushStatus() {
1064
1135
  }
1065
1136
 
1066
1137
  try {
1067
- let server = await apiGet("/api/push/status");
1138
+ let server = await apiGet("/api/push/status", opts);
1068
1139
  if (
1069
1140
  server?.enabled === true &&
1070
1141
  server?.subscribed !== true &&
@@ -1076,8 +1147,8 @@ async function refreshPushStatus() {
1076
1147
  subscription: clientSubscription,
1077
1148
  userAgent: navigator.userAgent,
1078
1149
  standalone: isStandaloneMode(),
1079
- });
1080
- server = await apiGet("/api/push/status");
1150
+ }, opts);
1151
+ server = await apiGet("/api/push/status", opts);
1081
1152
  } catch {
1082
1153
  // Best effort: if the browser still has a local subscription, the
1083
1154
  // status row can reflect that while the enable action repairs it.
@@ -1114,13 +1185,13 @@ async function fetchMoltbookScoutStatus() {
1114
1185
  }
1115
1186
  }
1116
1187
 
1117
- async function fetchA2aRelayStatus() {
1188
+ async function fetchA2aRelayStatus(opts = {}) {
1118
1189
  if (!state.session?.a2aRelayEnabled) {
1119
1190
  state.a2aRelayStatus = null;
1120
1191
  return;
1121
1192
  }
1122
1193
  try {
1123
- state.a2aRelayStatus = await apiGet("/api/a2a/relay-status");
1194
+ state.a2aRelayStatus = await apiGet("/api/a2a/relay-status", opts);
1124
1195
  } catch {
1125
1196
  state.a2aRelayStatus = null;
1126
1197
  }
@@ -1204,8 +1275,8 @@ async function getClientPushState() {
1204
1275
  };
1205
1276
  }
1206
1277
 
1207
- async function refreshInbox() {
1208
- const fast = await apiGet("/api/inbox");
1278
+ async function refreshInbox(opts = {}) {
1279
+ const fast = await apiGet("/api/inbox", opts);
1209
1280
  // `/api/inbox` now returns only `{ pending, completed }`. The `diff`
1210
1281
  // half lives at `/api/inbox/diff` because it spawns `git` subprocesses
1211
1282
  // server-side and was blocking first paint of the completed/pending
@@ -1240,13 +1311,309 @@ async function refreshInboxDiff() {
1240
1311
  }
1241
1312
  }
1242
1313
 
1243
- async function refreshTimeline() {
1244
- state.timeline = await hydrateTimelinePayloadImages(await apiGet("/api/timeline"));
1314
+ async function refreshTimeline(opts = {}) {
1315
+ const requestOpts = {
1316
+ timeoutMs: TIMELINE_REFRESH_TIMEOUT_MS,
1317
+ probeLanWhileSticky: true,
1318
+ stickyLanProbeTimeoutMs: TIMELINE_STICKY_LAN_PROBE_TIMEOUT_MS,
1319
+ ...opts,
1320
+ };
1321
+ setTimelinePayload(await apiGet("/api/timeline", requestOpts), { hydrateImages: true });
1322
+ }
1323
+
1324
+ async function refreshTimelineDirectLan(opts = {}) {
1325
+ setTimelinePayload(await apiGetDirectLan("/api/timeline", opts), { hydrateImages: true });
1326
+ }
1327
+
1328
+ function closeTimelineLiveStream() {
1329
+ if (timelineLiveStream) {
1330
+ try {
1331
+ timelineLiveStream.close();
1332
+ } catch {}
1333
+ }
1334
+ timelineLiveStream = null;
1335
+ if (timelineLiveStreamRetryTimer) {
1336
+ clearTimeout(timelineLiveStreamRetryTimer);
1337
+ timelineLiveStreamRetryTimer = 0;
1338
+ }
1339
+ }
1340
+
1341
+ function shouldUseTimelineLiveStream() {
1342
+ if (!state.session?.authenticated || typeof EventSource !== "function") {
1343
+ return false;
1344
+ }
1345
+ const telemetry = getRoutingTelemetry() || {};
1346
+ return telemetry.lastRoute === "lan";
1347
+ }
1348
+
1349
+ function scheduleTimelineLiveStreamRetry() {
1350
+ if (timelineLiveStreamRetryTimer || !state.session?.authenticated) {
1351
+ return;
1352
+ }
1353
+ timelineLiveStreamRetryTimer = setTimeout(() => {
1354
+ timelineLiveStreamRetryTimer = 0;
1355
+ syncTimelineLiveStream();
1356
+ }, TIMELINE_LIVE_RETRY_MS);
1357
+ }
1358
+
1359
+ function syncTimelineLiveStream() {
1360
+ if (!state.session?.authenticated) {
1361
+ closeTimelineLiveStream();
1362
+ return;
1363
+ }
1364
+ if (timelineLiveStream) {
1365
+ return;
1366
+ }
1367
+ if (!shouldUseTimelineLiveStream()) {
1368
+ return;
1369
+ }
1370
+ try {
1371
+ const stream = new EventSource("/api/timeline/stream");
1372
+ timelineLiveStream = stream;
1373
+ stream.addEventListener("hello", (event) => {
1374
+ const data = parseTimelineLiveEvent(event);
1375
+ lastTimelineLiveRevision = Math.max(lastTimelineLiveRevision, Number(data?.revision) || 0);
1376
+ });
1377
+ stream.addEventListener("timeline:update", (event) => {
1378
+ const data = parseTimelineLiveEvent(event);
1379
+ handleTimelineLiveUpdate(data).catch((err) => {
1380
+ console.warn("[timeline-live]", err?.message || err);
1381
+ });
1382
+ });
1383
+ stream.addEventListener("heartbeat", (event) => {
1384
+ const data = parseTimelineLiveEvent(event);
1385
+ lastTimelineLiveRevision = Math.max(lastTimelineLiveRevision, Number(data?.revision) || 0);
1386
+ });
1387
+ stream.onerror = () => {
1388
+ if (timelineLiveStream === stream) {
1389
+ closeTimelineLiveStream();
1390
+ scheduleTimelineLiveStreamRetry();
1391
+ }
1392
+ };
1393
+ } catch (err) {
1394
+ console.warn("[timeline-live]", err?.message || err);
1395
+ closeTimelineLiveStream();
1396
+ scheduleTimelineLiveStreamRetry();
1397
+ }
1398
+ }
1399
+
1400
+ function parseTimelineLiveEvent(event) {
1401
+ try {
1402
+ return JSON.parse(event?.data || "{}");
1403
+ } catch {
1404
+ return null;
1405
+ }
1406
+ }
1407
+
1408
+ async function handleTimelineLiveUpdate(data) {
1409
+ const revision = Number(data?.revision) || 0;
1410
+ if (revision && revision <= lastTimelineLiveRevision) {
1411
+ return;
1412
+ }
1413
+ lastTimelineLiveRevision = Math.max(lastTimelineLiveRevision, revision);
1414
+ if (timelineLiveRefreshInFlight) {
1415
+ timelineLiveRefreshPending = true;
1416
+ return;
1417
+ }
1418
+ timelineLiveRefreshInFlight = true;
1419
+ try {
1420
+ do {
1421
+ timelineLiveRefreshPending = false;
1422
+ try {
1423
+ await refreshTimelineDirectLan({ timeoutMs: TIMELINE_LIVE_REFRESH_TIMEOUT_MS });
1424
+ } catch {
1425
+ await refreshTimeline({
1426
+ timeoutMs: TIMELINE_LIVE_REFRESH_TIMEOUT_MS,
1427
+ probeLanWhileSticky: true,
1428
+ stickyLanProbeTimeoutMs: TIMELINE_STICKY_LAN_PROBE_TIMEOUT_MS,
1429
+ });
1430
+ }
1431
+ renderAfterBackgroundDataRefresh();
1432
+ } while (timelineLiveRefreshPending);
1433
+ } finally {
1434
+ timelineLiveRefreshInFlight = false;
1435
+ }
1436
+ }
1437
+
1438
+ function setTimelinePayload(payload, options = {}) {
1439
+ const requestId = ++timelineHydrationSequence;
1440
+ const normalizedPayload = payload && typeof payload === "object" ? payload : null;
1441
+ state.timeline = normalizedPayload;
1245
1442
  syncTimelineThreadFilter();
1246
1443
  syncTimelineKindFilter();
1444
+ syncTimelineLiveStream();
1445
+
1446
+ if (options.hydrateImages !== true || !timelinePayloadHasImages(normalizedPayload)) {
1447
+ return;
1448
+ }
1449
+
1450
+ hydrateTimelinePayloadImages(normalizedPayload)
1451
+ .then((hydrated) => {
1452
+ if (requestId !== timelineHydrationSequence || !hydrated) {
1453
+ return;
1454
+ }
1455
+ state.timeline = hydrated;
1456
+ syncTimelineThreadFilter();
1457
+ syncTimelineKindFilter();
1458
+ if (options.renderOnHydrate === false || !state.session?.authenticated) {
1459
+ return;
1460
+ }
1461
+ renderAfterBackgroundDataRefresh();
1462
+ })
1463
+ .catch(() => {});
1464
+ }
1465
+
1466
+ function latestTimelineEntryForClientEvent() {
1467
+ const entries = Array.isArray(state.timeline?.entries) ? state.timeline.entries : [];
1468
+ return entries.length > 0 ? entries[0] : null;
1469
+ }
1470
+
1471
+ function newlyRenderedTimelineTokensForClientEvent(visible) {
1472
+ if (!visible) {
1473
+ return [];
1474
+ }
1475
+ const entries = Array.isArray(state.timeline?.entries) ? state.timeline.entries : [];
1476
+ const rendered = [];
1477
+ for (const entry of entries.slice(0, 20)) {
1478
+ const token = normalizeClientText(entry?.token || entry?.createdAtMs || "");
1479
+ const kind = normalizeClientText(entry?.kind || "");
1480
+ if (!token || !kind || reportedTimelineRenderTokens.has(token)) {
1481
+ continue;
1482
+ }
1483
+ reportedTimelineRenderTokens.add(token);
1484
+ rendered.push({
1485
+ token,
1486
+ kind,
1487
+ createdAtMs: Number(entry?.createdAtMs) || 0,
1488
+ });
1489
+ if (rendered.length >= 5) {
1490
+ break;
1491
+ }
1492
+ }
1493
+ if (reportedTimelineRenderTokens.size > 500) {
1494
+ const keep = new Set(entries.slice(0, 250).map((entry) => normalizeClientText(entry?.token || entry?.createdAtMs || "")).filter(Boolean));
1495
+ for (const token of reportedTimelineRenderTokens) {
1496
+ if (!keep.has(token)) {
1497
+ reportedTimelineRenderTokens.delete(token);
1498
+ }
1499
+ }
1500
+ }
1501
+ return rendered;
1502
+ }
1503
+
1504
+ function reportTimelineRendered(reason = "render") {
1505
+ if (!state.session?.authenticated) {
1506
+ return;
1507
+ }
1508
+ const latest = latestTimelineEntryForClientEvent();
1509
+ if (!latest) {
1510
+ return;
1511
+ }
1512
+ const latestToken = normalizeClientText(latest.token || latest.createdAtMs || "");
1513
+ const latestKind = normalizeClientText(latest.kind || "");
1514
+ if (!latestToken || !latestKind) {
1515
+ return;
1516
+ }
1517
+ const desktop = isDesktopLayout();
1518
+ const visible = state.currentTab === "timeline" && (desktop || !state.detailOpen);
1519
+ const telemetry = getRoutingTelemetry() || {};
1520
+ const route = normalizeClientText(telemetry.lastRoute || "unknown");
1521
+ const renderedTokens = newlyRenderedTimelineTokensForClientEvent(visible);
1522
+ const reportKey = [
1523
+ latestKind,
1524
+ latestToken,
1525
+ state.currentTab,
1526
+ visible ? "visible" : "hidden",
1527
+ route,
1528
+ renderedTokens.map((item) => item.token).join(","),
1529
+ ].join(":");
1530
+ if (lastTimelineRenderReportKey === reportKey) {
1531
+ return;
1532
+ }
1533
+ lastTimelineRenderReportKey = reportKey;
1534
+
1535
+ const send = () => {
1536
+ const latestNow = latestTimelineEntryForClientEvent();
1537
+ if (!latestNow) {
1538
+ return;
1539
+ }
1540
+ const telemetryNow = getRoutingTelemetry() || telemetry;
1541
+ apiPost(
1542
+ "/api/client-events",
1543
+ {
1544
+ type: "timeline-render",
1545
+ reason,
1546
+ latestToken: normalizeClientText(latestNow.token || latestNow.createdAtMs || ""),
1547
+ latestKind: normalizeClientText(latestNow.kind || ""),
1548
+ latestCreatedAtMs: Number(latestNow.createdAtMs) || 0,
1549
+ renderedTokens,
1550
+ entryCount: Array.isArray(state.timeline?.entries) ? state.timeline.entries.length : 0,
1551
+ currentTab: state.currentTab,
1552
+ visible,
1553
+ route: normalizeClientText(telemetryNow.lastRoute || route || "unknown"),
1554
+ clientAtMs: Date.now(),
1555
+ appBuildId: APP_BUILD_ID,
1556
+ },
1557
+ {
1558
+ timeoutMs: CLIENT_EVENT_REPORT_TIMEOUT_MS,
1559
+ probeLanWhileSticky: false,
1560
+ suppressRoutingStatus: true,
1561
+ },
1562
+ ).catch(() => {});
1563
+ };
1564
+
1565
+ if (typeof requestAnimationFrame === "function") {
1566
+ requestAnimationFrame(send);
1567
+ } else {
1568
+ queueMicrotask(send);
1569
+ }
1570
+ }
1571
+
1572
+ function timelinePayloadHasImages(payload) {
1573
+ const entries = Array.isArray(payload?.entries) ? payload.entries : [];
1574
+ return entries.some((entry) => Array.isArray(entry?.imageUrls) && entry.imageUrls.length > 0);
1575
+ }
1576
+
1577
+ function renderAfterBackgroundDataRefresh() {
1578
+ ensureCurrentSelection();
1579
+ if (shouldDeferRenderForActiveInteraction()) {
1580
+ if (renderDeferredInteractionShellUpdates()) {
1581
+ reportTimelineRendered("deferred-refresh");
1582
+ }
1583
+ return;
1584
+ }
1585
+ renderCurrentSurface();
1247
1586
  }
1248
1587
 
1249
- async function refreshDevices() {
1588
+ async function refreshPrimaryTabData(tab = state.currentTab) {
1589
+ if (tab === "inbox") {
1590
+ await refreshInbox();
1591
+ return;
1592
+ }
1593
+ if (tab === "timeline") {
1594
+ await refreshTimeline();
1595
+ return;
1596
+ }
1597
+ if (tab === "diff") {
1598
+ await refreshInboxDiff();
1599
+ }
1600
+ }
1601
+
1602
+ function refreshPrimaryTabAfterNavigation(tab = state.currentTab) {
1603
+ if (!state.session?.authenticated || tab === "settings") {
1604
+ return;
1605
+ }
1606
+ refreshPrimaryTabData(tab)
1607
+ .then(() => {
1608
+ if (state.currentTab !== tab || state.currentTab === "settings") {
1609
+ return;
1610
+ }
1611
+ renderAfterBackgroundDataRefresh();
1612
+ })
1613
+ .catch(() => {});
1614
+ }
1615
+
1616
+ async function refreshDevices(opts = {}) {
1250
1617
  if (!state.session?.authenticated) {
1251
1618
  state.devices = [];
1252
1619
  state.deviceError = "";
@@ -1254,7 +1621,7 @@ async function refreshDevices() {
1254
1621
  }
1255
1622
 
1256
1623
  try {
1257
- const payload = await apiGet("/api/devices");
1624
+ const payload = await apiGet("/api/devices", opts);
1258
1625
  state.devices = Array.isArray(payload?.devices) ? payload.devices : [];
1259
1626
  state.deviceError = "";
1260
1627
  } catch (error) {
@@ -1532,6 +1899,7 @@ function timelineKindFilterOptions() {
1532
1899
  { id: "messages", label: L("timeline.kindFilter.messages"), icon: "timeline" },
1533
1900
  { id: "suggestions", label: L("timeline.kindFilter.suggestions"), icon: "suggestions" },
1534
1901
  { id: "files", label: L("timeline.kindFilter.files"), icon: "file-event" },
1902
+ { id: "commands", label: L("timeline.kindFilter.commands"), icon: "command" },
1535
1903
  { id: "approvals", label: L("timeline.kindFilter.approvals"), icon: "approval" },
1536
1904
  { id: "plans", label: L("timeline.kindFilter.plans"), icon: "plan" },
1537
1905
  { id: "choices", label: L("timeline.kindFilter.choices"), icon: "choice" },
@@ -1562,15 +1930,26 @@ function currentTimelineKindFilterOption() {
1562
1930
 
1563
1931
  function timelineEntryMatchesKindFilter(entry, filterId) {
1564
1932
  const kind = normalizeClientText(entry?.kind || entry?.item?.kind || "");
1933
+ const fileEventType = normalizeClientText(entry?.fileEventType || entry?.item?.fileEventType || "");
1934
+ const activityPhase = normalizeClientText(entry?.activityPhase || entry?.item?.activityPhase || "");
1565
1935
  switch (filterId) {
1566
1936
  case "messages":
1567
- return TIMELINE_MESSAGE_KINDS.has(kind);
1937
+ return TIMELINE_MESSAGE_KINDS.has(kind) || (kind === "activity_status" && activityPhase === "thinking");
1568
1938
  case "suggestions":
1569
1939
  return kind === "ambient_suggestions";
1570
1940
  case "files":
1571
- return kind === "file_event";
1941
+ return (
1942
+ (kind === "file_event" && fileEventType !== "git") ||
1943
+ (kind === "activity_status" && ["reading", "searching", "editing"].includes(activityPhase))
1944
+ );
1945
+ case "commands":
1946
+ return (
1947
+ kind === "command_event" ||
1948
+ (kind === "file_event" && fileEventType === "git") ||
1949
+ (kind === "activity_status" && activityPhase === "running_command")
1950
+ );
1572
1951
  case "approvals":
1573
- return kind === "approval";
1952
+ return kind === "approval" || (kind === "activity_status" && activityPhase === "awaiting_approval");
1574
1953
  case "plans":
1575
1954
  return kind === "plan" || kind === "plan_ready";
1576
1955
  case "choices":
@@ -1847,6 +2226,7 @@ async function logout({ revokeCurrentDeviceTrust = false } = {}) {
1847
2226
  }
1848
2227
 
1849
2228
  function resetAuthenticatedState() {
2229
+ closeTimelineLiveStream();
1850
2230
  state.session = null;
1851
2231
  state.inbox = null;
1852
2232
  // Reset the diff-loaded flag so the next sign-in shows the skeleton
@@ -1903,37 +2283,47 @@ async function revokeTrustedDevice(deviceId) {
1903
2283
  * On a polling interval, that means a long line of code keeps snapping
1904
2284
  * back to the start while the reader is mid-line.
1905
2285
  *
1906
- * `snapshotCodeBlockScrolls()` records each `.markdown pre`'s scrollLeft
2286
+ * `snapshotScrollableContentScrolls()` records each scrollable code/diff
2287
+ * block's scrollLeft
1907
2288
  * keyed by its trimmed textContent (so the key survives a fresh render
1908
- * regardless of position in the DOM tree). `restoreCodeBlockScrolls()`
2289
+ * regardless of position in the DOM tree). `restoreScrollableContentScrolls()`
1909
2290
  * walks the new DOM and restores any scrollLeft we still have a key for.
1910
2291
  *
1911
2292
  * Content-keyed matching is intentional: if the underlying code text
1912
2293
  * changes mid-scroll, the new <pre> is logically different and we let it
1913
2294
  * start at scrollLeft=0 rather than landing the reader somewhere unrelated.
1914
2295
  */
1915
- function snapshotCodeBlockScrolls() {
2296
+ // Blocks the user can scroll horizontally — we preserve their position
2297
+ // across innerHTML rebuilds and defer background renders while the user is
2298
+ // actively scrolling/selecting them. <pre> for fenced code; <table> for
2299
+ // pipe-style markdown tables; `.detail-diff-viewer` for file diffs.
2300
+ const SCROLLABLE_CONTENT_SELECTORS = ".markdown pre, .markdown table, .detail-diff-viewer";
2301
+
2302
+ function snapshotScrollableContentScrolls() {
1916
2303
  if (typeof document === "undefined") return null;
1917
- const blocks = document.querySelectorAll(".markdown pre");
2304
+ const blocks = document.querySelectorAll(SCROLLABLE_CONTENT_SELECTORS);
1918
2305
  if (blocks.length === 0) return null;
1919
2306
  const map = new Map();
1920
- for (const pre of blocks) {
1921
- const key = pre.textContent ? pre.textContent.trim() : "";
2307
+ for (const el of blocks) {
2308
+ const key = el.textContent ? el.textContent.trim() : "";
1922
2309
  if (!key) continue;
1923
- if (pre.scrollLeft === 0 && pre.scrollTop === 0) continue;
1924
- map.set(key, { scrollLeft: pre.scrollLeft, scrollTop: pre.scrollTop });
2310
+ if (el.scrollLeft === 0 && el.scrollTop === 0) continue;
2311
+ map.set(`${el.tagName}:${key}`, {
2312
+ scrollLeft: el.scrollLeft,
2313
+ scrollTop: el.scrollTop,
2314
+ });
1925
2315
  }
1926
2316
  return map.size > 0 ? map : null;
1927
2317
  }
1928
2318
 
1929
- function restoreCodeBlockScrolls(snapshot) {
2319
+ function restoreScrollableContentScrolls(snapshot) {
1930
2320
  if (!snapshot || typeof document === "undefined") return;
1931
- for (const pre of document.querySelectorAll(".markdown pre")) {
1932
- const key = pre.textContent ? pre.textContent.trim() : "";
1933
- const saved = key ? snapshot.get(key) : null;
2321
+ for (const el of document.querySelectorAll(SCROLLABLE_CONTENT_SELECTORS)) {
2322
+ const key = el.textContent ? el.textContent.trim() : "";
2323
+ const saved = key ? snapshot.get(`${el.tagName}:${key}`) : null;
1934
2324
  if (!saved) continue;
1935
- if (saved.scrollLeft) pre.scrollLeft = saved.scrollLeft;
1936
- if (saved.scrollTop) pre.scrollTop = saved.scrollTop;
2325
+ if (saved.scrollLeft) el.scrollLeft = saved.scrollLeft;
2326
+ if (saved.scrollTop) el.scrollTop = saved.scrollTop;
1937
2327
  }
1938
2328
  }
1939
2329
 
@@ -1957,7 +2347,7 @@ async function renderShell() {
1957
2347
  .filter(Boolean)
1958
2348
  .join(" ");
1959
2349
 
1960
- const codeBlockScrollSnapshot = snapshotCodeBlockScrolls();
2350
+ const scrollableContentSnapshot = snapshotScrollableContentScrolls();
1961
2351
 
1962
2352
  app.innerHTML = `
1963
2353
  <div class="${shellClassName}">
@@ -1987,8 +2377,9 @@ async function renderShell() {
1987
2377
  // Reapply any horizontal scroll the user dragged into a code block before
1988
2378
  // this re-render. Done after the imperative scroll resets above so they
1989
2379
  // can't fight each other.
1990
- restoreCodeBlockScrolls(codeBlockScrollSnapshot);
2380
+ restoreScrollableContentScrolls(scrollableContentSnapshot);
1991
2381
  requestAnimationFrame(dismissBootSplash);
2382
+ reportTimelineRendered("shell");
1992
2383
  }
1993
2384
 
1994
2385
  function applyPendingDetailScrollReset() {
@@ -2037,6 +2428,53 @@ function applyPendingSettingsScrollRestore() {
2037
2428
  });
2038
2429
  }
2039
2430
 
2431
+ function renderDeferredInteractionShellUpdates() {
2432
+ if (typeof document === "undefined") {
2433
+ return false;
2434
+ }
2435
+ if (state.currentTab === "settings") {
2436
+ return false;
2437
+ }
2438
+ const desktop = isDesktopLayout();
2439
+ // On mobile detail screens the list is intentionally not mounted, so the
2440
+ // latest state will appear as soon as the user backs out. On desktop the
2441
+ // list and detail are side-by-side, so updating only the list keeps new
2442
+ // timeline/inbox cards flowing without disturbing the detail pane.
2443
+ if (!desktop && state.detailOpen) {
2444
+ return false;
2445
+ }
2446
+ if (isListSurfaceInteractionActive()) {
2447
+ return false;
2448
+ }
2449
+ const listSurface = document.querySelector("[data-list-surface]");
2450
+ if (!listSurface) {
2451
+ return false;
2452
+ }
2453
+ const scrollLeft = listSurface.scrollLeft;
2454
+ const scrollTop = listSurface.scrollTop;
2455
+ listSurface.innerHTML = renderListPanel({
2456
+ tab: state.currentTab,
2457
+ entries: listEntriesForTab(state.currentTab),
2458
+ desktop,
2459
+ });
2460
+ listSurface.scrollLeft = scrollLeft;
2461
+ listSurface.scrollTop = scrollTop;
2462
+ bindPartialListSurfaceInteractions(listSurface);
2463
+ reportTimelineRendered("deferred-list");
2464
+ return true;
2465
+ }
2466
+
2467
+ function isListSurfaceInteractionActive() {
2468
+ if (state.threadFilterInteractionUntilMs > Date.now() || state.timelineKindFilterOpen) {
2469
+ return true;
2470
+ }
2471
+ if (typeof document === "undefined" || typeof Element === "undefined") {
2472
+ return false;
2473
+ }
2474
+ const activeElement = document.activeElement;
2475
+ return activeElement instanceof Element && Boolean(activeElement.closest("[data-list-surface]"));
2476
+ }
2477
+
2040
2478
  function currentViewportScrollY() {
2041
2479
  return window.scrollY || window.pageYOffset || document.documentElement?.scrollTop || 0;
2042
2480
  }
@@ -2049,11 +2487,40 @@ function clearThreadFilterInteraction() {
2049
2487
  state.threadFilterInteractionUntilMs = 0;
2050
2488
  }
2051
2489
 
2490
+ function markScrollableContentInteraction() {
2491
+ state.scrollableContentInteractionUntilMs = Date.now() + SCROLLABLE_CONTENT_INTERACTION_DEFER_MS;
2492
+ }
2493
+
2494
+ function selectionIntersectsScrollableContent() {
2495
+ if (typeof document === "undefined" || typeof Element === "undefined") {
2496
+ return false;
2497
+ }
2498
+ const selection = document.getSelection?.();
2499
+ if (!selection || selection.isCollapsed || selection.rangeCount === 0) {
2500
+ return false;
2501
+ }
2502
+ for (let index = 0; index < selection.rangeCount; index += 1) {
2503
+ const range = selection.getRangeAt(index);
2504
+ const node = range.commonAncestorContainer;
2505
+ const element = node instanceof Element ? node : node?.parentElement;
2506
+ if (element?.closest?.(SCROLLABLE_CONTENT_SELECTORS)) {
2507
+ return true;
2508
+ }
2509
+ }
2510
+ return false;
2511
+ }
2512
+
2052
2513
  function shouldDeferRenderForActiveInteraction() {
2053
2514
  const activeElement = document.activeElement;
2054
2515
  if (state.completionReplySheetToken) {
2055
2516
  return true;
2056
2517
  }
2518
+ if (state.scrollableContentInteractionUntilMs > Date.now()) {
2519
+ return true;
2520
+ }
2521
+ if (selectionIntersectsScrollableContent()) {
2522
+ return true;
2523
+ }
2057
2524
  if (
2058
2525
  activeElement instanceof HTMLTextAreaElement &&
2059
2526
  activeElement.matches("[data-completion-reply-textarea]") &&
@@ -2269,6 +2736,25 @@ function normalizeCompletionReplyWarning(value) {
2269
2736
  };
2270
2737
  }
2271
2738
 
2739
+ function normalizeCompletionReplyCompareText(value) {
2740
+ return normalizeClientText(value)
2741
+ .replace(/\s+/gu, " ")
2742
+ .trim();
2743
+ }
2744
+
2745
+ function completionReplyWarningMatchesSentText(error, text, attachmentCount = 0) {
2746
+ if (error?.errorKey !== "completion-reply-thread-advanced") {
2747
+ return false;
2748
+ }
2749
+ if (attachmentCount > 0) {
2750
+ return false;
2751
+ }
2752
+ const warning = normalizeCompletionReplyWarning(error?.payload?.warning);
2753
+ const warningText = normalizeCompletionReplyCompareText(warning?.summary || "");
2754
+ const sentText = normalizeCompletionReplyCompareText(text);
2755
+ return Boolean(warningText && sentText && warningText === sentText);
2756
+ }
2757
+
2272
2758
  function normalizeCompletionReplyAttachments(values) {
2273
2759
  const rawValues = Array.isArray(values)
2274
2760
  ? values
@@ -2474,9 +2960,15 @@ async function fetchCurrentDetailForItem(itemRef = state.currentItem) {
2474
2960
  if (hasDetailOverride(itemRef)) {
2475
2961
  return state.detailOverride.detail;
2476
2962
  }
2963
+ const detailUrl = `/api/items/${encodeURIComponent(itemRef.kind)}/${encodeURIComponent(itemRef.token)}`;
2477
2964
  try {
2478
2965
  const detail = await hydrateDetailImages(
2479
- await apiGet(`/api/items/${encodeURIComponent(itemRef.kind)}/${encodeURIComponent(itemRef.token)}`)
2966
+ await apiGet(detailUrl, {
2967
+ timeoutMs: DETAIL_FETCH_TIMEOUT_MS,
2968
+ probeLanWhileSticky: true,
2969
+ stickyLanProbeTimeoutMs: DETAIL_STICKY_LAN_PROBE_TIMEOUT_MS,
2970
+ preferRelayError: true,
2971
+ })
2480
2972
  );
2481
2973
  if (hasLaunchItemIntent(itemRef)) {
2482
2974
  state.launchItemIntent.status = "loaded";
@@ -2489,16 +2981,24 @@ async function fetchCurrentDetailForItem(itemRef = state.currentItem) {
2489
2981
  renderPair();
2490
2982
  return null;
2491
2983
  }
2492
- await refreshInbox();
2984
+ await Promise.race([
2985
+ refreshInbox(),
2986
+ wait(DETAIL_REFRESH_FALLBACK_TIMEOUT_MS),
2987
+ ]).catch(() => {});
2493
2988
  try {
2494
2989
  const detail = await hydrateDetailImages(
2495
- await apiGet(`/api/items/${encodeURIComponent(itemRef.kind)}/${encodeURIComponent(itemRef.token)}`)
2990
+ await apiGet(detailUrl, {
2991
+ timeoutMs: DETAIL_FETCH_TIMEOUT_MS,
2992
+ probeLanWhileSticky: true,
2993
+ stickyLanProbeTimeoutMs: DETAIL_STICKY_LAN_PROBE_TIMEOUT_MS,
2994
+ preferRelayError: true,
2995
+ })
2496
2996
  );
2497
2997
  if (hasLaunchItemIntent(itemRef)) {
2498
2998
  state.launchItemIntent.status = "loaded";
2499
2999
  }
2500
3000
  return detail;
2501
- } catch {
3001
+ } catch (retryError) {
2502
3002
  if (hasLaunchItemIntent(itemRef)) {
2503
3003
  clearChoiceLocalDraftForItem(itemRef);
2504
3004
  const fallbackDetail = buildLaunchItemFallbackDetail(itemRef);
@@ -2513,7 +3013,7 @@ async function fetchCurrentDetailForItem(itemRef = state.currentItem) {
2513
3013
  if (!state.currentItem) {
2514
3014
  return null;
2515
3015
  }
2516
- return null;
3016
+ return buildDetailLoadErrorDetail(itemRef, retryError || error);
2517
3017
  }
2518
3018
  }
2519
3019
  }
@@ -2528,6 +3028,7 @@ function queueCurrentDetailLoad(itemRef = state.currentItem) {
2528
3028
 
2529
3029
  const requestedItem = { ...itemRef };
2530
3030
  const requestId = ++detailLoadSequence;
3031
+ const hadRenderableDetailAtStart = Boolean(renderableCurrentDetail(requestedItem));
2531
3032
  state.currentDetailLoading = true;
2532
3033
  state.detailLoadingItem = requestedItem;
2533
3034
 
@@ -2554,6 +3055,16 @@ function queueCurrentDetailLoad(itemRef = state.currentItem) {
2554
3055
  }
2555
3056
  state.currentDetailLoading = false;
2556
3057
  state.detailLoadingItem = null;
3058
+ const completedInitialDetailLoad =
3059
+ !hadRenderableDetailAtStart &&
3060
+ Boolean(state.currentDetail) &&
3061
+ isSameItemRef(state.currentDetail, requestedItem) &&
3062
+ Boolean(state.currentItem) &&
3063
+ isSameItemRef(state.currentItem, requestedItem);
3064
+ if (shouldDeferRenderForActiveInteraction() && !completedInitialDetailLoad) {
3065
+ renderDeferredInteractionShellUpdates();
3066
+ return;
3067
+ }
2557
3068
  renderCurrentSurface();
2558
3069
  });
2559
3070
  }
@@ -2572,6 +3083,31 @@ function buildLaunchItemFallbackDetail(itemRef) {
2572
3083
  };
2573
3084
  }
2574
3085
 
3086
+ function buildDetailLoadErrorDetail(itemRef, error) {
3087
+ const snapshot = buildDetailLoadingSnapshot(itemRef) || {};
3088
+ const entry = selectedEntryForItem(itemRef);
3089
+ const item = entry?.item || {};
3090
+ const fallbackText = normalizeClientText(item.messageText || item.summary || item.title || "");
3091
+ const messageParts = [
3092
+ fallbackText ? `<p>${escapeHtml(fallbackText)}</p>` : "",
3093
+ ].filter(Boolean);
3094
+ return {
3095
+ kind: itemRef.kind,
3096
+ token: itemRef.token,
3097
+ title: item.title || snapshot.title || kindMeta(itemRef.kind).label,
3098
+ threadId: item.threadId || "",
3099
+ threadLabel: item.threadLabel || snapshot.threadLabel || "",
3100
+ summary: item.summary || fallbackText || "",
3101
+ messageHtml: messageParts.join(""),
3102
+ provider: item.provider || normalizeProviderClient(item.provider) || "",
3103
+ createdAtMs: Number(item.createdAtMs || snapshot.createdAtMs) || Date.now(),
3104
+ readOnly: true,
3105
+ actions: [],
3106
+ loadError: true,
3107
+ loadErrorMessage: normalizeClientText(error?.message || String(error || "")),
3108
+ };
3109
+ }
3110
+
2575
3111
  function resolveLaunchFallbackMessage(kind, isHandled) {
2576
3112
  if (kind === "approval") {
2577
3113
  return isHandled ? L("error.approvalAlreadyHandled") : L("error.approvalNotFound");
@@ -2636,7 +3172,7 @@ function renderDesktopWorkspace(detail) {
2636
3172
  (state.currentDetailLoading || !renderableCurrentDetail());
2637
3173
  return `
2638
3174
  <section class="desktop-workspace">
2639
- <aside class="surface surface--list">
3175
+ <aside class="surface surface--list" data-list-surface>
2640
3176
  ${renderListPanel({
2641
3177
  tab: state.currentTab,
2642
3178
  entries,
@@ -2664,7 +3200,7 @@ function renderMobileWorkspace(detail) {
2664
3200
  }
2665
3201
 
2666
3202
  return `
2667
- <section class="screen-block">
3203
+ <section class="screen-block" data-list-surface>
2668
3204
  ${renderListPanel({
2669
3205
  tab: state.currentTab,
2670
3206
  entries: listEntriesForTab(state.currentTab),
@@ -3245,22 +3781,27 @@ function renderTimelineEntry(entry, { desktop }) {
3245
3781
  const isMoltbookOrA2A = item.kind === "moltbook_reply" || item.kind === "moltbook_draft" || item.kind === "a2a_task" || item.kind === "a2a_task_result" || item.kind === "thread_share";
3246
3782
  const isMessageLike = TIMELINE_MESSAGE_KINDS.has(item.kind) || isMoltbookOrA2A;
3247
3783
  const isFileEvent = item.kind === "file_event";
3784
+ const isCommandEvent = item.kind === "command_event";
3785
+ const isActivityStatus = item.kind === "activity_status";
3248
3786
  const imageUrls = Array.isArray(item.imageUrls) ? item.imageUrls.filter(Boolean) : [];
3249
3787
  const fileRefs = normalizeClientFileRefs(item.fileRefs);
3250
- const primaryText = timelineEntryPrimaryText(item, entry.status, { isMessageLike, isFileEvent });
3251
- const secondaryText = timelineEntrySecondaryText(item, entry.status, primaryText, { isMessageLike, isFileEvent });
3788
+ const primaryText = timelineEntryPrimaryText(item, entry.status, { isMessageLike, isFileEvent, isCommandEvent });
3789
+ const secondaryText = timelineEntrySecondaryText(item, entry.status, primaryText, { isMessageLike, isFileEvent, isCommandEvent });
3252
3790
  const threadLabel = timelineEntryThreadLabel(item, isMessageLike);
3253
3791
  const timestampLabel = formatTimelineTimestamp(item.createdAtMs);
3254
3792
  const statusLabel = timelineEntryStatusLabel(item, isMessageLike);
3255
3793
  const fileEventFileSummary = isFileEvent ? timelineFileEventFileSummary(item) : "";
3256
3794
  const fileEventDiffStatsHtml = isFileEvent ? renderDiffEntryStatsHtml(item) : "";
3795
+ const toolEventCommand = timelineToolEventCommand(item);
3796
+ const tagName = isActivityStatus ? "div" : "button";
3797
+ const openAttrs = isActivityStatus
3798
+ ? `role="status" aria-live="polite"`
3799
+ : `data-open-item-kind="${escapeHtml(item.kind)}" data-open-item-token="${escapeHtml(item.token)}" data-source-tab="timeline"`;
3257
3800
 
3258
3801
  return `
3259
- <button
3260
- class="timeline-entry timeline-entry--${kindClassName} timeline-entry--kind-${kindNameClass} ${isMessageLike ? "timeline-entry--message" : "timeline-entry--operational"}"
3261
- data-open-item-kind="${escapeHtml(item.kind)}"
3262
- data-open-item-token="${escapeHtml(item.token)}"
3263
- data-source-tab="timeline"
3802
+ <${tagName}
3803
+ class="timeline-entry timeline-entry--${kindClassName} timeline-entry--kind-${kindNameClass} ${isMessageLike ? "timeline-entry--message" : "timeline-entry--operational"} ${isActivityStatus ? "timeline-entry--activity" : ""}"
3804
+ ${openAttrs}
3264
3805
  >
3265
3806
  <div class="timeline-entry__meta">
3266
3807
  <span class="timeline-entry__kind">
@@ -3270,12 +3811,13 @@ function renderTimelineEntry(entry, { desktop }) {
3270
3811
  </span>
3271
3812
  <span class="timeline-entry__meta-right">
3272
3813
  <span class="timeline-entry__time">${escapeHtml(timestampLabel)}</span>
3273
- <span class="timeline-entry__chevron" aria-hidden="true">${renderIcon("chevron-right")}</span>
3814
+ ${isActivityStatus ? `<span class="timeline-entry__activity-pulse" aria-hidden="true"></span>` : `<span class="timeline-entry__chevron" aria-hidden="true">${renderIcon("chevron-right")}</span>`}
3274
3815
  </span>
3275
3816
  </div>
3276
3817
  ${threadLabel ? `<p class="timeline-entry__thread">${escapeHtml(threadLabel)}</p>` : ""}
3277
3818
  <div class="timeline-entry__body">
3278
3819
  <p class="timeline-entry__title">${escapeHtml(primaryText)}</p>
3820
+ ${toolEventCommand ? `<pre class="timeline-entry__command"><code>${escapeHtml(toolEventCommand)}</code></pre>` : ""}
3279
3821
  ${secondaryText ? `<p class="timeline-entry__summary">${escapeHtml(secondaryText)}</p>` : ""}
3280
3822
  ${
3281
3823
  isFileEvent && fileEventFileSummary
@@ -3291,7 +3833,7 @@ function renderTimelineEntry(entry, { desktop }) {
3291
3833
  ${isFileEvent ? "" : renderTimelineEntryFileStrip(fileRefs)}
3292
3834
  </div>
3293
3835
  ${statusLabel ? `<div class="timeline-entry__footer"><span class="timeline-entry__status">${escapeHtml(statusLabel)}</span></div>` : ""}
3294
- </button>
3836
+ </${tagName}>
3295
3837
  `;
3296
3838
  }
3297
3839
 
@@ -3325,7 +3867,7 @@ function renderDiffEntry(entry) {
3325
3867
  }
3326
3868
 
3327
3869
  function timelineEntryStatusLabel(item, isMessageLike) {
3328
- if (isMessageLike || item?.kind === "file_event") {
3870
+ if (isMessageLike || item?.kind === "file_event" || item?.kind === "command_event") {
3329
3871
  return "";
3330
3872
  }
3331
3873
 
@@ -3408,7 +3950,7 @@ function timelineEntryThreadLabel(item, isMessage) {
3408
3950
  return threadLabel || "";
3409
3951
  }
3410
3952
 
3411
- function timelineEntryPrimaryText(item, status, { isMessageLike = false, isFileEvent = false } = {}) {
3953
+ function timelineEntryPrimaryText(item, status, { isMessageLike = false, isFileEvent = false, isCommandEvent = false } = {}) {
3412
3954
  if (item?.kind === "ambient_suggestions") {
3413
3955
  return item.summary || fallbackSummaryForKind(item.kind, status, item.provider);
3414
3956
  }
@@ -3421,10 +3963,14 @@ function timelineEntryPrimaryText(item, status, { isMessageLike = false, isFileE
3421
3963
  return fileEventTimelineCountLabel(item) || fallbackSummaryForKind(item.kind, status, item.provider);
3422
3964
  }
3423
3965
 
3966
+ if (isCommandEvent) {
3967
+ return L("common.commandEvent");
3968
+ }
3969
+
3424
3970
  return timelineDisplayTitleWithoutThread(item, { allowFallbackSummary: true }) || L("common.untitledItem");
3425
3971
  }
3426
3972
 
3427
- function timelineEntrySecondaryText(item, status, primaryText, { isMessageLike = false, isFileEvent = false } = {}) {
3973
+ function timelineEntrySecondaryText(item, status, primaryText, { isMessageLike = false, isFileEvent = false, isCommandEvent = false } = {}) {
3428
3974
  if (item?.kind === "ambient_suggestions") {
3429
3975
  return "";
3430
3976
  }
@@ -3442,6 +3988,10 @@ function timelineEntrySecondaryText(item, status, primaryText, { isMessageLike =
3442
3988
  return "";
3443
3989
  }
3444
3990
 
3991
+ if (isCommandEvent) {
3992
+ return "";
3993
+ }
3994
+
3445
3995
  const compactTitle = timelineDisplayTitleWithoutThread(item, { allowFallbackSummary: false });
3446
3996
  return compactTitle ? summaryText : "";
3447
3997
  }
@@ -3491,6 +4041,7 @@ function timelineGeneratedTitlePrefixes() {
3491
4041
  kindMeta("assistant_commentary").label,
3492
4042
  kindMeta("assistant_final").label,
3493
4043
  L("common.fileEvent"),
4044
+ L("common.commandEvent"),
3494
4045
  "Approval",
3495
4046
  "Plan",
3496
4047
  "Choice",
@@ -3499,6 +4050,7 @@ function timelineGeneratedTitlePrefixes() {
3499
4050
  "Commentary",
3500
4051
  "Final answer",
3501
4052
  "Files",
4053
+ "Command",
3502
4054
  "承認",
3503
4055
  "プラン",
3504
4056
  "選択",
@@ -3507,13 +4059,44 @@ function timelineGeneratedTitlePrefixes() {
3507
4059
  "途中経過",
3508
4060
  "最終回答",
3509
4061
  "ファイル",
4062
+ "コマンド",
3510
4063
  ];
3511
4064
  }
3512
4065
 
4066
+ function timelineCommandEventCommand(item) {
4067
+ return truncateUiText(firstMarkdownCodeFence(item?.messageText || "") || item?.summary || item?.title || "", 220);
4068
+ }
4069
+
4070
+ function shouldRenderFileEventCommand(item) {
4071
+ if (item?.kind !== "file_event") {
4072
+ return false;
4073
+ }
4074
+ return ["read", "search", "git"].includes(normalizeClientText(item?.fileEventType || ""));
4075
+ }
4076
+
4077
+ function timelineToolEventCommand(item) {
4078
+ if (item?.kind === "activity_status") {
4079
+ return truncateUiText(item?.commandText || "", 220);
4080
+ }
4081
+ if (item?.kind === "command_event") {
4082
+ return timelineCommandEventCommand(item);
4083
+ }
4084
+ if (!shouldRenderFileEventCommand(item)) {
4085
+ return "";
4086
+ }
4087
+ return truncateUiText(firstMarkdownCodeFence(item?.messageText || "") || item?.commandText || "", 220);
4088
+ }
4089
+
3513
4090
  function fileEventDisplayLabel(fileEventType) {
3514
4091
  switch (normalizeClientText(fileEventType || "")) {
3515
4092
  case "read":
3516
4093
  return L("fileEvent.read");
4094
+ case "search":
4095
+ return L("fileEvent.search");
4096
+ case "git":
4097
+ return L("fileEvent.command");
4098
+ case "command":
4099
+ return L("fileEvent.command");
3517
4100
  case "write":
3518
4101
  return L("fileEvent.write");
3519
4102
  case "create":
@@ -3529,13 +4112,26 @@ function fileEventDisplayLabel(fileEventType) {
3529
4112
 
3530
4113
  function fileEventTimelineCountLabel(item) {
3531
4114
  const fileEventType = normalizeClientText(item?.fileEventType || "");
3532
- const count = normalizeClientFileRefs(item?.fileRefs).length;
4115
+ const fileRefs = normalizeClientFileRefs(item?.fileRefs);
4116
+ const count = fileRefs.length;
3533
4117
  if (count <= 0) {
3534
4118
  return fileEventDisplayLabel(fileEventType) || L("common.fileEvent");
3535
4119
  }
3536
4120
  switch (fileEventType) {
3537
4121
  case "read":
4122
+ if (count === 1) {
4123
+ return L("fileEvent.read");
4124
+ }
3538
4125
  return L("fileEvent.timeline.read", { count });
4126
+ case "search":
4127
+ if (count === 1) {
4128
+ return L("fileEvent.search");
4129
+ }
4130
+ return L("fileEvent.timeline.search", { count });
4131
+ case "git":
4132
+ return L("fileEvent.timeline.command", { count });
4133
+ case "command":
4134
+ return L("fileEvent.timeline.command", { count });
3539
4135
  case "write":
3540
4136
  return L("fileEvent.timeline.write", { count });
3541
4137
  case "create":
@@ -3584,7 +4180,7 @@ function diffThreadFilesSummary(item) {
3584
4180
  .map((entry) => fileChangeEntryLabel(entry))
3585
4181
  .filter(Boolean);
3586
4182
  if (labels.length === 0) {
3587
- return "";
4183
+ return truncateUiText(firstMarkdownCodeFence(item?.messageText || "") || item?.summary || "");
3588
4184
  }
3589
4185
  const visibleLabels = labels.slice(0, 3);
3590
4186
  const hiddenCount = labels.length - visibleLabels.length;
@@ -4878,20 +5474,29 @@ function recentAutoPilotEntries(limit = 5) {
4878
5474
  }
4879
5475
 
4880
5476
  function isAutoPilotApprovalEntry(entry) {
5477
+ const mode = normalizeClientText(entry?.autoPilotMode || "");
4881
5478
  const stableId = normalizeClientText(entry?.stableId || "");
4882
5479
  return (
4883
5480
  normalizeClientText(entry?.kind || "") === "approval" &&
4884
5481
  normalizeClientText(entry?.outcome || "") === "approved" &&
4885
- (stableId.endsWith(":autopilot") || stableId.includes(":autopilot-write"))
5482
+ (mode === "read" || mode === "write" || stableId.endsWith(":autopilot") || stableId.includes(":autopilot-write"))
4886
5483
  );
4887
5484
  }
4888
5485
 
4889
5486
  function autoPilotEntryMode(item) {
5487
+ const mode = normalizeClientText(item?.autoPilotMode || "");
5488
+ if (mode === "read" || mode === "write") {
5489
+ return mode;
5490
+ }
4890
5491
  const stableId = normalizeClientText(item?.stableId || "");
4891
5492
  return stableId.includes(":autopilot-write") ? "write" : "read";
4892
5493
  }
4893
5494
 
4894
5495
  function autoPilotEntryWriteLane(item) {
5496
+ const projected = normalizeClientText(item?.autoPilotWriteLane || "");
5497
+ if (projected) {
5498
+ return projected;
5499
+ }
4895
5500
  const stableId = normalizeClientText(item?.stableId || "");
4896
5501
  const match = stableId.match(/:autopilot-write:([a-z_-]+)$/u);
4897
5502
  return normalizeClientText(match?.[1] || "");
@@ -5222,6 +5827,15 @@ function renderSettingsAutoPilotSuggestion({ lane, count }) {
5222
5827
  `;
5223
5828
  }
5224
5829
 
5830
+ function renderSettingsExternalLink({ href, label }) {
5831
+ return `
5832
+ <a class="settings-external-link" href="${escapeHtml(href)}" target="_blank" rel="noopener noreferrer" style="display:inline-flex;align-items:center;justify-content:flex-end;gap:.32rem;text-decoration:none;color:#8fd7ff;max-width:100%;">
5833
+ <span class="settings-external-link__label">${escapeHtml(label)}</span>
5834
+ <span class="settings-external-link__icon" aria-hidden="true" style="display:inline-flex;width:.86rem;height:.86rem;flex:0 0 auto;">${renderIcon("external-link")}</span>
5835
+ </a>
5836
+ `.trim();
5837
+ }
5838
+
5225
5839
  function renderSettingsMoltbookPage(context) {
5226
5840
  const scout = context.moltbookScout;
5227
5841
  if (!scout?.enabled) {
@@ -5236,10 +5850,16 @@ function renderSettingsMoltbookPage(context) {
5236
5850
  renderSettingsInfoRow(L("settings.row.moltbookBatchTopScore"), String(scout.batch.topScore)),
5237
5851
  renderSettingsInfoRow(L("settings.row.moltbookBatchRemaining"), `${Math.floor(scout.batch.remainingSeconds / 60)}:${String(scout.batch.remainingSeconds % 60).padStart(2, "0")}`),
5238
5852
  ] : [];
5239
- const accountRow = scout.account?.name && scout.account?.profileUrl
5853
+ const accountProfileLink = scout.account?.name && scout.account?.profileUrl
5854
+ ? renderSettingsExternalLink({
5855
+ href: scout.account.profileUrl,
5856
+ label: scout.account.name,
5857
+ })
5858
+ : "";
5859
+ const accountRow = accountProfileLink
5240
5860
  ? renderSettingsInfoRow(
5241
5861
  L("settings.row.moltbookAccount"),
5242
- `<a href="${escapeHtml(scout.account.profileUrl)}" target="_blank" rel="noopener">${escapeHtml(scout.account.name)}</a>`,
5862
+ accountProfileLink,
5243
5863
  { rawValue: true }
5244
5864
  )
5245
5865
  : null;
@@ -5310,7 +5930,10 @@ function renderSettingsA2aRelayPage(context) {
5310
5930
  ? L("settings.a2aRelay.status.polling")
5311
5931
  : L("settings.a2aRelay.status.disconnected");
5312
5932
  const profileUrl = `${relay.relayUrl}/u/${relay.userId}`;
5313
- const userIdLink = `<a href="${escapeHtml(profileUrl)}" target="_blank" rel="noopener">${escapeHtml(relay.userId)}</a>`;
5933
+ const userIdLink = renderSettingsExternalLink({
5934
+ href: profileUrl,
5935
+ label: relay.userId,
5936
+ });
5314
5937
  const relayHost = (() => { try { return new URL(relay.relayUrl).host; } catch { return relay.relayUrl; } })();
5315
5938
  const publicChecked = relay.acceptPublicTasks === true;
5316
5939
 
@@ -6335,6 +6958,7 @@ function renderStandardDetailDesktop(detail) {
6335
6958
  <div class="detail-shell">
6336
6959
  ${renderDetailMetaRow(detail, kindInfo)}
6337
6960
  <h2 class="detail-title detail-title--desktop">${renderDetailTitle(detail)}</h2>
6961
+ ${renderDetailLoadErrorNotice(detail)}
6338
6962
  ${detail.readOnly || detail.kind === "approval" || detail.kind === "moltbook_draft" || detail.kind === "moltbook_reply" || detail.kind === "thread_share" ? "" : renderDetailLead(detail, kindInfo)}
6339
6963
  ${renderPreviousContextCard(detail)}
6340
6964
  ${renderAutoPilotManualReview(detail)}
@@ -6355,6 +6979,7 @@ function renderStandardDetailDesktop(detail) {
6355
6979
  </section>
6356
6980
  `
6357
6981
  }
6982
+ ${renderCommandEventDetail(detail)}
6358
6983
  ${renderClaudePlanSection(detail)}
6359
6984
  ${renderClaudeQuestionSection(detail)}
6360
6985
  ${renderDetailImageGallery(detail)}
@@ -6377,6 +7002,7 @@ function renderStandardDetailMobile(detail) {
6377
7002
  <div class="detail-shell detail-shell--mobile">
6378
7003
  <div class="mobile-detail-scroll mobile-detail-scroll--detail">
6379
7004
  ${renderDetailMetaRow(detail, kindInfo, { mobile: true })}
7005
+ ${renderDetailLoadErrorNotice(detail, { mobile: true })}
6380
7006
  ${renderPreviousContextCard(detail, { mobile: true })}
6381
7007
  ${renderAutoPilotManualReview(detail, { mobile: true })}
6382
7008
  ${renderInterruptedDetailNotice(detail, { mobile: true })}
@@ -6397,6 +7023,7 @@ function renderStandardDetailMobile(detail) {
6397
7023
  </section>
6398
7024
  `
6399
7025
  }
7026
+ ${renderCommandEventDetail(detail, { mobile: true })}
6400
7027
  ${renderClaudePlanSection(detail, { mobile: true })}
6401
7028
  ${renderClaudeQuestionSection(detail, { mobile: true })}
6402
7029
  ${renderDetailImageGallery(detail, { mobile: true })}
@@ -6439,6 +7066,46 @@ function renderAmbientSuggestionsSection(detail, options = {}) {
6439
7066
  `;
6440
7067
  }
6441
7068
 
7069
+ function renderCommandEventDetail(detail, options = {}) {
7070
+ if (detail?.kind !== "command_event" && !shouldRenderFileEventCommand(detail)) {
7071
+ return "";
7072
+ }
7073
+ const commandText = normalizeClientText(detail?.commandText || "") || firstMarkdownCodeFence(detail?.messageText || "");
7074
+ if (!commandText) {
7075
+ return "";
7076
+ }
7077
+ return `
7078
+ <section class="detail-card detail-card--command ${options.mobile ? "detail-card--mobile" : ""}">
7079
+ <div class="detail-files-card__header">
7080
+ <span class="detail-files-card__icon" aria-hidden="true">${renderIcon("command")}</span>
7081
+ <span>${escapeHtml(L("common.commandEvent"))}</span>
7082
+ </div>
7083
+ <pre class="detail-command-block"><code>${escapeHtml(commandText)}</code></pre>
7084
+ </section>
7085
+ `;
7086
+ }
7087
+
7088
+ function renderDetailLoadErrorNotice(detail, options = {}) {
7089
+ if (detail?.loadError !== true) {
7090
+ return "";
7091
+ }
7092
+ const errorText = normalizeClientText(detail.loadErrorMessage || "");
7093
+ return `
7094
+ <section class="detail-card detail-card--body ${options.mobile ? "detail-card--mobile" : ""}">
7095
+ <div class="detail-body markdown">
7096
+ <p><strong>${escapeHtml(L("detail.loadFailedTitle"))}</strong></p>
7097
+ <p>${escapeHtml(L("detail.loadFailedCopy"))}</p>
7098
+ ${errorText ? `<p class="muted">${escapeHtml(errorText)}</p>` : ""}
7099
+ </div>
7100
+ <div class="actions actions--stack">
7101
+ <button class="secondary secondary--wide" type="button" data-detail-retry>
7102
+ ${escapeHtml(L("detail.loadRetry"))}
7103
+ </button>
7104
+ </div>
7105
+ </section>
7106
+ `;
7107
+ }
7108
+
6442
7109
  function renderAmbientSuggestionCard(detail, suggestion, index) {
6443
7110
  const copyKey = ambientSuggestionCopyKey(detail?.token || "", suggestion?.id || "", index);
6444
7111
  const copyStatus = state.ambientSuggestionCopyState?.key === copyKey
@@ -6471,7 +7138,7 @@ function renderAmbientSuggestionCard(detail, suggestion, index) {
6471
7138
  }
6472
7139
 
6473
7140
  function renderDetailPlainIntro(detail, options = {}) {
6474
- if (!["approval", "diff_thread", "file_event"].includes(detail?.kind || "")) {
7141
+ if (!["approval", "diff_thread", "file_event", "command_event"].includes(detail?.kind || "")) {
6475
7142
  return "";
6476
7143
  }
6477
7144
  const ak = normalizeClientText(detail?.approvalKind || "");
@@ -6995,7 +7662,7 @@ function renderMoltbookDraftComposer(detail, options = {}) {
6995
7662
  ? `
6996
7663
  <div class="actions actions--stack${options.mobile ? " actions--sticky" : ""}">
6997
7664
  <button type="submit" data-action="approve" class="primary primary--wide">${escapeHtml(approveLabel)}</button>
6998
- <button type="submit" data-action="deny" class="danger danger--wide">Deny</button>
7665
+ <button type="submit" data-action="deny" class="danger danger--wide">${escapeHtml(L("moltbook.draft.deny"))}</button>
6999
7666
  </div>
7000
7667
  `
7001
7668
  : `<p class="muted reply-composer__description">${escapeHtml(L("moltbook.draft.resolved"))}</p>`;
@@ -7043,6 +7710,29 @@ function renderA2ATaskDetail(detail, options = {}) {
7043
7710
  const statusBadge = !enabled && detail.kind === "a2a_task_result"
7044
7711
  ? `<span class="eyebrow-pill eyebrow-pill--subtle">${escapeHtml(L(statusKey))}</span>`
7045
7712
  : "";
7713
+ const viveworkerTask = detail.viveworker || {};
7714
+ const payment = viveworkerTask.payment || {};
7715
+ const priceLabel = payment.price
7716
+ ? `${payment.price} USDC`
7717
+ : detail.paidDeliverable?.price
7718
+ ? `${detail.paidDeliverable.price} USDC`
7719
+ : "";
7720
+ const paidDeliverableBlock = viveworkerTask.paidDeliverable || detail.paidDeliverable
7721
+ ? `
7722
+ <div class="reply-composer__context">
7723
+ <span class="eyebrow-pill eyebrow-pill--quiet">${escapeHtml(L("a2a.task.paidDeliverable"))}</span>
7724
+ <div class="reply-composer__context-body">
7725
+ ${viveworkerTask.requestedTier ? `<p><strong>${escapeHtml(L("a2a.task.requestedTier"))}</strong>: ${escapeHtml(viveworkerTask.requestedTier)}</p>` : ""}
7726
+ ${viveworkerTask.requestedExecutor ? `<p><strong>${escapeHtml(L("a2a.task.requestedExecutor"))}</strong>: ${escapeHtml(viveworkerTask.requestedExecutor)}</p>` : ""}
7727
+ ${viveworkerTask.requestedModel ? `<p><strong>${escapeHtml(L("a2a.task.requestedModel"))}</strong>: ${escapeHtml(viveworkerTask.requestedModel)}</p>` : ""}
7728
+ ${viveworkerTask.deliverableType ? `<p><strong>${escapeHtml(L("a2a.task.deliverableType"))}</strong>: ${escapeHtml(viveworkerTask.deliverableType)}</p>` : ""}
7729
+ ${priceLabel ? `<p><strong>${escapeHtml(L("a2a.task.price"))}</strong>: ${escapeHtml(priceLabel)}</p>` : ""}
7730
+ ${payment.payTo ? `<p><strong>${escapeHtml(L("a2a.task.payTo"))}</strong>: <code>${escapeHtml(payment.payTo)}</code></p>` : ""}
7731
+ ${detail.paidDeliverable?.url ? `<p><strong>${escapeHtml(L("a2a.task.unlockUrl"))}</strong>: <a href="${escapeHtml(detail.paidDeliverable.url)}" target="_blank" rel="noopener">${escapeHtml(detail.paidDeliverable.url)}</a></p>` : ""}
7732
+ </div>
7733
+ </div>
7734
+ `
7735
+ : "";
7046
7736
 
7047
7737
  // Show executor selector when "ask" mode is active and both CLIs are available.
7048
7738
  const executors = state.session?.a2aExecutors || { codex: false, claude: false };
@@ -7084,6 +7774,7 @@ function renderA2ATaskDetail(detail, options = {}) {
7084
7774
  <span class="eyebrow-pill eyebrow-pill--quiet">${escapeHtml(L("a2a.task.eyebrow"))}</span>
7085
7775
  ${statusBadge}
7086
7776
  ${callerLine}
7777
+ ${paidDeliverableBlock}
7087
7778
  <p class="muted reply-composer__description">${enabled ? escapeHtml(L("a2a.task.editHint")) : ""}</p>
7088
7779
  </div>
7089
7780
  <div class="reply-composer__instruction">
@@ -7818,6 +8509,8 @@ function renderTabButtons({ buttonClass, withIcons }) {
7818
8509
  }
7819
8510
 
7820
8511
  function bindShellInteractions() {
8512
+ bindScrollableContentRenderDeferral();
8513
+
7821
8514
  for (const button of document.querySelectorAll("[data-tab]")) {
7822
8515
  button.addEventListener("click", async () => {
7823
8516
  await switchTab(button.dataset.tab);
@@ -8063,14 +8756,31 @@ function bindShellInteractions() {
8063
8756
  });
8064
8757
  }
8065
8758
 
8759
+ for (const button of document.querySelectorAll("[data-detail-retry]")) {
8760
+ button.addEventListener("click", async (event) => {
8761
+ event.preventDefault();
8762
+ event.stopPropagation();
8763
+ if (!state.currentItem) {
8764
+ return;
8765
+ }
8766
+ state.currentDetail = null;
8767
+ state.currentDetailLoading = false;
8768
+ state.detailLoadingItem = null;
8769
+ queueCurrentDetailLoad(state.currentItem);
8770
+ await renderShell();
8771
+ });
8772
+ }
8773
+
8066
8774
  for (const button of document.querySelectorAll("[data-back-to-list]")) {
8067
8775
  button.addEventListener("click", async () => {
8776
+ const nextTab = state.currentTab;
8068
8777
  clearChoiceLocalDraftForItem(state.currentItem);
8069
8778
  state.detailOpen = false;
8070
8779
  state.pendingListScrollRestore = !isDesktopLayout() && Boolean(state.listScrollState);
8071
8780
  clearPinnedDetailState();
8072
8781
  syncCurrentItemUrl(null);
8073
8782
  await renderShell();
8783
+ refreshPrimaryTabAfterNavigation(nextTab);
8074
8784
  });
8075
8785
  }
8076
8786
 
@@ -8118,15 +8828,23 @@ function bindShellInteractions() {
8118
8828
  delete postBody.hazbaseReauth;
8119
8829
  }
8120
8830
  await apiPost(actionUrl, postBody);
8121
- if (keepDetailOpen && activeItem?.kind === "approval") {
8831
+ if (activeItem?.kind === "approval") {
8832
+ state.pendingActionUrls.delete(actionUrl);
8122
8833
  pinActionOutcomeDetail(
8123
8834
  activeItem,
8124
8835
  buildActionOutcomeDetail({
8125
8836
  kind: "approval",
8126
8837
  title: state.currentDetail?.title,
8127
- message: approvalOutcomeMessage(actionUrl, activeItem?.provider),
8838
+ message: approvalOutcomeMessage(actionUrl, state.currentDetail?.provider || activeItem?.provider),
8128
8839
  })
8129
8840
  );
8841
+ await renderShell();
8842
+ void refreshAuthenticatedState()
8843
+ .then(() => renderShell())
8844
+ .catch((error) => {
8845
+ console.debug?.("[approval-action-refresh-failed]", error?.message || String(error));
8846
+ });
8847
+ return;
8130
8848
  }
8131
8849
  await refreshAuthenticatedState();
8132
8850
  if (!keepDetailOpen && !isDesktopLayout()) {
@@ -8141,6 +8859,14 @@ function bindShellInteractions() {
8141
8859
  if (error?.errorKey === "hazbase-session-expired") {
8142
8860
  await fetchHazbaseStatus();
8143
8861
  }
8862
+ if (activeItem?.kind === "approval" && isAlreadyHandledApprovalError(error)) {
8863
+ const recovered = await recoverHandledApprovalDetail(activeItem);
8864
+ if (recovered) {
8865
+ pinActionOutcomeDetail(activeItem, recovered);
8866
+ await renderShell();
8867
+ return;
8868
+ }
8869
+ }
8144
8870
  if (approvalFinalized) {
8145
8871
  await refreshAuthenticatedState();
8146
8872
  if (keepDetailOpen && activeItem?.kind === "approval") {
@@ -9021,7 +9747,9 @@ for (const button of document.querySelectorAll("[data-wallet-address-copy]")) {
9021
9747
  labelCache.set(btn, btn.innerHTML);
9022
9748
  btn.disabled = true;
9023
9749
  if (btn.dataset.action === submittedAction) {
9024
- btn.innerHTML = submittedAction === "approve" ? "送信中…" : "処理中…";
9750
+ btn.innerHTML = submittedAction === "approve"
9751
+ ? escapeHtml(L("moltbook.draft.submittingApprove"))
9752
+ : escapeHtml(L("moltbook.draft.submittingDeny"));
9025
9753
  btn.classList.add("is-loading");
9026
9754
  } else {
9027
9755
  btn.classList.add("is-dimmed");
@@ -9239,8 +9967,43 @@ for (const button of document.querySelectorAll("[data-wallet-address-copy]")) {
9239
9967
  for (const attachment of attachments) {
9240
9968
  requestBody.append("image", attachment.file, attachment.name || attachment.file.name);
9241
9969
  }
9242
- const replyKind = replyForm.dataset.replyKind || "completion";
9243
- await apiPost(`/api/items/${encodeURIComponent(replyKind)}/${encodeURIComponent(token)}/reply`, requestBody);
9970
+ const sentNotice = L(draft.mode === "plan" ? "reply.notice.sentPlan" : "reply.notice.sentDefault", { provider: providerDisplayName(replyProvider) });
9971
+ const renderOptimisticSent = () => {
9972
+ const currentDraft = getCompletionReplyDraft(token);
9973
+ if (!currentDraft.sending || currentDraft.text !== text) {
9974
+ return;
9975
+ }
9976
+ setCompletionReplyDraft(token, {
9977
+ text: "",
9978
+ sentText: text,
9979
+ attachments: currentDraft.attachments,
9980
+ mode: draft.mode,
9981
+ sending: false,
9982
+ error: "",
9983
+ notice: sentNotice,
9984
+ warning: null,
9985
+ confirmOverride: false,
9986
+ collapsedAfterSend: true,
9987
+ });
9988
+ renderShell().catch((renderError) => {
9989
+ console.warn("[completion-reply-optimistic-render]", renderError?.message || renderError);
9990
+ });
9991
+ };
9992
+ const optimisticSentTimer = setTimeout(renderOptimisticSent, COMPLETION_REPLY_OPTIMISTIC_SENT_MS);
9993
+ let replyResult = null;
9994
+ try {
9995
+ const replyKind = replyForm.dataset.replyKind || "completion";
9996
+ replyResult = await apiPost(
9997
+ `/api/items/${encodeURIComponent(replyKind)}/${encodeURIComponent(token)}/reply`,
9998
+ requestBody,
9999
+ {
10000
+ timeoutMs: COMPLETION_REPLY_SEND_TIMEOUT_MS,
10001
+ preferRelayError: true,
10002
+ },
10003
+ );
10004
+ } finally {
10005
+ clearTimeout(optimisticSentTimer);
10006
+ }
9244
10007
  setCompletionReplyDraft(token, {
9245
10008
  text: "",
9246
10009
  sentText: text,
@@ -9248,14 +10011,51 @@ for (const button of document.querySelectorAll("[data-wallet-address-copy]")) {
9248
10011
  mode: draft.mode,
9249
10012
  sending: false,
9250
10013
  error: "",
9251
- notice: L(draft.mode === "plan" ? "reply.notice.sentPlan" : "reply.notice.sentDefault", { provider: providerDisplayName(replyProvider) }),
10014
+ notice: sentNotice,
9252
10015
  warning: null,
9253
10016
  confirmOverride: false,
9254
10017
  collapsedAfterSend: true,
9255
10018
  });
9256
- await refreshAuthenticatedState();
10019
+ if (replyResult?.ackTimeout === true) {
10020
+ console.info("[completion-reply] accepted after slow Codex ACK");
10021
+ }
10022
+ await renderShell();
10023
+ refreshAuthenticatedState()
10024
+ .then(renderShell)
10025
+ .catch((refreshError) => console.warn("[completion-reply-refresh]", refreshError?.message || refreshError));
10026
+ return;
9257
10027
  } catch (error) {
10028
+ const optimisticDraft = getCompletionReplyDraft(token);
10029
+ if (
10030
+ error.errorKey === "request-timeout" &&
10031
+ optimisticDraft.collapsedAfterSend &&
10032
+ optimisticDraft.sentText === text
10033
+ ) {
10034
+ refreshAuthenticatedState()
10035
+ .then(renderShell)
10036
+ .catch((refreshError) => console.warn("[completion-reply-refresh]", refreshError?.message || refreshError));
10037
+ return;
10038
+ }
9258
10039
  if (error.errorKey === "completion-reply-thread-advanced") {
10040
+ if (completionReplyWarningMatchesSentText(error, text, attachments.length)) {
10041
+ setCompletionReplyDraft(token, {
10042
+ text: "",
10043
+ sentText: text,
10044
+ attachments: [],
10045
+ mode: draft.mode,
10046
+ sending: false,
10047
+ error: "",
10048
+ notice: L(draft.mode === "plan" ? "reply.notice.sentPlan" : "reply.notice.sentDefault", { provider: providerDisplayName(replyProvider) }),
10049
+ warning: null,
10050
+ confirmOverride: false,
10051
+ collapsedAfterSend: true,
10052
+ });
10053
+ await renderShell();
10054
+ refreshAuthenticatedState()
10055
+ .then(renderShell)
10056
+ .catch((refreshError) => console.warn("[completion-reply-refresh]", refreshError?.message || refreshError));
10057
+ return;
10058
+ }
9259
10059
  setCompletionReplyDraft(token, {
9260
10060
  text,
9261
10061
  sentText: "",
@@ -9459,6 +10259,143 @@ function bindSharedUi(renderFn) {
9459
10259
  }
9460
10260
  }
9461
10261
 
10262
+ function bindScrollableContentRenderDeferral() {
10263
+ const mark = () => {
10264
+ markScrollableContentInteraction();
10265
+ };
10266
+ for (const el of document.querySelectorAll(SCROLLABLE_CONTENT_SELECTORS)) {
10267
+ el.addEventListener("scroll", mark, { passive: true });
10268
+ el.addEventListener("wheel", mark, { passive: true });
10269
+ el.addEventListener("touchstart", mark, { passive: true });
10270
+ el.addEventListener("touchmove", mark, { passive: true });
10271
+ el.addEventListener("pointerdown", mark);
10272
+ el.addEventListener("copy", mark);
10273
+ }
10274
+ }
10275
+
10276
+ function bindPartialListSurfaceInteractions(listSurface) {
10277
+ if (!listSurface || listSurface.dataset.partialListInteractionsBound === "true") {
10278
+ return;
10279
+ }
10280
+ listSurface.dataset.partialListInteractionsBound = "true";
10281
+
10282
+ const targetElement = (event) => {
10283
+ const target = event.target;
10284
+ return target instanceof Element ? target : target?.parentElement || null;
10285
+ };
10286
+ const threadSelectSelector = "[data-timeline-thread-select], [data-diff-thread-select], [data-completed-thread-select]";
10287
+
10288
+ listSurface.addEventListener("pointerdown", (event) => {
10289
+ if (targetElement(event)?.closest(threadSelectSelector)) {
10290
+ markThreadFilterInteraction();
10291
+ }
10292
+ });
10293
+ listSurface.addEventListener("focusin", (event) => {
10294
+ if (targetElement(event)?.closest(threadSelectSelector)) {
10295
+ markThreadFilterInteraction();
10296
+ }
10297
+ });
10298
+ listSurface.addEventListener("focusout", (event) => {
10299
+ if (targetElement(event)?.closest(threadSelectSelector)) {
10300
+ clearThreadFilterInteraction();
10301
+ }
10302
+ });
10303
+
10304
+ listSurface.addEventListener("change", async (event) => {
10305
+ const target = targetElement(event);
10306
+ const timelineSelect = target?.closest("[data-timeline-thread-select]");
10307
+ const diffSelect = target?.closest("[data-diff-thread-select]");
10308
+ const completedSelect = target?.closest("[data-completed-thread-select]");
10309
+ if (!timelineSelect && !diffSelect && !completedSelect) {
10310
+ return;
10311
+ }
10312
+ clearThreadFilterInteraction();
10313
+ if (timelineSelect) {
10314
+ state.timelineThreadFilter = timelineSelect.value || "all";
10315
+ state.timelineKindFilterOpen = false;
10316
+ } else if (diffSelect) {
10317
+ state.diffThreadFilter = diffSelect.value || "all";
10318
+ } else if (completedSelect) {
10319
+ state.completedThreadFilter = completedSelect.value || "all";
10320
+ }
10321
+ alignCurrentItemToVisibleEntries();
10322
+ await renderShell();
10323
+ });
10324
+
10325
+ listSurface.addEventListener("click", async (event) => {
10326
+ const target = targetElement(event);
10327
+ const threadSelect = target?.closest(threadSelectSelector);
10328
+ if (threadSelect) {
10329
+ markThreadFilterInteraction();
10330
+ return;
10331
+ }
10332
+
10333
+ const providerButton = target?.closest("[data-provider-filter]");
10334
+ if (providerButton) {
10335
+ event.preventDefault();
10336
+ const next = providerButton.dataset.providerFilter || "all";
10337
+ if (state.providerFilter === next) {
10338
+ return;
10339
+ }
10340
+ state.providerFilter = next;
10341
+ state.timelineThreadFilter = "all";
10342
+ state.timelineKindFilter = "all";
10343
+ state.timelineKindFilterOpen = false;
10344
+ state.completedThreadFilter = "all";
10345
+ state.diffThreadFilter = "all";
10346
+ alignCurrentItemToVisibleEntries();
10347
+ await renderShell();
10348
+ return;
10349
+ }
10350
+
10351
+ const kindToggle = target?.closest("[data-timeline-kind-filter-toggle]");
10352
+ if (kindToggle) {
10353
+ event.preventDefault();
10354
+ markThreadFilterInteraction();
10355
+ state.timelineKindFilterOpen = !state.timelineKindFilterOpen;
10356
+ await renderShell();
10357
+ return;
10358
+ }
10359
+
10360
+ const kindOption = target?.closest("[data-timeline-kind-filter-option]");
10361
+ if (kindOption) {
10362
+ event.preventDefault();
10363
+ clearThreadFilterInteraction();
10364
+ state.timelineKindFilter = kindOption.dataset.timelineKindFilterOption || "all";
10365
+ state.timelineKindFilterOpen = false;
10366
+ alignCurrentItemToVisibleEntries();
10367
+ await renderShell();
10368
+ return;
10369
+ }
10370
+
10371
+ const subtabButton = target?.closest("[data-inbox-subtab]");
10372
+ if (subtabButton) {
10373
+ const nextSubtab = subtabButton.dataset.inboxSubtab === "completed" ? "completed" : "pending";
10374
+ if (nextSubtab === state.inboxSubtab) {
10375
+ return;
10376
+ }
10377
+ state.inboxSubtab = nextSubtab;
10378
+ if (isDesktopLayout()) {
10379
+ alignCurrentItemToVisibleEntries();
10380
+ syncCurrentItemUrl(state.currentItem);
10381
+ }
10382
+ await renderShell();
10383
+ return;
10384
+ }
10385
+
10386
+ const itemButton = target?.closest("[data-open-item-kind][data-open-item-token]");
10387
+ if (itemButton) {
10388
+ openItem({
10389
+ kind: itemButton.dataset.openItemKind,
10390
+ token: itemButton.dataset.openItemToken,
10391
+ sourceTab: itemButton.dataset.sourceTab,
10392
+ sourceSubtab: itemButton.dataset.sourceSubtab,
10393
+ });
10394
+ await renderShell();
10395
+ }
10396
+ });
10397
+ }
10398
+
9462
10399
  function openSettingsSubpage(page) {
9463
10400
  if (!page) {
9464
10401
  return;
@@ -9500,6 +10437,7 @@ async function switchTab(tab) {
9500
10437
  syncCurrentItemUrl(state.currentItem);
9501
10438
  }
9502
10439
  await renderShell();
10440
+ refreshPrimaryTabAfterNavigation(tab);
9503
10441
  if (tab === "settings") {
9504
10442
  void fetchHazbaseStatus()
9505
10443
  .then(() => {
@@ -9681,6 +10619,8 @@ function kindMeta(kind, item) {
9681
10619
  return { label: L("common.assistantCommentary"), tone: "plan", icon: "assistant-commentary" };
9682
10620
  case "assistant_final":
9683
10621
  return { label: L("common.assistantFinal"), tone: "completion", icon: "assistant-final" };
10622
+ case "activity_status":
10623
+ return { label: L("server.title.activityStatus"), tone: "plan", icon: activityStatusIcon(item?.activityPhase) };
9684
10624
  case "ambient_suggestions":
9685
10625
  return { label: L("common.ambientSuggestions"), tone: "neutral", icon: "suggestions" };
9686
10626
  case "approval":
@@ -9695,7 +10635,12 @@ function kindMeta(kind, item) {
9695
10635
  case "diff_thread":
9696
10636
  return { label: L("common.diff"), tone: "neutral", icon: "diff" };
9697
10637
  case "file_event":
10638
+ if (normalizeClientText(item?.fileEventType || "") === "git") {
10639
+ return { label: L("common.commandEvent"), tone: "neutral", icon: "command" };
10640
+ }
9698
10641
  return { label: L("common.fileEvent"), tone: "neutral", icon: "file-event" };
10642
+ case "command_event":
10643
+ return { label: L("common.commandEvent"), tone: "neutral", icon: "command" };
9699
10644
  case "moltbook_reply":
9700
10645
  return { label: L("common.moltbookReply"), tone: "neutral", icon: "moltbook-comment" };
9701
10646
  case "moltbook_draft":
@@ -9713,6 +10658,24 @@ function kindMeta(kind, item) {
9713
10658
  }
9714
10659
  }
9715
10660
 
10661
+ function activityStatusIcon(phase) {
10662
+ switch (normalizeClientText(phase || "")) {
10663
+ case "reading":
10664
+ return "file-event";
10665
+ case "searching":
10666
+ return "filter";
10667
+ case "editing":
10668
+ return "diff";
10669
+ case "running_command":
10670
+ return "command";
10671
+ case "awaiting_approval":
10672
+ return "approval";
10673
+ case "thinking":
10674
+ default:
10675
+ return "pending";
10676
+ }
10677
+ }
10678
+
9716
10679
  function renderTypePillContent(kindInfo) {
9717
10680
  return `
9718
10681
  <span class="type-pill__icon" aria-hidden="true">${renderIcon(kindInfo.icon)}</span>
@@ -9728,6 +10691,9 @@ function itemIntentText(kind, status = "pending", provider) {
9728
10691
  if (kind === "file_event") {
9729
10692
  return L("intent.fileEvent");
9730
10693
  }
10694
+ if (kind === "command_event") {
10695
+ return L("intent.commandEvent");
10696
+ }
9731
10697
  if (kind === "ambient_suggestions") {
9732
10698
  return L("intent.ambientSuggestions");
9733
10699
  }
@@ -9765,6 +10731,9 @@ function detailIntentText(detail) {
9765
10731
  if (detail.kind === "file_event") {
9766
10732
  return itemIntentText(detail.kind, "timeline", provider);
9767
10733
  }
10734
+ if (detail.kind === "command_event") {
10735
+ return itemIntentText(detail.kind, "timeline", provider);
10736
+ }
9768
10737
  if (detail.kind === "ambient_suggestions") {
9769
10738
  return itemIntentText(detail.kind, "timeline", provider);
9770
10739
  }
@@ -9838,6 +10807,8 @@ function fallbackSummaryForKind(kind, status, provider) {
9838
10807
  return L("summary.diffThread");
9839
10808
  case "file_event":
9840
10809
  return L("summary.fileEvent", vars);
10810
+ case "command_event":
10811
+ return L("summary.commandEvent", vars);
9841
10812
  case "ambient_suggestions":
9842
10813
  return L("summary.ambientSuggestions", { count: 0, firstTitle: "", more: 0 });
9843
10814
  case "user_message":
@@ -10009,6 +10980,22 @@ function hasDetailOverride(itemRef = state.currentItem) {
10009
10980
  return Boolean(state.detailOverride && isSameItemRef(state.detailOverride, itemRef));
10010
10981
  }
10011
10982
 
10983
+ function isAlreadyHandledApprovalError(error) {
10984
+ return error?.errorKey === "approval-not-found" || error?.errorKey === "approval-already-handled";
10985
+ }
10986
+
10987
+ async function recoverHandledApprovalDetail(itemRef) {
10988
+ try {
10989
+ await refreshAuthenticatedState();
10990
+ const detail = await hydrateDetailImages(
10991
+ await apiGet(`/api/items/${encodeURIComponent(itemRef.kind)}/${encodeURIComponent(itemRef.token)}`)
10992
+ );
10993
+ return detail?.kind === "approval" && detail.readOnly === true ? detail : null;
10994
+ } catch {
10995
+ return null;
10996
+ }
10997
+ }
10998
+
10012
10999
  function shouldPreserveCurrentItem(itemRef = state.currentItem) {
10013
11000
  return Boolean(itemRef && (hasLaunchItemIntent(itemRef) || hasDetailOverride(itemRef)));
10014
11001
  }
@@ -10044,6 +11031,8 @@ function renderIcon(name) {
10044
11031
  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>`;
10045
11032
  case "file-event":
10046
11033
  return `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round"><path d="M8 3.8h5.9l4.3 4.3v10a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2v-12.3a2 2 0 0 1 2-2Z"/><path d="M13.9 3.8v4.3h4.3"/><path d="M9.2 13.1h5.6"/><path d="M9.2 16.2h4"/></svg>`;
11034
+ case "command":
11035
+ return `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round"><rect x="4.2" y="5" width="15.6" height="14" rx="2.6"/><path d="m7.8 10 2.4 2-2.4 2"/><path d="M12.2 14h4"/></svg>`;
10047
11036
  case "diff":
10048
11037
  return `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round"><path d="M7.5 5.5v13"/><path d="M4.8 8.2 7.5 5.5 10.2 8.2"/><path d="M16.5 18.5v-13"/><path d="m13.8 15.8 2.7 2.7 2.7-2.7"/><path d="M11.8 7.5h1.2"/><path d="M11 12h2.8"/><path d="M11.8 16.5h1.2"/></svg>`;
10049
11038
  case "pending":
@@ -10076,6 +11065,8 @@ function renderIcon(name) {
10076
11065
  return `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.85" stroke-linecap="round" stroke-linejoin="round"><rect x="5" y="12" width="14" height="7" rx="2"/><path d="M9 19h6"/><path d="M12 12v7"/><path d="M8.2 8.8a5.4 5.4 0 0 1 7.6 0"/><path d="M10.2 6.3a8.2 8.2 0 0 1 3.6 0"/><path d="M11.2 9.8a1.2 1.2 0 0 1 1.6 0"/></svg>`;
10077
11066
  case "link":
10078
11067
  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>`;
11068
+ case "external-link":
11069
+ return `<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round"><path d="M10 6H6.8A2.8 2.8 0 0 0 4 8.8v8.4A2.8 2.8 0 0 0 6.8 20h8.4a2.8 2.8 0 0 0 2.8-2.8V14"/><path d="M14 4h6v6"/><path d="m12.5 11.5 7-7"/></svg>`;
10079
11070
  case "clip":
10080
11071
  return `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round"><path d="m9.5 12.5 5.9-5.9a3 3 0 1 1 4.2 4.2l-7.7 7.7a5 5 0 1 1-7.1-7.1l8.1-8.1"/></svg>`;
10081
11072
  case "moltbook-draft":
@@ -10346,32 +11337,107 @@ function pruneTimelineImageObjectUrlCache() {
10346
11337
  }
10347
11338
  }
10348
11339
 
11340
+ function withRequestTimeout(init, opts = {}) {
11341
+ const timeoutMs = Number(opts.timeoutMs) || 0;
11342
+ if (!Number.isFinite(timeoutMs) || timeoutMs <= 0 || typeof AbortController === "undefined") {
11343
+ return { init, cleanup: () => {} };
11344
+ }
11345
+ if (init.signal?.aborted) {
11346
+ return { init, cleanup: () => {} };
11347
+ }
11348
+ const controller = new AbortController();
11349
+ let timer = null;
11350
+ const externalAbortHandler = init.signal
11351
+ ? () => controller.abort(init.signal.reason)
11352
+ : null;
11353
+ if (init.signal && externalAbortHandler) {
11354
+ init.signal.addEventListener("abort", externalAbortHandler, { once: true });
11355
+ }
11356
+ timer = setTimeout(() => {
11357
+ const error = new Error("request-timeout");
11358
+ error.name = "AbortError";
11359
+ controller.abort(error);
11360
+ }, timeoutMs);
11361
+ return {
11362
+ init: { ...init, signal: controller.signal },
11363
+ cleanup: () => {
11364
+ if (timer) clearTimeout(timer);
11365
+ if (init.signal && externalAbortHandler) {
11366
+ init.signal.removeEventListener("abort", externalAbortHandler);
11367
+ }
11368
+ },
11369
+ };
11370
+ }
11371
+
11372
+ function normalizeRequestError(error, opts = {}) {
11373
+ if (error?.name === "AbortError" && Number(opts.timeoutMs) > 0) {
11374
+ const timeoutError = new Error(L("error.requestTimedOut"));
11375
+ timeoutError.code = 0;
11376
+ timeoutError.status = 0;
11377
+ timeoutError.errorKey = "request-timeout";
11378
+ return timeoutError;
11379
+ }
11380
+ return error;
11381
+ }
11382
+
10349
11383
  async function apiGet(url, opts = {}) {
10350
11384
  // routedFetch tries LAN first, then falls back to the relay tunnel when
10351
11385
  // the phone is off-LAN. Returns a fetch-Response-compatible object so the
10352
11386
  // rest of this function is identical to a plain `fetch()` call.
10353
- const response = await routedFetch(url, {
11387
+ const timed = withRequestTimeout({
10354
11388
  credentials: "same-origin",
10355
11389
  headers: {
10356
11390
  Accept: "application/json",
10357
11391
  },
10358
11392
  }, opts);
10359
- if (!response.ok) {
10360
- const errorInfo = await readError(response);
10361
- const error = new Error(errorInfo.message);
10362
- error.code = response.status;
10363
- error.status = response.status;
10364
- error.errorKey = errorInfo.errorKey || "";
10365
- throw error;
11393
+ try {
11394
+ const response = await routedFetch(url, timed.init, opts);
11395
+ if (!response.ok) {
11396
+ const errorInfo = await readError(response);
11397
+ const error = new Error(errorInfo.message);
11398
+ error.code = response.status;
11399
+ error.status = response.status;
11400
+ error.errorKey = errorInfo.errorKey || "";
11401
+ throw error;
11402
+ }
11403
+ return await response.json();
11404
+ } catch (error) {
11405
+ throw normalizeRequestError(error, opts);
11406
+ } finally {
11407
+ timed.cleanup();
11408
+ }
11409
+ }
11410
+
11411
+ async function apiGetDirectLan(url, opts = {}) {
11412
+ const timed = withRequestTimeout({
11413
+ credentials: "same-origin",
11414
+ headers: {
11415
+ Accept: "application/json",
11416
+ },
11417
+ }, opts);
11418
+ try {
11419
+ const response = await fetch(url, timed.init);
11420
+ if (!response.ok) {
11421
+ const errorInfo = await readError(response);
11422
+ const error = new Error(errorInfo.message);
11423
+ error.code = response.status;
11424
+ error.status = response.status;
11425
+ error.errorKey = errorInfo.errorKey || "";
11426
+ throw error;
11427
+ }
11428
+ return await response.json();
11429
+ } catch (error) {
11430
+ throw normalizeRequestError(error, opts);
11431
+ } finally {
11432
+ timed.cleanup();
10366
11433
  }
10367
- return response.json();
10368
11434
  }
10369
11435
 
10370
- async function apiPost(url, body) {
11436
+ async function apiPost(url, body, opts = {}) {
10371
11437
  const isFormDataBody = typeof FormData !== "undefined" && body instanceof FormData;
10372
11438
  // Keep native FormData for LAN; routedFetch serializes it to multipart
10373
11439
  // bytes only when the request has to travel through the remote relay.
10374
- const response = await routedFetch(url, {
11440
+ const timed = withRequestTimeout({
10375
11441
  method: "POST",
10376
11442
  credentials: "same-origin",
10377
11443
  headers: isFormDataBody
@@ -10383,17 +11449,24 @@ async function apiPost(url, body) {
10383
11449
  Accept: "application/json",
10384
11450
  },
10385
11451
  body: isFormDataBody ? body : JSON.stringify(body || {}),
10386
- });
10387
- if (!response.ok) {
10388
- const errorInfo = await readError(response);
10389
- const error = new Error(errorInfo.message);
10390
- error.code = response.status;
10391
- error.status = response.status;
10392
- error.errorKey = errorInfo.errorKey || "";
10393
- error.payload = errorInfo.payload ?? null;
10394
- throw error;
11452
+ }, opts);
11453
+ try {
11454
+ const response = await routedFetch(url, timed.init, opts);
11455
+ if (!response.ok) {
11456
+ const errorInfo = await readError(response);
11457
+ const error = new Error(errorInfo.message);
11458
+ error.code = response.status;
11459
+ error.status = response.status;
11460
+ error.errorKey = errorInfo.errorKey || "";
11461
+ error.payload = errorInfo.payload ?? null;
11462
+ throw error;
11463
+ }
11464
+ return await response.json();
11465
+ } catch (error) {
11466
+ throw normalizeRequestError(error, opts);
11467
+ } finally {
11468
+ timed.cleanup();
10395
11469
  }
10396
- return response.json();
10397
11470
  }
10398
11471
 
10399
11472
  async function readError(response) {
@@ -10421,6 +11494,7 @@ function localizeApiError(value) {
10421
11494
  "device-not-found": "error.deviceNotFound",
10422
11495
  "web-push-disabled": "error.webPushDisabled",
10423
11496
  "push-subscription-expired": "error.pushSubscriptionExpired",
11497
+ "request-timeout": "error.requestTimedOut",
10424
11498
  "item-not-found": "error.itemNotFound",
10425
11499
  "completion-reply-unavailable": "error.completionReplyUnavailable",
10426
11500
  "completion-reply-thread-advanced": "error.completionReplyThreadAdvanced",