viveworker 0.1.4 → 0.1.6

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
@@ -8,19 +8,25 @@ const TIMELINE_MESSAGE_KINDS = new Set(["user_message", "assistant_commentary",
8
8
  const TIMELINE_OPERATIONAL_KINDS = new Set(["approval", "plan", "plan_ready", "choice", "completion"]);
9
9
  const THREAD_FILTER_INTERACTION_DEFER_MS = 8000;
10
10
  const MAX_COMPLETION_REPLY_IMAGE_COUNT = 4;
11
+ const NOTIFICATION_INTENT_CACHE = "viveworker-notification-intent-v1";
12
+ const NOTIFICATION_INTENT_PATH = "/__viveworker_notification_intent__";
11
13
 
12
14
  const state = {
13
15
  session: null,
14
16
  inbox: null,
15
17
  timeline: null,
16
18
  devices: [],
17
- currentTab: "pending",
19
+ currentTab: "inbox",
18
20
  currentItem: null,
19
21
  currentDetail: null,
20
22
  currentDetailLoading: false,
21
23
  detailLoadingItem: null,
22
24
  detailOpen: false,
25
+ inboxSubtab: "pending",
23
26
  timelineThreadFilter: "all",
27
+ timelineKindFilter: "all",
28
+ timelineKindFilterOpen: false,
29
+ diffThreadFilter: "all",
24
30
  completedThreadFilter: "all",
25
31
  settingsSubpage: "",
26
32
  settingsScrollState: null,
@@ -32,6 +38,7 @@ const state = {
32
38
  listScrollState: null,
33
39
  pendingListScrollRestore: false,
34
40
  threadFilterInteractionUntilMs: 0,
41
+ diffThreadExpandedFiles: {},
35
42
  choiceLocalDrafts: {},
36
43
  completionReplyDrafts: {},
37
44
  pairError: "",
@@ -86,6 +93,9 @@ async function boot() {
86
93
  await registerServiceWorker();
87
94
  navigator.serviceWorker?.addEventListener("message", handleServiceWorkerMessage);
88
95
  window.addEventListener("resize", handleViewportChange, { passive: true });
96
+ window.addEventListener("focus", handlePotentialExternalNavigation, { passive: true });
97
+ window.addEventListener("pageshow", handlePotentialExternalNavigation, { passive: true });
98
+ document.addEventListener("visibilitychange", handleDocumentVisibilityChange);
89
99
 
90
100
  await refreshSession();
91
101
 
@@ -107,6 +117,9 @@ async function boot() {
107
117
  if (parsedInitialItem) {
108
118
  state.currentItem = parsedInitialItem;
109
119
  state.currentTab = tabForItemKind(parsedInitialItem.kind, state.currentTab);
120
+ if (state.currentTab === "inbox") {
121
+ state.inboxSubtab = inboxSubtabForItemKind(parsedInitialItem.kind);
122
+ }
110
123
  state.detailOpen = true;
111
124
  if (isFastPathItemRef(parsedInitialItem)) {
112
125
  state.launchItemIntent = {
@@ -121,6 +134,7 @@ async function boot() {
121
134
  return;
122
135
  }
123
136
 
137
+ await consumePendingNotificationIntent();
124
138
  await syncDetectedLocalePreference();
125
139
  await refreshAuthenticatedState();
126
140
  ensureCurrentSelection();
@@ -130,6 +144,10 @@ async function boot() {
130
144
  if (!state.session?.authenticated) {
131
145
  return;
132
146
  }
147
+ const consumedNotificationIntent = await consumePendingNotificationIntent();
148
+ if (consumedNotificationIntent) {
149
+ return;
150
+ }
133
151
  await refreshAuthenticatedState();
134
152
  if (!shouldDeferRenderForActiveInteraction()) {
135
153
  await renderShell();
@@ -308,12 +326,15 @@ async function getClientPushState() {
308
326
 
309
327
  async function refreshInbox() {
310
328
  state.inbox = await apiGet("/api/inbox");
329
+ syncDiffThreadFilter();
311
330
  syncCompletedThreadFilter();
331
+ syncInboxSubtab();
312
332
  }
313
333
 
314
334
  async function refreshTimeline() {
315
335
  state.timeline = await apiGet("/api/timeline");
316
336
  syncTimelineThreadFilter();
337
+ syncTimelineKindFilter();
317
338
  }
318
339
 
319
340
  async function refreshDevices() {
@@ -378,6 +399,7 @@ function allInboxEntries() {
378
399
  }
379
400
  return [
380
401
  ...state.inbox.pending.map((item) => ({ item, status: "pending" })),
402
+ ...(Array.isArray(state.inbox.diff) ? state.inbox.diff.map((item) => ({ item, status: "diff" })) : []),
381
403
  ...state.inbox.completed.map((item) => ({ item, status: "completed" })),
382
404
  ];
383
405
  }
@@ -399,14 +421,14 @@ function listEntriesForTab(tab) {
399
421
  return [];
400
422
  }
401
423
  }
402
- if (tab === "pending") {
403
- return state.inbox.pending.map((item) => ({ item, status: "pending" }));
424
+ if (tab === "inbox") {
425
+ return listInboxEntries();
404
426
  }
405
427
  if (tab === "timeline") {
406
428
  return filteredTimelineEntries().map((item) => ({ item, status: "timeline" }));
407
429
  }
408
- if (tab === "completed") {
409
- return filteredCompletedEntries().map((item) => ({ item, status: "completed" }));
430
+ if (tab === "diff") {
431
+ return filteredDiffEntries().map((item) => ({ item, status: "diff" }));
410
432
  }
411
433
  return [];
412
434
  }
@@ -415,15 +437,29 @@ function listEntriesForCurrentTab() {
415
437
  return listEntriesForTab(state.currentTab);
416
438
  }
417
439
 
440
+ function listInboxEntries() {
441
+ if (!state.inbox) {
442
+ return [];
443
+ }
444
+ if (state.inboxSubtab === "completed") {
445
+ return filteredCompletedEntries().map((item) => ({ item, status: "completed" }));
446
+ }
447
+ return state.inbox.pending.map((item) => ({ item, status: "pending" }));
448
+ }
449
+
418
450
  function filteredTimelineEntries() {
419
451
  const entries = Array.isArray(state.timeline?.entries) ? state.timeline.entries : [];
420
452
  if (!entries.length) {
421
453
  return [];
422
454
  }
423
- if (!state.timelineThreadFilter || state.timelineThreadFilter === "all") {
424
- return entries;
455
+ let filtered = entries;
456
+ if (state.timelineThreadFilter && state.timelineThreadFilter !== "all") {
457
+ filtered = filtered.filter((entry) => entry.threadId === state.timelineThreadFilter);
458
+ }
459
+ if (!state.timelineKindFilter || state.timelineKindFilter === "all") {
460
+ return filtered;
425
461
  }
426
- return entries.filter((entry) => entry.threadId === state.timelineThreadFilter);
462
+ return filtered.filter((entry) => timelineEntryMatchesKindFilter(entry, state.timelineKindFilter));
427
463
  }
428
464
 
429
465
  function filteredCompletedEntries() {
@@ -437,6 +473,14 @@ function filteredCompletedEntries() {
437
473
  return entries.filter((entry) => entry.threadId === state.completedThreadFilter);
438
474
  }
439
475
 
476
+ function filteredDiffEntries() {
477
+ const entries = Array.isArray(state.inbox?.diff) ? state.inbox.diff : [];
478
+ if (!entries.length) {
479
+ return [];
480
+ }
481
+ return entries.slice();
482
+ }
483
+
440
484
  function syncTimelineThreadFilter() {
441
485
  const threads = Array.isArray(state.timeline?.threads) ? state.timeline.threads : [];
442
486
  if (!state.timelineThreadFilter || state.timelineThreadFilter === "all") {
@@ -448,6 +492,52 @@ function syncTimelineThreadFilter() {
448
492
  }
449
493
  }
450
494
 
495
+ function syncTimelineKindFilter() {
496
+ const validIds = new Set(timelineKindFilterOptions().map((option) => option.id));
497
+ if (!state.timelineKindFilter || !validIds.has(state.timelineKindFilter)) {
498
+ state.timelineKindFilter = "all";
499
+ }
500
+ }
501
+
502
+ function timelineKindFilterOptions() {
503
+ return [
504
+ { id: "all", label: L("timeline.kindFilter.all"), icon: "filter" },
505
+ { id: "messages", label: L("timeline.kindFilter.messages"), icon: "timeline" },
506
+ { id: "files", label: L("timeline.kindFilter.files"), icon: "file-event" },
507
+ { id: "approvals", label: L("timeline.kindFilter.approvals"), icon: "approval" },
508
+ { id: "plans", label: L("timeline.kindFilter.plans"), icon: "plan" },
509
+ { id: "choices", label: L("timeline.kindFilter.choices"), icon: "choice" },
510
+ { id: "completions", label: L("timeline.kindFilter.completions"), icon: "completion-item" },
511
+ ];
512
+ }
513
+
514
+ function currentTimelineKindFilterOption() {
515
+ return (
516
+ timelineKindFilterOptions().find((option) => option.id === state.timelineKindFilter) ||
517
+ timelineKindFilterOptions()[0]
518
+ );
519
+ }
520
+
521
+ function timelineEntryMatchesKindFilter(entry, filterId) {
522
+ const kind = normalizeClientText(entry?.kind || entry?.item?.kind || "");
523
+ switch (filterId) {
524
+ case "messages":
525
+ return TIMELINE_MESSAGE_KINDS.has(kind);
526
+ case "files":
527
+ return kind === "file_event";
528
+ case "approvals":
529
+ return kind === "approval";
530
+ case "plans":
531
+ return kind === "plan" || kind === "plan_ready";
532
+ case "choices":
533
+ return kind === "choice";
534
+ case "completions":
535
+ return kind === "completion";
536
+ default:
537
+ return true;
538
+ }
539
+ }
540
+
451
541
  function completedThreads() {
452
542
  const items = Array.isArray(state.inbox?.completed) ? state.inbox.completed : [];
453
543
  if (!items.length) {
@@ -473,6 +563,42 @@ function completedThreads() {
473
563
  return [...byThread.values()].sort((left, right) => right.latestAtMs - left.latestAtMs);
474
564
  }
475
565
 
566
+ function diffThreads() {
567
+ const items = Array.isArray(state.inbox?.diff) ? state.inbox.diff : [];
568
+ if (!items.length) {
569
+ return [];
570
+ }
571
+ const byThread = new Map();
572
+ for (const item of items) {
573
+ const threadId = normalizeClientText(item.threadId || "");
574
+ if (!threadId) {
575
+ continue;
576
+ }
577
+ const latestAtMs = Number(item.createdAtMs || 0);
578
+ const label = resolvedThreadLabel(threadId, item.threadLabel || "");
579
+ const previous = byThread.get(threadId);
580
+ if (!previous || latestAtMs >= previous.latestAtMs) {
581
+ byThread.set(threadId, {
582
+ id: threadId,
583
+ label,
584
+ latestAtMs,
585
+ });
586
+ }
587
+ }
588
+ return [...byThread.values()].sort((left, right) => right.latestAtMs - left.latestAtMs);
589
+ }
590
+
591
+ function syncDiffThreadFilter() {
592
+ const threads = diffThreads();
593
+ if (!state.diffThreadFilter || state.diffThreadFilter === "all") {
594
+ state.diffThreadFilter = "all";
595
+ return;
596
+ }
597
+ if (!threads.some((thread) => thread.id === state.diffThreadFilter)) {
598
+ state.diffThreadFilter = "all";
599
+ }
600
+ }
601
+
476
602
  function syncCompletedThreadFilter() {
477
603
  const threads = completedThreads();
478
604
  if (!state.completedThreadFilter || state.completedThreadFilter === "all") {
@@ -484,6 +610,14 @@ function syncCompletedThreadFilter() {
484
610
  }
485
611
  }
486
612
 
613
+ function syncInboxSubtab() {
614
+ if (state.inboxSubtab === "completed") {
615
+ state.inboxSubtab = "completed";
616
+ return;
617
+ }
618
+ state.inboxSubtab = "pending";
619
+ }
620
+
487
621
  function renderPair() {
488
622
  const shouldInstallFromHomeScreen = Boolean(initialPairToken) && !shouldAutoPairFromBootstrapToken();
489
623
  app.innerHTML = `
@@ -706,7 +840,7 @@ function shouldDeferRenderForActiveInteraction() {
706
840
  }
707
841
  if (
708
842
  activeElement instanceof HTMLSelectElement &&
709
- activeElement.matches("[data-timeline-thread-select], [data-completed-thread-select]")
843
+ activeElement.matches("[data-timeline-thread-select], [data-diff-thread-select], [data-completed-thread-select]")
710
844
  ) {
711
845
  return true;
712
846
  }
@@ -1269,11 +1403,16 @@ function renderMobileWorkspace(detail) {
1269
1403
  }
1270
1404
 
1271
1405
  function renderListPanel({ tab, entries, desktop }) {
1406
+ if (tab === "inbox") {
1407
+ return renderInboxPanel({ entries, desktop });
1408
+ }
1272
1409
  if (tab === "timeline") {
1273
1410
  return renderTimelinePanel({ entries, desktop });
1274
1411
  }
1412
+ if (tab === "diff") {
1413
+ return renderDiffPanel({ entries, desktop });
1414
+ }
1275
1415
  const meta = tabMeta(tab);
1276
- const threadFilterHtml = tab === "completed" ? renderCompletedThreadDropdown() : "";
1277
1416
  if (!desktop) {
1278
1417
  return `
1279
1418
  <div class="screen-shell screen-shell--mobile">
@@ -1281,7 +1420,6 @@ function renderListPanel({ tab, entries, desktop }) {
1281
1420
  <p class="screen-copy">${escapeHtml(meta.description)}</p>
1282
1421
  <span class="count-chip">${entries.length}</span>
1283
1422
  </div>
1284
- ${threadFilterHtml}
1285
1423
  ${
1286
1424
  entries.length
1287
1425
  ? `<div class="card-list">
@@ -1303,7 +1441,6 @@ function renderListPanel({ tab, entries, desktop }) {
1303
1441
  <span class="count-chip">${entries.length}</span>
1304
1442
  </div>
1305
1443
  <p class="screen-copy">${escapeHtml(meta.description)}</p>
1306
- ${threadFilterHtml}
1307
1444
  ${
1308
1445
  entries.length
1309
1446
  ? `<div class="card-list ${desktop ? "card-list--desktop" : ""}">
@@ -1315,6 +1452,96 @@ function renderListPanel({ tab, entries, desktop }) {
1315
1452
  `;
1316
1453
  }
1317
1454
 
1455
+ function renderInboxPanel({ entries, desktop }) {
1456
+ const meta = tabMeta("inbox");
1457
+ const subtabControls = renderInboxSubtabs();
1458
+ const threadFilterHtml = state.inboxSubtab === "completed" ? renderCompletedThreadDropdown() : "";
1459
+ const bodyHtml = entries.length
1460
+ ? `<div class="card-list ${desktop ? "card-list--desktop" : ""}">
1461
+ ${entries.map((entry) => renderItemCard(entry, "inbox", desktop)).join("")}
1462
+ </div>`
1463
+ : renderInboxEmptyState();
1464
+
1465
+ if (!desktop) {
1466
+ return `
1467
+ <div class="screen-shell screen-shell--mobile">
1468
+ <div class="screen-header screen-header--mobile">
1469
+ <p class="screen-copy">${escapeHtml(meta.description)}</p>
1470
+ <span class="count-chip">${entries.length}</span>
1471
+ </div>
1472
+ ${subtabControls}
1473
+ ${threadFilterHtml}
1474
+ ${bodyHtml}
1475
+ </div>
1476
+ `;
1477
+ }
1478
+
1479
+ return `
1480
+ <div class="screen-shell">
1481
+ <div class="screen-header">
1482
+ <div>
1483
+ <p class="screen-eyebrow">${escapeHtml(meta.eyebrow)}</p>
1484
+ <h2 class="screen-title">${escapeHtml(meta.title)}</h2>
1485
+ </div>
1486
+ <span class="count-chip">${entries.length}</span>
1487
+ </div>
1488
+ <p class="screen-copy">${escapeHtml(meta.description)}</p>
1489
+ ${subtabControls}
1490
+ ${threadFilterHtml}
1491
+ ${bodyHtml}
1492
+ </div>
1493
+ `;
1494
+ }
1495
+
1496
+ function renderInboxSubtabs() {
1497
+ const pendingCount = pendingInboxCount();
1498
+ return `
1499
+ <div class="inbox-subtabs" role="tablist" aria-label="${escapeHtml(tabMeta("inbox").title)}">
1500
+ ${inboxSubtabOptions()
1501
+ .map(
1502
+ (option) => {
1503
+ const showPendingBadge = option.id === "pending" && pendingCount > 0;
1504
+ const badgeHtml = showPendingBadge
1505
+ ? `<span class="inbox-subtabs__badge" aria-hidden="true">${pendingCount}</span>`
1506
+ : "";
1507
+ const ariaLabel = showPendingBadge ? `${option.label} (${pendingCount})` : option.label;
1508
+ return `
1509
+ <button
1510
+ type="button"
1511
+ class="inbox-subtabs__button ${state.inboxSubtab === option.id ? "is-active" : ""}"
1512
+ data-inbox-subtab="${escapeHtml(option.id)}"
1513
+ role="tab"
1514
+ aria-selected="${state.inboxSubtab === option.id ? "true" : "false"}"
1515
+ aria-label="${escapeHtml(ariaLabel)}"
1516
+ >
1517
+ <span class="inbox-subtabs__button-label">${escapeHtml(option.label)}</span>
1518
+ ${badgeHtml}
1519
+ </button>
1520
+ `
1521
+ }
1522
+ )
1523
+ .join("")}
1524
+ </div>
1525
+ `;
1526
+ }
1527
+
1528
+ function inboxSubtabOptions() {
1529
+ return [
1530
+ { id: "pending", label: L("inbox.subtab.pending") },
1531
+ { id: "completed", label: L("inbox.subtab.completed") },
1532
+ ];
1533
+ }
1534
+
1535
+ function renderInboxEmptyState() {
1536
+ const isCompletedView = state.inboxSubtab === "completed";
1537
+ return `
1538
+ <div class="empty-state">
1539
+ <p class="empty-state__title">${escapeHtml(L(isCompletedView ? "inbox.subtab.completed" : "inbox.subtab.pending"))}</p>
1540
+ <p class="muted">${escapeHtml(L(isCompletedView ? "empty.completed" : "empty.pending"))}</p>
1541
+ </div>
1542
+ `;
1543
+ }
1544
+
1318
1545
  function renderItemCard(entry, sourceTab, desktop) {
1319
1546
  if (entry.status === "completed" && entry.item.kind === "completion") {
1320
1547
  return renderCompletedCompletionCard(entry, sourceTab);
@@ -1398,6 +1625,7 @@ function renderCompletedCompletionCard(entry, sourceTab) {
1398
1625
  data-open-item-kind="${escapeHtml(item.kind)}"
1399
1626
  data-open-item-token="${escapeHtml(item.token)}"
1400
1627
  data-source-tab="${escapeHtml(sourceTab)}"
1628
+ data-source-subtab="completed"
1401
1629
  >
1402
1630
  <div class="item-card__header">
1403
1631
  <div class="item-card__meta">
@@ -1453,12 +1681,47 @@ function renderTimelinePanel({ entries, desktop }) {
1453
1681
  `;
1454
1682
  }
1455
1683
 
1684
+ function renderDiffPanel({ entries, desktop }) {
1685
+ const meta = tabMeta("diff");
1686
+ const listClassName = desktop ? "diff-list diff-list--desktop" : "diff-list";
1687
+ const bodyHtml = entries.length
1688
+ ? `<div class="${listClassName}">${entries.map((entry) => renderDiffEntry(entry)).join("")}</div>`
1689
+ : renderEmptyList("diff");
1690
+
1691
+ if (!desktop) {
1692
+ return `
1693
+ <div class="screen-shell screen-shell--mobile diff-shell diff-shell--mobile">
1694
+ <div class="screen-header screen-header--mobile">
1695
+ <p class="screen-copy">${escapeHtml(meta.description)}</p>
1696
+ <span class="count-chip">${entries.length}</span>
1697
+ </div>
1698
+ ${bodyHtml}
1699
+ </div>
1700
+ `;
1701
+ }
1702
+
1703
+ return `
1704
+ <div class="screen-shell diff-shell">
1705
+ <div class="screen-header">
1706
+ <div>
1707
+ <p class="screen-eyebrow">${escapeHtml(meta.eyebrow)}</p>
1708
+ <h2 class="screen-title">${escapeHtml(meta.title)}</h2>
1709
+ </div>
1710
+ <span class="count-chip">${entries.length}</span>
1711
+ </div>
1712
+ <p class="screen-copy">${escapeHtml(meta.description)}</p>
1713
+ ${bodyHtml}
1714
+ </div>
1715
+ `;
1716
+ }
1717
+
1456
1718
  function renderTimelineThreadDropdown() {
1457
1719
  const threads = Array.isArray(state.timeline?.threads) ? state.timeline.threads : [];
1458
1720
  return renderThreadDropdown({
1459
1721
  inputId: "timeline-thread-select",
1460
1722
  dataAttribute: "data-timeline-thread-select",
1461
1723
  selectedThreadId: state.timelineThreadFilter,
1724
+ controlsHtml: renderTimelineKindFilterControls(),
1462
1725
  threads: threads.map((thread) => ({
1463
1726
  id: thread.id,
1464
1727
  label: dropdownThreadLabel(thread.id, thread.label || ""),
@@ -1475,7 +1738,16 @@ function renderCompletedThreadDropdown() {
1475
1738
  });
1476
1739
  }
1477
1740
 
1478
- function renderThreadDropdown({ inputId, dataAttribute, selectedThreadId, threads }) {
1741
+ function renderDiffThreadDropdown() {
1742
+ return renderThreadDropdown({
1743
+ inputId: "diff-thread-select",
1744
+ dataAttribute: "data-diff-thread-select",
1745
+ selectedThreadId: state.diffThreadFilter,
1746
+ threads: diffThreads(),
1747
+ });
1748
+ }
1749
+
1750
+ function renderThreadDropdown({ inputId, dataAttribute, selectedThreadId, threads, controlsHtml = "" }) {
1479
1751
  const options = [
1480
1752
  {
1481
1753
  id: "all",
@@ -1490,38 +1762,88 @@ function renderThreadDropdown({ inputId, dataAttribute, selectedThreadId, thread
1490
1762
  return `
1491
1763
  <div class="timeline-thread-filter">
1492
1764
  <label class="timeline-thread-filter__label" for="${escapeHtml(inputId)}">${escapeHtml(L("timeline.filterLabel"))}</label>
1493
- <div class="timeline-thread-select-wrap">
1494
- <select id="${escapeHtml(inputId)}" class="timeline-thread-select" ${dataAttribute}>
1495
- ${options
1496
- .map(
1497
- (thread) => `
1498
- <option value="${escapeHtml(thread.id)}" ${selectedThreadId === thread.id ? "selected" : ""}>
1499
- ${escapeHtml(thread.label)}
1500
- </option>
1501
- `
1502
- )
1503
- .join("")}
1504
- </select>
1505
- <span class="timeline-thread-select__chevron" aria-hidden="true">${renderIcon("chevron-down")}</span>
1765
+ <div class="timeline-thread-filter__row">
1766
+ <div class="timeline-thread-select-wrap">
1767
+ <select id="${escapeHtml(inputId)}" class="timeline-thread-select" ${dataAttribute}>
1768
+ ${options
1769
+ .map(
1770
+ (thread) => `
1771
+ <option value="${escapeHtml(thread.id)}" ${selectedThreadId === thread.id ? "selected" : ""}>
1772
+ ${escapeHtml(thread.label)}
1773
+ </option>
1774
+ `
1775
+ )
1776
+ .join("")}
1777
+ </select>
1778
+ <span class="timeline-thread-select__chevron" aria-hidden="true">${renderIcon("chevron-down")}</span>
1779
+ </div>
1780
+ ${controlsHtml}
1506
1781
  </div>
1507
1782
  </div>
1508
1783
  `;
1509
1784
  }
1510
1785
 
1786
+ function renderTimelineKindFilterControls() {
1787
+ const current = currentTimelineKindFilterOption();
1788
+ const options = timelineKindFilterOptions();
1789
+ return `
1790
+ <div class="timeline-kind-filter" data-timeline-kind-filter-root>
1791
+ <button
1792
+ type="button"
1793
+ class="timeline-kind-filter__button ${current.id !== "all" ? "is-active" : ""}"
1794
+ data-timeline-kind-filter-toggle
1795
+ aria-expanded="${state.timelineKindFilterOpen ? "true" : "false"}"
1796
+ aria-label="${escapeHtml(L("timeline.kindFilterButtonLabel"))}"
1797
+ >
1798
+ <span class="timeline-kind-filter__button-icon" aria-hidden="true">${renderIcon(current.icon)}</span>
1799
+ </button>
1800
+ ${
1801
+ state.timelineKindFilterOpen
1802
+ ? `
1803
+ <div class="timeline-kind-filter__popover" role="menu" aria-label="${escapeHtml(L("timeline.kindFilterLabel"))}">
1804
+ ${options
1805
+ .map(
1806
+ (option) => `
1807
+ <button
1808
+ type="button"
1809
+ class="timeline-kind-filter__option ${option.id === current.id ? "is-selected" : ""}"
1810
+ data-timeline-kind-filter-option="${escapeHtml(option.id)}"
1811
+ role="menuitemradio"
1812
+ aria-checked="${option.id === current.id ? "true" : "false"}"
1813
+ >
1814
+ <span class="timeline-kind-filter__option-icon" aria-hidden="true">${renderIcon(option.icon)}</span>
1815
+ <span class="timeline-kind-filter__option-label">${escapeHtml(option.label)}</span>
1816
+ <span class="timeline-kind-filter__option-check" aria-hidden="true">${
1817
+ option.id === current.id ? renderIcon("check") : ""
1818
+ }</span>
1819
+ </button>
1820
+ `
1821
+ )
1822
+ .join("")}
1823
+ </div>
1824
+ `
1825
+ : ""
1826
+ }
1827
+ </div>
1828
+ `;
1829
+ }
1830
+
1511
1831
  function renderTimelineEntry(entry, { desktop }) {
1512
1832
  const item = entry.item;
1513
1833
  const kindInfo = kindMeta(item.kind);
1514
1834
  const kindClassName = escapeHtml(kindInfo.tone || "neutral");
1515
1835
  const kindNameClass = escapeHtml(String(item.kind || "item").replace(/_/gu, "-"));
1516
1836
  const isMessageLike = TIMELINE_MESSAGE_KINDS.has(item.kind) || item.kind === "completion";
1837
+ const isFileEvent = item.kind === "file_event";
1517
1838
  const imageUrls = Array.isArray(item.imageUrls) ? item.imageUrls.filter(Boolean) : [];
1518
- const primaryText = isMessageLike
1519
- ? item.summary || fallbackSummaryForKind(item.kind, entry.status)
1520
- : item.title || L("common.untitledItem");
1521
- const secondaryText = isMessageLike ? "" : item.summary || fallbackSummaryForKind(item.kind, entry.status);
1839
+ const fileRefs = normalizeClientFileRefs(item.fileRefs);
1840
+ const primaryText = timelineEntryPrimaryText(item, entry.status, { isMessageLike, isFileEvent });
1841
+ const secondaryText = timelineEntrySecondaryText(item, entry.status, primaryText, { isMessageLike, isFileEvent });
1522
1842
  const threadLabel = timelineEntryThreadLabel(item, isMessageLike);
1523
1843
  const timestampLabel = formatTimelineTimestamp(item.createdAtMs);
1524
- const statusLabel = isMessageLike ? "" : L("common.actionNeeded");
1844
+ const statusLabel = timelineEntryStatusLabel(item, isMessageLike);
1845
+ const fileEventFileSummary = isFileEvent ? timelineFileEventFileSummary(item) : "";
1846
+ const fileEventDiffStatsHtml = isFileEvent ? renderDiffEntryStatsHtml(item) : "";
1525
1847
 
1526
1848
  return `
1527
1849
  <button
@@ -1544,49 +1866,415 @@ function renderTimelineEntry(entry, { desktop }) {
1544
1866
  <div class="timeline-entry__body">
1545
1867
  <p class="timeline-entry__title">${escapeHtml(primaryText)}</p>
1546
1868
  ${secondaryText ? `<p class="timeline-entry__summary">${escapeHtml(secondaryText)}</p>` : ""}
1869
+ ${
1870
+ isFileEvent && fileEventFileSummary
1871
+ ? `<p class="timeline-entry__file-summary" title="${escapeHtml(
1872
+ normalizeClientFileRefs(item.fileRefs).join("\n")
1873
+ )}">${escapeHtml(fileEventFileSummary)}</p>`
1874
+ : ""
1875
+ }
1876
+ ${isFileEvent && fileEventDiffStatsHtml ? `<div class="timeline-entry__file-diff-stats diff-entry__stats">${fileEventDiffStatsHtml}</div>` : ""}
1547
1877
  ${renderTimelineEntryImageStrip(imageUrls)}
1878
+ ${isFileEvent ? "" : renderTimelineEntryFileStrip(fileRefs)}
1548
1879
  </div>
1549
1880
  ${statusLabel ? `<div class="timeline-entry__footer"><span class="timeline-entry__status">${escapeHtml(statusLabel)}</span></div>` : ""}
1550
1881
  </button>
1551
1882
  `;
1552
1883
  }
1553
1884
 
1554
- function renderTimelineEntryImageStrip(imageUrls) {
1555
- if (!Array.isArray(imageUrls) || imageUrls.length === 0) {
1556
- return "";
1885
+ function renderDiffEntry(entry) {
1886
+ const item = entry.item;
1887
+ const threadLabel = diffThreadCardTitle(item);
1888
+ const fileChipsHtml = renderDiffEntryFileChips(item);
1889
+ const fallbackSummary = fileChipsHtml ? "" : diffThreadSummaryLabel(item);
1890
+ const statsHtml = renderDiffEntryStatsHtml(item);
1891
+ const latestChangeSummary = diffThreadLatestChangeSummary(item);
1892
+
1893
+ return `
1894
+ <button
1895
+ class="diff-entry diff-entry--thread"
1896
+ data-open-item-kind="${escapeHtml(item.kind)}"
1897
+ data-open-item-token="${escapeHtml(item.token)}"
1898
+ data-source-tab="diff"
1899
+ >
1900
+ <div class="diff-entry__header">
1901
+ <p class="timeline-entry__thread diff-entry__thread">${escapeHtml(threadLabel)}</p>
1902
+ <span class="diff-entry__chevron" aria-hidden="true">${renderIcon("chevron-right")}</span>
1903
+ </div>
1904
+ <div class="diff-entry__body">
1905
+ ${fileChipsHtml ? `<div class="diff-entry__files">${fileChipsHtml}</div>` : ""}
1906
+ ${fallbackSummary ? `<p class="diff-entry__title">${escapeHtml(fallbackSummary)}</p>` : ""}
1907
+ ${statsHtml ? `<div class="diff-entry__stats">${statsHtml}</div>` : ""}
1908
+ ${latestChangeSummary ? `<p class="diff-entry__summary">${escapeHtml(latestChangeSummary)}</p>` : ""}
1909
+ </div>
1910
+ </button>
1911
+ `;
1912
+ }
1913
+
1914
+ function timelineEntryStatusLabel(item, isMessageLike) {
1915
+ if (isMessageLike || item?.kind === "file_event") {
1916
+ return "";
1917
+ }
1918
+
1919
+ const outcome = normalizeClientText(item?.outcome || "");
1920
+ switch (outcome) {
1921
+ case "pending":
1922
+ return L("common.actionNeeded");
1923
+ case "approved":
1924
+ return L("timeline.status.approved");
1925
+ case "rejected":
1926
+ return L("timeline.status.rejected");
1927
+ case "implemented":
1928
+ return L("timeline.status.implemented");
1929
+ case "dismissed":
1930
+ return L("timeline.status.dismissed");
1931
+ case "submitted":
1932
+ return L("timeline.status.submitted");
1933
+ default:
1934
+ break;
1935
+ }
1936
+
1937
+ if (item?.kind === "approval" || item?.kind === "plan" || item?.kind === "plan_ready" || item?.kind === "choice") {
1938
+ return L("common.actionNeeded");
1939
+ }
1940
+ return "";
1941
+ }
1942
+
1943
+ function renderTimelineEntryImageStrip(imageUrls) {
1944
+ if (!Array.isArray(imageUrls) || imageUrls.length === 0) {
1945
+ return "";
1946
+ }
1947
+
1948
+ return `
1949
+ <div class="timeline-entry__images" aria-hidden="true">
1950
+ ${imageUrls
1951
+ .slice(0, 4)
1952
+ .map(
1953
+ (imageUrl, index) => `
1954
+ <span class="timeline-entry__image-frame">
1955
+ <img
1956
+ class="timeline-entry__image"
1957
+ src="${escapeHtml(imageUrl)}"
1958
+ alt="${escapeHtml(L("detail.imageAlt", { index: index + 1 }))}"
1959
+ loading="lazy"
1960
+ >
1961
+ </span>
1962
+ `
1963
+ )
1964
+ .join("")}
1965
+ </div>
1966
+ `;
1967
+ }
1968
+
1969
+ function renderTimelineEntryFileStrip(fileRefs) {
1970
+ if (!Array.isArray(fileRefs) || fileRefs.length === 0) {
1971
+ return "";
1972
+ }
1973
+
1974
+ return `
1975
+ <div class="timeline-entry__files" aria-label="${escapeHtml(L("detail.filesTitle"))}">
1976
+ ${fileRefs
1977
+ .slice(0, 4)
1978
+ .map(
1979
+ (fileRef) => `
1980
+ <span class="file-ref-chip" title="${escapeHtml(fileRef)}">
1981
+ <span class="file-ref-chip__icon" aria-hidden="true">${renderIcon("item")}</span>
1982
+ <span class="file-ref-chip__label">${escapeHtml(fileRefLabel(fileRef))}</span>
1983
+ </span>
1984
+ `
1985
+ )
1986
+ .join("")}
1987
+ </div>
1988
+ `;
1989
+ }
1990
+
1991
+ function timelineEntryThreadLabel(item, isMessage) {
1992
+ const threadLabel = resolvedThreadLabel(item.threadId || "", item.threadLabel || "");
1993
+ return threadLabel || "";
1994
+ }
1995
+
1996
+ function timelineEntryPrimaryText(item, status, { isMessageLike = false, isFileEvent = false } = {}) {
1997
+ if (isMessageLike) {
1998
+ return item.summary || fallbackSummaryForKind(item.kind, status);
1999
+ }
2000
+
2001
+ if (isFileEvent) {
2002
+ return fileEventTimelineCountLabel(item) || fallbackSummaryForKind(item.kind, status);
2003
+ }
2004
+
2005
+ return timelineDisplayTitleWithoutThread(item, { allowFallbackSummary: true }) || L("common.untitledItem");
2006
+ }
2007
+
2008
+ function timelineEntrySecondaryText(item, status, primaryText, { isMessageLike = false, isFileEvent = false } = {}) {
2009
+ if (isMessageLike) {
2010
+ return "";
2011
+ }
2012
+
2013
+ const summaryText = normalizeClientText(item.summary || fallbackSummaryForKind(item.kind, status));
2014
+ if (!summaryText || summaryText === normalizeClientText(primaryText || "")) {
2015
+ return "";
2016
+ }
2017
+
2018
+ if (isFileEvent) {
2019
+ return "";
2020
+ }
2021
+
2022
+ const compactTitle = timelineDisplayTitleWithoutThread(item, { allowFallbackSummary: false });
2023
+ return compactTitle ? summaryText : "";
2024
+ }
2025
+
2026
+ function timelineDisplayTitleWithoutThread(item, { allowFallbackSummary = false } = {}) {
2027
+ const rawTitle = normalizeClientText(item?.title || "");
2028
+ const threadLabel = resolvedThreadLabel(item?.threadId || "", item?.threadLabel || "");
2029
+ if (!rawTitle) {
2030
+ return allowFallbackSummary ? normalizeClientText(item?.summary || "") : "";
2031
+ }
2032
+
2033
+ let displayTitle = rawTitle;
2034
+ const removablePrefixes = timelineGeneratedTitlePrefixes();
2035
+ const hadGeneratedPrefix = removablePrefixes.some((prefix) => {
2036
+ const normalizedPrefix = normalizeClientText(prefix || "");
2037
+ return normalizedPrefix && rawTitle.startsWith(`${normalizedPrefix} | `);
2038
+ });
2039
+
2040
+ for (const prefix of removablePrefixes) {
2041
+ const normalizedPrefix = normalizeClientText(prefix || "");
2042
+ if (normalizedPrefix && displayTitle.startsWith(`${normalizedPrefix} | `)) {
2043
+ displayTitle = normalizeClientText(displayTitle.slice(normalizedPrefix.length + 3));
2044
+ break;
2045
+ }
2046
+ }
2047
+
2048
+ if (hadGeneratedPrefix) {
2049
+ if (!displayTitle || (threadLabel && displayTitle === threadLabel)) {
2050
+ return allowFallbackSummary ? normalizeClientText(item?.summary || "") : "";
2051
+ }
2052
+ }
2053
+
2054
+ if (threadLabel && displayTitle === threadLabel) {
2055
+ return allowFallbackSummary ? normalizeClientText(item?.summary || "") : "";
2056
+ }
2057
+
2058
+ return displayTitle || (allowFallbackSummary ? normalizeClientText(item?.summary || "") : "");
2059
+ }
2060
+
2061
+ function timelineGeneratedTitlePrefixes() {
2062
+ return [
2063
+ kindMeta("approval").label,
2064
+ kindMeta("plan").label,
2065
+ kindMeta("choice").label,
2066
+ kindMeta("completion").label,
2067
+ kindMeta("user_message").label,
2068
+ kindMeta("assistant_commentary").label,
2069
+ kindMeta("assistant_final").label,
2070
+ L("common.fileEvent"),
2071
+ "Approval",
2072
+ "Plan",
2073
+ "Choice",
2074
+ "Completed",
2075
+ "User message",
2076
+ "Commentary",
2077
+ "Final answer",
2078
+ "Files",
2079
+ "承認",
2080
+ "プラン",
2081
+ "選択",
2082
+ "完了",
2083
+ "メッセージ",
2084
+ "途中経過",
2085
+ "最終回答",
2086
+ "ファイル",
2087
+ ];
2088
+ }
2089
+
2090
+ function fileEventDisplayLabel(fileEventType) {
2091
+ switch (normalizeClientText(fileEventType || "")) {
2092
+ case "read":
2093
+ return L("fileEvent.read");
2094
+ case "write":
2095
+ return L("fileEvent.write");
2096
+ case "create":
2097
+ return L("fileEvent.create");
2098
+ default:
2099
+ return "";
2100
+ }
2101
+ }
2102
+
2103
+ function fileEventTimelineCountLabel(item) {
2104
+ const fileEventType = normalizeClientText(item?.fileEventType || "");
2105
+ const count = normalizeClientFileRefs(item?.fileRefs).length;
2106
+ if (count <= 0) {
2107
+ return fileEventDisplayLabel(fileEventType) || L("common.fileEvent");
2108
+ }
2109
+ switch (fileEventType) {
2110
+ case "read":
2111
+ return L("fileEvent.timeline.read", { count });
2112
+ case "write":
2113
+ return L("fileEvent.timeline.write", { count });
2114
+ case "create":
2115
+ return L("fileEvent.timeline.create", { count });
2116
+ default:
2117
+ return L("common.fileEvent");
2118
+ }
2119
+ }
2120
+
2121
+ function diffThreadSummaryLabel(item) {
2122
+ const count = Math.max(0, Number(item?.changedFileCount) || 0);
2123
+ if (count <= 0) {
2124
+ return L("common.diff");
2125
+ }
2126
+ return L("diff.threadSummary", { count });
2127
+ }
2128
+
2129
+ function diffThreadCardTitle(item) {
2130
+ return resolvedThreadLabel(item?.threadId || "", item?.threadLabel || "") || L("timeline.unknownThread");
2131
+ }
2132
+
2133
+ function diffThreadCardSummary(item) {
2134
+ const parts = [];
2135
+ const filesLabel = diffThreadFilesSummary(item);
2136
+ if (filesLabel) {
2137
+ parts.push(filesLabel);
2138
+ } else {
2139
+ const summaryLabel = diffThreadSummaryLabel(item);
2140
+ if (summaryLabel && summaryLabel !== L("common.diff")) {
2141
+ parts.push(summaryLabel);
2142
+ }
2143
+ }
2144
+ const statsLabel = diffEntryStatsLabel(item);
2145
+ if (statsLabel) {
2146
+ parts.push(statsLabel);
2147
+ }
2148
+ return parts.join(" • ");
2149
+ }
2150
+
2151
+ function diffThreadFilesSummary(item) {
2152
+ const labels = normalizeClientFileRefs(item?.fileRefs)
2153
+ .map((fileRef) => fileRefLabel(fileRef))
2154
+ .filter(Boolean);
2155
+ if (labels.length === 0) {
2156
+ return "";
2157
+ }
2158
+ const visibleLabels = labels.slice(0, 3);
2159
+ const hiddenCount = labels.length - visibleLabels.length;
2160
+ if (hiddenCount > 0) {
2161
+ visibleLabels.push(`+${hiddenCount}`);
2162
+ }
2163
+ return visibleLabels.join(", ");
2164
+ }
2165
+
2166
+ function diffThreadLatestChangeSummary(item) {
2167
+ const prefix = L("diff.latestChange");
2168
+ const timestampLabel = formatDiffCardTimestamp(item?.latestChangedAtMs || item?.createdAtMs);
2169
+ if (timestampLabel) {
2170
+ return `${prefix}: ${timestampLabel}`;
2171
+ }
2172
+ if (Number(item?.latestChangedAtMs) > 0 || Number(item?.createdAtMs) > 0) {
2173
+ return L("diff.latestChangeFallback");
2174
+ }
2175
+ return "";
2176
+ }
2177
+
2178
+ function renderDiffEntryFileChips(item) {
2179
+ const fileRefs = normalizeClientFileRefs(item?.fileRefs);
2180
+ if (fileRefs.length === 0) {
2181
+ return "";
2182
+ }
2183
+ const visibleRefs = fileRefs.slice(0, 4);
2184
+ const hiddenCount = fileRefs.length - visibleRefs.length;
2185
+ const chips = visibleRefs.map(
2186
+ (fileRef) => `
2187
+ <span class="file-ref-chip" title="${escapeHtml(fileRef)}">
2188
+ <span class="file-ref-chip__icon" aria-hidden="true">${renderIcon("item")}</span>
2189
+ <span class="file-ref-chip__label">${escapeHtml(fileRefLabel(fileRef))}</span>
2190
+ </span>
2191
+ `
2192
+ );
2193
+ if (hiddenCount > 0) {
2194
+ chips.push(`
2195
+ <span class="file-ref-chip file-ref-chip--count">
2196
+ <span class="file-ref-chip__label">+${hiddenCount}</span>
2197
+ </span>
2198
+ `);
2199
+ }
2200
+ return chips.join("");
2201
+ }
2202
+
2203
+ function renderDiffEntryStatsHtml(item) {
2204
+ const addedLines = Math.max(0, Number(item?.diffAddedLines ?? item?.addedLines) || 0);
2205
+ const removedLines = Math.max(0, Number(item?.diffRemovedLines ?? item?.removedLines) || 0);
2206
+ if (!addedLines && !removedLines) {
2207
+ return "";
2208
+ }
2209
+ const parts = [];
2210
+ if (addedLines) {
2211
+ parts.push(`<span class="diff-entry__stat diff-entry__stat--added">+${escapeHtml(String(addedLines))}</span>`);
2212
+ }
2213
+ if (removedLines) {
2214
+ parts.push(`<span class="diff-entry__stat diff-entry__stat--removed">-${escapeHtml(String(removedLines))}</span>`);
2215
+ }
2216
+ return parts.join('<span class="diff-entry__stats-separator">/</span>');
2217
+ }
2218
+
2219
+ function formatDiffCardTimestamp(value) {
2220
+ const timestamp = Number(value) || 0;
2221
+ if (!timestamp) {
2222
+ return "";
2223
+ }
2224
+ try {
2225
+ return new Intl.DateTimeFormat(state.locale || DEFAULT_LOCALE, {
2226
+ month: "numeric",
2227
+ day: "numeric",
2228
+ hour: "numeric",
2229
+ minute: "2-digit",
2230
+ }).format(new Date(timestamp));
2231
+ } catch {
2232
+ return new Date(timestamp).toLocaleString();
2233
+ }
2234
+ }
2235
+
2236
+ function diffThreadFileExpansionKey(token, fileRef) {
2237
+ return `${normalizeClientText(token || "")}:${normalizeClientText(fileRef || "")}`;
2238
+ }
2239
+
2240
+ function isDiffThreadFileExpanded(token, fileRef) {
2241
+ const key = diffThreadFileExpansionKey(token, fileRef);
2242
+ return state.diffThreadExpandedFiles?.[key] === true;
2243
+ }
2244
+
2245
+ function toggleDiffThreadFileExpanded(token, fileRef) {
2246
+ const key = diffThreadFileExpansionKey(token, fileRef);
2247
+ if (!key || key === ":") {
2248
+ return;
1557
2249
  }
1558
-
1559
- return `
1560
- <div class="timeline-entry__images" aria-hidden="true">
1561
- ${imageUrls
1562
- .slice(0, 4)
1563
- .map(
1564
- (imageUrl, index) => `
1565
- <span class="timeline-entry__image-frame">
1566
- <img
1567
- class="timeline-entry__image"
1568
- src="${escapeHtml(imageUrl)}"
1569
- alt="${escapeHtml(L("detail.imageAlt", { index: index + 1 }))}"
1570
- loading="lazy"
1571
- >
1572
- </span>
1573
- `
1574
- )
1575
- .join("")}
1576
- </div>
1577
- `;
2250
+ state.diffThreadExpandedFiles = {
2251
+ ...(state.diffThreadExpandedFiles || {}),
2252
+ [key]: !isDiffThreadFileExpanded(token, fileRef),
2253
+ };
1578
2254
  }
1579
2255
 
1580
- function timelineEntryThreadLabel(item, isMessage) {
1581
- const threadLabel = resolvedThreadLabel(item.threadId || "", item.threadLabel || "");
1582
- if (!threadLabel) {
2256
+ function timelineFileEventFileSummary(item) {
2257
+ const labels = normalizeClientFileRefs(item?.fileRefs)
2258
+ .map((fileRef) => fileRefLabel(fileRef))
2259
+ .filter(Boolean);
2260
+ if (labels.length === 0) {
1583
2261
  return "";
1584
2262
  }
1585
- if (isMessage) {
1586
- return threadLabel;
2263
+ const visibleLabels = labels.slice(0, 3);
2264
+ const hiddenCount = labels.length - visibleLabels.length;
2265
+ if (hiddenCount > 0) {
2266
+ visibleLabels.push(`+${hiddenCount}`);
2267
+ }
2268
+ return visibleLabels.join(", ");
2269
+ }
2270
+
2271
+ function diffEntryStatsLabel(item) {
2272
+ const addedLines = Math.max(0, Number(item?.diffAddedLines ?? item?.addedLines) || 0);
2273
+ const removedLines = Math.max(0, Number(item?.diffRemovedLines ?? item?.removedLines) || 0);
2274
+ if (!addedLines && !removedLines) {
2275
+ return "";
1587
2276
  }
1588
- const title = normalizeClientText(item.title || "");
1589
- return title.includes(threadLabel) ? "" : threadLabel;
2277
+ return `+${addedLines} / -${removedLines}`;
1590
2278
  }
1591
2279
 
1592
2280
  function sanitizeThreadLabelForDisplay(label = "", threadId = "") {
@@ -2296,6 +2984,7 @@ function renderDetailContent(detail, { mobile }) {
2296
2984
  function renderStandardDetailDesktop(detail) {
2297
2985
  const kindInfo = kindMeta(detail.kind);
2298
2986
  const spaciousBodyDetail = TIMELINE_MESSAGE_KINDS.has(detail.kind) || detail.kind === "completion";
2987
+ const plainIntro = renderDetailPlainIntro(detail);
2299
2988
  return `
2300
2989
  <div class="detail-shell">
2301
2990
  ${renderDetailMetaRow(detail, kindInfo)}
@@ -2303,10 +2992,19 @@ function renderStandardDetailDesktop(detail) {
2303
2992
  ${detail.readOnly ? "" : renderDetailLead(detail, kindInfo)}
2304
2993
  ${renderPreviousContextCard(detail)}
2305
2994
  ${renderInterruptedDetailNotice(detail)}
2306
- <section class="detail-card detail-card--body ${spaciousBodyDetail ? "detail-card--message-body" : ""}">
2307
- <div class="detail-body ${spaciousBodyDetail ? "detail-body--message " : ""}markdown">${detail.messageHtml || ""}</div>
2308
- </section>
2995
+ ${
2996
+ plainIntro
2997
+ ? plainIntro
2998
+ : `
2999
+ <section class="detail-card detail-card--body ${spaciousBodyDetail ? "detail-card--message-body" : ""}">
3000
+ <div class="detail-body ${spaciousBodyDetail ? "detail-body--message " : ""}markdown">${detail.messageHtml || ""}</div>
3001
+ </section>
3002
+ `
3003
+ }
2309
3004
  ${renderDetailImageGallery(detail)}
3005
+ ${renderDetailDiffPanel(detail)}
3006
+ ${renderDetailDiffThreadGroups(detail)}
3007
+ ${renderDetailFileRefs(detail)}
2310
3008
  ${renderCompletionReplyComposer(detail)}
2311
3009
  ${detail.readOnly ? "" : renderActionButtons(detail.actions || [])}
2312
3010
  </div>
@@ -2316,6 +3014,7 @@ function renderStandardDetailDesktop(detail) {
2316
3014
  function renderStandardDetailMobile(detail) {
2317
3015
  const kindInfo = kindMeta(detail.kind);
2318
3016
  const spaciousBodyDetail = TIMELINE_MESSAGE_KINDS.has(detail.kind) || detail.kind === "completion";
3017
+ const plainIntro = renderDetailPlainIntro(detail, { mobile: true });
2319
3018
  return `
2320
3019
  <div class="mobile-detail-screen">
2321
3020
  <div class="detail-shell detail-shell--mobile">
@@ -2323,11 +3022,20 @@ function renderStandardDetailMobile(detail) {
2323
3022
  ${renderDetailMetaRow(detail, kindInfo, { mobile: true })}
2324
3023
  ${renderPreviousContextCard(detail, { mobile: true })}
2325
3024
  ${renderInterruptedDetailNotice(detail, { mobile: true })}
2326
- <section class="detail-card detail-card--body detail-card--mobile ${spaciousBodyDetail ? "detail-card--message-body" : ""}">
2327
- ${detail.readOnly ? "" : renderDetailLead(detail, kindInfo, { mobile: true })}
2328
- <div class="detail-body ${spaciousBodyDetail ? "detail-body--message " : ""}markdown">${detail.messageHtml || ""}</div>
2329
- </section>
3025
+ ${
3026
+ plainIntro
3027
+ ? plainIntro
3028
+ : `
3029
+ <section class="detail-card detail-card--body detail-card--mobile ${spaciousBodyDetail ? "detail-card--message-body" : ""}">
3030
+ ${detail.readOnly ? "" : renderDetailLead(detail, kindInfo, { mobile: true })}
3031
+ <div class="detail-body ${spaciousBodyDetail ? "detail-body--message " : ""}markdown">${detail.messageHtml || ""}</div>
3032
+ </section>
3033
+ `
3034
+ }
2330
3035
  ${renderDetailImageGallery(detail, { mobile: true })}
3036
+ ${renderDetailDiffPanel(detail, { mobile: true })}
3037
+ ${renderDetailDiffThreadGroups(detail, { mobile: true })}
3038
+ ${renderDetailFileRefs(detail, { mobile: true })}
2331
3039
  ${renderCompletionReplyComposer(detail, { mobile: true })}
2332
3040
  </div>
2333
3041
  ${detail.readOnly ? "" : renderActionButtons(detail.actions || [], { mobileSticky: true })}
@@ -2336,6 +3044,20 @@ function renderStandardDetailMobile(detail) {
2336
3044
  `;
2337
3045
  }
2338
3046
 
3047
+ function renderDetailPlainIntro(detail, options = {}) {
3048
+ if (!["diff_thread", "file_event"].includes(detail?.kind || "")) {
3049
+ return "";
3050
+ }
3051
+ if (!detail?.messageHtml) {
3052
+ return "";
3053
+ }
3054
+ return `
3055
+ <div class="detail-page-copy ${options.mobile ? "detail-page-copy--mobile" : ""} markdown">
3056
+ ${detail.messageHtml}
3057
+ </div>
3058
+ `;
3059
+ }
3060
+
2339
3061
  function renderDetailMetaRow(detail, kindInfo, options = {}) {
2340
3062
  const timestampLabel = detail.createdAtMs ? formatTimelineTimestamp(detail.createdAtMs) : "";
2341
3063
  const progressPill = options.progressLabel
@@ -2435,6 +3157,185 @@ function renderDetailImageGallery(detail, options = {}) {
2435
3157
  `;
2436
3158
  }
2437
3159
 
3160
+ function renderDetailFileRefs(detail, options = {}) {
3161
+ const fileRefs = normalizeClientFileRefs(detail?.fileRefs);
3162
+ if (fileRefs.length === 0) {
3163
+ return "";
3164
+ }
3165
+
3166
+ return `
3167
+ <section class="detail-card detail-card--files ${options.mobile ? "detail-card--mobile" : ""}">
3168
+ <div class="detail-files-card__header">
3169
+ <span class="detail-files-card__icon" aria-hidden="true">${renderIcon("item")}</span>
3170
+ <span>${escapeHtml(L("detail.filesTitle"))}</span>
3171
+ </div>
3172
+ <div class="detail-file-grid">
3173
+ ${fileRefs
3174
+ .map(
3175
+ (fileRef) => `
3176
+ <div class="detail-file-chip" title="${escapeHtml(fileRef)}">
3177
+ <span class="detail-file-chip__label">${escapeHtml(fileRefLabel(fileRef))}</span>
3178
+ <span class="detail-file-chip__path">${escapeHtml(fileRef)}</span>
3179
+ </div>
3180
+ `
3181
+ )
3182
+ .join("")}
3183
+ </div>
3184
+ </section>
3185
+ `;
3186
+ }
3187
+
3188
+ function renderDetailDiffPanel(detail, options = {}) {
3189
+ const detailKind = normalizeClientText(detail?.kind || "");
3190
+ if (!["file_event", "approval"].includes(detailKind)) {
3191
+ return "";
3192
+ }
3193
+
3194
+ const fileEventType = normalizeClientText(detail?.fileEventType || "");
3195
+ if (detailKind === "file_event" && !["write", "create"].includes(fileEventType)) {
3196
+ return "";
3197
+ }
3198
+ if (
3199
+ detailKind === "approval" &&
3200
+ !String(detail?.diffText || "").trim() &&
3201
+ !detail?.diffAvailable &&
3202
+ normalizeClientFileRefs(detail?.fileRefs).length === 0
3203
+ ) {
3204
+ return "";
3205
+ }
3206
+
3207
+ const diffText = String(detail?.diffText || "").replace(/\r\n/g, "\n").trim();
3208
+ const statsHtml = renderDiffEntryStatsHtml(detail);
3209
+
3210
+ return `
3211
+ <section class="detail-card detail-card--diff ${options.mobile ? "detail-card--mobile" : ""}">
3212
+ <div class="detail-diff-card__header">
3213
+ <div class="detail-diff-card__title-wrap">
3214
+ <span class="detail-diff-card__icon" aria-hidden="true">${renderIcon("diff")}</span>
3215
+ <span>${escapeHtml(L("detail.diffTitle"))}</span>
3216
+ </div>
3217
+ ${statsHtml ? `<span class="detail-diff-card__stats diff-entry__stats">${statsHtml}</span>` : ""}
3218
+ </div>
3219
+ ${
3220
+ diffText
3221
+ ? `<div class="detail-diff-viewer">${renderDiffLines(diffText)}</div>`
3222
+ : `<p class="detail-diff-card__notice">${escapeHtml(L("detail.diffUnavailable"))}</p>`
3223
+ }
3224
+ </section>
3225
+ `;
3226
+ }
3227
+
3228
+ function renderDetailDiffThreadGroups(detail, options = {}) {
3229
+ if (detail?.kind !== "diff_thread") {
3230
+ return "";
3231
+ }
3232
+
3233
+ const files = Array.isArray(detail?.files) ? detail.files.filter(Boolean) : [];
3234
+ if (files.length === 0) {
3235
+ return "";
3236
+ }
3237
+
3238
+ return files
3239
+ .map((fileGroup) => renderDetailDiffThreadFileGroup(detail, fileGroup, options))
3240
+ .join("");
3241
+ }
3242
+
3243
+ function renderDetailDiffThreadFileGroup(detail, fileGroup, options = {}) {
3244
+ const fileRef = normalizeClientText(fileGroup?.fileRef || "");
3245
+ const fileLabel = normalizeClientText(fileGroup?.fileLabel || "") || fileRefLabel(fileRef) || L("common.unavailable");
3246
+ const statsHtml = renderDiffEntryStatsHtml(fileGroup);
3247
+ const sections = Array.isArray(fileGroup?.sections) ? fileGroup.sections.filter(Boolean) : [];
3248
+ const expanded = isDiffThreadFileExpanded(detail?.token, fileRef);
3249
+
3250
+ return `
3251
+ <section class="detail-card detail-card--diff-thread ${options.mobile ? "detail-card--mobile" : ""}">
3252
+ <button
3253
+ type="button"
3254
+ class="detail-diff-thread__header ${expanded ? "is-open" : ""}"
3255
+ data-diff-thread-file-toggle
3256
+ data-diff-thread-token="${escapeHtml(detail?.token || "")}"
3257
+ data-diff-thread-file="${escapeHtml(fileRef)}"
3258
+ >
3259
+ <div class="detail-diff-thread__title-wrap">
3260
+ <span class="detail-diff-thread__icon" aria-hidden="true">${renderIcon("item")}</span>
3261
+ <div class="detail-diff-thread__title-text">
3262
+ <span class="detail-diff-thread__label">${escapeHtml(fileLabel)}</span>
3263
+ ${fileRef ? `<span class="detail-diff-thread__path">${escapeHtml(fileRef)}</span>` : ""}
3264
+ </div>
3265
+ </div>
3266
+ <div class="detail-diff-thread__header-right">
3267
+ ${statsHtml ? `<span class="detail-diff-thread__stats diff-entry__stats">${statsHtml}</span>` : ""}
3268
+ <span class="detail-diff-thread__chevron" aria-hidden="true">${renderIcon("chevron-right")}</span>
3269
+ </div>
3270
+ </button>
3271
+ ${
3272
+ expanded
3273
+ ? `
3274
+ <div class="detail-diff-thread__sections">
3275
+ ${sections.map((section) => renderDetailDiffThreadSection(section)).join("")}
3276
+ </div>
3277
+ `
3278
+ : ""
3279
+ }
3280
+ </section>
3281
+ `;
3282
+ }
3283
+
3284
+ function renderDetailDiffThreadSection(section) {
3285
+ const sectionLabel = fileEventDisplayLabel(section?.fileEventType || "") || L("common.diff");
3286
+ const timestampLabel = section?.createdAtMs ? formatTimelineTimestamp(section.createdAtMs) : "";
3287
+ const statsHtml = renderDiffEntryStatsHtml(section);
3288
+ const diffText = String(section?.diffText || "").replace(/\r\n/g, "\n").trim();
3289
+
3290
+ return `
3291
+ <div class="detail-diff-thread__section">
3292
+ <div class="detail-diff-thread__section-meta">
3293
+ <span class="detail-diff-thread__section-label">${escapeHtml(sectionLabel)}</span>
3294
+ <div class="detail-diff-thread__section-right">
3295
+ ${statsHtml ? `<span class="detail-diff-thread__section-stats diff-entry__stats">${statsHtml}</span>` : ""}
3296
+ ${timestampLabel ? `<span class="detail-diff-thread__section-time">${escapeHtml(timestampLabel)}</span>` : ""}
3297
+ </div>
3298
+ </div>
3299
+ ${
3300
+ diffText
3301
+ ? `<div class="detail-diff-viewer">${renderDiffLines(diffText)}</div>`
3302
+ : `<p class="detail-diff-card__notice">${escapeHtml(L("detail.diffUnavailable"))}</p>`
3303
+ }
3304
+ </div>
3305
+ `;
3306
+ }
3307
+
3308
+ function renderDiffLines(diffText) {
3309
+ return String(diffText || "")
3310
+ .replace(/\r\n/g, "\n")
3311
+ .split("\n")
3312
+ .map((line) => {
3313
+ const className = diffLineClassName(line);
3314
+ return `<div class="detail-diff-line ${className}">${escapeHtml(line || " ")}</div>`;
3315
+ })
3316
+ .join("");
3317
+ }
3318
+
3319
+ function diffLineClassName(line) {
3320
+ const text = String(line || "");
3321
+ if (text.startsWith("diff --git") || text.startsWith("index ") || text.startsWith("new file mode")) {
3322
+ return "detail-diff-line--meta";
3323
+ }
3324
+ if (text.startsWith("@@")) {
3325
+ return "detail-diff-line--hunk";
3326
+ }
3327
+ if (text.startsWith("+++ ") || text.startsWith("--- ")) {
3328
+ return "detail-diff-line--file";
3329
+ }
3330
+ if (text.startsWith("+")) {
3331
+ return "detail-diff-line--add";
3332
+ }
3333
+ if (text.startsWith("-")) {
3334
+ return "detail-diff-line--remove";
3335
+ }
3336
+ return "detail-diff-line--context";
3337
+ }
3338
+
2438
3339
  function renderCompletionReplyComposer(detail, options = {}) {
2439
3340
  if (detail.kind !== "completion" || detail.reply?.enabled !== true) {
2440
3341
  return "";
@@ -2985,14 +3886,19 @@ function renderBottomTabs() {
2985
3886
  }
2986
3887
 
2987
3888
  function renderTabButtons({ buttonClass, withIcons }) {
3889
+ const pendingCount = pendingInboxCount();
2988
3890
  return tabs()
2989
3891
  .map(
2990
- (tab) => `
2991
- <button class="${buttonClass} ${state.currentTab === tab.id ? "is-active" : ""}" data-tab="${escapeHtml(tab.id)}">
2992
- ${withIcons ? `<span class="tab-icon" aria-hidden="true">${renderIcon(tab.icon)}</span>` : ""}
3892
+ (tab) => {
3893
+ const showAttentionDot = withIcons && buttonClass === "bottom-nav__button" && tab.id === "inbox" && pendingCount > 0;
3894
+ const ariaLabel = showAttentionDot ? `${tab.label} (${pendingCount})` : tab.label;
3895
+ return `
3896
+ <button class="${buttonClass} ${state.currentTab === tab.id ? "is-active" : ""}" data-tab="${escapeHtml(tab.id)}" aria-label="${escapeHtml(ariaLabel)}">
3897
+ ${withIcons ? `<span class="tab-icon-wrap"><span class="tab-icon" aria-hidden="true">${renderIcon(tab.icon)}</span>${showAttentionDot ? `<span class="bottom-nav__attention-dot" aria-hidden="true"></span>` : ""}</span>` : ""}
2993
3898
  <span class="tab-label">${escapeHtml(tab.label)}</span>
2994
3899
  </button>
2995
3900
  `
3901
+ }
2996
3902
  )
2997
3903
  .join("");
2998
3904
  }
@@ -3060,6 +3966,27 @@ function bindShellInteractions() {
3060
3966
  select.addEventListener("change", async () => {
3061
3967
  clearThreadFilterInteraction();
3062
3968
  state.timelineThreadFilter = select.value || "all";
3969
+ state.timelineKindFilterOpen = false;
3970
+ alignCurrentItemToVisibleEntries();
3971
+ await renderShell();
3972
+ });
3973
+ }
3974
+
3975
+ for (const button of document.querySelectorAll("[data-timeline-kind-filter-toggle]")) {
3976
+ button.addEventListener("click", async (event) => {
3977
+ event.preventDefault();
3978
+ markThreadFilterInteraction();
3979
+ state.timelineKindFilterOpen = !state.timelineKindFilterOpen;
3980
+ await renderShell();
3981
+ });
3982
+ }
3983
+
3984
+ for (const button of document.querySelectorAll("[data-timeline-kind-filter-option]")) {
3985
+ button.addEventListener("click", async (event) => {
3986
+ event.preventDefault();
3987
+ clearThreadFilterInteraction();
3988
+ state.timelineKindFilter = button.dataset.timelineKindFilterOption || "all";
3989
+ state.timelineKindFilterOpen = false;
3063
3990
  alignCurrentItemToVisibleEntries();
3064
3991
  await renderShell();
3065
3992
  });
@@ -3084,12 +4011,28 @@ function bindShellInteractions() {
3084
4011
  });
3085
4012
  }
3086
4013
 
4014
+ for (const button of document.querySelectorAll("[data-inbox-subtab]")) {
4015
+ button.addEventListener("click", async () => {
4016
+ const nextSubtab = button.dataset.inboxSubtab === "completed" ? "completed" : "pending";
4017
+ if (nextSubtab === state.inboxSubtab) {
4018
+ return;
4019
+ }
4020
+ state.inboxSubtab = nextSubtab;
4021
+ if (isDesktopLayout()) {
4022
+ alignCurrentItemToVisibleEntries();
4023
+ syncCurrentItemUrl(state.currentItem);
4024
+ }
4025
+ await renderShell();
4026
+ });
4027
+ }
4028
+
3087
4029
  for (const button of document.querySelectorAll("[data-open-item-kind][data-open-item-token]")) {
3088
4030
  button.addEventListener("click", async () => {
3089
4031
  openItem({
3090
4032
  kind: button.dataset.openItemKind,
3091
4033
  token: button.dataset.openItemToken,
3092
4034
  sourceTab: button.dataset.sourceTab,
4035
+ sourceSubtab: button.dataset.sourceSubtab,
3093
4036
  });
3094
4037
  await renderShell();
3095
4038
  });
@@ -3107,6 +4050,15 @@ function bindShellInteractions() {
3107
4050
  });
3108
4051
  }
3109
4052
 
4053
+ for (const button of document.querySelectorAll("[data-diff-thread-file-toggle]")) {
4054
+ button.addEventListener("click", async (event) => {
4055
+ event.preventDefault();
4056
+ event.stopPropagation();
4057
+ toggleDiffThreadFileExpanded(button.dataset.diffThreadToken || "", button.dataset.diffThreadFile || "");
4058
+ await renderShell();
4059
+ });
4060
+ }
4061
+
3110
4062
  for (const button of document.querySelectorAll("[data-back-to-list]")) {
3111
4063
  button.addEventListener("click", async () => {
3112
4064
  clearChoiceLocalDraftForItem(state.currentItem);
@@ -3550,6 +4502,7 @@ function closeSettingsSubpage() {
3550
4502
 
3551
4503
  async function switchTab(tab) {
3552
4504
  state.currentTab = tab;
4505
+ state.timelineKindFilterOpen = false;
3553
4506
  state.pushNotice = "";
3554
4507
  state.pushError = "";
3555
4508
  state.settingsSubpage = "";
@@ -3566,10 +4519,14 @@ async function switchTab(tab) {
3566
4519
  await renderShell();
3567
4520
  }
3568
4521
 
3569
- function openItem({ kind, token, sourceTab }) {
4522
+ function openItem({ kind, token, sourceTab, sourceSubtab }) {
3570
4523
  const previousItem = state.currentItem ? { ...state.currentItem } : null;
3571
4524
  clearPinnedDetailState();
3572
4525
  const nextTab = sourceTab || tabForItemKind(kind, state.currentTab);
4526
+ if (nextTab === "inbox") {
4527
+ state.inboxSubtab = inboxSubtabForItemKind(kind, sourceSubtab);
4528
+ }
4529
+ state.timelineKindFilterOpen = false;
3573
4530
  if (previousItem && (previousItem.kind !== kind || previousItem.token !== token)) {
3574
4531
  clearChoiceLocalDraftForItem(previousItem);
3575
4532
  }
@@ -3641,14 +4598,14 @@ function isSettingsSubpageOpen() {
3641
4598
 
3642
4599
  function tabMeta(tab) {
3643
4600
  switch (tab) {
3644
- case "pending":
4601
+ case "inbox":
3645
4602
  return {
3646
- id: "pending",
3647
- title: L("tab.pending.title"),
3648
- label: L("tab.pending.label"),
4603
+ id: "inbox",
4604
+ title: L("tab.inbox.title"),
4605
+ label: L("tab.inbox.label"),
3649
4606
  icon: "pending",
3650
- eyebrow: L("tab.pending.eyebrow"),
3651
- description: L("tab.pending.description"),
4607
+ eyebrow: L("tab.inbox.eyebrow"),
4608
+ description: L("tab.inbox.description"),
3652
4609
  };
3653
4610
  case "timeline":
3654
4611
  return {
@@ -3659,14 +4616,14 @@ function tabMeta(tab) {
3659
4616
  eyebrow: L("tab.timeline.eyebrow"),
3660
4617
  description: L("tab.timeline.description"),
3661
4618
  };
3662
- case "completed":
4619
+ case "diff":
3663
4620
  return {
3664
- id: "completed",
3665
- title: L("tab.completed.title"),
3666
- label: L("tab.completed.label"),
3667
- icon: "completed",
3668
- eyebrow: L("tab.completed.eyebrow"),
3669
- description: L("tab.completed.description"),
4621
+ id: "diff",
4622
+ title: L("tab.code.title"),
4623
+ label: L("tab.code.label"),
4624
+ icon: "file-event",
4625
+ eyebrow: L("tab.code.eyebrow"),
4626
+ description: L("tab.code.description"),
3670
4627
  };
3671
4628
  case "settings":
3672
4629
  return {
@@ -3684,26 +4641,43 @@ function tabMeta(tab) {
3684
4641
 
3685
4642
  function tabs() {
3686
4643
  return [
3687
- tabMeta("pending"),
4644
+ tabMeta("inbox"),
3688
4645
  tabMeta("timeline"),
3689
- tabMeta("completed"),
4646
+ tabMeta("diff"),
3690
4647
  tabMeta("settings"),
3691
4648
  ];
3692
4649
  }
3693
4650
 
4651
+ function pendingInboxCount() {
4652
+ return Array.isArray(state.inbox?.pending) ? state.inbox.pending.length : 0;
4653
+ }
4654
+
3694
4655
  function tabForItemKind(kind, fallback) {
4656
+ if (kind === "diff_thread") {
4657
+ return "diff";
4658
+ }
4659
+ if (kind === "file_event") {
4660
+ return "timeline";
4661
+ }
3695
4662
  if (TIMELINE_MESSAGE_KINDS.has(kind)) {
3696
4663
  return "timeline";
3697
4664
  }
3698
4665
  if (kind === "completion") {
3699
- return "completed";
4666
+ return "inbox";
3700
4667
  }
3701
4668
  if (fallback === "timeline") {
3702
4669
  return "timeline";
3703
4670
  }
3704
4671
  return kind === "approval" || kind === "plan" || kind === "choice"
3705
- ? "pending"
3706
- : fallback || "pending";
4672
+ ? "inbox"
4673
+ : fallback || "inbox";
4674
+ }
4675
+
4676
+ function inboxSubtabForItemKind(kind, sourceSubtab = "") {
4677
+ if (normalizeClientText(sourceSubtab || "") === "completed") {
4678
+ return "completed";
4679
+ }
4680
+ return kind === "completion" ? "completed" : "pending";
3707
4681
  }
3708
4682
 
3709
4683
  function kindMeta(kind) {
@@ -3723,6 +4697,10 @@ function kindMeta(kind) {
3723
4697
  return { label: L("common.choice"), tone: "choice", icon: "choice" };
3724
4698
  case "completion":
3725
4699
  return { label: L("common.completion"), tone: "completion", icon: "completion-item" };
4700
+ case "diff_thread":
4701
+ return { label: L("common.diff"), tone: "neutral", icon: "diff" };
4702
+ case "file_event":
4703
+ return { label: L("common.fileEvent"), tone: "neutral", icon: "file-event" };
3726
4704
  default:
3727
4705
  return { label: L("common.item"), tone: "neutral", icon: "item" };
3728
4706
  }
@@ -3736,6 +4714,12 @@ function renderTypePillContent(kindInfo) {
3736
4714
  }
3737
4715
 
3738
4716
  function itemIntentText(kind, status = "pending") {
4717
+ if (kind === "diff_thread") {
4718
+ return L("intent.diffThread");
4719
+ }
4720
+ if (kind === "file_event") {
4721
+ return L("intent.fileEvent");
4722
+ }
3739
4723
  if (kind === "user_message") {
3740
4724
  return L("intent.userMessage");
3741
4725
  }
@@ -3763,6 +4747,12 @@ function itemIntentText(kind, status = "pending") {
3763
4747
  }
3764
4748
 
3765
4749
  function detailIntentText(detail) {
4750
+ if (detail.kind === "diff_thread") {
4751
+ return itemIntentText(detail.kind, "diff");
4752
+ }
4753
+ if (detail.kind === "file_event") {
4754
+ return itemIntentText(detail.kind, "timeline");
4755
+ }
3766
4756
  if (TIMELINE_MESSAGE_KINDS.has(detail.kind)) {
3767
4757
  return itemIntentText(detail.kind, "timeline");
3768
4758
  }
@@ -3816,6 +4806,10 @@ function fallbackSummaryForKind(kind, status) {
3816
4806
  return L("summary.completed");
3817
4807
  }
3818
4808
  switch (kind) {
4809
+ case "diff_thread":
4810
+ return L("summary.diffThread");
4811
+ case "file_event":
4812
+ return L("summary.fileEvent");
3819
4813
  case "user_message":
3820
4814
  return L("summary.userMessage");
3821
4815
  case "assistant_commentary":
@@ -4018,6 +5012,10 @@ function renderIcon(name) {
4018
5012
  return `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="8"/><path d="m8.7 12.1 2 2.1 4.7-4.9"/></svg>`;
4019
5013
  case "item":
4020
5014
  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>`;
5015
+ case "file-event":
5016
+ 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>`;
5017
+ case "diff":
5018
+ 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>`;
4021
5019
  case "pending":
4022
5020
  return `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M12 3v5"/><path d="M12 16v5"/><path d="M4.8 6.8l3.5 3.5"/><path d="M15.7 15.7l3.5 3.5"/><path d="M3 12h5"/><path d="M16 12h5"/><path d="M4.8 17.2l3.5-3.5"/><path d="M15.7 8.3l3.5-3.5"/></svg>`;
4023
5021
  case "timeline":
@@ -4044,6 +5042,8 @@ function renderIcon(name) {
4044
5042
  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>`;
4045
5043
  case "clip":
4046
5044
  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>`;
5045
+ case "filter":
5046
+ return `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round"><path d="M5 7h14"/><path d="M8 12h8"/><path d="M10.5 17h3"/></svg>`;
4047
5047
  case "check":
4048
5048
  return `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="m6.8 12.5 3.2 3.2 7.2-7.4"/></svg>`;
4049
5049
  case "back":
@@ -4151,7 +5151,29 @@ function handleServiceWorkerMessage(event) {
4151
5151
  const type = event?.data?.type || "";
4152
5152
  if (type === "pushsubscriptionchange") {
4153
5153
  refreshPushStatus().then(renderCurrentSurface).catch(() => {});
5154
+ return;
5155
+ }
5156
+ if (type === "open-target-url" && event?.data?.url) {
5157
+ applyExternalTargetUrl(event.data.url).catch(() => {});
5158
+ }
5159
+ }
5160
+
5161
+ function handlePotentialExternalNavigation() {
5162
+ consumePendingNotificationIntent()
5163
+ .then((consumed) => {
5164
+ if (consumed) {
5165
+ return;
5166
+ }
5167
+ return applyExternalTargetUrl(window.location.href, { allowRefresh: false });
5168
+ })
5169
+ .catch(() => {});
5170
+ }
5171
+
5172
+ function handleDocumentVisibilityChange() {
5173
+ if (document.visibilityState !== "visible") {
5174
+ return;
4154
5175
  }
5176
+ handlePotentialExternalNavigation();
4155
5177
  }
4156
5178
 
4157
5179
  async function apiGet(url) {
@@ -4250,11 +5272,124 @@ function normalizeClientText(value) {
4250
5272
  return String(value ?? "").trim();
4251
5273
  }
4252
5274
 
5275
+ function normalizeClientFileRefs(fileRefs) {
5276
+ if (!Array.isArray(fileRefs)) {
5277
+ return [];
5278
+ }
5279
+ const deduped = [];
5280
+ const seen = new Set();
5281
+ for (const fileRef of fileRefs) {
5282
+ const normalized = normalizeClientText(fileRef);
5283
+ if (!normalized || seen.has(normalized)) {
5284
+ continue;
5285
+ }
5286
+ seen.add(normalized);
5287
+ deduped.push(normalized);
5288
+ if (deduped.length >= 8) {
5289
+ break;
5290
+ }
5291
+ }
5292
+ return deduped;
5293
+ }
5294
+
5295
+ function fileRefLabel(fileRef) {
5296
+ const normalized = normalizeClientText(fileRef);
5297
+ if (!normalized) {
5298
+ return "";
5299
+ }
5300
+ const segments = normalized.split("/").filter(Boolean);
5301
+ return segments[segments.length - 1] || normalized;
5302
+ }
5303
+
4253
5304
  function parseItemRef(value) {
4254
5305
  const [kind, token] = String(value || "").split(":");
4255
5306
  return kind && token ? { kind, token } : null;
4256
5307
  }
4257
5308
 
5309
+ async function applyExternalTargetUrl(urlString, { allowRefresh = true } = {}) {
5310
+ if (!state.session?.authenticated) {
5311
+ return;
5312
+ }
5313
+
5314
+ let nextUrl;
5315
+ try {
5316
+ nextUrl = new URL(urlString, window.location.origin);
5317
+ } catch {
5318
+ return;
5319
+ }
5320
+
5321
+ const itemRef = parseItemRef(nextUrl.searchParams.get("item"));
5322
+ if (!itemRef) {
5323
+ return;
5324
+ }
5325
+
5326
+ const sameItem =
5327
+ Boolean(state.currentItem) &&
5328
+ isSameItemRef(state.currentItem, itemRef) &&
5329
+ (isDesktopLayout() || state.detailOpen);
5330
+ if (sameItem) {
5331
+ if (allowRefresh) {
5332
+ await refreshAuthenticatedState();
5333
+ ensureCurrentSelection();
5334
+ await renderShell();
5335
+ }
5336
+ return;
5337
+ }
5338
+
5339
+ openItem({
5340
+ kind: itemRef.kind,
5341
+ token: itemRef.token,
5342
+ sourceTab: tabForItemKind(itemRef.kind, state.currentTab),
5343
+ });
5344
+ if (isFastPathItemRef(itemRef)) {
5345
+ state.launchItemIntent = {
5346
+ ...itemRef,
5347
+ status: "pending",
5348
+ };
5349
+ }
5350
+ await renderShell();
5351
+
5352
+ if (!allowRefresh) {
5353
+ return;
5354
+ }
5355
+ await refreshAuthenticatedState();
5356
+ ensureCurrentSelection();
5357
+ await renderShell();
5358
+ }
5359
+
5360
+ async function consumePendingNotificationIntent() {
5361
+ if (!state.session?.authenticated || typeof caches === "undefined") {
5362
+ return false;
5363
+ }
5364
+ let cache;
5365
+ try {
5366
+ cache = await caches.open(NOTIFICATION_INTENT_CACHE);
5367
+ } catch {
5368
+ return false;
5369
+ }
5370
+
5371
+ const request = new Request(NOTIFICATION_INTENT_PATH);
5372
+ const match = await cache.match(request).catch(() => null);
5373
+ if (!match) {
5374
+ return false;
5375
+ }
5376
+
5377
+ let payload = null;
5378
+ try {
5379
+ payload = await match.json();
5380
+ } catch {
5381
+ payload = null;
5382
+ }
5383
+ await cache.delete(request).catch(() => {});
5384
+
5385
+ const url = normalizeClientText(payload?.url || "");
5386
+ if (!url) {
5387
+ return false;
5388
+ }
5389
+ await applyExternalTargetUrl(url, { allowRefresh: true });
5390
+ return true;
5391
+ }
5392
+
4258
5393
  function buildAppUrl(nextParams) {
4259
5394
  const query = nextParams.toString();
4260
5395
  return `/app${query ? `?${query}` : ""}`;