authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-04-02 11:39:27-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-04-02 11:39:27-07:00
log5ce5472bc389e178132df40f316014d714f3275d
tree37a0b996c3cfa000e99bec9d85a95caa65f1d5f6
parent9ea443d73afc72cf9fa617bdc1344e67913e4c63
signature Signed by PGP key B5690EEEBB952194

Scope git action progress toasts to the originating thread (#1675)


3 files changed, 290 insertions(+), 50 deletions(-)

apps/web/src/components/GitActionsControl.browser.tsx created+238
......@@ -0,0 +1,238 @@
1import { ThreadId } from "@t3tools/contracts";
2import { useState } from "react";
3import { afterEach, describe, expect, it, vi } from "vitest";
4import { render } from "vitest-browser-react";
5
6const THREAD_A = ThreadId.makeUnsafe("thread-a");
7const THREAD_B = ThreadId.makeUnsafe("thread-b");
8const GIT_CWD = "/repo/project";
9const BRANCH_NAME = "feature/toast-scope";
10
11const {
12 invalidateGitQueriesSpy,
13 invalidateGitStatusQuerySpy,
14 runStackedActionMutateAsyncSpy,
15 setThreadBranchSpy,
16 toastAddSpy,
17 toastCloseSpy,
18 toastPromiseSpy,
19 toastUpdateSpy,
20} = vi.hoisted(() => ({
21 invalidateGitQueriesSpy: vi.fn(() => Promise.resolve()),
22 invalidateGitStatusQuerySpy: vi.fn(() => Promise.resolve()),
23 runStackedActionMutateAsyncSpy: vi.fn(() => new Promise<never>(() => undefined)),
24 setThreadBranchSpy: vi.fn(),
25 toastAddSpy: vi.fn(() => "toast-1"),
26 toastCloseSpy: vi.fn(),
27 toastPromiseSpy: vi.fn(),
28 toastUpdateSpy: vi.fn(),
29}));
30
31vi.mock("@tanstack/react-query", async () => {
32 const actual =
33 await vi.importActual<typeof import("@tanstack/react-query")>("@tanstack/react-query");
34
35 return {
36 ...actual,
37 useIsMutating: vi.fn(() => 0),
38 useMutation: vi.fn((options: { __kind?: string }) => {
39 if (options.__kind === "run-stacked-action") {
40 return {
41 mutateAsync: runStackedActionMutateAsyncSpy,
42 isPending: false,
43 };
44 }
45
46 if (options.__kind === "pull") {
47 return {
48 mutateAsync: vi.fn(),
49 isPending: false,
50 };
51 }
52
53 return {
54 mutate: vi.fn(),
55 mutateAsync: vi.fn(),
56 isPending: false,
57 };
58 }),
59 useQuery: vi.fn((options: { queryKey?: string[] }) => {
60 if (options.queryKey?.[0] === "git-status") {
61 return {
62 data: {
63 branch: BRANCH_NAME,
64 hasWorkingTreeChanges: false,
65 workingTree: { files: [], insertions: 0, deletions: 0 },
66 hasUpstream: true,
67 aheadCount: 1,
68 behindCount: 0,
69 pr: null,
70 },
71 error: null,
72 };
73 }
74
75 if (options.queryKey?.[0] === "git-branches") {
76 return {
77 data: {
78 isRepo: true,
79 hasOriginRemote: true,
80 branches: [
81 {
82 name: BRANCH_NAME,
83 current: true,
84 isDefault: false,
85 worktreePath: null,
86 },
87 ],
88 },
89 error: null,
90 };
91 }
92
93 return { data: null, error: null };
94 }),
95 useQueryClient: vi.fn(() => ({})),
96 };
97});
98
99vi.mock("~/components/ui/toast", () => ({
100 toastManager: {
101 add: toastAddSpy,
102 close: toastCloseSpy,
103 promise: toastPromiseSpy,
104 update: toastUpdateSpy,
105 },
106}));
107
108vi.mock("~/editorPreferences", () => ({
109 openInPreferredEditor: vi.fn(),
110}));
111
112vi.mock("~/lib/gitReactQuery", () => ({
113 gitBranchesQueryOptions: vi.fn(() => ({ queryKey: ["git-branches"] })),
114 gitInitMutationOptions: vi.fn(() => ({ __kind: "init" })),
115 gitMutationKeys: {
116 pull: vi.fn(() => ["pull"]),
117 runStackedAction: vi.fn(() => ["run-stacked-action"]),
118 },
119 gitPullMutationOptions: vi.fn(() => ({ __kind: "pull" })),
120 gitRunStackedActionMutationOptions: vi.fn(() => ({ __kind: "run-stacked-action" })),
121 gitStatusQueryOptions: vi.fn(() => ({ queryKey: ["git-status"] })),
122 invalidateGitQueries: invalidateGitQueriesSpy,
123 invalidateGitStatusQuery: invalidateGitStatusQuerySpy,
124}));
125
126vi.mock("~/lib/utils", async () => {
127 const actual = await vi.importActual<typeof import("~/lib/utils")>("~/lib/utils");
128
129 return {
130 ...actual,
131 newCommandId: vi.fn(() => "command-1"),
132 randomUUID: vi.fn(() => "action-1"),
133 };
134});
135
136vi.mock("~/nativeApi", () => ({
137 readNativeApi: vi.fn(() => null),
138}));
139
140vi.mock("~/store", () => ({
141 useStore: (selector: (state: unknown) => unknown) =>
142 selector({
143 setThreadBranch: setThreadBranchSpy,
144 threads: [
145 { id: THREAD_A, branch: BRANCH_NAME, worktreePath: null },
146 { id: THREAD_B, branch: BRANCH_NAME, worktreePath: null },
147 ],
148 }),
149}));
150
151vi.mock("~/terminal-links", () => ({
152 resolvePathLinkTarget: vi.fn(),
153}));
154
155import GitActionsControl from "./GitActionsControl";
156
157function findButtonByText(text: string): HTMLButtonElement | null {
158 return (Array.from(document.querySelectorAll("button")).find((button) =>
159 button.textContent?.includes(text),
160 ) ?? null) as HTMLButtonElement | null;
161}
162
163function Harness() {
164 const [activeThreadId, setActiveThreadId] = useState(THREAD_A);
165
166 return (
167 <>
168 <button type="button" onClick={() => setActiveThreadId(THREAD_B)}>
169 Switch thread
170 </button>
171 <GitActionsControl gitCwd={GIT_CWD} activeThreadId={activeThreadId} />
172 </>
173 );
174}
175
176describe("GitActionsControl thread-scoped progress toast", () => {
177 afterEach(() => {
178 vi.useRealTimers();
179 vi.clearAllMocks();
180 document.body.innerHTML = "";
181 });
182
183 it("keeps an in-flight git action toast pinned to the thread that started it", async () => {
184 vi.useFakeTimers();
185
186 const host = document.createElement("div");
187 document.body.append(host);
188 const screen = await render(<Harness />, { container: host });
189
190 try {
191 const quickActionButton = findButtonByText("Push & create PR");
192 expect(quickActionButton, 'Unable to find button containing "Push & create PR"').toBeTruthy();
193 if (!(quickActionButton instanceof HTMLButtonElement)) {
194 throw new Error('Unable to find button containing "Push & create PR"');
195 }
196 quickActionButton.click();
197
198 expect(toastAddSpy).toHaveBeenCalledWith(
199 expect.objectContaining({
200 data: { threadId: THREAD_A },
201 title: "Pushing...",
202 type: "loading",
203 }),
204 );
205
206 await vi.advanceTimersByTimeAsync(1_000);
207
208 expect(toastUpdateSpy).toHaveBeenLastCalledWith(
209 "toast-1",
210 expect.objectContaining({
211 data: { threadId: THREAD_A },
212 title: "Pushing...",
213 type: "loading",
214 }),
215 );
216
217 const switchThreadButton = findButtonByText("Switch thread");
218 expect(switchThreadButton, 'Unable to find button containing "Switch thread"').toBeTruthy();
219 if (!(switchThreadButton instanceof HTMLButtonElement)) {
220 throw new Error('Unable to find button containing "Switch thread"');
221 }
222 switchThreadButton.click();
223 await vi.advanceTimersByTimeAsync(1_000);
224
225 expect(toastUpdateSpy).toHaveBeenLastCalledWith(
226 "toast-1",
227 expect.objectContaining({
228 data: { threadId: THREAD_A },
229 title: "Pushing...",
230 type: "loading",
231 }),
232 );
233 } finally {
234 await screen.unmount();
235 host.remove();
236 }
237 });
238});
apps/web/src/components/GitActionsControl.tsx+51-49
......@@ -38,7 +38,7 @@ import { Menu, MenuItem, MenuPopup, MenuTrigger } from "~/components/ui/menu";
3838import { Popover, PopoverPopup, PopoverTrigger } from "~/components/ui/popover";
3939import { ScrollArea } from "~/components/ui/scroll-area";
4040import { Textarea } from "~/components/ui/textarea";
41import { toastManager } from "~/components/ui/toast";
41import { toastManager, type ThreadToastData } from "~/components/ui/toast";
4242import { openInPreferredEditor } from "~/editorPreferences";
4343import {
4444 gitBranchesQueryOptions,
......@@ -73,6 +73,7 @@ type GitActionToastId = ReturnType<typeof toastManager.add>;
7373
7474interface ActiveGitActionProgress {
7575 toastId: GitActionToastId;
76 toastData: ThreadToastData | undefined;
7677 actionId: string;
7778 title: string;
7879 phaseStartedAtMs: number | null;
......@@ -223,6 +224,7 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions
223224 const [pendingDefaultBranchAction, setPendingDefaultBranchAction] =
224225 useState<PendingDefaultBranchAction | null>(null);
225226 const activeGitActionProgressRef = useRef<ActiveGitActionProgress | null>(null);
227 let runGitActionWithToast: (input: RunGitActionWithToastInput) => Promise<void>;
226228
227229 const updateActiveProgressToast = useCallback(() => {
228230 const progress = activeGitActionProgressRef.current;
......@@ -234,9 +236,9 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions
234236 title: progress.title,
235237 description: resolveProgressDescription(progress),
236238 timeout: 0,
237 data: threadToastData,
239 data: progress.toastData,
238240 });
239 }, [threadToastData]);
241 }, []);
240242
241243 const persistThreadBranchSync = useCallback(
242244 (branch: string | null) => {
......@@ -402,7 +404,7 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions
402404 });
403405 }, [gitStatusForActions, threadToastData]);
404406
405 const runGitActionWithToast = useEffectEvent(
407 runGitActionWithToast = useEffectEvent(
406408 async ({
407409 action,
408410 commitMessage,
......@@ -455,6 +457,7 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions
455457 action === "create_pr" &&
456458 (!actionStatus?.hasUpstream || (actionStatus?.aheadCount ?? 0) > 0),
457459 });
460 const scopedToastData = threadToastData ? { ...threadToastData } : undefined;
458461 const actionId = randomUUID();
459462 const resolvedProgressToastId =
460463 progressToastId ??
......@@ -463,11 +466,12 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions
463466 title: progressStages[0] ?? "Running git action...",
464467 description: "Waiting for Git...",
465468 timeout: 0,
466 data: threadToastData,
469 data: scopedToastData,
467470 });
468471
469472 activeGitActionProgressRef.current = {
470473 toastId: resolvedProgressToastId,
474 toastData: scopedToastData,
471475 actionId,
472476 title: progressStages[0] ?? "Running git action...",
473477 phaseStartedAtMs: null,
......@@ -483,7 +487,7 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions
483487 title: progressStages[0] ?? "Running git action...",
484488 description: "Waiting for Git...",
485489 timeout: 0,
486 data: threadToastData,
490 data: scopedToastData,
487491 });
488492 }
489493
......@@ -587,30 +591,38 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions
587591 };
588592 }
589593
590 toastManager.update(resolvedProgressToastId, {
594 const successToastBase = {
591595 type: "success",
592596 title: result.toast.title,
593597 description: result.toast.description,
594598 timeout: 0,
595599 data: {
596 ...threadToastData,
600 ...scopedToastData,
597601 dismissAfterVisibleMs: 10_000,
598602 },
599 ...(toastActionProps ? { actionProps: toastActionProps } : {}),
600 });
603 } as const;
604
605 if (toastActionProps) {
606 toastManager.update(resolvedProgressToastId, {
607 ...successToastBase,
608 actionProps: toastActionProps,
609 });
610 } else {
611 toastManager.update(resolvedProgressToastId, successToastBase);
612 }
601613 } catch (err) {
602614 activeGitActionProgressRef.current = null;
603615 toastManager.update(resolvedProgressToastId, {
604616 type: "error",
605617 title: "Action failed",
606618 description: err instanceof Error ? err.message : "An error occurred.",
607 data: threadToastData,
619 data: scopedToastData,
608620 });
609621 }
610622 },
611623 );
612624
613 const continuePendingDefaultBranchAction = useCallback(() => {
625 const continuePendingDefaultBranchAction = () => {
614626 if (!pendingDefaultBranchAction) return;
615627 const { action, commitMessage, onConfirmed, filePaths } = pendingDefaultBranchAction;
616628 setPendingDefaultBranchAction(null);
......@@ -621,9 +633,9 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions
621633 ...(filePaths ? { filePaths } : {}),
622634 skipDefaultBranchPrompt: true,
623635 });
624 }, [pendingDefaultBranchAction]);
636 };
625637
626 const checkoutFeatureBranchAndContinuePendingAction = useCallback(() => {
638 const checkoutFeatureBranchAndContinuePendingAction = () => {
627639 if (!pendingDefaultBranchAction) return;
628640 const { action, commitMessage, onConfirmed, filePaths } = pendingDefaultBranchAction;
629641 setPendingDefaultBranchAction(null);
......@@ -635,9 +647,9 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions
635647 featureBranch: true,
636648 skipDefaultBranchPrompt: true,
637649 });
638 }, [pendingDefaultBranchAction]);
650 };
639651
640 const runDialogActionOnNewBranch = useCallback(() => {
652 const runDialogActionOnNewBranch = () => {
641653 if (!isCommitDialogOpen) return;
642654 const commitMessage = dialogCommitMessage.trim();
643655
......@@ -653,9 +665,9 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions
653665 featureBranch: true,
654666 skipDefaultBranchPrompt: true,
655667 });
656 }, [allSelected, isCommitDialogOpen, dialogCommitMessage, selectedFiles]);
668 };
657669
658 const runQuickAction = useCallback(() => {
670 const runQuickAction = () => {
659671 if (quickAction.kind === "open_pr") {
660672 void openExistingPr();
661673 return;
......@@ -693,31 +705,28 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions
693705 if (quickAction.action) {
694706 void runGitActionWithToast({ action: quickAction.action });
695707 }
696 }, [openExistingPr, pullMutation, quickAction, threadToastData]);
708 };
697709
698 const openDialogForMenuItem = useCallback(
699 (item: GitActionMenuItem) => {
700 if (item.disabled) return;
701 if (item.kind === "open_pr") {
702 void openExistingPr();
703 return;
704 }
705 if (item.dialogAction === "push") {
706 void runGitActionWithToast({ action: "push" });
707 return;
708 }
709 if (item.dialogAction === "create_pr") {
710 void runGitActionWithToast({ action: "create_pr" });
711 return;
712 }
713 setExcludedFiles(new Set());
714 setIsEditingFiles(false);
715 setIsCommitDialogOpen(true);
716 },
717 [openExistingPr, setIsCommitDialogOpen],
718 );
710 const openDialogForMenuItem = (item: GitActionMenuItem) => {
711 if (item.disabled) return;
712 if (item.kind === "open_pr") {
713 void openExistingPr();
714 return;
715 }
716 if (item.dialogAction === "push") {
717 void runGitActionWithToast({ action: "push" });
718 return;
719 }
720 if (item.dialogAction === "create_pr") {
721 void runGitActionWithToast({ action: "create_pr" });
722 return;
723 }
724 setExcludedFiles(new Set());
725 setIsEditingFiles(false);
726 setIsCommitDialogOpen(true);
727 };
719728
720 const runDialogAction = useCallback(() => {
729 const runDialogAction = () => {
721730 if (!isCommitDialogOpen) return;
722731 const commitMessage = dialogCommitMessage.trim();
723732 setIsCommitDialogOpen(false);
......@@ -729,14 +738,7 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions
729738 ...(commitMessage ? { commitMessage } : {}),
730739 ...(!allSelected ? { filePaths: selectedFiles.map((f) => f.path) } : {}),
731740 });
732 }, [
733 allSelected,
734 dialogCommitMessage,
735 isCommitDialogOpen,
736 selectedFiles,
737 setDialogCommitMessage,
738 setIsCommitDialogOpen,
739 ]);
741 };
740742
741743 const openChangedFileInEditor = useCallback(
742744 (filePath: string) => {
apps/web/src/components/ui/toast.tsx+1-1
......@@ -19,7 +19,7 @@ import { buttonVariants } from "~/components/ui/button";
1919import { useCopyToClipboard } from "~/hooks/useCopyToClipboard";
2020import { buildVisibleToastLayout, shouldHideCollapsedToastContent } from "./toast.logic";
2121
22type ThreadToastData = {
22export type ThreadToastData = {
2323 threadId?: ThreadId | null;
2424 tooltipStyle?: boolean;
2525 dismissAfterVisibleMs?: number;