Skip to content

Commit fe5cd96

Browse files
perf(web): streamed reply text no longer re-renders ChatView
ChatView subscribed to the live thread projection, so every streamed assistant or reasoning delta re-rendered it and rebuilt the uncached timeline region. client-runtime gains quietThreadAtom and quietVisibleTurnItemsAtom. They hold the previous value while the only change is text streamed into existing assistant and reasoning items. ChatView reads the thread through them. ThreadTimeline reads the live turn items and derives the rows it renders, and the find provider reads the live projection to refresh open results. Co-Authored-By: Claude Opus 5.5 <[email protected]>
1 parent 968dd51 commit fe5cd96

6 files changed

Lines changed: 284 additions & 36 deletions

File tree

‎apps/web/src/components/ChatView.tsx‎

Lines changed: 23 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -26,9 +26,7 @@ import {
2626
isPaintOnlyThreadTimeline,
2727
peekHeldThreadTimeline,
2828
peekRememberedThreadTimeline,
29-
rememberReadyThreadTimeline,
3029
resolveThreadSwitchTimeline,
31-
timelineHasEphemeralPreviewUrls,
3230
recallCheckoutIsRepo,
3331
rememberCheckoutIsRepo,
3432
} from "./ChatView.logic";
@@ -235,11 +233,11 @@ import {
235233
resolveThreadDetailRef,
236234
useEnvironmentSupportsServerBrowser,
237235
useProject,
238-
useThreadProjection,
236+
useQuietThreadProjection,
237+
useQuietThreadVisibleTurnItems,
239238
useThreadStatus,
240239
useThreadHistory,
241240
useQuietThreadShell,
242-
useThreadVisibleTurnItems,
243241
useThreadCheckpointSummaries,
244242
} from "../state/entities";
245243
import { useEnvironmentShellHasSnapshot } from "../state/shell";
@@ -254,7 +252,8 @@ import {
254252
RevertConfirmHost,
255253
} from "./chat/ChatOverlayHosts";
256254
import type { AssistantCitationRequest } from "./chat/AssistantCitationSource";
257-
import { MessagesTimeline, type MessagesTimelineHistoryControls } from "./chat/MessagesTimeline";
255+
import type { MessagesTimelineHistoryControls } from "./chat/MessagesTimeline";
256+
import { ThreadTimeline } from "./chat/ThreadTimeline";
258257
import { ProviderSubagentBar } from "./chat/ProviderSubagentBar";
259258
import { getTriggerDisplayModelName } from "./chat/providerIconUtils";
260259
import {
@@ -461,7 +460,7 @@ export default function ChatView(props: ChatViewProps) {
461460
shellExists: serverThreadShell !== null,
462461
waitForShell: draftThread !== null,
463462
});
464-
const serverThreadProjection = useThreadProjection(routeThreadDetailRef);
463+
const serverThreadProjection = useQuietThreadProjection(routeThreadDetailRef);
465464
const serverProjection = serverThreadProjection?.projection ?? null;
466465
const serverThread =
467466
serverThreadShell !== null || serverProjection === null
@@ -498,7 +497,7 @@ export default function ChatView(props: ChatViewProps) {
498497
serverProjection,
499498
activeProviderThread,
500499
);
501-
const serverVisibleTurnItems = useThreadVisibleTurnItems(routeThreadDetailRef);
500+
const serverVisibleTurnItems = useQuietThreadVisibleTurnItems(routeThreadDetailRef);
502501
const serverThreadHistory = useThreadHistory(routeThreadDetailRef);
503502
const threadHistoryControls = (() => {
504503
if (routeThreadDetailRef === null || !shouldShowLoadEarlierControl(serverThreadHistory)) {
@@ -784,7 +783,7 @@ export default function ChatView(props: ChatViewProps) {
784783
thread: serverProjection.thread,
785784
messages: serverProjection.messages,
786785
});
787-
const workflowOpeningParentProjection = useThreadProjection(
786+
const workflowOpeningParentProjection = useQuietThreadProjection(
788787
workflowOpeningParentId === null || parentSubagentEnvironmentId === null
789788
? null
790789
: scopeThreadRef(parentSubagentEnvironmentId, workflowOpeningParentId),
@@ -1455,8 +1454,7 @@ export default function ChatView(props: ChatViewProps) {
14551454
{ ...codexFeedbackMessage(submission, "assistant"), runId: null },
14561455
],
14571456
);
1458-
const serverTimelineEntries = useIncrementalTimelineEntries(activeThreadKey, {
1459-
visibleTurnItems: serverVisibleTurnItems,
1457+
const timelineEntriesInput = {
14601458
optimisticMessages: optimisticUserMessages,
14611459
anchoredMessages: anchoredTimelineMessages,
14621460
attachmentUrlById: timelineAttachmentUrlById,
@@ -1467,6 +1465,12 @@ export default function ChatView(props: ChatViewProps) {
14671465
nodes: serverProjection.nodes,
14681466
plans: serverProjection.plans,
14691467
}),
1468+
};
1469+
// Held through streamed text, like serverVisibleTurnItems; ThreadTimeline
1470+
// derives the rows it renders from the live turn items.
1471+
const serverTimelineEntries = useIncrementalTimelineEntries(activeThreadKey, {
1472+
...timelineEntriesInput,
1473+
visibleTurnItems: serverVisibleTurnItems,
14701474
});
14711475
const draftTimelineEntries = optimisticUserMessages.map(
14721476
(message) =>
@@ -1636,21 +1640,6 @@ export default function ChatView(props: ChatViewProps) {
16361640
const activeProjectCwd = activeProject?.workspaceRoot ?? null;
16371641
const activeThreadWorktreePath = activeThread?.worktreePath ?? null;
16381642
const activeWorkspaceRoot = activeThreadWorktreePath ?? activeProjectCwd ?? undefined;
1639-
useLayoutEffect(() => {
1640-
if (
1641-
threadDetailLoading ||
1642-
timelineEntries.length === 0 ||
1643-
timelineHasEphemeralPreviewUrls(timelineEntries)
1644-
) {
1645-
return;
1646-
}
1647-
rememberReadyThreadTimeline({
1648-
threadKey: activeThreadKey,
1649-
entries: timelineEntries,
1650-
markdownCwd: gitCwd,
1651-
workspaceRoot: activeWorkspaceRoot ?? null,
1652-
});
1653-
}, [activeThreadKey, activeWorkspaceRoot, gitCwd, threadDetailLoading, timelineEntries]);
16541643
const heldPaintContext = paintOnlyDisplayedTimeline
16551644
? peekHeldThreadTimeline<typeof timelineEntries>()
16561645
: null;
@@ -3213,7 +3202,6 @@ export default function ChatView(props: ChatViewProps) {
32133202
thread: activeThreadRef,
32143203
enabled:
32153204
isServerThread && serverConfig?.threadFind === true && !paintOnlyDisplayedTimeline,
3216-
content: serverProjection ?? undefined,
32173205
}}
32183206
controlsRef={threadFindControlsRef}
32193207
onOpenChange={setIsThreadFindActive}
@@ -3244,7 +3232,15 @@ export default function ChatView(props: ChatViewProps) {
32443232
setThreadErrorBannerDismissTick={setThreadErrorBannerDismissTick}
32453233
/>
32463234
<div className="relative flex min-h-0 flex-1 flex-col bg-background">
3247-
<MessagesTimeline
3235+
<ThreadTimeline
3236+
threadDetailRef={routeThreadDetailRef}
3237+
entriesInput={timelineEntriesInput}
3238+
draftEntries={isServerThread ? null : draftTimelineEntries}
3239+
activeThreadKey={activeThreadKey}
3240+
syncing={threadSyncPhase !== null}
3241+
detailLoading={threadDetailLoading}
3242+
readyMarkdownCwd={gitCwd}
3243+
readyWorkspaceRoot={activeWorkspaceRoot ?? null}
32483244
citationRequest={paintOnlyDisplayedTimeline ? null : citationRequest}
32493245
citationHistoryLoading={threadDetailLoading}
32503246
{...(!paintOnlyDisplayedTimeline
@@ -3298,7 +3294,6 @@ export default function ChatView(props: ChatViewProps) {
32983294
)
32993295
}
33003296
listRef={legendListRef}
3301-
timelineEntries={displayedTimeline.entries}
33023297
runs={paintOnlyDisplayedTimeline ? [] : (serverProjection?.runs ?? [])}
33033298
latestRun={paintOnlyDisplayedTimeline ? null : activeActivityRun}
33043299
runningRunId={paintOnlyDisplayedTimeline ? null : activeRunningTurnId}
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
import type { ScopedThreadRef } from "@t3tools/contracts";
2+
import { type ComponentProps, useLayoutEffect } from "react";
3+
import { useIncrementalTimelineEntries } from "../../hooks/useIncrementalTimelineEntries";
4+
import type { TimelineEntriesInput, TimelineEntry } from "../../session-logic";
5+
import { useThreadVisibleTurnItems } from "../../state/entities";
6+
import {
7+
peekRememberedThreadTimeline,
8+
rememberReadyThreadTimeline,
9+
resolveThreadSwitchTimeline,
10+
timelineHasEphemeralPreviewUrls,
11+
} from "../ChatView.logic";
12+
import { MessagesTimeline } from "./MessagesTimeline";
13+
14+
/**
15+
* The timeline with the streamed text. ChatView reads the thread through its quiet
16+
* atoms, which hold through text deltas; this reads the live turn items, so a
17+
* delta re-renders the timeline without re-rendering ChatView.
18+
*/
19+
export function ThreadTimeline({
20+
threadDetailRef,
21+
entriesInput,
22+
draftEntries,
23+
activeThreadKey,
24+
syncing,
25+
detailLoading,
26+
readyMarkdownCwd,
27+
readyWorkspaceRoot,
28+
...timelineProps
29+
}: Omit<ComponentProps<typeof MessagesTimeline>, "timelineEntries"> & {
30+
/** Null until the thread's detail may load; see resolveThreadDetailRef. */
31+
threadDetailRef: ScopedThreadRef | null;
32+
entriesInput: Omit<TimelineEntriesInput, "visibleTurnItems">;
33+
/** A local draft's optimistic rows; null for a server thread. */
34+
draftEntries: ReadonlyArray<TimelineEntry> | null;
35+
activeThreadKey: string | null;
36+
syncing: boolean;
37+
detailLoading: boolean;
38+
readyMarkdownCwd: string | null;
39+
readyWorkspaceRoot: string | null;
40+
}) {
41+
const visibleTurnItems = useThreadVisibleTurnItems(threadDetailRef);
42+
const serverEntries = useIncrementalTimelineEntries(activeThreadKey, {
43+
...entriesInput,
44+
visibleTurnItems,
45+
});
46+
const entries = draftEntries ?? serverEntries;
47+
const displayed = resolveThreadSwitchTimeline({
48+
loading: entries.length === 0 && syncing,
49+
activeThreadKey,
50+
nextEntries: entries,
51+
rememberedForActive: peekRememberedThreadTimeline<typeof entries>(activeThreadKey),
52+
});
53+
useLayoutEffect(() => {
54+
if (detailLoading || entries.length === 0 || timelineHasEphemeralPreviewUrls(entries)) {
55+
return;
56+
}
57+
rememberReadyThreadTimeline({
58+
threadKey: activeThreadKey,
59+
entries,
60+
markdownCwd: readyMarkdownCwd,
61+
workspaceRoot: readyWorkspaceRoot,
62+
});
63+
}, [activeThreadKey, detailLoading, entries, readyMarkdownCwd, readyWorkspaceRoot]);
64+
return <MessagesTimeline {...timelineProps} timelineEntries={displayed.entries} />;
65+
}

‎apps/web/src/components/chat/useThreadFind.ts‎

Lines changed: 5 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,10 @@
11
import type { InlineSkill } from "@t3tools/shared/inlineSkills";
2-
import type {
3-
OrchestrationV2ThreadProjection,
4-
OrchestrationV2SearchThreadResult,
5-
ScopedThreadRef,
6-
} from "@t3tools/contracts";
2+
import type { OrchestrationV2SearchThreadResult, ScopedThreadRef } from "@t3tools/contracts";
73
import { scopedThreadKey } from "@t3tools/client-runtime/environment";
84
import { useEffect, useLayoutEffect, useRef, useState, type RefObject } from "react";
95
import { useStableCallback } from "~/hooks/useStableCallback";
106
import { orchestrationEnvironment } from "~/state/orchestration";
7+
import { useThreadProjection } from "~/state/entities";
118
import { useEnvironmentQuery } from "~/state/query";
129
import { useDebouncedValue } from "~/state/queries";
1310
import {
@@ -42,14 +39,15 @@ export function useThreadFind({
4239
enabled,
4340
progressive = false,
4441
skills = EMPTY_SKILLS,
45-
content,
4642
}: {
4743
thread: ScopedThreadRef | null;
4844
enabled: boolean;
4945
progressive?: boolean;
5046
skills?: readonly InlineSkill[];
51-
content: Pick<OrchestrationV2ThreadProjection, "visibleTurnItems" | "runs"> | undefined;
5247
}) {
48+
// Read here rather than passed in: a streamed delta re-renders only the find
49+
// provider, which refreshes open results from it.
50+
const content = useThreadProjection(enabled ? thread : null)?.projection;
5351
// Held in state so the timeline props that carry it can be stored in state too.
5452
const [positionReader] = useState<RefObject<ThreadFindPositionReader | null>>(() => ({
5553
current: null,

‎apps/web/src/state/entities.ts‎

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -156,6 +156,13 @@ export function useThreadProjection(ref: ScopedThreadRef | null): EnvironmentThr
156156
);
157157
}
158158

159+
/** The thread's projection, held through streamed assistant and reasoning text; see quietThreadAtom. */
160+
export function useQuietThreadProjection(ref: ScopedThreadRef | null): EnvironmentThread | null {
161+
return useAtomValue(
162+
ref === null ? EMPTY_THREAD_PROJECTION_ATOM : environmentThreadDetails.quietThreadAtom(ref),
163+
);
164+
}
165+
159166
export function useThreadStatus(ref: ScopedThreadRef | null): EnvironmentThreadStatus {
160167
return useAtomValue(
161168
ref === null ? EMPTY_THREAD_STATUS_ATOM : environmentThreadDetails.statusAtom(ref),
@@ -188,6 +195,17 @@ export function useThreadVisibleTurnItems(
188195
);
189196
}
190197

198+
/** Visible turn items, held through streamed text; see quietThreadAtom. */
199+
export function useQuietThreadVisibleTurnItems(
200+
ref: ScopedThreadRef | null,
201+
): ReadonlyArray<OrchestrationV2ProjectedTurnItem> {
202+
return useAtomValue(
203+
ref === null
204+
? EMPTY_VISIBLE_TURN_ITEMS_ATOM
205+
: environmentThreadDetails.quietVisibleTurnItemsAtom(ref),
206+
);
207+
}
208+
191209
export function useThreadCheckpointSummaries(
192210
ref: ScopedThreadRef | null,
193211
): ReadonlyArray<ThreadCheckpointSummary> {
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
import { MessageId, RunId, TurnItemId, type OrchestrationV2TurnItem } from "@t3tools/contracts";
2+
import { describe, expect, it } from "@effect/vitest";
3+
import * as DateTime from "effect/DateTime";
4+
5+
import { v2Now, v2Projection, v2ThreadId } from "./orchestrationV2TestFixtures.ts";
6+
import { onlyStreamedTextChanged } from "./threadDetail.ts";
7+
8+
const later = DateTime.makeUnsafe("2026-06-20T00:00:01.000Z");
9+
10+
const assistant: OrchestrationV2TurnItem = {
11+
id: TurnItemId.make("item-assistant"),
12+
threadId: v2ThreadId,
13+
runId: RunId.make("run-1"),
14+
nodeId: null,
15+
providerThreadId: null,
16+
providerTurnId: null,
17+
nativeItemRef: null,
18+
parentItemId: null,
19+
ordinal: 1,
20+
status: "running",
21+
title: null,
22+
startedAt: v2Now,
23+
completedAt: null,
24+
updatedAt: v2Now,
25+
type: "assistant_message",
26+
messageId: MessageId.make("message-assistant"),
27+
text: "Hel",
28+
streaming: true,
29+
};
30+
31+
function withItem(item: OrchestrationV2TurnItem, updatedAt = v2Now) {
32+
return {
33+
...v2Projection,
34+
turnItems: [item],
35+
visibleTurnItems: [
36+
{
37+
position: 0,
38+
visibility: "local" as const,
39+
sourceThreadId: v2ThreadId,
40+
sourceItemId: item.id,
41+
item,
42+
},
43+
],
44+
updatedAt,
45+
};
46+
}
47+
48+
describe("onlyStreamedTextChanged", () => {
49+
const before = withItem(assistant);
50+
51+
it("holds through streamed text", () => {
52+
const next = withItem({ ...assistant, text: "Hello", updatedAt: later }, later);
53+
expect(onlyStreamedTextChanged(before, next)).toBe(true);
54+
});
55+
56+
it("sees the item finish streaming", () => {
57+
const next = withItem({ ...assistant, text: "Hello", streaming: false, status: "completed" });
58+
expect(onlyStreamedTextChanged(before, next)).toBe(false);
59+
});
60+
61+
it("sees a new item", () => {
62+
const next = { ...before, turnItems: [assistant, { ...assistant, id: TurnItemId.make("b") }] };
63+
expect(onlyStreamedTextChanged(before, next)).toBe(false);
64+
});
65+
66+
it("sees other fields change on a streaming item", () => {
67+
const next = withItem({ ...assistant, text: "Hello", title: "Answer" });
68+
expect(onlyStreamedTextChanged(before, next)).toBe(false);
69+
});
70+
71+
it("sees changes outside the turn items", () => {
72+
expect(onlyStreamedTextChanged(before, { ...before, runs: [] })).toBe(false);
73+
});
74+
});

0 commit comments

Comments
 (0)