@opengeni/react 0.25.0 → 0.25.5

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 (46) hide show
  1. package/dist/{chunk-AVPU5PMC.js → chunk-F4CFWKVI.js} +332 -64
  2. package/dist/chunk-F4CFWKVI.js.map +1 -0
  3. package/dist/chunk-GQN2QIR2.js +3767 -0
  4. package/dist/chunk-GQN2QIR2.js.map +1 -0
  5. package/dist/{chunk-I3BJZIG5.js → chunk-HIWPQYWI.js} +2 -120
  6. package/dist/chunk-HIWPQYWI.js.map +1 -0
  7. package/dist/chunk-M7X4JZOD.js +121 -0
  8. package/dist/chunk-M7X4JZOD.js.map +1 -0
  9. package/dist/{chunk-SHOFILHJ.js → chunk-MP6237JB.js} +570 -984
  10. package/dist/chunk-MP6237JB.js.map +1 -0
  11. package/dist/chunk-OZDLELJQ.js +937 -0
  12. package/dist/chunk-OZDLELJQ.js.map +1 -0
  13. package/dist/{chunk-RDPDU4TA.js → chunk-TMH6HZWF.js} +3 -3
  14. package/dist/{chunk-6XUS5VFM.js → chunk-YU5PGUK7.js} +6 -4
  15. package/dist/{chunk-6XUS5VFM.js.map → chunk-YU5PGUK7.js.map} +1 -1
  16. package/dist/{composer-BXb0Q1HF.d.ts → composer-DoC4veX1.d.ts} +4 -75
  17. package/dist/composer.d.ts +2 -1
  18. package/dist/composer.js +4 -3
  19. package/dist/index.d.ts +14 -259
  20. package/dist/index.js +1016 -5140
  21. package/dist/index.js.map +1 -1
  22. package/dist/machines.js +3 -2
  23. package/dist/session-Du_FsrZ1.d.ts +264 -0
  24. package/dist/session-ui-BqQDH7YV.d.ts +183 -0
  25. package/dist/session-ui.d.ts +7 -0
  26. package/dist/session-ui.js +17 -0
  27. package/dist/session-ui.js.map +1 -0
  28. package/dist/session.d.ts +3 -1
  29. package/dist/session.js +26 -9
  30. package/dist/use-file-attachments-C0kpHrs9.d.ts +76 -0
  31. package/dist/{session-BEvtFWhe.d.ts → use-turn-queue-3rkJwjFv.d.ts} +3 -185
  32. package/package.json +6 -2
  33. package/src/components/message-timeline.tsx +92 -52
  34. package/src/hooks/use-composer.ts +456 -69
  35. package/src/hooks/use-file-attachments.ts +1 -1
  36. package/src/hooks/use-goal.ts +1 -1
  37. package/src/hooks/use-session-events.ts +5 -0
  38. package/src/hooks/use-session-lineage.ts +1 -1
  39. package/src/hooks/use-session.ts +9 -6
  40. package/src/session-ui.ts +13 -0
  41. package/src/session.ts +15 -0
  42. package/src/timeline/activity-rail.tsx +1 -1
  43. package/dist/chunk-AVPU5PMC.js.map +0 -1
  44. package/dist/chunk-I3BJZIG5.js.map +0 -1
  45. package/dist/chunk-SHOFILHJ.js.map +0 -1
  46. /package/dist/{chunk-RDPDU4TA.js.map → chunk-TMH6HZWF.js.map} +0 -0
@@ -1,5 +1,5 @@
1
- import { E as EmbeddedSessionClientOverride, e as EmbeddedSessionMcpApprovalPolicyClientOverride, f as EmbeddedHumanInputClientOverride } from './session-context-rgrfElMl.js';
2
- import { ToolAuthNeededPayload, ResourceRef, ToolRef, SessionStatus, SessionEvent, StreamConnectionState, SessionQueueSnapshot, SessionTurn, EffectiveSessionControl, ComposerDraft, SessionControlResponse, SessionMcpServerMetadata, SessionMcpApprovalPolicy, UpdateSessionMcpApprovalPolicyResponse, HumanInputQuestion, SessionHumanInputRequest, SubmitHumanInputResponseRequest } from '@opengeni/sdk';
1
+ import { ToolAuthNeededPayload, ResourceRef, ToolRef, SessionStatus, SessionQueueSnapshot, SessionTurn, EffectiveSessionControl, ComposerDraft, SessionEvent } from '@opengeni/sdk';
2
+ import { E as EmbeddedSessionClientOverride } from './session-context-rgrfElMl.js';
3
3
  import { S as SessionEventFeedOptions } from './use-composer-BnEzheSk.js';
4
4
 
5
5
  type UserMessageItem = {
@@ -249,70 +249,6 @@ type TimelineGroup = {
249
249
  groups: TimelineGroup[];
250
250
  };
251
251
 
252
- type SessionEventsConnectionState = StreamConnectionState | "idle" | "ended" | "error";
253
- type UseSessionEventsOptions = EmbeddedSessionClientOverride & {
254
- /** Resume after this sequence (exclusive). Nonzero keeps full replay/resume semantics. */
255
- after?: number | undefined;
256
- /** Load a bounded tail by default, or opt back into full replay from `after`. */
257
- replay?: "windowed" | "full" | undefined;
258
- /** Pause the stream without unmounting (e.g. hidden tab). Defaults to true. */
259
- enabled?: boolean | undefined;
260
- };
261
- type UseSessionEventsResult = {
262
- /** Replayed + live events, ordered by sequence, no gaps, no duplicates. */
263
- events: SessionEvent[];
264
- /** Projected, renderable timeline (memoized over `events`). */
265
- timeline: TimelineItem[];
266
- /** Latest session status observed in the event log, if any. */
267
- sessionStatus: SessionStatus | null;
268
- connectionState: SessionEventsConnectionState;
269
- /** Highest sequence seen so far (0 before the first event). */
270
- lastSequence: number;
271
- /** Exact serialized bytes retained in the current browser event window. */
272
- windowBytes: number;
273
- /** Whether older delivered events were evicted from the browser window. */
274
- windowTruncated: boolean;
275
- /** True until the initial tail window has been applied (windowed mode). */
276
- initialLoading: boolean;
277
- /** Whether older durable events are available before the current window. */
278
- hasOlder: boolean;
279
- /** True while an older window is being fetched. */
280
- loadingOlder: boolean;
281
- /** Prepend an older density-bounded window; resolves true when more remain. */
282
- loadOlder: () => Promise<boolean>;
283
- error: Error | null;
284
- };
285
- declare const SESSION_EVENT_BROWSER_MAX_BYTES: number;
286
- declare const SESSION_EVENT_BROWSER_MAX_COUNT = 10000;
287
- declare const SESSION_EVENT_BROWSER_SINGLE_EVENT_MAX_BYTES: number;
288
- declare const SESSION_EVENT_BROWSER_PENDING_MAX_BYTES: number;
289
- declare const SESSION_EVENT_BROWSER_PENDING_MAX_COUNT = 256;
290
- type BrowserSessionEventWindow = {
291
- events: SessionEvent[];
292
- bytes: number;
293
- truncated: boolean;
294
- };
295
- /**
296
- * Live-stream a session's event log with replay-by-sequence, reconnect, and
297
- * batched React updates. Fresh loads default to a bounded tail window; pass
298
- * `replay: "full"` or a nonzero `after` for the previous full replay path.
299
- */
300
- declare function useSessionEvents(sessionId: string | null | undefined, options?: UseSessionEventsOptions): UseSessionEventsResult;
301
- /**
302
- * Keep one direction-aware count+byte-bounded browser window. Live/default
303
- * accumulation retains the newest suffix; backward paging retains the oldest
304
- * prefix so newly fetched history cannot be immediately evicted. This is
305
- * deliberately separate from durable history and transport paging: when a
306
- * backward page evicts the live tail, the hook reconnects from the retained
307
- * high-water mark while preserving the highest-ever-observed sequence
308
- * separately. The source event remains durable in PostgreSQL throughout.
309
- */
310
- declare function boundBrowserSessionEventWindow(events: readonly SessionEvent[], options?: {
311
- maxBytes?: number;
312
- maxCount?: number;
313
- direction?: "newest" | "oldest";
314
- }): BrowserSessionEventWindow;
315
-
316
252
  /** Events that can change the authoritative prompt queue or effective control. */
317
253
  declare function isTurnQueueEvent(event: Pick<SessionEvent, "type">): boolean;
318
254
  type QueueMutationKind = "move" | "edit" | "steer" | "delete";
@@ -351,122 +287,4 @@ type UseTurnQueueResult = {
351
287
  */
352
288
  declare function useTurnQueue(sessionId: string | null | undefined, options?: UseTurnQueueOptions): UseTurnQueueResult;
353
289
 
354
- type UseSessionControlOptions = EmbeddedSessionClientOverride;
355
- type UseSessionControlResult = {
356
- pause: (reason?: string) => Promise<SessionControlResponse | null>;
357
- resume: (reason?: string) => Promise<SessionControlResponse | null>;
358
- controlling: boolean;
359
- /** Approve a pending `requires_action` approval. */
360
- approve: (approvalId: string, message?: string) => Promise<SessionEvent | null>;
361
- /** Reject a pending `requires_action` approval. */
362
- reject: (approvalId: string, message?: string) => Promise<SessionEvent | null>;
363
- /** True while an approval decision is in flight. */
364
- responding: boolean;
365
- error: Error | null;
366
- clearError: () => void;
367
- };
368
- /**
369
- * Session pause/resume and approval decisions. Pair with
370
- * `useSessionEvents` (for `session.requiresAction` payloads carrying the
371
- * `approvalId`) to render an approval bar.
372
- */
373
- declare function useSessionControl(sessionId: string | null | undefined, options?: UseSessionControlOptions): UseSessionControlResult;
374
-
375
- declare function isSessionMcpApprovalPolicyEvent(event: Pick<SessionEvent, "type" | "payload">, serverId?: string): boolean;
376
- type UseSessionMcpApprovalPolicyOptions = EmbeddedSessionMcpApprovalPolicyClientOverride & SessionEventFeedOptions & {
377
- /** Optional safety-net polling (ms). Off by default; policy events drive refreshes. */
378
- pollIntervalMs?: number | undefined;
379
- };
380
- type UseSessionMcpApprovalPolicyResult = {
381
- server: SessionMcpServerMetadata | null;
382
- policy: SessionMcpApprovalPolicy | null;
383
- loading: boolean;
384
- error: Error | null;
385
- refresh: () => Promise<void>;
386
- update: (policy: SessionMcpApprovalPolicy) => Promise<UpdateSessionMcpApprovalPolicyResponse | null>;
387
- updating: boolean;
388
- clearError: () => void;
389
- };
390
- /**
391
- * Read and update one existing session MCP server's approval policy. Updates
392
- * become effective for the next claimed attempt; current work is unchanged.
393
- */
394
- declare function useSessionMcpApprovalPolicy(sessionId: string | null | undefined, serverId: string | null | undefined, options?: UseSessionMcpApprovalPolicyOptions): UseSessionMcpApprovalPolicyResult;
395
-
396
- type PendingApproval = {
397
- /** The id to send back via `user.approvalDecision` (`approvalId`). */
398
- id: string;
399
- /** Tool/function name awaiting the decision. */
400
- name: string;
401
- arguments?: unknown;
402
- /** The raw approval entry from the `session.requiresAction` payload. */
403
- raw?: unknown;
404
- };
405
- /** The approvals carried by one `session.requiresAction` payload. */
406
- declare function approvalsFromRequiresAction(payload: unknown): PendingApproval[];
407
- /** The approvals still awaiting a decision after replaying `events` in order. */
408
- declare function projectPendingApprovals(events: SessionEvent[]): PendingApproval[];
409
-
410
- /** Minimal actionable request shape available from the durable event log. */
411
- type PendingHumanInputRequest = {
412
- id: string;
413
- turnId: string | null;
414
- questions: HumanInputQuestion[];
415
- allowSkip: boolean;
416
- expiresAt: string | null;
417
- };
418
- /** Parse one generic `session.humanInput.requested` event defensively. */
419
- declare function humanInputRequestFromEvent(event: Pick<SessionEvent, "type" | "payload" | "turnId">): PendingHumanInputRequest | null;
420
- /**
421
- * Fold a durable event log into the structured requests that are actionable
422
- * now. Responses remove one request; a terminal owning turn clears any
423
- * unresolved requests that died with it. Replaying history cannot resurrect a
424
- * previously answered card.
425
- */
426
- declare function projectPendingHumanInputRequests(events: SessionEvent[]): PendingHumanInputRequest[];
427
-
428
- /** Events that can create, settle, or invalidate an actionable request. */
429
- declare function isHumanInputEvent(event: Pick<SessionEvent, "type">): boolean;
430
- type UseHumanInputRequestsOptions = EmbeddedHumanInputClientOverride & SessionEventFeedOptions & {
431
- /** Optional safety-net polling. Durable events drive refresh by default. */
432
- pollIntervalMs?: number | undefined;
433
- };
434
- type UseHumanInputRequestsResult = {
435
- requests: SessionHumanInputRequest[];
436
- loading: boolean;
437
- error: Error | null;
438
- refresh: () => Promise<void>;
439
- respond: (requestId: string, response: SubmitHumanInputResponseRequest) => Promise<SessionEvent | null>;
440
- respondingRequestId: string | null;
441
- mutationError: Error | null;
442
- clearMutationError: () => void;
443
- };
444
- /**
445
- * Authoritative structured-human-input hook. The server's pending-request
446
- * table is the read model; events only trigger reconciliation. This lets an
447
- * embedded host use the same primitive with a shared event feed or its own SDK
448
- * proxy without rebuilding request lifecycle logic.
449
- */
450
- declare function useHumanInputRequests(sessionId: string | null | undefined, options?: UseHumanInputRequestsOptions): UseHumanInputRequestsResult;
451
-
452
- /** Readable label for a tool call, without leaking an MCP server prefix. */
453
- declare function toolDisplayName(name: string): string;
454
- declare function buildTimeline(events: SessionEvent[]): TimelineItem[];
455
- /**
456
- * Whether the session's most recent turn ended in credit exhaustion — the
457
- * terminal credit state apps key their "add credits" affordances on. Derived
458
- * from the LAST turn-end event (completed/failed/cancelled): a later turn that
459
- * settles any other way (someone topped up and kept working) clears it.
460
- */
461
- declare function creditExhaustedFromEvents(events: SessionEvent[]): boolean;
462
- /** The latest session status carried in the event log, if any. */
463
- declare function sessionStatusFromEvents(events: SessionEvent[]): SessionStatus | null;
464
- declare function groupTimeline(items: TimelineItem[]): TimelineGroup[];
465
- /**
466
- * Find a session id in orchestration tool arguments or output. Handles raw
467
- * objects, JSON strings, and MCP tool results (`{ content: [{ type: "text",
468
- * text: "{...}" }], structuredContent? }`).
469
- */
470
- declare function extractSessionRef(value: unknown, depth?: number): string | null;
471
-
472
- export { useTurnQueue as $, type ActivityItem as A, type BrowserSessionEventWindow as B, buildTimeline as C, creditExhaustedFromEvents as D, extractSessionRef as E, groupTimeline as F, type GoalItem as G, humanInputRequestFromEvent as H, isHumanInputEvent as I, isSessionMcpApprovalPolicyEvent as J, isTurnQueueEvent as K, projectPendingApprovals as L, projectPendingHumanInputRequests as M, type NoticeItem as N, sessionStatusFromEvents as O, type PendingApproval as P, type QueueMutationKind as Q, type ReasoningItem as R, SESSION_EVENT_BROWSER_MAX_BYTES as S, type ToolCallItem as T, type UseTurnQueueResult as U, toolDisplayName as V, type WorkerItem as W, useHumanInputRequests as X, useSessionControl as Y, useSessionEvents as Z, useSessionMcpApprovalPolicy as _, type TurnOutcome as a, type MemoryItem as a0, type WorkerCompletionItem as a1, type TimelineItem as b, type AgentMessageItem as c, type UserMessageItem as d, type AuthNeededItem as e, type PendingHumanInputRequest as f, SESSION_EVENT_BROWSER_MAX_COUNT as g, SESSION_EVENT_BROWSER_PENDING_MAX_BYTES as h, SESSION_EVENT_BROWSER_PENDING_MAX_COUNT as i, SESSION_EVENT_BROWSER_SINGLE_EVENT_MAX_BYTES as j, type SandboxItem as k, type SessionEventsConnectionState as l, type SessionStatusItem as m, type TimelineGroup as n, type TurnEndItem as o, type UseHumanInputRequestsOptions as p, type UseHumanInputRequestsResult as q, type UseSessionControlOptions as r, type UseSessionControlResult as s, type UseSessionEventsOptions as t, type UseSessionEventsResult as u, type UseSessionMcpApprovalPolicyOptions as v, type UseSessionMcpApprovalPolicyResult as w, type UseTurnQueueOptions as x, approvalsFromRequiresAction as y, boundBrowserSessionEventWindow as z };
290
+ export { type ActivityItem as A, type GoalItem as G, type MemoryItem as M, type NoticeItem as N, type QueueMutationKind as Q, type ReasoningItem as R, type SandboxItem as S, type TurnOutcome as T, type UseTurnQueueOptions as U, type WorkerItem as W, type AgentMessageItem as a, type AuthNeededItem as b, type SessionStatusItem as c, type TimelineGroup as d, type TimelineItem as e, type ToolCallItem as f, type TurnEndItem as g, type UseTurnQueueResult as h, type UserMessageItem as i, isTurnQueueEvent as j, type WorkerCompletionItem as k, useTurnQueue as u };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@opengeni/react",
3
- "version": "0.25.0",
3
+ "version": "0.25.5",
4
4
  "description": "React hooks and styled components for OpenGeni: live session streaming, chat composer, message timeline, session status, and fleet views — token-themed (CSS variables), dark-first, built on Tailwind v4 + Radix + Motion.",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {
@@ -31,6 +31,10 @@
31
31
  "types": "./dist/session.d.ts",
32
32
  "import": "./dist/session.js"
33
33
  },
34
+ "./session-ui": {
35
+ "types": "./dist/session-ui.d.ts",
36
+ "import": "./dist/session-ui.js"
37
+ },
34
38
  "./machines": {
35
39
  "types": "./dist/machines.d.ts",
36
40
  "import": "./dist/machines.js"
@@ -62,7 +66,7 @@
62
66
  "@dnd-kit/utilities": "3.2.2",
63
67
  "@fontsource-variable/noto-sans-arabic": "5.2.10",
64
68
  "@fontsource-variable/noto-sans-jp": "5.2.10",
65
- "@opengeni/sdk": "^0.25.0",
69
+ "@opengeni/sdk": "^0.25.5",
66
70
  "clsx": "^2.1.1",
67
71
  "lucide-react": "^1.8.0",
68
72
  "motion": "^12.0.0",
@@ -150,18 +150,23 @@ export function MessageTimeline({
150
150
  // structurally impossible — the reader only ever sees it already at the
151
151
  // bottom. An empty timeline reveals immediately (there is nothing to anchor).
152
152
  const [revealed, setRevealed] = useState(false);
153
- // Our own scrollTop assignments echo back as scroll events; those must never
154
- // UNPIN the reader (they are not reader intent). Marked around every
155
- // programmatic assignment and consumed by onScroll. When an assignment is a
156
- // NO-OP (already at the target) no scroll event will fire, so the mark must
157
- // self-clear a stale mark would eat the reader's next real scroll-up.
158
- const programmaticScrollRef = useRef(false);
153
+ // Our own scrollTop assignments echo back as delayed scroll events. Keep the
154
+ // ordered, clamped targets rather than only the latest one: successive
155
+ // ResizeObserver corrections can queue several echoes before the browser
156
+ // dispatches them. If reader input lands at a different position, its value
157
+ // wins and clears the pending echoes. A matching echo must not recapture the
158
+ // visual anchor while progressive prepend is still mounting older rows.
159
+ const programmaticScrollTargetsRef = useRef<number[]>([]);
159
160
  const assignScrollTop = useCallback((node: HTMLElement, value: number) => {
160
161
  const previous = node.scrollTop;
161
- programmaticScrollRef.current = true;
162
162
  node.scrollTop = value;
163
163
  if (node.scrollTop === previous) {
164
- programmaticScrollRef.current = false;
164
+ return;
165
+ }
166
+ const pending = programmaticScrollTargetsRef.current;
167
+ pending.push(node.scrollTop);
168
+ if (pending.length > 32) {
169
+ pending.splice(0, pending.length - 32);
165
170
  }
166
171
  }, []);
167
172
  // Mirror `pinned` into a ref so the ResizeObserver callback (a stable closure)
@@ -187,9 +192,12 @@ export function MessageTimeline({
187
192
  allGroups.length > 0 && (bulkActive || firstKeyChangedForBulk || mountingOlderGroups);
188
193
 
189
194
  // Snapshot the topmost visible element and where it sits in the viewport, so a
190
- // later reflow can restore it to the same spot. Transient chrome (the backfill
191
- // sentinel and shimmer) is skipped anchoring to a row that unmounts when the
192
- // older window lands would drop the correction mid-prepend.
195
+ // later reflow can restore it to the same spot. Prefer durable row wrappers
196
+ // over their containing group: an older activity item can merge into the
197
+ // existing group, leaving that outer group in place while shifting every row
198
+ // after the insertion. Transient chrome (the backfill sentinel and shimmer) is
199
+ // skipped — anchoring to a row that unmounts when the older window lands would
200
+ // drop the correction mid-prepend.
193
201
  const captureAnchor = useCallback(() => {
194
202
  const node = scrollRef.current;
195
203
  const inner = node?.firstElementChild;
@@ -198,31 +206,63 @@ export function MessageTimeline({
198
206
  return;
199
207
  }
200
208
  const containerTop = node.getBoundingClientRect().top;
201
- for (const child of Array.from(inner.children)) {
202
- if (child instanceof HTMLElement && child.dataset.ogTimelineChrome !== undefined) {
203
- continue;
204
- }
205
- const rect = child.getBoundingClientRect();
206
- if (rect.bottom > containerTop + 1) {
207
- anchorRef.current = { el: child, top: rect.top - containerTop };
208
- return;
209
+ const findVisible = (selector: string): Element | null => {
210
+ for (const candidate of inner.querySelectorAll(selector)) {
211
+ if (candidate instanceof HTMLElement && candidate.dataset.ogTimelineChrome !== undefined) {
212
+ continue;
213
+ }
214
+ const rect = candidate.getBoundingClientRect();
215
+ if (rect.bottom > containerTop + 1 && rect.height > 0) {
216
+ return candidate;
217
+ }
209
218
  }
219
+ return null;
220
+ };
221
+ const anchor =
222
+ findVisible("[data-og-timeline-row-anchor]") ??
223
+ findVisible("[data-og-timeline-group-anchor]");
224
+ if (anchor) {
225
+ const rect = anchor.getBoundingClientRect();
226
+ anchorRef.current = { el: anchor, top: rect.top - containerTop };
227
+ return;
210
228
  }
211
229
  anchorRef.current = null;
212
230
  }, []);
213
231
 
214
- // Follow the stream while pinned to the bottom; never fight the reader.
232
+ // Restore the user-owned anchor synchronously after React commits a frame.
233
+ // ResizeObserver remains a late-measurement fallback, but progressive history
234
+ // frames must be corrected before the browser can paint an intermediate jump.
235
+ const restoreAnchor = useCallback(
236
+ (node: HTMLElement) => {
237
+ if (autoFollow && pinnedRef.current) {
238
+ assignScrollTop(node, node.scrollHeight);
239
+ return;
240
+ }
241
+ const anchor = anchorRef.current;
242
+ if (!anchor || !anchor.el.isConnected) {
243
+ return;
244
+ }
245
+ const containerTop = node.getBoundingClientRect().top;
246
+ const now = anchor.el.getBoundingClientRect().top - containerTop;
247
+ const diff = now - anchor.top;
248
+ if (diff !== 0) {
249
+ assignScrollTop(node, node.scrollTop + diff);
250
+ }
251
+ },
252
+ [assignScrollTop, autoFollow],
253
+ );
254
+
215
255
  // A LAYOUT effect so the very first paint of a freshly loaded session is
216
256
  // already anchored at the bottom — no visible traversal down the history.
217
257
  useLayoutEffect(() => {
218
258
  const node = scrollRef.current;
219
- if (node && autoFollow && pinned) {
220
- assignScrollTop(node, node.scrollHeight);
259
+ if (node) {
260
+ restoreAnchor(node);
221
261
  }
222
262
  if (!revealed && groups.length > 0) {
223
263
  setRevealed(true);
224
264
  }
225
- }, [resolvedItems, working, autoFollow, pinned, revealed, groups.length, assignScrollTop]);
265
+ }, [resolvedItems, working, revealed, groups.length, restoreAnchor]);
226
266
 
227
267
  // A cleared timeline (stream identity change) re-arms the reveal so the next
228
268
  // session also first paints at its bottom.
@@ -297,32 +337,26 @@ export function MessageTimeline({
297
337
  if (!current) {
298
338
  return;
299
339
  }
300
- if (autoFollow && pinnedRef.current) {
301
- assignScrollTop(current, current.scrollHeight);
302
- } else {
303
- const anchor = anchorRef.current;
304
- if (anchor && anchor.el.isConnected) {
305
- const containerTop = current.getBoundingClientRect().top;
306
- const now = anchor.el.getBoundingClientRect().top - containerTop;
307
- const diff = now - anchor.top;
308
- if (diff !== 0) {
309
- assignScrollTop(current, current.scrollTop + diff);
310
- }
311
- }
312
- }
313
- captureAnchor();
340
+ restoreAnchor(current);
314
341
  });
315
342
  observer.observe(inner);
316
343
  return () => observer.disconnect();
317
- }, [autoFollow, captureAnchor, assignScrollTop]);
344
+ }, [restoreAnchor]);
318
345
 
319
346
  const onScroll = () => {
320
347
  const node = scrollRef.current;
321
348
  if (!node) {
322
349
  return;
323
350
  }
324
- const programmatic = programmaticScrollRef.current;
325
- programmaticScrollRef.current = false;
351
+ const pending = programmaticScrollTargetsRef.current;
352
+ const programmaticIndex = pending.findIndex((target) => Math.abs(node.scrollTop - target) <= 1);
353
+ const programmatic = programmaticIndex >= 0;
354
+ if (programmatic) {
355
+ pending.splice(0, programmaticIndex + 1);
356
+ } else {
357
+ // A real reader scroll supersedes every delayed programmatic echo.
358
+ pending.length = 0;
359
+ }
326
360
  const nextPinned = node.scrollHeight - node.scrollTop - node.clientHeight < 48;
327
361
  // Echoes of our own assignments may PIN but never UNPIN — only the reader
328
362
  // scrolling away releases the bottom-follow.
@@ -330,7 +364,12 @@ export function MessageTimeline({
330
364
  pinnedRef.current = nextPinned;
331
365
  setPinned(nextPinned);
332
366
  }
333
- captureAnchor();
367
+ // The assignment site already captured the corrected anchor. Recapturing
368
+ // it from a delayed echo can observe the next prepended row before that
369
+ // row's ResizeObserver correction and make the viewport walk up the page.
370
+ if (!programmatic) {
371
+ captureAnchor();
372
+ }
334
373
  };
335
374
 
336
375
  return (
@@ -366,17 +405,18 @@ export function MessageTimeline({
366
405
  </div>
367
406
  ) : null}
368
407
  {groups.map(({ group, key }, index) => (
369
- <TimelineGroupView
370
- key={key}
371
- group={group}
372
- renderMessageText={renderMessageText}
373
- onOpenSession={onOpenSession}
374
- onMemoryClick={onMemoryClick}
375
- onReconnect={onReconnect}
376
- resolveProviderLogo={resolveProviderLogo}
377
- toolRegistry={toolRegistry}
378
- foldLiveCluster={isAgentProgress(groups[index + 1]?.group)}
379
- />
408
+ <div key={key} data-og-timeline-group-anchor="">
409
+ <TimelineGroupView
410
+ group={group}
411
+ renderMessageText={renderMessageText}
412
+ onOpenSession={onOpenSession}
413
+ onMemoryClick={onMemoryClick}
414
+ onReconnect={onReconnect}
415
+ resolveProviderLogo={resolveProviderLogo}
416
+ toolRegistry={toolRegistry}
417
+ foldLiveCluster={isAgentProgress(groups[index + 1]?.group)}
418
+ />
419
+ </div>
380
420
  ))}
381
421
  {working ? (
382
422
  <div className="flex items-center gap-2 text-sm">