Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 8 additions & 5 deletions apps/web/src/components/ChatView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -226,7 +226,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,
Expand All @@ -235,7 +235,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,
Expand Down Expand Up @@ -1614,14 +1614,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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<VcsStatusResult, "refName" | "pr"> | null;
}) {
const { serverThreadRef, persisted, projectId, projectRepository, threadBranch, gitStatus } =
input;
Expand Down
68 changes: 68 additions & 0 deletions apps/web/src/state/vcs.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
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<VcsStatusResult> = {}) =>
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<AsyncResult.AsyncResult<VcsStatusResult, never>>(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);

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();
});
});
13 changes: 13 additions & 0 deletions apps/web/src/state/vcs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,26 @@ 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";

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<typeof vcsEnvironment.status>) =>
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<typeof chatGitStatusAtom> = 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.
Expand Down
46 changes: 46 additions & 0 deletions apps/web/test/appScenarios.renders.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand All @@ -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,
Expand Down Expand Up @@ -327,6 +330,49 @@ it("app-switch-settings-section", async () => {
]);
});

// 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 gitStatus = vcsEnvironment.status({
environmentId: ENVIRONMENT_ID,
input: { cwd: "/workspace/render" },
});
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) }));
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
}
// 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.
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]);
});

// ChatView re-renders when the tree goes dirty or clean; the composer and side chat timeline should not.
it("app-git-status-updates-with-side-chat", async () => {
using _clock = frozenClock();
Expand Down
Loading