A session opened from the sidebar could land above the bottom, or show a frame sitting lower and then snap up. The viewport was pinned before the content was final: the recap note renders once the session record arrives and grew the footer under the pinned viewport, and on large sessions subagent task cards grow when their child sessions load, moving everything above the viewport. - The recap note holds the timeline reveal until the session record is in memory, so it is part of the first finished picture. - The scroll hook holds the reveal until the viewport is pinned; the reveal itself runs once the content height has held still for two frames, with one exact pin against the final height (bounded at 300ms). - Sitting on the end of a session that is not producing output is an invariant: content growth re-pins from a MutationObserver in the same frame the list writes its layout, so no frame paints with the end out of view. Output growth keeps gliding through followEnd, which now glides only while the session is working.
55 lines
1.7 KiB
TypeScript
55 lines
1.7 KiB
TypeScript
import React from 'react';
|
|
|
|
/**
|
|
* Coordinates the first paint of a freshly opened session so the timeline
|
|
* appears as one finished picture instead of arriving in pieces.
|
|
*
|
|
* Renderers that mount with a provisional paint (markdown whose blocks are not
|
|
* in the settled cache yet, so code is unhighlighted) take a hold while they
|
|
* catch up. The timeline stays invisible while any hold is open, then reveals
|
|
* everything at once. The gate accepts holds only during the opening commit:
|
|
* rows that mount later, while scrolling, must never hide the timeline.
|
|
*
|
|
* A hold that never releases must not hide the chat forever, so the owner
|
|
* reveals after `TIMELINE_REVEAL_CAP_MS` regardless.
|
|
*/
|
|
export type TimelineRevealGate = {
|
|
/** Take a hold; returns the release. Returns null once the gate is closed. */
|
|
hold: () => (() => void) | null;
|
|
/** Stops accepting holds. Existing holds still count. */
|
|
close: () => void;
|
|
readonly holds: number;
|
|
/** Called when the last hold releases, if the gate is closed by then. */
|
|
onEmpty: (() => void) | null;
|
|
};
|
|
|
|
export const TIMELINE_REVEAL_CAP_MS = 250;
|
|
|
|
export const createTimelineRevealGate = (): TimelineRevealGate => {
|
|
let holds = 0;
|
|
let accepting = true;
|
|
const gate: TimelineRevealGate = {
|
|
hold: () => {
|
|
if (!accepting) return null;
|
|
holds += 1;
|
|
let released = false;
|
|
return () => {
|
|
if (released) return;
|
|
released = true;
|
|
holds -= 1;
|
|
if (holds === 0 && !accepting) gate.onEmpty?.();
|
|
};
|
|
},
|
|
close: () => {
|
|
accepting = false;
|
|
},
|
|
get holds() {
|
|
return holds;
|
|
},
|
|
onEmpty: null,
|
|
};
|
|
return gate;
|
|
};
|
|
|
|
export const TimelineRevealGateContext = React.createContext<TimelineRevealGate | null>(null);
|