diff --git a/apps/web/src/components/GitActionsControl.browser.tsx b/apps/web/src/components/GitActionsControl.browser.tsx new file mode 100644 index 0000000000..a975a65bbe --- /dev/null +++ b/apps/web/src/components/GitActionsControl.browser.tsx @@ -0,0 +1,238 @@ +import { ThreadId } from "@t3tools/contracts"; +import { useState } from "react"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { render } from "vitest-browser-react"; + +const THREAD_A = ThreadId.makeUnsafe("thread-a"); +const THREAD_B = ThreadId.makeUnsafe("thread-b"); +const GIT_CWD = "/repo/project"; +const BRANCH_NAME = "feature/toast-scope"; + +const { + invalidateGitQueriesSpy, + invalidateGitStatusQuerySpy, + runStackedActionMutateAsyncSpy, + setThreadBranchSpy, + toastAddSpy, + toastCloseSpy, + toastPromiseSpy, + toastUpdateSpy, +} = vi.hoisted(() => ({ + invalidateGitQueriesSpy: vi.fn(() => Promise.resolve()), + invalidateGitStatusQuerySpy: vi.fn(() => Promise.resolve()), + runStackedActionMutateAsyncSpy: vi.fn(() => new Promise(() => undefined)), + setThreadBranchSpy: vi.fn(), + toastAddSpy: vi.fn(() => "toast-1"), + toastCloseSpy: vi.fn(), + toastPromiseSpy: vi.fn(), + toastUpdateSpy: vi.fn(), +})); + +vi.mock("@tanstack/react-query", async () => { + const actual = + await vi.importActual("@tanstack/react-query"); + + return { + ...actual, + useIsMutating: vi.fn(() => 0), + useMutation: vi.fn((options: { __kind?: string }) => { + if (options.__kind === "run-stacked-action") { + return { + mutateAsync: runStackedActionMutateAsyncSpy, + isPending: false, + }; + } + + if (options.__kind === "pull") { + return { + mutateAsync: vi.fn(), + isPending: false, + }; + } + + return { + mutate: vi.fn(), + mutateAsync: vi.fn(), + isPending: false, + }; + }), + useQuery: vi.fn((options: { queryKey?: string[] }) => { + if (options.queryKey?.[0] === "git-status") { + return { + data: { + branch: BRANCH_NAME, + hasWorkingTreeChanges: false, + workingTree: { files: [], insertions: 0, deletions: 0 }, + hasUpstream: true, + aheadCount: 1, + behindCount: 0, + pr: null, + }, + error: null, + }; + } + + if (options.queryKey?.[0] === "git-branches") { + return { + data: { + isRepo: true, + hasOriginRemote: true, + branches: [ + { + name: BRANCH_NAME, + current: true, + isDefault: false, + worktreePath: null, + }, + ], + }, + error: null, + }; + } + + return { data: null, error: null }; + }), + useQueryClient: vi.fn(() => ({})), + }; +}); + +vi.mock("~/components/ui/toast", () => ({ + toastManager: { + add: toastAddSpy, + close: toastCloseSpy, + promise: toastPromiseSpy, + update: toastUpdateSpy, + }, +})); + +vi.mock("~/editorPreferences", () => ({ + openInPreferredEditor: vi.fn(), +})); + +vi.mock("~/lib/gitReactQuery", () => ({ + gitBranchesQueryOptions: vi.fn(() => ({ queryKey: ["git-branches"] })), + gitInitMutationOptions: vi.fn(() => ({ __kind: "init" })), + gitMutationKeys: { + pull: vi.fn(() => ["pull"]), + runStackedAction: vi.fn(() => ["run-stacked-action"]), + }, + gitPullMutationOptions: vi.fn(() => ({ __kind: "pull" })), + gitRunStackedActionMutationOptions: vi.fn(() => ({ __kind: "run-stacked-action" })), + gitStatusQueryOptions: vi.fn(() => ({ queryKey: ["git-status"] })), + invalidateGitQueries: invalidateGitQueriesSpy, + invalidateGitStatusQuery: invalidateGitStatusQuerySpy, +})); + +vi.mock("~/lib/utils", async () => { + const actual = await vi.importActual("~/lib/utils"); + + return { + ...actual, + newCommandId: vi.fn(() => "command-1"), + randomUUID: vi.fn(() => "action-1"), + }; +}); + +vi.mock("~/nativeApi", () => ({ + readNativeApi: vi.fn(() => null), +})); + +vi.mock("~/store", () => ({ + useStore: (selector: (state: unknown) => unknown) => + selector({ + setThreadBranch: setThreadBranchSpy, + threads: [ + { id: THREAD_A, branch: BRANCH_NAME, worktreePath: null }, + { id: THREAD_B, branch: BRANCH_NAME, worktreePath: null }, + ], + }), +})); + +vi.mock("~/terminal-links", () => ({ + resolvePathLinkTarget: vi.fn(), +})); + +import GitActionsControl from "./GitActionsControl"; + +function findButtonByText(text: string): HTMLButtonElement | null { + return (Array.from(document.querySelectorAll("button")).find((button) => + button.textContent?.includes(text), + ) ?? null) as HTMLButtonElement | null; +} + +function Harness() { + const [activeThreadId, setActiveThreadId] = useState(THREAD_A); + + return ( + <> + + + + ); +} + +describe("GitActionsControl thread-scoped progress toast", () => { + afterEach(() => { + vi.useRealTimers(); + vi.clearAllMocks(); + document.body.innerHTML = ""; + }); + + it("keeps an in-flight git action toast pinned to the thread that started it", async () => { + vi.useFakeTimers(); + + const host = document.createElement("div"); + document.body.append(host); + const screen = await render(, { container: host }); + + try { + const quickActionButton = findButtonByText("Push & create PR"); + expect(quickActionButton, 'Unable to find button containing "Push & create PR"').toBeTruthy(); + if (!(quickActionButton instanceof HTMLButtonElement)) { + throw new Error('Unable to find button containing "Push & create PR"'); + } + quickActionButton.click(); + + expect(toastAddSpy).toHaveBeenCalledWith( + expect.objectContaining({ + data: { threadId: THREAD_A }, + title: "Pushing...", + type: "loading", + }), + ); + + await vi.advanceTimersByTimeAsync(1_000); + + expect(toastUpdateSpy).toHaveBeenLastCalledWith( + "toast-1", + expect.objectContaining({ + data: { threadId: THREAD_A }, + title: "Pushing...", + type: "loading", + }), + ); + + const switchThreadButton = findButtonByText("Switch thread"); + expect(switchThreadButton, 'Unable to find button containing "Switch thread"').toBeTruthy(); + if (!(switchThreadButton instanceof HTMLButtonElement)) { + throw new Error('Unable to find button containing "Switch thread"'); + } + switchThreadButton.click(); + await vi.advanceTimersByTimeAsync(1_000); + + expect(toastUpdateSpy).toHaveBeenLastCalledWith( + "toast-1", + expect.objectContaining({ + data: { threadId: THREAD_A }, + title: "Pushing...", + type: "loading", + }), + ); + } finally { + await screen.unmount(); + host.remove(); + } + }); +}); diff --git a/apps/web/src/components/GitActionsControl.tsx b/apps/web/src/components/GitActionsControl.tsx index 6e811e6f4b..9f661f08dc 100644 --- a/apps/web/src/components/GitActionsControl.tsx +++ b/apps/web/src/components/GitActionsControl.tsx @@ -38,7 +38,7 @@ import { Menu, MenuItem, MenuPopup, MenuTrigger } from "~/components/ui/menu"; import { Popover, PopoverPopup, PopoverTrigger } from "~/components/ui/popover"; import { ScrollArea } from "~/components/ui/scroll-area"; import { Textarea } from "~/components/ui/textarea"; -import { toastManager } from "~/components/ui/toast"; +import { toastManager, type ThreadToastData } from "~/components/ui/toast"; import { openInPreferredEditor } from "~/editorPreferences"; import { gitBranchesQueryOptions, @@ -73,6 +73,7 @@ type GitActionToastId = ReturnType; interface ActiveGitActionProgress { toastId: GitActionToastId; + toastData: ThreadToastData | undefined; actionId: string; title: string; phaseStartedAtMs: number | null; @@ -223,6 +224,7 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions const [pendingDefaultBranchAction, setPendingDefaultBranchAction] = useState(null); const activeGitActionProgressRef = useRef(null); + let runGitActionWithToast: (input: RunGitActionWithToastInput) => Promise; const updateActiveProgressToast = useCallback(() => { const progress = activeGitActionProgressRef.current; @@ -234,9 +236,9 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions title: progress.title, description: resolveProgressDescription(progress), timeout: 0, - data: threadToastData, + data: progress.toastData, }); - }, [threadToastData]); + }, []); const persistThreadBranchSync = useCallback( (branch: string | null) => { @@ -402,7 +404,7 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions }); }, [gitStatusForActions, threadToastData]); - const runGitActionWithToast = useEffectEvent( + runGitActionWithToast = useEffectEvent( async ({ action, commitMessage, @@ -455,6 +457,7 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions action === "create_pr" && (!actionStatus?.hasUpstream || (actionStatus?.aheadCount ?? 0) > 0), }); + const scopedToastData = threadToastData ? { ...threadToastData } : undefined; const actionId = randomUUID(); const resolvedProgressToastId = progressToastId ?? @@ -463,11 +466,12 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions title: progressStages[0] ?? "Running git action...", description: "Waiting for Git...", timeout: 0, - data: threadToastData, + data: scopedToastData, }); activeGitActionProgressRef.current = { toastId: resolvedProgressToastId, + toastData: scopedToastData, actionId, title: progressStages[0] ?? "Running git action...", phaseStartedAtMs: null, @@ -483,7 +487,7 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions title: progressStages[0] ?? "Running git action...", description: "Waiting for Git...", timeout: 0, - data: threadToastData, + data: scopedToastData, }); } @@ -587,30 +591,38 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions }; } - toastManager.update(resolvedProgressToastId, { + const successToastBase = { type: "success", title: result.toast.title, description: result.toast.description, timeout: 0, data: { - ...threadToastData, + ...scopedToastData, dismissAfterVisibleMs: 10_000, }, - ...(toastActionProps ? { actionProps: toastActionProps } : {}), - }); + } as const; + + if (toastActionProps) { + toastManager.update(resolvedProgressToastId, { + ...successToastBase, + actionProps: toastActionProps, + }); + } else { + toastManager.update(resolvedProgressToastId, successToastBase); + } } catch (err) { activeGitActionProgressRef.current = null; toastManager.update(resolvedProgressToastId, { type: "error", title: "Action failed", description: err instanceof Error ? err.message : "An error occurred.", - data: threadToastData, + data: scopedToastData, }); } }, ); - const continuePendingDefaultBranchAction = useCallback(() => { + const continuePendingDefaultBranchAction = () => { if (!pendingDefaultBranchAction) return; const { action, commitMessage, onConfirmed, filePaths } = pendingDefaultBranchAction; setPendingDefaultBranchAction(null); @@ -621,9 +633,9 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions ...(filePaths ? { filePaths } : {}), skipDefaultBranchPrompt: true, }); - }, [pendingDefaultBranchAction]); + }; - const checkoutFeatureBranchAndContinuePendingAction = useCallback(() => { + const checkoutFeatureBranchAndContinuePendingAction = () => { if (!pendingDefaultBranchAction) return; const { action, commitMessage, onConfirmed, filePaths } = pendingDefaultBranchAction; setPendingDefaultBranchAction(null); @@ -635,9 +647,9 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions featureBranch: true, skipDefaultBranchPrompt: true, }); - }, [pendingDefaultBranchAction]); + }; - const runDialogActionOnNewBranch = useCallback(() => { + const runDialogActionOnNewBranch = () => { if (!isCommitDialogOpen) return; const commitMessage = dialogCommitMessage.trim(); @@ -653,9 +665,9 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions featureBranch: true, skipDefaultBranchPrompt: true, }); - }, [allSelected, isCommitDialogOpen, dialogCommitMessage, selectedFiles]); + }; - const runQuickAction = useCallback(() => { + const runQuickAction = () => { if (quickAction.kind === "open_pr") { void openExistingPr(); return; @@ -693,31 +705,28 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions if (quickAction.action) { void runGitActionWithToast({ action: quickAction.action }); } - }, [openExistingPr, pullMutation, quickAction, threadToastData]); + }; - const openDialogForMenuItem = useCallback( - (item: GitActionMenuItem) => { - if (item.disabled) return; - if (item.kind === "open_pr") { - void openExistingPr(); - return; - } - if (item.dialogAction === "push") { - void runGitActionWithToast({ action: "push" }); - return; - } - if (item.dialogAction === "create_pr") { - void runGitActionWithToast({ action: "create_pr" }); - return; - } - setExcludedFiles(new Set()); - setIsEditingFiles(false); - setIsCommitDialogOpen(true); - }, - [openExistingPr, setIsCommitDialogOpen], - ); + const openDialogForMenuItem = (item: GitActionMenuItem) => { + if (item.disabled) return; + if (item.kind === "open_pr") { + void openExistingPr(); + return; + } + if (item.dialogAction === "push") { + void runGitActionWithToast({ action: "push" }); + return; + } + if (item.dialogAction === "create_pr") { + void runGitActionWithToast({ action: "create_pr" }); + return; + } + setExcludedFiles(new Set()); + setIsEditingFiles(false); + setIsCommitDialogOpen(true); + }; - const runDialogAction = useCallback(() => { + const runDialogAction = () => { if (!isCommitDialogOpen) return; const commitMessage = dialogCommitMessage.trim(); setIsCommitDialogOpen(false); @@ -729,14 +738,7 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions ...(commitMessage ? { commitMessage } : {}), ...(!allSelected ? { filePaths: selectedFiles.map((f) => f.path) } : {}), }); - }, [ - allSelected, - dialogCommitMessage, - isCommitDialogOpen, - selectedFiles, - setDialogCommitMessage, - setIsCommitDialogOpen, - ]); + }; const openChangedFileInEditor = useCallback( (filePath: string) => { diff --git a/apps/web/src/components/ui/toast.tsx b/apps/web/src/components/ui/toast.tsx index a55e36c195..123bb388cc 100644 --- a/apps/web/src/components/ui/toast.tsx +++ b/apps/web/src/components/ui/toast.tsx @@ -19,7 +19,7 @@ import { buttonVariants } from "~/components/ui/button"; import { useCopyToClipboard } from "~/hooks/useCopyToClipboard"; import { buildVisibleToastLayout, shouldHideCollapsedToastContent } from "./toast.logic"; -type ThreadToastData = { +export type ThreadToastData = { threadId?: ThreadId | null; tooltipStyle?: boolean; dismissAfterVisibleMs?: number;