From 62cc55ab0669d8614c08ab29551e71a815006f4a Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 12 Feb 2026 12:54:36 -0800 Subject: [PATCH] Extract Git actions UI into dedicated GitActionsControl component - Move git menu, modal, and stacked action logic out of `ChatView` - Render a new `GitActionsControl` wired with `api` and `gitCwd` --- apps/web/src/components/ChatView.tsx | 643 +--------------- apps/web/src/components/GitActionsControl.tsx | 711 ++++++++++++++++++ 2 files changed, 713 insertions(+), 641 deletions(-) create mode 100644 apps/web/src/components/GitActionsControl.tsx diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 4c628420cb072a8a303cac9414de1d62b7ff3690..732b0c6a8817523a8777730930bd44ca60dc2d04 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -1,8 +1,5 @@ import { PROVIDER_SEND_TURN_MAX_INPUT_CHARS, - type GitRunStackedActionResult, - type GitStackedAction, - type GitStatusResult, type ProviderApprovalDecision, type ProviderEvent, } from "@t3tools/contracts"; @@ -38,19 +35,10 @@ import { } from "../session-logic"; import { useStore } from "../store"; import BranchToolbar from "./BranchToolbar"; +import GitActionsControl from "./GitActionsControl"; import { isTerminalToggleShortcut } from "../terminal-shortcuts"; import ChatMarkdown from "./ChatMarkdown"; import ThreadTerminalDrawer from "./ThreadTerminalDrawer"; -import { - CheckIcon, - CircleIcon, - CloudUploadIcon, - GitCommitIcon, - GithubIcon, - Loader2Icon, - MinusIcon, - XIcon, -} from "lucide-react"; function formatMessageMeta(createdAt: string, duration: string | null): string { if (!duration) return formatTimestamp(createdAt); @@ -70,109 +58,6 @@ function editorLabel(editor: (typeof EDITORS)[number]): string { const LAST_EDITOR_KEY = "t3code:last-editor"; const MAX_VISIBLE_WORK_LOG_ENTRIES = 6; -interface GitActionMenuItem { - id: GitStackedAction; - label: string; - disabled: boolean; - icon: "commit" | "push" | "pr"; -} - -function GitActionIcon(props: { icon: GitActionMenuItem["icon"]; disabled: boolean }) { - const toneClass = props.disabled ? "text-muted-foreground/45" : "text-foreground/85"; - - if (props.icon === "commit") { - return ; - } - - if (props.icon === "push") { - return ; - } - - return ; -} - -type GitProgressStepStatus = - | "pending" - | "running" - | "completed" - | "skipped" - | "failed"; - -interface GitProgressStep { - id: "generate" | "commit" | "push" | "pr"; - label: string; - status: GitProgressStepStatus; - detail?: string; -} - -function gitActionModalTitle(action: GitStackedAction): string { - if (action === "commit") return "Commit your changes"; - if (action === "commit_push") return "Commit and push changes"; - return "Commit, push and open PR"; -} - -function initialGitProgressSteps( - action: GitStackedAction, - commitMessage: string, -): GitProgressStep[] { - const hasCustomMessage = commitMessage.trim().length > 0; - const steps: GitProgressStep[] = []; - - if (!hasCustomMessage) { - steps.push({ - id: "generate", - label: "Generate commit message", - status: "pending", - }); - } - - steps.push({ - id: "commit", - label: "Commit changes", - status: "pending", - }); - - if (action !== "commit") { - steps.push({ - id: "push", - label: "Push branch", - status: "pending", - }); - } - - if (action === "commit_push_pr") { - steps.push({ - id: "pr", - label: "Create or open PR", - status: "pending", - }); - } - - return steps; -} - -function updateProgressStep( - steps: GitProgressStep[], - id: GitProgressStep["id"], - status: GitProgressStepStatus, - detail?: string, -): GitProgressStep[] { - return steps.map((step) => { - if (step.id !== id) return step; - return { - ...step, - status, - ...(detail ? { detail } : {}), - }; - }); -} - -function runActionLabel(action: GitStackedAction): string { - if (action === "commit") return "Commit"; - if (action === "commit_push") return "Commit & Push"; - return "Commit, Push & Open PR"; -} - function workToneClass(tone: "thinking" | "tool" | "info" | "error"): string { if (tone === "error") return "text-rose-300/50 dark:text-rose-300/50"; if (tone === "tool") return "text-muted-foreground/70"; @@ -254,15 +139,6 @@ export default function ChatView() { const [isConnecting, setIsConnecting] = useState(false); const [isModelMenuOpen, setIsModelMenuOpen] = useState(false); const [isEditorMenuOpen, setIsEditorMenuOpen] = useState(false); - const [isGitMenuOpen, setIsGitMenuOpen] = useState(false); - const [isGitActionRunning, setIsGitActionRunning] = useState(false); - const [gitStatus, setGitStatus] = useState(null); - const [gitActionError, setGitActionError] = useState(null); - const [gitModalAction, setGitModalAction] = useState(null); - const [gitModalCommitMessage, setGitModalCommitMessage] = useState(""); - const [gitModalProgress, setGitModalProgress] = useState([]); - const [gitModalError, setGitModalError] = useState(null); - const [gitModalResult, setGitModalResult] = useState(null); const [lastEditor, setLastEditor] = useState(() => { const stored = localStorage.getItem(LAST_EDITOR_KEY); return EDITORS.some((e) => e.id === stored) ? (stored as EditorId) : EDITORS[0].id; @@ -278,8 +154,6 @@ export default function ChatView() { const textareaRef = useRef(null); const modelMenuRef = useRef(null); const editorMenuRef = useRef(null); - const gitMenuRef = useRef(null); - const latestGitCwdRef = useRef(null); const terminalOpenByThreadRef = useRef>({}); const activeThread = state.threads.find((t) => t.id === state.activeThreadId); @@ -384,227 +258,6 @@ export default function ChatView() { sandboxMode: "workspace-write", } as const); const gitCwd = activeThread?.worktreePath ?? activeProject?.cwd ?? null; - const gitBaseDisabled = !api || !gitCwd || !gitStatus || isGitActionRunning; - const gitActionMenuItems = useMemo(() => { - if (!gitStatus) return []; - - const hasBranch = gitStatus.branch !== null; - const hasOpenPr = gitStatus.openPr !== null; - const canCommit = !gitBaseDisabled && gitStatus.hasWorkingTreeChanges; - const canPush = - !gitBaseDisabled && - hasBranch && - (gitStatus.hasWorkingTreeChanges || gitStatus.aheadCount > 0); - const canViewPr = - !gitBaseDisabled && - hasBranch && - gitStatus.behindCount === 0 && - (gitStatus.hasWorkingTreeChanges || gitStatus.aheadCount > 0 || hasOpenPr); - - return [ - { - id: "commit", - label: "Commit", - disabled: !canCommit, - icon: "commit", - }, - { - id: "commit_push", - label: "Push", - disabled: !canPush, - icon: "push", - }, - { - id: "commit_push_pr", - label: "View PR", - disabled: !canViewPr, - icon: "pr", - }, - ]; - }, [gitBaseDisabled, gitStatus]); - const isGitModalOpen = gitModalAction !== null; - const gitModalPreviewSteps = useMemo( - () => - gitModalAction - ? initialGitProgressSteps(gitModalAction, gitModalCommitMessage) - : ([] as GitProgressStep[]), - [gitModalAction, gitModalCommitMessage], - ); - const gitModalSteps = - gitModalProgress.length > 0 ? gitModalProgress : gitModalPreviewSteps; - - useEffect(() => { - latestGitCwdRef.current = gitCwd; - }, [gitCwd]); - - const refreshGitStatus = useCallback(async () => { - const requestCwd = gitCwd; - if (!api || !requestCwd) { - setGitStatus(null); - return; - } - - const nextStatus = await api.git.status({ cwd: requestCwd }); - if (latestGitCwdRef.current !== requestCwd) return; - setGitStatus(nextStatus); - setGitActionError(null); - }, [api, gitCwd]); - - const openGitActionModal = useCallback((action: GitStackedAction) => { - setIsGitMenuOpen(false); - setGitModalAction(action); - setGitModalCommitMessage(""); - setGitModalProgress([]); - setGitModalError(null); - setGitModalResult(null); - setGitActionError(null); - }, []); - - const closeGitActionModal = useCallback(() => { - if (isGitActionRunning) return; - setGitModalAction(null); - setGitModalCommitMessage(""); - setGitModalProgress([]); - setGitModalError(null); - setGitModalResult(null); - }, [isGitActionRunning]); - - const runGitAction = useCallback(async () => { - if (!api || !gitCwd || !gitModalAction) return; - const actionCwd = gitCwd; - const action = gitModalAction; - const commitMessage = gitModalCommitMessage.trim(); - const includeGeneratedCommitMessage = commitMessage.length === 0; - - setIsGitActionRunning(true); - setGitModalError(null); - setGitActionError(null); - setGitModalResult(null); - setGitModalProgress(initialGitProgressSteps(action, commitMessage)); - - let commit: GitRunStackedActionResult["commit"] = { - status: "skipped_no_changes", - }; - let push: GitRunStackedActionResult["push"] = { - status: "skipped_not_requested", - }; - let pr: GitRunStackedActionResult["pr"] = { - status: "skipped_not_requested", - }; - - const updateStep = ( - id: GitProgressStep["id"], - status: GitProgressStepStatus, - detail?: string, - ) => { - setGitModalProgress((steps) => updateProgressStep(steps, id, status, detail)); - }; - - try { - if (includeGeneratedCommitMessage) { - updateStep("generate", "running"); - } else { - updateStep("commit", "running"); - } - - const commitRun = await api.git.runStackedAction({ - cwd: actionCwd, - action: "commit", - ...(commitMessage.length > 0 ? { commitMessage } : {}), - }); - commit = commitRun.commit; - - if (includeGeneratedCommitMessage) { - if (commitRun.commit.status === "created") { - updateStep( - "generate", - "completed", - commitRun.commit.subject - ? `Generated: ${commitRun.commit.subject}` - : "Generated commit message.", - ); - } else { - updateStep("generate", "skipped", "No local changes to commit."); - } - } - - if (commitRun.commit.status === "created") { - updateStep( - "commit", - "completed", - commitRun.commit.subject ?? "Committed local changes.", - ); - } else { - updateStep("commit", "skipped", "No local changes to commit."); - } - - if (action !== "commit") { - updateStep("push", "running"); - const pushRun = await api.git.runStackedAction({ - cwd: actionCwd, - action: "commit_push", - }); - push = pushRun.push; - if (pushRun.push.status === "pushed") { - updateStep( - "push", - "completed", - pushRun.push.upstreamBranch - ? `Pushed to ${pushRun.push.upstreamBranch}.` - : "Pushed latest commits.", - ); - } else { - updateStep("push", "skipped", "Branch already up to date."); - } - } - - if (action === "commit_push_pr") { - updateStep("pr", "running"); - const prRun = await api.git.runStackedAction({ - cwd: actionCwd, - action: "commit_push_pr", - }); - pr = prRun.pr; - if (prRun.pr.status === "opened_existing") { - updateStep( - "pr", - "completed", - prRun.pr.number - ? `Opened existing PR #${prRun.pr.number}.` - : "Opened existing PR.", - ); - } else if (prRun.pr.status === "created") { - updateStep( - "pr", - "completed", - prRun.pr.number ? `Created PR #${prRun.pr.number}.` : "Created PR.", - ); - } else { - updateStep("pr", "skipped", "PR step was not requested."); - } - } - - setGitModalResult({ action, commit, push, pr }); - } catch (error) { - const message = error instanceof Error ? error.message : "Git action failed."; - setGitModalError(message); - setGitActionError(message); - setGitModalProgress((steps) => { - const active = steps.find((step) => step.status === "running"); - if (!active) return steps; - return updateProgressStep(steps, active.id, "failed", message); - }); - } finally { - setIsGitActionRunning(false); - try { - if (latestGitCwdRef.current === actionCwd) { - await refreshGitStatus(); - } - } catch { - setGitStatus(null); - } - } - }, [api, gitCwd, gitModalAction, gitModalCommitMessage, refreshGitStatus]); const envLocked = Boolean( activeThread && @@ -668,43 +321,6 @@ export default function ChatView() { const activeWorktreePath = activeThread?.worktreePath; - useEffect(() => { - setGitActionError(null); - setGitModalError(null); - setGitModalAction(null); - setGitModalCommitMessage(""); - setGitModalProgress([]); - setGitModalResult(null); - }, [activeProject?.id, activeWorktreePath]); - - useEffect(() => { - let cancelled = false; - if (!api || !gitCwd) { - setGitStatus(null); - return; - } - - const load = async () => { - try { - const nextStatus = await api.git.status({ cwd: gitCwd }); - if (!cancelled) { - setGitStatus(nextStatus); - setGitActionError(null); - } - } catch (error) { - if (!cancelled) { - setGitStatus(null); - setGitActionError(error instanceof Error ? error.message : "Failed to read git status."); - } - } - }; - - void load(); - return () => { - cancelled = true; - }; - }, [api, gitCwd]); - useEffect(() => { if (!activeThread?.id) return; setEnvMode(activeWorktreePath ? "worktree" : "local"); @@ -760,37 +376,6 @@ export default function ChatView() { }; }, [isEditorMenuOpen]); - useEffect(() => { - if (!isGitMenuOpen) return; - - const handleClickOutside = (event: MouseEvent) => { - if (!gitMenuRef.current) return; - if (event.target instanceof Node && !gitMenuRef.current.contains(event.target)) { - setIsGitMenuOpen(false); - } - }; - - window.addEventListener("mousedown", handleClickOutside); - return () => { - window.removeEventListener("mousedown", handleClickOutside); - }; - }, [isGitMenuOpen]); - - useEffect(() => { - if (!isGitModalOpen) return; - - const handleEscape = (event: globalThis.KeyboardEvent) => { - if (event.key !== "Escape") return; - if (isGitActionRunning) return; - closeGitActionModal(); - }; - - window.addEventListener("keydown", handleEscape); - return () => { - window.removeEventListener("keydown", handleEscape); - }; - }, [closeGitActionModal, isGitActionRunning, isGitModalOpen]); - // Cmd+O / Ctrl+O to open in last-used editor useEffect(() => { const handler = (e: globalThis.KeyboardEvent) => { @@ -1110,57 +695,7 @@ export default function ChatView() { )} {/* Git actions */} {activeProject && ( -
- - {isGitMenuOpen && ( -
-

Git actions

- {gitActionMenuItems.map((item) => { - return ( - - ); - })} - {gitStatus?.branch === null && ( -

- Detached HEAD: push and PR actions are unavailable. -

- )} - {gitStatus && - gitStatus.branch !== null && - !gitStatus.hasWorkingTreeChanges && - gitStatus.aheadCount === 0 && - gitStatus.behindCount > 0 && ( -

- Branch is behind upstream. Pull/rebase before opening a PR. -

- )} -
- )} -
+ )} {/* Diff toggle */} - - -
-
- Branch - - {gitStatus?.branch ?? "(detached HEAD)"} - -
-
- Changes - - {gitStatus?.hasWorkingTreeChanges ? "Working tree has changes" : "No local changes"} - -
-
- -
- -