Skip to content

Commit bcc6c08

Browse files
Yuruzuuclaudemaria-rcks
authored
perf(web): chat view redraws no longer re-render the composer or side chat timeline (#28564)
Co-authored-by: Claude Opus 5.5 <[email protected]> Co-authored-by: maria-rcks <[email protected]>
1 parent 0c0de57 commit bcc6c08

5 files changed

Lines changed: 101 additions & 33 deletions

File tree

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

Lines changed: 20 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -382,6 +382,7 @@ const EMPTY_FEEDBACK_SUBMISSIONS: ReadonlyArray<CodexFeedbackSubmission> = [];
382382
const EMPTY_PROVIDER_SKILLS: ServerProvider["skills"] = [];
383383
const EMPTY_TURN_ITEMS: ThreadStructure["turnItems"] = [];
384384
const EMPTY_AUTO_UPDATE_ENVIRONMENTS: ReadonlyArray<EnvironmentPresentation> = [];
385+
const EMPTY_COMPOSER_BANNER_ITEMS: ReadonlyArray<ComposerBannerStackItem> = [];
385386

386387
const EMPTY_PENDING_FILE_SURFACE_IDS: ReadonlySet<string> = new Set();
387388

@@ -866,20 +867,18 @@ export default function ChatView(props: ChatViewProps) {
866867
const sideChatParent = useQuietThreadShell(sideChatParentRef);
867868
const parentThreadLink = (() => {
868869
if (sideChatParentRef !== null) {
870+
const title = sideChatParent?.title ?? "Parent thread";
871+
// Embedded, the parent is the view around it.
872+
if (embedded) return { title, sideChat: true };
869873
return {
870-
title: sideChatParent?.title ?? "Parent thread",
874+
title,
871875
sideChat: true,
872-
// Embedded, the parent is the view around it.
873-
...(embedded
874-
? {}
875-
: {
876-
open: (): void => {
877-
void navigate({
878-
to: "/$environmentId/$threadId",
879-
params: buildThreadRouteParams(sideChatParentRef),
880-
});
881-
},
882-
}),
876+
open: (): void => {
877+
void navigate({
878+
to: "/$environmentId/$threadId",
879+
params: buildThreadRouteParams(sideChatParentRef),
880+
});
881+
},
883882
};
884883
}
885884
if (parentSubagentThreadRef !== null) {
@@ -3586,7 +3585,7 @@ export default function ChatView(props: ChatViewProps) {
35863585
activeThreadId={activeThreadId}
35873586
activeThread={activeThread}
35883587
activeThreadShell={activeThreadShell}
3589-
promptHistoryMessages={timelineMessages}
3588+
promptHistoryEntries={timelineEntries}
35903589
forceExpandedOnMobile={
35913590
forceExpandedMobileComposer && isDraftHeroState
35923591
}
@@ -3621,7 +3620,11 @@ export default function ChatView(props: ChatViewProps) {
36213620
: projectCloneSendBlockReason
36223621
}
36233622
isPreparingWorktree={isPreparingWorktree && !sendQueuesBehindSetup}
3624-
bannerItems={isDraftHeroState ? [] : composerBannerItems}
3623+
bannerItems={
3624+
isDraftHeroState || composerBannerItems.length === 0
3625+
? EMPTY_COMPOSER_BANNER_ITEMS
3626+
: composerBannerItems
3627+
}
36253628
resumeCompactionTokens={resumeCompactionTokens}
36263629
keepFullHistory={keepFullHistory}
36273630
onToggleKeepFullHistory={toggleKeepFullHistoryStable}
@@ -3661,7 +3664,9 @@ export default function ChatView(props: ChatViewProps) {
36613664
}
36623665
activeThreadModelSelection={activeThread?.modelSelection}
36633666
activeProjectId={activeProject?.id ?? null}
3664-
activeContextWindow={activeContextWindow}
3667+
contextWindowEntries={serverVisibleTurnItems}
3668+
contextWindowLiveUsage={activeThreadLiveTokenUsage}
3669+
contextWindowProviderThread={activeProviderThread}
36653670
compactThreadUnavailable={compactThreadUnavailable}
36663671
compactDisabled={compactDisabled}
36673672
compactDisabledReason={compactDisabledReason}

‎apps/web/src/components/chat/ChatComposer.tsx‎

Lines changed: 17 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -239,25 +239,20 @@ import { proposedPlanTitle } from "@t3tools/shared/proposedPlanText";
239239
import { hasProviderSetup } from "./ProviderStatusBanner";
240240
import { type AppModelOption, getAppModelOptionsForInstance } from "../../modelSelection";
241241
import type { UnifiedSettings } from "@t3tools/contracts/settings";
242-
import {
243-
isVideoAttachment,
244-
type ChatMessage,
245-
type SessionPhase,
246-
type Thread,
247-
type ThreadShell,
248-
} from "../../types";
242+
import { isVideoAttachment, type SessionPhase, type Thread, type ThreadShell } from "../../types";
249243
import { type ComposerPromptHistoryPosition } from "./composerPromptHistory";
250244
import type { PendingUserInputDraftAnswer } from "@t3tools/client-runtime/state/pending-user-input";
251245
import type {
252246
LatestProposedPlanState,
253247
PendingApproval,
254248
PendingUserInput,
249+
TimelineEntry,
255250
} from "../../session-logic";
256251
import {
257252
resolveComposerDispatchMode,
258253
type ComposerDispatchMode,
259254
} from "@t3tools/client-runtime/state/composer-dispatch";
260-
import type { ContextWindowSnapshot } from "../../lib/contextWindow";
255+
import { deriveLatestContextWindowSnapshot } from "../../lib/contextWindow";
261256
import {
262257
resolveProviderSkillsForCwd,
263258
resolveProviderSlashCommandsForCwd,
@@ -371,7 +366,7 @@ export interface ChatComposerProps {
371366
activeThreadId: ThreadId | null;
372367
activeThread: Thread | undefined;
373368
activeThreadShell: ThreadShell | null;
374-
promptHistoryMessages: ReadonlyArray<ChatMessage>;
369+
promptHistoryEntries: ReadonlyArray<TimelineEntry>;
375370
forceExpandedOnMobile: boolean;
376371
projectSelectionRequired: boolean;
377372

@@ -433,7 +428,9 @@ export interface ChatComposerProps {
433428
reportedModelSelection?: ModelSelection | null;
434429
activeProjectId: ProjectId | null;
435430

436-
activeContextWindow: ContextWindowSnapshot | null;
431+
contextWindowEntries: Parameters<typeof deriveLatestContextWindowSnapshot>[0];
432+
contextWindowLiveUsage: Parameters<typeof deriveLatestContextWindowSnapshot>[1];
433+
contextWindowProviderThread: Parameters<typeof deriveLatestContextWindowSnapshot>[2];
437434
compactThreadUnavailable: boolean;
438435
compactDisabled: boolean;
439436
compactDisabledReason: string | null;
@@ -524,7 +521,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
524521
onMultipleModelSelectionsChange: setMultipleModelSelections,
525522
activeThreadId,
526523
activeThread,
527-
promptHistoryMessages,
524+
promptHistoryEntries,
528525
forceExpandedOnMobile,
529526
projectSelectionRequired,
530527
phase,
@@ -557,7 +554,9 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
557554
activeThreadModelSelection,
558555
reportedModelSelection,
559556
activeProjectId,
560-
activeContextWindow,
557+
contextWindowEntries,
558+
contextWindowLiveUsage,
559+
contextWindowProviderThread,
561560
compactThreadUnavailable,
562561
compactDisabled,
563562
compactDisabledReason,
@@ -602,6 +601,11 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
602601
editingQueuedAttachments,
603602
onRemoveEditingQueuedAttachment,
604603
} = props;
604+
const activeContextWindow = deriveLatestContextWindowSnapshot(
605+
contextWindowEntries,
606+
contextWindowLiveUsage,
607+
contextWindowProviderThread,
608+
);
605609
const primaryEnvironmentId = usePrimaryEnvironmentId();
606610
const composerDraftTargetKey = composerTargetKey(composerDraftTarget);
607611
const {
@@ -2210,7 +2214,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
22102214
planModeUiEnabled,
22112215
isLiteralPendingAnswer,
22122216
pendingUserInputs,
2213-
promptHistoryMessages,
2217+
promptHistoryEntries,
22142218
promptHistoryPositionRef,
22152219
composerDraftTarget,
22162220
promptRef,

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

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,7 @@ export function composerCommandKeyHandler(input: {
4444
readonly planModeUiEnabled: boolean;
4545
readonly isLiteralPendingAnswer: boolean;
4646
readonly pendingUserInputs: ReadonlyArray<PendingUserInput>;
47-
readonly promptHistoryMessages: ChatComposerProps["promptHistoryMessages"];
47+
readonly promptHistoryEntries: ChatComposerProps["promptHistoryEntries"];
4848
readonly promptHistoryPositionRef: RefObject<{
4949
targetKey: string;
5050
position: ComposerPromptHistoryPosition;
@@ -96,7 +96,7 @@ export function composerCommandKeyHandler(input: {
9696
planModeUiEnabled,
9797
isLiteralPendingAnswer,
9898
pendingUserInputs,
99-
promptHistoryMessages,
99+
promptHistoryEntries,
100100
promptHistoryPositionRef,
101101
composerDraftTarget,
102102
promptRef,
@@ -165,7 +165,9 @@ export function composerCommandKeyHandler(input: {
165165
const step = stepComposerPromptHistory({
166166
direction,
167167
// Built on the keypress, not per render: the timeline changes on every streamed delta.
168-
entries: buildComposerPromptHistoryEntries(promptHistoryMessages),
168+
entries: buildComposerPromptHistoryEntries(
169+
promptHistoryEntries.flatMap((entry) => (entry.kind === "message" ? [entry.message] : [])),
170+
),
169171
position,
170172
currentPrompt: promptRef.current,
171173
});

‎apps/web/test/appScenarios.renders.tsx‎

Lines changed: 37 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
// chat and settings routes, the sidebar and the right panel, through the same stores and
44
// atoms the app reads. See renderScenarios.renders.tsx for the counting rules.
55
import { createMemoryHistory } from "@tanstack/react-router";
6+
import { scopeThreadRef } from "@t3tools/client-runtime/environment";
67
import type { ThreadId } from "@t3tools/contracts";
78
import { DEFAULT_CLIENT_SETTINGS } from "@t3tools/contracts/settings";
89
import type * as Context from "effect/Context";
@@ -13,12 +14,14 @@ import "~/index.css";
1314

1415
import { AppRoot } from "~/AppRoot";
1516
import { __setClientSettingsForTests } from "~/hooks/useSettings";
17+
import { useRightPanelStore } from "~/rightPanelStore";
1618
import { getRouter, type AppRouter } from "~/router";
1719
import {
1820
ENVIRONMENT_ID,
1921
assistantItem,
2022
emitTerminalMetadata,
2123
emitThreadEvent,
24+
emitVcsStatus,
2225
installFakeEnvironment,
2326
isThreadSubscribed,
2427
runningRun,
@@ -48,6 +51,7 @@ vi.mock(import("~/rpc/requestLatencyState"), async (original) => ({
4851
trackRpcRequestSent: () => {},
4952
}));
5053

54+
const GIT_STATUS_UPDATES = 20;
5155
const HISTORY_TURNS = 20;
5256
const STREAM_DELTAS = 30;
5357
const TYPED_TEXT = "fix the flaky tests!";
@@ -63,7 +67,9 @@ const [WARM, A, B, LIVE] = THREADS.map((thread) => thread.id) as [
6367
ThreadId,
6468
];
6569

66-
installFakeEnvironment(THREADS);
70+
const SIDE_CHAT = { ...threadWithHistory("thread-side", HISTORY_TURNS), sideChatOf: WARM };
71+
72+
installFakeEnvironment([...THREADS, SIDE_CHAT]);
6773
// Desktop-sized, so the sidebar, composer and right panel lay out as they do in the app.
6874
await page.viewport(1400, 900);
6975
// A returning user: the first-run gate would otherwise wait on onboarding evidence.
@@ -320,3 +326,33 @@ it("app-switch-settings-section", async () => {
320326
await measure(openSettings(router, "/settings/appearance")),
321327
]);
322328
});
329+
330+
// ChatView re-renders when the tree goes dirty or clean; the composer and side chat timeline should not.
331+
it("app-git-status-updates-with-side-chat", async () => {
332+
using _clock = frozenClock();
333+
useRightPanelStore.getState().openSideChat(scopeThreadRef(ENVIRONMENT_ID, WARM), SIDE_CHAT.id);
334+
await using _app = (await openThread(WARM)).screen;
335+
await expect.element(lastAnswer(SIDE_CHAT.id)).toBeVisible();
336+
const local = (insertions: number) => ({
337+
isRepo: true,
338+
hasPrimaryRemote: true,
339+
isDefaultRef: true,
340+
refName: "main",
341+
hasWorkingTreeChanges: insertions % 2 === 1,
342+
workingTree: {
343+
files: [{ path: "src/app.ts", insertions, deletions: 0 }],
344+
insertions,
345+
deletions: 0,
346+
},
347+
});
348+
await act(() => emitVcsStatus({ _tag: "snapshot", local: local(0), remote: null }));
349+
const counted = await measure(async () => {
350+
for (let update = 1; update <= GIT_STATUS_UPDATES; update++) {
351+
await act(() => emitVcsStatus({ _tag: "localUpdated", local: local(update) }));
352+
}
353+
});
354+
expect(counted.components.ChatView).toBe(GIT_STATUS_UPDATES * 2);
355+
expect(counted.components.ChatComposer).toBeUndefined();
356+
expect(counted.components.ConversationTimeline).toBeUndefined();
357+
await report("app-git-status-updates-with-side-chat", [counted]);
358+
});

‎apps/web/test/fakeEnvironment.ts‎

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import {
1717
TurnItemId,
1818
WS_METHODS,
1919
WsRpcGroup,
20+
type VcsStatusStreamEvent,
2021
type OrchestrationProjectShell,
2122
type OrchestrationV2ProjectedTurnItem,
2223
type OrchestrationV2Run,
@@ -182,6 +183,8 @@ function run(threadId: ThreadId, id: RunId, ordinal: number, userMessageId: stri
182183
}
183184

184185
export interface FakeThread {
186+
/** Makes this a side chat of that thread. */
187+
readonly sideChatOf?: ThreadId;
185188
readonly id: ThreadId;
186189
readonly title: string;
187190
readonly runs: ReadonlyArray<OrchestrationV2Run>;
@@ -224,7 +227,12 @@ function shellOf(thread: FakeThread): OrchestrationV2ThreadShell {
224227
branch: "main",
225228
worktreePath: null,
226229
activeProviderThreadId: null,
227-
lineage: { rootThreadId: thread.id, parentThreadId: null, relationshipToParent: null },
230+
lineage: {
231+
rootThreadId: thread.sideChatOf ?? thread.id,
232+
parentThreadId: thread.sideChatOf ?? null,
233+
relationshipToParent: null,
234+
...(thread.sideChatOf ? { sideChat: true } : {}),
235+
},
228236
forkedFrom: null,
229237
createdBy: "user",
230238
creationSource: "web",
@@ -311,6 +319,7 @@ const threadSubscribers = new Map<string, Queue.Queue<OrchestrationV2ThreadStrea
311319
let terminalMetadataSubscriber: Queue.Queue<TerminalMetadataStreamEvent> | null = null;
312320
let threads: ReadonlyArray<FakeThread> = [];
313321
let sequence = 1;
322+
let vcsStatusSubscriber: Queue.Queue<VcsStatusStreamEvent> | null = null;
314323

315324
const subscribe = <A>(first: ReadonlyArray<A>, register?: (queue: Queue.Queue<A>) => () => void) =>
316325
Stream.unwrap(
@@ -365,6 +374,13 @@ const handlers: Record<string, (input: never) => unknown> = {
365374
},
366375
);
367376
},
377+
[WS_METHODS.subscribeVcsStatus]: () =>
378+
subscribe<VcsStatusStreamEvent>([], (queue) => {
379+
vcsStatusSubscriber = queue;
380+
return () => {
381+
if (vcsStatusSubscriber === queue) vcsStatusSubscriber = null;
382+
};
383+
}),
368384
[WS_METHODS.subscribeServerConfig]: () =>
369385
subscribe([{ version: 1 as const, type: "snapshot" as const, config: serverConfig }]),
370386
};
@@ -482,3 +498,8 @@ export function runningRun(threadId: ThreadId, id: string, ordinal: number, user
482498
}
483499

484500
export { userItem };
501+
502+
export async function emitVcsStatus(event: VcsStatusStreamEvent): Promise<void> {
503+
if (vcsStatusSubscriber === null) throw new Error("No VCS status subscription");
504+
await Effect.runPromise(Queue.offer(vcsStatusSubscriber, event));
505+
}

0 commit comments

Comments
 (0)