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.
- package/README.en.md +4 -1
- package/README.md +5 -1
- package/lib/client.js +1757 -282
- package/lib/client.js.map +1 -1
- package/lib/index.js +299 -3
- package/package.json +1 -1
- package/src/client/AgentRowEntrance.module.css +1 -4
- package/src/client/AnimatedDisclosure.tsx +3 -3
- package/src/client/DebugPanel.tsx +34 -15
- package/src/client/FrameCoordinator.ts +180 -0
- package/src/client/LogarithmicFade.module.css +160 -0
- package/src/client/SmoothStreamCard.tsx +41 -3
- package/src/client/StreamBuffer.ts +145 -0
- package/src/client/TypewriterAssistantNodeView.module.css +82 -12
- package/src/client/TypewriterAssistantNodeView.tsx +232 -104
- package/src/client/debugRuntime.ts +58 -0
- package/src/client/harnessIcons.ts +27 -0
- package/src/client/index.ts +153 -11
- package/src/client/locales.ts +90 -0
- package/src/client/smooth-stream-card-controller.ts +10 -3
- package/src/client/smooth-stream-settings-api.ts +7 -3
- package/src/client/teleprompterGlide.ts +499 -70
- package/src/client/useDecoupledMarkdown.ts +173 -0
- package/src/client/useFpsGuard.ts +9 -6
- package/src/client/useLogarithmicFade.ts +672 -0
- package/src/client/useProgressiveDomText.ts +27 -11
- package/src/client/useSmoothStreamContent.ts +5 -13
- package/src/config.ts +1 -1
- package/src/plugin.ts +99 -12
- package/src/settings-api.ts +4 -0
- package/src/settings-channel.ts +303 -0
- package/src/settings.ts +14 -0
|
@@ -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
|
-
|
|
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Δ +
|
|
784
|
-
const
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
2017
|
-
|
|
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
|
|
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(
|
|
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),
|
|
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
|
-
|
|
2347
|
-
|
|
2348
|
-
|
|
2349
|
-
|
|
2350
|
-
|
|
2351
|
-
|
|
2352
|
-
|
|
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
|
-
|
|
2357
|
-
|
|
2358
|
-
|
|
2359
|
-
|
|
2360
|
-
|
|
2361
|
-
|
|
2362
|
-
|
|
2363
|
-
|
|
2364
|
-
|
|
2365
|
-
|
|
2366
|
-
|
|
2367
|
-
|
|
2368
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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):
|
|
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
|
|
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
|
-
|
|
3235
|
+
return true
|
|
2809
3236
|
}
|
|
2810
|
-
|
|
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
|
|