authorgravatar for saiansh2525@gmail.comYash Singh <saiansh2525@gmail.com> 2026-10-12 19:15:56-05:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-10-12 19:15:56-05:00
logb03534fa7b847e8403322e416189c6293e6846e0
treec4128a02b40c9816d4fdfec23a6f76efc17e1ea9
parentffb4af8d5d4fcfc5e9322295ff846a6d1e7ffbf8
signature Signed by PGP key B5690EEEBB952194

fix(web): Git actions follow the selected thread (#18862)


3 files changed, 258 insertions(+), 18 deletions(-)

apps/web/src/components/GitActionsControl.lifecycle.test.ts created+234
......@@ -0,0 +1,234 @@
1// @vitest-environment jsdom
2import { AuthSourceControlWriteScope, EnvironmentId, ThreadId } from "@t3tools/contracts";
3import { AsyncResult } from "effect/reactivity";
4import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test";
5
6const state = vi.hoisted(() => ({
7 actions: [] as {
8 scope: { environmentId: string | null; cwd: string | null };
9 input: { threadId?: string; action: string };
10 }[],
11 toasts: [] as { type?: string; description?: string; actionProps?: { onClick: () => void } }[],
12 scopes: new Set<string>(),
13 primaryScopes: new Set<string>(),
14}));
15
16vi.mock("@effect/atom-react", () => ({
17 useAtomValue: (atom: unknown) => (atom === "vcs-state" ? { isRunning: false } : null),
18}));
19vi.mock("~/state/entities", () => ({
20 useThreadProjection: () => null,
21 useThreadShell: (ref: { threadId: string }) => ({ branch: "main", id: ref.threadId }),
22}));
23vi.mock("~/state/session", () => ({
24 useEnvironmentScope: (environmentId: unknown, scope: string) =>
25 (environmentId === "environment" ? state.scopes : state.primaryScopes).has(scope),
26 readEnvironmentScope: (environmentId: unknown, scope: string) =>
27 (environmentId === "environment" ? state.scopes : state.primaryScopes).has(scope),
28}));
29vi.mock("~/state/use-atom-command", () => ({ useAtomCommand: (command: unknown) => command }));
30vi.mock("~/state/server", () => ({ serverEnvironment: { configValueAtom: () => null } }));
31vi.mock("~/state/sourceControl", () => ({ sourceControlEnvironment: { discovery: () => null } }));
32vi.mock("~/state/vcs", () => ({
33 vcsEnvironment: { status: () => null },
34 vcsActionManager: { stateAtom: () => "vcs-state" },
35}));
36vi.mock("~/state/threads", () => ({
37 threadEnvironment: { updateMetadata: async () => AsyncResult.success(undefined) },
38}));
39vi.mock("~/state/query", () => ({
40 useEnvironmentQuery: () => ({
41 data: {
42 sourceControlProviders: [],
43 isRepo: true,
44 refName: "main",
45 isDefaultRef: false,
46 hasPrimaryRemote: true,
47 hasWorkingTreeChanges: true,
48 workingTree: {
49 files: [{ path: "file.ts", status: "modified", insertions: 1, deletions: 0 }],
50 },
51 },
52 error: null,
53 }),
54}));
55vi.mock("@tanstack/react-router", () => ({ useNavigate: () => () => {} }));
56vi.mock("~/composerDraftStore", () => ({
57 useComposerDraftStore: (
58 select: (store: {
59 getDraftSession: () => null;
60 getDraftThreadByRef: () => null;
61 setDraftThreadContext: () => void;
62 }) => unknown,
63 ) =>
64 select({
65 getDraftSession: () => null,
66 getDraftThreadByRef: () => null,
67 setDraftThreadContext: () => {},
68 }),
69}));
70vi.mock("~/lib/sourceControlActions", () => ({
71 useSourceControlActionRunning: () => false,
72 useVcsInitAction: () => ({}),
73 useVcsPullAction: () => ({}),
74 useSourceControlPublishRepositoryAction: () => ({}),
75 useGitStackedAction: (scope: { environmentId: string | null; cwd: string | null }) => ({
76 run: async (input: { featureBranch?: boolean; threadId?: string; action: string }) => {
77 state.actions.push({ scope, input });
78 return {
79 _tag: "Success",
80 value: {
81 branch: { status: "skipped_not_requested" },
82 toast: {
83 title: "Committed",
84 description: "Committed",
85 cta: {
86 kind: "run_action",
87 label: "Push & create PR",
88 action: { kind: "commit_push_pr" },
89 },
90 },
91 },
92 };
93 },
94 }),
95}));
96vi.mock("~/lib/utils", () => ({ cn: () => "", randomUUID: () => "action" }));
97vi.mock("~/editorPreferences", () => ({ useOpenInPreferredEditor: () => () => {} }));
98vi.mock("~/browser/useOpenLink", () => ({ useOpenLink: () => () => {} }));
99vi.mock("~/lib/openPullRequestLink", () => ({ useOpenPrLink: () => () => {} }));
100vi.mock("~/components/ui/toast", () => ({
101 stackedThreadToast: (input: unknown) => input,
102 toastManager: {
103 add: (input: {
104 type?: string;
105 description?: string;
106 actionProps?: { onClick: () => void };
107 }) => {
108 state.toasts.push(input);
109 return "toast";
110 },
111 update: () => {},
112 close: () => {},
113 },
114}));
115vi.mock("~/components/ui/dialog", () => ({
116 Dialog: "Dialog",
117 DialogDescription: "DialogDescription",
118 DialogFooter: "DialogFooter",
119 DialogHeader: "DialogHeader",
120 DialogPanel: "DialogPanel",
121 DialogPopup: "DialogPopup",
122 DialogTitle: "DialogTitle",
123}));
124vi.mock("~/components/ui/group", () => ({ Group: "Group", GroupSeparator: "GroupSeparator" }));
125vi.mock("~/components/ui/menu", () => ({
126 Menu: "Menu",
127 MenuItem: "MenuItem",
128 MenuItemLabel: "MenuItemLabel",
129 MenuPopup: "MenuPopup",
130 MenuTrigger: "MenuTrigger",
131}));
132vi.mock("~/components/ui/popover", () => ({
133 Popover: "Popover",
134 PopoverPopup: "PopoverPopup",
135 PopoverTrigger: "PopoverTrigger",
136}));
137vi.mock("~/components/ui/tooltip", () => ({
138 Tooltip: "Tooltip",
139 TooltipPopup: "TooltipPopup",
140 TooltipTrigger: "TooltipTrigger",
141}));
142vi.mock("~/components/ui/button", () => ({ Button: "button" }));
143vi.mock("~/components/ui/checkbox", () => ({ Checkbox: "Checkbox" }));
144vi.mock("~/components/ui/input", () => ({ Input: "Input" }));
145vi.mock("~/components/ui/radio-group", () => ({ RadioGroup: "RadioGroup" }));
146vi.mock("~/components/ui/scroll-area", () => ({ ScrollArea: "ScrollArea" }));
147vi.mock("~/components/ui/spinner", () => ({ Spinner: "Spinner" }));
148vi.mock("~/components/ui/textarea", () => ({ Textarea: "Textarea" }));
149vi.mock("~/components/ui/toggle", () => ({ toggleVariants: () => "" }));
150vi.mock("./AnimatedHeight", () => ({ AnimatedHeight: "AnimatedHeight" }));
151
152import GitActionsControl from "./GitActionsControl";
153
154import { createElement, memo } from "react";
155import { act, create } from "react-test-renderer";
156
157vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
158
159const renderers: ReturnType<typeof create>[] = [];
160// ThreadDetailsPanel keeps this component mounted behind React.memo when threads change.
161const MemoGitActionsControl = memo(GitActionsControl);
162const props = (thread: string, cwd: string) => ({
163 gitCwd: cwd,
164 activeThreadRef: {
165 environmentId: EnvironmentId.make("environment"),
166 threadId: ThreadId.make(thread),
167 },
168});
169
170async function renderActions(thread: string, cwd: string) {
171 let renderer!: ReturnType<typeof create>;
172 await act(async () => {
173 renderer = create(createElement(MemoGitActionsControl, props(thread, cwd)));
174 });
175 renderers.push(renderer);
176 return renderer;
177}
178
179async function clickQuickAction(renderer: ReturnType<typeof create>) {
180 const button = renderer.root
181 .findAllByType("button")
182 .find((button) => button.props.onClick && !button.props.disabled);
183 expect(button).toBeDefined();
184 await act(async () => {
185 button!.props.onClick({});
186 });
187}
188
189beforeEach(() => {
190 state.scopes = new Set([AuthSourceControlWriteScope]);
191 state.primaryScopes = new Set([AuthSourceControlWriteScope]);
192 state.actions = [];
193 state.toasts = [];
194});
195
196afterEach(async () => {
197 await act(async () => {
198 for (const renderer of renderers.splice(0)) renderer.unmount();
199 });
200});
201
202describe("Git action targets across thread navigation", () => {
203 it("runs a retained PR toast in the thread that produced it", async () => {
204 const renderer = await renderActions("first", "/first");
205 await clickQuickAction(renderer);
206 const toast = state.toasts.find((toast) => toast.actionProps)!;
207 expect(toast).toBeDefined();
208 await act(async () => {
209 renderer.update(createElement(MemoGitActionsControl, props("second", "/second")));
210 });
211 await act(async () => {
212 toast.actionProps!.onClick();
213 });
214 expect(state.actions.at(-1)).toMatchObject({
215 scope: { cwd: "/first" },
216 input: { threadId: "first", action: "commit_push_pr" },
217 });
218 });
219
220 it("runs the memoized panel button in the current thread after navigation", async () => {
221 const renderer = await renderActions("first", "/first");
222 for (const [thread, cwd] of [
223 ["first", "/first"],
224 ["second", "/second"],
225 ["first", "/first"],
226 ] as const) {
227 await act(async () => {
228 renderer.update(createElement(MemoGitActionsControl, props(thread, cwd)));
229 });
230 await clickQuickAction(renderer);
231 expect(state.actions.at(-1)).toMatchObject({ scope: { cwd }, input: { threadId: thread } });
232 }
233 });
234});
apps/web/src/components/GitActionsControl.test.ts+4-5
......@@ -23,15 +23,14 @@ const state = vi.hoisted(() => ({
2323
2424vi.mock("react", async (importOriginal) => ({
2525 ...(await importOriginal<typeof import("react")>()),
26 useCallback: (callback: unknown) => callback,
26 useCallback: (callback: typeof state.run) => {
27 if (callback?.constructor.name === "AsyncFunction") state.run = callback;
28 return callback;
29 },
2730 useMemo: (factory: () => unknown) => factory(),
2831 useState: (initial: unknown) => [typeof initial === "function" ? initial() : initial, () => {}],
2932 useRef: (current: unknown) => ({ current }),
3033 useEffect: () => {},
31 useEffectEvent: (callback: typeof state.run) => {
32 state.run = callback;
33 return callback;
34 },
3534}));
3635vi.mock("@effect/atom-react", () => ({
3736 useAtomValue: (atom: unknown) => (atom === "vcs-state" ? { isRunning: false } : null),
apps/web/src/components/GitActionsControl.tsx+20-13
......@@ -21,15 +21,7 @@ import type {
2121} from "@t3tools/contracts";
2222import { useNavigate } from "@tanstack/react-router";
2323import * as Option from "effect/Option";
24import {
25 type MouseEvent,
26 useCallback,
27 useEffect,
28 useEffectEvent,
29 useMemo,
30 useRef,
31 useState,
32} from "react";
24import { type MouseEvent, useCallback, useEffect, useMemo, useRef, useState } from "react";
3325import { flushSync } from "react-dom";
3426import {
3527 CheckIcon,
......@@ -1062,7 +1054,6 @@ export default function GitActionsControl({
10621054 );
10631055 const vcsActionState = useAtomValue(vcsActionManager.stateAtom(sourceControlScope));
10641056 const visibleInlineSuccess = inlineSuccess?.scopeKey === successScopeKey ? inlineSuccess : null;
1065 let runGitActionWithToast: (input: RunGitActionWithToastInput) => Promise<void>;
10661057
10671058 useEffect(() => {
10681059 if (!inlineSuccess) return;
......@@ -1258,8 +1249,10 @@ export default function GitActionsControl({
12581249 };
12591250 }, [activeEnvironmentId, gitCwd, refreshVcsStatus]);
12601251
1261 runGitActionWithToast = useEffectEvent(
1262 async ({
1252 // React 19.2 leaves useEffectEvent stale inside the memoized details panel.
1253 // Bind each render's actions to its checkout and thread, including retained toast actions.
1254 const runGitActionWithToast = useCallback(
1255 async function runGitActionWithToast({
12631256 action,
12641257 commitMessage,
12651258 onConfirmed,
......@@ -1267,7 +1260,7 @@ export default function GitActionsControl({
12671260 statusOverride,
12681261 featureBranch = false,
12691262 filePaths,
1270 }: RunGitActionWithToastInput) => {
1263 }: RunGitActionWithToastInput) {
12711264 if (
12721265 activeEnvironmentId === null ||
12731266 !readEnvironmentScope(activeEnvironmentId, AuthSourceControlWriteScope) ||
......@@ -1415,6 +1408,20 @@ export default function GitActionsControl({
14151408 });
14161409 }
14171410 },
1411 [
1412 activeDraftThread,
1413 activeEnvironmentId,
1414 activeServerThread,
1415 gitStatusForActions,
1416 isDefaultRef,
1417 isLocalDraftThread,
1418 isPanel,
1419 openPrLink,
1420 runImmediateGitAction,
1421 successScopeKey,
1422 syncThreadBranchAfterGitAction,
1423 threadToastData,
1424 ],
14181425 );
14191426
14201427 const continuePendingDefaultBranchAction = () => {