From b0e9cabc37311f8efd7bd9f202e2fada5d12d85a Mon Sep 17 00:00:00 2001 From: Yuruzuu Date: Sun, 1 Nov 2026 16:32:17 +0800 Subject: [PATCH 1/3] perf(web): changing file counts no longer re-render the chat view Co-Authored-By: Claude Opus 5.5 --- apps/web/src/components/ChatView.tsx | 13 +++-- .../pullRequest/useThreadLinkedPullRequest.ts | 2 +- apps/web/src/state/vcs.test.ts | 49 +++++++++++++++++++ apps/web/src/state/vcs.ts | 13 +++++ apps/web/test/appScenarios.renders.tsx | 42 +++++++++++++++- apps/web/test/fakeEnvironment.ts | 23 ++++++++- 6 files changed, 134 insertions(+), 8 deletions(-) create mode 100644 apps/web/src/state/vcs.test.ts diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index ea477b9144791..f1e5fde0a4e62 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -224,7 +224,7 @@ import { type TerminalContextDraft } from "../lib/terminalContext"; import { collectInlineContextIds } from "../lib/composerContextReferences"; import { selectThreadTerminalUiState, useTerminalUiStateStore } from "../terminalUiStateStore"; import { useKnownTerminalSessions, useThreadRunningTerminalIds } from "../state/terminalSessions"; -import { useEnvironmentQuery } from "../state/query"; +import { useEnvironmentQueryRefresh } from "../state/query"; import { useEnvironmentScope } from "~/state/session"; import { primaryServerAvailableEditorsAtom, @@ -233,7 +233,7 @@ import { } from "../state/server"; import { threadEnvironment } from "../state/threads"; import { resolveProviderSkillsForCwd } from "@t3tools/client-runtime/providerSkills"; -import { vcsEnvironment } from "../state/vcs"; +import { chatGitStatusAtom, NO_CHAT_GIT_STATUS_ATOM, vcsEnvironment } from "../state/vcs"; import { type EnvironmentPresentation, useEnvironment, @@ -1612,14 +1612,17 @@ export default function ChatView(props: ChatViewProps) { }) : null; const gitStatusCwd = activeThread?.worktreePath ?? gitCwd; - const gitStatusQuery = useEnvironmentQuery( + const gitStatusAtom = gitStatusCwd === null ? null : vcsEnvironment.status({ environmentId, input: { cwd: gitStatusCwd }, - }), - ); + }); + const gitStatusQuery = { + data: useAtomValue(gitStatusAtom ? chatGitStatusAtom(gitStatusAtom) : NO_CHAT_GIT_STATUS_ATOM), + refresh: useEnvironmentQueryRefresh(gitStatusAtom).refresh, + }; useWorkspaceMutationRefresh({ enabled: gitStatusCwd !== null, mutationId: workspaceMutationId, diff --git a/apps/web/src/components/pullRequest/useThreadLinkedPullRequest.ts b/apps/web/src/components/pullRequest/useThreadLinkedPullRequest.ts index acecaaf6e14de..27ee415b8f3d5 100644 --- a/apps/web/src/components/pullRequest/useThreadLinkedPullRequest.ts +++ b/apps/web/src/components/pullRequest/useThreadLinkedPullRequest.ts @@ -25,7 +25,7 @@ export function useThreadLinkedPullRequest(input: { readonly projectId: ProjectId | null; readonly projectRepository: string | null; readonly threadBranch: string | null; - readonly gitStatus: VcsStatusResult | null; + readonly gitStatus: Pick | null; }) { const { serverThreadRef, persisted, projectId, projectRepository, threadBranch, gitStatus } = input; diff --git a/apps/web/src/state/vcs.test.ts b/apps/web/src/state/vcs.test.ts new file mode 100644 index 0000000000000..32451106602e2 --- /dev/null +++ b/apps/web/src/state/vcs.test.ts @@ -0,0 +1,49 @@ +import type { VcsStatusResult } from "@t3tools/contracts"; +import { AsyncResult, Atom, AtomRegistry } from "effect/reactivity"; +import { describe, expect, it, vi } from "vite-plus/test"; + +import { chatGitStatusAtom } from "./vcs"; + +vi.mock("../connection/runtime", () => ({ connectionAtomRuntime: {} })); +vi.mock("@t3tools/client-runtime/state/vcs", () => ({ + createVcsActionManager: () => ({}), + createVcsEnvironmentAtoms: () => ({}), + createVcsStatusRevalidationAtomFamily: () => () => null, +})); + +const status = (changes: Partial = {}) => + AsyncResult.success({ + isRepo: true, + hasPrimaryRemote: true, + isDefaultRef: true, + refName: "main", + hasWorkingTreeChanges: true, + workingTree: { files: [], insertions: 1, deletions: 0 }, + hasUpstream: true, + aheadCount: 0, + behindCount: 0, + pr: null, + ...changes, + } satisfies VcsStatusResult); + +describe("chatGitStatusAtom", () => { + it("notifies only when a field the chat view reads changes", () => { + const registry = AtomRegistry.make(); + const source = Atom.make>(status()); + const selected = chatGitStatusAtom(source as never); + const notified: unknown[] = []; + const unsubscribe = registry.subscribe(selected, (value) => notified.push(value)); + registry.get(selected); + notified.length = 0; + + registry.set( + source, + status({ workingTree: { files: [], insertions: 2, deletions: 0 }, aheadCount: 1 }), + ); + expect(notified).toEqual([]); + + registry.set(source, status({ refName: "feature" })); + expect(notified).toHaveLength(1); + unsubscribe(); + }); +}); diff --git a/apps/web/src/state/vcs.ts b/apps/web/src/state/vcs.ts index fd4f34caaa68d..26cfedaf8e46c 100644 --- a/apps/web/src/state/vcs.ts +++ b/apps/web/src/state/vcs.ts @@ -3,6 +3,8 @@ import { createVcsEnvironmentAtoms, createVcsStatusRevalidationAtomFamily, } from "@t3tools/client-runtime/state/vcs"; +import * as Equal from "effect/Equal"; +import { AsyncResult, Atom } from "effect/reactivity"; import { connectionAtomRuntime } from "../connection/runtime"; import { subscribeDocumentReturn } from "../lib/documentActivity"; @@ -10,6 +12,17 @@ import { subscribeDocumentReturn } from "../lib/documentActivity"; export const vcsEnvironment = createVcsEnvironmentAtoms(connectionAtomRuntime); export const vcsActionManager = createVcsActionManager(connectionAtomRuntime); +/** Only what a chat view reads, so changing file counts don't re-render it on every edit. */ +export const chatGitStatusAtom = Atom.family((status: ReturnType) => + Atom.map(status, (result) => { + const data = AsyncResult.getOrElse(result, () => null); + if (data === null) return null; + const { isRepo, refName, hasWorkingTreeChanges, pr } = data; + return { isRepo, refName, hasWorkingTreeChanges, pr }; + }).pipe(Atom.withEquality(Equal.equals)), +); +export const NO_CHAT_GIT_STATUS_ATOM: ReturnType = Atom.make(null); + /** * Refreshes a checkout's status when the user comes back to the window, where * edits made in another app or terminal may be waiting. diff --git a/apps/web/test/appScenarios.renders.tsx b/apps/web/test/appScenarios.renders.tsx index 459f3b9791d7f..ec3683e034387 100644 --- a/apps/web/test/appScenarios.renders.tsx +++ b/apps/web/test/appScenarios.renders.tsx @@ -3,6 +3,7 @@ // chat and settings routes, the sidebar and the right panel, through the same stores and // atoms the app reads. See renderScenarios.renders.tsx for the counting rules. import { createMemoryHistory } from "@tanstack/react-router"; +import { scopeThreadRef } from "@t3tools/client-runtime/environment"; import type { ThreadId } from "@t3tools/contracts"; import { DEFAULT_CLIENT_SETTINGS } from "@t3tools/contracts/settings"; import type * as Context from "effect/Context"; @@ -13,11 +14,13 @@ import "~/index.css"; import { AppRoot } from "~/AppRoot"; import { __setClientSettingsForTests } from "~/hooks/useSettings"; +import { useRightPanelStore } from "~/rightPanelStore"; import { getRouter, type AppRouter } from "~/router"; import { ENVIRONMENT_ID, assistantItem, emitThreadEvent, + emitVcsStatus, installFakeEnvironment, isThreadSubscribed, runningRun, @@ -47,6 +50,7 @@ vi.mock(import("~/rpc/requestLatencyState"), async (original) => ({ trackRpcRequestSent: () => {}, })); +const GIT_STATUS_UPDATES = 20; const HISTORY_TURNS = 20; const STREAM_DELTAS = 30; const TYPED_TEXT = "fix the flaky tests!"; @@ -61,7 +65,9 @@ const [WARM, A, B, LIVE] = THREADS.map((thread) => thread.id) as [ ThreadId, ]; -installFakeEnvironment(THREADS); +const SIDE_CHAT = { ...threadWithHistory("thread-side", HISTORY_TURNS), sideChatOf: WARM }; + +installFakeEnvironment([...THREADS, SIDE_CHAT]); // Desktop-sized, so the sidebar, composer and right panel lay out as they do in the app. await page.viewport(1400, 900); // A returning user: the first-run gate would otherwise wait on onboarding evidence. @@ -287,3 +293,37 @@ it("app-switch-settings-section", async () => { await measure(openSettings(router, "/settings/appearance")), ]); }); + +// ChatView reads only a few status fields, so changing file counts should not reach it. +// Keep git scenarios last: the status they leave cached adds renders to later scenarios. +it("app-git-file-counts-with-side-chat", async () => { + using _clock = frozenClock(); + useRightPanelStore.getState().openSideChat(scopeThreadRef(ENVIRONMENT_ID, WARM), SIDE_CHAT.id); + await using _app = (await openThread(WARM)).screen; + await expect.element(lastAnswer(SIDE_CHAT.id)).toBeVisible(); + const local = (insertions: number, refName = "main") => ({ + isRepo: true, + hasPrimaryRemote: true, + isDefaultRef: true, + refName, + hasWorkingTreeChanges: true, + workingTree: { + files: [{ path: "src/app.ts", insertions, deletions: 0 }], + insertions, + deletions: 0, + }, + }); + await act(() => emitVcsStatus({ _tag: "snapshot", local: local(0), remote: null })); + const counted = await measure(async () => { + for (let update = 1; update <= GIT_STATUS_UPDATES; update++) { + await act(() => emitVcsStatus({ _tag: "localUpdated", local: local(update) })); + } + }); + expect(counted.components.ChatView).toBeUndefined(); + // A field the chat view does read still reaches both chats. + const branchSwitch = await measure(() => + act(() => emitVcsStatus({ _tag: "localUpdated", local: local(0, "feature") })), + ); + expect(branchSwitch.components.ChatView).toBe(2); + await report("app-git-file-counts-with-side-chat", [counted]); +}); diff --git a/apps/web/test/fakeEnvironment.ts b/apps/web/test/fakeEnvironment.ts index c586ad51c2c5f..a9576bfb50cbf 100644 --- a/apps/web/test/fakeEnvironment.ts +++ b/apps/web/test/fakeEnvironment.ts @@ -17,6 +17,7 @@ import { TurnItemId, WS_METHODS, WsRpcGroup, + type VcsStatusStreamEvent, type OrchestrationProjectShell, type OrchestrationV2ProjectedTurnItem, type OrchestrationV2Run, @@ -181,6 +182,8 @@ function run(threadId: ThreadId, id: RunId, ordinal: number, userMessageId: stri } export interface FakeThread { + /** Makes this a side chat of that thread. */ + readonly sideChatOf?: ThreadId; readonly id: ThreadId; readonly title: string; readonly runs: ReadonlyArray; @@ -223,7 +226,12 @@ function shellOf(thread: FakeThread): OrchestrationV2ThreadShell { branch: "main", worktreePath: null, activeProviderThreadId: null, - lineage: { rootThreadId: thread.id, parentThreadId: null, relationshipToParent: null }, + lineage: { + rootThreadId: thread.sideChatOf ?? thread.id, + parentThreadId: thread.sideChatOf ?? null, + relationshipToParent: null, + ...(thread.sideChatOf ? { sideChat: true } : {}), + }, forkedFrom: null, createdBy: "user", creationSource: "web", @@ -309,6 +317,7 @@ const json = (body: unknown, status = 200) => const threadSubscribers = new Map>(); let threads: ReadonlyArray = []; let sequence = 1; +let vcsStatusSubscriber: Queue.Queue | null = null; const subscribe = (first: ReadonlyArray, register?: (queue: Queue.Queue) => () => void) => Stream.unwrap( @@ -356,6 +365,13 @@ const handlers: Record unknown> = { }, ); }, + [WS_METHODS.subscribeVcsStatus]: () => + subscribe([], (queue) => { + vcsStatusSubscriber = queue; + return () => { + if (vcsStatusSubscriber === queue) vcsStatusSubscriber = null; + }; + }), [WS_METHODS.subscribeServerConfig]: () => subscribe([{ version: 1 as const, type: "snapshot" as const, config: serverConfig }]), }; @@ -468,3 +484,8 @@ export function runningRun(threadId: ThreadId, id: string, ordinal: number, user } export { userItem }; + +export async function emitVcsStatus(event: VcsStatusStreamEvent): Promise { + if (vcsStatusSubscriber === null) throw new Error("No VCS status subscription"); + await Effect.runPromise(Queue.offer(vcsStatusSubscriber, event)); +} From b73677c49cb60dadbd7a7e33d234770b892ef8ec Mon Sep 17 00:00:00 2001 From: Yuruzuu Date: Sun, 1 Nov 2026 16:50:31 +0800 Subject: [PATCH 2/3] test(web): cover the other chat git status fields Co-Authored-By: Claude Opus 5.5 --- apps/web/src/state/vcs.test.ts | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/apps/web/src/state/vcs.test.ts b/apps/web/src/state/vcs.test.ts index 32451106602e2..67bb122510ce1 100644 --- a/apps/web/src/state/vcs.test.ts +++ b/apps/web/src/state/vcs.test.ts @@ -44,6 +44,25 @@ describe("chatGitStatusAtom", () => { registry.set(source, status({ refName: "feature" })); expect(notified).toHaveLength(1); + + registry.set(source, status({ refName: "feature", hasWorkingTreeChanges: false })); + expect(notified).toHaveLength(2); + + const pr = () => ({ + number: 42, + title: "Feature PR", + url: "https://github.com/pingdotgg/t3code/pull/42", + baseRef: "main", + headRef: "feature", + state: "open" as const, + }); + const linked = { refName: "feature", hasWorkingTreeChanges: false }; + registry.set(source, status({ ...linked, pr: pr() })); + expect(notified).toHaveLength(3); + + // A new object with the same data, as each remote status refresh sends. + registry.set(source, status({ ...linked, pr: pr() })); + expect(notified).toHaveLength(3); unsubscribe(); }); }); From dec373a2afe9fe0578c80be7988ce2985eb54730 Mon Sep 17 00:00:00 2001 From: Yuruzuu Date: Tue, 3 Nov 2026 04:15:10 +0800 Subject: [PATCH 3/3] test(web): file count scenario waits for the last status update Co-Authored-By: Claude Opus 5.5 --- apps/web/test/appScenarios.renders.tsx | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/apps/web/test/appScenarios.renders.tsx b/apps/web/test/appScenarios.renders.tsx index 02e1de396d73e..6da75df06e452 100644 --- a/apps/web/test/appScenarios.renders.tsx +++ b/apps/web/test/appScenarios.renders.tsx @@ -7,6 +7,7 @@ import { scopeThreadRef } from "@t3tools/client-runtime/environment"; import type { ThreadId } from "@t3tools/contracts"; import { DEFAULT_CLIENT_SETTINGS } from "@t3tools/contracts/settings"; import type * as Context from "effect/Context"; +import { AsyncResult } from "effect/reactivity"; import { expect, it, vi } from "vite-plus/test"; import { commands, page, userEvent } from "vite-plus/test/browser"; @@ -16,6 +17,8 @@ import { AppRoot } from "~/AppRoot"; import { __setClientSettingsForTests } from "~/hooks/useSettings"; import { useRightPanelStore } from "~/rightPanelStore"; import { getRouter, type AppRouter } from "~/router"; +import { appAtomRegistry } from "~/rpc/atomRegistry"; +import { vcsEnvironment } from "~/state/vcs"; import { ENVIRONMENT_ID, assistantItem, @@ -334,6 +337,10 @@ it("app-git-file-counts-with-side-chat", async () => { useRightPanelStore.getState().openSideChat(scopeThreadRef(ENVIRONMENT_ID, WARM), SIDE_CHAT.id); await using _app = (await openThread(WARM)).screen; await expect.element(lastAnswer(SIDE_CHAT.id)).toBeVisible(); + const gitStatus = vcsEnvironment.status({ + environmentId: ENVIRONMENT_ID, + input: { cwd: "/workspace/render" }, + }); const local = (insertions: number, refName = "main") => ({ isRepo: true, hasPrimaryRemote: true, @@ -351,6 +358,11 @@ it("app-git-file-counts-with-side-chat", async () => { for (let update = 1; update <= GIT_STATUS_UPDATES; update++) { await act(() => emitVcsStatus({ _tag: "localUpdated", local: local(update) })); } + // Zero renders only counts if the last update arrived inside this window. + await vi.waitFor(() => { + const status = AsyncResult.getOrElse(appAtomRegistry.get(gitStatus), () => null); + expect(status?.workingTree.insertions).toBe(GIT_STATUS_UPDATES); + }); }); expect(counted.components.ChatView).toBeUndefined(); // A field the chat view does read still reaches both chats.