dsh-smooth-stream 0.6.1 → 0.7.0

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.
@@ -28,7 +28,8 @@
28
28
 
29
29
  import { useLayoutEffect, useRef, type RefObject } from 'react'
30
30
  import { DEFAULT_STREAM_DEBUG_TUNING, type StreamDebugTuning } from '../settings.ts'
31
- import { debugRuntime } from './debugRuntime.ts'
31
+ import { debugRuntime, readNewestStreamMetric, type FollowTerminalPhase } from './debugRuntime.ts'
32
+ import { FrameCoordinator } from './FrameCoordinator.ts'
32
33
 
33
34
  /**
34
35
  * Programmatic follow marker retained for hosts that recognize external
@@ -486,7 +487,14 @@ function setShift(element: HTMLElement, px: number): void {
486
487
 
487
488
  function turnStatusOf(port: HTMLElement): HTMLElement | null {
488
489
  return port.querySelector<HTMLElement>(
489
- '[data-chat-turn-status], [data-chat-flow] > [role="status"]',
490
+ // dsh 0.2.x replaced the per-turn status row with RunningStatus — the
491
+ // whale tail row `[data-chat-running]`, a persistent last child of the
492
+ // flow column for the whole running session. Its inner `role="status"`
493
+ // span is visually-hidden and not a direct flow child, so the old
494
+ // selector misses it and every status-aware path (shift exclusion,
495
+ // runway hosting, paint ceiling, unmount compensation, completion
496
+ // cascade) silently no-ops on 0.2.x.
497
+ '[data-chat-turn-status], [data-chat-flow] > [role="status"], [data-chat-flow] > [data-chat-running]',
490
498
  )
491
499
  }
492
500
 
@@ -538,6 +546,11 @@ function flowElementOf(port: HTMLElement): HTMLElement | null {
538
546
  ?? port.querySelector<HTMLElement>('[data-chat-flow]')
539
547
  }
540
548
 
549
+ /** Keep completion padding on the stable flow wrapper across row replacement. */
550
+ function flowPadElementOf(port: HTMLElement): HTMLElement | null {
551
+ return port.querySelector<HTMLElement>('[data-chat-flow]') ?? flowElementOf(port)
552
+ }
553
+
541
554
  function ensureFlowFillsPort(port: HTMLElement): void {
542
555
  const element = flowElementOf(port)
543
556
  const owned = followFlowFills.get(port)
@@ -632,6 +645,8 @@ export const FOLLOW_PAINT_LIMIT_TTL_MS = 250
632
645
 
633
646
  const followPaintLimits = new WeakMap<HTMLElement, FollowPaintLimit>()
634
647
  const followHadChrome = new WeakSet<HTMLElement>()
648
+ /** A status row that existed during this turn makes its removal a host cascade. */
649
+ const followHadStatus = new WeakSet<HTMLElement>()
635
650
  /** Last painted shift per port, to spread a wrap's one-line step over frames. */
636
651
  const followLastShiftPx = new WeakMap<HTMLElement, number>()
637
652
  /** Last settled floor per port, to size the shift decay bound against extent collapse. */
@@ -867,7 +882,7 @@ function pruneDeadRunway(port: HTMLElement): boolean {
867
882
  }
868
883
 
869
884
  function setFlowPad(port: HTMLElement, px: number): void {
870
- const flow = flowElementOf(port)
885
+ const flow = flowPadElementOf(port)
871
886
  if (flow === null) return
872
887
  const existing = followSettlePads.get(port)
873
888
  const original = existing?.original ?? flow.style.paddingBottom
@@ -908,10 +923,51 @@ function followTrace(event: string, detail: Record<string, number | string | boo
908
923
  if (!traceActive()) return
909
924
  console.log(`[dsh-follow] ${event}`, JSON.stringify(detail))
910
925
  }
926
+ /**
927
+ * Scroll length observed at the previous measured frame, per port. The
928
+ * completion handoff uses the delta to seed the growth credit from geometry the
929
+ * page actually committed, instead of assuming the full runway was earned.
930
+ */
931
+ const followObservedContentHeight = new WeakMap<HTMLElement, number>()
932
+
933
+ /** Growth this commit added, as last measured by `applyVisual`. */
934
+ function environmentCommitGrowthPx(port: HTMLElement): number {
935
+ const previous = followObservedContentHeight.get(port)
936
+ if (previous === undefined) return 0
937
+ return Math.max(0, port.scrollHeight - previous)
938
+ }
939
+
911
940
  function hostShOf(port: HTMLElement): number {
912
941
  return port.scrollHeight
913
942
  }
914
943
 
944
+ /**
945
+ * Reveal characters the smoother still owes, read from the stream ledger the
946
+ * smoother publishes each frame. Only meaningful together with
947
+ * `producerComplete`: a non-zero backlog while the producer runs is ordinary
948
+ * streaming, the same backlog after it stops is the `terminal-drain` phase.
949
+ */
950
+ let followRemainingRevealChars = 0
951
+ function refreshTerminalRevealLedger(): { producerComplete: boolean; drain: boolean } {
952
+ const stream = readNewestStreamMetric()
953
+ followRemainingRevealChars = stream?.backlog ?? 0
954
+ return {
955
+ producerComplete: stream?.producerComplete ?? false,
956
+ drain: (stream?.producerComplete ?? false) && (stream?.backlog ?? 0) > 0,
957
+ }
958
+ }
959
+
960
+ /**
961
+ * Screen-space reading-anchor delta for diagnostics only. Gated on the debug
962
+ * runtime so the production hot path pays nothing, and it reuses the shared
963
+ * `measureReadingAnchor` ledger, which already excludes the engine's own glide
964
+ * and only reports motion the follower did not author.
965
+ */
966
+ function measureAnchorDeltaForTelemetry(port: HTMLElement): number | null {
967
+ if (!debugRuntime.isEnabled()) return null
968
+ return measureReadingAnchor(port)?.delta ?? null
969
+ }
970
+
915
971
  function invalidatePaintLimit(port: HTMLElement): void {
916
972
  followPaintLimits.delete(port)
917
973
  }
@@ -923,8 +979,58 @@ interface FollowMotionState {
923
979
  readonly lagPx: number
924
980
  readonly reservePx: number
925
981
  readonly velocityPxPerSec: number
982
+ /** Terminal-follow phase this frame (see `FollowTerminalPhase`). */
983
+ readonly terminalPhase?: FollowTerminalPhase
984
+ /** Physical owned bottom margin written by the last `applyVisual`. */
985
+ readonly runwayPx?: number
986
+ /** `runwayOffset - visibleReserve` of the last `applyVisual`. */
987
+ readonly baselineShiftPx?: number
988
+ /** Screen-space reading-anchor delta measured for this frame, when available. */
989
+ readonly anchorDeltaPx?: number | null
990
+ /** Reveal characters still queued while the producer has already stopped. */
991
+ readonly remainingRevealChars?: number
926
992
  }
927
993
 
994
+ /**
995
+ * Terminal-follow phase ledger. `terminal-drain` (producer stopped, reveal
996
+ * queue still owes text) and `host-cascade` (host is swapping status rows,
997
+ * collapsing Think, mounting the tail) demand opposite corrections: drain may
998
+ * only continue upward toward the natural floor, while a cascade may need
999
+ * temporary credit to hold the reading anchor. Keeping them in one readable
1000
+ * field is what lets the settle path stop treating ordinary completion as a
1001
+ * hostile host transaction.
1002
+ */
1003
+ const followTerminalPhases = new WeakMap<HTMLElement, FollowTerminalPhase>()
1004
+
1005
+ /**
1006
+ * Measured terminal budget per port: the owned bottom space this port is
1007
+ * allowed to keep while the producer has stopped and reveal work is still
1008
+ * draining. It is seeded from a real measurement (the owned offset plus the
1009
+ * length the last commit actually added) and may only shrink afterwards — a
1010
+ * budget that grows re-opens speculative blank space below the reply, which is
1011
+ * exactly the "lift" the terminal phase exists to remove.
1012
+ */
1013
+ const followTerminalBudgets = new WeakMap<HTMLElement, number>()
1014
+
1015
+ /**
1016
+ * COMPLETION GROWTH CREDIT. Pixels of floor growth the reader actually
1017
+ * received in the completion window, which a shift rise may be funded from.
1018
+ *
1019
+ * This is a ledger rather than a per-frame clamp because growth and the
1020
+ * matching shift rise do not have to land on the same frame: a completion whose
1021
+ * final append covers several wraps commits its growth over two or three
1022
+ * frames, and a clamp that only ever compared against the PREVIOUS frame's
1023
+ * growth banked nothing across that gap. The uncovered remainder then had no
1024
+ * legal way to rise, so it survived as a real offset and was released later by
1025
+ * the pad retirement — the visible release-then-return this credit exists to
1026
+ * prevent. Credit is still bounded by growth the reader actually got, so a
1027
+ * rise can never manufacture motion that did not come from content.
1028
+ */
1029
+ const followCompletionGrowthCredit = new WeakMap<HTMLElement, number>()
1030
+
1031
+ /** Largest shift rise one frame may fund from banked completion credit. */
1032
+ export const FOLLOW_COMPLETION_CREDIT_MAX_STEP_PX = 24
1033
+
928
1034
  /** Logical position and velocity survive a React owner handoff and finish. */
929
1035
  const followMotionStates = new WeakMap<HTMLElement, FollowMotionState>()
930
1036
 
@@ -1132,6 +1238,7 @@ function safeShiftLimit(
1132
1238
  if (last === undefined) return 0
1133
1239
  const status = turnStatusOf(port)
1134
1240
  const composer = port.querySelector<HTMLElement>('[data-composer-seat]')
1241
+ if (status !== null) followHadStatus.add(port)
1135
1242
  if (status !== null || composer !== null) followHadChrome.add(port)
1136
1243
  const cached = followPaintLimits.get(port)
1137
1244
  // Content growth alone cannot move the limit (measured at the floor, the
@@ -1256,6 +1363,46 @@ function applyVisual(
1256
1363
  const surfaces = shiftSurfacesOf(port)
1257
1364
  ensureFlowFillsPort(port)
1258
1365
  void runwayPx
1366
+ // Once production has stopped and there is no status surface left to
1367
+ // animate, the only correct target is the port's current natural floor.
1368
+ // Keep that calculation in the same frame as the DOM measurement: remove
1369
+ // owned layout space, clear the compositor offsets, measure the resulting
1370
+ // scrollHeight, then write exactly that floor. A terminal pad/transform
1371
+ // pair makes the visible position depend on a later retirement pass and is
1372
+ // the source of the end-of-stream rebound.
1373
+ if (
1374
+ followTerminalPhases.get(port) === 'terminal-drain'
1375
+ && turnStatusOf(port) === null
1376
+ && !followHadStatus.has(port)
1377
+ ) {
1378
+ restoreRunway(port)
1379
+ setFlowPad(port, 0)
1380
+ const contentHeight = Math.max(0, port.scrollHeight)
1381
+ const floor = Math.max(0, contentHeight - port.clientHeight)
1382
+ if (port.style.overflowAnchor !== 'none') port.style.overflowAnchor = 'none'
1383
+ if (port.style.scrollBehavior !== 'auto') port.style.scrollBehavior = 'auto'
1384
+ if (writeScrollTop) setFollowScrollTop(port, floor)
1385
+ followRunwayOffsetHistory.set(port, 0)
1386
+ followObservedContentHeight.set(port, contentHeight)
1387
+ followLastFloorPx.set(port, floor)
1388
+ followLastShiftPx.set(port, 0)
1389
+ followMotionStates.set(port, {
1390
+ capacityPx: Number.POSITIVE_INFINITY,
1391
+ constrained: false,
1392
+ extent: contentHeight,
1393
+ lagPx: 0,
1394
+ reservePx: 0,
1395
+ velocityPxPerSec: 0,
1396
+ terminalPhase: 'terminal-drain',
1397
+ runwayPx: 0,
1398
+ baselineShiftPx: 0,
1399
+ anchorDeltaPx: measureAnchorDeltaForTelemetry(port),
1400
+ remainingRevealChars: followRemainingRevealChars,
1401
+ })
1402
+ for (const surface of surfaces) setShift(surface, 0)
1403
+ FrameCoordinator.forDocument().markLayoutDirty()
1404
+ return contentHeight
1405
+ }
1259
1406
  // A runway added while the column still fit the viewport was absorbed by
1260
1407
  // the host's bottom slack: its measured offset was zero, but once real
1261
1408
  // overflow begins the same margin costs scroll length. Detect that
@@ -1278,7 +1425,18 @@ function applyVisual(
1278
1425
  followFloorHistory.set(port, preFloor)
1279
1426
  }
1280
1427
  }
1281
- ensureRunway(port, surfaces, reservePx)
1428
+ // TERMINAL BUDGET CAP. While the producer has stopped and reveal work is
1429
+ // still draining, the owned margin is held to a MEASURED budget instead of
1430
+ // the fixed 72px streaming runway. The cap is deliberately a floor against
1431
+ // the live reservation (`Math.max(budget, reservePx)`): the budget can only
1432
+ // ever be tighter than what is already reserved on this port, so it can never
1433
+ // create the `reservePx > runwayOffset` mismatch whose difference repaints
1434
+ // the whole message. It shrinks the margin only as far as the reservation
1435
+ // already went, which is exactly the space this completion has paid for.
1436
+ const effectiveRunwayPx = followTerminalPhases.has(port)
1437
+ ? Math.max(Math.min(FOLLOW_STATUS_RUNWAY_PX, followTerminalBudgets.get(port) ?? FOLLOW_STATUS_RUNWAY_PX), reservePx)
1438
+ : FOLLOW_STATUS_RUNWAY_PX
1439
+ ensureRunway(port, surfaces, effectiveRunwayPx)
1282
1440
  const contentHeight2 = Math.max(0, port.scrollHeight)
1283
1441
  const runwayOffset2 = runwayOffsetOf(port)
1284
1442
  // Rebase the spring extent onto the current offset domain. targetHeight
@@ -1294,6 +1452,7 @@ function applyVisual(
1294
1452
  followRunwayOffsetHistory.set(port, runwayOffset2)
1295
1453
  const contentHeight = contentHeight2
1296
1454
  const runwayOffset = runwayOffset2
1455
+ followObservedContentHeight.set(port, contentHeight2)
1297
1456
  const targetHeight = Math.max(0, contentHeight - runwayOffset)
1298
1457
  const floor = Math.max(0, contentHeight - port.clientHeight)
1299
1458
  const extent = Math.min(targetHeight, Math.max(0, animatedH))
@@ -1390,9 +1549,27 @@ function applyVisual(
1390
1549
  // paint; a completion without a collapse covers the runway in full on the
1391
1550
  // first frame (the drain contract). The streaming path never enters this
1392
1551
  // branch — wrap compensation stays unlimited there.
1552
+ //
1553
+ // The funding is banked as CREDIT (`followCompletionGrowthCredit`) instead of
1554
+ // being re-derived from the previous frame: a multi-wrap completion commits
1555
+ // its growth across several frames, and a per-frame-only comparison forgot
1556
+ // the earlier frames' growth, leaving the shift unable to rise at all once
1557
+ // the layout stopped growing. That stuck remainder is what the pad
1558
+ // retirement later released as the visible return.
1393
1559
  if (followCompletionSettle.has(port) && previousShift !== undefined && previousFloor !== undefined) {
1394
1560
  const confirmedGrowthPx = Math.max(0, floor - previousFloor)
1395
- if (shift > previousShift + confirmedGrowthPx) shift = previousShift + confirmedGrowthPx
1561
+ let credit = (followCompletionGrowthCredit.get(port) ?? 0) + confirmedGrowthPx
1562
+ if (shift > previousShift) {
1563
+ // Growth-funded rise: spend part of the bank, bounded per frame so a
1564
+ // single burst cannot teleport the newest line.
1565
+ const rise = shift - previousShift
1566
+ const funded = Math.min(rise, FOLLOW_COMPLETION_CREDIT_MAX_STEP_PX)
1567
+ const spend = Math.min(funded, credit)
1568
+ const unfunded = rise - spend
1569
+ if (unfunded > 0) shift -= unfunded
1570
+ credit -= spend
1571
+ }
1572
+ followCompletionGrowthCredit.set(port, credit)
1396
1573
  }
1397
1574
  followLastShiftPx.set(port, shift)
1398
1575
  const requestedShift = trajectoryShiftPx ?? (baselineShift + requestedLag)
@@ -1409,10 +1586,19 @@ function applyVisual(
1409
1586
  lagPx: effectiveLag,
1410
1587
  reservePx: visibleReserve,
1411
1588
  velocityPxPerSec,
1589
+ terminalPhase: followTerminalPhases.get(port) ?? 'live',
1590
+ runwayPx: runwayOffset,
1591
+ baselineShiftPx: baselineShift,
1592
+ anchorDeltaPx: followTerminalPhases.has(port) ? measureAnchorDeltaForTelemetry(port) : null,
1593
+ remainingRevealChars: followRemainingRevealChars,
1412
1594
  })
1413
1595
  for (const surface of surfaces) setShift(surface, shift)
1414
1596
  const status = turnStatusOf(port)
1415
1597
  if (status !== null) setShift(status, 0)
1598
+ // This pass wrote layout-affecting state (padding, min-height, an owned
1599
+ // scrollTop, surface transforms). Mark geometry dirty so the shared clock
1600
+ // re-reads it at the top of the NEXT frame instead of mid-write.
1601
+ FrameCoordinator.forDocument().markLayoutDirty()
1416
1602
  return effectiveExtent
1417
1603
  }
1418
1604
 
@@ -1446,8 +1632,15 @@ function finishAtNaturalFloor(
1446
1632
  port: HTMLElement,
1447
1633
  retainCompositor = true,
1448
1634
  writeScrollTop = true,
1635
+ /** A handoff release owns the current transforms until its pad is gone. */
1636
+ deferCompositor = false,
1449
1637
  ): void {
1450
1638
  followCompletionSettle.delete(port)
1639
+ // No engine-owned geometry survives this call: label the port `natural` so
1640
+ // the final telemetry sample reports the end state rather than the cascade it
1641
+ // just left.
1642
+ followTerminalPhases.set(port, 'natural')
1643
+ followCompletionGrowthCredit.delete(port)
1451
1644
  followTraceUntilMs = Math.max(followTraceUntilMs, performance.now() + 10000)
1452
1645
  followTrace('finish-enter', { sh: hostShOf(port), st: Math.round(port.scrollTop), pad: Math.round(flowPadOf(port)), retain: retainCompositor })
1453
1646
  const surfaces = shiftSurfacesOf(port)
@@ -1466,6 +1659,10 @@ function finishAtNaturalFloor(
1466
1659
  port.removeAttribute(FOLLOW_OWNED_ATTR)
1467
1660
  port.style.overflowAnchor = ''
1468
1661
  port.style.scrollBehavior = ''
1662
+ if (deferCompositor) {
1663
+ followMotionStates.delete(port)
1664
+ return
1665
+ }
1469
1666
  for (const surface of surfaces) {
1470
1667
  if (promotedSet.has(surface)) holdCompositorAtRest(surface)
1471
1668
  else setShift(surface, 0)
@@ -1545,13 +1742,20 @@ export function useConversationFollow(
1545
1742
  const startedAsEntrance = entrance
1546
1743
  const owner = {}
1547
1744
  const generation = ++followGeneration
1548
- let rafId = 0
1549
1745
  let last = performance.now()
1550
1746
  let following = true
1551
1747
  let primed = false
1552
1748
  let animatedH = 0
1553
1749
  let reservePx = 0
1554
1750
  let velocityPxPerSec = 0
1751
+ /**
1752
+ * Scroll length the most recent observed commit actually added. This is the
1753
+ * honest measure of how much room the still-draining text needs, and it is
1754
+ * what seeds the terminal budget in place of the fixed 72px streaming
1755
+ * runway.
1756
+ */
1757
+ let lastCommitGrowthPx = 0
1758
+ let lastObservedContentHeight = 0
1555
1759
  let interacting = false
1556
1760
  let readerGestureIntent = false
1557
1761
  let readerReleased = false
@@ -1576,7 +1780,41 @@ export function useConversationFollow(
1576
1780
  let holding: HTMLElement | null = null
1577
1781
  let entrancePending = entranceRef.current
1578
1782
 
1783
+ /**
1784
+ * Tall-row entrance clamp. A surface shift can only mask lag up to the
1785
+ * real gap to status/composer chrome; a row that commits more than that
1786
+ * (an Edit call with its diff, a wide tool card) repaints the remainder
1787
+ * as an instant upward step — and the 0.2.x host's order-length tail
1788
+ * snap pins scrollTop to the new floor in the same task, so no
1789
+ * scroll-domain lag can be carried either. Reveal the excess through the
1790
+ * wrapper's REAL height instead: clamp it to 0 at prime and raise it at
1791
+ * the spring cadence, so the floor grows gradually and every bottom
1792
+ * writer (this engine and the host snap alike) glides the row in.
1793
+ */
1794
+ let entranceClamp: {
1795
+ readonly element: HTMLElement
1796
+ readonly extent: number
1797
+ /** Wrapper height already committed when the clamp armed (0 on mount). */
1798
+ readonly basePx: number
1799
+ revealedPx: number
1800
+ readonly maxHeight: string
1801
+ readonly overflow: string
1802
+ } | null = null
1803
+
1804
+ const releaseEntranceClamp = (): void => {
1805
+ if (entranceClamp === null) return
1806
+ const clamp = entranceClamp
1807
+ entranceClamp = null
1808
+ clamp.element.style.maxHeight = clamp.maxHeight
1809
+ clamp.element.style.overflow = clamp.overflow
1810
+ }
1811
+
1812
+ const entranceClampLagPx = (): number => entranceClamp === null
1813
+ ? 0
1814
+ : Math.max(0, entranceClamp.extent - entranceClamp.revealedPx)
1815
+
1579
1816
  const finishEntrance = (): void => {
1817
+ releaseEntranceClamp()
1580
1818
  if (!entrancePending) return
1581
1819
  entrancePending = false
1582
1820
  onEntranceSettledRef.current?.()
@@ -1606,6 +1844,8 @@ export function useConversationFollow(
1606
1844
 
1607
1845
  const reportFollow = (next: HTMLElement, isActive: boolean): void => {
1608
1846
  const state = followMotionStates.get(next)
1847
+ const phase: FollowTerminalPhase = followTerminalPhases.get(next) ?? (isActive ? 'live' : 'natural')
1848
+ const runwayPx = state?.runwayPx ?? runwayOffsetOf(next)
1609
1849
  debugRuntime.reportFollow(next, {
1610
1850
  // TEMP audit provenance: lagPx=-1 marks the fallback path (no motion
1611
1851
  // state owned by this reporter this frame).
@@ -1620,6 +1860,12 @@ export function useConversationFollow(
1620
1860
  scrollHeight: next.scrollHeight,
1621
1861
  clientHeight: next.clientHeight,
1622
1862
  active: isActive,
1863
+ terminalPhase: phase,
1864
+ runwayPx,
1865
+ terminalBudgetPx: followTerminalBudgets.get(next) ?? runwayPx,
1866
+ baselineShiftPx: state?.baselineShiftPx ?? Math.max(0, runwayPx - (state?.reservePx ?? 0)),
1867
+ readingAnchorDeltaPx: state?.anchorDeltaPx ?? null,
1868
+ remainingRevealChars: followRemainingRevealChars,
1623
1869
  })
1624
1870
  }
1625
1871
 
@@ -2014,8 +2260,14 @@ export function useConversationFollow(
2014
2260
  if (tail !== null) resize.observe(tail)
2015
2261
  }
2016
2262
 
2017
- const frame = (now: number) => {
2018
- rafId = requestAnimationFrame(frame)
2263
+ const coordinator = FrameCoordinator.forDocument()
2264
+ const frameTaskRef: { id: string | null } = { id: null }
2265
+ const stopFollowTask = (): void => {
2266
+ if (frameTaskRef.id === null) return
2267
+ coordinator.unregisterTask(frameTaskRef.id)
2268
+ frameTaskRef.id = null
2269
+ }
2270
+ const frame = (now: number): boolean => {
2019
2271
  // Spring time is clamped so one paint after a stall cannot teleport the
2020
2272
  // transcript. Runway response uses real elapsed time, otherwise long
2021
2273
  // frames would open paint room more slowly precisely when it is needed.
@@ -2024,9 +2276,9 @@ export function useConversationFollow(
2024
2276
  const tuning = debugRuntime.activeTuning()
2025
2277
  last = now
2026
2278
  const root = rootRef.current
2027
- if (root === null) return
2279
+ if (root === null) return activeRef.current
2028
2280
  const nextPort = root.closest<HTMLElement>('[data-conversation-scroll]')
2029
- if (nextPort === null) return
2281
+ if (nextPort === null) return activeRef.current
2030
2282
  bindPort(nextPort)
2031
2283
  observeTailSurface()
2032
2284
  resetHostScrollOwnershipForNewTurn(nextPort)
@@ -2034,8 +2286,8 @@ export function useConversationFollow(
2034
2286
  if (activeRef.current) followActivePorts.add(nextPort)
2035
2287
  else followActivePorts.delete(nextPort)
2036
2288
  // A hidden/unmeasured port has no meaningful floor yet. Keep this owner
2037
- // unprimed and let the already-scheduled RAF initialize it after layout.
2038
- if (nextPort.clientHeight <= 0) return
2289
+ // unprimed and let the shared clock initialize it after layout.
2290
+ if (nextPort.clientHeight <= 0) return true
2039
2291
 
2040
2292
  const floor = Math.max(0, nextPort.scrollHeight - nextPort.clientHeight)
2041
2293
  const reportedLag = floor - nextPort.scrollTop
@@ -2052,7 +2304,17 @@ export function useConversationFollow(
2052
2304
  if (completionSettleGuardsPort(nextPort)) {
2053
2305
  primed = true
2054
2306
  following = false
2055
- return
2307
+ return activeRef.current
2308
+ }
2309
+ if (activeRef.current) {
2310
+ // A new live reply starts from the natural geometry left by the
2311
+ // previous reply. Do not carry its terminal ledger into this turn;
2312
+ // otherwise the direct terminal-floor path could own the first
2313
+ // frame of the new stream before a fresh drain is observed.
2314
+ followTerminalPhases.delete(nextPort)
2315
+ followTerminalBudgets.delete(nextPort)
2316
+ followCompletionGrowthCredit.delete(nextPort)
2317
+ followHadStatus.delete(nextPort)
2056
2318
  }
2057
2319
  const inherited = nextPort.hasAttribute(FOLLOW_OWNED_ATTR)
2058
2320
  ? followMotionStates.get(nextPort)
@@ -2077,10 +2339,42 @@ export function useConversationFollow(
2077
2339
  animatedH = entrancePending
2078
2340
  ? Math.max(0, nextPort.scrollHeight - entranceExtent)
2079
2341
  : nextPort.scrollHeight
2342
+ // The shift path cannot mask an entrance taller than the real gap
2343
+ // to status/composer chrome (the geometry invariant in applyVisual
2344
+ // catches the excess up in the same frame). Clamp the wrapper's
2345
+ // real height here — applyVisual below then measures the
2346
+ // pre-entrance floor and the commit lands with zero visual delta —
2347
+ // and let the frame loop raise the clamp at the spring cadence.
2348
+ // A MOUNT entrance carries the wrapper's full height as the extent
2349
+ // (clamp starts at 0). A growth-pulse re-prime on a settled row
2350
+ // carries only the DELTA: starting at 0 there would collapse the
2351
+ // whole committed row mid-stream and pan the viewport up by its
2352
+ // full height, so the clamp starts at the pre-growth height and
2353
+ // reveals only the delta.
2354
+ if (
2355
+ entrancePending
2356
+ && entranceExtent > safeShiftLimit(nextPort, shiftSurfacesOf(nextPort)) + FOLLOW_SETTLE_EPSILON_PX
2357
+ ) {
2358
+ const fullPx = root.offsetHeight
2359
+ if (fullPx > 0) {
2360
+ const basePx = Math.max(0, fullPx - entranceExtent)
2361
+ entranceClamp = {
2362
+ element: root,
2363
+ extent: entranceExtent,
2364
+ basePx,
2365
+ revealedPx: 0,
2366
+ maxHeight: root.style.maxHeight,
2367
+ overflow: root.style.overflow,
2368
+ }
2369
+ root.style.overflow = 'hidden'
2370
+ root.style.maxHeight = `${basePx}px`
2371
+ }
2372
+ }
2080
2373
  // Established before first paint; the matching margin below is
2081
2374
  // written in the same commit, so this held-and-canceled space
2082
2375
  // never moves a pixel.
2083
2376
  const hasStatus = turnStatusOf(nextPort) !== null
2377
+ if (hasStatus) followHadStatus.add(nextPort)
2084
2378
  // ZERO-DOWNWARD-REBOUND: the reservation must never land below the
2085
2379
  // margin this port already owns. base = margin − reservation is the
2086
2380
  // painted shift; a reservation smaller than the owned margin would
@@ -2161,7 +2455,10 @@ export function useConversationFollow(
2161
2455
  updateRevealScale(nextPort, elapsedMs)
2162
2456
  reportFollow(nextPort, activeRef.current)
2163
2457
  const runwayOffset = runwayOffsetOf(nextPort)
2164
- const entranceLag = Math.max(0, nextPort.scrollHeight - animatedH - runwayOffset)
2458
+ const entranceLag = Math.max(
2459
+ 0,
2460
+ nextPort.scrollHeight - animatedH - runwayOffset,
2461
+ ) + entranceClampLagPx()
2165
2462
  if (entranceLag <= FOLLOW_SETTLE_EPSILON_PX) finishEntrance()
2166
2463
  } else {
2167
2464
  finishEntrance()
@@ -2170,7 +2467,7 @@ export function useConversationFollow(
2170
2467
  finishEntrance()
2171
2468
  }
2172
2469
  primed = true
2173
- return
2470
+ return activeRef.current
2174
2471
  }
2175
2472
 
2176
2473
  const repinSlack = readerReleased ? FOLLOW_REPIN_PX : FOLLOW_SLACK_PX
@@ -2204,18 +2501,18 @@ export function useConversationFollow(
2204
2501
  if (!activeRef.current || !following) {
2205
2502
  followScrollLedgers.set(nextPort, nextPort.scrollTop)
2206
2503
  reportFollow(nextPort, activeRef.current)
2207
- return
2504
+ return activeRef.current
2208
2505
  }
2209
2506
  detectHostScroll(nextPort, floor)
2210
2507
  if (hostOwnsScroll) {
2211
2508
  followScrollLedgers.set(nextPort, nextPort.scrollTop)
2212
2509
  reportFollow(nextPort, false)
2213
- return
2510
+ return false
2214
2511
  }
2215
2512
  hold(nextPort)
2216
2513
  if (!isLeader(nextPort)) {
2217
2514
  finishEntrance()
2218
- return
2515
+ return true
2219
2516
  }
2220
2517
  // Runway and an equal transform cancel visually. It is the zero point,
2221
2518
  // not residual motion: decaying below it would scroll past the final
@@ -2230,6 +2527,7 @@ export function useConversationFollow(
2230
2527
  const predictGrowth = predictiveRef?.current ?? predictive
2231
2528
  const statusElement = turnStatusOf(nextPort)
2232
2529
  const hasStatus = statusElement !== null
2530
+ if (hasStatus) followHadStatus.add(nextPort)
2233
2531
  if (statusElement !== null) lastStatusHeightPx = statusElement.offsetHeight
2234
2532
  const statusJustRemoved = predictGrowth && statusWasPresent === true && !hasStatus
2235
2533
  if (statusJustRemoved) {
@@ -2262,12 +2560,47 @@ export function useConversationFollow(
2262
2560
  const effectiveReserveTarget = Math.max(reservePx, pressureReserveTarget)
2263
2561
  const reserveStep = 1 - Math.exp(-elapsedMs / tuning.reserveResponseMs)
2264
2562
  reservePx += (effectiveReserveTarget - reservePx) * reserveStep
2563
+ // TERMINAL DRAIN: the producer has stopped but the reveal queue still owes
2564
+ // text. From here the owned margin is no longer free to stay at the full
2565
+ // streaming runway — it is held to the space this completion has actually
2566
+ // measured, so the final text converges on its natural floor instead of
2567
+ // reserving a fixed 72px that a later retirement has to give back.
2568
+ //
2569
+ // The budget is seeded from a real measurement (the offset already in the
2570
+ // DOM plus the length the last observed commit added) and then only
2571
+ // shrinks. It is also never allowed below the reservation this port has
2572
+ // already made, because `runwayOffset < reservePx` is precisely the
2573
+ // mismatch whose difference repaints the whole message.
2574
+ const terminalState = refreshTerminalRevealLedger()
2575
+ if (terminalState.drain) {
2576
+ if (!followTerminalPhases.has(nextPort)) {
2577
+ const seeded = Math.min(
2578
+ FOLLOW_STATUS_RUNWAY_PX,
2579
+ Math.max(runwayOffsetOf(nextPort), 0) + Math.max(0, lastCommitGrowthPx),
2580
+ )
2581
+ followTerminalPhases.set(nextPort, 'terminal-drain')
2582
+ followTerminalBudgets.set(nextPort, seeded)
2583
+ followTrace('terminal-drain', { budget: Math.round(seeded), reserve: Math.round(reservePx), backlog: followRemainingRevealChars })
2584
+ }
2585
+ }
2586
+ const terminalBudget = followTerminalPhases.has(nextPort)
2587
+ ? Math.max(
2588
+ Math.min(followTerminalBudgets.get(nextPort) ?? FOLLOW_STATUS_RUNWAY_PX, FOLLOW_STATUS_RUNWAY_PX),
2589
+ reservePx,
2590
+ )
2591
+ : FOLLOW_STATUS_RUNWAY_PX
2265
2592
  if (predictGrowth || runwayOffsetOf(nextPort) > 0.5) {
2266
- ensureRunway(nextPort, shiftSurfacesOf(nextPort), reservePx)
2593
+ ensureRunway(nextPort, shiftSurfacesOf(nextPort), terminalBudget)
2267
2594
  }
2268
2595
  const runwayOffset = runwayOffsetOf(nextPort)
2269
2596
  const contentHeight = nextPort.scrollHeight
2270
2597
  const floorNow = Math.max(0, contentHeight - nextPort.clientHeight)
2598
+ // Ledger the length this commit actually added, so the terminal budget is
2599
+ // seeded from observed geometry rather than from the streaming constant.
2600
+ lastCommitGrowthPx = lastObservedContentHeight > 0
2601
+ ? Math.max(0, contentHeight - lastObservedContentHeight)
2602
+ : 0
2603
+ lastObservedContentHeight = contentHeight
2271
2604
  const trajectoryActive = predictive
2272
2605
  && root.querySelector('[data-variant="think"]') === null
2273
2606
  && runwayOffset > 0
@@ -2344,29 +2677,52 @@ export function useConversationFollow(
2344
2677
  trajectoryPositionPx = null
2345
2678
  trajectoryWasActive = false
2346
2679
  }
2347
- const lag = Math.max(0, contentHeight - animatedH - runwayOffset)
2348
- const step = computeFollowStep(dt, {
2349
- lag,
2350
- speedEma: speedCpsRef.current,
2351
- velocityPxPerSec,
2352
- }, tuning)
2353
- if (lag <= 0.1) {
2680
+ if (entranceClamp !== null) {
2681
+ // The clamp IS the entrance lag: the spring drives the wrapper's
2682
+ // real height, every bottom write (ours and the host snap) tracks
2683
+ // the growing floor, and the scroll-domain lag stays drained.
2684
+ const clamp = entranceClamp
2685
+ const remainingPx = Math.max(0, clamp.extent - clamp.revealedPx)
2686
+ const clampStep = computeFollowStep(dt, {
2687
+ lag: remainingPx,
2688
+ speedEma: speedCpsRef.current,
2689
+ velocityPxPerSec,
2690
+ }, tuning)
2691
+ if (remainingPx <= 0.1) {
2692
+ clamp.revealedPx = clamp.extent
2693
+ clamp.element.style.maxHeight = `${clamp.basePx + clamp.extent}px`
2694
+ velocityPxPerSec = 0
2695
+ } else {
2696
+ clamp.revealedPx = Math.min(clamp.extent, clamp.revealedPx + clampStep.advancePx)
2697
+ clamp.element.style.maxHeight = `${clamp.basePx + clamp.revealedPx}px`
2698
+ velocityPxPerSec = clampStep.velocityPxPerSec
2699
+ }
2354
2700
  animatedH = contentHeight - runwayOffset
2355
- velocityPxPerSec = 0
2356
2701
  } else {
2357
- // ZERO-DOWNWARD-REBOUND: the reservation is NOT real lag. With the
2358
- // steady-state tail pin it already rides as baseline-canceled gap
2359
- // (shift = margin − reservation + reveal lag), so forcing the spring
2360
- // to stop `reservePx` short of the natural floor — the pre-tail-pin
2361
- // "hold the reserve as lag" semantic — would double-count it and
2362
- // repaint the difference as an instant downward step the moment
2363
- // prediction shuts off. The spring always drains to the natural
2364
- // floor; the painted shift decays through the rate-limited release.
2365
- animatedH = Math.min(
2366
- contentHeight - runwayOffset,
2367
- animatedH + step.advancePx,
2368
- )
2369
- velocityPxPerSec = step.velocityPxPerSec
2702
+ const lag = Math.max(0, contentHeight - animatedH - runwayOffset)
2703
+ const step = computeFollowStep(dt, {
2704
+ lag,
2705
+ speedEma: speedCpsRef.current,
2706
+ velocityPxPerSec,
2707
+ }, tuning)
2708
+ if (lag <= 0.1) {
2709
+ animatedH = contentHeight - runwayOffset
2710
+ velocityPxPerSec = 0
2711
+ } else {
2712
+ // ZERO-DOWNWARD-REBOUND: the reservation is NOT real lag. With the
2713
+ // steady-state tail pin it already rides as baseline-canceled gap
2714
+ // (shift = margin − reservation + reveal lag), so forcing the spring
2715
+ // to stop `reservePx` short of the natural floor — the pre-tail-pin
2716
+ // "hold the reserve as lag" semantic — would double-count it and
2717
+ // repaint the difference as an instant downward step the moment
2718
+ // prediction shuts off. The spring always drains to the natural
2719
+ // floor; the painted shift decays through the rate-limited release.
2720
+ animatedH = Math.min(
2721
+ contentHeight - runwayOffset,
2722
+ animatedH + step.advancePx,
2723
+ )
2724
+ velocityPxPerSec = step.velocityPxPerSec
2725
+ }
2370
2726
  }
2371
2727
  }
2372
2728
  animatedH = applyVisual(
@@ -2413,18 +2769,27 @@ export function useConversationFollow(
2413
2769
  const remainingEntranceLag = Math.max(
2414
2770
  0,
2415
2771
  nextPort.scrollHeight - animatedH - runwayOffsetOf(nextPort),
2416
- )
2772
+ ) + entranceClampLagPx()
2417
2773
  if (remainingEntranceLag <= FOLLOW_SETTLE_EPSILON_PX) finishEntrance()
2774
+ // Stay armed while the reply is streaming or the entrance is settling;
2775
+ // the coordinator parks the loop once both are done.
2776
+ return true
2418
2777
  }
2419
2778
 
2420
2779
  // Prime ownership and the final committed height in this layout phase.
2421
- // A producer-complete text arm can mount and drain before the next RAF;
2780
+ // A producer-complete text arm can mount and drain before the next frame;
2422
2781
  // deferring this first pass would let it unmount unprimed after replacing
2423
2782
  // the previous owner, leaving a large final append at the old scrollTop.
2783
+ // The shared clock then keeps this owner framed; the loop still parks
2784
+ // itself whenever the reply is neither streaming nor being followed.
2785
+ frameTaskRef.id = coordinator.registerTask({
2786
+ onSimulate: (_dtMs, now) => frame(now),
2787
+ })
2424
2788
  frame(performance.now())
2425
2789
  return () => {
2790
+ releaseEntranceClamp()
2426
2791
  if (!controlScrollRef.current) {
2427
- cancelAnimationFrame(rafId)
2792
+ stopFollowTask()
2428
2793
  if (port !== null) followActivePorts.delete(port)
2429
2794
  unsubscribeCommit?.()
2430
2795
  resize?.disconnect()
@@ -2442,7 +2807,7 @@ export function useConversationFollow(
2442
2807
  releaseRevealScale()
2443
2808
  return
2444
2809
  }
2445
- cancelAnimationFrame(rafId)
2810
+ stopFollowTask()
2446
2811
  if (port !== null) followActivePorts.delete(port)
2447
2812
  unsubscribeCommit?.()
2448
2813
  if (interactTimer !== null) clearTimeout(interactTimer)
@@ -2496,6 +2861,27 @@ export function useConversationFollow(
2496
2861
  if (!activeRef.current) {
2497
2862
  followTraceUntilMs = Math.max(followTraceUntilMs, performance.now() + 10000)
2498
2863
  followTrace('fast-gate', { sh: host.scrollHeight, st: Math.round(host.scrollTop), pad: Math.round(flowPadOf(host)) })
2864
+ const stableTail = turnStatusOf(host) === null
2865
+ && followTerminalPhases.get(host) !== 'host-cascade'
2866
+ && !followHadStatus.has(host)
2867
+ const ownedRunway = runwayOffsetOf(host)
2868
+ if (
2869
+ stableTail
2870
+ && (ownedRunway > FOLLOW_SETTLE_EPSILON_PX || flowPadOf(host) > FOLLOW_SETTLE_EPSILON_PX)
2871
+ ) {
2872
+ // Stable terminal text has no host surface left to cascade. Remove
2873
+ // the owned space and land on the natural floor in this same task;
2874
+ // there is no second pad-retirement animation to move the message
2875
+ // away from the target and then back again.
2876
+ restoreRunway(host)
2877
+ setFlowPad(host, 0)
2878
+ finishAtNaturalFloor(host, !startedAsEntrance, true)
2879
+ followLeaders.delete(host)
2880
+ followCompletionSettle.delete(host)
2881
+ releaseRevealScale()
2882
+ debugRuntime.reportFollow(host, null)
2883
+ return
2884
+ }
2499
2885
  restoreRunway(host)
2500
2886
  setFlowPad(host, 0)
2501
2887
  finishAtNaturalFloor(host, !startedAsEntrance, true)
@@ -2529,6 +2915,23 @@ export function useConversationFollow(
2529
2915
  // pre-paint task. Converting the dead margin to pad creates a second
2530
2916
  // owned extent that must later retire as the visible "slow rebound".
2531
2917
  followTraceUntilMs = performance.now() + 15000
2918
+ // Terminal phase ledger. The drain phase (producer stopped, reveal queue
2919
+ // still owes text) may only converge upward on the natural floor, so its
2920
+ // measured budget survives the handoff; the cascade phase is entered
2921
+ // whenever a real host transaction is what moved the layout.
2922
+ followTerminalPhases.set(
2923
+ host,
2924
+ statusWasPresent === true || turnStatusOf(host) !== null || followHadStatus.has(host)
2925
+ ? 'host-cascade'
2926
+ : 'terminal-drain',
2927
+ )
2928
+ if (!followTerminalBudgets.has(host)) {
2929
+ followTerminalBudgets.set(host, Math.min(FOLLOW_STATUS_RUNWAY_PX, Math.max(0, reservePx)))
2930
+ }
2931
+ // Seed the growth credit with the content this task actually committed, so
2932
+ // the settle's first frames can pay down the runway it is retiring instead
2933
+ // of waiting for a later frame's growth to fund it.
2934
+ followCompletionGrowthCredit.set(host, Math.max(0, environmentCommitGrowthPx(host)))
2532
2935
  if (pruneDeadRunway(host)) {
2533
2936
  followTrace('cleanup-dead-margin', { sh: host.scrollHeight, st: Math.round(host.scrollTop), pad: Math.round(flowPadOf(host)) })
2534
2937
  reservePx = 0
@@ -2590,7 +2993,14 @@ export function useConversationFollow(
2590
2993
  for (const name of GESTURE_EVENTS) {
2591
2994
  host.addEventListener(name, markGesture, { passive: true })
2592
2995
  }
2996
+ const settleTaskRef: { id: string | null } = { id: null }
2997
+ const stopSettleTask = (): void => {
2998
+ if (settleTaskRef.id === null) return
2999
+ coordinator.unregisterTask(settleTaskRef.id)
3000
+ settleTaskRef.id = null
3001
+ }
2593
3002
  const stopSettleListeners = (): void => {
3003
+ stopSettleTask()
2594
3004
  for (const name of GESTURE_EVENTS) host.removeEventListener(name, markGesture)
2595
3005
  resize?.disconnect()
2596
3006
  mutations?.disconnect()
@@ -2628,10 +3038,10 @@ export function useConversationFollow(
2628
3038
  // commit pixel-stable is handed back to the layout at the bounded
2629
3039
  // settle rate and the pinned viewport glides down to the natural
2630
3040
  // resting position against the composer.
2631
- const settleFrame = (now: number): void => {
3041
+ const settleFrame = (now: number): boolean => {
2632
3042
  if (!isLeader(host)) {
2633
3043
  stopSettleListeners()
2634
- return
3044
+ return false
2635
3045
  }
2636
3046
  if (interacting && (readerGestureIntent || readerScrolledUp(host))) {
2637
3047
  readerGestureIntent = false
@@ -2642,7 +3052,7 @@ export function useConversationFollow(
2642
3052
  releaseRevealScale()
2643
3053
  debugRuntime.reportFollow(host, null)
2644
3054
  stopSettleListeners()
2645
- return
3055
+ return false
2646
3056
  }
2647
3057
  const dt = Math.min(FOLLOW_MAX_FRAME_MS, Math.max(0, now - settleLast))
2648
3058
  const tuning = debugRuntime.activeTuning()
@@ -2657,7 +3067,7 @@ export function useConversationFollow(
2657
3067
  releaseRevealScale()
2658
3068
  debugRuntime.reportFollow(host, null)
2659
3069
  stopSettleListeners()
2660
- return
3070
+ return false
2661
3071
  }
2662
3072
  // HOST COMPLETION CASCADE: around completion the host swaps the status
2663
3073
  // row for its process/tail rows, auto-collapses the think disclosure
@@ -2678,6 +3088,13 @@ export function useConversationFollow(
2678
3088
  ? enforceReadingAnchor(host, true)
2679
3089
  : null
2680
3090
  settleQuietMs = !settleRetiring && (guardDelta === null || Math.abs(guardDelta) <= 0.5) ? settleQuietMs + dt : 0
3091
+ // The settle OWNS the port only while a hostile host transaction is
3092
+ // still on screen. With no status row left, this is ordinary terminal
3093
+ // text drain: converge on the natural floor instead of keeping the
3094
+ // fixed streaming runway the settle path would otherwise re-assert.
3095
+ if (followTerminalPhases.get(host) !== 'host-cascade') {
3096
+ followTerminalPhases.set(host, turnStatusOf(host) === null ? 'terminal-drain' : 'host-cascade')
3097
+ }
2681
3098
  // ZERO-DOWNWARD-REBOUND (root cause): scrollTop is pinned to the floor
2682
3099
  // and the floor rides the owned margin 1:1, so the settle must NOT
2683
3100
  // shrink the margin — that clamps scrollTop downward with no shift
@@ -2691,18 +3108,38 @@ export function useConversationFollow(
2691
3108
  const settleStatus = turnStatusOf(host)
2692
3109
  const ownedRunwayPx = runwayOffsetOf(host)
2693
3110
  if (ownedRunwayPx > FOLLOW_SETTLE_EPSILON_PX) {
3111
+ // CREDIT-BOUNDED TRANSFER (U3). Moving the margin into the pad is
3112
+ // compensation for a HOST transaction — a status swap or a row
3113
+ // replacement stealing layout height under the pin. That credit must
3114
+ // be earned: once the cascade has quieted and the drain has closed,
3115
+ // the remaining margin is no longer compensation for anything, so it
3116
+ // is capped by the terminal budget instead of being transferred in
3117
+ // full and then retired on the `FOLLOW_RUNWAY_RETIRE_MS` timer. The
3118
+ // timer path stays only as the host-cascade fallback.
3119
+ const terminalDrain = followTerminalPhases.get(host) === 'terminal-drain'
3120
+ const earnedBudgetPx = Math.min(
3121
+ FOLLOW_STATUS_RUNWAY_PX,
3122
+ Math.max(followTerminalBudgets.get(host) ?? FOLLOW_STATUS_RUNWAY_PX, reservePx),
3123
+ )
2694
3124
  const requestedTransferPx = settleStatus === null
2695
3125
  ? ownedRunwayPx
2696
3126
  : Math.min(
2697
3127
  reservePx,
2698
3128
  ((tuning.runwayPx || FOLLOW_STATUS_RUNWAY_PX) / FOLLOW_RUNWAY_RETIRE_MS) * dt,
2699
3129
  )
2700
- const transferredPx = transferRunwayToFlowPad(host, requestedTransferPx)
3130
+ const cappedTransferPx = terminalDrain
3131
+ ? Math.min(requestedTransferPx, Math.max(0, ownedRunwayPx - earnedBudgetPx))
3132
+ : requestedTransferPx
3133
+ const transferredPx = transferRunwayToFlowPad(host, cappedTransferPx)
2701
3134
  // Do not bank a completion pad. The margin release and pad removal
2702
3135
  // happen in one layout pass, so there is no extra extent to retire
2703
3136
  // later and no second slow rebound after the cascade quiets.
2704
3137
  if (transferredPx > 0) setFlowPad(host, Math.max(0, flowPadOf(host) - transferredPx))
2705
3138
  reservePx = Math.max(0, reservePx - transferredPx)
3139
+ followTerminalBudgets.set(
3140
+ host,
3141
+ Math.max(reservePx, Math.min(earnedBudgetPx, runwayOffsetOf(host))),
3142
+ )
2706
3143
  // targetHeight = scrollHeight - runway. The equal margin-to-pad
2707
3144
  // transfer keeps scrollHeight fixed and lowers runway by δ, so the
2708
3145
  // logical extent rises by exactly δ (not 2δ). Both this rebase and
@@ -2756,7 +3193,7 @@ export function useConversationFollow(
2756
3193
  releaseRevealScale()
2757
3194
  debugRuntime.reportFollow(host, null)
2758
3195
  stopSettleListeners()
2759
- return
3196
+ return false
2760
3197
  }
2761
3198
  const step = computeFollowStep(dt, {
2762
3199
  lag,
@@ -2795,9 +3232,11 @@ export function useConversationFollow(
2795
3232
  }
2796
3233
  }
2797
3234
  reportFollow(host, false)
2798
- requestAnimationFrame(settleFrame)
3235
+ return true
2799
3236
  }
2800
- requestAnimationFrame(settleFrame)
3237
+ settleTaskRef.id = coordinator.registerTask({
3238
+ onSimulate: (_dtMs, now) => settleFrame(now),
3239
+ })
2801
3240
  }
2802
3241
  }, [active, rootRef, speedCpsRef, revealScaleRef, predictive, predictiveRef, controlScroll])
2803
3242