import { type ScopedThreadRef } from "@t3tools/contracts";import type { GitActionProgressEvent, GitRunStackedActionResult, GitStackedAction, GitStatusResult,} from "@t3tools/contracts";import { useIsMutating, useMutation, useQueryClient } from "@tanstack/react-query";import { useCallback, useEffect, useEffectEvent, useMemo, useRef, useState } from "react";import { ChevronDownIcon, CloudUploadIcon, GitCommitIcon, InfoIcon } from "lucide-react";import { GitHubIcon } from "./Icons";import { buildGitActionProgressStages, buildMenuItems, getGitActionDisabledReason, type GitActionIconName, type GitActionMenuItem, type GitQuickAction, type DefaultBranchConfirmableAction, requiresDefaultBranchConfirmation, resolveDefaultBranchActionDialogCopy, resolveLiveThreadBranchUpdate, resolveQuickAction, resolveThreadBranchUpdate,} from "./GitActionsControl.logic";import { Button } from "~/components/ui/button";import { Checkbox } from "~/components/ui/checkbox";import { Dialog, DialogDescription, DialogFooter, DialogHeader, DialogPanel, DialogPopup, DialogTitle,} from "~/components/ui/dialog";import { Group, GroupSeparator } from "~/components/ui/group";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, type ThreadToastData } from "~/components/ui/toast";import { openInPreferredEditor } from "~/editorPreferences";import { gitInitMutationOptions, gitMutationKeys, gitPullMutationOptions, gitRunStackedActionMutationOptions,} from "~/lib/gitReactQuery";import { refreshGitStatus, useGitStatus } from "~/lib/gitStatusState";import { newCommandId, randomUUID } from "~/lib/utils";import { resolvePathLinkTarget } from "~/terminal-links";import { type DraftId, useComposerDraftStore } from "~/composerDraftStore";import { readEnvironmentApi } from "~/environmentApi";import { readLocalApi } from "~/localApi";import { useStore } from "~/store";import { createThreadSelectorByRef } from "~/storeSelectors"; interface GitActionsControlProps { gitCwd: string | null; activeThreadRef: ScopedThreadRef | null; draftId?: DraftId;} interface PendingDefaultBranchAction { action: DefaultBranchConfirmableAction; branchName: string; includesCommit: boolean; commitMessage?: string; onConfirmed?: () => void; filePaths?: string[];} type GitActionToastId = ReturnType<typeof toastManager.add>; interface ActiveGitActionProgress { toastId: GitActionToastId; toastData: ThreadToastData | undefined; actionId: string; title: string; phaseStartedAtMs: number | null; hookStartedAtMs: number | null; hookName: string | null; lastOutputLine: string | null; currentPhaseLabel: string | null;} interface RunGitActionWithToastInput { action: GitStackedAction; commitMessage?: string; onConfirmed?: () => void; skipDefaultBranchPrompt?: boolean; statusOverride?: GitStatusResult | null; featureBranch?: boolean; progressToastId?: GitActionToastId; filePaths?: string[];} const GIT_STATUS_WINDOW_REFRESH_DEBOUNCE_MS = 250; function formatElapsedDescription(startedAtMs: number | null): string | undefined { if (startedAtMs === null) { return undefined; } const elapsedSeconds = Math.max(0, Math.floor((Date.now() - startedAtMs) / 1000)); if (elapsedSeconds < 60) { return `Running for ${elapsedSeconds}s`; } const minutes = Math.floor(elapsedSeconds / 60); const seconds = elapsedSeconds % 60; return `Running for ${minutes}m ${seconds}s`;} function resolveProgressDescription(progress: ActiveGitActionProgress): string | undefined { if (progress.lastOutputLine) { return progress.lastOutputLine; } return formatElapsedDescription(progress.hookStartedAtMs ?? progress.phaseStartedAtMs);} const COMMIT_DIALOG_TITLE = "Commit changes";const COMMIT_DIALOG_DESCRIPTION = "Review and confirm your commit. Leave the message blank to auto-generate one."; function GitActionItemIcon({ icon }: { icon: GitActionIconName }) { if (icon === "commit") return <GitCommitIcon />; if (icon === "push") return <CloudUploadIcon />; return <GitHubIcon />;} function GitQuickActionIcon({ quickAction }: { quickAction: GitQuickAction }) { const iconClassName = "size-3.5"; if (quickAction.kind === "open_pr") return <GitHubIcon className={iconClassName} />; if (quickAction.kind === "run_pull") return <InfoIcon className={iconClassName} />; if (quickAction.kind === "run_action") { if (quickAction.action === "commit") return <GitCommitIcon className={iconClassName} />; if (quickAction.action === "push" || quickAction.action === "commit_push") { return <CloudUploadIcon className={iconClassName} />; } return <GitHubIcon className={iconClassName} />; } if (quickAction.label === "Commit") return <GitCommitIcon className={iconClassName} />; return <InfoIcon className={iconClassName} />;} export default function GitActionsControl({ gitCwd, activeThreadRef, draftId,}: GitActionsControlProps) { const activeEnvironmentId = activeThreadRef?.environmentId ?? null; const threadToastData = useMemo( () => (activeThreadRef ? { threadRef: activeThreadRef } : undefined), [activeThreadRef], ); const activeServerThreadSelector = useMemo( () => createThreadSelectorByRef(activeThreadRef), [activeThreadRef], ); const activeServerThread = useStore(activeServerThreadSelector); const activeDraftThread = useComposerDraftStore((store) => draftId ? store.getDraftSession(draftId) : activeThreadRef ? store.getDraftThreadByRef(activeThreadRef) : null, ); const setDraftThreadContext = useComposerDraftStore((store) => store.setDraftThreadContext); const setThreadBranch = useStore((store) => store.setThreadBranch); const queryClient = useQueryClient(); const [isCommitDialogOpen, setIsCommitDialogOpen] = useState(false); const [dialogCommitMessage, setDialogCommitMessage] = useState(""); const [excludedFiles, setExcludedFiles] = useState<ReadonlySet<string>>(new Set()); const [isEditingFiles, setIsEditingFiles] = useState(false); const [pendingDefaultBranchAction, setPendingDefaultBranchAction] = useState<PendingDefaultBranchAction | null>(null); const activeGitActionProgressRef = useRef<ActiveGitActionProgress | null>(null); let runGitActionWithToast: (input: RunGitActionWithToastInput) => Promise<void>; const updateActiveProgressToast = useCallback(() => { const progress = activeGitActionProgressRef.current; if (!progress) { return; } toastManager.update(progress.toastId, { type: "loading", title: progress.title, description: resolveProgressDescription(progress), timeout: 0, data: progress.toastData, }); }, []); const persistThreadBranchSync = useCallback( (branch: string | null) => { if (!activeThreadRef) { return; } if (activeServerThread) { if (activeServerThread.branch === branch) { return; } const worktreePath = activeServerThread.worktreePath; const api = readEnvironmentApi(activeThreadRef.environmentId); if (api) { void api.orchestration .dispatchCommand({ type: "thread.meta.update", commandId: newCommandId(), threadId: activeThreadRef.threadId, branch, worktreePath, }) .catch(() => undefined); } setThreadBranch(activeThreadRef, branch, worktreePath); return; } if (!activeDraftThread || activeDraftThread.branch === branch) { return; } setDraftThreadContext(draftId ?? activeThreadRef, { branch, worktreePath: activeDraftThread.worktreePath, }); }, [ activeDraftThread, activeServerThread, activeThreadRef, draftId, setDraftThreadContext, setThreadBranch, ], ); const syncThreadBranchAfterGitAction = useCallback( (result: GitRunStackedActionResult) => { const branchUpdate = resolveThreadBranchUpdate(result); if (!branchUpdate) { return; } persistThreadBranchSync(branchUpdate.branch); }, [persistThreadBranchSync], ); const { data: gitStatus = null, error: gitStatusError } = useGitStatus({ environmentId: activeEnvironmentId, cwd: gitCwd, }); // Default to true while loading so we don't flash init controls. const isRepo = gitStatus?.isRepo ?? true; const hasOriginRemote = gitStatus?.hasOriginRemote ?? false; const gitStatusForActions = gitStatus; const allFiles = gitStatusForActions?.workingTree.files ?? []; const selectedFiles = allFiles.filter((f) => !excludedFiles.has(f.path)); const allSelected = excludedFiles.size === 0; const noneSelected = selectedFiles.length === 0; const initMutation = useMutation( gitInitMutationOptions({ environmentId: activeEnvironmentId, cwd: gitCwd, queryClient }), ); const runImmediateGitActionMutation = useMutation( gitRunStackedActionMutationOptions({ environmentId: activeEnvironmentId, cwd: gitCwd, queryClient, }), ); const pullMutation = useMutation( gitPullMutationOptions({ environmentId: activeEnvironmentId, cwd: gitCwd, queryClient }), ); const isRunStackedActionRunning = useIsMutating({ mutationKey: gitMutationKeys.runStackedAction(activeEnvironmentId, gitCwd), }) > 0; const isPullRunning = useIsMutating({ mutationKey: gitMutationKeys.pull(activeEnvironmentId, gitCwd) }) > 0; const isGitActionRunning = isRunStackedActionRunning || isPullRunning; const isSelectingWorktreeBase = !activeServerThread && activeDraftThread?.envMode === "worktree" && activeDraftThread.worktreePath === null; useEffect(() => { if (isGitActionRunning || isSelectingWorktreeBase) { return; } const branchUpdate = resolveLiveThreadBranchUpdate({ threadBranch: activeServerThread?.branch ?? activeDraftThread?.branch ?? null, gitStatus: gitStatusForActions, }); if (!branchUpdate) { return; } persistThreadBranchSync(branchUpdate.branch); }, [ activeServerThread?.branch, activeDraftThread?.branch, gitStatusForActions, isGitActionRunning, isSelectingWorktreeBase, persistThreadBranchSync, ]); const isDefaultBranch = useMemo(() => { return gitStatusForActions?.isDefaultBranch ?? false; }, [gitStatusForActions?.isDefaultBranch]); const gitActionMenuItems = useMemo( () => buildMenuItems(gitStatusForActions, isGitActionRunning, hasOriginRemote), [gitStatusForActions, hasOriginRemote, isGitActionRunning], ); const quickAction = useMemo( () => resolveQuickAction(gitStatusForActions, isGitActionRunning, isDefaultBranch, hasOriginRemote), [gitStatusForActions, hasOriginRemote, isDefaultBranch, isGitActionRunning], ); const quickActionDisabledReason = quickAction.disabled ? (quickAction.hint ?? "This action is currently unavailable.") : null; const pendingDefaultBranchActionCopy = pendingDefaultBranchAction ? resolveDefaultBranchActionDialogCopy({ action: pendingDefaultBranchAction.action, branchName: pendingDefaultBranchAction.branchName, includesCommit: pendingDefaultBranchAction.includesCommit, }) : null; useEffect(() => { const interval = window.setInterval(() => { if (!activeGitActionProgressRef.current) { return; } updateActiveProgressToast(); }, 1000); return () => { window.clearInterval(interval); }; }, [updateActiveProgressToast]); useEffect(() => { if (gitCwd === null) { return; } let refreshTimeout: number | null = null; const scheduleRefreshCurrentGitStatus = () => { if (refreshTimeout !== null) { window.clearTimeout(refreshTimeout); } refreshTimeout = window.setTimeout(() => { refreshTimeout = null; void refreshGitStatus({ environmentId: activeEnvironmentId, cwd: gitCwd }).catch( () => undefined, ); }, GIT_STATUS_WINDOW_REFRESH_DEBOUNCE_MS); }; const handleVisibilityChange = () => { if (document.visibilityState === "visible") { scheduleRefreshCurrentGitStatus(); } }; window.addEventListener("focus", scheduleRefreshCurrentGitStatus); document.addEventListener("visibilitychange", handleVisibilityChange); return () => { if (refreshTimeout !== null) { window.clearTimeout(refreshTimeout); } window.removeEventListener("focus", scheduleRefreshCurrentGitStatus); document.removeEventListener("visibilitychange", handleVisibilityChange); }; }, [activeEnvironmentId, gitCwd]); const openExistingPr = useCallback(async () => { const api = readLocalApi(); if (!api) { toastManager.add({ type: "error", title: "Link opening is unavailable.", data: threadToastData, }); return; } const prUrl = gitStatusForActions?.pr?.state === "open" ? gitStatusForActions.pr.url : null; if (!prUrl) { toastManager.add({ type: "error", title: "No open PR found.", data: threadToastData, }); return; } void api.shell.openExternal(prUrl).catch((err: unknown) => { toastManager.add({ type: "error", title: "Unable to open PR link", description: err instanceof Error ? err.message : "An error occurred.", data: threadToastData, }); }); }, [gitStatusForActions, threadToastData]); runGitActionWithToast = useEffectEvent( async ({ action, commitMessage, onConfirmed, skipDefaultBranchPrompt = false, statusOverride, featureBranch = false, progressToastId, filePaths, }: RunGitActionWithToastInput) => { const actionStatus = statusOverride ?? gitStatusForActions; const actionBranch = actionStatus?.branch ?? null; const actionIsDefaultBranch = featureBranch ? false : isDefaultBranch; const actionCanCommit = action === "commit" || action === "commit_push" || action === "commit_push_pr"; const includesCommit = actionCanCommit && (action === "commit" || !!actionStatus?.hasWorkingTreeChanges || featureBranch); if ( !skipDefaultBranchPrompt && requiresDefaultBranchConfirmation(action, actionIsDefaultBranch) && actionBranch ) { if ( action !== "push" && action !== "create_pr" && action !== "commit_push" && action !== "commit_push_pr" ) { return; } setPendingDefaultBranchAction({ action, branchName: actionBranch, includesCommit, ...(commitMessage ? { commitMessage } : {}), ...(onConfirmed ? { onConfirmed } : {}), ...(filePaths ? { filePaths } : {}), }); return; } onConfirmed?.(); const progressStages = buildGitActionProgressStages({ action, hasCustomCommitMessage: !!commitMessage?.trim(), hasWorkingTreeChanges: !!actionStatus?.hasWorkingTreeChanges, featureBranch, shouldPushBeforePr: action === "create_pr" && (!actionStatus?.hasUpstream || (actionStatus?.aheadCount ?? 0) > 0), }); const scopedToastData = threadToastData ? { ...threadToastData } : undefined; const actionId = randomUUID(); const resolvedProgressToastId = progressToastId ?? toastManager.add({ type: "loading", title: progressStages[0] ?? "Running git action...", description: "Waiting for Git...", timeout: 0, data: scopedToastData, }); activeGitActionProgressRef.current = { toastId: resolvedProgressToastId, toastData: scopedToastData, actionId, title: progressStages[0] ?? "Running git action...", phaseStartedAtMs: null, hookStartedAtMs: null, hookName: null, lastOutputLine: null, currentPhaseLabel: progressStages[0] ?? "Running git action...", }; if (progressToastId) { toastManager.update(progressToastId, { type: "loading", title: progressStages[0] ?? "Running git action...", description: "Waiting for Git...",Lines 1–500 of 1106. Load more
import { type ScopedThreadRef } from "@t3tools/contracts"; import type { GitActionProgressEvent, GitRunStackedActionResult, GitStackedAction, GitStatusResult, } from "@t3tools/contracts"; import { useIsMutating, useMutation, useQueryClient } from "@tanstack/react-query"; import { useCallback, useEffect, useEffectEvent, useMemo, useRef, useState } from "react"; import { ChevronDownIcon, CloudUploadIcon, GitCommitIcon, InfoIcon } from "lucide-react"; import { GitHubIcon } from "./Icons"; import { buildGitActionProgressStages, buildMenuItems, getGitActionDisabledReason, type GitActionIconName, type GitActionMenuItem, type GitQuickAction, type DefaultBranchConfirmableAction, requiresDefaultBranchConfirmation, resolveDefaultBranchActionDialogCopy, resolveLiveThreadBranchUpdate, resolveQuickAction, resolveThreadBranchUpdate, } from "./GitActionsControl.logic"; import { Button } from "~/components/ui/button"; import { Checkbox } from "~/components/ui/checkbox"; import { Dialog, DialogDescription, DialogFooter, DialogHeader, DialogPanel, DialogPopup, DialogTitle, } from "~/components/ui/dialog"; import { Group, GroupSeparator } from "~/components/ui/group"; 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, type ThreadToastData } from "~/components/ui/toast"; import { openInPreferredEditor } from "~/editorPreferences"; import { gitInitMutationOptions, gitMutationKeys, gitPullMutationOptions, gitRunStackedActionMutationOptions, } from "~/lib/gitReactQuery"; import { refreshGitStatus, useGitStatus } from "~/lib/gitStatusState"; import { newCommandId, randomUUID } from "~/lib/utils"; import { resolvePathLinkTarget } from "~/terminal-links"; import { type DraftId, useComposerDraftStore } from "~/composerDraftStore"; import { readEnvironmentApi } from "~/environmentApi"; import { readLocalApi } from "~/localApi"; import { useStore } from "~/store"; import { createThreadSelectorByRef } from "~/storeSelectors"; interface GitActionsControlProps { gitCwd: string | null; activeThreadRef: ScopedThreadRef | null; draftId?: DraftId; } interface PendingDefaultBranchAction { action: DefaultBranchConfirmableAction; branchName: string; includesCommit: boolean; commitMessage?: string; onConfirmed?: () => void; filePaths?: string[]; } type GitActionToastId = ReturnType<typeof toastManager.add>; interface ActiveGitActionProgress { toastId: GitActionToastId; toastData: ThreadToastData | undefined; actionId: string; title: string; phaseStartedAtMs: number | null; hookStartedAtMs: number | null; hookName: string | null; lastOutputLine: string | null; currentPhaseLabel: string | null; } interface RunGitActionWithToastInput { action: GitStackedAction; commitMessage?: string; onConfirmed?: () => void; skipDefaultBranchPrompt?: boolean; statusOverride?: GitStatusResult | null; featureBranch?: boolean; progressToastId?: GitActionToastId; filePaths?: string[]; } const GIT_STATUS_WINDOW_REFRESH_DEBOUNCE_MS = 250; function formatElapsedDescription(startedAtMs: number | null): string | undefined { if (startedAtMs === null) { return undefined; } const elapsedSeconds = Math.max(0, Math.floor((Date.now() - startedAtMs) / 1000)); if (elapsedSeconds < 60) { return `Running for ${elapsedSeconds}s`; } const minutes = Math.floor(elapsedSeconds / 60); const seconds = elapsedSeconds % 60; return `Running for ${minutes}m ${seconds}s`; } function resolveProgressDescription(progress: ActiveGitActionProgress): string | undefined { if (progress.lastOutputLine) { return progress.lastOutputLine; } return formatElapsedDescription(progress.hookStartedAtMs ?? progress.phaseStartedAtMs); } const COMMIT_DIALOG_TITLE = "Commit changes"; const COMMIT_DIALOG_DESCRIPTION = "Review and confirm your commit. Leave the message blank to auto-generate one."; function GitActionItemIcon({ icon }: { icon: GitActionIconName }) { if (icon === "commit") return <GitCommitIcon />; if (icon === "push") return <CloudUploadIcon />; return <GitHubIcon />; } function GitQuickActionIcon({ quickAction }: { quickAction: GitQuickAction }) { const iconClassName = "size-3.5"; if (quickAction.kind === "open_pr") return <GitHubIcon className={iconClassName} />; if (quickAction.kind === "run_pull") return <InfoIcon className={iconClassName} />; if (quickAction.kind === "run_action") { if (quickAction.action === "commit") return <GitCommitIcon className={iconClassName} />; if (quickAction.action === "push" || quickAction.action === "commit_push") { return <CloudUploadIcon className={iconClassName} />; } return <GitHubIcon className={iconClassName} />; } if (quickAction.label === "Commit") return <GitCommitIcon className={iconClassName} />; return <InfoIcon className={iconClassName} />; } export default function GitActionsControl({ gitCwd, activeThreadRef, draftId, }: GitActionsControlProps) { const activeEnvironmentId = activeThreadRef?.environmentId ?? null; const threadToastData = useMemo( () => (activeThreadRef ? { threadRef: activeThreadRef } : undefined), [activeThreadRef], ); const activeServerThreadSelector = useMemo( () => createThreadSelectorByRef(activeThreadRef), [activeThreadRef], ); const activeServerThread = useStore(activeServerThreadSelector); const activeDraftThread = useComposerDraftStore((store) => draftId ? store.getDraftSession(draftId) : activeThreadRef ? store.getDraftThreadByRef(activeThreadRef) : null, ); const setDraftThreadContext = useComposerDraftStore((store) => store.setDraftThreadContext); const setThreadBranch = useStore((store) => store.setThreadBranch); const queryClient = useQueryClient(); const [isCommitDialogOpen, setIsCommitDialogOpen] = useState(false); const [dialogCommitMessage, setDialogCommitMessage] = useState(""); const [excludedFiles, setExcludedFiles] = useState<ReadonlySet<string>>(new Set()); const [isEditingFiles, setIsEditingFiles] = useState(false); const [pendingDefaultBranchAction, setPendingDefaultBranchAction] = useState<PendingDefaultBranchAction | null>(null); const activeGitActionProgressRef = useRef<ActiveGitActionProgress | null>(null); let runGitActionWithToast: (input: RunGitActionWithToastInput) => Promise<void>; const updateActiveProgressToast = useCallback(() => { const progress = activeGitActionProgressRef.current; if (!progress) { return; } toastManager.update(progress.toastId, { type: "loading", title: progress.title, description: resolveProgressDescription(progress), timeout: 0, data: progress.toastData, }); }, []); const persistThreadBranchSync = useCallback( (branch: string | null) => { if (!activeThreadRef) { return; } if (activeServerThread) { if (activeServerThread.branch === branch) { return; } const worktreePath = activeServerThread.worktreePath; const api = readEnvironmentApi(activeThreadRef.environmentId); if (api) { void api.orchestration .dispatchCommand({ type: "thread.meta.update", commandId: newCommandId(), threadId: activeThreadRef.threadId, branch, worktreePath, }) .catch(() => undefined); } setThreadBranch(activeThreadRef, branch, worktreePath); return; } if (!activeDraftThread || activeDraftThread.branch === branch) { return; } setDraftThreadContext(draftId ?? activeThreadRef, { branch, worktreePath: activeDraftThread.worktreePath, }); }, [ activeDraftThread, activeServerThread, activeThreadRef, draftId, setDraftThreadContext, setThreadBranch, ], ); const syncThreadBranchAfterGitAction = useCallback( (result: GitRunStackedActionResult) => { const branchUpdate = resolveThreadBranchUpdate(result); if (!branchUpdate) { return; } persistThreadBranchSync(branchUpdate.branch); }, [persistThreadBranchSync], ); const { data: gitStatus = null, error: gitStatusError } = useGitStatus({ environmentId: activeEnvironmentId, cwd: gitCwd, }); // Default to true while loading so we don't flash init controls. const isRepo = gitStatus?.isRepo ?? true; const hasOriginRemote = gitStatus?.hasOriginRemote ?? false; const gitStatusForActions = gitStatus; const allFiles = gitStatusForActions?.workingTree.files ?? []; const selectedFiles = allFiles.filter((f) => !excludedFiles.has(f.path)); const allSelected = excludedFiles.size === 0; const noneSelected = selectedFiles.length === 0; const initMutation = useMutation( gitInitMutationOptions({ environmentId: activeEnvironmentId, cwd: gitCwd, queryClient }), ); const runImmediateGitActionMutation = useMutation( gitRunStackedActionMutationOptions({ environmentId: activeEnvironmentId, cwd: gitCwd, queryClient, }), ); const pullMutation = useMutation( gitPullMutationOptions({ environmentId: activeEnvironmentId, cwd: gitCwd, queryClient }), ); const isRunStackedActionRunning = useIsMutating({ mutationKey: gitMutationKeys.runStackedAction(activeEnvironmentId, gitCwd), }) > 0; const isPullRunning = useIsMutating({ mutationKey: gitMutationKeys.pull(activeEnvironmentId, gitCwd) }) > 0; const isGitActionRunning = isRunStackedActionRunning || isPullRunning; const isSelectingWorktreeBase = !activeServerThread && activeDraftThread?.envMode === "worktree" && activeDraftThread.worktreePath === null; useEffect(() => { if (isGitActionRunning || isSelectingWorktreeBase) { return; } const branchUpdate = resolveLiveThreadBranchUpdate({ threadBranch: activeServerThread?.branch ?? activeDraftThread?.branch ?? null, gitStatus: gitStatusForActions, }); if (!branchUpdate) { return; } persistThreadBranchSync(branchUpdate.branch); }, [ activeServerThread?.branch, activeDraftThread?.branch, gitStatusForActions, isGitActionRunning, isSelectingWorktreeBase, persistThreadBranchSync, ]); const isDefaultBranch = useMemo(() => { return gitStatusForActions?.isDefaultBranch ?? false; }, [gitStatusForActions?.isDefaultBranch]); const gitActionMenuItems = useMemo( () => buildMenuItems(gitStatusForActions, isGitActionRunning, hasOriginRemote), [gitStatusForActions, hasOriginRemote, isGitActionRunning], ); const quickAction = useMemo( () => resolveQuickAction(gitStatusForActions, isGitActionRunning, isDefaultBranch, hasOriginRemote), [gitStatusForActions, hasOriginRemote, isDefaultBranch, isGitActionRunning], ); const quickActionDisabledReason = quickAction.disabled ? (quickAction.hint ?? "This action is currently unavailable.") : null; const pendingDefaultBranchActionCopy = pendingDefaultBranchAction ? resolveDefaultBranchActionDialogCopy({ action: pendingDefaultBranchAction.action, branchName: pendingDefaultBranchAction.branchName, includesCommit: pendingDefaultBranchAction.includesCommit, }) : null; useEffect(() => { const interval = window.setInterval(() => { if (!activeGitActionProgressRef.current) { return; } updateActiveProgressToast(); }, 1000); return () => { window.clearInterval(interval); }; }, [updateActiveProgressToast]); useEffect(() => { if (gitCwd === null) { return; } let refreshTimeout: number | null = null; const scheduleRefreshCurrentGitStatus = () => { if (refreshTimeout !== null) { window.clearTimeout(refreshTimeout); } refreshTimeout = window.setTimeout(() => { refreshTimeout = null; void refreshGitStatus({ environmentId: activeEnvironmentId, cwd: gitCwd }).catch( () => undefined, ); }, GIT_STATUS_WINDOW_REFRESH_DEBOUNCE_MS); }; const handleVisibilityChange = () => { if (document.visibilityState === "visible") { scheduleRefreshCurrentGitStatus(); } }; window.addEventListener("focus", scheduleRefreshCurrentGitStatus); document.addEventListener("visibilitychange", handleVisibilityChange); return () => { if (refreshTimeout !== null) { window.clearTimeout(refreshTimeout); } window.removeEventListener("focus", scheduleRefreshCurrentGitStatus); document.removeEventListener("visibilitychange", handleVisibilityChange); }; }, [activeEnvironmentId, gitCwd]); const openExistingPr = useCallback(async () => { const api = readLocalApi(); if (!api) { toastManager.add({ type: "error", title: "Link opening is unavailable.", data: threadToastData, }); return; } const prUrl = gitStatusForActions?.pr?.state === "open" ? gitStatusForActions.pr.url : null; if (!prUrl) { toastManager.add({ type: "error", title: "No open PR found.", data: threadToastData, }); return; } void api.shell.openExternal(prUrl).catch((err: unknown) => { toastManager.add({ type: "error", title: "Unable to open PR link", description: err instanceof Error ? err.message : "An error occurred.", data: threadToastData, }); }); }, [gitStatusForActions, threadToastData]); runGitActionWithToast = useEffectEvent( async ({ action, commitMessage, onConfirmed, skipDefaultBranchPrompt = false, statusOverride, featureBranch = false, progressToastId, filePaths, }: RunGitActionWithToastInput) => { const actionStatus = statusOverride ?? gitStatusForActions; const actionBranch = actionStatus?.branch ?? null; const actionIsDefaultBranch = featureBranch ? false : isDefaultBranch; const actionCanCommit = action === "commit" || action === "commit_push" || action === "commit_push_pr"; const includesCommit = actionCanCommit && (action === "commit" || !!actionStatus?.hasWorkingTreeChanges || featureBranch); if ( !skipDefaultBranchPrompt && requiresDefaultBranchConfirmation(action, actionIsDefaultBranch) && actionBranch ) { if ( action !== "push" && action !== "create_pr" && action !== "commit_push" && action !== "commit_push_pr" ) { return; } setPendingDefaultBranchAction({ action, branchName: actionBranch, includesCommit, ...(commitMessage ? { commitMessage } : {}), ...(onConfirmed ? { onConfirmed } : {}), ...(filePaths ? { filePaths } : {}), }); return; } onConfirmed?.(); const progressStages = buildGitActionProgressStages({ action, hasCustomCommitMessage: !!commitMessage?.trim(), hasWorkingTreeChanges: !!actionStatus?.hasWorkingTreeChanges, featureBranch, shouldPushBeforePr: action === "create_pr" && (!actionStatus?.hasUpstream || (actionStatus?.aheadCount ?? 0) > 0), }); const scopedToastData = threadToastData ? { ...threadToastData } : undefined; const actionId = randomUUID(); const resolvedProgressToastId = progressToastId ?? toastManager.add({ type: "loading", title: progressStages[0] ?? "Running git action...", description: "Waiting for Git...", timeout: 0, data: scopedToastData, }); activeGitActionProgressRef.current = { toastId: resolvedProgressToastId, toastData: scopedToastData, actionId, title: progressStages[0] ?? "Running git action...", phaseStartedAtMs: null, hookStartedAtMs: null, hookName: null, lastOutputLine: null, currentPhaseLabel: progressStages[0] ?? "Running git action...", }; if (progressToastId) { toastManager.update(progressToastId, { type: "loading", title: progressStages[0] ?? "Running git action...", description: "Waiting for Git...",