mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-07 00:44:49 +00:00
* refactor(app): stop resetting the chat timeline when its agent id changes A mounted timeline only ever shows one agent: every host keys its panel by tab id, and a tab keeps its id across a target change only when a draft becomes the agent it created. The per-agent resets inside the timeline therefore never had a different agent to reset for. They only fired at that create handoff, where they hid and re-placed a timeline that was already on screen. Delete them: LegendList's dataKey and the placed agent id, the web viewport key, the bottom anchor's resetForAgent, the history window's agent id, and the expansion and path-opener resets in the stream view. * fix(app): keep one chat timeline across the draft-to-agent handoff A draft tab and the agent its first prompt creates rendered two different panels: DraftPanel mounted its own dock and list, and the retarget swapped it for AgentPanel's. On Android, with the LegendList timeline, the swap blanked the first turn and re-placed it. Render both from the agent panel. A draft supplies its timeline from the pending create in the create-flow store (composer/draft/timeline.ts) and its composer in the composer slot. The retarget then re-renders the same stream view with the agent's timeline, so the list instance and its measured rows stay mounted. Agent gating is unchanged. Float the compact header over draft tabs as it already floats over agent tabs. The header sat in flow above a draft and over an agent, so the list viewport grew 92 dp at the retarget and the turn moved. The handoff E2E now fails if the first row disappears, hides, or shifts down while the draft becomes the agent. * fix(app): load subagents and tasks only for an agent, not a draft tab
790 lines
25 KiB
TypeScript
790 lines
25 KiB
TypeScript
import { useEffect, useRef, useState } from "react";
|
|
import type { BottomAnchorTransportBehavior } from "./strategy";
|
|
|
|
export type BottomAnchorMode = "sticky-bottom" | "detached";
|
|
|
|
export interface BottomAnchorRouteRequest {
|
|
reason: "initial-entry" | "resume";
|
|
agentId: string;
|
|
requestKey: string;
|
|
}
|
|
|
|
export interface BottomAnchorLocalRequest {
|
|
reason: "jump-to-bottom" | "message-sent" | "rewind";
|
|
agentId: string;
|
|
}
|
|
|
|
export type BottomAnchorBlockedReason =
|
|
| "waiting_for_history_readiness"
|
|
| "waiting_for_measurable_viewport"
|
|
| "waiting_for_measurable_content"
|
|
| "waiting_for_post_layout_verification";
|
|
|
|
type BottomAnchorRequestReason =
|
|
| BottomAnchorRouteRequest["reason"]
|
|
| BottomAnchorLocalRequest["reason"];
|
|
|
|
interface BottomAnchorRequest {
|
|
id: number;
|
|
agentId: string;
|
|
reason: BottomAnchorRequestReason;
|
|
requestKey: string;
|
|
}
|
|
|
|
interface ControllerMeasurementState {
|
|
containerKey: string;
|
|
viewportWidth: number;
|
|
viewportHeight: number;
|
|
contentHeight: number;
|
|
offsetY: number;
|
|
viewportMeasuredForKey: string | null;
|
|
contentMeasuredForKey: string | null;
|
|
}
|
|
|
|
interface AttemptContext {
|
|
requestId: number | null;
|
|
retries: number;
|
|
confirmationPasses?: number;
|
|
startedContentHeight?: number;
|
|
startedOffsetY?: number;
|
|
startedViewportHeight?: number;
|
|
}
|
|
|
|
interface ScheduledFrameHandle {
|
|
cancelled: boolean;
|
|
rafId: number | null;
|
|
remainingFrames: number;
|
|
callback: () => void;
|
|
}
|
|
|
|
interface BottomAnchorControllerDriver {
|
|
destroy: () => void;
|
|
getSnapshot: () => {
|
|
mode: BottomAnchorMode;
|
|
pendingRequest: BottomAnchorRequest | null;
|
|
pendingVerification: AttemptContext | null;
|
|
blockedReason: BottomAnchorBlockedReason | null;
|
|
};
|
|
applyRouteRequest: (request: BottomAnchorRouteRequest | null) => void;
|
|
requestLocalAnchor: (request: BottomAnchorLocalRequest) => void;
|
|
beginUserScroll: () => void;
|
|
endUserScroll: (params: { isAtEnd: boolean }) => void;
|
|
detachByUser: () => void;
|
|
holdForDisclosure: () => void;
|
|
releaseDisclosure: (params: { isAtEnd: boolean }) => void;
|
|
handleViewportMetricsChange: (params: {
|
|
previousViewportWidth: number;
|
|
viewportWidth: number;
|
|
previousViewportHeight: number;
|
|
viewportHeight: number;
|
|
}) => void;
|
|
handleContentSizeChange: () => void;
|
|
prepareForStickyViewportChange: () => void;
|
|
handleScrollNearBottomChange: (params: {
|
|
nextIsNearBottom: boolean;
|
|
scrollDelta: number;
|
|
}) => void;
|
|
notifyAuthoritativeHistoryMaybeChanged: () => void;
|
|
reevaluate: (animated?: boolean) => void;
|
|
}
|
|
|
|
interface CreateBottomAnchorControllerDriverInput {
|
|
getAgentId: () => string;
|
|
getIsAuthoritativeHistoryReady: () => boolean;
|
|
getRenderStrategy: () => string;
|
|
getTransportBehavior: () => BottomAnchorTransportBehavior;
|
|
getMeasurementState: () => ControllerMeasurementState;
|
|
isNearBottom: () => boolean;
|
|
scrollToBottom: (animated: boolean) => void;
|
|
onModeChange: (mode: BottomAnchorMode) => void;
|
|
scheduleFrame: (params: {
|
|
kind: "attempt" | "verification";
|
|
callback: () => void;
|
|
delayFrames?: number;
|
|
}) => unknown;
|
|
cancelFrame: (handle: unknown) => void;
|
|
}
|
|
|
|
const MAX_VERIFICATION_RETRIES = 3;
|
|
const WEB_PARTIAL_VIRTUALIZED_CONFIRMATION_DELAY_FRAMES = 1;
|
|
const USER_SCROLL_AWAY_DELTA_PX = 24;
|
|
|
|
function scheduleAnimationFrameWithDelay(input: {
|
|
callback: () => void;
|
|
delayFrames?: number;
|
|
}): ScheduledFrameHandle {
|
|
const handle: ScheduledFrameHandle = {
|
|
cancelled: false,
|
|
rafId: null,
|
|
remainingFrames: Math.max(0, input.delayFrames ?? 0),
|
|
callback: input.callback,
|
|
};
|
|
|
|
const tick = () => {
|
|
if (handle.cancelled) {
|
|
return;
|
|
}
|
|
if (handle.remainingFrames > 0) {
|
|
handle.remainingFrames -= 1;
|
|
handle.rafId = requestAnimationFrame(tick);
|
|
return;
|
|
}
|
|
handle.rafId = null;
|
|
input.callback();
|
|
};
|
|
|
|
handle.rafId = requestAnimationFrame(tick);
|
|
return handle;
|
|
}
|
|
|
|
function cancelScheduledAnimationFrame(handle: unknown): void {
|
|
const scheduled = handle as ScheduledFrameHandle | null;
|
|
if (!scheduled) {
|
|
return;
|
|
}
|
|
scheduled.cancelled = true;
|
|
if (scheduled.rafId !== null) {
|
|
cancelAnimationFrame(scheduled.rafId);
|
|
scheduled.rafId = null;
|
|
}
|
|
}
|
|
|
|
function deriveVerificationBlockedReason(input: {
|
|
isAuthoritativeHistoryReady: boolean;
|
|
measurementState: ControllerMeasurementState;
|
|
}): Exclude<BottomAnchorBlockedReason, "waiting_for_post_layout_verification"> | null {
|
|
if (!input.isAuthoritativeHistoryReady) {
|
|
return "waiting_for_history_readiness";
|
|
}
|
|
if (
|
|
input.measurementState.viewportHeight <= 0 ||
|
|
input.measurementState.viewportMeasuredForKey !== input.measurementState.containerKey
|
|
) {
|
|
return "waiting_for_measurable_viewport";
|
|
}
|
|
if (
|
|
input.measurementState.contentHeight <= 0 ||
|
|
input.measurementState.contentMeasuredForKey !== input.measurementState.containerKey
|
|
) {
|
|
return "waiting_for_measurable_content";
|
|
}
|
|
return null;
|
|
}
|
|
|
|
export function deriveBottomAnchorBlockedReason(input: {
|
|
pendingRequest: BottomAnchorRequest | null;
|
|
isAuthoritativeHistoryReady: boolean;
|
|
measurementState: ControllerMeasurementState;
|
|
pendingVerificationRequestId: number | null;
|
|
}): BottomAnchorBlockedReason | null {
|
|
if (!input.pendingRequest) {
|
|
return null;
|
|
}
|
|
if (!input.isAuthoritativeHistoryReady) {
|
|
return "waiting_for_history_readiness";
|
|
}
|
|
if (
|
|
input.measurementState.viewportHeight <= 0 ||
|
|
input.measurementState.viewportMeasuredForKey !== input.measurementState.containerKey
|
|
) {
|
|
return "waiting_for_measurable_viewport";
|
|
}
|
|
if (
|
|
input.measurementState.contentHeight <= 0 ||
|
|
input.measurementState.contentMeasuredForKey !== input.measurementState.containerKey
|
|
) {
|
|
return "waiting_for_measurable_content";
|
|
}
|
|
if (input.pendingVerificationRequestId === input.pendingRequest.id) {
|
|
return "waiting_for_post_layout_verification";
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function deriveRetryDisposition(input: {
|
|
mode: BottomAnchorMode;
|
|
retries: number;
|
|
verificationRetryMode: BottomAnchorTransportBehavior["verificationRetryMode"];
|
|
}): "retry-scroll" | "retry-verify" | "fail" {
|
|
if (input.mode !== "sticky-bottom" || input.retries >= MAX_VERIFICATION_RETRIES) {
|
|
return "fail";
|
|
}
|
|
return input.verificationRetryMode === "recheck" ? "retry-verify" : "retry-scroll";
|
|
}
|
|
|
|
function shouldRequireRouteRequestConfirmation(input: {
|
|
request: BottomAnchorRequest | null;
|
|
measurementState: ControllerMeasurementState;
|
|
confirmationPasses: number;
|
|
}): boolean {
|
|
if (!input.request) {
|
|
return false;
|
|
}
|
|
if (input.request.reason !== "initial-entry" && input.request.reason !== "resume") {
|
|
return false;
|
|
}
|
|
if (input.measurementState.containerKey !== "web-partial-virtualized") {
|
|
return false;
|
|
}
|
|
return input.confirmationPasses < 1;
|
|
}
|
|
|
|
function createBottomAnchorControllerDriver(
|
|
input: CreateBottomAnchorControllerDriverInput,
|
|
): BottomAnchorControllerDriver {
|
|
let requestSequence = 0;
|
|
let mode: BottomAnchorMode = "sticky-bottom";
|
|
let pendingRequest: BottomAnchorRequest | null = null;
|
|
let pendingVerification: AttemptContext | null = null;
|
|
let blockedReason: BottomAnchorBlockedReason | null = null;
|
|
let attemptHandle: unknown = null;
|
|
let verificationHandle: unknown = null;
|
|
let lastRouteRequestKey: string | null = null;
|
|
let stickyMeasurementRevision = 0;
|
|
let lastVerifiedStickyMeasurementRevision = 0;
|
|
let isUserScrollActive = false;
|
|
|
|
const setBlockedReason = (nextBlockedReason: BottomAnchorBlockedReason | null) => {
|
|
if (blockedReason === nextBlockedReason) {
|
|
return;
|
|
}
|
|
blockedReason = nextBlockedReason;
|
|
};
|
|
|
|
const setModeInternal = (nextMode: BottomAnchorMode) => {
|
|
if (mode === nextMode) {
|
|
return;
|
|
}
|
|
mode = nextMode;
|
|
input.onModeChange(nextMode);
|
|
if (nextMode === "detached") {
|
|
lastVerifiedStickyMeasurementRevision = stickyMeasurementRevision;
|
|
}
|
|
};
|
|
|
|
const markStickyMeasurementChanged = () => {
|
|
stickyMeasurementRevision += 1;
|
|
};
|
|
|
|
const markStickyMeasurementVerified = () => {
|
|
lastVerifiedStickyMeasurementRevision = stickyMeasurementRevision;
|
|
};
|
|
|
|
const cancelPendingAttempt = () => {
|
|
if (attemptHandle) {
|
|
input.cancelFrame(attemptHandle);
|
|
attemptHandle = null;
|
|
}
|
|
if (verificationHandle) {
|
|
input.cancelFrame(verificationHandle);
|
|
verificationHandle = null;
|
|
}
|
|
pendingVerification = null;
|
|
};
|
|
|
|
const cancelPendingRequest = (_reason: string) => {
|
|
const currentRequest = pendingRequest;
|
|
if (!currentRequest) {
|
|
cancelPendingAttempt();
|
|
setBlockedReason(null);
|
|
return;
|
|
}
|
|
pendingRequest = null;
|
|
cancelPendingAttempt();
|
|
setBlockedReason(null);
|
|
};
|
|
|
|
const deriveDriverBlockedReason = (measurementState: ControllerMeasurementState) =>
|
|
deriveBottomAnchorBlockedReason({
|
|
pendingRequest,
|
|
isAuthoritativeHistoryReady: input.getIsAuthoritativeHistoryReady(),
|
|
measurementState,
|
|
pendingVerificationRequestId:
|
|
verificationHandle !== null ? (pendingVerification?.requestId ?? null) : null,
|
|
});
|
|
|
|
const scheduleVerification = (attemptContext: AttemptContext, delayFramesOverride?: number) => {
|
|
if (verificationHandle) {
|
|
input.cancelFrame(verificationHandle);
|
|
}
|
|
verificationHandle = input.scheduleFrame({
|
|
kind: "verification",
|
|
delayFrames: delayFramesOverride ?? input.getTransportBehavior().verificationDelayFrames,
|
|
callback: () => {
|
|
verificationHandle = null;
|
|
const currentRequest = pendingRequest;
|
|
const isRequestAttempt = currentRequest && attemptContext.requestId === currentRequest.id;
|
|
const measurementState = input.getMeasurementState();
|
|
const verificationBlockedReason = deriveVerificationBlockedReason({
|
|
isAuthoritativeHistoryReady: input.getIsAuthoritativeHistoryReady(),
|
|
measurementState,
|
|
});
|
|
|
|
if (verificationBlockedReason) {
|
|
pendingVerification = attemptContext;
|
|
setBlockedReason(verificationBlockedReason);
|
|
return;
|
|
}
|
|
|
|
const verifiedNearBottom = input.isNearBottom();
|
|
const retryDisposition = verifiedNearBottom
|
|
? null
|
|
: deriveRetryDisposition({
|
|
mode,
|
|
retries: attemptContext.retries,
|
|
verificationRetryMode: input.getTransportBehavior().verificationRetryMode,
|
|
});
|
|
|
|
if (verifiedNearBottom) {
|
|
if (
|
|
isRequestAttempt &&
|
|
shouldRequireRouteRequestConfirmation({
|
|
request: currentRequest,
|
|
measurementState,
|
|
confirmationPasses: attemptContext.confirmationPasses ?? 0,
|
|
})
|
|
) {
|
|
pendingVerification = {
|
|
...attemptContext,
|
|
confirmationPasses: (attemptContext.confirmationPasses ?? 0) + 1,
|
|
};
|
|
setBlockedReason("waiting_for_post_layout_verification");
|
|
scheduleVerification(
|
|
pendingVerification,
|
|
WEB_PARTIAL_VIRTUALIZED_CONFIRMATION_DELAY_FRAMES,
|
|
);
|
|
return;
|
|
}
|
|
pendingVerification = null;
|
|
markStickyMeasurementVerified();
|
|
if (isRequestAttempt) {
|
|
pendingRequest = null;
|
|
}
|
|
setBlockedReason(null);
|
|
return;
|
|
}
|
|
|
|
if (retryDisposition === "retry-verify") {
|
|
pendingVerification = {
|
|
requestId: attemptContext.requestId,
|
|
retries: attemptContext.retries + 1,
|
|
};
|
|
setBlockedReason("waiting_for_post_layout_verification");
|
|
scheduleVerification(pendingVerification);
|
|
return;
|
|
}
|
|
|
|
if (retryDisposition === "retry-scroll") {
|
|
pendingVerification = {
|
|
requestId: attemptContext.requestId,
|
|
retries: attemptContext.retries + 1,
|
|
};
|
|
evaluate(false, "retry_scroll");
|
|
return;
|
|
}
|
|
|
|
pendingVerification = null;
|
|
setBlockedReason(isRequestAttempt ? "waiting_for_post_layout_verification" : null);
|
|
},
|
|
});
|
|
};
|
|
|
|
const runAttempt = (animated: boolean) => {
|
|
const measurementState = input.getMeasurementState();
|
|
const attemptContext: AttemptContext = {
|
|
requestId: pendingRequest?.id ?? null,
|
|
retries: pendingVerification?.retries ?? 0,
|
|
startedContentHeight: measurementState.contentHeight,
|
|
startedOffsetY: measurementState.offsetY,
|
|
startedViewportHeight: measurementState.viewportHeight,
|
|
};
|
|
pendingVerification = attemptContext;
|
|
input.scrollToBottom(animated);
|
|
scheduleVerification(attemptContext);
|
|
setBlockedReason(deriveDriverBlockedReason(input.getMeasurementState()));
|
|
};
|
|
|
|
const evaluate = (
|
|
animated: boolean,
|
|
_reason:
|
|
| "request_created"
|
|
| "viewport_change"
|
|
| "content_size_change"
|
|
| "scroll_near_bottom_change"
|
|
| "user_scroll_end"
|
|
| "disclosure_released"
|
|
| "history_readiness_change"
|
|
| "manual_reevaluate"
|
|
| "retry_scroll",
|
|
) => {
|
|
if (isUserScrollActive) {
|
|
return;
|
|
}
|
|
if (attemptHandle) {
|
|
return;
|
|
}
|
|
attemptHandle = input.scheduleFrame({
|
|
kind: "attempt",
|
|
callback: () => {
|
|
attemptHandle = null;
|
|
const measurementState = input.getMeasurementState();
|
|
const nextBlockedReason = deriveDriverBlockedReason(measurementState);
|
|
setBlockedReason(nextBlockedReason);
|
|
|
|
const shouldAttemptForPendingRequest =
|
|
pendingRequest !== null && nextBlockedReason === null;
|
|
const shouldAttemptForStickyVerification =
|
|
mode === "sticky-bottom" && pendingVerification !== null && nextBlockedReason === null;
|
|
|
|
if (!shouldAttemptForPendingRequest && !shouldAttemptForStickyVerification) {
|
|
return;
|
|
}
|
|
|
|
runAttempt(animated);
|
|
},
|
|
});
|
|
};
|
|
|
|
const createRequest = (request: BottomAnchorRouteRequest | BottomAnchorLocalRequest) => {
|
|
cancelPendingAttempt();
|
|
const nextRequest: BottomAnchorRequest = {
|
|
id: requestSequence + 1,
|
|
agentId: request.agentId,
|
|
reason: request.reason,
|
|
requestKey:
|
|
"requestKey" in request
|
|
? request.requestKey
|
|
: `${request.agentId}:${request.reason}:${requestSequence + 1}`,
|
|
};
|
|
requestSequence = nextRequest.id;
|
|
pendingRequest = nextRequest;
|
|
pendingVerification = null;
|
|
setModeInternal(
|
|
"requestKey" in request
|
|
? "sticky-bottom"
|
|
: __private__.deriveModeForLocalRequest({ reason: request.reason }),
|
|
);
|
|
evaluate(request.reason === "jump-to-bottom", "request_created");
|
|
};
|
|
|
|
return {
|
|
destroy() {
|
|
cancelPendingAttempt();
|
|
},
|
|
getSnapshot() {
|
|
return {
|
|
mode,
|
|
pendingRequest,
|
|
pendingVerification,
|
|
blockedReason,
|
|
};
|
|
},
|
|
applyRouteRequest(request) {
|
|
if (!request) {
|
|
return;
|
|
}
|
|
if (lastRouteRequestKey === request.requestKey) {
|
|
return;
|
|
}
|
|
lastRouteRequestKey = request.requestKey;
|
|
createRequest(request);
|
|
},
|
|
requestLocalAnchor(request) {
|
|
createRequest(request);
|
|
},
|
|
// The gesture owns the viewport, so following stops until it ends: content growing at the end
|
|
// must not move the rows under the finger. The gesture's end decides whether following resumes.
|
|
beginUserScroll() {
|
|
isUserScrollActive = true;
|
|
cancelPendingAttempt();
|
|
setModeInternal("detached");
|
|
},
|
|
// Following resumes only for a gesture that ends at the end. Resuming from short of it would
|
|
// scroll the rows the finger just let go of, so a release above the end, or with output that
|
|
// arrived below it during the gesture, stays detached with the pill.
|
|
endUserScroll(params) {
|
|
isUserScrollActive = false;
|
|
if (params.isAtEnd) {
|
|
if (mode === "detached") {
|
|
setModeInternal("sticky-bottom");
|
|
pendingVerification = { requestId: null, retries: 0 };
|
|
evaluate(false, "user_scroll_end");
|
|
return;
|
|
}
|
|
if (pendingRequest) {
|
|
evaluate(false, "user_scroll_end");
|
|
return;
|
|
}
|
|
if (
|
|
!input.isNearBottom() ||
|
|
stickyMeasurementRevision !== lastVerifiedStickyMeasurementRevision
|
|
) {
|
|
pendingVerification = { requestId: null, retries: 0 };
|
|
evaluate(false, "user_scroll_end");
|
|
return;
|
|
}
|
|
markStickyMeasurementVerified();
|
|
return;
|
|
}
|
|
cancelPendingRequest("user_scrolled_away");
|
|
setModeInternal("detached");
|
|
},
|
|
detachByUser() {
|
|
if (mode === "detached") {
|
|
return;
|
|
}
|
|
cancelPendingRequest("user_scrolled_away");
|
|
setModeInternal("detached");
|
|
},
|
|
// A disclosure keeps its trigger in place, so following output stops while it applies.
|
|
holdForDisclosure() {
|
|
cancelPendingRequest("disclosure");
|
|
setModeInternal("detached");
|
|
},
|
|
// Following resumes only if the applied change left the list at its end. Scroll events
|
|
// during the hold never re-stick: before the change lands the list is still at the end.
|
|
releaseDisclosure(params) {
|
|
if (isUserScrollActive || mode !== "detached" || !params.isAtEnd) {
|
|
return;
|
|
}
|
|
setModeInternal("sticky-bottom");
|
|
pendingVerification = { requestId: null, retries: 0 };
|
|
evaluate(false, "disclosure_released");
|
|
},
|
|
handleViewportMetricsChange(params) {
|
|
if (
|
|
params.previousViewportWidth !== params.viewportWidth ||
|
|
params.previousViewportHeight !== params.viewportHeight
|
|
) {
|
|
markStickyMeasurementChanged();
|
|
}
|
|
if (isUserScrollActive) {
|
|
return;
|
|
}
|
|
const shouldRestick = __private__.shouldRestickOnViewportChange({
|
|
mode,
|
|
previousViewportWidth: params.previousViewportWidth,
|
|
viewportWidth: params.viewportWidth,
|
|
previousViewportHeight: params.previousViewportHeight,
|
|
viewportHeight: params.viewportHeight,
|
|
});
|
|
if (shouldRestick && !pendingRequest) {
|
|
pendingVerification = { requestId: null, retries: 0 };
|
|
}
|
|
if (shouldRestick || pendingRequest) {
|
|
evaluate(false, "viewport_change");
|
|
}
|
|
},
|
|
// The list keeps its own end through content changes while following, so a change in content
|
|
// height never scrolls from here. It only gives a pending jump another look at the new geometry.
|
|
handleContentSizeChange() {
|
|
if (isUserScrollActive || !pendingRequest) {
|
|
return;
|
|
}
|
|
evaluate(false, "content_size_change");
|
|
},
|
|
prepareForStickyViewportChange() {
|
|
if (mode !== "sticky-bottom") {
|
|
return;
|
|
}
|
|
markStickyMeasurementChanged();
|
|
},
|
|
handleScrollNearBottomChange(params) {
|
|
const { nextIsNearBottom, scrollDelta } = params;
|
|
if (isUserScrollActive) {
|
|
return;
|
|
}
|
|
if (
|
|
nextIsNearBottom &&
|
|
mode === "sticky-bottom" &&
|
|
stickyMeasurementRevision !== lastVerifiedStickyMeasurementRevision
|
|
) {
|
|
markStickyMeasurementVerified();
|
|
}
|
|
const hasUnverifiedStickyMeasurementChange =
|
|
stickyMeasurementRevision !== lastVerifiedStickyMeasurementRevision;
|
|
if (
|
|
__private__.shouldDetachFromScrollAway({
|
|
mode,
|
|
nextIsNearBottom,
|
|
scrollDelta,
|
|
hasPendingRequest: pendingRequest !== null,
|
|
hasPendingVerification: pendingVerification !== null,
|
|
hasUnverifiedStickyMeasurementChange,
|
|
})
|
|
) {
|
|
this.detachByUser();
|
|
return;
|
|
}
|
|
if (mode === "sticky-bottom" && !nextIsNearBottom && hasUnverifiedStickyMeasurementChange) {
|
|
if (!pendingRequest && !pendingVerification) {
|
|
pendingVerification = { requestId: null, retries: 0 };
|
|
}
|
|
evaluate(false, "scroll_near_bottom_change");
|
|
return;
|
|
}
|
|
if (nextIsNearBottom && pendingRequest) {
|
|
evaluate(false, "scroll_near_bottom_change");
|
|
}
|
|
},
|
|
notifyAuthoritativeHistoryMaybeChanged() {
|
|
if (!pendingVerification && !pendingRequest) {
|
|
return;
|
|
}
|
|
evaluate(false, "history_readiness_change");
|
|
},
|
|
reevaluate(animated = false) {
|
|
evaluate(animated, "manual_reevaluate");
|
|
},
|
|
};
|
|
}
|
|
|
|
export const __private__ = {
|
|
createBottomAnchorControllerDriver,
|
|
deriveBottomAnchorBlockedReason,
|
|
deriveVerificationBlockedReason,
|
|
deriveRetryDisposition,
|
|
deriveModeForLocalRequest(_input: {
|
|
reason: BottomAnchorLocalRequest["reason"];
|
|
}): BottomAnchorMode {
|
|
return "sticky-bottom";
|
|
},
|
|
shouldRestickOnViewportChange(input: {
|
|
mode: BottomAnchorMode;
|
|
previousViewportWidth: number;
|
|
viewportWidth: number;
|
|
previousViewportHeight: number;
|
|
viewportHeight: number;
|
|
}): boolean {
|
|
return (
|
|
input.mode === "sticky-bottom" &&
|
|
((input.previousViewportHeight > 0 &&
|
|
input.viewportHeight > 0 &&
|
|
input.previousViewportHeight !== input.viewportHeight) ||
|
|
(input.previousViewportWidth > 0 &&
|
|
input.viewportWidth > 0 &&
|
|
input.previousViewportWidth !== input.viewportWidth))
|
|
);
|
|
},
|
|
shouldDetachFromScrollAway(input: {
|
|
mode: BottomAnchorMode;
|
|
nextIsNearBottom: boolean;
|
|
scrollDelta: number;
|
|
hasPendingRequest: boolean;
|
|
hasPendingVerification: boolean;
|
|
hasUnverifiedStickyMeasurementChange: boolean;
|
|
}): boolean {
|
|
const scrolledAwayIntentionally = Math.abs(input.scrollDelta) >= USER_SCROLL_AWAY_DELTA_PX;
|
|
return (
|
|
input.mode === "sticky-bottom" &&
|
|
!input.nextIsNearBottom &&
|
|
!input.hasPendingRequest &&
|
|
!input.hasPendingVerification &&
|
|
(!input.hasUnverifiedStickyMeasurementChange || scrolledAwayIntentionally)
|
|
);
|
|
},
|
|
};
|
|
|
|
export function useBottomAnchorController(input: {
|
|
agentId: string;
|
|
routeRequest: BottomAnchorRouteRequest | null;
|
|
isAuthoritativeHistoryReady: boolean;
|
|
renderStrategy: string;
|
|
getTransportBehavior: () => BottomAnchorTransportBehavior;
|
|
getMeasurementState: () => ControllerMeasurementState;
|
|
isNearBottom: () => boolean;
|
|
scrollToBottom: (animated: boolean) => void;
|
|
}) {
|
|
const [mode, setMode] = useState<BottomAnchorMode>("sticky-bottom");
|
|
const agentIdRef = useRef(input.agentId);
|
|
const readinessRef = useRef(input.isAuthoritativeHistoryReady);
|
|
const renderStrategyRef = useRef(input.renderStrategy);
|
|
const getTransportBehaviorRef = useRef(input.getTransportBehavior);
|
|
const getMeasurementStateRef = useRef(input.getMeasurementState);
|
|
const isNearBottomRef = useRef(input.isNearBottom);
|
|
const scrollToBottomRef = useRef(input.scrollToBottom);
|
|
const driverRef = useRef<BottomAnchorControllerDriver | null>(null);
|
|
|
|
agentIdRef.current = input.agentId;
|
|
readinessRef.current = input.isAuthoritativeHistoryReady;
|
|
renderStrategyRef.current = input.renderStrategy;
|
|
getTransportBehaviorRef.current = input.getTransportBehavior;
|
|
getMeasurementStateRef.current = input.getMeasurementState;
|
|
isNearBottomRef.current = input.isNearBottom;
|
|
scrollToBottomRef.current = input.scrollToBottom;
|
|
|
|
if (!driverRef.current) {
|
|
driverRef.current = __private__.createBottomAnchorControllerDriver({
|
|
getAgentId: () => agentIdRef.current,
|
|
getIsAuthoritativeHistoryReady: () => readinessRef.current,
|
|
getRenderStrategy: () => renderStrategyRef.current,
|
|
getTransportBehavior: () => getTransportBehaviorRef.current(),
|
|
getMeasurementState: () => getMeasurementStateRef.current(),
|
|
isNearBottom: () => isNearBottomRef.current(),
|
|
scrollToBottom: (animated) => scrollToBottomRef.current(animated),
|
|
onModeChange: (nextMode) => setMode(nextMode),
|
|
scheduleFrame: ({ callback, delayFrames }) =>
|
|
scheduleAnimationFrameWithDelay({ callback, delayFrames }),
|
|
cancelFrame: (handle) => cancelScheduledAnimationFrame(handle),
|
|
});
|
|
}
|
|
|
|
useEffect(() => {
|
|
driverRef.current?.applyRouteRequest(input.routeRequest);
|
|
}, [input.routeRequest]);
|
|
|
|
useEffect(() => {
|
|
driverRef.current?.notifyAuthoritativeHistoryMaybeChanged();
|
|
}, [input.isAuthoritativeHistoryReady]);
|
|
|
|
useEffect(() => {
|
|
return () => {
|
|
driverRef.current?.destroy();
|
|
driverRef.current = null;
|
|
};
|
|
}, []);
|
|
|
|
return {
|
|
mode,
|
|
requestLocalAnchor(request: BottomAnchorLocalRequest) {
|
|
driverRef.current?.requestLocalAnchor(request);
|
|
},
|
|
beginUserScroll() {
|
|
driverRef.current?.beginUserScroll();
|
|
},
|
|
endUserScroll(params: { isAtEnd: boolean }) {
|
|
driverRef.current?.endUserScroll(params);
|
|
},
|
|
detachByUser() {
|
|
driverRef.current?.detachByUser();
|
|
},
|
|
holdForDisclosure() {
|
|
driverRef.current?.holdForDisclosure();
|
|
},
|
|
releaseDisclosure(params: { isAtEnd: boolean }) {
|
|
driverRef.current?.releaseDisclosure(params);
|
|
},
|
|
handleViewportLayout() {},
|
|
handleViewportMetricsChange(params: {
|
|
previousViewportWidth: number;
|
|
viewportWidth: number;
|
|
previousViewportHeight: number;
|
|
viewportHeight: number;
|
|
}) {
|
|
driverRef.current?.handleViewportMetricsChange(params);
|
|
},
|
|
handleContentSizeChange() {
|
|
driverRef.current?.handleContentSizeChange();
|
|
},
|
|
prepareForStickyViewportChange() {
|
|
driverRef.current?.prepareForStickyViewportChange();
|
|
},
|
|
handleScrollNearBottomChange(params: { nextIsNearBottom: boolean; scrollDelta: number }) {
|
|
driverRef.current?.handleScrollNearBottomChange(params);
|
|
},
|
|
reevaluate(animated = false) {
|
|
driverRef.current?.reevaluate(animated);
|
|
},
|
|
};
|
|
}
|