diff --git a/packages/app/src/components/git-diff-pane.tsx b/packages/app/src/components/git-diff-pane.tsx index 70e7135ba..cbb8b4ec4 100644 --- a/packages/app/src/components/git-diff-pane.tsx +++ b/packages/app/src/components/git-diff-pane.tsx @@ -80,6 +80,7 @@ import { openExternalUrl } from "@/utils/open-external-url"; import { GitActionsSplitButton } from "@/components/git-actions-split-button"; import { usePanelStore } from "@/stores/panel-store"; import { buildWorkspaceExplorerStateKey } from "@/hooks/use-file-explorer-actions"; +import { useToast } from "@/contexts/toast-context"; import { formatDiffContentText, formatDiffGutterText, @@ -607,12 +608,12 @@ type DiffFlatItem = export function GitDiffPane({ serverId, workspaceId, cwd, hideHeaderRow }: GitDiffPaneProps) { const { theme } = useUnistyles(); + const toast = useToast(); const isMobile = useIsCompactFormFactor(); const showDesktopWebScrollbar = Platform.OS === "web" && !isMobile; const canUseSplitLayout = Platform.OS === "web" && !isMobile; const router = useRouter(); const [diffModeOverride, setDiffModeOverride] = useState<"uncommitted" | "base" | null>(null); - const [actionError, setActionError] = useState(null); const [postShipArchiveSuggested, setPostShipArchiveSuggested] = useState(false); const [shipDefault, setShipDefault] = useState<"merge" | "pr">("merge"); const { preferences: changesPreferences, updatePreferences: updateChangesPreferences } = @@ -919,77 +920,106 @@ export function GitDiffPane({ serverId, workspaceId, cwd, hideHeaderRow }: GitDi const runMergeFromBase = useCheckoutGitActionsStore((state) => state.mergeFromBase); const runArchiveWorktree = useCheckoutGitActionsStore((state) => state.archiveWorktree); + const toastActionError = useCallback( + (error: unknown, fallback: string) => { + const message = error instanceof Error ? error.message : fallback; + toast.error(message); + }, + [toast], + ); + + const toastActionSuccess = useCallback( + (message: string) => { + toast.show(message, { variant: "success" }); + }, + [toast], + ); + const handleCommit = useCallback(() => { - setActionError(null); - void runCommit({ serverId, cwd }).catch((err) => { - const message = err instanceof Error ? err.message : "Failed to commit"; - setActionError(message); - }); - }, [runCommit, serverId, cwd]); + void runCommit({ serverId, cwd }) + .then(() => { + toastActionSuccess("Committed"); + }) + .catch((err) => { + toastActionError(err, "Failed to commit"); + }); + }, [cwd, runCommit, serverId, toastActionError, toastActionSuccess]); const handlePush = useCallback(() => { - setActionError(null); - void runPush({ serverId, cwd }).catch((err) => { - const message = err instanceof Error ? err.message : "Failed to push"; - setActionError(message); - }); - }, [runPush, serverId, cwd]); + void runPush({ serverId, cwd }) + .then(() => { + toastActionSuccess("Pushed"); + }) + .catch((err) => { + toastActionError(err, "Failed to push"); + }); + }, [cwd, runPush, serverId, toastActionError, toastActionSuccess]); const handleCreatePr = useCallback(() => { void persistShipDefault("pr"); - setActionError(null); - void runCreatePr({ serverId, cwd }).catch((err) => { - const message = err instanceof Error ? err.message : "Failed to create PR"; - setActionError(message); - }); - }, [persistShipDefault, runCreatePr, serverId, cwd]); + void runCreatePr({ serverId, cwd }) + .then(() => { + toastActionSuccess("PR created"); + }) + .catch((err) => { + toastActionError(err, "Failed to create PR"); + }); + }, [cwd, persistShipDefault, runCreatePr, serverId, toastActionError, toastActionSuccess]); const handleMergeBranch = useCallback(() => { if (!baseRef) { - setActionError("Base ref unavailable"); + toast.error("Base ref unavailable"); return; } void persistShipDefault("merge"); - setActionError(null); void runMergeBranch({ serverId, cwd, baseRef }) .then(() => { setPostShipArchiveSuggested(true); + toastActionSuccess("Merged"); }) .catch((err) => { - const message = err instanceof Error ? err.message : "Failed to merge"; - setActionError(message); + toastActionError(err, "Failed to merge"); }); - }, [baseRef, persistShipDefault, runMergeBranch, serverId, cwd]); + }, [ + baseRef, + cwd, + persistShipDefault, + runMergeBranch, + serverId, + toast, + toastActionError, + toastActionSuccess, + ]); const handleMergeFromBase = useCallback(() => { if (!baseRef) { - setActionError("Base ref unavailable"); + toast.error("Base ref unavailable"); return; } - setActionError(null); - void runMergeFromBase({ serverId, cwd, baseRef }).catch((err) => { - const message = err instanceof Error ? err.message : "Failed to merge from base"; - setActionError(message); - }); - }, [baseRef, runMergeFromBase, serverId, cwd]); + void runMergeFromBase({ serverId, cwd, baseRef }) + .then(() => { + toastActionSuccess("Updated"); + }) + .catch((err) => { + toastActionError(err, "Failed to merge from base"); + }); + }, [baseRef, cwd, runMergeFromBase, serverId, toast, toastActionError, toastActionSuccess]); const handleArchiveWorktree = useCallback(() => { const worktreePath = status?.cwd; if (!worktreePath) { - setActionError("Worktree path unavailable"); + toast.error("Worktree path unavailable"); return; } - setActionError(null); const targetWorkingDir = resolveNewAgentWorkingDir(cwd, status ?? null); void runArchiveWorktree({ serverId, cwd, worktreePath }) .then(() => { router.replace(buildNewAgentRoute(serverId, targetWorkingDir)); }) .catch((err) => { - const message = err instanceof Error ? err.message : "Failed to archive worktree"; - setActionError(message); + toastActionError(err, "Failed to archive worktree"); }); - }, [runArchiveWorktree, router, serverId, cwd, status]); + }, [cwd, router, runArchiveWorktree, serverId, status, toast, toastActionError]); const renderFlatItem = useCallback( ({ item }: { item: DiffFlatItem }) => { @@ -1458,7 +1488,6 @@ export function GitDiffPane({ serverId, workspaceId, cwd, hideHeaderRow }: GitDi ) : null} - {actionError ? {actionError} : null} {prErrorMessage ? {prErrorMessage} : null} diff --git a/packages/app/src/components/toast-host.tsx b/packages/app/src/components/toast-host.tsx index 9920d660b..8b4916875 100644 --- a/packages/app/src/components/toast-host.tsx +++ b/packages/app/src/components/toast-host.tsx @@ -47,6 +47,7 @@ export function useToastHost(): { toast: ToastState | null; dismiss: () => void; } { + const { theme } = useUnistyles(); const [toast, setToast] = useState(null); const idRef = useRef(0); @@ -84,11 +85,11 @@ export function useToastHost(): { copied: (label?: string) => show(label ? `Copied ${label}` : "Copied", { variant: "success", - icon: , + icon: , }), error: (message: string) => show(message, { variant: "error", durationMs: 3200 }), }), - [show], + [show, theme.colors.foreground], ); const dismiss = useCallback(() => { @@ -113,6 +114,8 @@ export function ToastViewport({ const opacity = useRef(new Animated.Value(0)).current; const translateY = useRef(new Animated.Value(-8)).current; const timeoutRef = useRef | null>(null); + const dismissDeadlineRef = useRef(null); + const remainingDurationRef = useRef(0); const clearTimer = useCallback(() => { if (timeoutRef.current) { @@ -143,9 +146,39 @@ export function ToastViewport({ }); }, [clearTimer, onDismiss, opacity, translateY]); + const scheduleDismiss = useCallback( + (durationMs: number) => { + clearTimer(); + const nextDurationMs = Math.max(0, durationMs); + remainingDurationRef.current = nextDurationMs; + dismissDeadlineRef.current = Date.now() + nextDurationMs; + timeoutRef.current = setTimeout(() => { + animateOut(); + }, nextDurationMs); + }, + [animateOut, clearTimer], + ); + + const pauseDismiss = useCallback(() => { + if (dismissDeadlineRef.current !== null) { + remainingDurationRef.current = Math.max(0, dismissDeadlineRef.current - Date.now()); + } + dismissDeadlineRef.current = null; + clearTimer(); + }, [clearTimer]); + + const resumeDismiss = useCallback(() => { + if (!toast) { + return; + } + scheduleDismiss(remainingDurationRef.current || toast.durationMs); + }, [scheduleDismiss, toast]); + useEffect(() => { if (!toast) { clearTimer(); + dismissDeadlineRef.current = null; + remainingDurationRef.current = 0; opacity.setValue(0); translateY.setValue(-8); return; @@ -170,14 +203,13 @@ export function ToastViewport({ }), ]).start(); - timeoutRef.current = setTimeout(() => { - animateOut(); - }, toast.durationMs); + remainingDurationRef.current = toast.durationMs; + scheduleDismiss(toast.durationMs); return () => { clearTimer(); }; - }, [animateOut, clearTimer, opacity, toast, translateY]); + }, [clearTimer, opacity, scheduleDismiss, toast, translateY]); if (!toast) { return null; @@ -202,6 +234,8 @@ export function ToastViewport({ {toast.content} @@ -255,7 +288,7 @@ const styles = StyleSheet.create((theme) => ({ alignItems: "center", gap: theme.spacing[2], backgroundColor: theme.colors.surface0, - borderRadius: theme.borderRadius.full, + borderRadius: theme.borderRadius["2xl"], borderWidth: theme.borderWidth[1], borderColor: theme.colors.border, paddingVertical: theme.spacing[2], diff --git a/packages/app/src/hooks/use-git-actions.ts b/packages/app/src/hooks/use-git-actions.ts index 9856685b2..535cd3687 100644 --- a/packages/app/src/hooks/use-git-actions.ts +++ b/packages/app/src/hooks/use-git-actions.ts @@ -7,6 +7,7 @@ import { useCheckoutPrStatusQuery } from "@/hooks/use-checkout-pr-status-query"; import { buildGitActions, type GitActions } from "@/components/git-actions-policy"; import { buildNewAgentRoute, resolveNewAgentWorkingDir } from "@/utils/new-agent-routing"; import { openExternalUrl } from "@/utils/open-external-url"; +import { useToast } from "@/contexts/toast-context"; export type { GitActionId, GitAction, GitActions } from "@/components/git-actions-policy"; @@ -31,13 +32,12 @@ interface UseGitActionsInput { interface UseGitActionsResult { gitActions: GitActions; branchLabel: string; - actionError: string | null; isGit: boolean; } export function useGitActions({ serverId, cwd, icons }: UseGitActionsInput): UseGitActionsResult { const router = useRouter(); - const [actionError, setActionError] = useState(null); + const toast = useToast(); const [postShipArchiveSuggested, setPostShipArchiveSuggested] = useState(false); const [shipDefault, setShipDefault] = useState<"merge" | "pr">("merge"); @@ -125,78 +125,107 @@ export function useGitActions({ serverId, cwd, icons }: UseGitActionsInput): Use const runMergeFromBase = useCheckoutGitActionsStore((state) => state.mergeFromBase); const runArchiveWorktree = useCheckoutGitActionsStore((state) => state.archiveWorktree); + const toastActionError = useCallback( + (error: unknown, fallback: string) => { + const message = error instanceof Error ? error.message : fallback; + toast.error(message); + }, + [toast], + ); + + const toastActionSuccess = useCallback( + (message: string) => { + toast.show(message, { variant: "success" }); + }, + [toast], + ); + // Handlers const handleCommit = useCallback(() => { - setActionError(null); - void runCommit({ serverId, cwd }).catch((err) => { - const message = err instanceof Error ? err.message : "Failed to commit"; - setActionError(message); - }); - }, [runCommit, serverId, cwd]); + void runCommit({ serverId, cwd }) + .then(() => { + toastActionSuccess("Committed"); + }) + .catch((err) => { + toastActionError(err, "Failed to commit"); + }); + }, [cwd, runCommit, serverId, toastActionError, toastActionSuccess]); const handlePush = useCallback(() => { - setActionError(null); - void runPush({ serverId, cwd }).catch((err) => { - const message = err instanceof Error ? err.message : "Failed to push"; - setActionError(message); - }); - }, [runPush, serverId, cwd]); + void runPush({ serverId, cwd }) + .then(() => { + toastActionSuccess("Pushed"); + }) + .catch((err) => { + toastActionError(err, "Failed to push"); + }); + }, [cwd, runPush, serverId, toastActionError, toastActionSuccess]); const handleCreatePr = useCallback(() => { void persistShipDefault("pr"); - setActionError(null); - void runCreatePr({ serverId, cwd }).catch((err) => { - const message = err instanceof Error ? err.message : "Failed to create PR"; - setActionError(message); - }); - }, [persistShipDefault, runCreatePr, serverId, cwd]); + void runCreatePr({ serverId, cwd }) + .then(() => { + toastActionSuccess("PR created"); + }) + .catch((err) => { + toastActionError(err, "Failed to create PR"); + }); + }, [cwd, persistShipDefault, runCreatePr, serverId, toastActionError, toastActionSuccess]); const handleMergeBranch = useCallback(() => { if (!baseRef) { - setActionError("Base ref unavailable"); + toast.error("Base ref unavailable"); return; } void persistShipDefault("merge"); - setActionError(null); void runMergeBranch({ serverId, cwd, baseRef }) .then(() => { setPostShipArchiveSuggested(true); + toastActionSuccess("Merged"); }) .catch((err) => { - const message = err instanceof Error ? err.message : "Failed to merge"; - setActionError(message); + toastActionError(err, "Failed to merge"); }); - }, [baseRef, persistShipDefault, runMergeBranch, serverId, cwd]); + }, [ + baseRef, + cwd, + persistShipDefault, + runMergeBranch, + serverId, + toast, + toastActionError, + toastActionSuccess, + ]); const handleMergeFromBase = useCallback(() => { if (!baseRef) { - setActionError("Base ref unavailable"); + toast.error("Base ref unavailable"); return; } - setActionError(null); - void runMergeFromBase({ serverId, cwd, baseRef }).catch((err) => { - const message = err instanceof Error ? err.message : "Failed to merge from base"; - setActionError(message); - }); - }, [baseRef, runMergeFromBase, serverId, cwd]); + void runMergeFromBase({ serverId, cwd, baseRef }) + .then(() => { + toastActionSuccess("Updated"); + }) + .catch((err) => { + toastActionError(err, "Failed to merge from base"); + }); + }, [baseRef, cwd, runMergeFromBase, serverId, toast, toastActionError, toastActionSuccess]); const handleArchiveWorktree = useCallback(() => { const worktreePath = status?.cwd; if (!worktreePath) { - setActionError("Worktree path unavailable"); + toast.error("Worktree path unavailable"); return; } - setActionError(null); const targetWorkingDir = resolveNewAgentWorkingDir(cwd, status ?? null); void runArchiveWorktree({ serverId, cwd, worktreePath }) .then(() => { router.replace(buildNewAgentRoute(serverId, targetWorkingDir)); }) .catch((err) => { - const message = err instanceof Error ? err.message : "Failed to archive worktree"; - setActionError(message); + toastActionError(err, "Failed to archive worktree"); }); - }, [runArchiveWorktree, router, serverId, cwd, status]); + }, [cwd, router, runArchiveWorktree, serverId, status, toast, toastActionError]); // Derived state const actionsDisabled = !isGit || Boolean(status?.error) || isStatusLoading; @@ -333,5 +362,5 @@ export function useGitActions({ serverId, cwd, icons }: UseGitActionsInput): Use baseRef, ]); - return { gitActions, branchLabel, actionError, isGit }; + return { gitActions, branchLabel, isGit }; }