authorgravatar for me@t3.ggTheo Browne <me@t3.gg> 2026-09-13 19:02:01-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-13 19:02:01-07:00
logc07575f573dd3a1af4f734297d17f7c951c95f10
tree1041d550439dd87e9a1b38476a3e09e359f20d6b
parent66e39ca2aabde054bc50312a9c34f05dbd1f6f9e
signature Signed by PGP key B5690EEEBB952194

feat(server): show finished paragraphs and code blocks while the response streams (#11062)

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>

8 files changed, 626 insertions(+), 26 deletions(-)

apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts+230-1
...@@ -55,7 +55,10 @@ import { OrchestrationProjectionPipelineLive } from "./ProjectionPipeline.ts";...@@ -55,7 +55,10 @@ import { OrchestrationProjectionPipelineLive } from "./ProjectionPipeline.ts";
55import { OrchestrationProjectionSnapshotQueryLive } from "./ProjectionSnapshotQuery.ts";55import { OrchestrationProjectionSnapshotQueryLive } from "./ProjectionSnapshotQuery.ts";
56import * as ThreadBackgroundLiveness from "../ThreadBackgroundLiveness.ts";56import * as ThreadBackgroundLiveness from "../ThreadBackgroundLiveness.ts";
57import * as ThreadPlanProgress from "../ThreadPlanProgress.ts";57import * as ThreadPlanProgress from "../ThreadPlanProgress.ts";
58import { ProviderRuntimeIngestionLive } from "./ProviderRuntimeIngestion.ts";58import {
59 ProviderRuntimeIngestionLive,
60 splitBufferedAssistantText,
61} from "./ProviderRuntimeIngestion.ts";
59import { DEFAULT_THREAD_TITLE } from "../threadTitles.ts";62import { DEFAULT_THREAD_TITLE } from "../threadTitles.ts";
60import { OrchestrationEngineService } from "../Services/OrchestrationEngine.ts";63import { OrchestrationEngineService } from "../Services/OrchestrationEngine.ts";
61import { ProviderRuntimeIngestionService } from "../Services/ProviderRuntimeIngestion.ts";64import { ProviderRuntimeIngestionService } from "../Services/ProviderRuntimeIngestion.ts";
...@@ -297,7 +300,24 @@ describe("ProviderRuntimeIngestion", () => {...@@ -297,7 +300,24 @@ describe("ProviderRuntimeIngestion", () => {
297 });300 });
298 }),301 }),
299 ).pipe(Layer.provide(projectionSnapshotLayer));302 ).pipe(Layer.provide(projectionSnapshotLayer));
303 // Real clock plus an offset the test can advance, so delivery pacing in
304 // ingestion can be driven without sleeping. Sleeps stay real.
305 let clockOffsetMs = 0;
306 const realClock = Effect.runSync(Effect.service(Clock.Clock));
307 const shiftedClock: Clock.Clock = {
308 currentTimeMillisUnsafe: () => realClock.currentTimeMillisUnsafe() + clockOffsetMs,
309 currentTimeMillis: Effect.sync(() => realClock.currentTimeMillisUnsafe() + clockOffsetMs),
310 currentTimeNanosUnsafe: () =>
311 realClock.currentTimeNanosUnsafe() + BigInt(clockOffsetMs) * 1_000_000n,
312 currentTimeNanos: Effect.sync(
313 () => realClock.currentTimeNanosUnsafe() + BigInt(clockOffsetMs) * 1_000_000n,
314 ),
315 monotonicTimeNanosUnsafe: () => realClock.monotonicTimeNanosUnsafe(),
316 monotonicTimeNanos: realClock.monotonicTimeNanos,
317 sleep: (duration) => realClock.sleep(duration),
318 };
300 const layer = ProviderRuntimeIngestionLive.pipe(319 const layer = ProviderRuntimeIngestionLive.pipe(
320 Layer.provide(Layer.succeed(Clock.Clock, shiftedClock)),
301 Layer.provideMerge(orchestrationLayer),321 Layer.provideMerge(orchestrationLayer),
302 Layer.provideMerge(ingestionProjectionSnapshotLayer),322 Layer.provideMerge(ingestionProjectionSnapshotLayer),
303 // Single shared liveness instance across ingestion (writer), the323 // Single shared liveness instance across ingestion (writer), the
...@@ -392,6 +412,9 @@ describe("ProviderRuntimeIngestion", () => {...@@ -392,6 +412,9 @@ describe("ProviderRuntimeIngestion", () => {
392 .pipe(Effect.map(Option.getOrThrow)),412 .pipe(Effect.map(Option.getOrThrow)),
393 ),413 ),
394 emit: provider.emit,414 emit: provider.emit,
415 advanceClock: (ms: number) => {
416 clockOffsetMs += ms;
417 },
395 emitAndDrain,418 emitAndDrain,
396 sqlCount: sqlCounter.count,419 sqlCount: sqlCounter.count,
397 setProviderSession: provider.setSession,420 setProviderSession: provider.setSession,
...@@ -3129,6 +3152,145 @@ describe("ProviderRuntimeIngestion", () => {...@@ -3129,6 +3152,145 @@ describe("ProviderRuntimeIngestion", () => {
3129 expect(finalMessage?.streaming).toBe(false);3152 expect(finalMessage?.streaming).toBe(false);
3130 });3153 });
31313154
3155 it("delivers finished paragraphs while the rest of the message stays buffered", async () => {
3156 const harness = await createHarness();
3157 const now = "2026-01-01T00:00:00.000Z";
3158 const codex = ProviderDriverKind.make("codex");
3159 const threadId = asThreadId("thread-1");
3160 const turnId = asTurnId("turn-paragraph-flush");
3161 const itemId = asItemId("item-paragraph-flush");
3162
3163 harness.emit({
3164 type: "turn.started",
3165 eventId: asEventId("evt-paragraph-started"),
3166 provider: codex,
3167 createdAt: now,
3168 threadId,
3169 turnId,
3170 });
3171 await waitForThread(
3172 harness.readModel,
3173 (thread) => thread.session?.status === "running" && thread.session?.activeTurnId === turnId,
3174 );
3175
3176 // Each delta lands well outside the pacing window of the one before.
3177 const emitDelta = (eventId: string, delta: string) => {
3178 harness.advanceClock(1_000);
3179 harness.emit({
3180 type: "content.delta",
3181 eventId: asEventId(eventId),
3182 provider: codex,
3183 createdAt: now,
3184 threadId,
3185 turnId,
3186 itemId,
3187 payload: { streamKind: "assistant_text", delta },
3188 });
3189 };
3190
3191 emitDelta("evt-paragraph-1", "First paragraph.\n\nSecond para");
3192 const afterFirst = await waitForThread(harness.readModel, (thread) =>
3193 thread.messages.some(
3194 (message: ProviderRuntimeTestMessage) => message.id === `assistant:${itemId}`,
3195 ),
3196 );
3197 expect(
3198 afterFirst.messages.find((m: ProviderRuntimeTestMessage) => m.id === `assistant:${itemId}`),
3199 ).toMatchObject({
3200 text: "First paragraph.\n\n",
3201 streaming: true,
3202 });
3203
3204 // An open code block holds the whole block until its closing fence lands.
3205 emitDelta("evt-paragraph-2", "graph.\n\n```ts\nconst a = 1;\n\nconst b = 2;\n");
3206 await harness.drain();
3207 expect(
3208 (await harness.readModel()).threads
3209 .find((t) => t.id === threadId)
3210 ?.messages.find((m: ProviderRuntimeTestMessage) => m.id === `assistant:${itemId}`)?.text,
3211 ).toBe("First paragraph.\n\nSecond paragraph.\n\n");
3212
3213 emitDelta("evt-paragraph-3", "```\n\nTail without newline");
3214 harness.emit({
3215 type: "item.completed",
3216 eventId: asEventId("evt-paragraph-completed"),
3217 provider: codex,
3218 createdAt: now,
3219 threadId,
3220 turnId,
3221 itemId,
3222 payload: { itemType: "assistant_message", status: "completed" },
3223 });
3224 const finalThread = await waitForThread(harness.readModel, (thread) =>
3225 thread.messages.some(
3226 (message: ProviderRuntimeTestMessage) =>
3227 message.id === `assistant:${itemId}` && !message.streaming,
3228 ),
3229 );
3230 expect(
3231 finalThread.messages.find((m: ProviderRuntimeTestMessage) => m.id === `assistant:${itemId}`)
3232 ?.text,
3233 ).toBe(
3234 "First paragraph.\n\nSecond paragraph.\n\n```ts\nconst a = 1;\n\nconst b = 2;\n```\n\nTail without newline",
3235 );
3236 });
3237
3238 it("holds paragraphs that finish inside the pacing window and lands them together", async () => {
3239 const harness = await createHarness();
3240 const codex = ProviderDriverKind.make("codex");
3241 const threadId = asThreadId("thread-1");
3242 const turnId = asTurnId("turn-paced");
3243 const itemId = asItemId("item-paced");
3244 // Every delta carries the same event time, like OpenCode does for one
3245 // part. Pacing must follow the server clock, not the event stamp.
3246 const now = "2026-01-01T00:00:00.000Z";
3247
3248 harness.emit({
3249 type: "turn.started",
3250 eventId: asEventId("evt-paced-started"),
3251 provider: codex,
3252 createdAt: now,
3253 threadId,
3254 turnId,
3255 });
3256 await waitForThread(
3257 harness.readModel,
3258 (thread) => thread.session?.status === "running" && thread.session?.activeTurnId === turnId,
3259 );
3260 // Emit is fire-and-forget, so drain after each delta before moving the
3261 // clock. Otherwise the worker reads a clock that has already advanced.
3262 let clockMs = 0;
3263 const emitDelta = async (eventId: string, delta: string, offsetMs: number) => {
3264 harness.advanceClock(offsetMs - clockMs);
3265 clockMs = offsetMs;
3266 await harness.emitAndDrain([
3267 {
3268 type: "content.delta",
3269 eventId: asEventId(eventId),
3270 provider: codex,
3271 createdAt: now,
3272 threadId,
3273 turnId,
3274 itemId,
3275 payload: { streamKind: "assistant_text", delta },
3276 },
3277 ]);
3278 };
3279 const messageText = async () =>
3280 (await harness.readModel()).threads
3281 .find((t) => t.id === threadId)
3282 ?.messages.find((m: ProviderRuntimeTestMessage) => m.id === `assistant:${itemId}`)?.text;
3283
3284 await emitDelta("evt-paced-1", "One.\n\n", 0);
3285 await emitDelta("evt-paced-2", "Two.\n\n", 100);
3286 await emitDelta("evt-paced-3", "Three.\n\n", 200);
3287 // The first paragraph lands right away. The next two are inside the window.
3288 expect(await messageText()).toBe("One.\n\n");
3289
3290 await emitDelta("evt-paced-4", "Four.\n\n", 500);
3291 expect(await messageText()).toBe("One.\n\nTwo.\n\nThree.\n\nFour.\n\n");
3292 });
3293
3132 it("spills oversized buffered deltas and still finalizes full assistant text", async () => {3294 it("spills oversized buffered deltas and still finalizes full assistant text", async () => {
3133 const harness = await createHarness();3295 const harness = await createHarness();
3134 const now = "2026-01-01T00:00:00.000Z";3296 const now = "2026-01-01T00:00:00.000Z";
...@@ -4367,3 +4529,70 @@ describe("ProviderRuntimeIngestion", () => {...@@ -4367,3 +4529,70 @@ describe("ProviderRuntimeIngestion", () => {
4367 expect(thread.session?.lastError).toBe("runtime still processed");4529 expect(thread.session?.lastError).toBe("runtime still processed");
4368 });4530 });
4369});4531});
4532
4533describe("splitBufferedAssistantText", () => {
4534 it("keeps a partial trailing line buffered", () => {
4535 expect(splitBufferedAssistantText("one\n\ntwo")).toEqual({ ready: "one\n\n", rest: "two" });
4536 expect(splitBufferedAssistantText("one\ntwo")).toEqual({ ready: "", rest: "one\ntwo" });
4537 });
4538
4539 it("does not split inside an open fence and delivers the block at its closing fence", () => {
4540 const open = "intro\n\n```\ncode\n\nmore\n";
4541 expect(splitBufferedAssistantText(open)).toEqual({
4542 ready: "intro\n\n",
4543 rest: "```\ncode\n\nmore\n",
4544 });
4545 expect(splitBufferedAssistantText(`${open}\`\`\`\nafter`)).toEqual({
4546 ready: `${open}\`\`\`\n`,
4547 rest: "after",
4548 });
4549 });
4550
4551 it("does not treat a fence with an info string as a closing fence", () => {
4552 const text = "```\n```javascript\nstill code\n\nmore\n";
4553 expect(splitBufferedAssistantText(text)).toEqual({ ready: "", rest: text });
4554 });
4555
4556 it("treats a fence indented four or more spaces as code, not a closing fence", () => {
4557 const text = "```\n ```\n\nstill code\n";
4558 expect(splitBufferedAssistantText(text)).toEqual({ ready: "", rest: text });
4559 expect(splitBufferedAssistantText("```\n ```\nafter")).toEqual({
4560 ready: "```\n ```\n",
4561 rest: "after",
4562 });
4563 });
4564
4565 it("keeps a fence nested under a list item open across its blank lines", () => {
4566 const text = "- step\n\n ```ts\n a\n\n b\n ```\n\nafter\n";
4567 expect(splitBufferedAssistantText(text)).toEqual({
4568 ready: "- step\n\n ```ts\n a\n\n b\n ```\n\n",
4569 rest: "after\n",
4570 });
4571 });
4572
4573 it("does not treat a no-break-space line as blank", () => {
4574 expect(splitBufferedAssistantText("para\n\u00a0\ncont\n\nnext")).toEqual({
4575 ready: "para\n\u00a0\ncont\n\n",
4576 rest: "next",
4577 });
4578 });
4579
4580 it("treats CRLF blank lines as boundaries", () => {
4581 expect(splitBufferedAssistantText("one\r\n\r\ntwo")).toEqual({
4582 ready: "one\r\n\r\n",
4583 rest: "two",
4584 });
4585 });
4586
4587 it("only closes a fence with the same marker of equal or greater length", () => {
4588 const text = "````\n```\nstill code\n\n````\n\nout\n";
4589 expect(splitBufferedAssistantText(text)).toEqual({
4590 ready: "````\n```\nstill code\n\n````\n\n",
4591 rest: "out\n",
4592 });
4593 expect(splitBufferedAssistantText("~~~\n```\n\nx\n")).toEqual({
4594 ready: "",
4595 rest: "~~~\n```\n\nx\n",
4596 });
4597 });
4598});
apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts+99-3
...@@ -19,6 +19,7 @@ import {...@@ -19,6 +19,7 @@ import {
19} from "@t3tools/contracts";19} from "@t3tools/contracts";
20import * as Cache from "effect/Cache";20import * as Cache from "effect/Cache";
21import * as Cause from "effect/Cause";21import * as Cause from "effect/Cause";
22import * as Clock from "effect/Clock";
22import * as Crypto from "effect/Crypto";23import * as Crypto from "effect/Crypto";
23import * as DateTime from "effect/DateTime";24import * as DateTime from "effect/DateTime";
24import * as Duration from "effect/Duration";25import * as Duration from "effect/Duration";
...@@ -107,6 +108,11 @@ const BUFFERED_PROPOSED_PLAN_BY_ID_TTL = Duration.minutes(120);...@@ -107,6 +108,11 @@ const BUFFERED_PROPOSED_PLAN_BY_ID_TTL = Duration.minutes(120);
107const TASK_DESCRIPTION_BY_TASK_CACHE_CAPACITY = 10_000;108const TASK_DESCRIPTION_BY_TASK_CACHE_CAPACITY = 10_000;
108const TASK_DESCRIPTION_BY_TASK_TTL = Duration.minutes(120);109const TASK_DESCRIPTION_BY_TASK_TTL = Duration.minutes(120);
109const MAX_BUFFERED_ASSISTANT_CHARS = 24_000;110const MAX_BUFFERED_ASSISTANT_CHARS = 24_000;
111// Paragraphs that finish within this window after a delivery stay buffered
112// and land together on the next one. Keeps fast models from repainting the
113// message several times a second while still showing the first paragraph
114// as soon as it is done.
115const MIN_ASSISTANT_DELIVERY_INTERVAL_MS = 400;
110const STRICT_PROVIDER_LIFECYCLE_GUARD = process.env.T3CODE_STRICT_PROVIDER_LIFECYCLE_GUARD !== "0";116const STRICT_PROVIDER_LIFECYCLE_GUARD = process.env.T3CODE_STRICT_PROVIDER_LIFECYCLE_GUARD !== "0";
111117
112type TurnStartRequestedDomainEvent = Extract<118type TurnStartRequestedDomainEvent = Extract<
...@@ -180,6 +186,58 @@ function hasRenderableAssistantText(text: string | undefined): boolean {...@@ -180,6 +186,58 @@ function hasRenderableAssistantText(text: string | undefined): boolean {
180 return (text?.trim().length ?? 0) > 0;186 return (text?.trim().length ?? 0) > 0;
181}187}
182188
189// An opening fence may sit at any indentation, since fences inside list
190// items are indented past the marker. A closing fence may be indented at most
191// three spaces more than its opener. Deeper lines are content in the block.
192const MARKDOWN_FENCE_PATTERN = /^( *)(`{3,}|~{3,})/;
193// CommonMark blank lines hold only spaces and tabs. Other whitespace, such as
194// a no-break space, is paragraph content.
195const BLANK_LINE_PATTERN = /^[ \t]*$/;
196
197/**
198 * Splits buffered assistant text at the last blank line or closing code fence
199 * that is not inside an open fenced code block. `ready` is safe to deliver now
200 * because the markdown before it will not change shape as more text arrives.
201 * `rest` stays buffered until the next boundary or completion. Only fully
202 * terminated lines count, so a trailing partial line never leaks.
203 */
204export function splitBufferedAssistantText(text: string): { ready: string; rest: string } {
205 let openFence: { marker: string; indent: number } | null = null;
206 let boundary = -1;
207 let lineStart = 0;
208 for (;;) {
209 const newline = text.indexOf("\n", lineStart);
210 if (newline === -1) {
211 break;
212 }
213 const line = text.slice(lineStart, newline).replace(/[ \t\r]+$/, "");
214 const fenceMatch = MARKDOWN_FENCE_PATTERN.exec(line);
215 if (fenceMatch) {
216 const indent = fenceMatch[1]!.length;
217 const marker = fenceMatch[2]!;
218 if (openFence === null) {
219 openFence = { marker, indent };
220 } else if (
221 marker[0] === openFence.marker[0] &&
222 marker.length >= openFence.marker.length &&
223 indent <= openFence.indent + 3 &&
224 line.length === indent + marker.length
225 ) {
226 // CommonMark: a closing fence carries no info string.
227 openFence = null;
228 boundary = newline + 1;
229 }
230 } else if (openFence === null && BLANK_LINE_PATTERN.test(line) && lineStart > 0) {
231 boundary = newline + 1;
232 }
233 lineStart = newline + 1;
234 }
235 if (boundary === -1) {
236 return { ready: "", rest: text };
237 }
238 return { ready: text.slice(0, boundary), rest: text.slice(boundary) };
239}
240
183function proposedPlanIdForTurn(threadId: ThreadId, turnId: TurnId): string {241function proposedPlanIdForTurn(threadId: ThreadId, turnId: TurnId): string {
184 return `plan:${threadId}:turn:${turnId}`;242 return `plan:${threadId}:turn:${turnId}`;
185}243}
...@@ -927,6 +985,12 @@ const make = Effect.gen(function* () {...@@ -927,6 +985,12 @@ const make = Effect.gen(function* () {
927 timeToLive: BUFFERED_MESSAGE_TEXT_BY_MESSAGE_ID_TTL,985 timeToLive: BUFFERED_MESSAGE_TEXT_BY_MESSAGE_ID_TTL,
928 lookup: () => Effect.succeed(""),986 lookup: () => Effect.succeed(""),
929 });987 });
988 // Epoch millis of the last early delivery per message, for pacing.
989 const lastAssistantDeliveryAtByMessageId = yield* Cache.make<MessageId, number>({
990 capacity: BUFFERED_MESSAGE_TEXT_BY_MESSAGE_ID_CACHE_CAPACITY,
991 timeToLive: BUFFERED_MESSAGE_TEXT_BY_MESSAGE_ID_TTL,
992 lookup: () => Effect.succeed(0),
993 });
930994
931 const assistantSegmentStateByTurnKey = yield* Cache.make<string, AssistantSegmentState>({995 const assistantSegmentStateByTurnKey = yield* Cache.make<string, AssistantSegmentState>({
932 capacity: TURN_MESSAGE_IDS_BY_TURN_CACHE_CAPACITY,996 capacity: TURN_MESSAGE_IDS_BY_TURN_CACHE_CAPACITY,
...@@ -1103,7 +1167,7 @@ const make = Effect.gen(function* () {...@@ -1103,7 +1167,7 @@ const make = Effect.gen(function* () {
1103 });1167 });
1104 });1168 });
11051169
1106 const appendBufferedAssistantText = (messageId: MessageId, delta: string) =>1170 const appendBufferedAssistantText = (messageId: MessageId, delta: string, atMillis: number) =>
1107 Cache.getOption(bufferedAssistantTextByMessageId, messageId).pipe(1171 Cache.getOption(bufferedAssistantTextByMessageId, messageId).pipe(
1108 Effect.flatMap((existingText) =>1172 Effect.flatMap((existingText) =>
1109 Effect.gen(function* () {1173 Effect.gen(function* () {
...@@ -1111,6 +1175,30 @@ const make = Effect.gen(function* () {...@@ -1111,6 +1175,30 @@ const make = Effect.gen(function* () {
1111 onNone: () => delta,1175 onNone: () => delta,
1112 onSome: (text) => `${text}${delta}`,1176 onSome: (text) => `${text}${delta}`,
1113 });1177 });
1178
1179 // Deliver finished paragraphs and closed code blocks early so the
1180 // user sees progress without token-by-token repaints.
1181 const { ready, rest } = splitBufferedAssistantText(nextText);
1182 const lastDeliveredAt = Option.getOrUndefined(
1183 yield* Cache.getOption(lastAssistantDeliveryAtByMessageId, messageId),
1184 );
1185 const paced =
1186 lastDeliveredAt === undefined ||
1187 atMillis - lastDeliveredAt >= MIN_ASSISTANT_DELIVERY_INTERVAL_MS;
1188 if (
1189 paced &&
1190 hasRenderableAssistantText(ready) &&
1191 rest.length <= MAX_BUFFERED_ASSISTANT_CHARS
1192 ) {
1193 if (rest.length > 0) {
1194 yield* Cache.set(bufferedAssistantTextByMessageId, messageId, rest);
1195 } else {
1196 yield* Cache.invalidate(bufferedAssistantTextByMessageId, messageId);
1197 }
1198 yield* Cache.set(lastAssistantDeliveryAtByMessageId, messageId, atMillis);
1199 return ready;
1200 }
1201
1114 if (nextText.length <= MAX_BUFFERED_ASSISTANT_CHARS) {1202 if (nextText.length <= MAX_BUFFERED_ASSISTANT_CHARS) {
1115 yield* Cache.set(bufferedAssistantTextByMessageId, messageId, nextText);1203 yield* Cache.set(bufferedAssistantTextByMessageId, messageId, nextText);
1116 return "";1204 return "";
...@@ -1133,7 +1221,9 @@ const make = Effect.gen(function* () {...@@ -1133,7 +1221,9 @@ const make = Effect.gen(function* () {
1133 );1221 );
11341222
1135 const clearBufferedAssistantText = (messageId: MessageId) =>1223 const clearBufferedAssistantText = (messageId: MessageId) =>
1136 Cache.invalidate(bufferedAssistantTextByMessageId, messageId);1224 Cache.invalidate(bufferedAssistantTextByMessageId, messageId).pipe(
1225 Effect.andThen(Cache.invalidate(lastAssistantDeliveryAtByMessageId, messageId)),
1226 );
11371227
1138 const appendBufferedProposedPlan = (planId: string, delta: string, createdAt: string) =>1228 const appendBufferedProposedPlan = (planId: string, delta: string, createdAt: string) =>
1139 Cache.getOption(bufferedProposedPlanById, planId).pipe(1229 Cache.getOption(bufferedProposedPlanById, planId).pipe(
...@@ -1675,7 +1765,13 @@ const make = Effect.gen(function* () {...@@ -1675,7 +1765,13 @@ const make = Effect.gen(function* () {
1675 : "buffered",1765 : "buffered",
1676 );1766 );
1677 if (assistantDeliveryMode === "buffered") {1767 if (assistantDeliveryMode === "buffered") {
1678 const spillChunk = yield* appendBufferedAssistantText(assistantMessageId, assistantDelta);1768 // Pace on the server clock. OpenCode stamps every delta of a part
1769 // with the part's start time, so the event time cannot measure gaps.
1770 const spillChunk = yield* appendBufferedAssistantText(
1771 assistantMessageId,
1772 assistantDelta,
1773 yield* Clock.currentTimeMillis,
1774 );
1679 if (spillChunk.length > 0) {1775 if (spillChunk.length > 0) {
1680 yield* orchestrationEngine.dispatch({1776 yield* orchestrationEngine.dispatch({
1681 type: "thread.message.assistant.delta",1777 type: "thread.message.assistant.delta",
apps/web/src/components/ChatMarkdown.tsx+11-1
...@@ -3196,7 +3196,15 @@ const CHAT_MARKDOWN_COMPONENTS = {...@@ -3196,7 +3196,15 @@ const CHAT_MARKDOWN_COMPONENTS = {
3196 resetKeys={[codeBlock.code, language, diffThemeName, isStreaming]}3196 resetKeys={[codeBlock.code, language, diffThemeName, isStreaming]}
3197 fallback={<pre {...props}>{children}</pre>}3197 fallback={<pre {...props}>{children}</pre>}
3198 >3198 >
3199 <Suspense fallback={<pre {...props}>{children}</pre>}>3199 {/* Reserve the block's height but stay hidden until Shiki has colored
3200 it, so plain text never flashes before the highlighted version. */}
3201 <Suspense
3202 fallback={
3203 <pre {...props} className="invisible" aria-hidden>
3204 {children}
3205 </pre>
3206 }
3207 >
3200 <SuspenseShikiCodeBlock3208 <SuspenseShikiCodeBlock
3201 className={codeBlock.className}3209 className={codeBlock.className}
3202 code={codeBlock.code}3210 code={codeBlock.code}
...@@ -3249,6 +3257,8 @@ function ChatMarkdown({...@@ -3249,6 +3257,8 @@ function ChatMarkdown({
3249 "chat-markdown w-full min-w-0 text-sm leading-relaxed text-foreground/80 [overflow-wrap:anywhere] [word-break:break-word]",3257 "chat-markdown w-full min-w-0 text-sm leading-relaxed text-foreground/80 [overflow-wrap:anywhere] [word-break:break-word]",
3250 className,3258 className,
3251 )}3259 )}
3260 // Gates the fade-in for blocks that arrive while the response streams.
3261 data-streaming={componentState.isStreaming ? "" : undefined}
3252 onCopy={handleCopy}3262 onCopy={handleCopy}
3253 >3263 >
3254 <ChatMarkdownRendererContext value={componentState}>3264 <ChatMarkdownRendererContext value={componentState}>
apps/web/src/components/chat/MessagesTimeline.test.tsx+87
...@@ -901,6 +901,93 @@ describe("MessagesTimeline", () => {...@@ -901,6 +901,93 @@ describe("MessagesTimeline", () => {
901 expect(markup).not.toContain("<a href=");901 expect(markup).not.toContain("<a href=");
902 });902 });
903903
904 it("glides to the end while a turn is running and snaps otherwise", () => {
905 const entries = [buildUserTimelineEntry("Hello")];
906 const working = renderToStaticMarkup(
907 <MessagesTimeline {...buildProps()} isWorking timelineEntries={entries} />,
908 );
909 expect(working).toContain('data-maintain-scroll-at-end-animated="true"');
910
911 const idle = renderToStaticMarkup(
912 <MessagesTimeline {...buildProps()} timelineEntries={entries} />,
913 );
914 expect(idle).toContain('data-maintain-scroll-at-end-animated="false"');
915 });
916
917 it("snaps to the end while a thread switch settles, even mid-turn", async () => {
918 const frames = new Map<number, FrameRequestCallback>();
919 let nextFrame = 0;
920 vi.stubGlobal("requestAnimationFrame", (callback: FrameRequestCallback) => {
921 frames.set(++nextFrame, callback);
922 return nextFrame;
923 });
924 vi.stubGlobal("cancelAnimationFrame", (frame: number) => frames.delete(frame));
925 vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
926 const flushFrame = () =>
927 act(() => {
928 const callbacks = [...frames.values()];
929 frames.clear();
930 callbacks.forEach((callback) => callback(0));
931 });
932 // A work entry renders without the DOM globals that message rows need
933 // under react-test-renderer.
934 const entries = [
935 {
936 id: "entry-settle-work",
937 kind: "work" as const,
938 createdAt: MESSAGE_CREATED_AT,
939 entry: {
940 id: "work-settle",
941 createdAt: MESSAGE_CREATED_AT,
942 toolCallId: "call-settle",
943 label: "Run lint",
944 tone: "tool" as const,
945 itemType: "command_execution" as const,
946 command: "pnpm lint",
947 toolLifecycleStatus: "completed" as const,
948 },
949 },
950 ];
951 const animatedAttr = (renderer: ReactTestRenderer) =>
952 renderer.root.findByProps({ "data-testid": "legend-list" }).props[
953 "data-maintain-scroll-at-end-animated"
954 ];
955 let renderer!: ReactTestRenderer;
956 try {
957 act(() => {
958 renderer = create(
959 <MessagesTimeline
960 {...buildProps()}
961 isWorking
962 routeThreadKey="env-1:thread-a"
963 timelineEntries={entries}
964 />,
965 );
966 });
967 expect(animatedAttr(renderer)).toBe(true);
968
969 act(() => {
970 renderer.update(
971 <MessagesTimeline
972 {...buildProps()}
973 isWorking
974 routeThreadKey="env-1:thread-b"
975 timelineEntries={entries}
976 />,
977 );
978 });
979 expect(animatedAttr(renderer)).toBe(false);
980
981 // Two frames later the switch has settled and gliding resumes.
982 flushFrame();
983 flushFrame();
984 expect(animatedAttr(renderer)).toBe(true);
985 } finally {
986 act(() => renderer?.unmount());
987 vi.unstubAllGlobals();
988 }
989 });
990
904 it("keeps reserved end space when tool work starts while reading history", () => {991 it("keeps reserved end space when tool work starts while reading history", () => {
905 const turnId = TurnId.make("turn-with-active-tool");992 const turnId = TurnId.make("turn-with-active-tool");
906 const firstEntry = buildUserTimelineEntry("Run the command.");993 const firstEntry = buildUserTimelineEntry("Run the command.");
apps/web/src/components/chat/MessagesTimeline.tsx+31-1
...@@ -227,6 +227,7 @@ import {...@@ -227,6 +227,7 @@ import {
227import { createContextPresentationRegistry } from "../contextPresentationRegistry";227import { createContextPresentationRegistry } from "../contextPresentationRegistry";
228import { useOpenPrLink } from "~/lib/openPullRequestLink";228import { useOpenPrLink } from "~/lib/openPullRequestLink";
229import type { ChatMarkdownContextReference } from "../ChatMarkdown";229import type { ChatMarkdownContextReference } from "../ChatMarkdown";
230import { useMediaQuery } from "~/hooks/useMediaQuery";
230import { cn } from "~/lib/utils";231import { cn } from "~/lib/utils";
231import { useUiStateStore } from "~/uiStateStore";232import { useUiStateStore } from "~/uiStateStore";
232import { type TimestampFormat } from "@t3tools/contracts/settings";233import { type TimestampFormat } from "@t3tools/contracts/settings";
...@@ -348,6 +349,13 @@ const TIMELINE_MAINTAIN_SCROLL_AT_END = {...@@ -348,6 +349,13 @@ const TIMELINE_MAINTAIN_SCROLL_AT_END = {
348 layout: true,349 layout: true,
349 },350 },
350} as const satisfies MaintainScrollAtEndOptions;351} as const satisfies MaintainScrollAtEndOptions;
352// Streamed text lands a paragraph at a time. A smooth scroll to the end
353// turns each landing into a short glide instead of a jump. Thread switches
354// and layout settles keep the instant variant so nothing visibly travels.
355const TIMELINE_MAINTAIN_SCROLL_AT_END_SMOOTH = {
356 ...TIMELINE_MAINTAIN_SCROLL_AT_END,
357 animated: true,
358} as const satisfies MaintainScrollAtEndOptions;
351359
352// ---------------------------------------------------------------------------360// ---------------------------------------------------------------------------
353// Props (public API)361// Props (public API)
...@@ -470,14 +478,19 @@ export const MessagesTimeline = memo(function MessagesTimeline({...@@ -470,14 +478,19 @@ export const MessagesTimeline = memo(function MessagesTimeline({
470 new Set(),478 new Set(),
471 );479 );
472 const listIdentityKey = displayThreadKey ?? routeThreadKey;480 const listIdentityKey = displayThreadKey ?? routeThreadKey;
481 const prefersReducedMotion = useMediaQuery("(prefers-reduced-motion: reduce)");
473 const listIdentityRef = useRef(listIdentityKey);482 const listIdentityRef = useRef(listIdentityKey);
474 const previousLatestTurnRef = useRef(latestTurn);483 const previousLatestTurnRef = useRef(latestTurn);
484 // The list stays mounted across thread switches. Its first end pins on the
485 // new thread must snap, not glide, even if that thread is mid-turn.
486 const [settlingListIdentity, setSettlingListIdentity] = useState<string | null>(null);
475 let paintedExpandedTurnIds = expandedTurnIds;487 let paintedExpandedTurnIds = expandedTurnIds;
476 let paintedExpandedWorkGroupIds = expandedWorkGroupIds;488 let paintedExpandedWorkGroupIds = expandedWorkGroupIds;
477 let paintedExpandedSpawnEntryIds = expandedSpawnEntryIds;489 let paintedExpandedSpawnEntryIds = expandedSpawnEntryIds;
478 if (listIdentityRef.current !== listIdentityKey) {490 if (listIdentityRef.current !== listIdentityKey) {
479 listIdentityRef.current = listIdentityKey;491 listIdentityRef.current = listIdentityKey;
480 previousLatestTurnRef.current = latestTurn;492 previousLatestTurnRef.current = latestTurn;
493 setSettlingListIdentity(listIdentityKey);
481 paintedExpandedTurnIds = new Set();494 paintedExpandedTurnIds = new Set();
482 paintedExpandedWorkGroupIds = new Set();495 paintedExpandedWorkGroupIds = new Set();
483 paintedExpandedSpawnEntryIds = new Set();496 paintedExpandedSpawnEntryIds = new Set();
...@@ -522,6 +535,21 @@ export const MessagesTimeline = memo(function MessagesTimeline({...@@ -522,6 +535,21 @@ export const MessagesTimeline = memo(function MessagesTimeline({
522 };535 };
523 }, []);536 }, []);
524537
538 useEffect(() => {
539 if (settlingListIdentity === null) return;
540 // Two frames covers the fresh-data layout pass and the initial end pin.
541 let second: number | null = null;
542 const first = requestAnimationFrame(() => {
543 second = requestAnimationFrame(() => {
544 setSettlingListIdentity((current) => (current === settlingListIdentity ? null : current));
545 });
546 });
547 return () => {
548 cancelAnimationFrame(first);
549 if (second !== null) cancelAnimationFrame(second);
550 };
551 }, [settlingListIdentity]);
552
525 const suspendEndScrollMaintenanceForDisclosure = useCallback(553 const suspendEndScrollMaintenanceForDisclosure = useCallback(
526 (anchorKey: string, collapsed = false) => {554 (anchorKey: string, collapsed = false) => {
527 disclosureAnchorKeyRef.current = anchorKey;555 disclosureAnchorKeyRef.current = anchorKey;
...@@ -977,7 +1005,9 @@ export const MessagesTimeline = memo(function MessagesTimeline({...@@ -977,7 +1005,9 @@ export const MessagesTimeline = memo(function MessagesTimeline({
977 !liveFollowEnabled ||1005 !liveFollowEnabled ||
978 disclosureToggleSettling1006 disclosureToggleSettling
979 ? false1007 ? false
980 : TIMELINE_MAINTAIN_SCROLL_AT_END1008 : isWorking && !prefersReducedMotion && settlingListIdentity === null
1009 ? TIMELINE_MAINTAIN_SCROLL_AT_END_SMOOTH
1010 : TIMELINE_MAINTAIN_SCROLL_AT_END
981 }1011 }
982 maintainVisibleContentPosition={1012 maintainVisibleContentPosition={
983 citationPositioning ? false : maintainVisibleContentPosition1013 citationPositioning ? false : maintainVisibleContentPosition
apps/web/src/index.css+16
...@@ -1891,6 +1891,22 @@ code {...@@ -1891,6 +1891,22 @@ code {
1891 background: transparent !important;1891 background: transparent !important;
1892}1892}
18931893
1894/* Paragraphs and code blocks arrive in chunks while a response streams. Fade
1895 each new block in so the chunk does not pop. @starting-style only applies
1896 when an element is first inserted, and the rule is gated on data-streaming,
1897 so opening a finished thread never replays the fade. Opacity only, one
1898 shot, no layout change. */
1899@media (prefers-reduced-motion: no-preference) {
1900 .chat-markdown[data-streaming] > *,
1901 .chat-markdown[data-streaming] .chat-markdown-shiki {
1902 transition: opacity 600ms ease-out;
1903
1904 @starting-style {
1905 opacity: 0;
1906 }
1907 }
1908}
1909
1894/* Diagnostics-style tables: row separators only, uppercase headers, and a1910/* Diagnostics-style tables: row separators only, uppercase headers, and a
1895 scroll-fade container for horizontal overflow. The root chat-markdown1911 scroll-fade container for horizontal overflow. The root chat-markdown
1896 wrapping rules (overflow-wrap: anywhere) would let columns shrink to single1912 wrapping rules (overflow-wrap: anywhere) would let columns shrink to single
patches/@legendapp__list@3.3.5.patch+147-15
...@@ -1,5 +1,5 @@...@@ -1,5 +1,5 @@
1diff --git a/keyboard.d.ts b/keyboard.d.ts1diff --git a/keyboard.d.ts b/keyboard.d.ts
2index 367945cdfa8a8c260b7a127657a75c016c9ab46f..0ac7bf8b8e9c386058b2374199e58fe8c92d39b3 1006442index 367945c..0ac7bf8 100644
3--- a/keyboard.d.ts3--- a/keyboard.d.ts
4+++ b/keyboard.d.ts4+++ b/keyboard.d.ts
5@@ -279,7 +279,7 @@ type KeyboardChatComposerInsetListRef = {5@@ -279,7 +279,7 @@ type KeyboardChatComposerInsetListRef = {
...@@ -23,7 +23,7 @@ index 367945cdfa8a8c260b7a127657a75c016c9ab46f..0ac7bf8b8e9c386058b2374199e58fe8...@@ -23,7 +23,7 @@ index 367945cdfa8a8c260b7a127657a75c016c9ab46f..0ac7bf8b8e9c386058b2374199e58fe8
23 } & React.RefAttributes<LegendListRef>) => React.ReactElement | null;23 } & React.RefAttributes<LegendListRef>) => React.ReactElement | null;
24 24
25diff --git a/keyboard.js b/keyboard.js25diff --git a/keyboard.js b/keyboard.js
26index 6645bcbb1f77c36c432035eaf8b2d6d924fc8bda..321c4c305129dd3ce3f06127c47d51720a73fb83 10064426index 6645bcb..321c4c3 100644
27--- a/keyboard.js27--- a/keyboard.js
28+++ b/keyboard.js28+++ b/keyboard.js
29@@ -33,19 +33,22 @@ if (typeof __DEV__ !== "undefined" && __DEV__ && !reactNativeKeyboardController.29@@ -33,19 +33,22 @@ if (typeof __DEV__ !== "undefined" && __DEV__ && !reactNativeKeyboardController.
...@@ -119,7 +119,7 @@ index 6645bcbb1f77c36c432035eaf8b2d6d924fc8bda..321c4c305129dd3ce3f06127c47d5172...@@ -119,7 +119,7 @@ index 6645bcbb1f77c36c432035eaf8b2d6d924fc8bda..321c4c305129dd3ce3f06127c47d5172
119 renderScrollComponent: memoList,119 renderScrollComponent: memoList,
120 ...rest120 ...rest
121diff --git a/keyboard.mjs b/keyboard.mjs121diff --git a/keyboard.mjs b/keyboard.mjs
122index 87b38b9607c2eba6b407c2acfd520633bdb0e7c2..111eb242ba9d9ade1cb912550f7df85cf4f361ee 100644122index 87b38b9..111eb24 100644
123--- a/keyboard.mjs123--- a/keyboard.mjs
124+++ b/keyboard.mjs124+++ b/keyboard.mjs
125@@ -1,7 +1,7 @@125@@ -1,7 +1,7 @@
...@@ -224,7 +224,7 @@ index 87b38b9607c2eba6b407c2acfd520633bdb0e7c2..111eb242ba9d9ade1cb912550f7df85c...@@ -224,7 +224,7 @@ index 87b38b9607c2eba6b407c2acfd520633bdb0e7c2..111eb242ba9d9ade1cb912550f7df85c
224 renderScrollComponent: memoList,224 renderScrollComponent: memoList,
225 ...rest225 ...rest
226diff --git a/react-native.d.ts b/react-native.d.ts226diff --git a/react-native.d.ts b/react-native.d.ts
227index ce1fe00001c9e5aee6c6ea8bb2d4757d4586d002..3ccf6f16067152dfcb0c143371e2ec6aba6636e5 100644227index ce1fe00..3ccf6f1 100644
228--- a/react-native.d.ts228--- a/react-native.d.ts
229+++ b/react-native.d.ts229+++ b/react-native.d.ts
230@@ -293,6 +293,12 @@ interface LegendListSpecificProps<ItemT, TItemType extends string | undefined> {230@@ -293,6 +293,12 @@ interface LegendListSpecificProps<ItemT, TItemType extends string | undefined> {
...@@ -241,13 +241,13 @@ index ce1fe00001c9e5aee6c6ea8bb2d4757d4586d002..3ccf6f16067152dfcb0c143371e2ec6a...@@ -241,13 +241,13 @@ index ce1fe00001c9e5aee6c6ea8bb2d4757d4586d002..3ccf6f16067152dfcb0c143371e2ec6a
241 * Number of columns to render items in.241 * Number of columns to render items in.
242 * @default 1242 * @default 1
243diff --git a/react-native.js b/react-native.js243diff --git a/react-native.js b/react-native.js
244index b3c5a306b293f797a8b338adfca3060c0f6db22b..5ac6bbe8fe40bf252fefa6b885c2196d0677d75f 100644244index b3c5a30..5ac6bbe 100644
245--- a/react-native.js245--- a/react-native.js
246+++ b/react-native.js246+++ b/react-native.js
247@@ -717,6 +717,15 @@ function hasActiveInitialScroll(state) {247@@ -717,6 +717,15 @@ function hasActiveInitialScroll(state) {
248 return !!(state == null ? void 0 : state.initialScroll) && !state.didFinishInitialScroll;248 return !!(state == null ? void 0 : state.initialScroll) && !state.didFinishInitialScroll;
249 }249 }
250250
251+// Size-only changes may not emit a scroll event to refresh the edge signal.251+// Size-only changes may not emit a scroll event to refresh the edge signal.
252+function getIsAtEnd(ctx, contentSize = getContentSize(ctx)) {252+function getIsAtEnd(ctx, contentSize = getContentSize(ctx)) {
253+ const { queuedInitialLayout, scroll, scrollLength } = ctx.state;253+ const { queuedInitialLayout, scroll, scrollLength } = ctx.state;
...@@ -809,13 +809,13 @@ index b3c5a306b293f797a8b338adfca3060c0f6db22b..5ac6bbe8fe40bf252fefa6b885c2196d...@@ -809,13 +809,13 @@ index b3c5a306b293f797a8b338adfca3060c0f6db22b..5ac6bbe8fe40bf252fefa6b885c2196d
809 recycleItems,809 recycleItems,
810 refreshControl: refreshControlElement ? stylePaddingTopState > 0 ? React2__namespace.cloneElement(refreshControlElement, {810 refreshControl: refreshControlElement ? stylePaddingTopState > 0 ? React2__namespace.cloneElement(refreshControlElement, {
811diff --git a/react-native.mjs b/react-native.mjs811diff --git a/react-native.mjs b/react-native.mjs
812index 40e87cda8c9bc79a889e5542f29af429a24b24d4..93aac741d2cf77ce35996362439108176f19da7a 100644812index 40e87cd..93aac74 100644
813--- a/react-native.mjs813--- a/react-native.mjs
814+++ b/react-native.mjs814+++ b/react-native.mjs
815@@ -696,6 +696,15 @@ function hasActiveInitialScroll(state) {815@@ -696,6 +696,15 @@ function hasActiveInitialScroll(state) {
816 return !!(state == null ? void 0 : state.initialScroll) && !state.didFinishInitialScroll;816 return !!(state == null ? void 0 : state.initialScroll) && !state.didFinishInitialScroll;
817 }817 }
818818
819+// Size-only changes may not emit a scroll event to refresh the edge signal.819+// Size-only changes may not emit a scroll event to refresh the edge signal.
820+function getIsAtEnd(ctx, contentSize = getContentSize(ctx)) {820+function getIsAtEnd(ctx, contentSize = getContentSize(ctx)) {
821+ const { queuedInitialLayout, scroll, scrollLength } = ctx.state;821+ const { queuedInitialLayout, scroll, scrollLength } = ctx.state;
...@@ -1376,8 +1376,78 @@ index 40e87cda8c9bc79a889e5542f29af429a24b24d4..93aac741d2cf77ce3599636243910817...@@ -1376,8 +1376,78 @@ index 40e87cda8c9bc79a889e5542f29af429a24b24d4..93aac741d2cf77ce3599636243910817
1376 onScroll: onScrollHandler,1376 onScroll: onScrollHandler,
1377 recycleItems,1377 recycleItems,
1378 refreshControl: refreshControlElement ? stylePaddingTopState > 0 ? React2.cloneElement(refreshControlElement, {1378 refreshControl: refreshControlElement ? stylePaddingTopState > 0 ? React2.cloneElement(refreshControlElement, {
1379diff --git a/react-native.web.js b/react-native.web.js
1380index 914d2da..8b88b09 100644
1381--- a/react-native.web.js
1382+++ b/react-native.web.js
1383@@ -5651,6 +5651,18 @@ var ContainerSlot = typedMemo(function ContainerSlot2(props) {
1384 });
1385
1386 // src/utils/reordering.ts
1387+// Element.moveBefore keeps the moved subtree's state and does not count as an
1388+// insertion, so CSS @starting-style transitions and iframes inside a row do not
1389+// restart when the list reorders its containers. insertBefore is the fallback.
1390+function moveChildBefore(container, element, reference) {
1391+ if (typeof container.moveBefore === "function") {
1392+ container.moveBefore(element, reference);
1393+ } else if (reference) {
1394+ container.insertBefore(element, reference);
1395+ } else {
1396+ container.appendChild(element);
1397+ }
1398+}
1399 function sortDOMElements(container, indexByElement) {
1400 const elements = Array.from(container.children);
1401 if (elements.length <= 1) return elements;
1402@@ -5690,9 +5702,9 @@ function sortDOMElements(container, indexByElement) {
1403 }
1404 }
1405 if (nextStableElement) {
1406- container.insertBefore(element, nextStableElement);
1407+ moveChildBefore(container, element, nextStableElement);
1408 } else {
1409- container.appendChild(element);
1410+ moveChildBefore(container, element, null);
1411 }
1412 }
1413 }
1414diff --git a/react-native.web.mjs b/react-native.web.mjs
1415index 95465f2..d7cbe1c 100644
1416--- a/react-native.web.mjs
1417+++ b/react-native.web.mjs
1418@@ -5630,6 +5630,18 @@ var ContainerSlot = typedMemo(function ContainerSlot2(props) {
1419 });
1420
1421 // src/utils/reordering.ts
1422+// Element.moveBefore keeps the moved subtree's state and does not count as an
1423+// insertion, so CSS @starting-style transitions and iframes inside a row do not
1424+// restart when the list reorders its containers. insertBefore is the fallback.
1425+function moveChildBefore(container, element, reference) {
1426+ if (typeof container.moveBefore === "function") {
1427+ container.moveBefore(element, reference);
1428+ } else if (reference) {
1429+ container.insertBefore(element, reference);
1430+ } else {
1431+ container.appendChild(element);
1432+ }
1433+}
1434 function sortDOMElements(container, indexByElement) {
1435 const elements = Array.from(container.children);
1436 if (elements.length <= 1) return elements;
1437@@ -5669,9 +5681,9 @@ function sortDOMElements(container, indexByElement) {
1438 }
1439 }
1440 if (nextStableElement) {
1441- container.insertBefore(element, nextStableElement);
1442+ moveChildBefore(container, element, nextStableElement);
1443 } else {
1444- container.appendChild(element);
1445+ moveChildBefore(container, element, null);
1446 }
1447 }
1448 }
1379diff --git a/react.js b/react.js1449diff --git a/react.js b/react.js
1380index 914d2dafaafa001c9ab6791a0a0581659295e333..a2df18d450eebe451fda42bab47d1658458edbb1 1006441450index 914d2da..0c2917d 100644
1381--- a/react.js1451--- a/react.js
1382+++ b/react.js1452+++ b/react.js
1383@@ -4702,7 +4702,8 @@ function maybeUpdateAnchoredEndSpace(ctx) {1453@@ -4702,7 +4702,8 @@ function maybeUpdateAnchoredEndSpace(ctx) {
...@@ -1403,7 +1473,38 @@ index 914d2dafaafa001c9ab6791a0a0581659295e333..a2df18d450eebe451fda42bab47d1658...@@ -1403,7 +1473,38 @@ index 914d2dafaafa001c9ab6791a0a0581659295e333..a2df18d450eebe451fda42bab47d1658
1403 }1473 }
1404 return nextSize;1474 return nextSize;
1405 }1475 }
1406@@ -6446,8 +6453,8 @@ function ScrollAdjust() {1476@@ -5651,6 +5658,18 @@ var ContainerSlot = typedMemo(function ContainerSlot2(props) {
1477 });
1478
1479 // src/utils/reordering.ts
1480+// Element.moveBefore keeps the moved subtree's state and does not count as an
1481+// insertion, so CSS @starting-style transitions and iframes inside a row do not
1482+// restart when the list reorders its containers. insertBefore is the fallback.
1483+function moveChildBefore(container, element, reference) {
1484+ if (typeof container.moveBefore === "function") {
1485+ container.moveBefore(element, reference);
1486+ } else if (reference) {
1487+ container.insertBefore(element, reference);
1488+ } else {
1489+ container.appendChild(element);
1490+ }
1491+}
1492 function sortDOMElements(container, indexByElement) {
1493 const elements = Array.from(container.children);
1494 if (elements.length <= 1) return elements;
1495@@ -5690,9 +5709,9 @@ function sortDOMElements(container, indexByElement) {
1496 }
1497 }
1498 if (nextStableElement) {
1499- container.insertBefore(element, nextStableElement);
1500+ moveChildBefore(container, element, nextStableElement);
1501 } else {
1502- container.appendChild(element);
1503+ moveChildBefore(container, element, null);
1504 }
1505 }
1506 }
1507@@ -6446,8 +6465,8 @@ function ScrollAdjust() {
1407 window.getComputedStyle(contentNode)[axis.paddingEndProp]1508 window.getComputedStyle(contentNode)[axis.paddingEndProp]
1408 );1509 );
1409 const temporaryPaddingEnd = `${(currentPaddingEnd || 0) + pad}px`;1510 const temporaryPaddingEnd = `${(currentPaddingEnd || 0) + pad}px`;
...@@ -1414,7 +1515,7 @@ index 914d2dafaafa001c9ab6791a0a0581659295e333..a2df18d450eebe451fda42bab47d1658...@@ -1414,7 +1515,7 @@ index 914d2dafaafa001c9ab6791a0a0581659295e333..a2df18d450eebe451fda42bab47d1658
1414 scrollBy();1515 scrollBy();
1415 if (resetPaddingRafRef.current !== void 0) {1516 if (resetPaddingRafRef.current !== void 0) {
1416diff --git a/react.mjs b/react.mjs1517diff --git a/react.mjs b/react.mjs
1417index 95465f2ab89ce41a10553f58af83618f7310e83c..25cf046f2c3141ddce5a0b6e28c354b865331b4f 1006441518index 95465f2..b73bfdf 100644
1418--- a/react.mjs1519--- a/react.mjs
1419+++ b/react.mjs1520+++ b/react.mjs
1420@@ -4681,7 +4681,8 @@ function maybeUpdateAnchoredEndSpace(ctx) {1521@@ -4681,7 +4681,8 @@ function maybeUpdateAnchoredEndSpace(ctx) {
...@@ -1440,7 +1541,38 @@ index 95465f2ab89ce41a10553f58af83618f7310e83c..25cf046f2c3141ddce5a0b6e28c354b8...@@ -1440,7 +1541,38 @@ index 95465f2ab89ce41a10553f58af83618f7310e83c..25cf046f2c3141ddce5a0b6e28c354b8
1440 }1541 }
1441 return nextSize;1542 return nextSize;
1442 }1543 }
1443@@ -6425,8 +6432,8 @@ function ScrollAdjust() {1544@@ -5630,6 +5637,18 @@ var ContainerSlot = typedMemo(function ContainerSlot2(props) {
1545 });
1546
1547 // src/utils/reordering.ts
1548+// Element.moveBefore keeps the moved subtree's state and does not count as an
1549+// insertion, so CSS @starting-style transitions and iframes inside a row do not
1550+// restart when the list reorders its containers. insertBefore is the fallback.
1551+function moveChildBefore(container, element, reference) {
1552+ if (typeof container.moveBefore === "function") {
1553+ container.moveBefore(element, reference);
1554+ } else if (reference) {
1555+ container.insertBefore(element, reference);
1556+ } else {
1557+ container.appendChild(element);
1558+ }
1559+}
1560 function sortDOMElements(container, indexByElement) {
1561 const elements = Array.from(container.children);
1562 if (elements.length <= 1) return elements;
1563@@ -5669,9 +5688,9 @@ function sortDOMElements(container, indexByElement) {
1564 }
1565 }
1566 if (nextStableElement) {
1567- container.insertBefore(element, nextStableElement);
1568+ moveChildBefore(container, element, nextStableElement);
1569 } else {
1570- container.appendChild(element);
1571+ moveChildBefore(container, element, null);
1572 }
1573 }
1574 }
1575@@ -6425,8 +6444,8 @@ function ScrollAdjust() {
1444 window.getComputedStyle(contentNode)[axis.paddingEndProp]1576 window.getComputedStyle(contentNode)[axis.paddingEndProp]
1445 );1577 );
1446 const temporaryPaddingEnd = `${(currentPaddingEnd || 0) + pad}px`;1578 const temporaryPaddingEnd = `${(currentPaddingEnd || 0) + pad}px`;
...@@ -1451,7 +1583,7 @@ index 95465f2ab89ce41a10553f58af83618f7310e83c..25cf046f2c3141ddce5a0b6e28c354b8...@@ -1451,7 +1583,7 @@ index 95465f2ab89ce41a10553f58af83618f7310e83c..25cf046f2c3141ddce5a0b6e28c354b8
1451 scrollBy();1583 scrollBy();
1452 if (resetPaddingRafRef.current !== void 0) {1584 if (resetPaddingRafRef.current !== void 0) {
1453diff --git a/reanimated.d.ts b/reanimated.d.ts1585diff --git a/reanimated.d.ts b/reanimated.d.ts
1454index e5043320700b12f34f4c0babbc341f85ca8135c1..2ce63830a28636b21937a0741fd0e613dae950fe 1006441586index e504332..2ce6383 100644
1455--- a/reanimated.d.ts1587--- a/reanimated.d.ts
1456+++ b/reanimated.d.ts1588+++ b/reanimated.d.ts
1457@@ -294,6 +294,12 @@ interface LegendListSpecificProps<ItemT, TItemType extends string | undefined> {1589@@ -294,6 +294,12 @@ interface LegendListSpecificProps<ItemT, TItemType extends string | undefined> {
...@@ -1468,7 +1600,7 @@ index e5043320700b12f34f4c0babbc341f85ca8135c1..2ce63830a28636b21937a0741fd0e613...@@ -1468,7 +1600,7 @@ index e5043320700b12f34f4c0babbc341f85ca8135c1..2ce63830a28636b21937a0741fd0e613
1468 * Number of columns to render items in.1600 * Number of columns to render items in.
1469 * @default 11601 * @default 1
1470diff --git a/reanimated.js b/reanimated.js1602diff --git a/reanimated.js b/reanimated.js
1471index f1265fad74189591b5aae86cf2e3a31f9c0fdb02..fc03be2190c046f743ffde45ed189ee7868d0cec 1006441603index f1265fa..fc03be2 100644
1472--- a/reanimated.js1604--- a/reanimated.js
1473+++ b/reanimated.js1605+++ b/reanimated.js
1474@@ -115,8 +115,10 @@ var ReanimatedPositionView = typedMemo(function ReanimatedPositionViewComponent(1606@@ -115,8 +115,10 @@ var ReanimatedPositionView = typedMemo(function ReanimatedPositionViewComponent(
...@@ -1631,7 +1763,7 @@ index f1265fad74189591b5aae86cf2e3a31f9c0fdb02..fc03be2190c046f743ffde45ed189ee7...@@ -1631,7 +1763,7 @@ index f1265fad74189591b5aae86cf2e3a31f9c0fdb02..fc03be2190c046f743ffde45ed189ee7
1631 renderScrollComponent: renderReanimatedScrollComponent,1763 renderScrollComponent: renderReanimatedScrollComponent,
1632 ...IsNewArchitecture ? { stickyPositionComponentInternal } : {}1764 ...IsNewArchitecture ? { stickyPositionComponentInternal } : {}
1633diff --git a/reanimated.mjs b/reanimated.mjs1765diff --git a/reanimated.mjs b/reanimated.mjs
1634index 29a00d5dc084fd408a0e0a0a4d64e856d0ed1525..6ab929250c8924e2d9919ee09a4f774238fbb90a 1006441766index 29a00d5..6ab9292 100644
1635--- a/reanimated.mjs1767--- a/reanimated.mjs
1636+++ b/reanimated.mjs1768+++ b/reanimated.mjs
1637@@ -1,7 +1,7 @@1769@@ -1,7 +1,7 @@
pnpm-lock.yaml+5-5
...@@ -91,7 +91,7 @@ patchedDependencies:...@@ -91,7 +91,7 @@ patchedDependencies:
91 '@effect/vitest@4.0.0-rc.112': a16b1e870d8c29e4a98b17cc4c638a4ff471753d8ca3487f78a22b759caf951b91 '@effect/vitest@4.0.0-rc.112': a16b1e870d8c29e4a98b17cc4c638a4ff471753d8ca3487f78a22b759caf951b
92 '@expo/metro-config@57.0.12': 96f1a75347e6ea02dc4b7034ace815d8ee39e18b8166ebfb573d9e58328f0dc292 '@expo/metro-config@57.0.12': 96f1a75347e6ea02dc4b7034ace815d8ee39e18b8166ebfb573d9e58328f0dc2
93 '@ff-labs/fff-node@0.9.4': ab9ff544009e1891cfe3930105862d3699007f38922a79f3c98d90018deca36893 '@ff-labs/fff-node@0.9.4': ab9ff544009e1891cfe3930105862d3699007f38922a79f3c98d90018deca368
94 '@legendapp/list@3.3.5': a05e968651a1352d2f374324016d1034b763b9fb2c5bf5f95111540d6899a52b94 '@legendapp/list@3.3.5': 680cc6a5c5b4a4032e467e7b3fde22f89a84c0ee2e6eac6fda737d6277cc0806
95 '@pierre/diffs@1.3.0-beta.10': 0ccee155b93b63d810e2c1a40c1fd676fb6fbcfa72cf6430dcedf1a3ae475ab495 '@pierre/diffs@1.3.0-beta.10': 0ccee155b93b63d810e2c1a40c1fd676fb6fbcfa72cf6430dcedf1a3ae475ab4
96 '@react-native-ai/apple@0.12.0': 2d09870c2848d185cb05b53ed823a46e12dba519324d8dd8e584e28731990f9d96 '@react-native-ai/apple@0.12.0': 2d09870c2848d185cb05b53ed823a46e12dba519324d8dd8e584e28731990f9d
97 '@react-native-menu/menu@2.0.0': f63d256bf6a97a873b5e628eb595bd6ef0075ddd5bdd890fc920f7a6024290dd97 '@react-native-menu/menu@2.0.0': f63d256bf6a97a873b5e628eb595bd6ef0075ddd5bdd890fc920f7a6024290dd
...@@ -248,7 +248,7 @@ importers:...@@ -248,7 +248,7 @@ importers:
248 version: 57.0.14(@babel/core@7.29.7)(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(expo@57.0.18)(react-dom@19.2.3(react@19.2.3))(react-native-worklets@0.10.1(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)248 version: 57.0.14(@babel/core@7.29.7)(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(expo@57.0.18)(react-dom@19.2.3(react@19.2.3))(react-native-worklets@0.10.1(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)
249 '@legendapp/list':249 '@legendapp/list':
250 specifier: 'catalog:'250 specifier: 'catalog:'
251 version: 3.3.5(patch_hash=a05e968651a1352d2f374324016d1034b763b9fb2c5bf5f95111540d6899a52b)(react-dom@19.2.3(react@19.2.3))(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)251 version: 3.3.5(patch_hash=680cc6a5c5b4a4032e467e7b3fde22f89a84c0ee2e6eac6fda737d6277cc0806)(react-dom@19.2.3(react@19.2.3))(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)
252 '@material/material-color-utilities':252 '@material/material-color-utilities':
253 specifier: 0.3.0253 specifier: 0.3.0
254 version: 0.3.0254 version: 0.3.0
...@@ -607,7 +607,7 @@ importers:...@@ -607,7 +607,7 @@ importers:
607 version: 0.9.0607 version: 0.9.0
608 '@legendapp/list':608 '@legendapp/list':
609 specifier: 'catalog:'609 specifier: 'catalog:'
610 version: 3.3.5(patch_hash=a05e968651a1352d2f374324016d1034b763b9fb2c5bf5f95111540d6899a52b)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)610 version: 3.3.5(patch_hash=680cc6a5c5b4a4032e467e7b3fde22f89a84c0ee2e6eac6fda737d6277cc0806)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
611 '@lexical/react':611 '@lexical/react':
612 specifier: ^0.41.0612 specifier: ^0.41.0
613 version: 0.41.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(yjs@13.6.31)613 version: 0.41.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(yjs@13.6.31)
...@@ -13997,7 +13997,7 @@ snapshots:...@@ -13997,7 +13997,7 @@ snapshots:
13997 dependencies:13997 dependencies:
13998 jsbi: 4.3.213998 jsbi: 4.3.2
1399913999
14000 '@legendapp/list@3.3.5(patch_hash=a05e968651a1352d2f374324016d1034b763b9fb2c5bf5f95111540d6899a52b)(react-dom@19.2.3(react@19.2.3))(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)':14000 '@legendapp/list@3.3.5(patch_hash=680cc6a5c5b4a4032e467e7b3fde22f89a84c0ee2e6eac6fda737d6277cc0806)(react-dom@19.2.3(react@19.2.3))(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)':
14001 dependencies:14001 dependencies:
14002 react: 19.2.314002 react: 19.2.3
14003 use-sync-external-store: 1.6.0(react@19.2.3)14003 use-sync-external-store: 1.6.0(react@19.2.3)
...@@ -14005,7 +14005,7 @@ snapshots:...@@ -14005,7 +14005,7 @@ snapshots:
14005 react-dom: 19.2.3(react@19.2.3)14005 react-dom: 19.2.3(react@19.2.3)
14006 react-native: 0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)14006 react-native: 0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)
1400714007
14008 '@legendapp/list@3.3.5(patch_hash=a05e968651a1352d2f374324016d1034b763b9fb2c5bf5f95111540d6899a52b)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)':14008 '@legendapp/list@3.3.5(patch_hash=680cc6a5c5b4a4032e467e7b3fde22f89a84c0ee2e6eac6fda737d6277cc0806)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)':
14009 dependencies:14009 dependencies:
14010 react: 19.2.614010 react: 19.2.6
14011 use-sync-external-store: 1.6.0(react@19.2.6)14011 use-sync-external-store: 1.6.0(react@19.2.6)