From c07575f573dd3a1af4f734297d17f7c951c95f10 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Sun, 13 Sep 2026 19:02:01 -0700 Subject: [PATCH] feat(server): show finished paragraphs and code blocks while the response streams (#11062) Co-authored-by: Claude Fable 5.1 --- .../Layers/ProviderRuntimeIngestion.test.ts | 231 +++++++++++++++++- .../Layers/ProviderRuntimeIngestion.ts | 102 +++++++- apps/web/src/components/ChatMarkdown.tsx | 12 +- .../components/chat/MessagesTimeline.test.tsx | 87 +++++++ .../src/components/chat/MessagesTimeline.tsx | 32 ++- apps/web/src/index.css | 16 ++ patches/@legendapp__list@3.3.5.patch | 162 ++++++++++-- pnpm-lock.yaml | 10 +- 8 files changed, 626 insertions(+), 26 deletions(-) diff --git a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts index 1094ab48b7ac88cc9affd620f9ee98073e638a40..c22cf3e53bea7c7604aa267234378cb845ddd22d 100644 --- a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts @@ -55,7 +55,10 @@ import { OrchestrationProjectionPipelineLive } from "./ProjectionPipeline.ts"; import { OrchestrationProjectionSnapshotQueryLive } from "./ProjectionSnapshotQuery.ts"; import * as ThreadBackgroundLiveness from "../ThreadBackgroundLiveness.ts"; import * as ThreadPlanProgress from "../ThreadPlanProgress.ts"; -import { ProviderRuntimeIngestionLive } from "./ProviderRuntimeIngestion.ts"; +import { + ProviderRuntimeIngestionLive, + splitBufferedAssistantText, +} from "./ProviderRuntimeIngestion.ts"; import { DEFAULT_THREAD_TITLE } from "../threadTitles.ts"; import { OrchestrationEngineService } from "../Services/OrchestrationEngine.ts"; import { ProviderRuntimeIngestionService } from "../Services/ProviderRuntimeIngestion.ts"; @@ -297,7 +300,24 @@ describe("ProviderRuntimeIngestion", () => { }); }), ).pipe(Layer.provide(projectionSnapshotLayer)); + // Real clock plus an offset the test can advance, so delivery pacing in + // ingestion can be driven without sleeping. Sleeps stay real. + let clockOffsetMs = 0; + const realClock = Effect.runSync(Effect.service(Clock.Clock)); + const shiftedClock: Clock.Clock = { + currentTimeMillisUnsafe: () => realClock.currentTimeMillisUnsafe() + clockOffsetMs, + currentTimeMillis: Effect.sync(() => realClock.currentTimeMillisUnsafe() + clockOffsetMs), + currentTimeNanosUnsafe: () => + realClock.currentTimeNanosUnsafe() + BigInt(clockOffsetMs) * 1_000_000n, + currentTimeNanos: Effect.sync( + () => realClock.currentTimeNanosUnsafe() + BigInt(clockOffsetMs) * 1_000_000n, + ), + monotonicTimeNanosUnsafe: () => realClock.monotonicTimeNanosUnsafe(), + monotonicTimeNanos: realClock.monotonicTimeNanos, + sleep: (duration) => realClock.sleep(duration), + }; const layer = ProviderRuntimeIngestionLive.pipe( + Layer.provide(Layer.succeed(Clock.Clock, shiftedClock)), Layer.provideMerge(orchestrationLayer), Layer.provideMerge(ingestionProjectionSnapshotLayer), // Single shared liveness instance across ingestion (writer), the @@ -392,6 +412,9 @@ describe("ProviderRuntimeIngestion", () => { .pipe(Effect.map(Option.getOrThrow)), ), emit: provider.emit, + advanceClock: (ms: number) => { + clockOffsetMs += ms; + }, emitAndDrain, sqlCount: sqlCounter.count, setProviderSession: provider.setSession, @@ -3129,6 +3152,145 @@ describe("ProviderRuntimeIngestion", () => { expect(finalMessage?.streaming).toBe(false); }); + it("delivers finished paragraphs while the rest of the message stays buffered", async () => { + const harness = await createHarness(); + const now = "2026-01-01T00:00:00.000Z"; + const codex = ProviderDriverKind.make("codex"); + const threadId = asThreadId("thread-1"); + const turnId = asTurnId("turn-paragraph-flush"); + const itemId = asItemId("item-paragraph-flush"); + + harness.emit({ + type: "turn.started", + eventId: asEventId("evt-paragraph-started"), + provider: codex, + createdAt: now, + threadId, + turnId, + }); + await waitForThread( + harness.readModel, + (thread) => thread.session?.status === "running" && thread.session?.activeTurnId === turnId, + ); + + // Each delta lands well outside the pacing window of the one before. + const emitDelta = (eventId: string, delta: string) => { + harness.advanceClock(1_000); + harness.emit({ + type: "content.delta", + eventId: asEventId(eventId), + provider: codex, + createdAt: now, + threadId, + turnId, + itemId, + payload: { streamKind: "assistant_text", delta }, + }); + }; + + emitDelta("evt-paragraph-1", "First paragraph.\n\nSecond para"); + const afterFirst = await waitForThread(harness.readModel, (thread) => + thread.messages.some( + (message: ProviderRuntimeTestMessage) => message.id === `assistant:${itemId}`, + ), + ); + expect( + afterFirst.messages.find((m: ProviderRuntimeTestMessage) => m.id === `assistant:${itemId}`), + ).toMatchObject({ + text: "First paragraph.\n\n", + streaming: true, + }); + + // An open code block holds the whole block until its closing fence lands. + emitDelta("evt-paragraph-2", "graph.\n\n```ts\nconst a = 1;\n\nconst b = 2;\n"); + await harness.drain(); + expect( + (await harness.readModel()).threads + .find((t) => t.id === threadId) + ?.messages.find((m: ProviderRuntimeTestMessage) => m.id === `assistant:${itemId}`)?.text, + ).toBe("First paragraph.\n\nSecond paragraph.\n\n"); + + emitDelta("evt-paragraph-3", "```\n\nTail without newline"); + harness.emit({ + type: "item.completed", + eventId: asEventId("evt-paragraph-completed"), + provider: codex, + createdAt: now, + threadId, + turnId, + itemId, + payload: { itemType: "assistant_message", status: "completed" }, + }); + const finalThread = await waitForThread(harness.readModel, (thread) => + thread.messages.some( + (message: ProviderRuntimeTestMessage) => + message.id === `assistant:${itemId}` && !message.streaming, + ), + ); + expect( + finalThread.messages.find((m: ProviderRuntimeTestMessage) => m.id === `assistant:${itemId}`) + ?.text, + ).toBe( + "First paragraph.\n\nSecond paragraph.\n\n```ts\nconst a = 1;\n\nconst b = 2;\n```\n\nTail without newline", + ); + }); + + it("holds paragraphs that finish inside the pacing window and lands them together", async () => { + const harness = await createHarness(); + const codex = ProviderDriverKind.make("codex"); + const threadId = asThreadId("thread-1"); + const turnId = asTurnId("turn-paced"); + const itemId = asItemId("item-paced"); + // Every delta carries the same event time, like OpenCode does for one + // part. Pacing must follow the server clock, not the event stamp. + const now = "2026-01-01T00:00:00.000Z"; + + harness.emit({ + type: "turn.started", + eventId: asEventId("evt-paced-started"), + provider: codex, + createdAt: now, + threadId, + turnId, + }); + await waitForThread( + harness.readModel, + (thread) => thread.session?.status === "running" && thread.session?.activeTurnId === turnId, + ); + // Emit is fire-and-forget, so drain after each delta before moving the + // clock. Otherwise the worker reads a clock that has already advanced. + let clockMs = 0; + const emitDelta = async (eventId: string, delta: string, offsetMs: number) => { + harness.advanceClock(offsetMs - clockMs); + clockMs = offsetMs; + await harness.emitAndDrain([ + { + type: "content.delta", + eventId: asEventId(eventId), + provider: codex, + createdAt: now, + threadId, + turnId, + itemId, + payload: { streamKind: "assistant_text", delta }, + }, + ]); + }; + const messageText = async () => + (await harness.readModel()).threads + .find((t) => t.id === threadId) + ?.messages.find((m: ProviderRuntimeTestMessage) => m.id === `assistant:${itemId}`)?.text; + + await emitDelta("evt-paced-1", "One.\n\n", 0); + await emitDelta("evt-paced-2", "Two.\n\n", 100); + await emitDelta("evt-paced-3", "Three.\n\n", 200); + // The first paragraph lands right away. The next two are inside the window. + expect(await messageText()).toBe("One.\n\n"); + + await emitDelta("evt-paced-4", "Four.\n\n", 500); + expect(await messageText()).toBe("One.\n\nTwo.\n\nThree.\n\nFour.\n\n"); + }); + it("spills oversized buffered deltas and still finalizes full assistant text", async () => { const harness = await createHarness(); const now = "2026-01-01T00:00:00.000Z"; @@ -4367,3 +4529,70 @@ describe("ProviderRuntimeIngestion", () => { expect(thread.session?.lastError).toBe("runtime still processed"); }); }); + +describe("splitBufferedAssistantText", () => { + it("keeps a partial trailing line buffered", () => { + expect(splitBufferedAssistantText("one\n\ntwo")).toEqual({ ready: "one\n\n", rest: "two" }); + expect(splitBufferedAssistantText("one\ntwo")).toEqual({ ready: "", rest: "one\ntwo" }); + }); + + it("does not split inside an open fence and delivers the block at its closing fence", () => { + const open = "intro\n\n```\ncode\n\nmore\n"; + expect(splitBufferedAssistantText(open)).toEqual({ + ready: "intro\n\n", + rest: "```\ncode\n\nmore\n", + }); + expect(splitBufferedAssistantText(`${open}\`\`\`\nafter`)).toEqual({ + ready: `${open}\`\`\`\n`, + rest: "after", + }); + }); + + it("does not treat a fence with an info string as a closing fence", () => { + const text = "```\n```javascript\nstill code\n\nmore\n"; + expect(splitBufferedAssistantText(text)).toEqual({ ready: "", rest: text }); + }); + + it("treats a fence indented four or more spaces as code, not a closing fence", () => { + const text = "```\n ```\n\nstill code\n"; + expect(splitBufferedAssistantText(text)).toEqual({ ready: "", rest: text }); + expect(splitBufferedAssistantText("```\n ```\nafter")).toEqual({ + ready: "```\n ```\n", + rest: "after", + }); + }); + + it("keeps a fence nested under a list item open across its blank lines", () => { + const text = "- step\n\n ```ts\n a\n\n b\n ```\n\nafter\n"; + expect(splitBufferedAssistantText(text)).toEqual({ + ready: "- step\n\n ```ts\n a\n\n b\n ```\n\n", + rest: "after\n", + }); + }); + + it("does not treat a no-break-space line as blank", () => { + expect(splitBufferedAssistantText("para\n\u00a0\ncont\n\nnext")).toEqual({ + ready: "para\n\u00a0\ncont\n\n", + rest: "next", + }); + }); + + it("treats CRLF blank lines as boundaries", () => { + expect(splitBufferedAssistantText("one\r\n\r\ntwo")).toEqual({ + ready: "one\r\n\r\n", + rest: "two", + }); + }); + + it("only closes a fence with the same marker of equal or greater length", () => { + const text = "````\n```\nstill code\n\n````\n\nout\n"; + expect(splitBufferedAssistantText(text)).toEqual({ + ready: "````\n```\nstill code\n\n````\n\n", + rest: "out\n", + }); + expect(splitBufferedAssistantText("~~~\n```\n\nx\n")).toEqual({ + ready: "", + rest: "~~~\n```\n\nx\n", + }); + }); +}); diff --git a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts index 964f60d3a30645d817c8c299c2fe3a63f15f388f..8126537bd9f7fdbd9df987badfffa5d4a1bc9c2b 100644 --- a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts +++ b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts @@ -19,6 +19,7 @@ import { } from "@t3tools/contracts"; import * as Cache from "effect/Cache"; import * as Cause from "effect/Cause"; +import * as Clock from "effect/Clock"; import * as Crypto from "effect/Crypto"; import * as DateTime from "effect/DateTime"; import * as Duration from "effect/Duration"; @@ -107,6 +108,11 @@ const BUFFERED_PROPOSED_PLAN_BY_ID_TTL = Duration.minutes(120); const TASK_DESCRIPTION_BY_TASK_CACHE_CAPACITY = 10_000; const TASK_DESCRIPTION_BY_TASK_TTL = Duration.minutes(120); const MAX_BUFFERED_ASSISTANT_CHARS = 24_000; +// Paragraphs that finish within this window after a delivery stay buffered +// and land together on the next one. Keeps fast models from repainting the +// message several times a second while still showing the first paragraph +// as soon as it is done. +const MIN_ASSISTANT_DELIVERY_INTERVAL_MS = 400; const STRICT_PROVIDER_LIFECYCLE_GUARD = process.env.T3CODE_STRICT_PROVIDER_LIFECYCLE_GUARD !== "0"; type TurnStartRequestedDomainEvent = Extract< @@ -180,6 +186,58 @@ function hasRenderableAssistantText(text: string | undefined): boolean { return (text?.trim().length ?? 0) > 0; } +// An opening fence may sit at any indentation, since fences inside list +// items are indented past the marker. A closing fence may be indented at most +// three spaces more than its opener. Deeper lines are content in the block. +const MARKDOWN_FENCE_PATTERN = /^( *)(`{3,}|~{3,})/; +// CommonMark blank lines hold only spaces and tabs. Other whitespace, such as +// a no-break space, is paragraph content. +const BLANK_LINE_PATTERN = /^[ \t]*$/; + +/** + * Splits buffered assistant text at the last blank line or closing code fence + * that is not inside an open fenced code block. `ready` is safe to deliver now + * because the markdown before it will not change shape as more text arrives. + * `rest` stays buffered until the next boundary or completion. Only fully + * terminated lines count, so a trailing partial line never leaks. + */ +export function splitBufferedAssistantText(text: string): { ready: string; rest: string } { + let openFence: { marker: string; indent: number } | null = null; + let boundary = -1; + let lineStart = 0; + for (;;) { + const newline = text.indexOf("\n", lineStart); + if (newline === -1) { + break; + } + const line = text.slice(lineStart, newline).replace(/[ \t\r]+$/, ""); + const fenceMatch = MARKDOWN_FENCE_PATTERN.exec(line); + if (fenceMatch) { + const indent = fenceMatch[1]!.length; + const marker = fenceMatch[2]!; + if (openFence === null) { + openFence = { marker, indent }; + } else if ( + marker[0] === openFence.marker[0] && + marker.length >= openFence.marker.length && + indent <= openFence.indent + 3 && + line.length === indent + marker.length + ) { + // CommonMark: a closing fence carries no info string. + openFence = null; + boundary = newline + 1; + } + } else if (openFence === null && BLANK_LINE_PATTERN.test(line) && lineStart > 0) { + boundary = newline + 1; + } + lineStart = newline + 1; + } + if (boundary === -1) { + return { ready: "", rest: text }; + } + return { ready: text.slice(0, boundary), rest: text.slice(boundary) }; +} + function proposedPlanIdForTurn(threadId: ThreadId, turnId: TurnId): string { return `plan:${threadId}:turn:${turnId}`; } @@ -927,6 +985,12 @@ const make = Effect.gen(function* () { timeToLive: BUFFERED_MESSAGE_TEXT_BY_MESSAGE_ID_TTL, lookup: () => Effect.succeed(""), }); + // Epoch millis of the last early delivery per message, for pacing. + const lastAssistantDeliveryAtByMessageId = yield* Cache.make({ + capacity: BUFFERED_MESSAGE_TEXT_BY_MESSAGE_ID_CACHE_CAPACITY, + timeToLive: BUFFERED_MESSAGE_TEXT_BY_MESSAGE_ID_TTL, + lookup: () => Effect.succeed(0), + }); const assistantSegmentStateByTurnKey = yield* Cache.make({ capacity: TURN_MESSAGE_IDS_BY_TURN_CACHE_CAPACITY, @@ -1103,7 +1167,7 @@ const make = Effect.gen(function* () { }); }); - const appendBufferedAssistantText = (messageId: MessageId, delta: string) => + const appendBufferedAssistantText = (messageId: MessageId, delta: string, atMillis: number) => Cache.getOption(bufferedAssistantTextByMessageId, messageId).pipe( Effect.flatMap((existingText) => Effect.gen(function* () { @@ -1111,6 +1175,30 @@ const make = Effect.gen(function* () { onNone: () => delta, onSome: (text) => `${text}${delta}`, }); + + // Deliver finished paragraphs and closed code blocks early so the + // user sees progress without token-by-token repaints. + const { ready, rest } = splitBufferedAssistantText(nextText); + const lastDeliveredAt = Option.getOrUndefined( + yield* Cache.getOption(lastAssistantDeliveryAtByMessageId, messageId), + ); + const paced = + lastDeliveredAt === undefined || + atMillis - lastDeliveredAt >= MIN_ASSISTANT_DELIVERY_INTERVAL_MS; + if ( + paced && + hasRenderableAssistantText(ready) && + rest.length <= MAX_BUFFERED_ASSISTANT_CHARS + ) { + if (rest.length > 0) { + yield* Cache.set(bufferedAssistantTextByMessageId, messageId, rest); + } else { + yield* Cache.invalidate(bufferedAssistantTextByMessageId, messageId); + } + yield* Cache.set(lastAssistantDeliveryAtByMessageId, messageId, atMillis); + return ready; + } + if (nextText.length <= MAX_BUFFERED_ASSISTANT_CHARS) { yield* Cache.set(bufferedAssistantTextByMessageId, messageId, nextText); return ""; @@ -1133,7 +1221,9 @@ const make = Effect.gen(function* () { ); const clearBufferedAssistantText = (messageId: MessageId) => - Cache.invalidate(bufferedAssistantTextByMessageId, messageId); + Cache.invalidate(bufferedAssistantTextByMessageId, messageId).pipe( + Effect.andThen(Cache.invalidate(lastAssistantDeliveryAtByMessageId, messageId)), + ); const appendBufferedProposedPlan = (planId: string, delta: string, createdAt: string) => Cache.getOption(bufferedProposedPlanById, planId).pipe( @@ -1675,7 +1765,13 @@ const make = Effect.gen(function* () { : "buffered", ); if (assistantDeliveryMode === "buffered") { - const spillChunk = yield* appendBufferedAssistantText(assistantMessageId, assistantDelta); + // Pace on the server clock. OpenCode stamps every delta of a part + // with the part's start time, so the event time cannot measure gaps. + const spillChunk = yield* appendBufferedAssistantText( + assistantMessageId, + assistantDelta, + yield* Clock.currentTimeMillis, + ); if (spillChunk.length > 0) { yield* orchestrationEngine.dispatch({ type: "thread.message.assistant.delta", diff --git a/apps/web/src/components/ChatMarkdown.tsx b/apps/web/src/components/ChatMarkdown.tsx index 3aeaaa5b8443c3260c320ab8d9e5b8ff4fecb3ba..b0209754b0d0b0216e05c65790ea5f27c8d91841 100644 --- a/apps/web/src/components/ChatMarkdown.tsx +++ b/apps/web/src/components/ChatMarkdown.tsx @@ -3196,7 +3196,15 @@ const CHAT_MARKDOWN_COMPONENTS = { resetKeys={[codeBlock.code, language, diffThemeName, isStreaming]} fallback={
{children}
} > - {children}}> + {/* Reserve the block's height but stay hidden until Shiki has colored + it, so plain text never flashes before the highlighted version. */} + + {children} + + } + > diff --git a/apps/web/src/components/chat/MessagesTimeline.test.tsx b/apps/web/src/components/chat/MessagesTimeline.test.tsx index 6991f443259492fe6fbf4d84f8182040f48b3c71..8f982064869b1a5102cfcc3481448a79421ae009 100644 --- a/apps/web/src/components/chat/MessagesTimeline.test.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.test.tsx @@ -901,6 +901,93 @@ describe("MessagesTimeline", () => { expect(markup).not.toContain(" { + const entries = [buildUserTimelineEntry("Hello")]; + const working = renderToStaticMarkup( + , + ); + expect(working).toContain('data-maintain-scroll-at-end-animated="true"'); + + const idle = renderToStaticMarkup( + , + ); + expect(idle).toContain('data-maintain-scroll-at-end-animated="false"'); + }); + + it("snaps to the end while a thread switch settles, even mid-turn", async () => { + const frames = new Map(); + let nextFrame = 0; + vi.stubGlobal("requestAnimationFrame", (callback: FrameRequestCallback) => { + frames.set(++nextFrame, callback); + return nextFrame; + }); + vi.stubGlobal("cancelAnimationFrame", (frame: number) => frames.delete(frame)); + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + const flushFrame = () => + act(() => { + const callbacks = [...frames.values()]; + frames.clear(); + callbacks.forEach((callback) => callback(0)); + }); + // A work entry renders without the DOM globals that message rows need + // under react-test-renderer. + const entries = [ + { + id: "entry-settle-work", + kind: "work" as const, + createdAt: MESSAGE_CREATED_AT, + entry: { + id: "work-settle", + createdAt: MESSAGE_CREATED_AT, + toolCallId: "call-settle", + label: "Run lint", + tone: "tool" as const, + itemType: "command_execution" as const, + command: "pnpm lint", + toolLifecycleStatus: "completed" as const, + }, + }, + ]; + const animatedAttr = (renderer: ReactTestRenderer) => + renderer.root.findByProps({ "data-testid": "legend-list" }).props[ + "data-maintain-scroll-at-end-animated" + ]; + let renderer!: ReactTestRenderer; + try { + act(() => { + renderer = create( + , + ); + }); + expect(animatedAttr(renderer)).toBe(true); + + act(() => { + renderer.update( + , + ); + }); + expect(animatedAttr(renderer)).toBe(false); + + // Two frames later the switch has settled and gliding resumes. + flushFrame(); + flushFrame(); + expect(animatedAttr(renderer)).toBe(true); + } finally { + act(() => renderer?.unmount()); + vi.unstubAllGlobals(); + } + }); + it("keeps reserved end space when tool work starts while reading history", () => { const turnId = TurnId.make("turn-with-active-tool"); const firstEntry = buildUserTimelineEntry("Run the command."); diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index f388db6044503d9e4b0718a91016365fb6ba1a05..084287080bd9819cc0804a19fe9b5a323f25b6f9 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -227,6 +227,7 @@ import { import { createContextPresentationRegistry } from "../contextPresentationRegistry"; import { useOpenPrLink } from "~/lib/openPullRequestLink"; import type { ChatMarkdownContextReference } from "../ChatMarkdown"; +import { useMediaQuery } from "~/hooks/useMediaQuery"; import { cn } from "~/lib/utils"; import { useUiStateStore } from "~/uiStateStore"; import { type TimestampFormat } from "@t3tools/contracts/settings"; @@ -348,6 +349,13 @@ const TIMELINE_MAINTAIN_SCROLL_AT_END = { layout: true, }, } as const satisfies MaintainScrollAtEndOptions; +// Streamed text lands a paragraph at a time. A smooth scroll to the end +// turns each landing into a short glide instead of a jump. Thread switches +// and layout settles keep the instant variant so nothing visibly travels. +const TIMELINE_MAINTAIN_SCROLL_AT_END_SMOOTH = { + ...TIMELINE_MAINTAIN_SCROLL_AT_END, + animated: true, +} as const satisfies MaintainScrollAtEndOptions; // --------------------------------------------------------------------------- // Props (public API) @@ -470,14 +478,19 @@ export const MessagesTimeline = memo(function MessagesTimeline({ new Set(), ); const listIdentityKey = displayThreadKey ?? routeThreadKey; + const prefersReducedMotion = useMediaQuery("(prefers-reduced-motion: reduce)"); const listIdentityRef = useRef(listIdentityKey); const previousLatestTurnRef = useRef(latestTurn); + // The list stays mounted across thread switches. Its first end pins on the + // new thread must snap, not glide, even if that thread is mid-turn. + const [settlingListIdentity, setSettlingListIdentity] = useState(null); let paintedExpandedTurnIds = expandedTurnIds; let paintedExpandedWorkGroupIds = expandedWorkGroupIds; let paintedExpandedSpawnEntryIds = expandedSpawnEntryIds; if (listIdentityRef.current !== listIdentityKey) { listIdentityRef.current = listIdentityKey; previousLatestTurnRef.current = latestTurn; + setSettlingListIdentity(listIdentityKey); paintedExpandedTurnIds = new Set(); paintedExpandedWorkGroupIds = new Set(); paintedExpandedSpawnEntryIds = new Set(); @@ -522,6 +535,21 @@ export const MessagesTimeline = memo(function MessagesTimeline({ }; }, []); + useEffect(() => { + if (settlingListIdentity === null) return; + // Two frames covers the fresh-data layout pass and the initial end pin. + let second: number | null = null; + const first = requestAnimationFrame(() => { + second = requestAnimationFrame(() => { + setSettlingListIdentity((current) => (current === settlingListIdentity ? null : current)); + }); + }); + return () => { + cancelAnimationFrame(first); + if (second !== null) cancelAnimationFrame(second); + }; + }, [settlingListIdentity]); + const suspendEndScrollMaintenanceForDisclosure = useCallback( (anchorKey: string, collapsed = false) => { disclosureAnchorKeyRef.current = anchorKey; @@ -977,7 +1005,9 @@ export const MessagesTimeline = memo(function MessagesTimeline({ !liveFollowEnabled || disclosureToggleSettling ? false - : TIMELINE_MAINTAIN_SCROLL_AT_END + : isWorking && !prefersReducedMotion && settlingListIdentity === null + ? TIMELINE_MAINTAIN_SCROLL_AT_END_SMOOTH + : TIMELINE_MAINTAIN_SCROLL_AT_END } maintainVisibleContentPosition={ citationPositioning ? false : maintainVisibleContentPosition diff --git a/apps/web/src/index.css b/apps/web/src/index.css index a7f6d91f2e40463a071b17330b3c3d4c2a3e4d7e..e7c5d919a211036a5f2b1d232456997df4d498ed 100644 --- a/apps/web/src/index.css +++ b/apps/web/src/index.css @@ -1891,6 +1891,22 @@ code { background: transparent !important; } +/* Paragraphs and code blocks arrive in chunks while a response streams. Fade + each new block in so the chunk does not pop. @starting-style only applies + when an element is first inserted, and the rule is gated on data-streaming, + so opening a finished thread never replays the fade. Opacity only, one + shot, no layout change. */ +@media (prefers-reduced-motion: no-preference) { + .chat-markdown[data-streaming] > *, + .chat-markdown[data-streaming] .chat-markdown-shiki { + transition: opacity 600ms ease-out; + + @starting-style { + opacity: 0; + } + } +} + /* Diagnostics-style tables: row separators only, uppercase headers, and a scroll-fade container for horizontal overflow. The root chat-markdown wrapping rules (overflow-wrap: anywhere) would let columns shrink to single diff --git a/patches/@legendapp__list@3.3.5.patch b/patches/@legendapp__list@3.3.5.patch index 98784b999549213a231a49a75e2603e1029f4f1e..88b739824b01e3f4cf88ad5e10441e2052c7168f 100644 --- a/patches/@legendapp__list@3.3.5.patch +++ b/patches/@legendapp__list@3.3.5.patch @@ -1,5 +1,5 @@ diff --git a/keyboard.d.ts b/keyboard.d.ts -index 367945cdfa8a8c260b7a127657a75c016c9ab46f..0ac7bf8b8e9c386058b2374199e58fe8c92d39b3 100644 +index 367945c..0ac7bf8 100644 --- a/keyboard.d.ts +++ b/keyboard.d.ts @@ -279,7 +279,7 @@ type KeyboardChatComposerInsetListRef = { @@ -23,7 +23,7 @@ index 367945cdfa8a8c260b7a127657a75c016c9ab46f..0ac7bf8b8e9c386058b2374199e58fe8 } & React.RefAttributes) => React.ReactElement | null; diff --git a/keyboard.js b/keyboard.js -index 6645bcbb1f77c36c432035eaf8b2d6d924fc8bda..321c4c305129dd3ce3f06127c47d51720a73fb83 100644 +index 6645bcb..321c4c3 100644 --- a/keyboard.js +++ b/keyboard.js @@ -33,19 +33,22 @@ if (typeof __DEV__ !== "undefined" && __DEV__ && !reactNativeKeyboardController. @@ -119,7 +119,7 @@ index 6645bcbb1f77c36c432035eaf8b2d6d924fc8bda..321c4c305129dd3ce3f06127c47d5172 renderScrollComponent: memoList, ...rest diff --git a/keyboard.mjs b/keyboard.mjs -index 87b38b9607c2eba6b407c2acfd520633bdb0e7c2..111eb242ba9d9ade1cb912550f7df85cf4f361ee 100644 +index 87b38b9..111eb24 100644 --- a/keyboard.mjs +++ b/keyboard.mjs @@ -1,7 +1,7 @@ @@ -224,7 +224,7 @@ index 87b38b9607c2eba6b407c2acfd520633bdb0e7c2..111eb242ba9d9ade1cb912550f7df85c renderScrollComponent: memoList, ...rest diff --git a/react-native.d.ts b/react-native.d.ts -index ce1fe00001c9e5aee6c6ea8bb2d4757d4586d002..3ccf6f16067152dfcb0c143371e2ec6aba6636e5 100644 +index ce1fe00..3ccf6f1 100644 --- a/react-native.d.ts +++ b/react-native.d.ts @@ -293,6 +293,12 @@ interface LegendListSpecificProps { @@ -241,13 +241,13 @@ index ce1fe00001c9e5aee6c6ea8bb2d4757d4586d002..3ccf6f16067152dfcb0c143371e2ec6a * Number of columns to render items in. * @default 1 diff --git a/react-native.js b/react-native.js -index b3c5a306b293f797a8b338adfca3060c0f6db22b..5ac6bbe8fe40bf252fefa6b885c2196d0677d75f 100644 +index b3c5a30..5ac6bbe 100644 --- a/react-native.js +++ b/react-native.js @@ -717,6 +717,15 @@ function hasActiveInitialScroll(state) { return !!(state == null ? void 0 : state.initialScroll) && !state.didFinishInitialScroll; } - + +// Size-only changes may not emit a scroll event to refresh the edge signal. +function getIsAtEnd(ctx, contentSize = getContentSize(ctx)) { + const { queuedInitialLayout, scroll, scrollLength } = ctx.state; @@ -809,13 +809,13 @@ index b3c5a306b293f797a8b338adfca3060c0f6db22b..5ac6bbe8fe40bf252fefa6b885c2196d recycleItems, refreshControl: refreshControlElement ? stylePaddingTopState > 0 ? React2__namespace.cloneElement(refreshControlElement, { diff --git a/react-native.mjs b/react-native.mjs -index 40e87cda8c9bc79a889e5542f29af429a24b24d4..93aac741d2cf77ce35996362439108176f19da7a 100644 +index 40e87cd..93aac74 100644 --- a/react-native.mjs +++ b/react-native.mjs @@ -696,6 +696,15 @@ function hasActiveInitialScroll(state) { return !!(state == null ? void 0 : state.initialScroll) && !state.didFinishInitialScroll; } - + +// Size-only changes may not emit a scroll event to refresh the edge signal. +function getIsAtEnd(ctx, contentSize = getContentSize(ctx)) { + const { queuedInitialLayout, scroll, scrollLength } = ctx.state; @@ -1376,8 +1376,78 @@ index 40e87cda8c9bc79a889e5542f29af429a24b24d4..93aac741d2cf77ce3599636243910817 onScroll: onScrollHandler, recycleItems, refreshControl: refreshControlElement ? stylePaddingTopState > 0 ? React2.cloneElement(refreshControlElement, { +diff --git a/react-native.web.js b/react-native.web.js +index 914d2da..8b88b09 100644 +--- a/react-native.web.js ++++ b/react-native.web.js +@@ -5651,6 +5651,18 @@ var ContainerSlot = typedMemo(function ContainerSlot2(props) { + }); + + // src/utils/reordering.ts ++// Element.moveBefore keeps the moved subtree's state and does not count as an ++// insertion, so CSS @starting-style transitions and iframes inside a row do not ++// restart when the list reorders its containers. insertBefore is the fallback. ++function moveChildBefore(container, element, reference) { ++ if (typeof container.moveBefore === "function") { ++ container.moveBefore(element, reference); ++ } else if (reference) { ++ container.insertBefore(element, reference); ++ } else { ++ container.appendChild(element); ++ } ++} + function sortDOMElements(container, indexByElement) { + const elements = Array.from(container.children); + if (elements.length <= 1) return elements; +@@ -5690,9 +5702,9 @@ function sortDOMElements(container, indexByElement) { + } + } + if (nextStableElement) { +- container.insertBefore(element, nextStableElement); ++ moveChildBefore(container, element, nextStableElement); + } else { +- container.appendChild(element); ++ moveChildBefore(container, element, null); + } + } + } +diff --git a/react-native.web.mjs b/react-native.web.mjs +index 95465f2..d7cbe1c 100644 +--- a/react-native.web.mjs ++++ b/react-native.web.mjs +@@ -5630,6 +5630,18 @@ var ContainerSlot = typedMemo(function ContainerSlot2(props) { + }); + + // src/utils/reordering.ts ++// Element.moveBefore keeps the moved subtree's state and does not count as an ++// insertion, so CSS @starting-style transitions and iframes inside a row do not ++// restart when the list reorders its containers. insertBefore is the fallback. ++function moveChildBefore(container, element, reference) { ++ if (typeof container.moveBefore === "function") { ++ container.moveBefore(element, reference); ++ } else if (reference) { ++ container.insertBefore(element, reference); ++ } else { ++ container.appendChild(element); ++ } ++} + function sortDOMElements(container, indexByElement) { + const elements = Array.from(container.children); + if (elements.length <= 1) return elements; +@@ -5669,9 +5681,9 @@ function sortDOMElements(container, indexByElement) { + } + } + if (nextStableElement) { +- container.insertBefore(element, nextStableElement); ++ moveChildBefore(container, element, nextStableElement); + } else { +- container.appendChild(element); ++ moveChildBefore(container, element, null); + } + } + } diff --git a/react.js b/react.js -index 914d2dafaafa001c9ab6791a0a0581659295e333..a2df18d450eebe451fda42bab47d1658458edbb1 100644 +index 914d2da..0c2917d 100644 --- a/react.js +++ b/react.js @@ -4702,7 +4702,8 @@ function maybeUpdateAnchoredEndSpace(ctx) { @@ -1403,7 +1473,38 @@ index 914d2dafaafa001c9ab6791a0a0581659295e333..a2df18d450eebe451fda42bab47d1658 } return nextSize; } -@@ -6446,8 +6453,8 @@ function ScrollAdjust() { +@@ -5651,6 +5658,18 @@ var ContainerSlot = typedMemo(function ContainerSlot2(props) { + }); + + // src/utils/reordering.ts ++// Element.moveBefore keeps the moved subtree's state and does not count as an ++// insertion, so CSS @starting-style transitions and iframes inside a row do not ++// restart when the list reorders its containers. insertBefore is the fallback. ++function moveChildBefore(container, element, reference) { ++ if (typeof container.moveBefore === "function") { ++ container.moveBefore(element, reference); ++ } else if (reference) { ++ container.insertBefore(element, reference); ++ } else { ++ container.appendChild(element); ++ } ++} + function sortDOMElements(container, indexByElement) { + const elements = Array.from(container.children); + if (elements.length <= 1) return elements; +@@ -5690,9 +5709,9 @@ function sortDOMElements(container, indexByElement) { + } + } + if (nextStableElement) { +- container.insertBefore(element, nextStableElement); ++ moveChildBefore(container, element, nextStableElement); + } else { +- container.appendChild(element); ++ moveChildBefore(container, element, null); + } + } + } +@@ -6446,8 +6465,8 @@ function ScrollAdjust() { window.getComputedStyle(contentNode)[axis.paddingEndProp] ); const temporaryPaddingEnd = `${(currentPaddingEnd || 0) + pad}px`; @@ -1414,7 +1515,7 @@ index 914d2dafaafa001c9ab6791a0a0581659295e333..a2df18d450eebe451fda42bab47d1658 scrollBy(); if (resetPaddingRafRef.current !== void 0) { diff --git a/react.mjs b/react.mjs -index 95465f2ab89ce41a10553f58af83618f7310e83c..25cf046f2c3141ddce5a0b6e28c354b865331b4f 100644 +index 95465f2..b73bfdf 100644 --- a/react.mjs +++ b/react.mjs @@ -4681,7 +4681,8 @@ function maybeUpdateAnchoredEndSpace(ctx) { @@ -1440,7 +1541,38 @@ index 95465f2ab89ce41a10553f58af83618f7310e83c..25cf046f2c3141ddce5a0b6e28c354b8 } return nextSize; } -@@ -6425,8 +6432,8 @@ function ScrollAdjust() { +@@ -5630,6 +5637,18 @@ var ContainerSlot = typedMemo(function ContainerSlot2(props) { + }); + + // src/utils/reordering.ts ++// Element.moveBefore keeps the moved subtree's state and does not count as an ++// insertion, so CSS @starting-style transitions and iframes inside a row do not ++// restart when the list reorders its containers. insertBefore is the fallback. ++function moveChildBefore(container, element, reference) { ++ if (typeof container.moveBefore === "function") { ++ container.moveBefore(element, reference); ++ } else if (reference) { ++ container.insertBefore(element, reference); ++ } else { ++ container.appendChild(element); ++ } ++} + function sortDOMElements(container, indexByElement) { + const elements = Array.from(container.children); + if (elements.length <= 1) return elements; +@@ -5669,9 +5688,9 @@ function sortDOMElements(container, indexByElement) { + } + } + if (nextStableElement) { +- container.insertBefore(element, nextStableElement); ++ moveChildBefore(container, element, nextStableElement); + } else { +- container.appendChild(element); ++ moveChildBefore(container, element, null); + } + } + } +@@ -6425,8 +6444,8 @@ function ScrollAdjust() { window.getComputedStyle(contentNode)[axis.paddingEndProp] ); const temporaryPaddingEnd = `${(currentPaddingEnd || 0) + pad}px`; @@ -1451,7 +1583,7 @@ index 95465f2ab89ce41a10553f58af83618f7310e83c..25cf046f2c3141ddce5a0b6e28c354b8 scrollBy(); if (resetPaddingRafRef.current !== void 0) { diff --git a/reanimated.d.ts b/reanimated.d.ts -index e5043320700b12f34f4c0babbc341f85ca8135c1..2ce63830a28636b21937a0741fd0e613dae950fe 100644 +index e504332..2ce6383 100644 --- a/reanimated.d.ts +++ b/reanimated.d.ts @@ -294,6 +294,12 @@ interface LegendListSpecificProps { @@ -1468,7 +1600,7 @@ index e5043320700b12f34f4c0babbc341f85ca8135c1..2ce63830a28636b21937a0741fd0e613 * Number of columns to render items in. * @default 1 diff --git a/reanimated.js b/reanimated.js -index f1265fad74189591b5aae86cf2e3a31f9c0fdb02..fc03be2190c046f743ffde45ed189ee7868d0cec 100644 +index f1265fa..fc03be2 100644 --- a/reanimated.js +++ b/reanimated.js @@ -115,8 +115,10 @@ var ReanimatedPositionView = typedMemo(function ReanimatedPositionViewComponent( @@ -1631,7 +1763,7 @@ index f1265fad74189591b5aae86cf2e3a31f9c0fdb02..fc03be2190c046f743ffde45ed189ee7 renderScrollComponent: renderReanimatedScrollComponent, ...IsNewArchitecture ? { stickyPositionComponentInternal } : {} diff --git a/reanimated.mjs b/reanimated.mjs -index 29a00d5dc084fd408a0e0a0a4d64e856d0ed1525..6ab929250c8924e2d9919ee09a4f774238fbb90a 100644 +index 29a00d5..6ab9292 100644 --- a/reanimated.mjs +++ b/reanimated.mjs @@ -1,7 +1,7 @@ diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 06b0578ae05700797c43e6f05e221a8d20827411..26f13005de4bfafe955527c44282e25fbed31493 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -91,7 +91,7 @@ patchedDependencies: '@effect/vitest@4.0.0-rc.112': a16b1e870d8c29e4a98b17cc4c638a4ff471753d8ca3487f78a22b759caf951b '@expo/metro-config@57.0.12': 96f1a75347e6ea02dc4b7034ace815d8ee39e18b8166ebfb573d9e58328f0dc2 '@ff-labs/fff-node@0.9.4': ab9ff544009e1891cfe3930105862d3699007f38922a79f3c98d90018deca368 - '@legendapp/list@3.3.5': a05e968651a1352d2f374324016d1034b763b9fb2c5bf5f95111540d6899a52b + '@legendapp/list@3.3.5': 680cc6a5c5b4a4032e467e7b3fde22f89a84c0ee2e6eac6fda737d6277cc0806 '@pierre/diffs@1.3.0-beta.10': 0ccee155b93b63d810e2c1a40c1fd676fb6fbcfa72cf6430dcedf1a3ae475ab4 '@react-native-ai/apple@0.12.0': 2d09870c2848d185cb05b53ed823a46e12dba519324d8dd8e584e28731990f9d '@react-native-menu/menu@2.0.0': f63d256bf6a97a873b5e628eb595bd6ef0075ddd5bdd890fc920f7a6024290dd @@ -248,7 +248,7 @@ importers: 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) '@legendapp/list': specifier: 'catalog:' - 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) + 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) '@material/material-color-utilities': specifier: 0.3.0 version: 0.3.0 @@ -607,7 +607,7 @@ importers: version: 0.9.0 '@legendapp/list': specifier: 'catalog:' - version: 3.3.5(patch_hash=a05e968651a1352d2f374324016d1034b763b9fb2c5bf5f95111540d6899a52b)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + version: 3.3.5(patch_hash=680cc6a5c5b4a4032e467e7b3fde22f89a84c0ee2e6eac6fda737d6277cc0806)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) '@lexical/react': specifier: ^0.41.0 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: dependencies: jsbi: 4.3.2 - '@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)': + '@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)': dependencies: react: 19.2.3 use-sync-external-store: 1.6.0(react@19.2.3) @@ -14005,7 +14005,7 @@ snapshots: 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) - '@legendapp/list@3.3.5(patch_hash=a05e968651a1352d2f374324016d1034b763b9fb2c5bf5f95111540d6899a52b)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': + '@legendapp/list@3.3.5(patch_hash=680cc6a5c5b4a4032e467e7b3fde22f89a84c0ee2e6eac6fda737d6277cc0806)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': dependencies: react: 19.2.6 use-sync-external-store: 1.6.0(react@19.2.6) -- 2.54.0