@opengeni/react 0.28.2 → 0.32.1

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.
Files changed (208) hide show
  1. package/dist/approvals.d.ts +14 -0
  2. package/dist/{chunk-M7X4JZOD.js → chunk-4QOQ2DSR.js} +8 -1
  3. package/dist/chunk-4QOQ2DSR.js.map +1 -0
  4. package/dist/{chunk-HQFPO7VX.js → chunk-7CJOAW3V.js} +24 -3
  5. package/dist/{chunk-HQFPO7VX.js.map → chunk-7CJOAW3V.js.map} +1 -1
  6. package/dist/{chunk-PMSGE233.js → chunk-A2WITZTI.js} +377 -113
  7. package/dist/chunk-A2WITZTI.js.map +1 -0
  8. package/dist/{chunk-BC6RKIHS.js → chunk-GZ4KR67U.js} +11 -11
  9. package/dist/{chunk-4F2YLLHP.js → chunk-KZRUYCL4.js} +14 -17
  10. package/dist/chunk-KZRUYCL4.js.map +1 -0
  11. package/dist/{chunk-OCHBZ2X7.js → chunk-TCCE5ZU5.js} +192 -28
  12. package/dist/chunk-TCCE5ZU5.js.map +1 -0
  13. package/dist/chunk-U255M5EZ.js +178 -0
  14. package/dist/chunk-U255M5EZ.js.map +1 -0
  15. package/dist/{chunk-XVVQP7A4.js → chunk-WY2QN2CX.js} +2539 -2174
  16. package/dist/chunk-WY2QN2CX.js.map +1 -0
  17. package/dist/chunk-ZNNE2VES.js +6413 -0
  18. package/dist/chunk-ZNNE2VES.js.map +1 -0
  19. package/dist/client.d.ts +33 -0
  20. package/dist/commands/index.d.ts +3 -0
  21. package/dist/commands/registry.d.ts +41 -0
  22. package/dist/commands/types.d.ts +85 -0
  23. package/dist/components/approval-surface.d.ts +28 -0
  24. package/dist/components/chat-composer.d.ts +51 -0
  25. package/dist/components/code-editor.d.ts +43 -0
  26. package/dist/components/command-palette.d.ts +25 -0
  27. package/dist/components/composer-transcription-control.d.ts +36 -0
  28. package/dist/components/composer.d.ts +277 -0
  29. package/dist/components/copy-button.d.ts +26 -0
  30. package/dist/components/desktop-viewer.d.ts +85 -0
  31. package/dist/components/diff-view.d.ts +38 -0
  32. package/dist/components/enrollment-consent.d.ts +33 -0
  33. package/dist/components/enrollment-device-flow.d.ts +27 -0
  34. package/dist/components/file-browser.d.ts +47 -0
  35. package/dist/components/fleet-tile.d.ts +18 -0
  36. package/dist/components/human-input-form.d.ts +47 -0
  37. package/dist/components/machine-card.d.ts +21 -0
  38. package/dist/components/machine-dock-bar.d.ts +31 -0
  39. package/dist/components/machine-health-pill.d.ts +9 -0
  40. package/dist/components/machine-metrics.d.ts +15 -0
  41. package/dist/components/machine-status-pill.d.ts +54 -0
  42. package/dist/components/machines/health.d.ts +25 -0
  43. package/dist/components/machines/machine-detail.d.ts +14 -0
  44. package/dist/components/machines/metric-history-chart.d.ts +24 -0
  45. package/dist/components/machines/metric-sparkline.d.ts +9 -0
  46. package/dist/components/machines/series.d.ts +24 -0
  47. package/dist/components/machines-dashboard.d.ts +30 -0
  48. package/dist/components/markdown.d.ts +31 -0
  49. package/dist/components/message-timeline.d.ts +98 -0
  50. package/dist/components/model-picker.d.ts +19 -0
  51. package/dist/components/pierre-diff.d.ts +34 -0
  52. package/dist/components/pierre-file.d.ts +27 -0
  53. package/dist/components/queue-draft-policy.d.ts +7 -0
  54. package/dist/components/queue-surface-implementation.d.ts +20 -0
  55. package/dist/components/queue-surface-state.d.ts +12 -0
  56. package/dist/components/queue-surface.d.ts +12 -0
  57. package/dist/components/sandbox-files.d.ts +49 -0
  58. package/dist/components/sandbox-terminal.d.ts +123 -0
  59. package/dist/components/sandbox-workspace.d.ts +128 -0
  60. package/dist/components/session-chrome.d.ts +70 -0
  61. package/dist/components/session-status.d.ts +25 -0
  62. package/dist/components/soften-streaming-markdown.d.ts +13 -0
  63. package/dist/components/stream-reveal.d.ts +37 -0
  64. package/dist/components/tip-follow.d.ts +139 -0
  65. package/dist/components/tooltip.d.ts +16 -0
  66. package/dist/components/workbench-changes.d.ts +34 -0
  67. package/dist/components/workspace-dock.d.ts +35 -0
  68. package/dist/composer.d.ts +11 -7
  69. package/dist/composer.js +9 -6
  70. package/dist/hooks/internal.d.ts +46 -0
  71. package/dist/hooks/use-available-models.d.ts +37 -0
  72. package/dist/hooks/use-billing-usage.d.ts +26 -0
  73. package/dist/hooks/use-codex-accounts.d.ts +69 -0
  74. package/dist/{use-composer-ClHy1rkL.d.ts → hooks/use-composer.d.ts} +16 -24
  75. package/dist/hooks/use-desktop-stream.d.ts +43 -0
  76. package/dist/hooks/use-environments.d.ts +35 -0
  77. package/dist/{use-file-attachments-BmT8WtqO.d.ts → hooks/use-file-attachments.d.ts} +6 -9
  78. package/dist/hooks/use-goal.d.ts +46 -0
  79. package/dist/hooks/use-human-input.d.ts +26 -0
  80. package/dist/hooks/use-last-started-turn-policy.d.ts +27 -0
  81. package/dist/hooks/use-machine-chip.d.ts +54 -0
  82. package/dist/hooks/use-machines.d.ts +81 -0
  83. package/dist/hooks/use-packs.d.ts +27 -0
  84. package/dist/hooks/use-relay-frame-stream.d.ts +53 -0
  85. package/dist/hooks/use-rigs.d.ts +87 -0
  86. package/dist/hooks/use-sandbox-files.d.ts +108 -0
  87. package/dist/hooks/use-sandbox-git.d.ts +58 -0
  88. package/dist/hooks/use-sandbox-terminal.d.ts +62 -0
  89. package/dist/hooks/use-scheduled-tasks.d.ts +15 -0
  90. package/dist/hooks/use-session-capabilities.d.ts +83 -0
  91. package/dist/hooks/use-session-control.d.ts +22 -0
  92. package/dist/hooks/use-session-events.d.ts +92 -0
  93. package/dist/hooks/use-session-lineage.d.ts +17 -0
  94. package/dist/hooks/use-session-mcp-approval-policy.d.ts +23 -0
  95. package/dist/hooks/use-session.d.ts +23 -0
  96. package/dist/hooks/use-slash-commands.d.ts +66 -0
  97. package/dist/hooks/use-terminal-stream.d.ts +46 -0
  98. package/dist/hooks/use-transcription.d.ts +73 -0
  99. package/dist/hooks/use-turn-queue.d.ts +44 -0
  100. package/dist/hooks/use-voice-input.d.ts +25 -0
  101. package/dist/hooks/use-windowed-sections.d.ts +42 -0
  102. package/dist/hooks/use-workspace-capture.d.ts +49 -0
  103. package/dist/hooks/use-workspace-edit.d.ts +73 -0
  104. package/dist/hooks/use-workspace-sessions.d.ts +30 -0
  105. package/dist/hooks/use-workspaces.d.ts +22 -0
  106. package/dist/human-input.d.ts +18 -0
  107. package/dist/index.d.ts +144 -2119
  108. package/dist/index.js +421 -324
  109. package/dist/index.js.map +1 -1
  110. package/dist/lib/clipboard.d.ts +7 -0
  111. package/dist/lib/cn.d.ts +3 -0
  112. package/dist/lib/format.d.ts +48 -0
  113. package/dist/lib/git-patch.d.ts +8 -0
  114. package/dist/lib/motion-inspect.d.ts +5 -0
  115. package/dist/lib/motion.d.ts +14 -0
  116. package/dist/lib/noto-sans-arabic-loader.d.ts +1 -0
  117. package/dist/lib/noto-sans-jp-loader.generated.d.ts +1 -0
  118. package/dist/lib/relay-wire.d.ts +30 -0
  119. package/dist/lib/use-portal-token-style.d.ts +9 -0
  120. package/dist/lib/use-theme-type.d.ts +5 -0
  121. package/dist/lib/use-unicode-fonts.d.ts +12 -0
  122. package/dist/lib/xterm-renderer.d.ts +19 -0
  123. package/dist/lib/xterm-theme.d.ts +75 -0
  124. package/dist/machines.d.ts +30 -413
  125. package/dist/machines.js +3 -3
  126. package/dist/model-policy.d.ts +32 -0
  127. package/dist/model-policy.js +33 -0
  128. package/dist/model-policy.js.map +1 -0
  129. package/dist/provider.d.ts +16 -0
  130. package/dist/{queue-surface-implementation-UQ535OKQ.js → queue-surface-implementation-THPAKGMD.js} +2 -2
  131. package/dist/{queue-surface-implementation-UQ535OKQ.js.map → queue-surface-implementation-THPAKGMD.js.map} +1 -1
  132. package/dist/session-context.d.ts +82 -0
  133. package/dist/session-ui.d.ts +10 -7
  134. package/dist/session-ui.js +9 -5
  135. package/dist/session.d.ts +28 -6
  136. package/dist/session.js +11 -11
  137. package/dist/timeline/activity-rail.d.ts +19 -0
  138. package/dist/timeline/disclosure-context.d.ts +14 -0
  139. package/dist/timeline/entrance.d.ts +14 -0
  140. package/dist/timeline/fleet-decision-projection.d.ts +9 -0
  141. package/dist/timeline/fleet-decision-row.d.ts +11 -0
  142. package/dist/timeline/fold-memory.d.ts +15 -0
  143. package/dist/timeline/index.d.ts +18 -0
  144. package/dist/timeline/parsers.d.ts +96 -0
  145. package/dist/timeline/projection.d.ts +38 -0
  146. package/dist/timeline/registry.d.ts +40 -0
  147. package/dist/timeline/screenshot-lightbox.d.ts +26 -0
  148. package/dist/timeline/seen-activity-ids.d.ts +2 -0
  149. package/dist/timeline/shared.d.ts +125 -0
  150. package/dist/timeline/tool-diff.d.ts +8 -0
  151. package/dist/timeline/tool-display-name.d.ts +10 -0
  152. package/dist/timeline/tool-renderers.d.ts +5 -0
  153. package/dist/timeline/turn-summary.d.ts +116 -0
  154. package/dist/{use-turn-queue-Drm_ypue.d.ts → timeline/types.d.ts} +57 -67
  155. package/dist/types/machines.d.ts +11 -0
  156. package/package.json +8 -4
  157. package/src/client.ts +1 -0
  158. package/src/components/composer-transcription-control.tsx +278 -130
  159. package/src/components/composer.tsx +20 -15
  160. package/src/components/copy-button.tsx +127 -0
  161. package/src/components/markdown.tsx +231 -32
  162. package/src/components/message-timeline.tsx +1382 -378
  163. package/src/components/model-picker.tsx +83 -43
  164. package/src/components/queue-surface-implementation.tsx +1 -1
  165. package/src/components/session-chrome.tsx +942 -0
  166. package/src/components/soften-streaming-markdown.ts +205 -0
  167. package/src/components/stream-reveal.ts +195 -0
  168. package/src/components/tip-follow.ts +459 -0
  169. package/src/components/tooltip.tsx +103 -0
  170. package/src/composer.ts +6 -0
  171. package/src/hooks/internal.ts +30 -4
  172. package/src/hooks/use-available-models.ts +51 -4
  173. package/src/hooks/use-composer.ts +24 -18
  174. package/src/hooks/use-last-started-turn-policy.ts +95 -0
  175. package/src/hooks/use-session-events.ts +359 -5
  176. package/src/hooks/use-voice-input.ts +232 -0
  177. package/src/index.ts +49 -1
  178. package/src/lib/clipboard.ts +58 -0
  179. package/src/lib/format.ts +18 -0
  180. package/src/lib/motion-inspect.ts +5 -0
  181. package/src/lib/motion.ts +80 -0
  182. package/src/model-policy.ts +210 -0
  183. package/src/session-ui.ts +7 -0
  184. package/src/session.ts +2 -0
  185. package/src/timeline/activity-rail.tsx +46 -7
  186. package/src/timeline/entrance.tsx +10 -0
  187. package/src/timeline/fold-memory.ts +52 -0
  188. package/src/timeline/index.ts +11 -1
  189. package/src/timeline/projection.ts +252 -47
  190. package/src/timeline/seen-activity-ids.ts +19 -0
  191. package/src/timeline/shared.tsx +1 -1
  192. package/src/timeline/tool-renderers.tsx +75 -6
  193. package/src/timeline/turn-summary.tsx +387 -97
  194. package/src/timeline/types.ts +47 -0
  195. package/styles/index.css +99 -7
  196. package/styles/tokens.css +44 -3
  197. package/dist/chunk-4F2YLLHP.js.map +0 -1
  198. package/dist/chunk-M7X4JZOD.js.map +0 -1
  199. package/dist/chunk-OCHBZ2X7.js.map +0 -1
  200. package/dist/chunk-PMSGE233.js.map +0 -1
  201. package/dist/chunk-WAA247AB.js +0 -3858
  202. package/dist/chunk-WAA247AB.js.map +0 -1
  203. package/dist/chunk-XVVQP7A4.js.map +0 -1
  204. package/dist/composer-BMzimGTO.d.ts +0 -528
  205. package/dist/session-C3tIXLim.d.ts +0 -264
  206. package/dist/session-context-CAjlaeZb.d.ts +0 -84
  207. package/dist/session-ui-B_7cfN5u.d.ts +0 -259
  208. /package/dist/{chunk-BC6RKIHS.js.map → chunk-GZ4KR67U.js.map} +0 -0
@@ -2,6 +2,7 @@ import type { SessionEvent, SessionStatus } from "@opengeni/sdk";
2
2
  import {
3
3
  ArrowDownIcon,
4
4
  ArrowRightIcon,
5
+ ArrowUpToLineIcon,
5
6
  BotIcon,
6
7
  CheckCircle2Icon,
7
8
  CheckIcon,
@@ -11,6 +12,7 @@ import {
11
12
  PencilLineIcon,
12
13
  PlayIcon,
13
14
  RefreshCwIcon,
15
+ ShrinkIcon,
14
16
  TargetIcon,
15
17
  Trash2Icon,
16
18
  TriangleAlertIcon,
@@ -31,7 +33,18 @@ import {
31
33
  } from "react";
32
34
  import { cn } from "../lib/cn";
33
35
  import { formatRelativeTime, truncate } from "../lib/format";
36
+ import { prefersReducedMotion } from "../lib/motion";
34
37
  import { Markdown } from "./markdown";
38
+ import {
39
+ createTipFollowState,
40
+ readerScrollUpPx,
41
+ tipFollowCancel,
42
+ tipFollowCompensateShrink,
43
+ tipFollowStep,
44
+ TIP_FOLLOW_READER_UP_EPS_PX,
45
+ TIP_FOLLOW_SHRINK_EPS_PX,
46
+ type TipFollowState,
47
+ } from "./tip-follow";
35
48
  import {
36
49
  ActivityRail,
37
50
  buildTimeline,
@@ -41,25 +54,35 @@ import {
41
54
  type ActivityItem,
42
55
  type AgentMessageItem,
43
56
  type AuthNeededItem,
57
+ type ContextCompactionItem,
44
58
  type GoalItem,
59
+ type MachineInputBatchItem,
45
60
  type NoticeItem,
46
61
  type TimelineGroup,
47
62
  type TimelineItem,
48
63
  type ToolRegistry,
49
64
  type TurnSummaryOptions,
50
65
  type UserMessageItem,
66
+ type FoldRestingState,
51
67
  type WorkerCompletionItem,
68
+ FoldMemoryProvider,
69
+ inheritFoldRestingState,
52
70
  TurnSummary,
71
+ useFoldMemory,
72
+ useTurnSettleOpen,
53
73
  } from "../timeline";
74
+ import { CopyHoverFrame } from "./copy-button";
54
75
  import { SESSION_STATUS_META, StatusDot } from "./session-status";
55
76
  import { EntranceAnimationProvider, useEntranceAnimation } from "../timeline/entrance";
77
+ import { SeenActivityIdsProvider } from "../timeline/seen-activity-ids";
78
+ import { TooltipProvider } from "./tooltip";
56
79
 
57
80
  export type MessageTimelineProps = {
58
81
  /** Raw session events (projected internally) … */
59
82
  events?: SessionEvent[] | undefined;
60
83
  /** … or pre-projected items (e.g. from `useSessionEvents().timeline`). */
61
84
  items?: TimelineItem[] | undefined;
62
- /** Current session status; drives the live "working" indicator. */
85
+ /** Current session status (reserved; tip "Working…" chrome removed for now). */
63
86
  status?: SessionStatus | null | undefined;
64
87
  /** Plug a markdown renderer for message bodies (e.g. streamdown). */
65
88
  renderMessageText?:
@@ -108,11 +131,112 @@ export type MessageTimelineProps = {
108
131
  loadingOlder?: boolean | undefined;
109
132
  /** Called when the reader nears the top and older history should backfill. */
110
133
  onLoadOlder?: (() => void) | undefined;
134
+ /** Jump to the durable session start (bounded oldest window, no middle). */
135
+ onJumpToStart?: (() => void | Promise<void>) | undefined;
136
+ /** True while the oldest window is loading. */
137
+ loadingOldest?: boolean | undefined;
138
+ /** Newer durable history exists below the current (history) window. */
139
+ hasNewer?: boolean | undefined;
140
+ /** A newer history page is being fetched. */
141
+ loadingNewer?: boolean | undefined;
142
+ /** Page forward through history without loading the whole gap to the tip. */
143
+ onLoadNewer?: (() => void) | undefined;
144
+ /**
145
+ * Reload the live tip window. When omitted, Jump to latest only re-pins and
146
+ * scrolls the in-memory window.
147
+ */
148
+ onJumpToLatest?: (() => void | Promise<void>) | undefined;
111
149
  emptyState?: ReactNode | undefined;
112
150
  className?: string | undefined;
113
151
  };
114
152
 
115
- const INITIAL_MOUNTED_GROUPS = 1;
153
+ /**
154
+ * Scroll ownership, from first principles. Everything the events hook has
155
+ * loaded is mounted — no tip-lock window, no per-frame progressive reveal.
156
+ * (The in-memory window is already byte/count-bounded by useSessionEvents, and
157
+ * rows are memoized, so a full mount is cheap; the drip-feed machinery this
158
+ * replaces was the "content is hidden, then pops in in batches" wobble.)
159
+ *
160
+ * Scroll invariant (tip-follow camera — see `./tip-follow.ts`):
161
+ * - DOM truth mounts immediately; the camera eases down to the tip (not
162
+ * tip-glued feed-forward — that is the one-line yank).
163
+ * - One continuous follow while hot (faster τ when behind); sleeps when cold.
164
+ * - While pinned, tip-debt from growth/collapse must NEVER unpin — only
165
+ * wheel/keys/pointer-armed scroll-up. Height shrink compensates scrollTop
166
+ * by Δh (collapse owns motion); tip-ease pauses briefly so the two don't fight.
167
+ * - overflow-anchor off while pinned so the browser cannot instant-correct.
168
+ * - Scrolled up → history prepends restore via the retained group anchor
169
+ * (offsetTop delta); loadOlder can truncate the tip, so scrollHeight delta
170
+ * alone is wrong. Late layout while unpinned stays browser-owned.
171
+ */
172
+ const PIN_THRESHOLD_PX = 48;
173
+ /**
174
+ * Prefetch older history when the top sentinel is this far from the viewport.
175
+ * After a page loads we stay cool until the reader leaves this band (scrolls
176
+ * down into content) — never re-fire from continued scroll toward y=0.
177
+ */
178
+ const OLDER_PREFETCH_MARGIN_PX = 400;
179
+ const OLDER_PREFETCH_ROOT_MARGIN = `${OLDER_PREFETCH_MARGIN_PX}px 0px 0px 0px`;
180
+
181
+ /**
182
+ * Pinned = the viewport bottom is within PIN_THRESHOLD_PX of the content
183
+ * bottom. When the scroll range itself is shorter than the threshold, the
184
+ * whole range would count as "at the bottom" and the reader could never unpin
185
+ * to reach older history — so the effective threshold shrinks to the range,
186
+ * making the very top of a short window count as scrolled up. A window that
187
+ * cannot scroll at all is always pinned.
188
+ */
189
+ function maxScrollOf(node: HTMLElement): number {
190
+ return Math.max(0, node.scrollHeight - node.clientHeight);
191
+ }
192
+
193
+ /**
194
+ * Wheel bubbled from a nested overflow scroller that can still move up — not
195
+ * timeline intent (code blocks / notice `<pre>`).
196
+ */
197
+ function wheelConsumedByNestedScrollable(event: {
198
+ deltaY: number;
199
+ target: EventTarget | null;
200
+ currentTarget: EventTarget | null;
201
+ }): boolean {
202
+ if (event.deltaY >= 0) {
203
+ return false;
204
+ }
205
+ let el = event.target instanceof Element ? event.target : null;
206
+ const root = event.currentTarget instanceof Element ? event.currentTarget : null;
207
+ while (el && el !== root) {
208
+ if (el instanceof HTMLElement) {
209
+ const style = getComputedStyle(el);
210
+ const overflowY = style.overflowY;
211
+ if (
212
+ (overflowY === "auto" || overflowY === "scroll" || overflowY === "overlay") &&
213
+ el.scrollHeight > el.clientHeight + 1 &&
214
+ el.scrollTop > 0
215
+ ) {
216
+ return true;
217
+ }
218
+ }
219
+ el = el.parentElement;
220
+ }
221
+ return false;
222
+ }
223
+
224
+ function isNearBottom(node: HTMLElement): boolean {
225
+ const maxScroll = maxScrollOf(node);
226
+ if (maxScroll <= 1) {
227
+ return true;
228
+ }
229
+ const gap = maxScroll - node.scrollTop;
230
+ return gap < Math.min(PIN_THRESHOLD_PX, maxScroll);
231
+ }
232
+
233
+ /** Escape a value for use inside a CSS attribute selector. */
234
+ function cssEscapeAttribute(value: string): string {
235
+ if (typeof CSS !== "undefined" && typeof CSS.escape === "function") {
236
+ return CSS.escape(value);
237
+ }
238
+ return value.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
239
+ }
116
240
 
117
241
  /**
118
242
  * The session timeline: chat messages with streaming deltas, collapsed
@@ -123,7 +247,7 @@ const INITIAL_MOUNTED_GROUPS = 1;
123
247
  export function MessageTimeline({
124
248
  events,
125
249
  items,
126
- status,
250
+ status: _status,
127
251
  renderMessageText,
128
252
  onOpenSession,
129
253
  onMemoryClick,
@@ -135,146 +259,441 @@ export function MessageTimeline({
135
259
  hasOlder = false,
136
260
  loadingOlder = false,
137
261
  onLoadOlder,
262
+ onJumpToStart,
263
+ loadingOldest = false,
264
+ hasNewer = false,
265
+ loadingNewer = false,
266
+ onLoadNewer,
267
+ onJumpToLatest,
138
268
  emptyState,
139
269
  className,
140
270
  }: MessageTimelineProps) {
141
271
  const resolvedItems = useMemo(() => items ?? buildTimeline(events ?? []), [items, events]);
142
272
  const allGroups = useMemo(() => groupTimeline(resolvedItems), [resolvedItems]);
143
- const keyedGroups = useStableTimelineGroupKeys(allGroups);
144
- const { mountedGroups: groups, mountingOlderGroups } = useProgressivelyMountedGroups(keyedGroups);
145
- const firstGroupKey = allGroups[0] ? timelineGroupKey(allGroups[0]) : null;
146
-
273
+ const groups = useStableTimelineGroupKeys(allGroups);
147
274
  const scrollRef = useRef<HTMLDivElement | null>(null);
148
275
  const topSentinelRef = useRef<HTMLDivElement | null>(null);
276
+ const bottomSentinelRef = useRef<HTMLDivElement | null>(null);
149
277
  const previousBulkFirstKeyRef = useRef<string | null | undefined>(undefined);
150
278
  const [pinned, setPinned] = useState(true);
151
279
  const [bulkActive, setBulkActive] = useState(true);
280
+ // Older history prefetch is user-driven: a window shorter than the viewport
281
+ // + rootMargin would otherwise keep the top sentinel intersecting and fetch
282
+ // history forever while the reader sits at the tip. Arm on first scroll-up.
283
+ const olderPrefetchArmedRef = useRef(false);
284
+ const [olderPrefetchArmed, setOlderPrefetchArmed] = useState(false);
285
+ // One loadOlder per visit to the top band. Re-arm only after the reader
286
+ // leaves that band (scrolls down / sentinel exits) — never from scrolling
287
+ // further toward y=0 (that was the batch-top load loop).
288
+ const olderLoadGateRef = useRef<"armed" | "cooling">("armed");
289
+ const resizeFollowRafRef = useRef<number | null>(null);
290
+ const firstGroupKey = allGroups[0] ? timelineGroupKey(allGroups[0]) : null;
152
291
  // Content stays invisible until its first bottom-anchored frame, so a flash
153
- // of the window's TOP while a large timeline lays out across commits is
154
- // structurally impossible — the reader only ever sees it already at the
155
- // bottom. An empty timeline reveals immediately (there is nothing to anchor).
292
+ // of the window's TOP while a large timeline lays out is structurally
293
+ // impossible — the reader only ever sees it already at the bottom.
156
294
  const [revealed, setRevealed] = useState(false);
157
- // Our own scrollTop assignments echo back as delayed scroll events. Keep the
158
- // ordered, clamped targets rather than only the latest one: successive
159
- // ResizeObserver corrections can queue several echoes before the browser
160
- // dispatches them. If reader input lands at a different position, its value
161
- // wins and clears the pending echoes. A matching echo must not recapture the
162
- // visual anchor while progressive prepend is still mounting older rows.
163
- const programmaticScrollTargetsRef = useRef<number[]>([]);
164
- const assignScrollTop = useCallback((node: HTMLElement, value: number) => {
165
- const previous = node.scrollTop;
166
- node.scrollTop = value;
167
- if (node.scrollTop === previous) {
168
- return;
169
- }
170
- const pending = programmaticScrollTargetsRef.current;
171
- pending.push(node.scrollTop);
172
- if (pending.length > 32) {
173
- pending.splice(0, pending.length - 32);
174
- }
175
- }, []);
176
- // Mirror `pinned` into a ref so the ResizeObserver callback (a stable closure)
177
- // always reads the live value without re-subscribing on every scroll.
295
+ // Mirror `pinned` into a ref, written ONLY by applyPinned, so the
296
+ // ResizeObserver rAF (a stable closure) reads the live value and a snap can
297
+ // never race a just-unpinned reader across a pending React commit.
178
298
  const pinnedRef = useRef(true);
179
- // The reader's visual anchor: the topmost still-visible timeline element and
180
- // its offset from the viewport top. Recaptured on scroll and after every
181
- // height change, it lets us hold the reader's position when content above the
182
- // viewport expands or collapses (e.g. a turn folds when it settles).
183
- const anchorRef = useRef<{ el: Element; top: number } | null>(null);
184
- const lastItem = resolvedItems[resolvedItems.length - 1];
185
- const streaming =
186
- lastItem !== undefined &&
187
- (lastItem.kind === "agent-message" || lastItem.kind === "reasoning") &&
188
- lastItem.streaming;
189
- const working = status === "running" && !streaming;
299
+ // History windows (`hasNewer`) have a bottom that is not the live tip.
300
+ // Pin/follow must ignore that floor otherwise loadNewer appends yank the
301
+ // reader to the new page bottom. LoadOlder prepends already stay put because
302
+ // the reader is unpinned and scroll anchoring / delta correction owns place.
303
+ const hasNewerRef = useRef(hasNewer);
304
+ hasNewerRef.current = hasNewer;
305
+ // Jump-to-latest pressed while a history window is showing: the pin must
306
+ // wait for the tip window to actually land (`hasNewer` → false) — pinning
307
+ // immediately would snap to the bottom of the CURRENT history page and
308
+ // page-crawl forward through the gap.
309
+ const wantPinRef = useRef(false);
310
+ // Jump-to-start pressed: consume on the commit that swaps the window so the
311
+ // scroll-to-top write races neither the old DOM nor the prepend correction.
312
+ const pendingJumpToStartRef = useRef(false);
313
+ // Identifies the newest Jump-to-start click so a settling promise callback
314
+ // from an earlier click can never clear a re-click's pending flag.
315
+ const jumpToStartSeqRef = useRef(0);
316
+ // Prepend detection: the oldest loaded item's id changes exactly when older
317
+ // history lands (including the merge-into-first-group case where the first
318
+ // GROUP key is retained). Item ids, not group keys, are the durable signal.
319
+ const previousFirstItemIdRef = useRef<string | null>(null);
320
+ const previousScrollHeightRef = useRef(0);
321
+ // Per-commit place memory for unpinned prepend restore (see layout effect).
322
+ // Paired with max/height/client for clamp-conservation reader-intent math.
323
+ const lastScrollTopRef = useRef(0);
324
+ const lastMaxScrollRef = useRef(0);
325
+ const lastScrollHeightRef = useRef(0);
326
+ const lastClientHeightRef = useRef(0);
327
+ /**
328
+ * Armed by pointerdown on the scroller. Geometric scroll-up only unpins
329
+ * while armed — mid-turn fold+growth also drops scrollTop with a flat
330
+ * maxScroll and must NOT count as leave. Wheel/keys unpin directly.
331
+ */
332
+ const readerIntentArmRef = useRef(false);
333
+ // Resting fold state per durable group id (see fold-memory.ts). Outlives the
334
+ // deliberate chip remounts (activity→turn wrap, nested key flips) so a fold
335
+ // that already settled closed — or that the reader closed — never reopens.
336
+ const foldMemoryRef = useRef<Map<string, FoldRestingState>>(new Map());
337
+ const seenActivityIdsRef = useRef<Set<string>>(new Set());
338
+ const groupKeyByItemIdRef = useRef<Map<string, string>>(new Map());
339
+ const groupOffsetByKeyRef = useRef<Map<string, number>>(new Map());
340
+ const firstItemId = resolvedItems[0]?.id ?? null;
190
341
  // Bulk paints (the initial tail window, a prepended older window — detected
191
342
  // by the first group key changing) must not run per-row entrance animations.
192
343
  const firstKeyChangedForBulk =
193
344
  previousBulkFirstKeyRef.current !== undefined &&
194
345
  previousBulkFirstKeyRef.current !== firstGroupKey;
195
- const bulkRender =
196
- allGroups.length > 0 && (bulkActive || firstKeyChangedForBulk || mountingOlderGroups);
197
-
198
- // Snapshot the topmost visible element and where it sits in the viewport, so a
199
- // later reflow can restore it to the same spot. Prefer durable row wrappers
200
- // over their containing group: an older activity item can merge into the
201
- // existing group, leaving that outer group in place while shifting every row
202
- // after the insertion. Transient chrome (the backfill sentinel and shimmer) is
203
- // skipped — anchoring to a row that unmounts when the older window lands would
204
- // drop the correction mid-prepend.
205
- const captureAnchor = useCallback(() => {
206
- const node = scrollRef.current;
207
- const inner = node?.firstElementChild;
208
- if (!node || !inner) {
209
- anchorRef.current = null;
210
- return;
346
+ const bulkRender = allGroups.length > 0 && (bulkActive || firstKeyChangedForBulk);
347
+
348
+ // The ONLY writer of the pinned flag. Ref and state move together, so
349
+ // behavior (refs read by rAF callbacks) and rendering (the anchor class,
350
+ // the Jump-to-latest button) can never desync.
351
+ const applyPinned = useCallback((value: boolean) => {
352
+ if (pinnedRef.current !== value) {
353
+ pinnedRef.current = value;
354
+ setPinned(value);
211
355
  }
212
- const containerTop = node.getBoundingClientRect().top;
213
- const findVisible = (selector: string): Element | null => {
214
- for (const candidate of inner.querySelectorAll(selector)) {
215
- if (candidate instanceof HTMLElement && candidate.dataset.ogTimelineChrome !== undefined) {
216
- continue;
217
- }
218
- const rect = candidate.getBoundingClientRect();
219
- if (rect.bottom > containerTop + 1 && rect.height > 0) {
220
- return candidate;
221
- }
356
+ }, []);
357
+
358
+ const revealedRef = useRef(revealed);
359
+ revealedRef.current = revealed;
360
+
361
+ // Pure tip-follow camera. Pin intent uses clamp conservation, not timers.
362
+ const followRef = useRef<TipFollowState>(createTipFollowState());
363
+ const followFrameRef = useRef<number | null>(null);
364
+
365
+ const syncScrollBaseline = useCallback((node: HTMLElement) => {
366
+ lastScrollTopRef.current = node.scrollTop;
367
+ lastMaxScrollRef.current = maxScrollOf(node);
368
+ lastScrollHeightRef.current = node.scrollHeight;
369
+ lastClientHeightRef.current = node.clientHeight;
370
+ }, []);
371
+
372
+ const stopFollow = useCallback(() => {
373
+ followRef.current = tipFollowCancel(followRef.current);
374
+ if (followFrameRef.current !== null) {
375
+ cancelFrame(followFrameRef.current);
376
+ followFrameRef.current = null;
377
+ }
378
+ }, []);
379
+
380
+ /** Reader left the tip — wheel, keyboard, or pointer-armed scroll-up. */
381
+ const releasePinFromReader = useCallback(
382
+ (node?: HTMLElement | null) => {
383
+ if (!autoFollow || !pinnedRef.current || hasNewerRef.current) {
384
+ return;
222
385
  }
223
- return null;
224
- };
225
- const anchor =
226
- findVisible("[data-og-timeline-row-anchor]") ??
227
- findVisible("[data-og-timeline-group-anchor]");
228
- if (anchor) {
229
- const rect = anchor.getBoundingClientRect();
230
- anchorRef.current = { el: anchor, top: rect.top - containerTop };
386
+ // Unscrollable window: unpin strands Jump-to-latest with no way back.
387
+ if (node && maxScrollOf(node) <= 1) {
388
+ return;
389
+ }
390
+ readerIntentArmRef.current = false;
391
+ stopFollow();
392
+ applyPinned(false);
393
+ if (wantPinRef.current) {
394
+ wantPinRef.current = false;
395
+ }
396
+ if (!olderPrefetchArmedRef.current) {
397
+ olderPrefetchArmedRef.current = true;
398
+ setOlderPrefetchArmed(true);
399
+ }
400
+ },
401
+ [autoFollow, applyPinned, stopFollow],
402
+ );
403
+
404
+ const onWheel = (event: {
405
+ deltaY: number;
406
+ deltaX: number;
407
+ target: EventTarget | null;
408
+ currentTarget: EventTarget | null;
409
+ }) => {
410
+ // Nested overflow (code / notice pre) or mostly-horizontal pan: not tip leave.
411
+ if (event.deltaY >= 0) {
231
412
  return;
232
413
  }
233
- anchorRef.current = null;
234
- }, []);
414
+ if (Math.abs(event.deltaY) <= Math.abs(event.deltaX)) {
415
+ return;
416
+ }
417
+ if (wheelConsumedByNestedScrollable(event)) {
418
+ return;
419
+ }
420
+ const node =
421
+ event.currentTarget instanceof HTMLElement ? event.currentTarget : scrollRef.current;
422
+ releasePinFromReader(node);
423
+ };
235
424
 
236
- // Restore the user-owned anchor synchronously after React commits a frame.
237
- // ResizeObserver remains a late-measurement fallback, but progressive history
238
- // frames must be corrected before the browser can paint an intermediate jump.
239
- const restoreAnchor = useCallback(
425
+ /** Touch / stylus / mouse drag on the scroller — explicit leave (not layout). */
426
+ const onPointerDown = (event: {
427
+ button: number;
428
+ pointerType: string;
429
+ target: EventTarget | null;
430
+ }) => {
431
+ // Primary button / touch / pen only. Ignore right-click etc.
432
+ if (event.button !== 0 && event.pointerType === "mouse") {
433
+ return;
434
+ }
435
+ // Clicks on chips/buttons/links must not arm — their settle collapse
436
+ // also drops scrollTop and would false-unpin. Drag on prose/scroller may.
437
+ if (
438
+ event.target instanceof Element &&
439
+ event.target.closest("button, a, input, textarea, select, [role='button']")
440
+ ) {
441
+ return;
442
+ }
443
+ readerIntentArmRef.current = true;
444
+ };
445
+
446
+ const onKeyDown = (event: { key: string; currentTarget: EventTarget | null }) => {
447
+ if (event.key !== "ArrowUp" && event.key !== "PageUp" && event.key !== "Home") {
448
+ return;
449
+ }
450
+ const node =
451
+ event.currentTarget instanceof HTMLElement ? event.currentTarget : scrollRef.current;
452
+ releasePinFromReader(node);
453
+ };
454
+
455
+ const snapToBottom = useCallback(
240
456
  (node: HTMLElement) => {
241
- if (autoFollow && pinnedRef.current) {
242
- assignScrollTop(node, node.scrollHeight);
457
+ stopFollow();
458
+ node.scrollTop = Math.max(0, node.scrollHeight - node.clientHeight);
459
+ syncScrollBaseline(node);
460
+ followRef.current = {
461
+ ...followRef.current,
462
+ lastHeight: node.scrollHeight,
463
+ };
464
+ },
465
+ [stopFollow, syncScrollBaseline],
466
+ );
467
+
468
+ const driveFollowRef = useRef<(node: HTMLElement, now?: number) => void>(() => undefined);
469
+ const driveFollow = useCallback(
470
+ (node: HTMLElement, nowMs?: number) => {
471
+ if (!pinnedRef.current || hasNewerRef.current) {
472
+ stopFollow();
243
473
  return;
244
474
  }
245
- const anchor = anchorRef.current;
246
- if (!anchor || !anchor.el.isConnected) {
475
+ // Prefer the rAF timestamp so ease integrates against vsync (and tests
476
+ // can advance a synthetic clock via requestAnimationFrame callbacks).
477
+ const now =
478
+ typeof nowMs === "number"
479
+ ? nowMs
480
+ : typeof performance !== "undefined"
481
+ ? performance.now()
482
+ : Date.now();
483
+ const previousHeight = followRef.current.lastHeight;
484
+ // Settle-collapse: compensate Δh from the pre-shrink baseline (browser
485
+ // may already have clamped — don't double-subtract). Keep the follow rAF
486
+ // alive so when collapse ends (or stream resumes) we ease instead of a
487
+ // hard stop → flick. Do NOT tip-ease on the same frame as a real shrink
488
+ // (that fight was the top-of-viewport flicker).
489
+ if (previousHeight > 0 && node.scrollHeight < previousHeight - TIP_FOLLOW_SHRINK_EPS_PX) {
490
+ const nextTop = tipFollowCompensateShrink(
491
+ lastScrollTopRef.current,
492
+ previousHeight,
493
+ node.scrollHeight,
494
+ node.clientHeight,
495
+ );
496
+ if (node.scrollTop !== nextTop) {
497
+ node.scrollTop = nextTop;
498
+ }
499
+ syncScrollBaseline(node);
500
+ followRef.current = {
501
+ ...followRef.current,
502
+ lastHeight: node.scrollHeight,
503
+ running: true,
504
+ lastTs: now,
505
+ };
506
+ if (followFrameRef.current === null) {
507
+ followFrameRef.current = requestFrame((frameNow) => {
508
+ followFrameRef.current = null;
509
+ const current = scrollRef.current;
510
+ if (current) {
511
+ driveFollowRef.current(current, frameNow);
512
+ }
513
+ });
514
+ }
247
515
  return;
248
516
  }
249
- const containerTop = node.getBoundingClientRect().top;
250
- const now = anchor.el.getBoundingClientRect().top - containerTop;
251
- const diff = now - anchor.top;
252
- if (diff !== 0) {
253
- assignScrollTop(node, node.scrollTop + diff);
517
+ // Sub-eps height noise: adopt height without moving the camera.
518
+ if (previousHeight > 0 && node.scrollHeight < previousHeight) {
519
+ followRef.current = {
520
+ ...followRef.current,
521
+ lastHeight: node.scrollHeight,
522
+ };
523
+ }
524
+ const result = tipFollowStep(followRef.current, {
525
+ scrollTop: node.scrollTop,
526
+ scrollHeight: node.scrollHeight,
527
+ clientHeight: node.clientHeight,
528
+ now,
529
+ pinned: true,
530
+ reducedMotion: prefersReducedMotion(),
531
+ revealed: revealedRef.current,
532
+ });
533
+ followRef.current = result.state;
534
+ if (node.scrollTop !== result.scrollTop) {
535
+ node.scrollTop = result.scrollTop;
536
+ }
537
+ syncScrollBaseline(node);
538
+ if (result.state.running) {
539
+ if (followFrameRef.current === null) {
540
+ followFrameRef.current = requestFrame((frameNow) => {
541
+ followFrameRef.current = null;
542
+ const current = scrollRef.current;
543
+ if (current) {
544
+ driveFollowRef.current(current, frameNow);
545
+ }
546
+ });
547
+ }
548
+ } else if (followFrameRef.current !== null) {
549
+ cancelFrame(followFrameRef.current);
550
+ followFrameRef.current = null;
254
551
  }
255
552
  },
256
- [assignScrollTop, autoFollow],
553
+ [stopFollow, syncScrollBaseline],
257
554
  );
555
+ driveFollowRef.current = driveFollow;
258
556
 
259
- // A LAYOUT effect so the very first paint of a freshly loaded session is
260
- // already anchored at the bottom — no visible traversal down the history.
557
+ useEffect(() => stopFollow, [stopFollow]);
558
+
559
+ // The single post-commit scroll authority. Runs after EVERY commit (no dep
560
+ // list): any commit may change content height, and the decision is cheap.
561
+ // Also the ONLY writer of the prepend-correction baselines
562
+ // (previousFirstItemIdRef / previousScrollHeightRef / group offset maps).
563
+ // oxlint-disable-next-line react-hooks/exhaustive-deps -- Deliberately runs after every commit.
261
564
  useLayoutEffect(() => {
262
565
  const node = scrollRef.current;
263
- if (node) {
264
- restoreAnchor(node);
566
+ if (!node) {
567
+ return;
568
+ }
569
+ const previousFirstItemId = previousFirstItemIdRef.current;
570
+ const previousGroupKeyByItemId = groupKeyByItemIdRef.current;
571
+ const previousGroupOffsetByKey = groupOffsetByKeyRef.current;
572
+ const previousScrollTop = lastScrollTopRef.current;
573
+ const firstItemChanged = previousFirstItemId !== null && firstItemId !== previousFirstItemId;
574
+ const prepended =
575
+ firstItemChanged && resolvedItems.some((item) => item.id === previousFirstItemId);
576
+ if (pendingJumpToStartRef.current && firstItemChanged) {
577
+ // The oldest window landed — jump against the NEW DOM, and skip the
578
+ // prepend correction (it would shift the reader away from the top).
579
+ pendingJumpToStartRef.current = false;
580
+ stopFollow();
581
+ node.scrollTop = 0;
582
+ } else if (wantPinRef.current && !hasNewer) {
583
+ // Jump-to-latest was pressed on a history window and the tip window is
584
+ // in THIS commit — consume pre-paint so the first tip frame is already
585
+ // at the bottom (post-paint consumption flashed one clamped frame).
586
+ wantPinRef.current = false;
587
+ if (autoFollow) {
588
+ applyPinned(true);
589
+ snapToBottom(node);
590
+ }
591
+ } else if (autoFollow && pinnedRef.current && !hasNewer) {
592
+ // Live tip: tip-follow camera eases down (snap only first paint / jumps).
593
+ driveFollow(node);
594
+ } else if (prepended) {
595
+ // Keep the reader on the same retained rows. Prefer the offsetTop delta
596
+ // of the group that still holds the previous first item — that stays
597
+ // correct when loadOlder also truncates the tip (height delta then lies).
598
+ // If native anchoring already applied the same shift, leave scrollTop.
599
+ const anchorKey =
600
+ previousFirstItemId !== null
601
+ ? previousGroupKeyByItemId.get(previousFirstItemId)
602
+ : undefined;
603
+ const previousAnchorTop =
604
+ anchorKey !== undefined ? previousGroupOffsetByKey.get(anchorKey) : undefined;
605
+ const anchorEl =
606
+ anchorKey !== undefined
607
+ ? node.querySelector(`[data-og-group-key="${cssEscapeAttribute(anchorKey)}"]`)
608
+ : null;
609
+ let delta: number | null = null;
610
+ if (anchorEl instanceof HTMLElement && previousAnchorTop !== undefined) {
611
+ const moved = Math.round(anchorEl.offsetTop - previousAnchorTop);
612
+ if (moved !== 0) {
613
+ delta = moved;
614
+ }
615
+ }
616
+ if (delta === null) {
617
+ const heightDelta = Math.round(node.scrollHeight - previousScrollHeightRef.current);
618
+ if (heightDelta > 0) {
619
+ delta = heightDelta;
620
+ }
621
+ }
622
+ if (delta !== null) {
623
+ const expected = previousScrollTop + delta;
624
+ if (Math.abs(node.scrollTop - expected) > 2) {
625
+ node.scrollTop = expected;
626
+ }
627
+ }
628
+ }
629
+ // After a prepend, if restore left us below the top prefetch band,
630
+ // re-arm so a later approach can load again. Still cooling while parked
631
+ // inside the band (short pages) — that stops the y=0 load loop.
632
+ if (
633
+ prepended &&
634
+ !pinnedRef.current &&
635
+ olderLoadGateRef.current === "cooling" &&
636
+ node.scrollTop > OLDER_PREFETCH_MARGIN_PX
637
+ ) {
638
+ olderLoadGateRef.current = "armed";
639
+ }
640
+ previousFirstItemIdRef.current = firstItemId;
641
+ previousScrollHeightRef.current = node.scrollHeight;
642
+ syncScrollBaseline(node);
643
+ // Item→group keys are cheap (data only). offsetTop queries are O(groups)
644
+ // layout reads — skip while pinned at the live tip (every stream token
645
+ // used to remeasure the whole timeline; that was the long-run lag).
646
+ const nextKeyByItemId = new Map<string, string>();
647
+ for (const { group, key } of groups) {
648
+ for (const itemId of timelineGroupItemIds(group)) {
649
+ nextKeyByItemId.set(itemId, key);
650
+ }
651
+ }
652
+ groupKeyByItemIdRef.current = nextKeyByItemId;
653
+ const needOffsets = prepended || firstItemChanged || !pinnedRef.current || Boolean(hasNewer);
654
+ if (needOffsets) {
655
+ const nextOffsetByKey = new Map<string, number>();
656
+ for (const { key } of groups) {
657
+ const el = node.querySelector(`[data-og-group-key="${cssEscapeAttribute(key)}"]`);
658
+ if (el instanceof HTMLElement) {
659
+ nextOffsetByKey.set(key, el.offsetTop);
660
+ }
661
+ }
662
+ groupOffsetByKeyRef.current = nextOffsetByKey;
265
663
  }
266
664
  if (!revealed && groups.length > 0) {
267
665
  setRevealed(true);
268
666
  }
269
- }, [resolvedItems, working, revealed, groups.length, restoreAnchor]);
667
+ });
270
668
 
271
- // A cleared timeline (stream identity change) re-arms the reveal so the next
272
- // session also first paints at its bottom.
669
+ // A cleared timeline (stream identity change) re-arms the reveal + prefetch
670
+ // gate and returns to bottom-follow for the next session's first paint.
273
671
  useLayoutEffect(() => {
274
- if (allGroups.length === 0 && revealed) {
672
+ if (allGroups.length > 0) {
673
+ return;
674
+ }
675
+ if (revealed) {
275
676
  setRevealed(false);
276
677
  }
277
- }, [allGroups.length, revealed]);
678
+ if (olderPrefetchArmedRef.current) {
679
+ olderPrefetchArmedRef.current = false;
680
+ setOlderPrefetchArmed(false);
681
+ }
682
+ olderLoadGateRef.current = "armed";
683
+ wantPinRef.current = false;
684
+ pendingJumpToStartRef.current = false;
685
+ previousFirstItemIdRef.current = null;
686
+ previousScrollHeightRef.current = 0;
687
+ lastScrollTopRef.current = 0;
688
+ lastMaxScrollRef.current = 0;
689
+ lastScrollHeightRef.current = 0;
690
+ lastClientHeightRef.current = 0;
691
+ groupKeyByItemIdRef.current = new Map();
692
+ groupOffsetByKeyRef.current = new Map();
693
+ foldMemoryRef.current.clear();
694
+ seenActivityIdsRef.current.clear();
695
+ applyPinned(true);
696
+ }, [allGroups.length, revealed, applyPinned]);
278
697
 
279
698
  // Clear the bulk-paint marker a frame after it renders, so rows appended
280
699
  // live (streams, new turns) animate exactly as before.
@@ -284,27 +703,21 @@ export function MessageTimeline({
284
703
  return;
285
704
  }
286
705
  setBulkActive(true);
287
- // Initial tails and prepended history mount from newest to oldest across
288
- // frames. Keep every row born during that bulk window animation-free; only
289
- // clear the marker after the authoritative group list is fully mounted.
290
- if (mountingOlderGroups) {
291
- return;
292
- }
293
706
  const frame = requestFrame(() => setBulkActive(false));
294
707
  return () => cancelFrame(frame);
295
- }, [bulkRender, firstGroupKey, mountingOlderGroups]);
708
+ }, [bulkRender, firstGroupKey]);
296
709
 
297
- // Prefetch older history well before the reader reaches the top: the
298
- // sentinel sits above the first group and trips 1600px early, so backfill
299
- // is usually rendered (and anchored by the ResizeObserver below) before the
300
- // top of the window ever becomes visible.
710
+ // Prefetch older history only after the reader scrolls up from the tip.
711
+ // Once armed, the sentinel trips early so backfill is usually rendered
712
+ // (and its scroll delta corrected) before the reader reaches it. Gated so
713
+ // a short prepend that leaves the sentinel intersecting cannot loop.
301
714
  useEffect(() => {
302
715
  const root = scrollRef.current;
303
716
  const target = topSentinelRef.current;
304
717
  if (
305
718
  !root ||
306
719
  !target ||
307
- mountingOlderGroups ||
720
+ !olderPrefetchArmed ||
308
721
  !hasOlder ||
309
722
  loadingOlder ||
310
723
  !onLoadOlder ||
@@ -312,24 +725,57 @@ export function MessageTimeline({
312
725
  ) {
313
726
  return;
314
727
  }
728
+ const observer = new IntersectionObserver(
729
+ (entries) => {
730
+ const intersecting = entries.some((entry) => entry.isIntersecting);
731
+ if (!intersecting) {
732
+ // Left the top band — next approach may load once.
733
+ olderLoadGateRef.current = "armed";
734
+ return;
735
+ }
736
+ if (olderLoadGateRef.current !== "armed") {
737
+ return;
738
+ }
739
+ olderLoadGateRef.current = "cooling";
740
+ onLoadOlder();
741
+ },
742
+ { root, rootMargin: OLDER_PREFETCH_ROOT_MARGIN },
743
+ );
744
+ observer.observe(target);
745
+ return () => observer.disconnect();
746
+ }, [olderPrefetchArmed, hasOlder, loadingOlder, onLoadOlder, firstGroupKey]);
747
+
748
+ // History view: page forward when the reader nears the bottom of the current
749
+ // non-tip window. Does not pull the whole gap — one density-bounded page.
750
+ useEffect(() => {
751
+ const root = scrollRef.current;
752
+ const target = bottomSentinelRef.current;
753
+ if (
754
+ !root ||
755
+ !target ||
756
+ !hasNewer ||
757
+ loadingNewer ||
758
+ !onLoadNewer ||
759
+ typeof IntersectionObserver === "undefined"
760
+ ) {
761
+ return;
762
+ }
315
763
  const observer = new IntersectionObserver(
316
764
  (entries) => {
317
765
  if (entries.some((entry) => entry.isIntersecting)) {
318
- onLoadOlder();
766
+ onLoadNewer();
319
767
  }
320
768
  },
321
- { root, rootMargin: "1600px 0px 0px 0px" },
769
+ { root, rootMargin: "0px 0px 1200px 0px" },
322
770
  );
323
771
  observer.observe(target);
324
772
  return () => observer.disconnect();
325
- }, [hasOlder, loadingOlder, onLoadOlder, firstGroupKey, mountingOlderGroups]);
326
-
327
- // Scroll anchoring: when the content reflows (a fold expands/collapses, a
328
- // stream appends), keep following the bottom if pinned; otherwise pin the
329
- // reader's anchor in place. A change ABOVE the anchor shifts its viewport
330
- // offset we correct scrollTop by that shift so the reader never gets yanked.
331
- // A change BELOW the anchor (a bottom append while scrolled up) leaves the
332
- // anchor put, so `diff` is 0 and we leave scrollTop alone.
773
+ }, [hasNewer, loadingNewer, onLoadNewer, firstGroupKey]);
774
+
775
+ // Late layout that React commits cannot see (images decoding, fonts, code
776
+ // blocks) grows content without a commit. While pinned, soft-follow the tip;
777
+ // unpinned: do nothing chasing those shifts was the wobble. Coalesce RO
778
+ // into one rAF.
333
779
  useEffect(() => {
334
780
  const node = scrollRef.current;
335
781
  const inner = node?.firstElementChild;
@@ -337,138 +783,393 @@ export function MessageTimeline({
337
783
  return;
338
784
  }
339
785
  const observer = new ResizeObserver(() => {
340
- const current = scrollRef.current;
341
- if (!current) {
786
+ if (resizeFollowRafRef.current !== null) {
342
787
  return;
343
788
  }
344
- restoreAnchor(current);
789
+ resizeFollowRafRef.current = requestFrame(() => {
790
+ resizeFollowRafRef.current = null;
791
+ const current = scrollRef.current;
792
+ if (!current) {
793
+ return;
794
+ }
795
+ if (autoFollow && pinnedRef.current && !hasNewerRef.current) {
796
+ driveFollow(current);
797
+ }
798
+ });
345
799
  });
346
800
  observer.observe(inner);
347
- return () => observer.disconnect();
348
- }, [restoreAnchor]);
801
+ // The scroller's own box moves the bottom too (window resize, composer
802
+ // growing): clientHeight changes with no inner resize and no commit.
803
+ observer.observe(node);
804
+ return () => {
805
+ observer.disconnect();
806
+ if (resizeFollowRafRef.current !== null) {
807
+ cancelFrame(resizeFollowRafRef.current);
808
+ resizeFollowRafRef.current = null;
809
+ }
810
+ };
811
+ }, [autoFollow, driveFollow]);
812
+
813
+ // Entering a non-tip history window: drop any live pin so the page bottom
814
+ // cannot re-stick follow across loadNewer. Leaving it (the tip window
815
+ // landed): honor a pending Jump-to-latest, or re-pin a reader already parked
816
+ // at what just became the live bottom — paging forward to the tip must not
817
+ // strand them unpinned watching new content grow below.
818
+ useEffect(() => {
819
+ if (hasNewer) {
820
+ stopFollow();
821
+ applyPinned(false);
822
+ return;
823
+ }
824
+ const node = scrollRef.current;
825
+ if (!node) {
826
+ return;
827
+ }
828
+ if (wantPinRef.current) {
829
+ wantPinRef.current = false;
830
+ if (autoFollow) {
831
+ applyPinned(true);
832
+ snapToBottom(node);
833
+ }
834
+ return;
835
+ }
836
+ if (autoFollow && !pinnedRef.current && isNearBottom(node)) {
837
+ applyPinned(true);
838
+ }
839
+ }, [hasNewer, autoFollow, applyPinned, snapToBottom, stopFollow]);
349
840
 
841
+ // Unpin: wheel / keys always; pointer-armed geometric scroll-up only.
842
+ // Mid-turn fold + narration drops scrollTop while maxScroll stays flat —
843
+ // treating that as reader-up was the Jump-to-latest bug on soft-follow.
350
844
  const onScroll = () => {
351
845
  const node = scrollRef.current;
352
846
  if (!node) {
353
847
  return;
354
848
  }
355
- const pending = programmaticScrollTargetsRef.current;
356
- const programmaticIndex = pending.findIndex((target) => Math.abs(node.scrollTop - target) <= 1);
357
- const programmatic = programmaticIndex >= 0;
358
- if (programmatic) {
359
- pending.splice(0, programmaticIndex + 1);
360
- } else {
361
- // A real reader scroll supersedes every delayed programmatic echo.
362
- pending.length = 0;
849
+ const previousTop = lastScrollTopRef.current;
850
+ const previousMaxScroll = lastMaxScrollRef.current;
851
+ const nextTop = node.scrollTop;
852
+ const nextMaxScroll = maxScrollOf(node);
853
+ const nextHeight = node.scrollHeight;
854
+ const readerUp = readerScrollUpPx(previousTop, nextTop, previousMaxScroll, nextMaxScroll);
855
+ const maxFell = nextMaxScroll < previousMaxScroll - 1;
856
+ const readerArmed = readerIntentArmRef.current;
857
+ const heightShrunk =
858
+ followRef.current.lastHeight > 0 &&
859
+ nextHeight < followRef.current.lastHeight - TIP_FOLLOW_SHRINK_EPS_PX;
860
+
861
+ if (autoFollow && pinnedRef.current && !hasNewer) {
862
+ // Fold / composer shrink: compensate before baseline sync so driveFollow
863
+ // still sees the pre-shrink scrollTop (avoid double-subtract after clamp).
864
+ if (heightShrunk || maxFell) {
865
+ readerIntentArmRef.current = false;
866
+ driveFollow(node);
867
+ return;
868
+ }
869
+ syncScrollBaseline(node);
870
+ const nearBottomPinned = isNearBottom(node);
871
+ // Pointer-dragged scroll-up away from tip. Layout churn never arms this.
872
+ if (readerArmed && readerUp > TIP_FOLLOW_READER_UP_EPS_PX && !nearBottomPinned) {
873
+ readerIntentArmRef.current = false;
874
+ releasePinFromReader(node);
875
+ if (olderLoadGateRef.current === "cooling" && node.scrollTop > OLDER_PREFETCH_MARGIN_PX) {
876
+ olderLoadGateRef.current = "armed";
877
+ }
878
+ return;
879
+ }
880
+ // Growth debt / fold noise / camera echo: stay pinned and recover tip.
881
+ if (!nearBottomPinned || readerUp > TIP_FOLLOW_READER_UP_EPS_PX) {
882
+ driveFollow(node);
883
+ } else {
884
+ followRef.current = {
885
+ ...followRef.current,
886
+ lastHeight: nextHeight,
887
+ };
888
+ }
889
+ return;
363
890
  }
364
- const nextPinned = node.scrollHeight - node.scrollTop - node.clientHeight < 48;
365
- // Echoes of our own assignments may PIN but never UNPIN — only the reader
366
- // scrolling away releases the bottom-follow.
367
- if (nextPinned || !programmatic) {
368
- pinnedRef.current = nextPinned;
369
- setPinned(nextPinned);
891
+
892
+ syncScrollBaseline(node);
893
+ const nearBottom = isNearBottom(node);
894
+
895
+ // Re-pin only when the reader moved toward/at the tip — not when a fold
896
+ // clamp dragged scrollTop down onto nearBottom.
897
+ const nextPinned =
898
+ !hasNewer && nearBottom && nextTop >= previousTop - TIP_FOLLOW_READER_UP_EPS_PX;
899
+ if (!nextPinned) {
900
+ stopFollow();
370
901
  }
371
- // The assignment site already captured the corrected anchor. Recapturing
372
- // it from a delayed echo can observe the next prepended row before that
373
- // row's ResizeObserver correction and make the viewport walk up the page.
374
- if (!programmatic) {
375
- captureAnchor();
902
+ applyPinned(nextPinned);
903
+ // A far-from-bottom scroll while a Jump-to-latest is pending is the reader
904
+ // changing their mind: drop the latch, or a stale one (host rejected or
905
+ // never flipped hasNewer) would fire a surprise pin + snap whenever the
906
+ // reader later pages to the tip themselves. Our own snaps land AT the
907
+ // bottom, so their echoes read nearBottom and keep a live latch.
908
+ if (wantPinRef.current && !nearBottom) {
909
+ wantPinRef.current = false;
910
+ }
911
+ if (nextPinned) {
912
+ return;
913
+ }
914
+ if (!olderPrefetchArmedRef.current) {
915
+ olderPrefetchArmedRef.current = true;
916
+ setOlderPrefetchArmed(true);
917
+ }
918
+ // Re-arm older prefetch only after leaving the top band (scroll down into
919
+ // content). Never re-arm/load from continued scroll toward y=0.
920
+ if (olderLoadGateRef.current === "cooling" && node.scrollTop > OLDER_PREFETCH_MARGIN_PX) {
921
+ olderLoadGateRef.current = "armed";
376
922
  }
377
923
  };
378
924
 
379
925
  return (
380
926
  <LightboxProvider>
381
- <EntranceAnimationProvider value={!bulkRender}>
382
- <div className={cn("og-root relative flex min-h-0 flex-col", className)}>
383
- {/* overflow-anchor off: the browser's native scroll anchoring would fight
384
- the ResizeObserver corrections above — one authority only. */}
385
- <div
386
- ref={scrollRef}
387
- onScroll={onScroll}
388
- style={groups.length > 0 && !revealed ? { visibility: "hidden" } : undefined}
389
- className="min-h-0 flex-1 overflow-y-auto overscroll-contain px-4 py-6 [overflow-anchor:none] sm:px-6"
390
- >
391
- <div className="mx-auto flex w-full max-w-3xl flex-col gap-5">
392
- {groups.length === 0 && !working
393
- ? (emptyState ?? (
394
- <p className="py-10 text-center text-sm text-og-fg-subtle">No activity yet.</p>
395
- ))
396
- : null}
397
- {hasOlder && !mountingOlderGroups ? (
927
+ <FoldMemoryProvider value={foldMemoryRef.current}>
928
+ <SeenActivityIdsProvider value={seenActivityIdsRef.current}>
929
+ <EntranceAnimationProvider value={!bulkRender}>
930
+ <TooltipProvider delayDuration={400}>
931
+ <div className={cn("og-root relative flex min-h-0 flex-col", className)}>
932
+ {/* Pinned: anchoring off so the tip-follow camera owns the motion.
933
+ Unpinned: native scroll anchoring holds the reader's place. */}
398
934
  <div
399
- ref={topSentinelRef}
400
- data-og-top-sentinel=""
401
- data-og-timeline-chrome=""
402
- aria-hidden="true"
403
- className="h-px w-full shrink-0"
404
- />
405
- ) : null}
406
- {loadingOlder ? (
407
- <div data-og-timeline-chrome="" className="flex items-center gap-2 text-sm">
408
- <span className="og-shimmer-text font-medium">Loading earlier activity…</span>
409
- </div>
410
- ) : null}
411
- {groups.map(({ group, key }, index) => (
412
- <div key={key} data-og-timeline-group-anchor="">
413
- <TimelineGroupView
414
- group={group}
415
- renderMessageText={renderMessageText}
416
- onOpenSession={onOpenSession}
417
- onMemoryClick={onMemoryClick}
418
- onReconnect={onReconnect}
419
- resolveProviderLogo={resolveProviderLogo}
420
- toolRegistry={toolRegistry}
421
- turnSummary={turnSummary}
422
- foldLiveCluster={isAgentProgress(groups[index + 1]?.group)}
423
- />
424
- </div>
425
- ))}
426
- {working ? (
427
- <div className="flex items-center gap-2 text-sm">
428
- <span className="og-shimmer-text font-medium">Working…</span>
935
+ ref={scrollRef}
936
+ tabIndex={-1}
937
+ onScroll={onScroll}
938
+ onWheel={onWheel}
939
+ onPointerDown={onPointerDown}
940
+ onKeyDown={onKeyDown}
941
+ style={groups.length > 0 && !revealed ? { visibility: "hidden" } : undefined}
942
+ className={cn(
943
+ // tabIndex=-1 is programmatic only — never paint a focus ring on
944
+ // the whole scroller (click + Shift used to flash a blue outline).
945
+ "min-h-0 flex-1 overflow-y-auto overscroll-contain px-4 py-6 sm:px-6 outline-none",
946
+ autoFollow && pinned && !hasNewer
947
+ ? "[overflow-anchor:none]"
948
+ : "[overflow-anchor:auto]",
949
+ )}
950
+ >
951
+ <div className="relative mx-auto flex w-full max-w-3xl flex-col gap-5">
952
+ {groups.length === 0
953
+ ? (emptyState ?? (
954
+ <p className="py-10 text-center text-sm text-og-fg-subtle">
955
+ No activity yet.
956
+ </p>
957
+ ))
958
+ : null}
959
+ {hasOlder && olderPrefetchArmed ? (
960
+ // Overlaid, not a layout row: mounting/unmounting the sentinel
961
+ // must never shift content (that shift was itself a wobble).
962
+ <div
963
+ ref={topSentinelRef}
964
+ data-og-top-sentinel=""
965
+ data-og-timeline-chrome=""
966
+ aria-hidden="true"
967
+ className="pointer-events-none absolute inset-x-0 top-0 h-px"
968
+ />
969
+ ) : null}
970
+ {groups.map(({ group, key }, index) => {
971
+ const next = groups[index + 1]?.group;
972
+ const contextCompactionCount =
973
+ group.kind === "turn"
974
+ ? (group.contextCompactionCount ?? 0)
975
+ : group.kind === "activity" &&
976
+ next?.kind === "item" &&
977
+ next.item.kind === "context-compaction" &&
978
+ next.item.phase === "compacted"
979
+ ? 1
980
+ : 0;
981
+ return (
982
+ <div key={key} data-og-timeline-group-anchor="" data-og-group-key={key}>
983
+ <TimelineGroupView
984
+ group={group}
985
+ renderMessageText={renderMessageText}
986
+ onOpenSession={onOpenSession}
987
+ onMemoryClick={onMemoryClick}
988
+ onReconnect={onReconnect}
989
+ resolveProviderLogo={resolveProviderLogo}
990
+ toolRegistry={toolRegistry}
991
+ turnSummary={turnSummary}
992
+ foldLiveCluster={isAgentProgress(next)}
993
+ trailingAgentText={trailingAgentTextAfterTurn(group, next)}
994
+ contextCompactionCount={
995
+ contextCompactionCount > 0 ? contextCompactionCount : undefined
996
+ }
997
+ />
998
+ </div>
999
+ );
1000
+ })}
1001
+ {hasNewer ? (
1002
+ <div
1003
+ ref={bottomSentinelRef}
1004
+ data-og-bottom-sentinel=""
1005
+ data-og-timeline-chrome=""
1006
+ aria-hidden="true"
1007
+ className="h-px w-full"
1008
+ />
1009
+ ) : null}
1010
+ </div>
429
1011
  </div>
430
- ) : null}
431
- </div>
432
- </div>
433
- <AnimatePresence>
434
- {!pinned && autoFollow ? (
435
- <motion.button
436
- type="button"
437
- initial={{ opacity: 0, y: 8 }}
438
- animate={{ opacity: 1, y: 0 }}
439
- exit={{ opacity: 0, y: 8 }}
440
- transition={{ duration: 0.15, ease: "easeOut" }}
441
- onClick={() => {
442
- const node = scrollRef.current;
443
- if (node) {
444
- node.scrollTo({ top: node.scrollHeight, behavior: "smooth" });
445
- }
446
- pinnedRef.current = true;
447
- setPinned(true);
448
- }}
449
- className={cn(
450
- "absolute bottom-4 left-1/2 -translate-x-1/2",
451
- "inline-flex items-center gap-1.5 rounded-full border border-og-border bg-og-surface-3/90 px-3 py-1.5",
452
- "text-xs font-medium text-og-fg shadow-og-md backdrop-blur",
453
- "hover:border-og-border-strong",
454
- )}
455
- >
456
- <ArrowDownIcon className="size-3.5" />
457
- Jump to latest
458
- </motion.button>
459
- ) : null}
460
- </AnimatePresence>
461
- </div>
462
- </EntranceAnimationProvider>
1012
+ <AnimatePresence>
1013
+ {loadingOlder ||
1014
+ loadingOldest ||
1015
+ (hasOlder && onJumpToStart && olderPrefetchArmed) ? (
1016
+ // Floating over the scroller (not a timeline row) so showing and
1017
+ // hiding it never reflows history under the reader.
1018
+ <motion.div
1019
+ initial={{ opacity: 0, y: -6 }}
1020
+ animate={{ opacity: 1, y: 0 }}
1021
+ exit={{ opacity: 0, y: -6 }}
1022
+ transition={{ duration: 0.15, ease: "easeOut" }}
1023
+ data-og-loading-older=""
1024
+ aria-live="polite"
1025
+ className="absolute inset-x-0 top-3 z-10 flex justify-center gap-2"
1026
+ >
1027
+ {loadingOlder || loadingOldest ? (
1028
+ <span className="pointer-events-none inline-flex items-center rounded-full border border-og-border bg-og-surface-3/90 px-3 py-1 text-xs font-medium shadow-og-md backdrop-blur">
1029
+ <span className="og-shimmer-text">
1030
+ {loadingOldest ? "Jumping to start…" : "Loading earlier activity…"}
1031
+ </span>
1032
+ </span>
1033
+ ) : null}
1034
+ {hasOlder && onJumpToStart && !loadingOldest ? (
1035
+ <button
1036
+ type="button"
1037
+ data-og-jump-to-start=""
1038
+ disabled={loadingOlder}
1039
+ onClick={() => {
1040
+ applyPinned(false);
1041
+ pendingJumpToStartRef.current = true;
1042
+ const seq = ++jumpToStartSeqRef.current;
1043
+ const node = scrollRef.current;
1044
+ void Promise.resolve(onJumpToStart()).then(
1045
+ () => {
1046
+ // The commit that swaps in the oldest window consumes
1047
+ // the flag against the new DOM; this write covers the
1048
+ // already-committed order and the no-window-change
1049
+ // case (jumping within the current window).
1050
+ const scroller = scrollRef.current ?? node;
1051
+ if (scroller) {
1052
+ scroller.scrollTop = 0;
1053
+ }
1054
+ // A host may resolve without ever changing the
1055
+ // window (already on the oldest page). Any swap
1056
+ // commit runs its layout effect before the next
1057
+ // frame, so a flag still armed by then is the
1058
+ // no-change case — clear it, or a LATER prepend
1059
+ // would spuriously jump the reader to the top.
1060
+ requestFrame(() => {
1061
+ if (jumpToStartSeqRef.current === seq) {
1062
+ pendingJumpToStartRef.current = false;
1063
+ }
1064
+ });
1065
+ },
1066
+ () => {
1067
+ if (jumpToStartSeqRef.current === seq) {
1068
+ pendingJumpToStartRef.current = false;
1069
+ }
1070
+ },
1071
+ );
1072
+ }}
1073
+ className={cn(
1074
+ "inline-flex items-center gap-1.5 rounded-full border border-og-border bg-og-surface-3/90 px-3 py-1.5",
1075
+ "text-xs font-medium text-og-fg shadow-og-md backdrop-blur",
1076
+ "hover:border-og-border-strong disabled:opacity-60",
1077
+ )}
1078
+ >
1079
+ <ArrowUpToLineIcon className="size-3.5" />
1080
+ Jump to start
1081
+ </button>
1082
+ ) : null}
1083
+ </motion.div>
1084
+ ) : null}
1085
+ </AnimatePresence>
1086
+ <AnimatePresence>
1087
+ {loadingNewer ? (
1088
+ <motion.div
1089
+ initial={{ opacity: 0, y: 6 }}
1090
+ animate={{ opacity: 1, y: 0 }}
1091
+ exit={{ opacity: 0, y: 6 }}
1092
+ transition={{ duration: 0.15, ease: "easeOut" }}
1093
+ data-og-loading-newer=""
1094
+ aria-live="polite"
1095
+ className="pointer-events-none absolute inset-x-0 bottom-14 z-10 flex justify-center"
1096
+ >
1097
+ <span className="inline-flex items-center rounded-full border border-og-border bg-og-surface-3/90 px-3 py-1 text-xs font-medium shadow-og-md backdrop-blur">
1098
+ <span className="og-shimmer-text">Loading later activity…</span>
1099
+ </span>
1100
+ </motion.div>
1101
+ ) : null}
1102
+ </AnimatePresence>
1103
+ <AnimatePresence>
1104
+ {((!pinned && autoFollow) || hasNewer) && autoFollow ? (
1105
+ <motion.button
1106
+ type="button"
1107
+ initial={{ opacity: 0, y: 8 }}
1108
+ animate={{ opacity: 1, y: 0 }}
1109
+ exit={{ opacity: 0, y: 8 }}
1110
+ transition={{ duration: 0.15, ease: "easeOut" }}
1111
+ onClick={() => {
1112
+ if (hasNewer) {
1113
+ // Do not pin against the current history page — its bottom
1114
+ // is not the tip. The pin + snap run when the tip window
1115
+ // actually lands (`hasNewer` flips false).
1116
+ wantPinRef.current = true;
1117
+ const node = scrollRef.current;
1118
+ if (onJumpToLatest) {
1119
+ void Promise.resolve(onJumpToLatest()).then(
1120
+ () => {
1121
+ // Covers a host that flipped hasNewer before
1122
+ // resolving; otherwise the tip-window commit
1123
+ // consumes the flag.
1124
+ const current = scrollRef.current;
1125
+ if (current && wantPinRef.current && !hasNewerRef.current) {
1126
+ wantPinRef.current = false;
1127
+ applyPinned(true);
1128
+ snapToBottom(current);
1129
+ }
1130
+ },
1131
+ () => {
1132
+ // The tip reload failed (ordinary network error):
1133
+ // an armed latch would fire a surprise snap when
1134
+ // the reader later pages to the tip themselves.
1135
+ wantPinRef.current = false;
1136
+ },
1137
+ );
1138
+ } else if (node) {
1139
+ // No tip reload available: jump within the in-memory
1140
+ // window so the newer sentinel can page forward; the
1141
+ // latch pins if the tip window eventually lands.
1142
+ snapToBottom(node);
1143
+ }
1144
+ return;
1145
+ }
1146
+ const node = scrollRef.current;
1147
+ if (node) {
1148
+ applyPinned(true);
1149
+ snapToBottom(node);
1150
+ }
1151
+ }}
1152
+ className={cn(
1153
+ "absolute bottom-4 left-1/2 -translate-x-1/2",
1154
+ "inline-flex items-center gap-1.5 rounded-full border border-og-border bg-og-surface-3/90 px-3 py-1.5",
1155
+ "text-xs font-medium text-og-fg shadow-og-md backdrop-blur",
1156
+ "hover:border-og-border-strong",
1157
+ )}
1158
+ >
1159
+ <ArrowDownIcon className="size-3.5" />
1160
+ Jump to latest
1161
+ </motion.button>
1162
+ ) : null}
1163
+ </AnimatePresence>
1164
+ </div>
1165
+ </TooltipProvider>
1166
+ </EntranceAnimationProvider>
1167
+ </SeenActivityIdsProvider>
1168
+ </FoldMemoryProvider>
463
1169
  </LightboxProvider>
464
1170
  );
465
1171
  }
466
1172
 
467
- type MountedGroupWindow = {
468
- groupKeys: string[];
469
- visibleStart: number;
470
- };
471
-
472
1173
  type KeyedTimelineGroup = {
473
1174
  group: TimelineGroup;
474
1175
  key: string;
@@ -498,7 +1199,10 @@ function useStableTimelineGroupKeys(allGroups: TimelineGroup[]): KeyedTimelineGr
498
1199
  let retainedKey: string | undefined;
499
1200
  for (const itemId of itemIds) {
500
1201
  const previous = previousByItemId.get(itemId);
501
- if (previous && !usedKeys.has(previous.key)) {
1202
+ // Retain only same-kind matches. Activity → turn wrap must NOT keep the
1203
+ // activity chip's React key: that reused a collapsed TurnSummary and
1204
+ // skipped the settle beat (insta-collapse / content flash).
1205
+ if (previous && previous.group.kind === group.kind && !usedKeys.has(previous.key)) {
502
1206
  retainedKey = previous.key;
503
1207
  break;
504
1208
  }
@@ -523,82 +1227,6 @@ function useStableTimelineGroupKeys(allGroups: TimelineGroup[]): KeyedTimelineGr
523
1227
  return keyedGroups;
524
1228
  }
525
1229
 
526
- /**
527
- * Bulk history is already projected into its authoritative order before this
528
- * hook runs. Mount its newest group first, then prepend one older group per
529
- * animation frame so low-end browsers can paint and service input between
530
- * React/Markdown commits instead of doing the entire tail in one long task.
531
- *
532
- * A live append does not move the first mounted key, so it is included
533
- * immediately in the mounted suffix. A history prepend moves that key deeper
534
- * in the array; shifting `visibleStart` by that exact prefix keeps every
535
- * existing row mounted while the new prefix is revealed. If projection
536
- * coalesces the seam item itself away, the earliest surviving mounted key
537
- * provides the same anchor. Replacements with no shared mounted key (for
538
- * example a session switch or clear-view) start a fresh suffix.
539
- */
540
- function useProgressivelyMountedGroups(allGroups: KeyedTimelineGroup[]): {
541
- mountedGroups: KeyedTimelineGroup[];
542
- mountingOlderGroups: boolean;
543
- } {
544
- const previousGroupKeysRef = useRef<string[]>([]);
545
- const groupKeys = useMemo(() => {
546
- const nextKeys = allGroups.map((group) => group.key);
547
- const previousKeys = previousGroupKeysRef.current;
548
- return nextKeys.length === previousKeys.length &&
549
- nextKeys.every((key, index) => key === previousKeys[index])
550
- ? previousKeys
551
- : nextKeys;
552
- }, [allGroups]);
553
- useLayoutEffect(() => {
554
- previousGroupKeysRef.current = groupKeys;
555
- }, [groupKeys]);
556
- const [window, setWindow] = useState<MountedGroupWindow>(() => ({
557
- groupKeys,
558
- visibleStart: Math.max(0, allGroups.length - INITIAL_MOUNTED_GROUPS),
559
- }));
560
-
561
- const lastPossibleStart = Math.max(0, allGroups.length - INITIAL_MOUNTED_GROUPS);
562
- let visibleStart = 0;
563
- if (allGroups.length > 0) {
564
- const currentIndexByKey = new Map(groupKeys.map((key, index) => [key, index]));
565
- let retainedStart: number | undefined;
566
- for (const key of window.groupKeys.slice(window.visibleStart)) {
567
- const index = currentIndexByKey.get(key);
568
- if (index !== undefined) {
569
- retainedStart = index;
570
- break;
571
- }
572
- }
573
- visibleStart =
574
- retainedStart === undefined ? lastPossibleStart : Math.min(retainedStart, lastPossibleStart);
575
- }
576
-
577
- useEffect(() => {
578
- // Synchronize a prepend/replacement before scheduling its first reveal.
579
- if (window.groupKeys !== groupKeys || window.visibleStart !== visibleStart) {
580
- setWindow({ groupKeys, visibleStart });
581
- return;
582
- }
583
- if (visibleStart === 0) {
584
- return;
585
- }
586
- const frame = requestFrame(() => {
587
- setWindow((current) =>
588
- current.groupKeys === groupKeys && current.visibleStart > 0
589
- ? { ...current, visibleStart: current.visibleStart - 1 }
590
- : current,
591
- );
592
- });
593
- return () => cancelFrame(frame);
594
- }, [groupKeys, visibleStart, window.groupKeys, window.visibleStart]);
595
-
596
- return {
597
- mountedGroups: allGroups.slice(visibleStart),
598
- mountingOlderGroups: visibleStart > 0,
599
- };
600
- }
601
-
602
1230
  function requestFrame(callback: FrameRequestCallback): number {
603
1231
  if (typeof requestAnimationFrame === "function") {
604
1232
  return requestAnimationFrame(callback);
@@ -614,10 +1242,11 @@ function cancelFrame(id: number): void {
614
1242
  window.clearTimeout(id);
615
1243
  }
616
1244
 
617
- // Progressive history mounting reuses the exact group objects from `allGroups`.
618
- // Skip repainting those stable rows on every prefix reveal; live projection
619
- // creates a new group object, and behavior/callback changes are separate props,
620
- // so ordinary streaming and host updates still invalidate immediately.
1245
+ // The full loaded window stays mounted, so settled history rows must be cheap
1246
+ // on every commit: projection reuses group objects for unchanged groups, so
1247
+ // memo skips them. Live projection creates a new group object, and
1248
+ // behavior/callback changes are separate props, so ordinary streaming and host
1249
+ // updates still invalidate immediately.
621
1250
  const TimelineGroupView = memo(function TimelineGroupView({
622
1251
  group,
623
1252
  renderMessageText,
@@ -628,7 +1257,10 @@ const TimelineGroupView = memo(function TimelineGroupView({
628
1257
  toolRegistry,
629
1258
  turnSummary,
630
1259
  insideTurn = false,
1260
+ nestClusterChips = false,
631
1261
  foldLiveCluster = false,
1262
+ trailingAgentText,
1263
+ contextCompactionCount,
632
1264
  }: {
633
1265
  group: TimelineGroup;
634
1266
  renderMessageText?:
@@ -648,39 +1280,126 @@ const TimelineGroupView = memo(function TimelineGroupView({
648
1280
  failure surface, so nested chips stay tinted but quiet (no repeated
649
1281
  failure text, no auto-open) — one loud error, N calm sub-expands. */
650
1282
  insideTurn?: boolean;
1283
+ /**
1284
+ * Parent turn has ≥2 foldable activity clusters. Only then do we wrap settled
1285
+ * clusters in nested chips — a single cluster under "N steps" is redundant.
1286
+ * During outer settle chrome, nested chips stay force-open (structure kept,
1287
+ * height stable) instead of flat-mapping to bare rails.
1288
+ */
1289
+ nestClusterChips?: boolean;
1290
+ /**
1291
+ * Final agent answer extracted as a sibling after a settled turn — folded
1292
+ * into "Copy turn" so the chip copies the whole assistant reply, not only
1293
+ * mid-turn narration still inside the fold.
1294
+ */
1295
+ trailingAgentText?: string | undefined;
1296
+ /** Secondary chip facet when this fold sits next to a compaction landmark. */
1297
+ contextCompactionCount?: number | undefined;
651
1298
  }) {
1299
+ const enter = useEntranceAnimation();
1300
+ const settleChrome = useTurnSettleOpen();
1301
+ const foldMemory = useFoldMemory();
1302
+ // Settled (or live-fold) activity clusters get a chip. Inside an expanded
1303
+ // turn that is the second layer — quiet nested chips under the outer turn
1304
+ // summary when contiguous activity naturally clusters (≥2 only).
1305
+ const activityShouldFold =
1306
+ group.kind === "activity" &&
1307
+ Boolean(group.outcome || (foldLiveCluster && clusterIsSettled(group)));
1308
+ // Latch live→folded so a top-level shell that was already mounted open can
1309
+ // start the settle beat without remounting bare rail → wrapper.
1310
+ const liveActivitySettle = useLiveSettleFold(activityShouldFold && !insideTurn);
1311
+ const turnDefaultOpen = !insideTurn && group.kind === "turn" && group.outcome === "failed";
1312
+ // activity-* → turn-* remount: carry resting state so settleFold does not
1313
+ // re-open a chip the reader already watched collapse.
1314
+ if (group.kind === "turn" && foldMemory && !insideTurn) {
1315
+ inheritFoldRestingState(
1316
+ foldMemory,
1317
+ group.id,
1318
+ group.groups.flatMap((child) => (child.kind === "activity" ? [child.id] : [])),
1319
+ );
1320
+ }
1321
+ const settleFold =
1322
+ group.kind === "turn" ? Boolean(enter && !insideTurn && !turnDefaultOpen) : liveActivitySettle;
652
1323
  switch (group.kind) {
653
1324
  case "activity":
654
- return group.outcome || (foldLiveCluster && clusterIsSettled(group)) ? (
1325
+ if (insideTurn) {
1326
+ // Nested chips whenever the parent has ≥2 clusters. During outer settle
1327
+ // chrome they stay force-open so structure is visible and height stays
1328
+ // stable through collapse — never flat-map to bare rails (that flash
1329
+ // was the "inner steps vanish then reappear nested" bug). Key flips
1330
+ // when chrome clears so they remount closed inside the already-hidden
1331
+ // parent (safe; mid-collapse remount of closed chips was the snap).
1332
+ // foldKey memory overrides the force-open: a cluster that already
1333
+ // settled closed pre-wrap was showing as a CHIP, so mounting it closed
1334
+ // is both the stable height and the honest state — force-opening it
1335
+ // was the "already-collapsed cluster auto-expands at the end" reopen.
1336
+ const useNestedChip = nestClusterChips && activityShouldFold;
1337
+ if (!useNestedChip) {
1338
+ return (
1339
+ <ActivityRail
1340
+ items={group.items}
1341
+ onOpenSession={onOpenSession}
1342
+ onMemoryClick={onMemoryClick}
1343
+ toolRegistry={toolRegistry}
1344
+ bare
1345
+ />
1346
+ );
1347
+ }
1348
+ return (
1349
+ <TurnSummary
1350
+ key={settleChrome ? "settle" : "rest"}
1351
+ items={group.items}
1352
+ outcome={group.outcome}
1353
+ failureText={undefined}
1354
+ bare
1355
+ defaultOpen={settleChrome ? true : undefined}
1356
+ foldKey={group.id}
1357
+ facets={turnSummary?.facets}
1358
+ contextCompactionCount={contextCompactionCount}
1359
+ >
1360
+ <ActivityRail
1361
+ items={group.items}
1362
+ onOpenSession={onOpenSession}
1363
+ onMemoryClick={onMemoryClick}
1364
+ toolRegistry={toolRegistry}
1365
+ bare
1366
+ />
1367
+ </TurnSummary>
1368
+ );
1369
+ }
1370
+ // Always the same TurnSummary shell while live so mid-turn fold only
1371
+ // flips settleFold (collapse) instead of remounting bare rail → wrapper.
1372
+ return (
655
1373
  <TurnSummary
656
1374
  items={group.items}
657
1375
  outcome={group.outcome}
658
- failureText={insideTurn ? undefined : group.failureText}
659
- defaultOpen={!insideTurn && group.outcome === "failed" ? true : undefined}
660
- bare={insideTurn}
1376
+ failureText={group.failureText}
1377
+ defaultOpen={!activityShouldFold || group.outcome === "failed" ? true : undefined}
1378
+ foldKey={group.id}
661
1379
  facets={turnSummary?.facets}
1380
+ settleFold={settleFold}
1381
+ contextCompactionCount={contextCompactionCount}
662
1382
  >
663
- <ActivityRail
664
- items={group.items}
665
- onOpenSession={onOpenSession}
666
- onMemoryClick={onMemoryClick}
667
- toolRegistry={toolRegistry}
668
- bare={insideTurn}
669
- />
1383
+ <FoldBody>
1384
+ <TurnRailFrame>
1385
+ <ActivityRail
1386
+ items={group.items}
1387
+ onOpenSession={onOpenSession}
1388
+ onMemoryClick={onMemoryClick}
1389
+ toolRegistry={toolRegistry}
1390
+ bare
1391
+ />
1392
+ </TurnRailFrame>
1393
+ </FoldBody>
670
1394
  </TurnSummary>
671
- ) : (
672
- // A nested live cluster hangs on the turn's rail (bare); a top-level one
673
- // owns its own rail.
674
- <ActivityRail
675
- items={group.items}
676
- onOpenSession={onOpenSession}
677
- onMemoryClick={onMemoryClick}
678
- toolRegistry={toolRegistry}
679
- bare={insideTurn}
680
- />
681
1395
  );
682
1396
  case "turn": {
683
1397
  const activityItems = flattenActivityItems(group.groups);
1398
+ // Second-layer chips only when there are natural multi-cluster seams —
1399
+ // otherwise the outer turn chip alone is enough ("N steps" wrapping one
1400
+ // more "N steps" was the redundant double fold).
1401
+ const nestClusters = foldableActivityClusterCount(group.groups) >= 2;
1402
+ const turnCopyText = collectTurnCopyText(group.groups, trailingAgentText);
684
1403
  const body = group.groups.map((child) => (
685
1404
  <TimelineGroupView
686
1405
  key={timelineGroupKey(child)}
@@ -693,32 +1412,32 @@ const TimelineGroupView = memo(function TimelineGroupView({
693
1412
  toolRegistry={toolRegistry}
694
1413
  turnSummary={turnSummary}
695
1414
  insideTurn
1415
+ nestClusterChips={nestClusters}
696
1416
  />
697
1417
  ));
698
1418
  return (
699
1419
  <TurnSummary
700
1420
  items={activityItems}
701
1421
  outcome={group.outcome}
702
- // One loud error at the top; nested sub-turn chips stay calm — the
703
- // parent already renders the failure reason, so clear it here exactly
704
- // as the nested activity-cluster case does.
705
1422
  failureText={insideTurn ? undefined : group.failureText}
706
1423
  durationMs={durationBetween(group.startedAt, group.endedAt)}
707
- defaultOpen={!insideTurn && group.outcome === "failed" ? true : undefined}
1424
+ defaultOpen={turnDefaultOpen ? true : undefined}
708
1425
  bare={insideTurn}
1426
+ foldKey={group.id}
709
1427
  facets={turnSummary?.facets}
1428
+ settleFold={settleFold}
1429
+ copyText={insideTurn ? undefined : turnCopyText}
1430
+ contextCompactionCount={contextCompactionCount ?? group.contextCompactionCount}
710
1431
  >
711
- {insideTurn ? (
712
- // A nested turn is already on an ancestor rail — its body just stacks
713
- // flush (the bare-node body already indents it), so no second rule.
714
- <div className="flex flex-col gap-4">{body}</div>
715
- ) : (
716
- // The top-level turn draws THE rail: one thin continuous rule that
717
- // every nested node and step hangs off of.
718
- <div className="flex flex-col gap-4 border-l-2 border-og-border pl-3 sm:pl-4">
719
- {body}
720
- </div>
721
- )}
1432
+ <FoldBody>
1433
+ {insideTurn ? (
1434
+ // A nested turn is already on an ancestor rail its body just stacks
1435
+ // flush (the bare-node body already indents it), so no second rule.
1436
+ <div className="flex flex-col gap-4">{body}</div>
1437
+ ) : (
1438
+ <TurnRailFrame>{body}</TurnRailFrame>
1439
+ )}
1440
+ </FoldBody>
722
1441
  </TurnSummary>
723
1442
  );
724
1443
  }
@@ -735,6 +1454,42 @@ const TimelineGroupView = memo(function TimelineGroupView({
735
1454
  }
736
1455
  });
737
1456
 
1457
+ /**
1458
+ * Body under a turn/activity chip. Remount flashes are gated by the timeline
1459
+ * seen-activity-id map (not by killing entrance): FoldBody used to force
1460
+ * entrance off, which made every live tool pop with no fade.
1461
+ */
1462
+ function FoldBody({ children }: { children: ReactNode }) {
1463
+ return <>{children}</>;
1464
+ }
1465
+
1466
+ /** Stable left rule for turn/activity bodies — always present so settle wrap
1467
+ never inserts or removes the rail chrome. */
1468
+ function TurnRailFrame({ children }: { children: ReactNode }) {
1469
+ return (
1470
+ <div className="flex flex-col gap-4 border-l-2 border-og-border pl-3 sm:pl-4">{children}</div>
1471
+ );
1472
+ }
1473
+
1474
+ /**
1475
+ * True once THIS component instance has seen its group transition from
1476
+ * unfolded to folded — i.e. the reader watched the rows live and the fold is
1477
+ * new information worth choreographing. Latched: TurnSummary captures the flag
1478
+ * at its own mount (the flip render), so later prop churn is inert. History
1479
+ * that mounts already folded initializes folded and never latches.
1480
+ */
1481
+ function useLiveSettleFold(folded: boolean): boolean {
1482
+ const previousFoldedRef = useRef(folded);
1483
+ const latchedRef = useRef(false);
1484
+ if (!previousFoldedRef.current && folded) {
1485
+ latchedRef.current = true;
1486
+ }
1487
+ useLayoutEffect(() => {
1488
+ previousFoldedRef.current = folded;
1489
+ });
1490
+ return latchedRef.current;
1491
+ }
1492
+
738
1493
  function timelineGroupKey(group: TimelineGroup): string {
739
1494
  switch (group.kind) {
740
1495
  case "item":
@@ -789,6 +1544,17 @@ function clusterIsSettled(group: Extract<TimelineGroup, { kind: "activity" }>):
789
1544
  });
790
1545
  }
791
1546
 
1547
+ /** Settled activity clusters that could become nested chips under a turn. */
1548
+ function foldableActivityClusterCount(groups: readonly TimelineGroup[]): number {
1549
+ let count = 0;
1550
+ for (const child of groups) {
1551
+ if (child.kind === "activity" && (child.outcome || clusterIsSettled(child))) {
1552
+ count += 1;
1553
+ }
1554
+ }
1555
+ return count;
1556
+ }
1557
+
792
1558
  function flattenActivityItems(groups: TimelineGroup[]): ActivityItem[] {
793
1559
  const items: ActivityItem[] = [];
794
1560
  for (const group of groups) {
@@ -801,6 +1567,89 @@ function flattenActivityItems(groups: TimelineGroup[]): ActivityItem[] {
801
1567
  return items;
802
1568
  }
803
1569
 
1570
+ /** Assistant prose inside a turn fold (mid-turn narration), joined for copy. */
1571
+ function collectAgentMessageText(groups: readonly TimelineGroup[]): string {
1572
+ const parts: string[] = [];
1573
+ for (const group of groups) {
1574
+ if (group.kind === "item" && group.item.kind === "agent-message") {
1575
+ const text = group.item.text.trim();
1576
+ if (text.length > 0) {
1577
+ parts.push(text);
1578
+ }
1579
+ } else if (group.kind === "turn") {
1580
+ const nested = collectAgentMessageText(group.groups);
1581
+ if (nested.length > 0) {
1582
+ parts.push(nested);
1583
+ }
1584
+ }
1585
+ }
1586
+ return parts.join("\n\n");
1587
+ }
1588
+
1589
+ /** Non-null turnIds projected into a turn body (activity + nested messages). */
1590
+ function collectTurnIdsFromGroups(groups: readonly TimelineGroup[]): Set<string> {
1591
+ const ids = new Set<string>();
1592
+ for (const group of groups) {
1593
+ if (group.kind === "item") {
1594
+ const turnId = "turnId" in group.item ? group.item.turnId : null;
1595
+ if (typeof turnId === "string" && turnId.length > 0) {
1596
+ ids.add(turnId);
1597
+ }
1598
+ } else if (group.kind === "activity") {
1599
+ for (const item of group.items) {
1600
+ if (item.turnId) {
1601
+ ids.add(item.turnId);
1602
+ }
1603
+ }
1604
+ } else if (group.kind === "turn") {
1605
+ for (const nested of collectTurnIdsFromGroups(group.groups)) {
1606
+ ids.add(nested);
1607
+ }
1608
+ }
1609
+ }
1610
+ return ids;
1611
+ }
1612
+
1613
+ /**
1614
+ * Settled turns lift the final agent answer out as a sibling group. Include it
1615
+ * in "Copy turn" when present so the chip copies the full assistant reply.
1616
+ * Fenced by turnId so the next turn's answer is never stolen.
1617
+ */
1618
+ export function trailingAgentTextAfterTurn(
1619
+ group: TimelineGroup,
1620
+ next: TimelineGroup | undefined,
1621
+ ): string | undefined {
1622
+ if (group.kind !== "turn") {
1623
+ return undefined;
1624
+ }
1625
+ if (next?.kind === "item" && next.item.kind === "agent-message") {
1626
+ const turnIds = collectTurnIdsFromGroups(group.groups);
1627
+ // Degenerate body with no turnIds: do not guess — safer than lifting wrong.
1628
+ if (turnIds.size === 0) {
1629
+ return undefined;
1630
+ }
1631
+ if (!next.item.turnId || !turnIds.has(next.item.turnId)) {
1632
+ return undefined;
1633
+ }
1634
+ const text = next.item.text.trim();
1635
+ return text.length > 0 ? text : undefined;
1636
+ }
1637
+ return undefined;
1638
+ }
1639
+
1640
+ function collectTurnCopyText(
1641
+ groups: readonly TimelineGroup[],
1642
+ trailingAgentText: string | undefined,
1643
+ ): string | undefined {
1644
+ const parts = [collectAgentMessageText(groups), trailingAgentText?.trim() ?? ""].filter(
1645
+ (part) => part.length > 0,
1646
+ );
1647
+ if (parts.length === 0) {
1648
+ return undefined;
1649
+ }
1650
+ return parts.join("\n\n");
1651
+ }
1652
+
804
1653
  function durationBetween(startedAt: string, endedAt: string): number | undefined {
805
1654
  const started = Date.parse(startedAt);
806
1655
  const ended = Date.parse(endedAt);
@@ -843,8 +1692,12 @@ export function TimelineRow({
843
1692
  return <SessionStatusRow item={item} />;
844
1693
  case "goal":
845
1694
  return <GoalRow item={item} />;
1695
+ case "machine-input-batch":
1696
+ return <MachineInputBatchRow item={item} />;
846
1697
  case "notice":
847
1698
  return <NoticeRow item={item} />;
1699
+ case "context-compaction":
1700
+ return <CompactionRow item={item} />;
848
1701
  case "auth-needed":
849
1702
  return (
850
1703
  <AuthNeededRow
@@ -858,6 +1711,82 @@ export function TimelineRow({
858
1711
  }
859
1712
  }
860
1713
 
1714
+ const COMPACTION_TRIGGER_LABEL: Record<NonNullable<ContextCompactionItem["trigger"]>, string> = {
1715
+ auto: "Auto",
1716
+ operator: "Manual",
1717
+ proactive: "Auto",
1718
+ overflow: "Overflow",
1719
+ };
1720
+
1721
+ function CompactionRow({ item }: { item: ContextCompactionItem }) {
1722
+ const enter = useEntranceAnimation();
1723
+ const trigger =
1724
+ item.trigger && item.phase !== "started" ? COMPACTION_TRIGGER_LABEL[item.trigger] : null;
1725
+ const before =
1726
+ item.estimatedTokensBefore !== null
1727
+ ? Math.round(item.estimatedTokensBefore).toLocaleString("en-US")
1728
+ : null;
1729
+ const after =
1730
+ item.estimatedTokensAfter !== null
1731
+ ? Math.round(item.estimatedTokensAfter).toLocaleString("en-US")
1732
+ : null;
1733
+ const title =
1734
+ item.phase === "started"
1735
+ ? "Compacting conversation memory…"
1736
+ : item.phase === "compacted"
1737
+ ? before && after
1738
+ ? `Conversation memory compacted · ~${before} → ~${after} tokens`
1739
+ : "Conversation memory compacted"
1740
+ : "Couldn’t compact conversation memory";
1741
+ const subtitle =
1742
+ item.phase === "compacted"
1743
+ ? "Chat history above is unchanged"
1744
+ : item.phase === "skipped"
1745
+ ? compactionSkipSubtitle(item.skipReason)
1746
+ : null;
1747
+ const pill =
1748
+ item.phase === "skipped" && item.skipReason === "summarization_failed"
1749
+ ? "border-og-status-failed/35 bg-og-status-failed/10 text-og-status-failed"
1750
+ : item.phase === "started"
1751
+ ? "border-og-status-waiting/35 bg-og-status-waiting/10 text-og-status-waiting"
1752
+ : "border-og-border bg-og-surface-1 text-og-fg-muted";
1753
+ return (
1754
+ <div className={cn(enter && "animate-og-enter", "flex justify-center")}>
1755
+ <div
1756
+ className={cn(
1757
+ "inline-flex max-w-full flex-col items-center gap-0.5 rounded-full border px-3 py-1.5 text-og-sm",
1758
+ pill,
1759
+ )}
1760
+ role="status"
1761
+ >
1762
+ <span className="inline-flex max-w-full items-center gap-1.5">
1763
+ <ShrinkIcon className="size-3.5 shrink-0" />
1764
+ <span className="truncate">
1765
+ {title}
1766
+ {trigger ? ` · ${trigger}` : ""}
1767
+ </span>
1768
+ </span>
1769
+ {subtitle ? <span className="truncate text-og-xs opacity-80">{subtitle}</span> : null}
1770
+ </div>
1771
+ </div>
1772
+ );
1773
+ }
1774
+
1775
+ function compactionSkipSubtitle(reason: string | null): string {
1776
+ switch (reason) {
1777
+ case "no_history":
1778
+ return "No active history to compact";
1779
+ case "replacement_not_smaller":
1780
+ return "Checkpoint would not reduce memory size";
1781
+ case "replacement_unchanged":
1782
+ return "Checkpoint made no progress";
1783
+ case "summarization_failed":
1784
+ return "Request it again to retry. Chat history is unchanged.";
1785
+ default:
1786
+ return "Compaction was not needed. Chat history is unchanged.";
1787
+ }
1788
+ }
1789
+
861
1790
  function UserMessageRow({
862
1791
  item,
863
1792
  renderMessageText,
@@ -871,13 +1800,19 @@ function UserMessageRow({
871
1800
  return (
872
1801
  <div className={cn(enter && "animate-og-enter", "flex justify-end")}>
873
1802
  <div className="flex max-w-[85%] min-w-0 flex-col items-end gap-1">
874
- <div className="w-fit max-w-full min-w-0 rounded-og-lg rounded-br-og-xs border border-og-border bg-og-surface-2 px-4 py-2.5 text-og-md leading-6 text-og-fg">
875
- {renderMessageText ? (
876
- renderMessageText(item.text, item)
877
- ) : (
878
- <Markdown>{item.text}</Markdown>
879
- )}
880
- </div>
1803
+ <CopyHoverFrame
1804
+ copyText={item.text}
1805
+ label="Copy message"
1806
+ className="w-fit max-w-full min-w-0"
1807
+ >
1808
+ <div className="w-fit max-w-full min-w-0 rounded-og-lg rounded-br-og-xs border border-og-border bg-og-surface-2 px-4 py-2.5 text-og-md leading-6 text-og-fg">
1809
+ {renderMessageText ? (
1810
+ renderMessageText(item.text, item)
1811
+ ) : (
1812
+ <Markdown>{item.text}</Markdown>
1813
+ )}
1814
+ </div>
1815
+ </CopyHoverFrame>
881
1816
  </div>
882
1817
  </div>
883
1818
  );
@@ -893,29 +1828,23 @@ function AgentMessageRow({
893
1828
  | undefined;
894
1829
  }) {
895
1830
  const enter = useEntranceAnimation();
896
- const caret = item.streaming ? (
897
- <span
898
- className="ml-0.5 inline-block h-[1.1em] w-[2px] translate-y-[3px] animate-og-blink rounded-full bg-og-accent"
899
- aria-hidden
900
- />
901
- ) : null;
1831
+ // No streaming caret: it fought the trailing block layout (inline ↔ block)
1832
+ // and snapped on exit. Live text carries the stream via tip ink.
1833
+ const body = renderMessageText ? (
1834
+ renderMessageText(item.text, item)
1835
+ ) : (
1836
+ <Markdown streaming={item.streaming}>{item.text}</Markdown>
1837
+ );
1838
+ // While streaming, copy is still useful (current text) but keep chrome calm.
902
1839
  return (
903
- <div className={cn(enter && "animate-og-enter", "min-w-0 text-og-md leading-7 text-og-fg")}>
904
- {renderMessageText ? (
905
- <>
906
- {renderMessageText(item.text, item)}
907
- {caret}
908
- </>
909
- ) : (
910
- // While streaming, let the caret ride the end of the last rendered line:
911
- // the trailing block (usually a <p>) flows inline so the caret sits on
912
- // its baseline instead of dropping to a new line.
913
- <div className={item.streaming ? "[&_>div>:last-child]:inline" : undefined}>
914
- <Markdown>{item.text}</Markdown>
915
- {caret}
916
- </div>
917
- )}
918
- </div>
1840
+ <CopyHoverFrame
1841
+ copyText={item.text}
1842
+ label="Copy message"
1843
+ align="end"
1844
+ className={cn(enter && "animate-og-enter", "min-w-0 text-og-md leading-7 text-og-fg")}
1845
+ >
1846
+ {body}
1847
+ </CopyHoverFrame>
919
1848
  );
920
1849
  }
921
1850
 
@@ -1165,6 +2094,81 @@ function GoalRow({ item }: { item: GoalItem }) {
1165
2094
  );
1166
2095
  }
1167
2096
 
2097
+ const MACHINE_INPUT_META: Record<MachineInputBatchItem["members"][number]["kind"], string> = {
2098
+ scheduled_occurrence: "Scheduled update",
2099
+ goal_continuation: "Goal continued",
2100
+ agent_message: "Agent update",
2101
+ agent_steer_instruction: "Agent direction",
2102
+ child_terminal_result: "Agent finished",
2103
+ };
2104
+
2105
+ function MachineInputBatchRow({ item }: { item: MachineInputBatchItem }) {
2106
+ const enter = useEntranceAnimation();
2107
+ const visible = item.members.slice(0, 3);
2108
+ return (
2109
+ <div
2110
+ className={cn(
2111
+ enter && "animate-og-enter",
2112
+ "rounded-og-md border border-og-border/70 bg-og-surface-1/55 px-3 py-2.5",
2113
+ )}
2114
+ >
2115
+ {item.members.length > 1 && (
2116
+ <div className="mb-2 text-xs font-medium text-og-fg-subtle">
2117
+ {item.members.length} updates joined this turn
2118
+ </div>
2119
+ )}
2120
+ <div className="space-y-2">
2121
+ {visible.map((member) => (
2122
+ <MachineInputRow key={member.id} member={member} />
2123
+ ))}
2124
+ </div>
2125
+ {item.members.length > visible.length && (
2126
+ <details className="mt-2 pl-8 text-xs text-og-fg-muted">
2127
+ <summary className="cursor-pointer select-none hover:text-og-fg">
2128
+ Show {item.members.length - visible.length} more
2129
+ </summary>
2130
+ <div className="mt-2 space-y-2">
2131
+ {item.members.slice(visible.length).map((member) => (
2132
+ <MachineInputRow key={member.id} member={member} />
2133
+ ))}
2134
+ </div>
2135
+ </details>
2136
+ )}
2137
+ </div>
2138
+ );
2139
+ }
2140
+
2141
+ function MachineInputRow({ member }: { member: MachineInputBatchItem["members"][number] }) {
2142
+ const source = readableMachineInputSource(member.sourceId);
2143
+ return (
2144
+ <div className="flex min-w-0 items-start gap-2.5">
2145
+ <span className="mt-2 size-1.5 shrink-0 rounded-full bg-og-fg-subtle" aria-hidden />
2146
+ <div className="min-w-0 flex-1">
2147
+ <span className="text-xs font-medium text-og-fg-muted">
2148
+ {MACHINE_INPUT_META[member.kind]}
2149
+ </span>
2150
+ {source && <span className="ml-1.5 text-xs text-og-fg-subtle">from {source}</span>}
2151
+ {member.summary && (
2152
+ <p className="mt-0.5 whitespace-pre-wrap break-words text-sm leading-5 text-og-fg">
2153
+ {truncate(cleanMachineInputSummary(member.summary), 320)}
2154
+ </p>
2155
+ )}
2156
+ </div>
2157
+ </div>
2158
+ );
2159
+ }
2160
+
2161
+ function readableMachineInputSource(sourceId: string): string | null {
2162
+ const value = sourceId.trim();
2163
+ if (!value || /^[0-9a-f]{8}-[0-9a-f-]{27,}$/i.test(value)) return null;
2164
+ if (/^(goal|schedule|system):/i.test(value)) return null;
2165
+ return value.replaceAll("_", " ");
2166
+ }
2167
+
2168
+ function cleanMachineInputSummary(summary: string): string {
2169
+ return summary.replace(/^\[[A-Z][A-Z _-]*(?:\s+\d+\/\d+)?\]\s*/, "").trim();
2170
+ }
2171
+
1168
2172
  function NoticeRow({ item }: { item: NoticeItem }) {
1169
2173
  const enter = useEntranceAnimation();
1170
2174
  const tone =