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";...@@ -38,7 +38,7 @@ import { Menu, MenuItem, MenuPopup, MenuTrigger } from "~/components/ui/menu";
38import { Popover, PopoverPopup, PopoverTrigger } from "~/components/ui/popover";38import { Popover, PopoverPopup, PopoverTrigger } from "~/components/ui/popover";
39import { ScrollArea } from "~/components/ui/scroll-area";39import { ScrollArea } from "~/components/ui/scroll-area";
40import { Textarea } from "~/components/ui/textarea";40import { Textarea } from "~/components/ui/textarea";
41import { toastManager } from "~/components/ui/toast";41import { toastManager, type ThreadToastData } from "~/components/ui/toast";
42import { openInPreferredEditor } from "~/editorPreferences";42import { openInPreferredEditor } from "~/editorPreferences";
43import {43import {
44 gitBranchesQueryOptions,44 gitBranchesQueryOptions,
...@@ -73,6 +73,7 @@ type GitActionToastId = ReturnType<typeof toastManager.add>;...@@ -73,6 +73,7 @@ type GitActionToastId = ReturnType<typeof toastManager.add>;
7373
74interface ActiveGitActionProgress {74interface ActiveGitActionProgress {
75 toastId: GitActionToastId;75 toastId: GitActionToastId;
76 toastData: ThreadToastData | undefined;
76 actionId: string;77 actionId: string;
77 title: string;78 title: string;
78 phaseStartedAtMs: number | null;79 phaseStartedAtMs: number | null;
...@@ -223,6 +224,7 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions...@@ -223,6 +224,7 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions
223 const [pendingDefaultBranchAction, setPendingDefaultBranchAction] =224 const [pendingDefaultBranchAction, setPendingDefaultBranchAction] =
224 useState<PendingDefaultBranchAction | null>(null);225 useState<PendingDefaultBranchAction | null>(null);
225 const activeGitActionProgressRef = useRef<ActiveGitActionProgress | null>(null);226 const activeGitActionProgressRef = useRef<ActiveGitActionProgress | null>(null);
227 let runGitActionWithToast: (input: RunGitActionWithToastInput) => Promise<void>;
226228
227 const updateActiveProgressToast = useCallback(() => {229 const updateActiveProgressToast = useCallback(() => {
228 const progress = activeGitActionProgressRef.current;230 const progress = activeGitActionProgressRef.current;
...@@ -234,9 +236,9 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions...@@ -234,9 +236,9 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions
234 title: progress.title,236 title: progress.title,
235 description: resolveProgressDescription(progress),237 description: resolveProgressDescription(progress),
236 timeout: 0,238 timeout: 0,
237 data: threadToastData,239 data: progress.toastData,
238 });240 });
239 }, [threadToastData]);241 }, []);
240242
241 const persistThreadBranchSync = useCallback(243 const persistThreadBranchSync = useCallback(
242 (branch: string | null) => {244 (branch: string | null) => {
...@@ -402,7 +404,7 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions...@@ -402,7 +404,7 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions
402 });404 });
403 }, [gitStatusForActions, threadToastData]);405 }, [gitStatusForActions, threadToastData]);
404406
405 const runGitActionWithToast = useEffectEvent(407 runGitActionWithToast = useEffectEvent(
406 async ({408 async ({
407 action,409 action,
408 commitMessage,410 commitMessage,
...@@ -455,6 +457,7 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions...@@ -455,6 +457,7 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions
455 action === "create_pr" &&457 action === "create_pr" &&
456 (!actionStatus?.hasUpstream || (actionStatus?.aheadCount ?? 0) > 0),458 (!actionStatus?.hasUpstream || (actionStatus?.aheadCount ?? 0) > 0),
457 });459 });
460 const scopedToastData = threadToastData ? { ...threadToastData } : undefined;
458 const actionId = randomUUID();461 const actionId = randomUUID();
459 const resolvedProgressToastId =462 const resolvedProgressToastId =
460 progressToastId ??463 progressToastId ??
...@@ -463,11 +466,12 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions...@@ -463,11 +466,12 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions
463 title: progressStages[0] ?? "Running git action...",466 title: progressStages[0] ?? "Running git action...",
464 description: "Waiting for Git...",467 description: "Waiting for Git...",
465 timeout: 0,468 timeout: 0,
466 data: threadToastData,469 data: scopedToastData,
467 });470 });
468471
469 activeGitActionProgressRef.current = {472 activeGitActionProgressRef.current = {
470 toastId: resolvedProgressToastId,473 toastId: resolvedProgressToastId,
474 toastData: scopedToastData,
471 actionId,475 actionId,
472 title: progressStages[0] ?? "Running git action...",476 title: progressStages[0] ?? "Running git action...",
473 phaseStartedAtMs: null,477 phaseStartedAtMs: null,
...@@ -483,7 +487,7 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions...@@ -483,7 +487,7 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions
483 title: progressStages[0] ?? "Running git action...",487 title: progressStages[0] ?? "Running git action...",
484 description: "Waiting for Git...",488 description: "Waiting for Git...",
485 timeout: 0,489 timeout: 0,
486 data: threadToastData,490 data: scopedToastData,
487 });491 });
488 }492 }
489493
...@@ -587,30 +591,38 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions...@@ -587,30 +591,38 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions
587 };591 };
588 }592 }
589593
590 toastManager.update(resolvedProgressToastId, {594 const successToastBase = {
591 type: "success",595 type: "success",
592 title: result.toast.title,596 title: result.toast.title,
593 description: result.toast.description,597 description: result.toast.description,
594 timeout: 0,598 timeout: 0,
595 data: {599 data: {
596 ...threadToastData,600 ...scopedToastData,
597 dismissAfterVisibleMs: 10_000,601 dismissAfterVisibleMs: 10_000,
598 },602 },
599 ...(toastActionProps ? { actionProps: toastActionProps } : {}),603 } as const;
600 });604
605 if (toastActionProps) {
606 toastManager.update(resolvedProgressToastId, {
607 ...successToastBase,
608 actionProps: toastActionProps,
609 });
610 } else {
611 toastManager.update(resolvedProgressToastId, successToastBase);
612 }
601 } catch (err) {613 } catch (err) {
602 activeGitActionProgressRef.current = null;614 activeGitActionProgressRef.current = null;
603 toastManager.update(resolvedProgressToastId, {615 toastManager.update(resolvedProgressToastId, {
604 type: "error",616 type: "error",
605 title: "Action failed",617 title: "Action failed",
606 description: err instanceof Error ? err.message : "An error occurred.",618 description: err instanceof Error ? err.message : "An error occurred.",
607 data: threadToastData,619 data: scopedToastData,
608 });620 });
609 }621 }
610 },622 },
611 );623 );
612624
613 const continuePendingDefaultBranchAction = useCallback(() => {625 const continuePendingDefaultBranchAction = () => {
614 if (!pendingDefaultBranchAction) return;626 if (!pendingDefaultBranchAction) return;
615 const { action, commitMessage, onConfirmed, filePaths } = pendingDefaultBranchAction;627 const { action, commitMessage, onConfirmed, filePaths } = pendingDefaultBranchAction;
616 setPendingDefaultBranchAction(null);628 setPendingDefaultBranchAction(null);
...@@ -621,9 +633,9 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions...@@ -621,9 +633,9 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions
621 ...(filePaths ? { filePaths } : {}),633 ...(filePaths ? { filePaths } : {}),
622 skipDefaultBranchPrompt: true,634 skipDefaultBranchPrompt: true,
623 });635 });
624 }, [pendingDefaultBranchAction]);636 };
625637
626 const checkoutFeatureBranchAndContinuePendingAction = useCallback(() => {638 const checkoutFeatureBranchAndContinuePendingAction = () => {
627 if (!pendingDefaultBranchAction) return;639 if (!pendingDefaultBranchAction) return;
628 const { action, commitMessage, onConfirmed, filePaths } = pendingDefaultBranchAction;640 const { action, commitMessage, onConfirmed, filePaths } = pendingDefaultBranchAction;
629 setPendingDefaultBranchAction(null);641 setPendingDefaultBranchAction(null);
...@@ -635,9 +647,9 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions...@@ -635,9 +647,9 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions
635 featureBranch: true,647 featureBranch: true,
636 skipDefaultBranchPrompt: true,648 skipDefaultBranchPrompt: true,
637 });649 });
638 }, [pendingDefaultBranchAction]);650 };
639651
640 const runDialogActionOnNewBranch = useCallback(() => {652 const runDialogActionOnNewBranch = () => {
641 if (!isCommitDialogOpen) return;653 if (!isCommitDialogOpen) return;
642 const commitMessage = dialogCommitMessage.trim();654 const commitMessage = dialogCommitMessage.trim();
643655
...@@ -653,9 +665,9 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions...@@ -653,9 +665,9 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions
653 featureBranch: true,665 featureBranch: true,
654 skipDefaultBranchPrompt: true,666 skipDefaultBranchPrompt: true,
655 });667 });
656 }, [allSelected, isCommitDialogOpen, dialogCommitMessage, selectedFiles]);668 };
657669
658 const runQuickAction = useCallback(() => {670 const runQuickAction = () => {
659 if (quickAction.kind === "open_pr") {671 if (quickAction.kind === "open_pr") {
660 void openExistingPr();672 void openExistingPr();
661 return;673 return;
...@@ -693,31 +705,28 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions...@@ -693,31 +705,28 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions
693 if (quickAction.action) {705 if (quickAction.action) {
694 void runGitActionWithToast({ action: quickAction.action });706 void runGitActionWithToast({ action: quickAction.action });
695 }707 }
696 }, [openExistingPr, pullMutation, quickAction, threadToastData]);708 };
697709
698 const openDialogForMenuItem = useCallback(710 const openDialogForMenuItem = (item: GitActionMenuItem) => {
699 (item: GitActionMenuItem) => {711 if (item.disabled) return;
700 if (item.disabled) return;712 if (item.kind === "open_pr") {
701 if (item.kind === "open_pr") {713 void openExistingPr();
702 void openExistingPr();714 return;
703 return;715 }
704 }716 if (item.dialogAction === "push") {
705 if (item.dialogAction === "push") {717 void runGitActionWithToast({ action: "push" });
706 void runGitActionWithToast({ action: "push" });718 return;
707 return;719 }
708 }720 if (item.dialogAction === "create_pr") {
709 if (item.dialogAction === "create_pr") {721 void runGitActionWithToast({ action: "create_pr" });
710 void runGitActionWithToast({ action: "create_pr" });722 return;
711 return;723 }
712 }724 setExcludedFiles(new Set());
713 setExcludedFiles(new Set());725 setIsEditingFiles(false);
714 setIsEditingFiles(false);726 setIsCommitDialogOpen(true);
715 setIsCommitDialogOpen(true);727 };
716 },
717 [openExistingPr, setIsCommitDialogOpen],
718 );
719728
720 const runDialogAction = useCallback(() => {729 const runDialogAction = () => {
721 if (!isCommitDialogOpen) return;730 if (!isCommitDialogOpen) return;
722 const commitMessage = dialogCommitMessage.trim();731 const commitMessage = dialogCommitMessage.trim();
723 setIsCommitDialogOpen(false);732 setIsCommitDialogOpen(false);
...@@ -729,14 +738,7 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions...@@ -729,14 +738,7 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions
729 ...(commitMessage ? { commitMessage } : {}),738 ...(commitMessage ? { commitMessage } : {}),
730 ...(!allSelected ? { filePaths: selectedFiles.map((f) => f.path) } : {}),739 ...(!allSelected ? { filePaths: selectedFiles.map((f) => f.path) } : {}),
731 });740 });
732 }, [741 };
733 allSelected,
734 dialogCommitMessage,
735 isCommitDialogOpen,
736 selectedFiles,
737 setDialogCommitMessage,
738 setIsCommitDialogOpen,
739 ]);
740742
741 const openChangedFileInEditor = useCallback(743 const openChangedFileInEditor = useCallback(
742 (filePath: string) => {744 (filePath: string) => {
apps/web/src/components/ui/toast.tsx+1-1
...@@ -19,7 +19,7 @@ import { buttonVariants } from "~/components/ui/button";...@@ -19,7 +19,7 @@ import { buttonVariants } from "~/components/ui/button";
19import { useCopyToClipboard } from "~/hooks/useCopyToClipboard";19import { useCopyToClipboard } from "~/hooks/useCopyToClipboard";
20import { buildVisibleToastLayout, shouldHideCollapsedToastContent } from "./toast.logic";20import { buildVisibleToastLayout, shouldHideCollapsedToastContent } from "./toast.logic";
2121
22type ThreadToastData = {22export type ThreadToastData = {
23 threadId?: ThreadId | null;23 threadId?: ThreadId | null;
24 tooltipStyle?: boolean;24 tooltipStyle?: boolean;
25 dismissAfterVisibleMs?: number;25 dismissAfterVisibleMs?: number;