dsh-smooth-stream 0.6.0 → 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. */
@@ -780,8 +795,9 @@ function measureReadingAnchor(port: HTMLElement): { anchor: HTMLElement; index:
780
795
  return null
781
796
  }
782
797
  // Host-caused motion only: the engine's own shift glide and floor changes
783
- // cancel out (renderedΔ = topΔ − shiftΔ + floorΔ).
784
- const delta = (top - stored.top) - (shift - stored.shift) + (pad - stored.pad)
798
+ // cancel out (renderedΔ = topΔ + scrollΔ − shiftΔ + padΔ).
799
+ const scrollDelta = scrollTop - stored.scrollTop
800
+ const delta = (top - stored.top) + scrollDelta - (shift - stored.shift) + (pad - stored.pad)
785
801
  if (stored.element === anchor) {
786
802
  // Refresh the baseline to the current rendered position on every
787
803
  // measurement: between guard passes the viewport legitimately moves
@@ -866,7 +882,7 @@ function pruneDeadRunway(port: HTMLElement): boolean {
866
882
  }
867
883
 
868
884
  function setFlowPad(port: HTMLElement, px: number): void {
869
- const flow = flowElementOf(port)
885
+ const flow = flowPadElementOf(port)
870
886
  if (flow === null) return
871
887
  const existing = followSettlePads.get(port)
872
888
  const original = existing?.original ?? flow.style.paddingBottom
@@ -907,10 +923,51 @@ function followTrace(event: string, detail: Record<string, number | string | boo
907
923
  if (!traceActive()) return
908
924
  console.log(`[dsh-follow] ${event}`, JSON.stringify(detail))
909
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
+
910
940
  function hostShOf(port: HTMLElement): number {
911
941
  return port.scrollHeight
912
942
  }
913
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
+
914
971
  function invalidatePaintLimit(port: HTMLElement): void {
915
972
  followPaintLimits.delete(port)
916
973
  }
@@ -922,8 +979,58 @@ interface FollowMotionState {
922
979
  readonly lagPx: number
923
980
  readonly reservePx: number
924
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
925
992
  }
926
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
+
927
1034
  /** Logical position and velocity survive a React owner handoff and finish. */
928
1035
  const followMotionStates = new WeakMap<HTMLElement, FollowMotionState>()
929
1036
 
@@ -1131,6 +1238,7 @@ function safeShiftLimit(
1131
1238
  if (last === undefined) return 0
1132
1239
  const status = turnStatusOf(port)
1133
1240
  const composer = port.querySelector<HTMLElement>('[data-composer-seat]')
1241
+ if (status !== null) followHadStatus.add(port)
1134
1242
  if (status !== null || composer !== null) followHadChrome.add(port)
1135
1243
  const cached = followPaintLimits.get(port)
1136
1244
  // Content growth alone cannot move the limit (measured at the floor, the
@@ -1255,6 +1363,46 @@ function applyVisual(
1255
1363
  const surfaces = shiftSurfacesOf(port)
1256
1364
  ensureFlowFillsPort(port)
1257
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
+ }
1258
1406
  // A runway added while the column still fit the viewport was absorbed by
1259
1407
  // the host's bottom slack: its measured offset was zero, but once real
1260
1408
  // overflow begins the same margin costs scroll length. Detect that
@@ -1277,7 +1425,18 @@ function applyVisual(
1277
1425
  followFloorHistory.set(port, preFloor)
1278
1426
  }
1279
1427
  }
1280
- 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)
1281
1440
  const contentHeight2 = Math.max(0, port.scrollHeight)
1282
1441
  const runwayOffset2 = runwayOffsetOf(port)
1283
1442
  // Rebase the spring extent onto the current offset domain. targetHeight
@@ -1293,6 +1452,7 @@ function applyVisual(
1293
1452
  followRunwayOffsetHistory.set(port, runwayOffset2)
1294
1453
  const contentHeight = contentHeight2
1295
1454
  const runwayOffset = runwayOffset2
1455
+ followObservedContentHeight.set(port, contentHeight2)
1296
1456
  const targetHeight = Math.max(0, contentHeight - runwayOffset)
1297
1457
  const floor = Math.max(0, contentHeight - port.clientHeight)
1298
1458
  const extent = Math.min(targetHeight, Math.max(0, animatedH))
@@ -1389,9 +1549,27 @@ function applyVisual(
1389
1549
  // paint; a completion without a collapse covers the runway in full on the
1390
1550
  // first frame (the drain contract). The streaming path never enters this
1391
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.
1392
1559
  if (followCompletionSettle.has(port) && previousShift !== undefined && previousFloor !== undefined) {
1393
1560
  const confirmedGrowthPx = Math.max(0, floor - previousFloor)
1394
- 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)
1395
1573
  }
1396
1574
  followLastShiftPx.set(port, shift)
1397
1575
  const requestedShift = trajectoryShiftPx ?? (baselineShift + requestedLag)
@@ -1408,10 +1586,19 @@ function applyVisual(
1408
1586
  lagPx: effectiveLag,
1409
1587
  reservePx: visibleReserve,
1410
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,
1411
1594
  })
1412
1595
  for (const surface of surfaces) setShift(surface, shift)
1413
1596
  const status = turnStatusOf(port)
1414
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()
1415
1602
  return effectiveExtent
1416
1603
  }
1417
1604
 
@@ -1445,8 +1632,15 @@ function finishAtNaturalFloor(
1445
1632
  port: HTMLElement,
1446
1633
  retainCompositor = true,
1447
1634
  writeScrollTop = true,
1635
+ /** A handoff release owns the current transforms until its pad is gone. */
1636
+ deferCompositor = false,
1448
1637
  ): void {
1449
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)
1450
1644
  followTraceUntilMs = Math.max(followTraceUntilMs, performance.now() + 10000)
1451
1645
  followTrace('finish-enter', { sh: hostShOf(port), st: Math.round(port.scrollTop), pad: Math.round(flowPadOf(port)), retain: retainCompositor })
1452
1646
  const surfaces = shiftSurfacesOf(port)
@@ -1465,6 +1659,10 @@ function finishAtNaturalFloor(
1465
1659
  port.removeAttribute(FOLLOW_OWNED_ATTR)
1466
1660
  port.style.overflowAnchor = ''
1467
1661
  port.style.scrollBehavior = ''
1662
+ if (deferCompositor) {
1663
+ followMotionStates.delete(port)
1664
+ return
1665
+ }
1468
1666
  for (const surface of surfaces) {
1469
1667
  if (promotedSet.has(surface)) holdCompositorAtRest(surface)
1470
1668
  else setShift(surface, 0)
@@ -1544,13 +1742,20 @@ export function useConversationFollow(
1544
1742
  const startedAsEntrance = entrance
1545
1743
  const owner = {}
1546
1744
  const generation = ++followGeneration
1547
- let rafId = 0
1548
1745
  let last = performance.now()
1549
1746
  let following = true
1550
1747
  let primed = false
1551
1748
  let animatedH = 0
1552
1749
  let reservePx = 0
1553
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
1554
1759
  let interacting = false
1555
1760
  let readerGestureIntent = false
1556
1761
  let readerReleased = false
@@ -1575,7 +1780,41 @@ export function useConversationFollow(
1575
1780
  let holding: HTMLElement | null = null
1576
1781
  let entrancePending = entranceRef.current
1577
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
+
1578
1816
  const finishEntrance = (): void => {
1817
+ releaseEntranceClamp()
1579
1818
  if (!entrancePending) return
1580
1819
  entrancePending = false
1581
1820
  onEntranceSettledRef.current?.()
@@ -1605,6 +1844,8 @@ export function useConversationFollow(
1605
1844
 
1606
1845
  const reportFollow = (next: HTMLElement, isActive: boolean): void => {
1607
1846
  const state = followMotionStates.get(next)
1847
+ const phase: FollowTerminalPhase = followTerminalPhases.get(next) ?? (isActive ? 'live' : 'natural')
1848
+ const runwayPx = state?.runwayPx ?? runwayOffsetOf(next)
1608
1849
  debugRuntime.reportFollow(next, {
1609
1850
  // TEMP audit provenance: lagPx=-1 marks the fallback path (no motion
1610
1851
  // state owned by this reporter this frame).
@@ -1619,6 +1860,12 @@ export function useConversationFollow(
1619
1860
  scrollHeight: next.scrollHeight,
1620
1861
  clientHeight: next.clientHeight,
1621
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,
1622
1869
  })
1623
1870
  }
1624
1871
 
@@ -2013,8 +2260,14 @@ export function useConversationFollow(
2013
2260
  if (tail !== null) resize.observe(tail)
2014
2261
  }
2015
2262
 
2016
- const frame = (now: number) => {
2017
- 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 => {
2018
2271
  // Spring time is clamped so one paint after a stall cannot teleport the
2019
2272
  // transcript. Runway response uses real elapsed time, otherwise long
2020
2273
  // frames would open paint room more slowly precisely when it is needed.
@@ -2023,9 +2276,9 @@ export function useConversationFollow(
2023
2276
  const tuning = debugRuntime.activeTuning()
2024
2277
  last = now
2025
2278
  const root = rootRef.current
2026
- if (root === null) return
2279
+ if (root === null) return activeRef.current
2027
2280
  const nextPort = root.closest<HTMLElement>('[data-conversation-scroll]')
2028
- if (nextPort === null) return
2281
+ if (nextPort === null) return activeRef.current
2029
2282
  bindPort(nextPort)
2030
2283
  observeTailSurface()
2031
2284
  resetHostScrollOwnershipForNewTurn(nextPort)
@@ -2033,8 +2286,8 @@ export function useConversationFollow(
2033
2286
  if (activeRef.current) followActivePorts.add(nextPort)
2034
2287
  else followActivePorts.delete(nextPort)
2035
2288
  // A hidden/unmeasured port has no meaningful floor yet. Keep this owner
2036
- // unprimed and let the already-scheduled RAF initialize it after layout.
2037
- if (nextPort.clientHeight <= 0) return
2289
+ // unprimed and let the shared clock initialize it after layout.
2290
+ if (nextPort.clientHeight <= 0) return true
2038
2291
 
2039
2292
  const floor = Math.max(0, nextPort.scrollHeight - nextPort.clientHeight)
2040
2293
  const reportedLag = floor - nextPort.scrollTop
@@ -2051,7 +2304,17 @@ export function useConversationFollow(
2051
2304
  if (completionSettleGuardsPort(nextPort)) {
2052
2305
  primed = true
2053
2306
  following = false
2054
- 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)
2055
2318
  }
2056
2319
  const inherited = nextPort.hasAttribute(FOLLOW_OWNED_ATTR)
2057
2320
  ? followMotionStates.get(nextPort)
@@ -2076,10 +2339,42 @@ export function useConversationFollow(
2076
2339
  animatedH = entrancePending
2077
2340
  ? Math.max(0, nextPort.scrollHeight - entranceExtent)
2078
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
+ }
2079
2373
  // Established before first paint; the matching margin below is
2080
2374
  // written in the same commit, so this held-and-canceled space
2081
2375
  // never moves a pixel.
2082
2376
  const hasStatus = turnStatusOf(nextPort) !== null
2377
+ if (hasStatus) followHadStatus.add(nextPort)
2083
2378
  // ZERO-DOWNWARD-REBOUND: the reservation must never land below the
2084
2379
  // margin this port already owns. base = margin − reservation is the
2085
2380
  // painted shift; a reservation smaller than the owned margin would
@@ -2160,7 +2455,10 @@ export function useConversationFollow(
2160
2455
  updateRevealScale(nextPort, elapsedMs)
2161
2456
  reportFollow(nextPort, activeRef.current)
2162
2457
  const runwayOffset = runwayOffsetOf(nextPort)
2163
- const entranceLag = Math.max(0, nextPort.scrollHeight - animatedH - runwayOffset)
2458
+ const entranceLag = Math.max(
2459
+ 0,
2460
+ nextPort.scrollHeight - animatedH - runwayOffset,
2461
+ ) + entranceClampLagPx()
2164
2462
  if (entranceLag <= FOLLOW_SETTLE_EPSILON_PX) finishEntrance()
2165
2463
  } else {
2166
2464
  finishEntrance()
@@ -2169,7 +2467,7 @@ export function useConversationFollow(
2169
2467
  finishEntrance()
2170
2468
  }
2171
2469
  primed = true
2172
- return
2470
+ return activeRef.current
2173
2471
  }
2174
2472
 
2175
2473
  const repinSlack = readerReleased ? FOLLOW_REPIN_PX : FOLLOW_SLACK_PX
@@ -2203,18 +2501,18 @@ export function useConversationFollow(
2203
2501
  if (!activeRef.current || !following) {
2204
2502
  followScrollLedgers.set(nextPort, nextPort.scrollTop)
2205
2503
  reportFollow(nextPort, activeRef.current)
2206
- return
2504
+ return activeRef.current
2207
2505
  }
2208
2506
  detectHostScroll(nextPort, floor)
2209
2507
  if (hostOwnsScroll) {
2210
2508
  followScrollLedgers.set(nextPort, nextPort.scrollTop)
2211
2509
  reportFollow(nextPort, false)
2212
- return
2510
+ return false
2213
2511
  }
2214
2512
  hold(nextPort)
2215
2513
  if (!isLeader(nextPort)) {
2216
2514
  finishEntrance()
2217
- return
2515
+ return true
2218
2516
  }
2219
2517
  // Runway and an equal transform cancel visually. It is the zero point,
2220
2518
  // not residual motion: decaying below it would scroll past the final
@@ -2229,6 +2527,7 @@ export function useConversationFollow(
2229
2527
  const predictGrowth = predictiveRef?.current ?? predictive
2230
2528
  const statusElement = turnStatusOf(nextPort)
2231
2529
  const hasStatus = statusElement !== null
2530
+ if (hasStatus) followHadStatus.add(nextPort)
2232
2531
  if (statusElement !== null) lastStatusHeightPx = statusElement.offsetHeight
2233
2532
  const statusJustRemoved = predictGrowth && statusWasPresent === true && !hasStatus
2234
2533
  if (statusJustRemoved) {
@@ -2261,12 +2560,47 @@ export function useConversationFollow(
2261
2560
  const effectiveReserveTarget = Math.max(reservePx, pressureReserveTarget)
2262
2561
  const reserveStep = 1 - Math.exp(-elapsedMs / tuning.reserveResponseMs)
2263
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
2264
2592
  if (predictGrowth || runwayOffsetOf(nextPort) > 0.5) {
2265
- ensureRunway(nextPort, shiftSurfacesOf(nextPort), reservePx)
2593
+ ensureRunway(nextPort, shiftSurfacesOf(nextPort), terminalBudget)
2266
2594
  }
2267
2595
  const runwayOffset = runwayOffsetOf(nextPort)
2268
2596
  const contentHeight = nextPort.scrollHeight
2269
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
2270
2604
  const trajectoryActive = predictive
2271
2605
  && root.querySelector('[data-variant="think"]') === null
2272
2606
  && runwayOffset > 0
@@ -2343,29 +2677,52 @@ export function useConversationFollow(
2343
2677
  trajectoryPositionPx = null
2344
2678
  trajectoryWasActive = false
2345
2679
  }
2346
- const lag = Math.max(0, contentHeight - animatedH - runwayOffset)
2347
- const step = computeFollowStep(dt, {
2348
- lag,
2349
- speedEma: speedCpsRef.current,
2350
- velocityPxPerSec,
2351
- }, tuning)
2352
- 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
+ }
2353
2700
  animatedH = contentHeight - runwayOffset
2354
- velocityPxPerSec = 0
2355
2701
  } else {
2356
- // ZERO-DOWNWARD-REBOUND: the reservation is NOT real lag. With the
2357
- // steady-state tail pin it already rides as baseline-canceled gap
2358
- // (shift = margin − reservation + reveal lag), so forcing the spring
2359
- // to stop `reservePx` short of the natural floor — the pre-tail-pin
2360
- // "hold the reserve as lag" semantic — would double-count it and
2361
- // repaint the difference as an instant downward step the moment
2362
- // prediction shuts off. The spring always drains to the natural
2363
- // floor; the painted shift decays through the rate-limited release.
2364
- animatedH = Math.min(
2365
- contentHeight - runwayOffset,
2366
- animatedH + step.advancePx,
2367
- )
2368
- 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
+ }
2369
2726
  }
2370
2727
  }
2371
2728
  animatedH = applyVisual(
@@ -2412,18 +2769,27 @@ export function useConversationFollow(
2412
2769
  const remainingEntranceLag = Math.max(
2413
2770
  0,
2414
2771
  nextPort.scrollHeight - animatedH - runwayOffsetOf(nextPort),
2415
- )
2772
+ ) + entranceClampLagPx()
2416
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
2417
2777
  }
2418
2778
 
2419
2779
  // Prime ownership and the final committed height in this layout phase.
2420
- // 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;
2421
2781
  // deferring this first pass would let it unmount unprimed after replacing
2422
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
+ })
2423
2788
  frame(performance.now())
2424
2789
  return () => {
2790
+ releaseEntranceClamp()
2425
2791
  if (!controlScrollRef.current) {
2426
- cancelAnimationFrame(rafId)
2792
+ stopFollowTask()
2427
2793
  if (port !== null) followActivePorts.delete(port)
2428
2794
  unsubscribeCommit?.()
2429
2795
  resize?.disconnect()
@@ -2441,7 +2807,7 @@ export function useConversationFollow(
2441
2807
  releaseRevealScale()
2442
2808
  return
2443
2809
  }
2444
- cancelAnimationFrame(rafId)
2810
+ stopFollowTask()
2445
2811
  if (port !== null) followActivePorts.delete(port)
2446
2812
  unsubscribeCommit?.()
2447
2813
  if (interactTimer !== null) clearTimeout(interactTimer)
@@ -2492,25 +2858,35 @@ export function useConversationFollow(
2492
2858
  // measure the extent against it):
2493
2859
  let settleQuietMs = 0
2494
2860
  let settleSig = ''
2495
- const lagBeforeCompletionPaint = Math.max(
2496
- 0,
2497
- host.scrollHeight - animatedH - runwayOffsetOf(host),
2498
- )
2499
- const currentRunway = runwayOffsetOf(host)
2500
- const currentShift = Math.abs(currentShiftOf(shiftSurfacesOf(host).at(-1) ?? host))
2501
- if (
2502
- !activeRef.current
2503
- && lagBeforeCompletionPaint <= FOLLOW_SLACK_PX
2504
- && currentRunway <= FOLLOW_SETTLE_EPSILON_PX
2505
- && currentShift <= FOLLOW_SETTLE_EPSILON_PX
2506
- // A live completion pad must retire through the settle's glide first;
2507
- // fast-finishing here would freeze it in place as visible bottom gap.
2508
- && flowPadOf(host) <= FOLLOW_SETTLE_EPSILON_PX
2509
- ) {
2861
+ if (!activeRef.current) {
2510
2862
  followTraceUntilMs = Math.max(followTraceUntilMs, performance.now() + 10000)
2511
2863
  followTrace('fast-gate', { sh: host.scrollHeight, st: Math.round(host.scrollTop), pad: Math.round(flowPadOf(host)) })
2512
- finishAtNaturalFloor(host, !startedAsEntrance)
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
+ }
2885
+ restoreRunway(host)
2886
+ setFlowPad(host, 0)
2887
+ finishAtNaturalFloor(host, !startedAsEntrance, true)
2513
2888
  followLeaders.delete(host)
2889
+ followCompletionSettle.delete(host)
2514
2890
  releaseRevealScale()
2515
2891
  debugRuntime.reportFollow(host, null)
2516
2892
  return
@@ -2539,6 +2915,23 @@ export function useConversationFollow(
2539
2915
  // pre-paint task. Converting the dead margin to pad creates a second
2540
2916
  // owned extent that must later retire as the visible "slow rebound".
2541
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)))
2542
2935
  if (pruneDeadRunway(host)) {
2543
2936
  followTrace('cleanup-dead-margin', { sh: host.scrollHeight, st: Math.round(host.scrollTop), pad: Math.round(flowPadOf(host)) })
2544
2937
  reservePx = 0
@@ -2600,7 +2993,14 @@ export function useConversationFollow(
2600
2993
  for (const name of GESTURE_EVENTS) {
2601
2994
  host.addEventListener(name, markGesture, { passive: true })
2602
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
+ }
2603
3002
  const stopSettleListeners = (): void => {
3003
+ stopSettleTask()
2604
3004
  for (const name of GESTURE_EVENTS) host.removeEventListener(name, markGesture)
2605
3005
  resize?.disconnect()
2606
3006
  mutations?.disconnect()
@@ -2638,10 +3038,10 @@ export function useConversationFollow(
2638
3038
  // commit pixel-stable is handed back to the layout at the bounded
2639
3039
  // settle rate and the pinned viewport glides down to the natural
2640
3040
  // resting position against the composer.
2641
- const settleFrame = (now: number): void => {
3041
+ const settleFrame = (now: number): boolean => {
2642
3042
  if (!isLeader(host)) {
2643
3043
  stopSettleListeners()
2644
- return
3044
+ return false
2645
3045
  }
2646
3046
  if (interacting && (readerGestureIntent || readerScrolledUp(host))) {
2647
3047
  readerGestureIntent = false
@@ -2652,7 +3052,7 @@ export function useConversationFollow(
2652
3052
  releaseRevealScale()
2653
3053
  debugRuntime.reportFollow(host, null)
2654
3054
  stopSettleListeners()
2655
- return
3055
+ return false
2656
3056
  }
2657
3057
  const dt = Math.min(FOLLOW_MAX_FRAME_MS, Math.max(0, now - settleLast))
2658
3058
  const tuning = debugRuntime.activeTuning()
@@ -2667,7 +3067,7 @@ export function useConversationFollow(
2667
3067
  releaseRevealScale()
2668
3068
  debugRuntime.reportFollow(host, null)
2669
3069
  stopSettleListeners()
2670
- return
3070
+ return false
2671
3071
  }
2672
3072
  // HOST COMPLETION CASCADE: around completion the host swaps the status
2673
3073
  // row for its process/tail rows, auto-collapses the think disclosure
@@ -2688,6 +3088,13 @@ export function useConversationFollow(
2688
3088
  ? enforceReadingAnchor(host, true)
2689
3089
  : null
2690
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
+ }
2691
3098
  // ZERO-DOWNWARD-REBOUND (root cause): scrollTop is pinned to the floor
2692
3099
  // and the floor rides the owned margin 1:1, so the settle must NOT
2693
3100
  // shrink the margin — that clamps scrollTop downward with no shift
@@ -2701,18 +3108,38 @@ export function useConversationFollow(
2701
3108
  const settleStatus = turnStatusOf(host)
2702
3109
  const ownedRunwayPx = runwayOffsetOf(host)
2703
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
+ )
2704
3124
  const requestedTransferPx = settleStatus === null
2705
3125
  ? ownedRunwayPx
2706
3126
  : Math.min(
2707
3127
  reservePx,
2708
3128
  ((tuning.runwayPx || FOLLOW_STATUS_RUNWAY_PX) / FOLLOW_RUNWAY_RETIRE_MS) * dt,
2709
3129
  )
2710
- 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)
2711
3134
  // Do not bank a completion pad. The margin release and pad removal
2712
3135
  // happen in one layout pass, so there is no extra extent to retire
2713
3136
  // later and no second slow rebound after the cascade quiets.
2714
3137
  if (transferredPx > 0) setFlowPad(host, Math.max(0, flowPadOf(host) - transferredPx))
2715
3138
  reservePx = Math.max(0, reservePx - transferredPx)
3139
+ followTerminalBudgets.set(
3140
+ host,
3141
+ Math.max(reservePx, Math.min(earnedBudgetPx, runwayOffsetOf(host))),
3142
+ )
2716
3143
  // targetHeight = scrollHeight - runway. The equal margin-to-pad
2717
3144
  // transfer keeps scrollHeight fixed and lowers runway by δ, so the
2718
3145
  // logical extent rises by exactly δ (not 2δ). Both this rebase and
@@ -2766,7 +3193,7 @@ export function useConversationFollow(
2766
3193
  releaseRevealScale()
2767
3194
  debugRuntime.reportFollow(host, null)
2768
3195
  stopSettleListeners()
2769
- return
3196
+ return false
2770
3197
  }
2771
3198
  const step = computeFollowStep(dt, {
2772
3199
  lag,
@@ -2805,9 +3232,11 @@ export function useConversationFollow(
2805
3232
  }
2806
3233
  }
2807
3234
  reportFollow(host, false)
2808
- requestAnimationFrame(settleFrame)
3235
+ return true
2809
3236
  }
2810
- requestAnimationFrame(settleFrame)
3237
+ settleTaskRef.id = coordinator.registerTask({
3238
+ onSimulate: (_dtMs, now) => settleFrame(now),
3239
+ })
2811
3240
  }
2812
3241
  }, [active, rootRef, speedCpsRef, revealScaleRef, predictive, predictiveRef, controlScroll])
2813
3242