@opengeni/react 0.29.0 → 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 (207) 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-CSJBF3QJ.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-AVLMIWYO.js → chunk-KZRUYCL4.js} +12 -6
  10. package/dist/chunk-KZRUYCL4.js.map +1 -0
  11. package/dist/{chunk-OKXCECLG.js → chunk-TCCE5ZU5.js} +171 -22
  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-IPVOVOW6.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-D_1a8W_l.d.ts → timeline/types.d.ts} +41 -69
  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 +1304 -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 +23 -3
  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 +47 -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/timeline/activity-rail.tsx +46 -7
  185. package/src/timeline/entrance.tsx +10 -0
  186. package/src/timeline/fold-memory.ts +52 -0
  187. package/src/timeline/index.ts +9 -1
  188. package/src/timeline/projection.ts +226 -41
  189. package/src/timeline/seen-activity-ids.ts +19 -0
  190. package/src/timeline/shared.tsx +1 -1
  191. package/src/timeline/tool-renderers.tsx +75 -6
  192. package/src/timeline/turn-summary.tsx +387 -97
  193. package/src/timeline/types.ts +21 -0
  194. package/styles/index.css +99 -7
  195. package/styles/tokens.css +44 -3
  196. package/dist/chunk-AVLMIWYO.js.map +0 -1
  197. package/dist/chunk-CSJBF3QJ.js.map +0 -1
  198. package/dist/chunk-IPVOVOW6.js.map +0 -1
  199. package/dist/chunk-M7X4JZOD.js.map +0 -1
  200. package/dist/chunk-OKXCECLG.js.map +0 -1
  201. package/dist/chunk-ZMNPFZWL.js +0 -3918
  202. package/dist/chunk-ZMNPFZWL.js.map +0 -1
  203. package/dist/composer-BMzimGTO.d.ts +0 -528
  204. package/dist/session-DaONt-dY.d.ts +0 -264
  205. package/dist/session-context-CAjlaeZb.d.ts +0 -84
  206. package/dist/session-ui-B5_roA95.d.ts +0 -259
  207. /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,6 +54,7 @@ import {
41
54
  type ActivityItem,
42
55
  type AgentMessageItem,
43
56
  type AuthNeededItem,
57
+ type ContextCompactionItem,
44
58
  type GoalItem,
45
59
  type MachineInputBatchItem,
46
60
  type NoticeItem,
@@ -49,18 +63,26 @@ import {
49
63
  type ToolRegistry,
50
64
  type TurnSummaryOptions,
51
65
  type UserMessageItem,
66
+ type FoldRestingState,
52
67
  type WorkerCompletionItem,
68
+ FoldMemoryProvider,
69
+ inheritFoldRestingState,
53
70
  TurnSummary,
71
+ useFoldMemory,
72
+ useTurnSettleOpen,
54
73
  } from "../timeline";
74
+ import { CopyHoverFrame } from "./copy-button";
55
75
  import { SESSION_STATUS_META, StatusDot } from "./session-status";
56
76
  import { EntranceAnimationProvider, useEntranceAnimation } from "../timeline/entrance";
77
+ import { SeenActivityIdsProvider } from "../timeline/seen-activity-ids";
78
+ import { TooltipProvider } from "./tooltip";
57
79
 
58
80
  export type MessageTimelineProps = {
59
81
  /** Raw session events (projected internally) … */
60
82
  events?: SessionEvent[] | undefined;
61
83
  /** … or pre-projected items (e.g. from `useSessionEvents().timeline`). */
62
84
  items?: TimelineItem[] | undefined;
63
- /** Current session status; drives the live "working" indicator. */
85
+ /** Current session status (reserved; tip "Working…" chrome removed for now). */
64
86
  status?: SessionStatus | null | undefined;
65
87
  /** Plug a markdown renderer for message bodies (e.g. streamdown). */
66
88
  renderMessageText?:
@@ -109,11 +131,112 @@ export type MessageTimelineProps = {
109
131
  loadingOlder?: boolean | undefined;
110
132
  /** Called when the reader nears the top and older history should backfill. */
111
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;
112
149
  emptyState?: ReactNode | undefined;
113
150
  className?: string | undefined;
114
151
  };
115
152
 
116
- 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
+ }
117
240
 
118
241
  /**
119
242
  * The session timeline: chat messages with streaming deltas, collapsed
@@ -124,7 +247,7 @@ const INITIAL_MOUNTED_GROUPS = 1;
124
247
  export function MessageTimeline({
125
248
  events,
126
249
  items,
127
- status,
250
+ status: _status,
128
251
  renderMessageText,
129
252
  onOpenSession,
130
253
  onMemoryClick,
@@ -136,146 +259,441 @@ export function MessageTimeline({
136
259
  hasOlder = false,
137
260
  loadingOlder = false,
138
261
  onLoadOlder,
262
+ onJumpToStart,
263
+ loadingOldest = false,
264
+ hasNewer = false,
265
+ loadingNewer = false,
266
+ onLoadNewer,
267
+ onJumpToLatest,
139
268
  emptyState,
140
269
  className,
141
270
  }: MessageTimelineProps) {
142
271
  const resolvedItems = useMemo(() => items ?? buildTimeline(events ?? []), [items, events]);
143
272
  const allGroups = useMemo(() => groupTimeline(resolvedItems), [resolvedItems]);
144
- const keyedGroups = useStableTimelineGroupKeys(allGroups);
145
- const { mountedGroups: groups, mountingOlderGroups } = useProgressivelyMountedGroups(keyedGroups);
146
- const firstGroupKey = allGroups[0] ? timelineGroupKey(allGroups[0]) : null;
147
-
273
+ const groups = useStableTimelineGroupKeys(allGroups);
148
274
  const scrollRef = useRef<HTMLDivElement | null>(null);
149
275
  const topSentinelRef = useRef<HTMLDivElement | null>(null);
276
+ const bottomSentinelRef = useRef<HTMLDivElement | null>(null);
150
277
  const previousBulkFirstKeyRef = useRef<string | null | undefined>(undefined);
151
278
  const [pinned, setPinned] = useState(true);
152
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;
153
291
  // Content stays invisible until its first bottom-anchored frame, so a flash
154
- // of the window's TOP while a large timeline lays out across commits is
155
- // structurally impossible — the reader only ever sees it already at the
156
- // 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.
157
294
  const [revealed, setRevealed] = useState(false);
158
- // Our own scrollTop assignments echo back as delayed scroll events. Keep the
159
- // ordered, clamped targets rather than only the latest one: successive
160
- // ResizeObserver corrections can queue several echoes before the browser
161
- // dispatches them. If reader input lands at a different position, its value
162
- // wins and clears the pending echoes. A matching echo must not recapture the
163
- // visual anchor while progressive prepend is still mounting older rows.
164
- const programmaticScrollTargetsRef = useRef<number[]>([]);
165
- const assignScrollTop = useCallback((node: HTMLElement, value: number) => {
166
- const previous = node.scrollTop;
167
- node.scrollTop = value;
168
- if (node.scrollTop === previous) {
169
- return;
170
- }
171
- const pending = programmaticScrollTargetsRef.current;
172
- pending.push(node.scrollTop);
173
- if (pending.length > 32) {
174
- pending.splice(0, pending.length - 32);
175
- }
176
- }, []);
177
- // Mirror `pinned` into a ref so the ResizeObserver callback (a stable closure)
178
- // 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.
179
298
  const pinnedRef = useRef(true);
180
- // The reader's visual anchor: the topmost still-visible timeline element and
181
- // its offset from the viewport top. Recaptured on scroll and after every
182
- // height change, it lets us hold the reader's position when content above the
183
- // viewport expands or collapses (e.g. a turn folds when it settles).
184
- const anchorRef = useRef<{ el: Element; top: number } | null>(null);
185
- const lastItem = resolvedItems[resolvedItems.length - 1];
186
- const streaming =
187
- lastItem !== undefined &&
188
- (lastItem.kind === "agent-message" || lastItem.kind === "reasoning") &&
189
- lastItem.streaming;
190
- 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;
191
341
  // Bulk paints (the initial tail window, a prepended older window — detected
192
342
  // by the first group key changing) must not run per-row entrance animations.
193
343
  const firstKeyChangedForBulk =
194
344
  previousBulkFirstKeyRef.current !== undefined &&
195
345
  previousBulkFirstKeyRef.current !== firstGroupKey;
196
- const bulkRender =
197
- allGroups.length > 0 && (bulkActive || firstKeyChangedForBulk || mountingOlderGroups);
198
-
199
- // Snapshot the topmost visible element and where it sits in the viewport, so a
200
- // later reflow can restore it to the same spot. Prefer durable row wrappers
201
- // over their containing group: an older activity item can merge into the
202
- // existing group, leaving that outer group in place while shifting every row
203
- // after the insertion. Transient chrome (the backfill sentinel and shimmer) is
204
- // skipped — anchoring to a row that unmounts when the older window lands would
205
- // drop the correction mid-prepend.
206
- const captureAnchor = useCallback(() => {
207
- const node = scrollRef.current;
208
- const inner = node?.firstElementChild;
209
- if (!node || !inner) {
210
- anchorRef.current = null;
211
- 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);
212
355
  }
213
- const containerTop = node.getBoundingClientRect().top;
214
- const findVisible = (selector: string): Element | null => {
215
- for (const candidate of inner.querySelectorAll(selector)) {
216
- if (candidate instanceof HTMLElement && candidate.dataset.ogTimelineChrome !== undefined) {
217
- continue;
218
- }
219
- const rect = candidate.getBoundingClientRect();
220
- if (rect.bottom > containerTop + 1 && rect.height > 0) {
221
- return candidate;
222
- }
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;
223
385
  }
224
- return null;
225
- };
226
- const anchor =
227
- findVisible("[data-og-timeline-row-anchor]") ??
228
- findVisible("[data-og-timeline-group-anchor]");
229
- if (anchor) {
230
- const rect = anchor.getBoundingClientRect();
231
- 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) {
232
412
  return;
233
413
  }
234
- anchorRef.current = null;
235
- }, []);
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
+ };
424
+
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
+ };
236
454
 
237
- // Restore the user-owned anchor synchronously after React commits a frame.
238
- // ResizeObserver remains a late-measurement fallback, but progressive history
239
- // frames must be corrected before the browser can paint an intermediate jump.
240
- const restoreAnchor = useCallback(
455
+ const snapToBottom = useCallback(
241
456
  (node: HTMLElement) => {
242
- if (autoFollow && pinnedRef.current) {
243
- 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();
244
473
  return;
245
474
  }
246
- const anchor = anchorRef.current;
247
- 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
+ }
248
515
  return;
249
516
  }
250
- const containerTop = node.getBoundingClientRect().top;
251
- const now = anchor.el.getBoundingClientRect().top - containerTop;
252
- const diff = now - anchor.top;
253
- if (diff !== 0) {
254
- 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;
255
551
  }
256
552
  },
257
- [assignScrollTop, autoFollow],
553
+ [stopFollow, syncScrollBaseline],
258
554
  );
555
+ driveFollowRef.current = driveFollow;
259
556
 
260
- // A LAYOUT effect so the very first paint of a freshly loaded session is
261
- // 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.
262
564
  useLayoutEffect(() => {
263
565
  const node = scrollRef.current;
264
- if (node) {
265
- 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;
266
663
  }
267
664
  if (!revealed && groups.length > 0) {
268
665
  setRevealed(true);
269
666
  }
270
- }, [resolvedItems, working, revealed, groups.length, restoreAnchor]);
667
+ });
271
668
 
272
- // A cleared timeline (stream identity change) re-arms the reveal so the next
273
- // 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.
274
671
  useLayoutEffect(() => {
275
- if (allGroups.length === 0 && revealed) {
672
+ if (allGroups.length > 0) {
673
+ return;
674
+ }
675
+ if (revealed) {
276
676
  setRevealed(false);
277
677
  }
278
- }, [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]);
279
697
 
280
698
  // Clear the bulk-paint marker a frame after it renders, so rows appended
281
699
  // live (streams, new turns) animate exactly as before.
@@ -285,27 +703,21 @@ export function MessageTimeline({
285
703
  return;
286
704
  }
287
705
  setBulkActive(true);
288
- // Initial tails and prepended history mount from newest to oldest across
289
- // frames. Keep every row born during that bulk window animation-free; only
290
- // clear the marker after the authoritative group list is fully mounted.
291
- if (mountingOlderGroups) {
292
- return;
293
- }
294
706
  const frame = requestFrame(() => setBulkActive(false));
295
707
  return () => cancelFrame(frame);
296
- }, [bulkRender, firstGroupKey, mountingOlderGroups]);
708
+ }, [bulkRender, firstGroupKey]);
297
709
 
298
- // Prefetch older history well before the reader reaches the top: the
299
- // sentinel sits above the first group and trips 1600px early, so backfill
300
- // is usually rendered (and anchored by the ResizeObserver below) before the
301
- // 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.
302
714
  useEffect(() => {
303
715
  const root = scrollRef.current;
304
716
  const target = topSentinelRef.current;
305
717
  if (
306
718
  !root ||
307
719
  !target ||
308
- mountingOlderGroups ||
720
+ !olderPrefetchArmed ||
309
721
  !hasOlder ||
310
722
  loadingOlder ||
311
723
  !onLoadOlder ||
@@ -313,24 +725,57 @@ export function MessageTimeline({
313
725
  ) {
314
726
  return;
315
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
+ }
316
763
  const observer = new IntersectionObserver(
317
764
  (entries) => {
318
765
  if (entries.some((entry) => entry.isIntersecting)) {
319
- onLoadOlder();
766
+ onLoadNewer();
320
767
  }
321
768
  },
322
- { root, rootMargin: "1600px 0px 0px 0px" },
769
+ { root, rootMargin: "0px 0px 1200px 0px" },
323
770
  );
324
771
  observer.observe(target);
325
772
  return () => observer.disconnect();
326
- }, [hasOlder, loadingOlder, onLoadOlder, firstGroupKey, mountingOlderGroups]);
327
-
328
- // Scroll anchoring: when the content reflows (a fold expands/collapses, a
329
- // stream appends), keep following the bottom if pinned; otherwise pin the
330
- // reader's anchor in place. A change ABOVE the anchor shifts its viewport
331
- // offset we correct scrollTop by that shift so the reader never gets yanked.
332
- // A change BELOW the anchor (a bottom append while scrolled up) leaves the
333
- // 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.
334
779
  useEffect(() => {
335
780
  const node = scrollRef.current;
336
781
  const inner = node?.firstElementChild;
@@ -338,138 +783,393 @@ export function MessageTimeline({
338
783
  return;
339
784
  }
340
785
  const observer = new ResizeObserver(() => {
341
- const current = scrollRef.current;
342
- if (!current) {
786
+ if (resizeFollowRafRef.current !== null) {
343
787
  return;
344
788
  }
345
- 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
+ });
346
799
  });
347
800
  observer.observe(inner);
348
- return () => observer.disconnect();
349
- }, [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]);
350
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]);
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.
351
844
  const onScroll = () => {
352
845
  const node = scrollRef.current;
353
846
  if (!node) {
354
847
  return;
355
848
  }
356
- const pending = programmaticScrollTargetsRef.current;
357
- const programmaticIndex = pending.findIndex((target) => Math.abs(node.scrollTop - target) <= 1);
358
- const programmatic = programmaticIndex >= 0;
359
- if (programmatic) {
360
- pending.splice(0, programmaticIndex + 1);
361
- } else {
362
- // A real reader scroll supersedes every delayed programmatic echo.
363
- 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;
890
+ }
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();
901
+ }
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;
364
910
  }
365
- const nextPinned = node.scrollHeight - node.scrollTop - node.clientHeight < 48;
366
- // Echoes of our own assignments may PIN but never UNPIN — only the reader
367
- // scrolling away releases the bottom-follow.
368
- if (nextPinned || !programmatic) {
369
- pinnedRef.current = nextPinned;
370
- setPinned(nextPinned);
911
+ if (nextPinned) {
912
+ return;
371
913
  }
372
- // The assignment site already captured the corrected anchor. Recapturing
373
- // it from a delayed echo can observe the next prepended row before that
374
- // row's ResizeObserver correction and make the viewport walk up the page.
375
- if (!programmatic) {
376
- captureAnchor();
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";
377
922
  }
378
923
  };
379
924
 
380
925
  return (
381
926
  <LightboxProvider>
382
- <EntranceAnimationProvider value={!bulkRender}>
383
- <div className={cn("og-root relative flex min-h-0 flex-col", className)}>
384
- {/* overflow-anchor off: the browser's native scroll anchoring would fight
385
- the ResizeObserver corrections above — one authority only. */}
386
- <div
387
- ref={scrollRef}
388
- onScroll={onScroll}
389
- style={groups.length > 0 && !revealed ? { visibility: "hidden" } : undefined}
390
- className="min-h-0 flex-1 overflow-y-auto overscroll-contain px-4 py-6 [overflow-anchor:none] sm:px-6"
391
- >
392
- <div className="mx-auto flex w-full max-w-3xl flex-col gap-5">
393
- {groups.length === 0 && !working
394
- ? (emptyState ?? (
395
- <p className="py-10 text-center text-sm text-og-fg-subtle">No activity yet.</p>
396
- ))
397
- : null}
398
- {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. */}
399
934
  <div
400
- ref={topSentinelRef}
401
- data-og-top-sentinel=""
402
- data-og-timeline-chrome=""
403
- aria-hidden="true"
404
- className="h-px w-full shrink-0"
405
- />
406
- ) : null}
407
- {loadingOlder ? (
408
- <div data-og-timeline-chrome="" className="flex items-center gap-2 text-sm">
409
- <span className="og-shimmer-text font-medium">Loading earlier activity…</span>
410
- </div>
411
- ) : null}
412
- {groups.map(({ group, key }, index) => (
413
- <div key={key} data-og-timeline-group-anchor="">
414
- <TimelineGroupView
415
- group={group}
416
- renderMessageText={renderMessageText}
417
- onOpenSession={onOpenSession}
418
- onMemoryClick={onMemoryClick}
419
- onReconnect={onReconnect}
420
- resolveProviderLogo={resolveProviderLogo}
421
- toolRegistry={toolRegistry}
422
- turnSummary={turnSummary}
423
- foldLiveCluster={isAgentProgress(groups[index + 1]?.group)}
424
- />
425
- </div>
426
- ))}
427
- {working ? (
428
- <div className="flex items-center gap-2 text-sm">
429
- <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>
430
1011
  </div>
431
- ) : null}
432
- </div>
433
- </div>
434
- <AnimatePresence>
435
- {!pinned && autoFollow ? (
436
- <motion.button
437
- type="button"
438
- initial={{ opacity: 0, y: 8 }}
439
- animate={{ opacity: 1, y: 0 }}
440
- exit={{ opacity: 0, y: 8 }}
441
- transition={{ duration: 0.15, ease: "easeOut" }}
442
- onClick={() => {
443
- const node = scrollRef.current;
444
- if (node) {
445
- node.scrollTo({ top: node.scrollHeight, behavior: "smooth" });
446
- }
447
- pinnedRef.current = true;
448
- setPinned(true);
449
- }}
450
- className={cn(
451
- "absolute bottom-4 left-1/2 -translate-x-1/2",
452
- "inline-flex items-center gap-1.5 rounded-full border border-og-border bg-og-surface-3/90 px-3 py-1.5",
453
- "text-xs font-medium text-og-fg shadow-og-md backdrop-blur",
454
- "hover:border-og-border-strong",
455
- )}
456
- >
457
- <ArrowDownIcon className="size-3.5" />
458
- Jump to latest
459
- </motion.button>
460
- ) : null}
461
- </AnimatePresence>
462
- </div>
463
- </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>
464
1169
  </LightboxProvider>
465
1170
  );
466
1171
  }
467
1172
 
468
- type MountedGroupWindow = {
469
- groupKeys: string[];
470
- visibleStart: number;
471
- };
472
-
473
1173
  type KeyedTimelineGroup = {
474
1174
  group: TimelineGroup;
475
1175
  key: string;
@@ -499,7 +1199,10 @@ function useStableTimelineGroupKeys(allGroups: TimelineGroup[]): KeyedTimelineGr
499
1199
  let retainedKey: string | undefined;
500
1200
  for (const itemId of itemIds) {
501
1201
  const previous = previousByItemId.get(itemId);
502
- 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)) {
503
1206
  retainedKey = previous.key;
504
1207
  break;
505
1208
  }
@@ -524,82 +1227,6 @@ function useStableTimelineGroupKeys(allGroups: TimelineGroup[]): KeyedTimelineGr
524
1227
  return keyedGroups;
525
1228
  }
526
1229
 
527
- /**
528
- * Bulk history is already projected into its authoritative order before this
529
- * hook runs. Mount its newest group first, then prepend one older group per
530
- * animation frame so low-end browsers can paint and service input between
531
- * React/Markdown commits instead of doing the entire tail in one long task.
532
- *
533
- * A live append does not move the first mounted key, so it is included
534
- * immediately in the mounted suffix. A history prepend moves that key deeper
535
- * in the array; shifting `visibleStart` by that exact prefix keeps every
536
- * existing row mounted while the new prefix is revealed. If projection
537
- * coalesces the seam item itself away, the earliest surviving mounted key
538
- * provides the same anchor. Replacements with no shared mounted key (for
539
- * example a session switch or clear-view) start a fresh suffix.
540
- */
541
- function useProgressivelyMountedGroups(allGroups: KeyedTimelineGroup[]): {
542
- mountedGroups: KeyedTimelineGroup[];
543
- mountingOlderGroups: boolean;
544
- } {
545
- const previousGroupKeysRef = useRef<string[]>([]);
546
- const groupKeys = useMemo(() => {
547
- const nextKeys = allGroups.map((group) => group.key);
548
- const previousKeys = previousGroupKeysRef.current;
549
- return nextKeys.length === previousKeys.length &&
550
- nextKeys.every((key, index) => key === previousKeys[index])
551
- ? previousKeys
552
- : nextKeys;
553
- }, [allGroups]);
554
- useLayoutEffect(() => {
555
- previousGroupKeysRef.current = groupKeys;
556
- }, [groupKeys]);
557
- const [window, setWindow] = useState<MountedGroupWindow>(() => ({
558
- groupKeys,
559
- visibleStart: Math.max(0, allGroups.length - INITIAL_MOUNTED_GROUPS),
560
- }));
561
-
562
- const lastPossibleStart = Math.max(0, allGroups.length - INITIAL_MOUNTED_GROUPS);
563
- let visibleStart = 0;
564
- if (allGroups.length > 0) {
565
- const currentIndexByKey = new Map(groupKeys.map((key, index) => [key, index]));
566
- let retainedStart: number | undefined;
567
- for (const key of window.groupKeys.slice(window.visibleStart)) {
568
- const index = currentIndexByKey.get(key);
569
- if (index !== undefined) {
570
- retainedStart = index;
571
- break;
572
- }
573
- }
574
- visibleStart =
575
- retainedStart === undefined ? lastPossibleStart : Math.min(retainedStart, lastPossibleStart);
576
- }
577
-
578
- useEffect(() => {
579
- // Synchronize a prepend/replacement before scheduling its first reveal.
580
- if (window.groupKeys !== groupKeys || window.visibleStart !== visibleStart) {
581
- setWindow({ groupKeys, visibleStart });
582
- return;
583
- }
584
- if (visibleStart === 0) {
585
- return;
586
- }
587
- const frame = requestFrame(() => {
588
- setWindow((current) =>
589
- current.groupKeys === groupKeys && current.visibleStart > 0
590
- ? { ...current, visibleStart: current.visibleStart - 1 }
591
- : current,
592
- );
593
- });
594
- return () => cancelFrame(frame);
595
- }, [groupKeys, visibleStart, window.groupKeys, window.visibleStart]);
596
-
597
- return {
598
- mountedGroups: allGroups.slice(visibleStart),
599
- mountingOlderGroups: visibleStart > 0,
600
- };
601
- }
602
-
603
1230
  function requestFrame(callback: FrameRequestCallback): number {
604
1231
  if (typeof requestAnimationFrame === "function") {
605
1232
  return requestAnimationFrame(callback);
@@ -615,10 +1242,11 @@ function cancelFrame(id: number): void {
615
1242
  window.clearTimeout(id);
616
1243
  }
617
1244
 
618
- // Progressive history mounting reuses the exact group objects from `allGroups`.
619
- // Skip repainting those stable rows on every prefix reveal; live projection
620
- // creates a new group object, and behavior/callback changes are separate props,
621
- // 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.
622
1250
  const TimelineGroupView = memo(function TimelineGroupView({
623
1251
  group,
624
1252
  renderMessageText,
@@ -629,7 +1257,10 @@ const TimelineGroupView = memo(function TimelineGroupView({
629
1257
  toolRegistry,
630
1258
  turnSummary,
631
1259
  insideTurn = false,
1260
+ nestClusterChips = false,
632
1261
  foldLiveCluster = false,
1262
+ trailingAgentText,
1263
+ contextCompactionCount,
633
1264
  }: {
634
1265
  group: TimelineGroup;
635
1266
  renderMessageText?:
@@ -649,39 +1280,126 @@ const TimelineGroupView = memo(function TimelineGroupView({
649
1280
  failure surface, so nested chips stay tinted but quiet (no repeated
650
1281
  failure text, no auto-open) — one loud error, N calm sub-expands. */
651
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;
652
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;
653
1323
  switch (group.kind) {
654
1324
  case "activity":
655
- 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 (
656
1373
  <TurnSummary
657
1374
  items={group.items}
658
1375
  outcome={group.outcome}
659
- failureText={insideTurn ? undefined : group.failureText}
660
- defaultOpen={!insideTurn && group.outcome === "failed" ? true : undefined}
661
- bare={insideTurn}
1376
+ failureText={group.failureText}
1377
+ defaultOpen={!activityShouldFold || group.outcome === "failed" ? true : undefined}
1378
+ foldKey={group.id}
662
1379
  facets={turnSummary?.facets}
1380
+ settleFold={settleFold}
1381
+ contextCompactionCount={contextCompactionCount}
663
1382
  >
664
- <ActivityRail
665
- items={group.items}
666
- onOpenSession={onOpenSession}
667
- onMemoryClick={onMemoryClick}
668
- toolRegistry={toolRegistry}
669
- bare={insideTurn}
670
- />
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>
671
1394
  </TurnSummary>
672
- ) : (
673
- // A nested live cluster hangs on the turn's rail (bare); a top-level one
674
- // owns its own rail.
675
- <ActivityRail
676
- items={group.items}
677
- onOpenSession={onOpenSession}
678
- onMemoryClick={onMemoryClick}
679
- toolRegistry={toolRegistry}
680
- bare={insideTurn}
681
- />
682
1395
  );
683
1396
  case "turn": {
684
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);
685
1403
  const body = group.groups.map((child) => (
686
1404
  <TimelineGroupView
687
1405
  key={timelineGroupKey(child)}
@@ -694,32 +1412,32 @@ const TimelineGroupView = memo(function TimelineGroupView({
694
1412
  toolRegistry={toolRegistry}
695
1413
  turnSummary={turnSummary}
696
1414
  insideTurn
1415
+ nestClusterChips={nestClusters}
697
1416
  />
698
1417
  ));
699
1418
  return (
700
1419
  <TurnSummary
701
1420
  items={activityItems}
702
1421
  outcome={group.outcome}
703
- // One loud error at the top; nested sub-turn chips stay calm — the
704
- // parent already renders the failure reason, so clear it here exactly
705
- // as the nested activity-cluster case does.
706
1422
  failureText={insideTurn ? undefined : group.failureText}
707
1423
  durationMs={durationBetween(group.startedAt, group.endedAt)}
708
- defaultOpen={!insideTurn && group.outcome === "failed" ? true : undefined}
1424
+ defaultOpen={turnDefaultOpen ? true : undefined}
709
1425
  bare={insideTurn}
1426
+ foldKey={group.id}
710
1427
  facets={turnSummary?.facets}
1428
+ settleFold={settleFold}
1429
+ copyText={insideTurn ? undefined : turnCopyText}
1430
+ contextCompactionCount={contextCompactionCount ?? group.contextCompactionCount}
711
1431
  >
712
- {insideTurn ? (
713
- // A nested turn is already on an ancestor rail — its body just stacks
714
- // flush (the bare-node body already indents it), so no second rule.
715
- <div className="flex flex-col gap-4">{body}</div>
716
- ) : (
717
- // The top-level turn draws THE rail: one thin continuous rule that
718
- // every nested node and step hangs off of.
719
- <div className="flex flex-col gap-4 border-l-2 border-og-border pl-3 sm:pl-4">
720
- {body}
721
- </div>
722
- )}
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>
723
1441
  </TurnSummary>
724
1442
  );
725
1443
  }
@@ -736,6 +1454,42 @@ const TimelineGroupView = memo(function TimelineGroupView({
736
1454
  }
737
1455
  });
738
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
+
739
1493
  function timelineGroupKey(group: TimelineGroup): string {
740
1494
  switch (group.kind) {
741
1495
  case "item":
@@ -790,6 +1544,17 @@ function clusterIsSettled(group: Extract<TimelineGroup, { kind: "activity" }>):
790
1544
  });
791
1545
  }
792
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
+
793
1558
  function flattenActivityItems(groups: TimelineGroup[]): ActivityItem[] {
794
1559
  const items: ActivityItem[] = [];
795
1560
  for (const group of groups) {
@@ -802,6 +1567,89 @@ function flattenActivityItems(groups: TimelineGroup[]): ActivityItem[] {
802
1567
  return items;
803
1568
  }
804
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
+
805
1653
  function durationBetween(startedAt: string, endedAt: string): number | undefined {
806
1654
  const started = Date.parse(startedAt);
807
1655
  const ended = Date.parse(endedAt);
@@ -848,6 +1696,8 @@ export function TimelineRow({
848
1696
  return <MachineInputBatchRow item={item} />;
849
1697
  case "notice":
850
1698
  return <NoticeRow item={item} />;
1699
+ case "context-compaction":
1700
+ return <CompactionRow item={item} />;
851
1701
  case "auth-needed":
852
1702
  return (
853
1703
  <AuthNeededRow
@@ -861,6 +1711,82 @@ export function TimelineRow({
861
1711
  }
862
1712
  }
863
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
+
864
1790
  function UserMessageRow({
865
1791
  item,
866
1792
  renderMessageText,
@@ -874,13 +1800,19 @@ function UserMessageRow({
874
1800
  return (
875
1801
  <div className={cn(enter && "animate-og-enter", "flex justify-end")}>
876
1802
  <div className="flex max-w-[85%] min-w-0 flex-col items-end gap-1">
877
- <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">
878
- {renderMessageText ? (
879
- renderMessageText(item.text, item)
880
- ) : (
881
- <Markdown>{item.text}</Markdown>
882
- )}
883
- </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>
884
1816
  </div>
885
1817
  </div>
886
1818
  );
@@ -896,29 +1828,23 @@ function AgentMessageRow({
896
1828
  | undefined;
897
1829
  }) {
898
1830
  const enter = useEntranceAnimation();
899
- const caret = item.streaming ? (
900
- <span
901
- className="ml-0.5 inline-block h-[1.1em] w-[2px] translate-y-[3px] animate-og-blink rounded-full bg-og-accent"
902
- aria-hidden
903
- />
904
- ) : 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.
905
1839
  return (
906
- <div className={cn(enter && "animate-og-enter", "min-w-0 text-og-md leading-7 text-og-fg")}>
907
- {renderMessageText ? (
908
- <>
909
- {renderMessageText(item.text, item)}
910
- {caret}
911
- </>
912
- ) : (
913
- // While streaming, let the caret ride the end of the last rendered line:
914
- // the trailing block (usually a <p>) flows inline so the caret sits on
915
- // its baseline instead of dropping to a new line.
916
- <div className={item.streaming ? "[&_>div>:last-child]:inline" : undefined}>
917
- <Markdown>{item.text}</Markdown>
918
- {caret}
919
- </div>
920
- )}
921
- </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>
922
1848
  );
923
1849
  }
924
1850