1import { RunId, RunAttemptId, type MessageId } from "@t3tools/contracts";
2import * as Schema from "effect/Schema";
3
4export interface TimelineRunObservation {
5 readonly threadKey: string | null;
6 readonly hydrated: boolean;
7 readonly runId: RunId | null;
8}
9
10/** Opening a thread establishes a baseline; only later runs get new-turn framing. */
11export function observeTimelineRun(
12 previous: TimelineRunObservation | null,
13 input: TimelineRunObservation & {
14 readonly queued: boolean;
15 readonly messageId: MessageId | null;
16 },
17): { observation: TimelineRunObservation; anchorMessageId: MessageId | null } {
18 const observation = {
19 threadKey: input.threadKey,
20 hydrated: input.hydrated,
21 runId: input.queued ? null : input.runId,
22 };
23 if (previous?.threadKey !== input.threadKey || !previous.hydrated) {
24 return { observation, anchorMessageId: null };
25 }
26 if (
27 !input.hydrated ||
28 input.runId === null ||
29 input.queued ||
30 previous.runId === input.runId ||
31 input.messageId === null
32 ) {
33 return { observation: previous, anchorMessageId: null };
34 }
35 return { observation, anchorMessageId: input.messageId };
36}
37// Match the titlebar fade inset so draft promotion preserves the first row's position.
38export const CHAT_TIMELINE_ANCHOR_OFFSET = 24;
39
40export type TimelineScrollMode = "following-end" | "anchoring-new-turn" | "free-scrolling";
41
42export interface TimelineListMeasurementState {
43 readonly data: readonly unknown[];
44 readonly scroll: number;
45 readonly scrollLength: number;
46 readonly positionAtIndex: (index: number) => number | undefined;
47 readonly sizeAtIndex: (index: number) => number | undefined;
48}
49
50export interface AnchoredTurnMetrics {
51 readonly anchorTop: number;
52 readonly lastBottom: number;
53 readonly turnHeight: number;
54 readonly usableViewportHeight: number;
55 readonly visibleUsableBottom: number;
56 readonly overflowsUsableViewport: boolean;
57 readonly targetScrollToRevealEnd: number;
58 readonly scrollDeltaToRevealEnd: number;
59}
60
61export function getRowBottom(state: TimelineListMeasurementState, index: number): number | null {
62 const top = state.positionAtIndex(index);
63 const height = state.sizeAtIndex(index);
64 if (
65 typeof top !== "number" ||
66 typeof height !== "number" ||
67 !Number.isFinite(top) ||
68 !Number.isFinite(height)
69 ) {
70 return null;
71 }
72
73 return top + Math.max(1, height);
74}
75
76/**
77 * Whether the timeline's real rows extend past the viewport left above the
78 * composer. The list's own content length includes the composer inset
79 * spacer, so this measures from the last row instead. Unknown row geometry
80 * or an unmeasured viewport counts as fitting.
81 */
82export function timelineContentOverflowsViewport(
83 state: TimelineListMeasurementState | undefined,
84 input: { readonly composerInset: number; readonly anchorOffset: number },
85): boolean {
86 if (!state || !state.data || state.data.length === 0) {
87 return false;
88 }
89 const scrollLength = state.scrollLength;
90 if (typeof scrollLength !== "number" || !Number.isFinite(scrollLength) || scrollLength <= 0) {
91 return false;
92 }
93 const lastBottom = getRowBottom(state, state.data.length - 1);
94 if (lastBottom === null) {
95 return false;
96 }
97 const visibleScrollLength = Math.max(0, scrollLength - input.composerInset - input.anchorOffset);
98 return lastBottom > visibleScrollLength;
99}
100
101export function getAnchoredTurnMetrics({
102 state,
103 anchorIndex,
104 composerOverlayHeight,
105 anchorOffset,
106}: {
107 readonly state: TimelineListMeasurementState;
108 readonly anchorIndex: number;
109 readonly composerOverlayHeight: number;
110 readonly anchorOffset: number;
111}): AnchoredTurnMetrics | null {
112 if (state.data.length === 0) {
113 return null;
114 }
115
116 const boundedAnchorIndex = Math.max(0, Math.min(anchorIndex, state.data.length - 1));
117 const anchorTop = state.positionAtIndex(boundedAnchorIndex);
118 const lastBottom = getRowBottom(state, state.data.length - 1);
119 if (typeof anchorTop !== "number" || !Number.isFinite(anchorTop) || lastBottom === null) {
120 return null;
121 }
122
123 const usableViewportHeight = Math.max(
124 0,
125 state.scrollLength - composerOverlayHeight - anchorOffset,
126 );
127 const turnHeight = Math.max(0, lastBottom - anchorTop);
128 const visibleUsableBottom = state.scroll + usableViewportHeight;
129 const targetScrollToRevealEnd = Math.max(0, lastBottom - usableViewportHeight);
130 const scrollDeltaToRevealEnd = Math.max(0, targetScrollToRevealEnd - state.scroll);
131
132 return {
133 anchorTop,
134 lastBottom,
135 turnHeight,
136 usableViewportHeight,
137 visibleUsableBottom,
138 overflowsUsableViewport: turnHeight > usableViewportHeight,
139 targetScrollToRevealEnd,
140 scrollDeltaToRevealEnd,
141 };
142}
143
144export interface RememberedTimelinePosition {
145 readonly rowId: string;
146 readonly offsetWithinRow: number;
147 readonly scrollOffset: number;
148 readonly atEnd: boolean;
149 readonly disclosures?: {
150 readonly runs: ReadonlySet<RunId>;
151 readonly workGroups: ReadonlySet<string>;
152 readonly attempts: ReadonlySet<RunAttemptId>;
153 readonly workGroupState: {
154 scrollPositions: Map<string, { readonly entryId: string; readonly offset: number }>;
155 expandedEntries: Set<string>;
156 };
157 };
158}
159
160// Scoped thread keys keep separate environments independent. Bound the session cache.
161const rememberedTimelinePositions = new Map<string, RememberedTimelinePosition>();
162
163const REFRESH_POSITIONS_KEY = "t3code:ui-refresh-timeline:v1";
164const RefreshPositions = Schema.fromJsonString(
165 Schema.Record(
166 Schema.String,
167 Schema.Struct({
168 rowId: Schema.String,
169 offsetWithinRow: Schema.Finite,
170 scrollOffset: Schema.Finite,
171 atEnd: Schema.Boolean,
172 disclosures: Schema.optionalKey(
173 Schema.Struct({
174 runs: Schema.Array(RunId),
175 workGroups: Schema.Array(Schema.String),
176 attempts: Schema.Array(RunAttemptId),
177 workGroupState: Schema.Struct({
178 scrollPositions: Schema.Array(
179 Schema.Tuple([
180 Schema.String,
181 Schema.Struct({ entryId: Schema.String, offset: Schema.Finite }),
182 ]),
183 ),
184 expandedEntries: Schema.Array(Schema.String),
185 }),
186 }),
187 ),
188 }),
189 ),
190);
191
192/** Save only at refresh time; scrolling keeps its existing in-memory cache. */
193export function persistTimelinePositionsForReload(): void {
194 const positions = Object.fromEntries(
195 [...rememberedTimelinePositions].map(([key, position]) => [
196 key,
197 {
198 ...position,
199 ...(position.disclosures
200 ? {
201 disclosures: {
202 runs: [...position.disclosures.runs],
203 workGroups: [...position.disclosures.workGroups],
204 attempts: [...position.disclosures.attempts],
205 workGroupState: {
206 scrollPositions: [...position.disclosures.workGroupState.scrollPositions],
207 expandedEntries: [...position.disclosures.workGroupState.expandedEntries],
208 },
209 },
210 }
211 : {}),
212 },
213 ]),
214 );
215 window.sessionStorage.setItem(REFRESH_POSITIONS_KEY, JSON.stringify(positions));
216}
217
218if (typeof window !== "undefined") {
219 try {
220 const saved = window.sessionStorage.getItem(REFRESH_POSITIONS_KEY);
221 window.sessionStorage.removeItem(REFRESH_POSITIONS_KEY);
222 if (saved) {
223 const positions = Schema.decodeSync(RefreshPositions)(saved);
224 for (const [key, { disclosures, ...position }] of Object.entries(positions)) {
225 rememberedTimelinePositions.set(key, {
226 ...position,
227 ...(disclosures
228 ? {
229 disclosures: {
230 runs: new Set(disclosures.runs),
231 workGroups: new Set(disclosures.workGroups),
232 attempts: new Set(disclosures.attempts),
233 workGroupState: {
234 scrollPositions: new Map(disclosures.workGroupState.scrollPositions),
235 expandedEntries: new Set(disclosures.workGroupState.expandedEntries),
236 },
237 },
238 }
239 : {}),
240 });
241 }
242 }
243 } catch {
244 // Unreadable refresh state falls back to the normal initial position.
245 }
246}
247
248export function readTimelinePosition(threadKey: string) {
249 return rememberedTimelinePositions.get(threadKey);
250}
251
252export function rememberTimelinePosition(
253 threadKey: string,
254 position: RememberedTimelinePosition,
255 liveFollowEnabled: boolean,
256) {
257 rememberedTimelinePositions.delete(threadKey);
258 rememberedTimelinePositions.set(threadKey, {
259 ...position,
260 atEnd: liveFollowEnabled || position.atEnd,
261 });
262 if (rememberedTimelinePositions.size > 100) {
263 const oldest = rememberedTimelinePositions.keys().next().value;
264 if (oldest !== undefined) rememberedTimelinePositions.delete(oldest);
265 }
266}