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