viveworker 0.8.1 → 0.8.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/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
 
@@ -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(() => {});
1247
1464
  }
1248
1465
 
1249
- async function refreshDevices() {
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();
1586
+ }
1587
+
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" },
@@ -1569,6 +1937,8 @@ function timelineEntryMatchesKindFilter(entry, filterId) {
1569
1937
  return kind === "ambient_suggestions";
1570
1938
  case "files":
1571
1939
  return kind === "file_event";
1940
+ case "commands":
1941
+ return kind === "command_event";
1572
1942
  case "approvals":
1573
1943
  return kind === "approval";
1574
1944
  case "plans":
@@ -1847,6 +2217,7 @@ async function logout({ revokeCurrentDeviceTrust = false } = {}) {
1847
2217
  }
1848
2218
 
1849
2219
  function resetAuthenticatedState() {
2220
+ closeTimelineLiveStream();
1850
2221
  state.session = null;
1851
2222
  state.inbox = null;
1852
2223
  // Reset the diff-loaded flag so the next sign-in shows the skeleton
@@ -1903,37 +2274,47 @@ async function revokeTrustedDevice(deviceId) {
1903
2274
  * On a polling interval, that means a long line of code keeps snapping
1904
2275
  * back to the start while the reader is mid-line.
1905
2276
  *
1906
- * `snapshotCodeBlockScrolls()` records each `.markdown pre`'s scrollLeft
2277
+ * `snapshotScrollableContentScrolls()` records each scrollable code/diff
2278
+ * block's scrollLeft
1907
2279
  * keyed by its trimmed textContent (so the key survives a fresh render
1908
- * regardless of position in the DOM tree). `restoreCodeBlockScrolls()`
2280
+ * regardless of position in the DOM tree). `restoreScrollableContentScrolls()`
1909
2281
  * walks the new DOM and restores any scrollLeft we still have a key for.
1910
2282
  *
1911
2283
  * Content-keyed matching is intentional: if the underlying code text
1912
2284
  * changes mid-scroll, the new <pre> is logically different and we let it
1913
2285
  * start at scrollLeft=0 rather than landing the reader somewhere unrelated.
1914
2286
  */
1915
- function snapshotCodeBlockScrolls() {
2287
+ // Blocks the user can scroll horizontally — we preserve their position
2288
+ // across innerHTML rebuilds and defer background renders while the user is
2289
+ // actively scrolling/selecting them. <pre> for fenced code; <table> for
2290
+ // pipe-style markdown tables; `.detail-diff-viewer` for file diffs.
2291
+ const SCROLLABLE_CONTENT_SELECTORS = ".markdown pre, .markdown table, .detail-diff-viewer";
2292
+
2293
+ function snapshotScrollableContentScrolls() {
1916
2294
  if (typeof document === "undefined") return null;
1917
- const blocks = document.querySelectorAll(".markdown pre");
2295
+ const blocks = document.querySelectorAll(SCROLLABLE_CONTENT_SELECTORS);
1918
2296
  if (blocks.length === 0) return null;
1919
2297
  const map = new Map();
1920
- for (const pre of blocks) {
1921
- const key = pre.textContent ? pre.textContent.trim() : "";
2298
+ for (const el of blocks) {
2299
+ const key = el.textContent ? el.textContent.trim() : "";
1922
2300
  if (!key) continue;
1923
- if (pre.scrollLeft === 0 && pre.scrollTop === 0) continue;
1924
- map.set(key, { scrollLeft: pre.scrollLeft, scrollTop: pre.scrollTop });
2301
+ if (el.scrollLeft === 0 && el.scrollTop === 0) continue;
2302
+ map.set(`${el.tagName}:${key}`, {
2303
+ scrollLeft: el.scrollLeft,
2304
+ scrollTop: el.scrollTop,
2305
+ });
1925
2306
  }
1926
2307
  return map.size > 0 ? map : null;
1927
2308
  }
1928
2309
 
1929
- function restoreCodeBlockScrolls(snapshot) {
2310
+ function restoreScrollableContentScrolls(snapshot) {
1930
2311
  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;
2312
+ for (const el of document.querySelectorAll(SCROLLABLE_CONTENT_SELECTORS)) {
2313
+ const key = el.textContent ? el.textContent.trim() : "";
2314
+ const saved = key ? snapshot.get(`${el.tagName}:${key}`) : null;
1934
2315
  if (!saved) continue;
1935
- if (saved.scrollLeft) pre.scrollLeft = saved.scrollLeft;
1936
- if (saved.scrollTop) pre.scrollTop = saved.scrollTop;
2316
+ if (saved.scrollLeft) el.scrollLeft = saved.scrollLeft;
2317
+ if (saved.scrollTop) el.scrollTop = saved.scrollTop;
1937
2318
  }
1938
2319
  }
1939
2320
 
@@ -1957,7 +2338,7 @@ async function renderShell() {
1957
2338
  .filter(Boolean)
1958
2339
  .join(" ");
1959
2340
 
1960
- const codeBlockScrollSnapshot = snapshotCodeBlockScrolls();
2341
+ const scrollableContentSnapshot = snapshotScrollableContentScrolls();
1961
2342
 
1962
2343
  app.innerHTML = `
1963
2344
  <div class="${shellClassName}">
@@ -1987,8 +2368,9 @@ async function renderShell() {
1987
2368
  // Reapply any horizontal scroll the user dragged into a code block before
1988
2369
  // this re-render. Done after the imperative scroll resets above so they
1989
2370
  // can't fight each other.
1990
- restoreCodeBlockScrolls(codeBlockScrollSnapshot);
2371
+ restoreScrollableContentScrolls(scrollableContentSnapshot);
1991
2372
  requestAnimationFrame(dismissBootSplash);
2373
+ reportTimelineRendered("shell");
1992
2374
  }
1993
2375
 
1994
2376
  function applyPendingDetailScrollReset() {
@@ -2037,6 +2419,53 @@ function applyPendingSettingsScrollRestore() {
2037
2419
  });
2038
2420
  }
2039
2421
 
2422
+ function renderDeferredInteractionShellUpdates() {
2423
+ if (typeof document === "undefined") {
2424
+ return false;
2425
+ }
2426
+ if (state.currentTab === "settings") {
2427
+ return false;
2428
+ }
2429
+ const desktop = isDesktopLayout();
2430
+ // On mobile detail screens the list is intentionally not mounted, so the
2431
+ // latest state will appear as soon as the user backs out. On desktop the
2432
+ // list and detail are side-by-side, so updating only the list keeps new
2433
+ // timeline/inbox cards flowing without disturbing the detail pane.
2434
+ if (!desktop && state.detailOpen) {
2435
+ return false;
2436
+ }
2437
+ if (isListSurfaceInteractionActive()) {
2438
+ return false;
2439
+ }
2440
+ const listSurface = document.querySelector("[data-list-surface]");
2441
+ if (!listSurface) {
2442
+ return false;
2443
+ }
2444
+ const scrollLeft = listSurface.scrollLeft;
2445
+ const scrollTop = listSurface.scrollTop;
2446
+ listSurface.innerHTML = renderListPanel({
2447
+ tab: state.currentTab,
2448
+ entries: listEntriesForTab(state.currentTab),
2449
+ desktop,
2450
+ });
2451
+ listSurface.scrollLeft = scrollLeft;
2452
+ listSurface.scrollTop = scrollTop;
2453
+ bindPartialListSurfaceInteractions(listSurface);
2454
+ reportTimelineRendered("deferred-list");
2455
+ return true;
2456
+ }
2457
+
2458
+ function isListSurfaceInteractionActive() {
2459
+ if (state.threadFilterInteractionUntilMs > Date.now() || state.timelineKindFilterOpen) {
2460
+ return true;
2461
+ }
2462
+ if (typeof document === "undefined" || typeof Element === "undefined") {
2463
+ return false;
2464
+ }
2465
+ const activeElement = document.activeElement;
2466
+ return activeElement instanceof Element && Boolean(activeElement.closest("[data-list-surface]"));
2467
+ }
2468
+
2040
2469
  function currentViewportScrollY() {
2041
2470
  return window.scrollY || window.pageYOffset || document.documentElement?.scrollTop || 0;
2042
2471
  }
@@ -2049,11 +2478,40 @@ function clearThreadFilterInteraction() {
2049
2478
  state.threadFilterInteractionUntilMs = 0;
2050
2479
  }
2051
2480
 
2481
+ function markScrollableContentInteraction() {
2482
+ state.scrollableContentInteractionUntilMs = Date.now() + SCROLLABLE_CONTENT_INTERACTION_DEFER_MS;
2483
+ }
2484
+
2485
+ function selectionIntersectsScrollableContent() {
2486
+ if (typeof document === "undefined" || typeof Element === "undefined") {
2487
+ return false;
2488
+ }
2489
+ const selection = document.getSelection?.();
2490
+ if (!selection || selection.isCollapsed || selection.rangeCount === 0) {
2491
+ return false;
2492
+ }
2493
+ for (let index = 0; index < selection.rangeCount; index += 1) {
2494
+ const range = selection.getRangeAt(index);
2495
+ const node = range.commonAncestorContainer;
2496
+ const element = node instanceof Element ? node : node?.parentElement;
2497
+ if (element?.closest?.(SCROLLABLE_CONTENT_SELECTORS)) {
2498
+ return true;
2499
+ }
2500
+ }
2501
+ return false;
2502
+ }
2503
+
2052
2504
  function shouldDeferRenderForActiveInteraction() {
2053
2505
  const activeElement = document.activeElement;
2054
2506
  if (state.completionReplySheetToken) {
2055
2507
  return true;
2056
2508
  }
2509
+ if (state.scrollableContentInteractionUntilMs > Date.now()) {
2510
+ return true;
2511
+ }
2512
+ if (selectionIntersectsScrollableContent()) {
2513
+ return true;
2514
+ }
2057
2515
  if (
2058
2516
  activeElement instanceof HTMLTextAreaElement &&
2059
2517
  activeElement.matches("[data-completion-reply-textarea]") &&
@@ -2269,6 +2727,25 @@ function normalizeCompletionReplyWarning(value) {
2269
2727
  };
2270
2728
  }
2271
2729
 
2730
+ function normalizeCompletionReplyCompareText(value) {
2731
+ return normalizeClientText(value)
2732
+ .replace(/\s+/gu, " ")
2733
+ .trim();
2734
+ }
2735
+
2736
+ function completionReplyWarningMatchesSentText(error, text, attachmentCount = 0) {
2737
+ if (error?.errorKey !== "completion-reply-thread-advanced") {
2738
+ return false;
2739
+ }
2740
+ if (attachmentCount > 0) {
2741
+ return false;
2742
+ }
2743
+ const warning = normalizeCompletionReplyWarning(error?.payload?.warning);
2744
+ const warningText = normalizeCompletionReplyCompareText(warning?.summary || "");
2745
+ const sentText = normalizeCompletionReplyCompareText(text);
2746
+ return Boolean(warningText && sentText && warningText === sentText);
2747
+ }
2748
+
2272
2749
  function normalizeCompletionReplyAttachments(values) {
2273
2750
  const rawValues = Array.isArray(values)
2274
2751
  ? values
@@ -2474,9 +2951,15 @@ async function fetchCurrentDetailForItem(itemRef = state.currentItem) {
2474
2951
  if (hasDetailOverride(itemRef)) {
2475
2952
  return state.detailOverride.detail;
2476
2953
  }
2954
+ const detailUrl = `/api/items/${encodeURIComponent(itemRef.kind)}/${encodeURIComponent(itemRef.token)}`;
2477
2955
  try {
2478
2956
  const detail = await hydrateDetailImages(
2479
- await apiGet(`/api/items/${encodeURIComponent(itemRef.kind)}/${encodeURIComponent(itemRef.token)}`)
2957
+ await apiGet(detailUrl, {
2958
+ timeoutMs: DETAIL_FETCH_TIMEOUT_MS,
2959
+ probeLanWhileSticky: true,
2960
+ stickyLanProbeTimeoutMs: DETAIL_STICKY_LAN_PROBE_TIMEOUT_MS,
2961
+ preferRelayError: true,
2962
+ })
2480
2963
  );
2481
2964
  if (hasLaunchItemIntent(itemRef)) {
2482
2965
  state.launchItemIntent.status = "loaded";
@@ -2489,16 +2972,24 @@ async function fetchCurrentDetailForItem(itemRef = state.currentItem) {
2489
2972
  renderPair();
2490
2973
  return null;
2491
2974
  }
2492
- await refreshInbox();
2975
+ await Promise.race([
2976
+ refreshInbox(),
2977
+ wait(DETAIL_REFRESH_FALLBACK_TIMEOUT_MS),
2978
+ ]).catch(() => {});
2493
2979
  try {
2494
2980
  const detail = await hydrateDetailImages(
2495
- await apiGet(`/api/items/${encodeURIComponent(itemRef.kind)}/${encodeURIComponent(itemRef.token)}`)
2981
+ await apiGet(detailUrl, {
2982
+ timeoutMs: DETAIL_FETCH_TIMEOUT_MS,
2983
+ probeLanWhileSticky: true,
2984
+ stickyLanProbeTimeoutMs: DETAIL_STICKY_LAN_PROBE_TIMEOUT_MS,
2985
+ preferRelayError: true,
2986
+ })
2496
2987
  );
2497
2988
  if (hasLaunchItemIntent(itemRef)) {
2498
2989
  state.launchItemIntent.status = "loaded";
2499
2990
  }
2500
2991
  return detail;
2501
- } catch {
2992
+ } catch (retryError) {
2502
2993
  if (hasLaunchItemIntent(itemRef)) {
2503
2994
  clearChoiceLocalDraftForItem(itemRef);
2504
2995
  const fallbackDetail = buildLaunchItemFallbackDetail(itemRef);
@@ -2513,7 +3004,7 @@ async function fetchCurrentDetailForItem(itemRef = state.currentItem) {
2513
3004
  if (!state.currentItem) {
2514
3005
  return null;
2515
3006
  }
2516
- return null;
3007
+ return buildDetailLoadErrorDetail(itemRef, retryError || error);
2517
3008
  }
2518
3009
  }
2519
3010
  }
@@ -2528,6 +3019,7 @@ function queueCurrentDetailLoad(itemRef = state.currentItem) {
2528
3019
 
2529
3020
  const requestedItem = { ...itemRef };
2530
3021
  const requestId = ++detailLoadSequence;
3022
+ const hadRenderableDetailAtStart = Boolean(renderableCurrentDetail(requestedItem));
2531
3023
  state.currentDetailLoading = true;
2532
3024
  state.detailLoadingItem = requestedItem;
2533
3025
 
@@ -2554,6 +3046,16 @@ function queueCurrentDetailLoad(itemRef = state.currentItem) {
2554
3046
  }
2555
3047
  state.currentDetailLoading = false;
2556
3048
  state.detailLoadingItem = null;
3049
+ const completedInitialDetailLoad =
3050
+ !hadRenderableDetailAtStart &&
3051
+ Boolean(state.currentDetail) &&
3052
+ isSameItemRef(state.currentDetail, requestedItem) &&
3053
+ Boolean(state.currentItem) &&
3054
+ isSameItemRef(state.currentItem, requestedItem);
3055
+ if (shouldDeferRenderForActiveInteraction() && !completedInitialDetailLoad) {
3056
+ renderDeferredInteractionShellUpdates();
3057
+ return;
3058
+ }
2557
3059
  renderCurrentSurface();
2558
3060
  });
2559
3061
  }
@@ -2572,6 +3074,31 @@ function buildLaunchItemFallbackDetail(itemRef) {
2572
3074
  };
2573
3075
  }
2574
3076
 
3077
+ function buildDetailLoadErrorDetail(itemRef, error) {
3078
+ const snapshot = buildDetailLoadingSnapshot(itemRef) || {};
3079
+ const entry = selectedEntryForItem(itemRef);
3080
+ const item = entry?.item || {};
3081
+ const fallbackText = normalizeClientText(item.messageText || item.summary || item.title || "");
3082
+ const messageParts = [
3083
+ fallbackText ? `<p>${escapeHtml(fallbackText)}</p>` : "",
3084
+ ].filter(Boolean);
3085
+ return {
3086
+ kind: itemRef.kind,
3087
+ token: itemRef.token,
3088
+ title: item.title || snapshot.title || kindMeta(itemRef.kind).label,
3089
+ threadId: item.threadId || "",
3090
+ threadLabel: item.threadLabel || snapshot.threadLabel || "",
3091
+ summary: item.summary || fallbackText || "",
3092
+ messageHtml: messageParts.join(""),
3093
+ provider: item.provider || normalizeProviderClient(item.provider) || "",
3094
+ createdAtMs: Number(item.createdAtMs || snapshot.createdAtMs) || Date.now(),
3095
+ readOnly: true,
3096
+ actions: [],
3097
+ loadError: true,
3098
+ loadErrorMessage: normalizeClientText(error?.message || String(error || "")),
3099
+ };
3100
+ }
3101
+
2575
3102
  function resolveLaunchFallbackMessage(kind, isHandled) {
2576
3103
  if (kind === "approval") {
2577
3104
  return isHandled ? L("error.approvalAlreadyHandled") : L("error.approvalNotFound");
@@ -2636,7 +3163,7 @@ function renderDesktopWorkspace(detail) {
2636
3163
  (state.currentDetailLoading || !renderableCurrentDetail());
2637
3164
  return `
2638
3165
  <section class="desktop-workspace">
2639
- <aside class="surface surface--list">
3166
+ <aside class="surface surface--list" data-list-surface>
2640
3167
  ${renderListPanel({
2641
3168
  tab: state.currentTab,
2642
3169
  entries,
@@ -2664,7 +3191,7 @@ function renderMobileWorkspace(detail) {
2664
3191
  }
2665
3192
 
2666
3193
  return `
2667
- <section class="screen-block">
3194
+ <section class="screen-block" data-list-surface>
2668
3195
  ${renderListPanel({
2669
3196
  tab: state.currentTab,
2670
3197
  entries: listEntriesForTab(state.currentTab),
@@ -3245,15 +3772,17 @@ function renderTimelineEntry(entry, { desktop }) {
3245
3772
  const isMoltbookOrA2A = item.kind === "moltbook_reply" || item.kind === "moltbook_draft" || item.kind === "a2a_task" || item.kind === "a2a_task_result" || item.kind === "thread_share";
3246
3773
  const isMessageLike = TIMELINE_MESSAGE_KINDS.has(item.kind) || isMoltbookOrA2A;
3247
3774
  const isFileEvent = item.kind === "file_event";
3775
+ const isCommandEvent = item.kind === "command_event";
3248
3776
  const imageUrls = Array.isArray(item.imageUrls) ? item.imageUrls.filter(Boolean) : [];
3249
3777
  const fileRefs = normalizeClientFileRefs(item.fileRefs);
3250
- const primaryText = timelineEntryPrimaryText(item, entry.status, { isMessageLike, isFileEvent });
3251
- const secondaryText = timelineEntrySecondaryText(item, entry.status, primaryText, { isMessageLike, isFileEvent });
3778
+ const primaryText = timelineEntryPrimaryText(item, entry.status, { isMessageLike, isFileEvent, isCommandEvent });
3779
+ const secondaryText = timelineEntrySecondaryText(item, entry.status, primaryText, { isMessageLike, isFileEvent, isCommandEvent });
3252
3780
  const threadLabel = timelineEntryThreadLabel(item, isMessageLike);
3253
3781
  const timestampLabel = formatTimelineTimestamp(item.createdAtMs);
3254
3782
  const statusLabel = timelineEntryStatusLabel(item, isMessageLike);
3255
3783
  const fileEventFileSummary = isFileEvent ? timelineFileEventFileSummary(item) : "";
3256
3784
  const fileEventDiffStatsHtml = isFileEvent ? renderDiffEntryStatsHtml(item) : "";
3785
+ const toolEventCommand = timelineToolEventCommand(item);
3257
3786
 
3258
3787
  return `
3259
3788
  <button
@@ -3276,6 +3805,7 @@ function renderTimelineEntry(entry, { desktop }) {
3276
3805
  ${threadLabel ? `<p class="timeline-entry__thread">${escapeHtml(threadLabel)}</p>` : ""}
3277
3806
  <div class="timeline-entry__body">
3278
3807
  <p class="timeline-entry__title">${escapeHtml(primaryText)}</p>
3808
+ ${toolEventCommand ? `<pre class="timeline-entry__command"><code>${escapeHtml(toolEventCommand)}</code></pre>` : ""}
3279
3809
  ${secondaryText ? `<p class="timeline-entry__summary">${escapeHtml(secondaryText)}</p>` : ""}
3280
3810
  ${
3281
3811
  isFileEvent && fileEventFileSummary
@@ -3325,7 +3855,7 @@ function renderDiffEntry(entry) {
3325
3855
  }
3326
3856
 
3327
3857
  function timelineEntryStatusLabel(item, isMessageLike) {
3328
- if (isMessageLike || item?.kind === "file_event") {
3858
+ if (isMessageLike || item?.kind === "file_event" || item?.kind === "command_event") {
3329
3859
  return "";
3330
3860
  }
3331
3861
 
@@ -3408,7 +3938,7 @@ function timelineEntryThreadLabel(item, isMessage) {
3408
3938
  return threadLabel || "";
3409
3939
  }
3410
3940
 
3411
- function timelineEntryPrimaryText(item, status, { isMessageLike = false, isFileEvent = false } = {}) {
3941
+ function timelineEntryPrimaryText(item, status, { isMessageLike = false, isFileEvent = false, isCommandEvent = false } = {}) {
3412
3942
  if (item?.kind === "ambient_suggestions") {
3413
3943
  return item.summary || fallbackSummaryForKind(item.kind, status, item.provider);
3414
3944
  }
@@ -3421,10 +3951,14 @@ function timelineEntryPrimaryText(item, status, { isMessageLike = false, isFileE
3421
3951
  return fileEventTimelineCountLabel(item) || fallbackSummaryForKind(item.kind, status, item.provider);
3422
3952
  }
3423
3953
 
3954
+ if (isCommandEvent) {
3955
+ return L("common.commandEvent");
3956
+ }
3957
+
3424
3958
  return timelineDisplayTitleWithoutThread(item, { allowFallbackSummary: true }) || L("common.untitledItem");
3425
3959
  }
3426
3960
 
3427
- function timelineEntrySecondaryText(item, status, primaryText, { isMessageLike = false, isFileEvent = false } = {}) {
3961
+ function timelineEntrySecondaryText(item, status, primaryText, { isMessageLike = false, isFileEvent = false, isCommandEvent = false } = {}) {
3428
3962
  if (item?.kind === "ambient_suggestions") {
3429
3963
  return "";
3430
3964
  }
@@ -3442,6 +3976,10 @@ function timelineEntrySecondaryText(item, status, primaryText, { isMessageLike =
3442
3976
  return "";
3443
3977
  }
3444
3978
 
3979
+ if (isCommandEvent) {
3980
+ return "";
3981
+ }
3982
+
3445
3983
  const compactTitle = timelineDisplayTitleWithoutThread(item, { allowFallbackSummary: false });
3446
3984
  return compactTitle ? summaryText : "";
3447
3985
  }
@@ -3491,6 +4029,7 @@ function timelineGeneratedTitlePrefixes() {
3491
4029
  kindMeta("assistant_commentary").label,
3492
4030
  kindMeta("assistant_final").label,
3493
4031
  L("common.fileEvent"),
4032
+ L("common.commandEvent"),
3494
4033
  "Approval",
3495
4034
  "Plan",
3496
4035
  "Choice",
@@ -3499,6 +4038,7 @@ function timelineGeneratedTitlePrefixes() {
3499
4038
  "Commentary",
3500
4039
  "Final answer",
3501
4040
  "Files",
4041
+ "Command",
3502
4042
  "承認",
3503
4043
  "プラン",
3504
4044
  "選択",
@@ -3507,13 +4047,39 @@ function timelineGeneratedTitlePrefixes() {
3507
4047
  "途中経過",
3508
4048
  "最終回答",
3509
4049
  "ファイル",
4050
+ "コマンド",
3510
4051
  ];
3511
4052
  }
3512
4053
 
4054
+ function timelineCommandEventCommand(item) {
4055
+ return truncateUiText(firstMarkdownCodeFence(item?.messageText || "") || item?.summary || item?.title || "", 220);
4056
+ }
4057
+
4058
+ function shouldRenderFileEventCommand(item) {
4059
+ if (item?.kind !== "file_event") {
4060
+ return false;
4061
+ }
4062
+ return ["read", "search"].includes(normalizeClientText(item?.fileEventType || ""));
4063
+ }
4064
+
4065
+ function timelineToolEventCommand(item) {
4066
+ if (item?.kind === "command_event") {
4067
+ return timelineCommandEventCommand(item);
4068
+ }
4069
+ if (!shouldRenderFileEventCommand(item)) {
4070
+ return "";
4071
+ }
4072
+ return truncateUiText(firstMarkdownCodeFence(item?.messageText || "") || item?.commandText || "", 220);
4073
+ }
4074
+
3513
4075
  function fileEventDisplayLabel(fileEventType) {
3514
4076
  switch (normalizeClientText(fileEventType || "")) {
3515
4077
  case "read":
3516
4078
  return L("fileEvent.read");
4079
+ case "search":
4080
+ return L("fileEvent.search");
4081
+ case "command":
4082
+ return L("fileEvent.command");
3517
4083
  case "write":
3518
4084
  return L("fileEvent.write");
3519
4085
  case "create":
@@ -3536,6 +4102,10 @@ function fileEventTimelineCountLabel(item) {
3536
4102
  switch (fileEventType) {
3537
4103
  case "read":
3538
4104
  return L("fileEvent.timeline.read", { count });
4105
+ case "search":
4106
+ return L("fileEvent.timeline.search", { count });
4107
+ case "command":
4108
+ return L("fileEvent.timeline.command", { count });
3539
4109
  case "write":
3540
4110
  return L("fileEvent.timeline.write", { count });
3541
4111
  case "create":
@@ -3584,7 +4154,7 @@ function diffThreadFilesSummary(item) {
3584
4154
  .map((entry) => fileChangeEntryLabel(entry))
3585
4155
  .filter(Boolean);
3586
4156
  if (labels.length === 0) {
3587
- return "";
4157
+ return truncateUiText(firstMarkdownCodeFence(item?.messageText || "") || item?.summary || "");
3588
4158
  }
3589
4159
  const visibleLabels = labels.slice(0, 3);
3590
4160
  const hiddenCount = labels.length - visibleLabels.length;
@@ -5222,6 +5792,15 @@ function renderSettingsAutoPilotSuggestion({ lane, count }) {
5222
5792
  `;
5223
5793
  }
5224
5794
 
5795
+ function renderSettingsExternalLink({ href, label }) {
5796
+ return `
5797
+ <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%;">
5798
+ <span class="settings-external-link__label">${escapeHtml(label)}</span>
5799
+ <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>
5800
+ </a>
5801
+ `.trim();
5802
+ }
5803
+
5225
5804
  function renderSettingsMoltbookPage(context) {
5226
5805
  const scout = context.moltbookScout;
5227
5806
  if (!scout?.enabled) {
@@ -5236,10 +5815,16 @@ function renderSettingsMoltbookPage(context) {
5236
5815
  renderSettingsInfoRow(L("settings.row.moltbookBatchTopScore"), String(scout.batch.topScore)),
5237
5816
  renderSettingsInfoRow(L("settings.row.moltbookBatchRemaining"), `${Math.floor(scout.batch.remainingSeconds / 60)}:${String(scout.batch.remainingSeconds % 60).padStart(2, "0")}`),
5238
5817
  ] : [];
5239
- const accountRow = scout.account?.name && scout.account?.profileUrl
5818
+ const accountProfileLink = scout.account?.name && scout.account?.profileUrl
5819
+ ? renderSettingsExternalLink({
5820
+ href: scout.account.profileUrl,
5821
+ label: scout.account.name,
5822
+ })
5823
+ : "";
5824
+ const accountRow = accountProfileLink
5240
5825
  ? renderSettingsInfoRow(
5241
5826
  L("settings.row.moltbookAccount"),
5242
- `<a href="${escapeHtml(scout.account.profileUrl)}" target="_blank" rel="noopener">${escapeHtml(scout.account.name)}</a>`,
5827
+ accountProfileLink,
5243
5828
  { rawValue: true }
5244
5829
  )
5245
5830
  : null;
@@ -5310,7 +5895,10 @@ function renderSettingsA2aRelayPage(context) {
5310
5895
  ? L("settings.a2aRelay.status.polling")
5311
5896
  : L("settings.a2aRelay.status.disconnected");
5312
5897
  const profileUrl = `${relay.relayUrl}/u/${relay.userId}`;
5313
- const userIdLink = `<a href="${escapeHtml(profileUrl)}" target="_blank" rel="noopener">${escapeHtml(relay.userId)}</a>`;
5898
+ const userIdLink = renderSettingsExternalLink({
5899
+ href: profileUrl,
5900
+ label: relay.userId,
5901
+ });
5314
5902
  const relayHost = (() => { try { return new URL(relay.relayUrl).host; } catch { return relay.relayUrl; } })();
5315
5903
  const publicChecked = relay.acceptPublicTasks === true;
5316
5904
 
@@ -6335,6 +6923,7 @@ function renderStandardDetailDesktop(detail) {
6335
6923
  <div class="detail-shell">
6336
6924
  ${renderDetailMetaRow(detail, kindInfo)}
6337
6925
  <h2 class="detail-title detail-title--desktop">${renderDetailTitle(detail)}</h2>
6926
+ ${renderDetailLoadErrorNotice(detail)}
6338
6927
  ${detail.readOnly || detail.kind === "approval" || detail.kind === "moltbook_draft" || detail.kind === "moltbook_reply" || detail.kind === "thread_share" ? "" : renderDetailLead(detail, kindInfo)}
6339
6928
  ${renderPreviousContextCard(detail)}
6340
6929
  ${renderAutoPilotManualReview(detail)}
@@ -6355,6 +6944,7 @@ function renderStandardDetailDesktop(detail) {
6355
6944
  </section>
6356
6945
  `
6357
6946
  }
6947
+ ${renderCommandEventDetail(detail)}
6358
6948
  ${renderClaudePlanSection(detail)}
6359
6949
  ${renderClaudeQuestionSection(detail)}
6360
6950
  ${renderDetailImageGallery(detail)}
@@ -6377,6 +6967,7 @@ function renderStandardDetailMobile(detail) {
6377
6967
  <div class="detail-shell detail-shell--mobile">
6378
6968
  <div class="mobile-detail-scroll mobile-detail-scroll--detail">
6379
6969
  ${renderDetailMetaRow(detail, kindInfo, { mobile: true })}
6970
+ ${renderDetailLoadErrorNotice(detail, { mobile: true })}
6380
6971
  ${renderPreviousContextCard(detail, { mobile: true })}
6381
6972
  ${renderAutoPilotManualReview(detail, { mobile: true })}
6382
6973
  ${renderInterruptedDetailNotice(detail, { mobile: true })}
@@ -6397,6 +6988,7 @@ function renderStandardDetailMobile(detail) {
6397
6988
  </section>
6398
6989
  `
6399
6990
  }
6991
+ ${renderCommandEventDetail(detail, { mobile: true })}
6400
6992
  ${renderClaudePlanSection(detail, { mobile: true })}
6401
6993
  ${renderClaudeQuestionSection(detail, { mobile: true })}
6402
6994
  ${renderDetailImageGallery(detail, { mobile: true })}
@@ -6439,6 +7031,46 @@ function renderAmbientSuggestionsSection(detail, options = {}) {
6439
7031
  `;
6440
7032
  }
6441
7033
 
7034
+ function renderCommandEventDetail(detail, options = {}) {
7035
+ if (detail?.kind !== "command_event" && !shouldRenderFileEventCommand(detail)) {
7036
+ return "";
7037
+ }
7038
+ const commandText = normalizeClientText(detail?.commandText || "") || firstMarkdownCodeFence(detail?.messageText || "");
7039
+ if (!commandText) {
7040
+ return "";
7041
+ }
7042
+ return `
7043
+ <section class="detail-card detail-card--command ${options.mobile ? "detail-card--mobile" : ""}">
7044
+ <div class="detail-files-card__header">
7045
+ <span class="detail-files-card__icon" aria-hidden="true">${renderIcon("command")}</span>
7046
+ <span>${escapeHtml(L("common.commandEvent"))}</span>
7047
+ </div>
7048
+ <pre class="detail-command-block"><code>${escapeHtml(commandText)}</code></pre>
7049
+ </section>
7050
+ `;
7051
+ }
7052
+
7053
+ function renderDetailLoadErrorNotice(detail, options = {}) {
7054
+ if (detail?.loadError !== true) {
7055
+ return "";
7056
+ }
7057
+ const errorText = normalizeClientText(detail.loadErrorMessage || "");
7058
+ return `
7059
+ <section class="detail-card detail-card--body ${options.mobile ? "detail-card--mobile" : ""}">
7060
+ <div class="detail-body markdown">
7061
+ <p><strong>${escapeHtml(L("detail.loadFailedTitle"))}</strong></p>
7062
+ <p>${escapeHtml(L("detail.loadFailedCopy"))}</p>
7063
+ ${errorText ? `<p class="muted">${escapeHtml(errorText)}</p>` : ""}
7064
+ </div>
7065
+ <div class="actions actions--stack">
7066
+ <button class="secondary secondary--wide" type="button" data-detail-retry>
7067
+ ${escapeHtml(L("detail.loadRetry"))}
7068
+ </button>
7069
+ </div>
7070
+ </section>
7071
+ `;
7072
+ }
7073
+
6442
7074
  function renderAmbientSuggestionCard(detail, suggestion, index) {
6443
7075
  const copyKey = ambientSuggestionCopyKey(detail?.token || "", suggestion?.id || "", index);
6444
7076
  const copyStatus = state.ambientSuggestionCopyState?.key === copyKey
@@ -6471,7 +7103,7 @@ function renderAmbientSuggestionCard(detail, suggestion, index) {
6471
7103
  }
6472
7104
 
6473
7105
  function renderDetailPlainIntro(detail, options = {}) {
6474
- if (!["approval", "diff_thread", "file_event"].includes(detail?.kind || "")) {
7106
+ if (!["approval", "diff_thread", "file_event", "command_event"].includes(detail?.kind || "")) {
6475
7107
  return "";
6476
7108
  }
6477
7109
  const ak = normalizeClientText(detail?.approvalKind || "");
@@ -7043,6 +7675,29 @@ function renderA2ATaskDetail(detail, options = {}) {
7043
7675
  const statusBadge = !enabled && detail.kind === "a2a_task_result"
7044
7676
  ? `<span class="eyebrow-pill eyebrow-pill--subtle">${escapeHtml(L(statusKey))}</span>`
7045
7677
  : "";
7678
+ const viveworkerTask = detail.viveworker || {};
7679
+ const payment = viveworkerTask.payment || {};
7680
+ const priceLabel = payment.price
7681
+ ? `${payment.price} USDC`
7682
+ : detail.paidDeliverable?.price
7683
+ ? `${detail.paidDeliverable.price} USDC`
7684
+ : "";
7685
+ const paidDeliverableBlock = viveworkerTask.paidDeliverable || detail.paidDeliverable
7686
+ ? `
7687
+ <div class="reply-composer__context">
7688
+ <span class="eyebrow-pill eyebrow-pill--quiet">${escapeHtml(L("a2a.task.paidDeliverable"))}</span>
7689
+ <div class="reply-composer__context-body">
7690
+ ${viveworkerTask.requestedTier ? `<p><strong>${escapeHtml(L("a2a.task.requestedTier"))}</strong>: ${escapeHtml(viveworkerTask.requestedTier)}</p>` : ""}
7691
+ ${viveworkerTask.requestedExecutor ? `<p><strong>${escapeHtml(L("a2a.task.requestedExecutor"))}</strong>: ${escapeHtml(viveworkerTask.requestedExecutor)}</p>` : ""}
7692
+ ${viveworkerTask.requestedModel ? `<p><strong>${escapeHtml(L("a2a.task.requestedModel"))}</strong>: ${escapeHtml(viveworkerTask.requestedModel)}</p>` : ""}
7693
+ ${viveworkerTask.deliverableType ? `<p><strong>${escapeHtml(L("a2a.task.deliverableType"))}</strong>: ${escapeHtml(viveworkerTask.deliverableType)}</p>` : ""}
7694
+ ${priceLabel ? `<p><strong>${escapeHtml(L("a2a.task.price"))}</strong>: ${escapeHtml(priceLabel)}</p>` : ""}
7695
+ ${payment.payTo ? `<p><strong>${escapeHtml(L("a2a.task.payTo"))}</strong>: <code>${escapeHtml(payment.payTo)}</code></p>` : ""}
7696
+ ${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>` : ""}
7697
+ </div>
7698
+ </div>
7699
+ `
7700
+ : "";
7046
7701
 
7047
7702
  // Show executor selector when "ask" mode is active and both CLIs are available.
7048
7703
  const executors = state.session?.a2aExecutors || { codex: false, claude: false };
@@ -7084,6 +7739,7 @@ function renderA2ATaskDetail(detail, options = {}) {
7084
7739
  <span class="eyebrow-pill eyebrow-pill--quiet">${escapeHtml(L("a2a.task.eyebrow"))}</span>
7085
7740
  ${statusBadge}
7086
7741
  ${callerLine}
7742
+ ${paidDeliverableBlock}
7087
7743
  <p class="muted reply-composer__description">${enabled ? escapeHtml(L("a2a.task.editHint")) : ""}</p>
7088
7744
  </div>
7089
7745
  <div class="reply-composer__instruction">
@@ -7818,6 +8474,8 @@ function renderTabButtons({ buttonClass, withIcons }) {
7818
8474
  }
7819
8475
 
7820
8476
  function bindShellInteractions() {
8477
+ bindScrollableContentRenderDeferral();
8478
+
7821
8479
  for (const button of document.querySelectorAll("[data-tab]")) {
7822
8480
  button.addEventListener("click", async () => {
7823
8481
  await switchTab(button.dataset.tab);
@@ -8063,14 +8721,31 @@ function bindShellInteractions() {
8063
8721
  });
8064
8722
  }
8065
8723
 
8724
+ for (const button of document.querySelectorAll("[data-detail-retry]")) {
8725
+ button.addEventListener("click", async (event) => {
8726
+ event.preventDefault();
8727
+ event.stopPropagation();
8728
+ if (!state.currentItem) {
8729
+ return;
8730
+ }
8731
+ state.currentDetail = null;
8732
+ state.currentDetailLoading = false;
8733
+ state.detailLoadingItem = null;
8734
+ queueCurrentDetailLoad(state.currentItem);
8735
+ await renderShell();
8736
+ });
8737
+ }
8738
+
8066
8739
  for (const button of document.querySelectorAll("[data-back-to-list]")) {
8067
8740
  button.addEventListener("click", async () => {
8741
+ const nextTab = state.currentTab;
8068
8742
  clearChoiceLocalDraftForItem(state.currentItem);
8069
8743
  state.detailOpen = false;
8070
8744
  state.pendingListScrollRestore = !isDesktopLayout() && Boolean(state.listScrollState);
8071
8745
  clearPinnedDetailState();
8072
8746
  syncCurrentItemUrl(null);
8073
8747
  await renderShell();
8748
+ refreshPrimaryTabAfterNavigation(nextTab);
8074
8749
  });
8075
8750
  }
8076
8751
 
@@ -8118,7 +8793,8 @@ function bindShellInteractions() {
8118
8793
  delete postBody.hazbaseReauth;
8119
8794
  }
8120
8795
  await apiPost(actionUrl, postBody);
8121
- if (keepDetailOpen && activeItem?.kind === "approval") {
8796
+ if (activeItem?.kind === "approval") {
8797
+ state.pendingActionUrls.delete(actionUrl);
8122
8798
  pinActionOutcomeDetail(
8123
8799
  activeItem,
8124
8800
  buildActionOutcomeDetail({
@@ -8127,6 +8803,13 @@ function bindShellInteractions() {
8127
8803
  message: approvalOutcomeMessage(actionUrl, activeItem?.provider),
8128
8804
  })
8129
8805
  );
8806
+ await renderShell();
8807
+ void refreshAuthenticatedState()
8808
+ .then(() => renderShell())
8809
+ .catch((error) => {
8810
+ console.debug?.("[approval-action-refresh-failed]", error?.message || String(error));
8811
+ });
8812
+ return;
8130
8813
  }
8131
8814
  await refreshAuthenticatedState();
8132
8815
  if (!keepDetailOpen && !isDesktopLayout()) {
@@ -8141,6 +8824,14 @@ function bindShellInteractions() {
8141
8824
  if (error?.errorKey === "hazbase-session-expired") {
8142
8825
  await fetchHazbaseStatus();
8143
8826
  }
8827
+ if (activeItem?.kind === "approval" && isAlreadyHandledApprovalError(error)) {
8828
+ const recovered = await recoverHandledApprovalDetail(activeItem);
8829
+ if (recovered) {
8830
+ pinActionOutcomeDetail(activeItem, recovered);
8831
+ await renderShell();
8832
+ return;
8833
+ }
8834
+ }
8144
8835
  if (approvalFinalized) {
8145
8836
  await refreshAuthenticatedState();
8146
8837
  if (keepDetailOpen && activeItem?.kind === "approval") {
@@ -9239,8 +9930,43 @@ for (const button of document.querySelectorAll("[data-wallet-address-copy]")) {
9239
9930
  for (const attachment of attachments) {
9240
9931
  requestBody.append("image", attachment.file, attachment.name || attachment.file.name);
9241
9932
  }
9242
- const replyKind = replyForm.dataset.replyKind || "completion";
9243
- await apiPost(`/api/items/${encodeURIComponent(replyKind)}/${encodeURIComponent(token)}/reply`, requestBody);
9933
+ const sentNotice = L(draft.mode === "plan" ? "reply.notice.sentPlan" : "reply.notice.sentDefault", { provider: providerDisplayName(replyProvider) });
9934
+ const renderOptimisticSent = () => {
9935
+ const currentDraft = getCompletionReplyDraft(token);
9936
+ if (!currentDraft.sending || currentDraft.text !== text) {
9937
+ return;
9938
+ }
9939
+ setCompletionReplyDraft(token, {
9940
+ text: "",
9941
+ sentText: text,
9942
+ attachments: currentDraft.attachments,
9943
+ mode: draft.mode,
9944
+ sending: false,
9945
+ error: "",
9946
+ notice: sentNotice,
9947
+ warning: null,
9948
+ confirmOverride: false,
9949
+ collapsedAfterSend: true,
9950
+ });
9951
+ renderShell().catch((renderError) => {
9952
+ console.warn("[completion-reply-optimistic-render]", renderError?.message || renderError);
9953
+ });
9954
+ };
9955
+ const optimisticSentTimer = setTimeout(renderOptimisticSent, COMPLETION_REPLY_OPTIMISTIC_SENT_MS);
9956
+ let replyResult = null;
9957
+ try {
9958
+ const replyKind = replyForm.dataset.replyKind || "completion";
9959
+ replyResult = await apiPost(
9960
+ `/api/items/${encodeURIComponent(replyKind)}/${encodeURIComponent(token)}/reply`,
9961
+ requestBody,
9962
+ {
9963
+ timeoutMs: COMPLETION_REPLY_SEND_TIMEOUT_MS,
9964
+ preferRelayError: true,
9965
+ },
9966
+ );
9967
+ } finally {
9968
+ clearTimeout(optimisticSentTimer);
9969
+ }
9244
9970
  setCompletionReplyDraft(token, {
9245
9971
  text: "",
9246
9972
  sentText: text,
@@ -9248,14 +9974,51 @@ for (const button of document.querySelectorAll("[data-wallet-address-copy]")) {
9248
9974
  mode: draft.mode,
9249
9975
  sending: false,
9250
9976
  error: "",
9251
- notice: L(draft.mode === "plan" ? "reply.notice.sentPlan" : "reply.notice.sentDefault", { provider: providerDisplayName(replyProvider) }),
9977
+ notice: sentNotice,
9252
9978
  warning: null,
9253
9979
  confirmOverride: false,
9254
9980
  collapsedAfterSend: true,
9255
9981
  });
9256
- await refreshAuthenticatedState();
9982
+ if (replyResult?.ackTimeout === true) {
9983
+ console.info("[completion-reply] accepted after slow Codex ACK");
9984
+ }
9985
+ await renderShell();
9986
+ refreshAuthenticatedState()
9987
+ .then(renderShell)
9988
+ .catch((refreshError) => console.warn("[completion-reply-refresh]", refreshError?.message || refreshError));
9989
+ return;
9257
9990
  } catch (error) {
9991
+ const optimisticDraft = getCompletionReplyDraft(token);
9992
+ if (
9993
+ error.errorKey === "request-timeout" &&
9994
+ optimisticDraft.collapsedAfterSend &&
9995
+ optimisticDraft.sentText === text
9996
+ ) {
9997
+ refreshAuthenticatedState()
9998
+ .then(renderShell)
9999
+ .catch((refreshError) => console.warn("[completion-reply-refresh]", refreshError?.message || refreshError));
10000
+ return;
10001
+ }
9258
10002
  if (error.errorKey === "completion-reply-thread-advanced") {
10003
+ if (completionReplyWarningMatchesSentText(error, text, attachments.length)) {
10004
+ setCompletionReplyDraft(token, {
10005
+ text: "",
10006
+ sentText: text,
10007
+ attachments: [],
10008
+ mode: draft.mode,
10009
+ sending: false,
10010
+ error: "",
10011
+ notice: L(draft.mode === "plan" ? "reply.notice.sentPlan" : "reply.notice.sentDefault", { provider: providerDisplayName(replyProvider) }),
10012
+ warning: null,
10013
+ confirmOverride: false,
10014
+ collapsedAfterSend: true,
10015
+ });
10016
+ await renderShell();
10017
+ refreshAuthenticatedState()
10018
+ .then(renderShell)
10019
+ .catch((refreshError) => console.warn("[completion-reply-refresh]", refreshError?.message || refreshError));
10020
+ return;
10021
+ }
9259
10022
  setCompletionReplyDraft(token, {
9260
10023
  text,
9261
10024
  sentText: "",
@@ -9459,6 +10222,143 @@ function bindSharedUi(renderFn) {
9459
10222
  }
9460
10223
  }
9461
10224
 
10225
+ function bindScrollableContentRenderDeferral() {
10226
+ const mark = () => {
10227
+ markScrollableContentInteraction();
10228
+ };
10229
+ for (const el of document.querySelectorAll(SCROLLABLE_CONTENT_SELECTORS)) {
10230
+ el.addEventListener("scroll", mark, { passive: true });
10231
+ el.addEventListener("wheel", mark, { passive: true });
10232
+ el.addEventListener("touchstart", mark, { passive: true });
10233
+ el.addEventListener("touchmove", mark, { passive: true });
10234
+ el.addEventListener("pointerdown", mark);
10235
+ el.addEventListener("copy", mark);
10236
+ }
10237
+ }
10238
+
10239
+ function bindPartialListSurfaceInteractions(listSurface) {
10240
+ if (!listSurface || listSurface.dataset.partialListInteractionsBound === "true") {
10241
+ return;
10242
+ }
10243
+ listSurface.dataset.partialListInteractionsBound = "true";
10244
+
10245
+ const targetElement = (event) => {
10246
+ const target = event.target;
10247
+ return target instanceof Element ? target : target?.parentElement || null;
10248
+ };
10249
+ const threadSelectSelector = "[data-timeline-thread-select], [data-diff-thread-select], [data-completed-thread-select]";
10250
+
10251
+ listSurface.addEventListener("pointerdown", (event) => {
10252
+ if (targetElement(event)?.closest(threadSelectSelector)) {
10253
+ markThreadFilterInteraction();
10254
+ }
10255
+ });
10256
+ listSurface.addEventListener("focusin", (event) => {
10257
+ if (targetElement(event)?.closest(threadSelectSelector)) {
10258
+ markThreadFilterInteraction();
10259
+ }
10260
+ });
10261
+ listSurface.addEventListener("focusout", (event) => {
10262
+ if (targetElement(event)?.closest(threadSelectSelector)) {
10263
+ clearThreadFilterInteraction();
10264
+ }
10265
+ });
10266
+
10267
+ listSurface.addEventListener("change", async (event) => {
10268
+ const target = targetElement(event);
10269
+ const timelineSelect = target?.closest("[data-timeline-thread-select]");
10270
+ const diffSelect = target?.closest("[data-diff-thread-select]");
10271
+ const completedSelect = target?.closest("[data-completed-thread-select]");
10272
+ if (!timelineSelect && !diffSelect && !completedSelect) {
10273
+ return;
10274
+ }
10275
+ clearThreadFilterInteraction();
10276
+ if (timelineSelect) {
10277
+ state.timelineThreadFilter = timelineSelect.value || "all";
10278
+ state.timelineKindFilterOpen = false;
10279
+ } else if (diffSelect) {
10280
+ state.diffThreadFilter = diffSelect.value || "all";
10281
+ } else if (completedSelect) {
10282
+ state.completedThreadFilter = completedSelect.value || "all";
10283
+ }
10284
+ alignCurrentItemToVisibleEntries();
10285
+ await renderShell();
10286
+ });
10287
+
10288
+ listSurface.addEventListener("click", async (event) => {
10289
+ const target = targetElement(event);
10290
+ const threadSelect = target?.closest(threadSelectSelector);
10291
+ if (threadSelect) {
10292
+ markThreadFilterInteraction();
10293
+ return;
10294
+ }
10295
+
10296
+ const providerButton = target?.closest("[data-provider-filter]");
10297
+ if (providerButton) {
10298
+ event.preventDefault();
10299
+ const next = providerButton.dataset.providerFilter || "all";
10300
+ if (state.providerFilter === next) {
10301
+ return;
10302
+ }
10303
+ state.providerFilter = next;
10304
+ state.timelineThreadFilter = "all";
10305
+ state.timelineKindFilter = "all";
10306
+ state.timelineKindFilterOpen = false;
10307
+ state.completedThreadFilter = "all";
10308
+ state.diffThreadFilter = "all";
10309
+ alignCurrentItemToVisibleEntries();
10310
+ await renderShell();
10311
+ return;
10312
+ }
10313
+
10314
+ const kindToggle = target?.closest("[data-timeline-kind-filter-toggle]");
10315
+ if (kindToggle) {
10316
+ event.preventDefault();
10317
+ markThreadFilterInteraction();
10318
+ state.timelineKindFilterOpen = !state.timelineKindFilterOpen;
10319
+ await renderShell();
10320
+ return;
10321
+ }
10322
+
10323
+ const kindOption = target?.closest("[data-timeline-kind-filter-option]");
10324
+ if (kindOption) {
10325
+ event.preventDefault();
10326
+ clearThreadFilterInteraction();
10327
+ state.timelineKindFilter = kindOption.dataset.timelineKindFilterOption || "all";
10328
+ state.timelineKindFilterOpen = false;
10329
+ alignCurrentItemToVisibleEntries();
10330
+ await renderShell();
10331
+ return;
10332
+ }
10333
+
10334
+ const subtabButton = target?.closest("[data-inbox-subtab]");
10335
+ if (subtabButton) {
10336
+ const nextSubtab = subtabButton.dataset.inboxSubtab === "completed" ? "completed" : "pending";
10337
+ if (nextSubtab === state.inboxSubtab) {
10338
+ return;
10339
+ }
10340
+ state.inboxSubtab = nextSubtab;
10341
+ if (isDesktopLayout()) {
10342
+ alignCurrentItemToVisibleEntries();
10343
+ syncCurrentItemUrl(state.currentItem);
10344
+ }
10345
+ await renderShell();
10346
+ return;
10347
+ }
10348
+
10349
+ const itemButton = target?.closest("[data-open-item-kind][data-open-item-token]");
10350
+ if (itemButton) {
10351
+ openItem({
10352
+ kind: itemButton.dataset.openItemKind,
10353
+ token: itemButton.dataset.openItemToken,
10354
+ sourceTab: itemButton.dataset.sourceTab,
10355
+ sourceSubtab: itemButton.dataset.sourceSubtab,
10356
+ });
10357
+ await renderShell();
10358
+ }
10359
+ });
10360
+ }
10361
+
9462
10362
  function openSettingsSubpage(page) {
9463
10363
  if (!page) {
9464
10364
  return;
@@ -9500,6 +10400,7 @@ async function switchTab(tab) {
9500
10400
  syncCurrentItemUrl(state.currentItem);
9501
10401
  }
9502
10402
  await renderShell();
10403
+ refreshPrimaryTabAfterNavigation(tab);
9503
10404
  if (tab === "settings") {
9504
10405
  void fetchHazbaseStatus()
9505
10406
  .then(() => {
@@ -9696,6 +10597,8 @@ function kindMeta(kind, item) {
9696
10597
  return { label: L("common.diff"), tone: "neutral", icon: "diff" };
9697
10598
  case "file_event":
9698
10599
  return { label: L("common.fileEvent"), tone: "neutral", icon: "file-event" };
10600
+ case "command_event":
10601
+ return { label: L("common.commandEvent"), tone: "neutral", icon: "command" };
9699
10602
  case "moltbook_reply":
9700
10603
  return { label: L("common.moltbookReply"), tone: "neutral", icon: "moltbook-comment" };
9701
10604
  case "moltbook_draft":
@@ -9728,6 +10631,9 @@ function itemIntentText(kind, status = "pending", provider) {
9728
10631
  if (kind === "file_event") {
9729
10632
  return L("intent.fileEvent");
9730
10633
  }
10634
+ if (kind === "command_event") {
10635
+ return L("intent.commandEvent");
10636
+ }
9731
10637
  if (kind === "ambient_suggestions") {
9732
10638
  return L("intent.ambientSuggestions");
9733
10639
  }
@@ -9765,6 +10671,9 @@ function detailIntentText(detail) {
9765
10671
  if (detail.kind === "file_event") {
9766
10672
  return itemIntentText(detail.kind, "timeline", provider);
9767
10673
  }
10674
+ if (detail.kind === "command_event") {
10675
+ return itemIntentText(detail.kind, "timeline", provider);
10676
+ }
9768
10677
  if (detail.kind === "ambient_suggestions") {
9769
10678
  return itemIntentText(detail.kind, "timeline", provider);
9770
10679
  }
@@ -9838,6 +10747,8 @@ function fallbackSummaryForKind(kind, status, provider) {
9838
10747
  return L("summary.diffThread");
9839
10748
  case "file_event":
9840
10749
  return L("summary.fileEvent", vars);
10750
+ case "command_event":
10751
+ return L("summary.commandEvent", vars);
9841
10752
  case "ambient_suggestions":
9842
10753
  return L("summary.ambientSuggestions", { count: 0, firstTitle: "", more: 0 });
9843
10754
  case "user_message":
@@ -10009,6 +10920,22 @@ function hasDetailOverride(itemRef = state.currentItem) {
10009
10920
  return Boolean(state.detailOverride && isSameItemRef(state.detailOverride, itemRef));
10010
10921
  }
10011
10922
 
10923
+ function isAlreadyHandledApprovalError(error) {
10924
+ return error?.errorKey === "approval-not-found" || error?.errorKey === "approval-already-handled";
10925
+ }
10926
+
10927
+ async function recoverHandledApprovalDetail(itemRef) {
10928
+ try {
10929
+ await refreshAuthenticatedState();
10930
+ const detail = await hydrateDetailImages(
10931
+ await apiGet(`/api/items/${encodeURIComponent(itemRef.kind)}/${encodeURIComponent(itemRef.token)}`)
10932
+ );
10933
+ return detail?.kind === "approval" && detail.readOnly === true ? detail : null;
10934
+ } catch {
10935
+ return null;
10936
+ }
10937
+ }
10938
+
10012
10939
  function shouldPreserveCurrentItem(itemRef = state.currentItem) {
10013
10940
  return Boolean(itemRef && (hasLaunchItemIntent(itemRef) || hasDetailOverride(itemRef)));
10014
10941
  }
@@ -10044,6 +10971,8 @@ function renderIcon(name) {
10044
10971
  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
10972
  case "file-event":
10046
10973
  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>`;
10974
+ case "command":
10975
+ 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
10976
  case "diff":
10048
10977
  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
10978
  case "pending":
@@ -10076,6 +11005,8 @@ function renderIcon(name) {
10076
11005
  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
11006
  case "link":
10078
11007
  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>`;
11008
+ case "external-link":
11009
+ 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
11010
  case "clip":
10080
11011
  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
11012
  case "moltbook-draft":
@@ -10346,32 +11277,107 @@ function pruneTimelineImageObjectUrlCache() {
10346
11277
  }
10347
11278
  }
10348
11279
 
11280
+ function withRequestTimeout(init, opts = {}) {
11281
+ const timeoutMs = Number(opts.timeoutMs) || 0;
11282
+ if (!Number.isFinite(timeoutMs) || timeoutMs <= 0 || typeof AbortController === "undefined") {
11283
+ return { init, cleanup: () => {} };
11284
+ }
11285
+ if (init.signal?.aborted) {
11286
+ return { init, cleanup: () => {} };
11287
+ }
11288
+ const controller = new AbortController();
11289
+ let timer = null;
11290
+ const externalAbortHandler = init.signal
11291
+ ? () => controller.abort(init.signal.reason)
11292
+ : null;
11293
+ if (init.signal && externalAbortHandler) {
11294
+ init.signal.addEventListener("abort", externalAbortHandler, { once: true });
11295
+ }
11296
+ timer = setTimeout(() => {
11297
+ const error = new Error("request-timeout");
11298
+ error.name = "AbortError";
11299
+ controller.abort(error);
11300
+ }, timeoutMs);
11301
+ return {
11302
+ init: { ...init, signal: controller.signal },
11303
+ cleanup: () => {
11304
+ if (timer) clearTimeout(timer);
11305
+ if (init.signal && externalAbortHandler) {
11306
+ init.signal.removeEventListener("abort", externalAbortHandler);
11307
+ }
11308
+ },
11309
+ };
11310
+ }
11311
+
11312
+ function normalizeRequestError(error, opts = {}) {
11313
+ if (error?.name === "AbortError" && Number(opts.timeoutMs) > 0) {
11314
+ const timeoutError = new Error(L("error.requestTimedOut"));
11315
+ timeoutError.code = 0;
11316
+ timeoutError.status = 0;
11317
+ timeoutError.errorKey = "request-timeout";
11318
+ return timeoutError;
11319
+ }
11320
+ return error;
11321
+ }
11322
+
10349
11323
  async function apiGet(url, opts = {}) {
10350
11324
  // routedFetch tries LAN first, then falls back to the relay tunnel when
10351
11325
  // the phone is off-LAN. Returns a fetch-Response-compatible object so the
10352
11326
  // rest of this function is identical to a plain `fetch()` call.
10353
- const response = await routedFetch(url, {
11327
+ const timed = withRequestTimeout({
10354
11328
  credentials: "same-origin",
10355
11329
  headers: {
10356
11330
  Accept: "application/json",
10357
11331
  },
10358
11332
  }, 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;
11333
+ try {
11334
+ const response = await routedFetch(url, timed.init, opts);
11335
+ if (!response.ok) {
11336
+ const errorInfo = await readError(response);
11337
+ const error = new Error(errorInfo.message);
11338
+ error.code = response.status;
11339
+ error.status = response.status;
11340
+ error.errorKey = errorInfo.errorKey || "";
11341
+ throw error;
11342
+ }
11343
+ return await response.json();
11344
+ } catch (error) {
11345
+ throw normalizeRequestError(error, opts);
11346
+ } finally {
11347
+ timed.cleanup();
10366
11348
  }
10367
- return response.json();
10368
11349
  }
10369
11350
 
10370
- async function apiPost(url, body) {
11351
+ async function apiGetDirectLan(url, opts = {}) {
11352
+ const timed = withRequestTimeout({
11353
+ credentials: "same-origin",
11354
+ headers: {
11355
+ Accept: "application/json",
11356
+ },
11357
+ }, opts);
11358
+ try {
11359
+ const response = await fetch(url, timed.init);
11360
+ if (!response.ok) {
11361
+ const errorInfo = await readError(response);
11362
+ const error = new Error(errorInfo.message);
11363
+ error.code = response.status;
11364
+ error.status = response.status;
11365
+ error.errorKey = errorInfo.errorKey || "";
11366
+ throw error;
11367
+ }
11368
+ return await response.json();
11369
+ } catch (error) {
11370
+ throw normalizeRequestError(error, opts);
11371
+ } finally {
11372
+ timed.cleanup();
11373
+ }
11374
+ }
11375
+
11376
+ async function apiPost(url, body, opts = {}) {
10371
11377
  const isFormDataBody = typeof FormData !== "undefined" && body instanceof FormData;
10372
11378
  // Keep native FormData for LAN; routedFetch serializes it to multipart
10373
11379
  // bytes only when the request has to travel through the remote relay.
10374
- const response = await routedFetch(url, {
11380
+ const timed = withRequestTimeout({
10375
11381
  method: "POST",
10376
11382
  credentials: "same-origin",
10377
11383
  headers: isFormDataBody
@@ -10383,17 +11389,24 @@ async function apiPost(url, body) {
10383
11389
  Accept: "application/json",
10384
11390
  },
10385
11391
  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;
11392
+ }, opts);
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
+ error.payload = errorInfo.payload ?? null;
11402
+ throw error;
11403
+ }
11404
+ return await response.json();
11405
+ } catch (error) {
11406
+ throw normalizeRequestError(error, opts);
11407
+ } finally {
11408
+ timed.cleanup();
10395
11409
  }
10396
- return response.json();
10397
11410
  }
10398
11411
 
10399
11412
  async function readError(response) {
@@ -10421,6 +11434,7 @@ function localizeApiError(value) {
10421
11434
  "device-not-found": "error.deviceNotFound",
10422
11435
  "web-push-disabled": "error.webPushDisabled",
10423
11436
  "push-subscription-expired": "error.pushSubscriptionExpired",
11437
+ "request-timeout": "error.requestTimedOut",
10424
11438
  "item-not-found": "error.itemNotFound",
10425
11439
  "completion-reply-unavailable": "error.completionReplyUnavailable",
10426
11440
  "completion-reply-thread-advanced": "error.completionReplyThreadAdvanced",