authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-02-12 12:54:36-08:00
committergravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-02-12 12:54:36-08:00
log62cc55ab0669d8614c08ab29551e71a815006f4a
treeaa25d5c965cfa1756f0f639360d4f42a59faebbe
parent36bc85f54dbfa9e8cd5a9d61bcda0189bfef762d

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`

2 files changed, 713 insertions(+), 641 deletions(-)

apps/web/src/components/ChatView.tsx+2-641
...@@ -1,8 +1,5 @@...@@ -1,8 +1,5 @@
1import {1import {
2 PROVIDER_SEND_TURN_MAX_INPUT_CHARS,2 PROVIDER_SEND_TURN_MAX_INPUT_CHARS,
3 type GitRunStackedActionResult,
4 type GitStackedAction,
5 type GitStatusResult,
6 type ProviderApprovalDecision,3 type ProviderApprovalDecision,
7 type ProviderEvent,4 type ProviderEvent,
8} from "@t3tools/contracts";5} from "@t3tools/contracts";
...@@ -38,19 +35,10 @@ import {...@@ -38,19 +35,10 @@ import {
38} from "../session-logic";35} from "../session-logic";
39import { useStore } from "../store";36import { useStore } from "../store";
40import BranchToolbar from "./BranchToolbar";37import BranchToolbar from "./BranchToolbar";
38import GitActionsControl from "./GitActionsControl";
41import { isTerminalToggleShortcut } from "../terminal-shortcuts";39import { isTerminalToggleShortcut } from "../terminal-shortcuts";
42import ChatMarkdown from "./ChatMarkdown";40import ChatMarkdown from "./ChatMarkdown";
43import ThreadTerminalDrawer from "./ThreadTerminalDrawer";41import ThreadTerminalDrawer from "./ThreadTerminalDrawer";
44import {
45 CheckIcon,
46 CircleIcon,
47 CloudUploadIcon,
48 GitCommitIcon,
49 GithubIcon,
50 Loader2Icon,
51 MinusIcon,
52 XIcon,
53} from "lucide-react";
5442
55function formatMessageMeta(createdAt: string, duration: string | null): string {43function formatMessageMeta(createdAt: string, duration: string | null): string {
56 if (!duration) return formatTimestamp(createdAt);44 if (!duration) return formatTimestamp(createdAt);
...@@ -70,109 +58,6 @@ function editorLabel(editor: (typeof EDITORS)[number]): string {...@@ -70,109 +58,6 @@ function editorLabel(editor: (typeof EDITORS)[number]): string {
70const LAST_EDITOR_KEY = "t3code:last-editor";58const LAST_EDITOR_KEY = "t3code:last-editor";
71const MAX_VISIBLE_WORK_LOG_ENTRIES = 6;59const MAX_VISIBLE_WORK_LOG_ENTRIES = 6;
7260
73interface GitActionMenuItem {
74 id: GitStackedAction;
75 label: string;
76 disabled: boolean;
77 icon: "commit" | "push" | "pr";
78}
79
80function GitActionIcon(props: { icon: GitActionMenuItem["icon"]; disabled: boolean }) {
81 const toneClass = props.disabled ? "text-muted-foreground/45" : "text-foreground/85";
82
83 if (props.icon === "commit") {
84 return <GitCommitIcon className={`h-5 w-5 shrink-0 ${toneClass}`} />;
85 }
86
87 if (props.icon === "push") {
88 return <CloudUploadIcon className={`h-5 w-5 shrink-0 ${toneClass}`} />;
89 }
90
91 return <GithubIcon className={`h-5 w-5 shrink-0 ${toneClass}`} />;
92}
93
94type GitProgressStepStatus =
95 | "pending"
96 | "running"
97 | "completed"
98 | "skipped"
99 | "failed";
100
101interface GitProgressStep {
102 id: "generate" | "commit" | "push" | "pr";
103 label: string;
104 status: GitProgressStepStatus;
105 detail?: string;
106}
107
108function gitActionModalTitle(action: GitStackedAction): string {
109 if (action === "commit") return "Commit your changes";
110 if (action === "commit_push") return "Commit and push changes";
111 return "Commit, push and open PR";
112}
113
114function initialGitProgressSteps(
115 action: GitStackedAction,
116 commitMessage: string,
117): GitProgressStep[] {
118 const hasCustomMessage = commitMessage.trim().length > 0;
119 const steps: GitProgressStep[] = [];
120
121 if (!hasCustomMessage) {
122 steps.push({
123 id: "generate",
124 label: "Generate commit message",
125 status: "pending",
126 });
127 }
128
129 steps.push({
130 id: "commit",
131 label: "Commit changes",
132 status: "pending",
133 });
134
135 if (action !== "commit") {
136 steps.push({
137 id: "push",
138 label: "Push branch",
139 status: "pending",
140 });
141 }
142
143 if (action === "commit_push_pr") {
144 steps.push({
145 id: "pr",
146 label: "Create or open PR",
147 status: "pending",
148 });
149 }
150
151 return steps;
152}
153
154function updateProgressStep(
155 steps: GitProgressStep[],
156 id: GitProgressStep["id"],
157 status: GitProgressStepStatus,
158 detail?: string,
159): GitProgressStep[] {
160 return steps.map((step) => {
161 if (step.id !== id) return step;
162 return {
163 ...step,
164 status,
165 ...(detail ? { detail } : {}),
166 };
167 });
168}
169
170function runActionLabel(action: GitStackedAction): string {
171 if (action === "commit") return "Commit";
172 if (action === "commit_push") return "Commit & Push";
173 return "Commit, Push & Open PR";
174}
175
176function workToneClass(tone: "thinking" | "tool" | "info" | "error"): string {61function workToneClass(tone: "thinking" | "tool" | "info" | "error"): string {
177 if (tone === "error") return "text-rose-300/50 dark:text-rose-300/50";62 if (tone === "error") return "text-rose-300/50 dark:text-rose-300/50";
178 if (tone === "tool") return "text-muted-foreground/70";63 if (tone === "tool") return "text-muted-foreground/70";
...@@ -254,15 +139,6 @@ export default function ChatView() {...@@ -254,15 +139,6 @@ export default function ChatView() {
254 const [isConnecting, setIsConnecting] = useState(false);139 const [isConnecting, setIsConnecting] = useState(false);
255 const [isModelMenuOpen, setIsModelMenuOpen] = useState(false);140 const [isModelMenuOpen, setIsModelMenuOpen] = useState(false);
256 const [isEditorMenuOpen, setIsEditorMenuOpen] = useState(false);141 const [isEditorMenuOpen, setIsEditorMenuOpen] = useState(false);
257 const [isGitMenuOpen, setIsGitMenuOpen] = useState(false);
258 const [isGitActionRunning, setIsGitActionRunning] = useState(false);
259 const [gitStatus, setGitStatus] = useState<GitStatusResult | null>(null);
260 const [gitActionError, setGitActionError] = useState<string | null>(null);
261 const [gitModalAction, setGitModalAction] = useState<GitStackedAction | null>(null);
262 const [gitModalCommitMessage, setGitModalCommitMessage] = useState("");
263 const [gitModalProgress, setGitModalProgress] = useState<GitProgressStep[]>([]);
264 const [gitModalError, setGitModalError] = useState<string | null>(null);
265 const [gitModalResult, setGitModalResult] = useState<GitRunStackedActionResult | null>(null);
266 const [lastEditor, setLastEditor] = useState<EditorId>(() => {142 const [lastEditor, setLastEditor] = useState<EditorId>(() => {
267 const stored = localStorage.getItem(LAST_EDITOR_KEY);143 const stored = localStorage.getItem(LAST_EDITOR_KEY);
268 return EDITORS.some((e) => e.id === stored) ? (stored as EditorId) : EDITORS[0].id;144 return EDITORS.some((e) => e.id === stored) ? (stored as EditorId) : EDITORS[0].id;
...@@ -278,8 +154,6 @@ export default function ChatView() {...@@ -278,8 +154,6 @@ export default function ChatView() {
278 const textareaRef = useRef<HTMLTextAreaElement>(null);154 const textareaRef = useRef<HTMLTextAreaElement>(null);
279 const modelMenuRef = useRef<HTMLDivElement>(null);155 const modelMenuRef = useRef<HTMLDivElement>(null);
280 const editorMenuRef = useRef<HTMLDivElement>(null);156 const editorMenuRef = useRef<HTMLDivElement>(null);
281 const gitMenuRef = useRef<HTMLDivElement>(null);
282 const latestGitCwdRef = useRef<string | null>(null);
283 const terminalOpenByThreadRef = useRef<Record<string, boolean>>({});157 const terminalOpenByThreadRef = useRef<Record<string, boolean>>({});
284158
285 const activeThread = state.threads.find((t) => t.id === state.activeThreadId);159 const activeThread = state.threads.find((t) => t.id === state.activeThreadId);
...@@ -384,227 +258,6 @@ export default function ChatView() {...@@ -384,227 +258,6 @@ export default function ChatView() {
384 sandboxMode: "workspace-write",258 sandboxMode: "workspace-write",
385 } as const);259 } as const);
386 const gitCwd = activeThread?.worktreePath ?? activeProject?.cwd ?? null;260 const gitCwd = activeThread?.worktreePath ?? activeProject?.cwd ?? null;
387 const gitBaseDisabled = !api || !gitCwd || !gitStatus || isGitActionRunning;
388 const gitActionMenuItems = useMemo<GitActionMenuItem[]>(() => {
389 if (!gitStatus) return [];
390
391 const hasBranch = gitStatus.branch !== null;
392 const hasOpenPr = gitStatus.openPr !== null;
393 const canCommit = !gitBaseDisabled && gitStatus.hasWorkingTreeChanges;
394 const canPush =
395 !gitBaseDisabled &&
396 hasBranch &&
397 (gitStatus.hasWorkingTreeChanges || gitStatus.aheadCount > 0);
398 const canViewPr =
399 !gitBaseDisabled &&
400 hasBranch &&
401 gitStatus.behindCount === 0 &&
402 (gitStatus.hasWorkingTreeChanges || gitStatus.aheadCount > 0 || hasOpenPr);
403
404 return [
405 {
406 id: "commit",
407 label: "Commit",
408 disabled: !canCommit,
409 icon: "commit",
410 },
411 {
412 id: "commit_push",
413 label: "Push",
414 disabled: !canPush,
415 icon: "push",
416 },
417 {
418 id: "commit_push_pr",
419 label: "View PR",
420 disabled: !canViewPr,
421 icon: "pr",
422 },
423 ];
424 }, [gitBaseDisabled, gitStatus]);
425 const isGitModalOpen = gitModalAction !== null;
426 const gitModalPreviewSteps = useMemo(
427 () =>
428 gitModalAction
429 ? initialGitProgressSteps(gitModalAction, gitModalCommitMessage)
430 : ([] as GitProgressStep[]),
431 [gitModalAction, gitModalCommitMessage],
432 );
433 const gitModalSteps =
434 gitModalProgress.length > 0 ? gitModalProgress : gitModalPreviewSteps;
435
436 useEffect(() => {
437 latestGitCwdRef.current = gitCwd;
438 }, [gitCwd]);
439
440 const refreshGitStatus = useCallback(async () => {
441 const requestCwd = gitCwd;
442 if (!api || !requestCwd) {
443 setGitStatus(null);
444 return;
445 }
446
447 const nextStatus = await api.git.status({ cwd: requestCwd });
448 if (latestGitCwdRef.current !== requestCwd) return;
449 setGitStatus(nextStatus);
450 setGitActionError(null);
451 }, [api, gitCwd]);
452
453 const openGitActionModal = useCallback((action: GitStackedAction) => {
454 setIsGitMenuOpen(false);
455 setGitModalAction(action);
456 setGitModalCommitMessage("");
457 setGitModalProgress([]);
458 setGitModalError(null);
459 setGitModalResult(null);
460 setGitActionError(null);
461 }, []);
462
463 const closeGitActionModal = useCallback(() => {
464 if (isGitActionRunning) return;
465 setGitModalAction(null);
466 setGitModalCommitMessage("");
467 setGitModalProgress([]);
468 setGitModalError(null);
469 setGitModalResult(null);
470 }, [isGitActionRunning]);
471
472 const runGitAction = useCallback(async () => {
473 if (!api || !gitCwd || !gitModalAction) return;
474 const actionCwd = gitCwd;
475 const action = gitModalAction;
476 const commitMessage = gitModalCommitMessage.trim();
477 const includeGeneratedCommitMessage = commitMessage.length === 0;
478
479 setIsGitActionRunning(true);
480 setGitModalError(null);
481 setGitActionError(null);
482 setGitModalResult(null);
483 setGitModalProgress(initialGitProgressSteps(action, commitMessage));
484
485 let commit: GitRunStackedActionResult["commit"] = {
486 status: "skipped_no_changes",
487 };
488 let push: GitRunStackedActionResult["push"] = {
489 status: "skipped_not_requested",
490 };
491 let pr: GitRunStackedActionResult["pr"] = {
492 status: "skipped_not_requested",
493 };
494
495 const updateStep = (
496 id: GitProgressStep["id"],
497 status: GitProgressStepStatus,
498 detail?: string,
499 ) => {
500 setGitModalProgress((steps) => updateProgressStep(steps, id, status, detail));
501 };
502
503 try {
504 if (includeGeneratedCommitMessage) {
505 updateStep("generate", "running");
506 } else {
507 updateStep("commit", "running");
508 }
509
510 const commitRun = await api.git.runStackedAction({
511 cwd: actionCwd,
512 action: "commit",
513 ...(commitMessage.length > 0 ? { commitMessage } : {}),
514 });
515 commit = commitRun.commit;
516
517 if (includeGeneratedCommitMessage) {
518 if (commitRun.commit.status === "created") {
519 updateStep(
520 "generate",
521 "completed",
522 commitRun.commit.subject
523 ? `Generated: ${commitRun.commit.subject}`
524 : "Generated commit message.",
525 );
526 } else {
527 updateStep("generate", "skipped", "No local changes to commit.");
528 }
529 }
530
531 if (commitRun.commit.status === "created") {
532 updateStep(
533 "commit",
534 "completed",
535 commitRun.commit.subject ?? "Committed local changes.",
536 );
537 } else {
538 updateStep("commit", "skipped", "No local changes to commit.");
539 }
540
541 if (action !== "commit") {
542 updateStep("push", "running");
543 const pushRun = await api.git.runStackedAction({
544 cwd: actionCwd,
545 action: "commit_push",
546 });
547 push = pushRun.push;
548 if (pushRun.push.status === "pushed") {
549 updateStep(
550 "push",
551 "completed",
552 pushRun.push.upstreamBranch
553 ? `Pushed to ${pushRun.push.upstreamBranch}.`
554 : "Pushed latest commits.",
555 );
556 } else {
557 updateStep("push", "skipped", "Branch already up to date.");
558 }
559 }
560
561 if (action === "commit_push_pr") {
562 updateStep("pr", "running");
563 const prRun = await api.git.runStackedAction({
564 cwd: actionCwd,
565 action: "commit_push_pr",
566 });
567 pr = prRun.pr;
568 if (prRun.pr.status === "opened_existing") {
569 updateStep(
570 "pr",
571 "completed",
572 prRun.pr.number
573 ? `Opened existing PR #${prRun.pr.number}.`
574 : "Opened existing PR.",
575 );
576 } else if (prRun.pr.status === "created") {
577 updateStep(
578 "pr",
579 "completed",
580 prRun.pr.number ? `Created PR #${prRun.pr.number}.` : "Created PR.",
581 );
582 } else {
583 updateStep("pr", "skipped", "PR step was not requested.");
584 }
585 }
586
587 setGitModalResult({ action, commit, push, pr });
588 } catch (error) {
589 const message = error instanceof Error ? error.message : "Git action failed.";
590 setGitModalError(message);
591 setGitActionError(message);
592 setGitModalProgress((steps) => {
593 const active = steps.find((step) => step.status === "running");
594 if (!active) return steps;
595 return updateProgressStep(steps, active.id, "failed", message);
596 });
597 } finally {
598 setIsGitActionRunning(false);
599 try {
600 if (latestGitCwdRef.current === actionCwd) {
601 await refreshGitStatus();
602 }
603 } catch {
604 setGitStatus(null);
605 }
606 }
607 }, [api, gitCwd, gitModalAction, gitModalCommitMessage, refreshGitStatus]);
608261
609 const envLocked = Boolean(262 const envLocked = Boolean(
610 activeThread &&263 activeThread &&
...@@ -668,43 +321,6 @@ export default function ChatView() {...@@ -668,43 +321,6 @@ export default function ChatView() {
668321
669 const activeWorktreePath = activeThread?.worktreePath;322 const activeWorktreePath = activeThread?.worktreePath;
670323
671 useEffect(() => {
672 setGitActionError(null);
673 setGitModalError(null);
674 setGitModalAction(null);
675 setGitModalCommitMessage("");
676 setGitModalProgress([]);
677 setGitModalResult(null);
678 }, [activeProject?.id, activeWorktreePath]);
679
680 useEffect(() => {
681 let cancelled = false;
682 if (!api || !gitCwd) {
683 setGitStatus(null);
684 return;
685 }
686
687 const load = async () => {
688 try {
689 const nextStatus = await api.git.status({ cwd: gitCwd });
690 if (!cancelled) {
691 setGitStatus(nextStatus);
692 setGitActionError(null);
693 }
694 } catch (error) {
695 if (!cancelled) {
696 setGitStatus(null);
697 setGitActionError(error instanceof Error ? error.message : "Failed to read git status.");
698 }
699 }
700 };
701
702 void load();
703 return () => {
704 cancelled = true;
705 };
706 }, [api, gitCwd]);
707
708 useEffect(() => {324 useEffect(() => {
709 if (!activeThread?.id) return;325 if (!activeThread?.id) return;
710 setEnvMode(activeWorktreePath ? "worktree" : "local");326 setEnvMode(activeWorktreePath ? "worktree" : "local");
...@@ -760,37 +376,6 @@ export default function ChatView() {...@@ -760,37 +376,6 @@ export default function ChatView() {
760 };376 };
761 }, [isEditorMenuOpen]);377 }, [isEditorMenuOpen]);
762378
763 useEffect(() => {
764 if (!isGitMenuOpen) return;
765
766 const handleClickOutside = (event: MouseEvent) => {
767 if (!gitMenuRef.current) return;
768 if (event.target instanceof Node && !gitMenuRef.current.contains(event.target)) {
769 setIsGitMenuOpen(false);
770 }
771 };
772
773 window.addEventListener("mousedown", handleClickOutside);
774 return () => {
775 window.removeEventListener("mousedown", handleClickOutside);
776 };
777 }, [isGitMenuOpen]);
778
779 useEffect(() => {
780 if (!isGitModalOpen) return;
781
782 const handleEscape = (event: globalThis.KeyboardEvent) => {
783 if (event.key !== "Escape") return;
784 if (isGitActionRunning) return;
785 closeGitActionModal();
786 };
787
788 window.addEventListener("keydown", handleEscape);
789 return () => {
790 window.removeEventListener("keydown", handleEscape);
791 };
792 }, [closeGitActionModal, isGitActionRunning, isGitModalOpen]);
793
794 // Cmd+O / Ctrl+O to open in last-used editor379 // Cmd+O / Ctrl+O to open in last-used editor
795 useEffect(() => {380 useEffect(() => {
796 const handler = (e: globalThis.KeyboardEvent) => {381 const handler = (e: globalThis.KeyboardEvent) => {
...@@ -1110,57 +695,7 @@ export default function ChatView() {...@@ -1110,57 +695,7 @@ export default function ChatView() {
1110 )}695 )}
1111 {/* Git actions */}696 {/* Git actions */}
1112 {activeProject && (697 {activeProject && (
1113 <div className="relative" ref={gitMenuRef}>698 <GitActionsControl api={api} gitCwd={gitCwd} />
1114 <button
1115 type="button"
1116 className="inline-flex items-center gap-1.5 rounded-md border border-border px-2 py-1 text-[10px] text-muted-foreground/70 transition-colors duration-150 hover:bg-accent hover:text-foreground/80 disabled:cursor-not-allowed disabled:opacity-60"
1117 onClick={() => {
1118 if (!isGitMenuOpen) {
1119 void refreshGitStatus().catch(() => undefined);
1120 }
1121 setIsGitMenuOpen((v) => !v);
1122 }}
1123 disabled={!gitStatus || isGitActionRunning}
1124 >
1125 {isGitActionRunning ? "Running..." : "Git actions"}
1126 <span aria-hidden="true">▾</span>
1127 </button>
1128 {isGitMenuOpen && (
1129 <div className="absolute right-0 top-full z-50 mt-1 w-[280px] rounded-3xl border border-border bg-popover p-3 shadow-xl">
1130 <p className="px-3 pb-2 text-[13px] text-muted-foreground/75">Git actions</p>
1131 {gitActionMenuItems.map((item) => {
1132 return (
1133 <button
1134 key={item.id}
1135 type="button"
1136 className="mb-1.5 flex w-full items-center gap-3 rounded-2xl px-3 py-2.5 text-left text-[14px] text-foreground transition-colors duration-150 hover:bg-accent disabled:cursor-not-allowed disabled:text-muted-foreground/65"
1137 disabled={item.disabled}
1138 onClick={() => {
1139 openGitActionModal(item.id);
1140 }}
1141 >
1142 <GitActionIcon icon={item.icon} disabled={item.disabled} />
1143 <span>{item.label}</span>
1144 </button>
1145 );
1146 })}
1147 {gitStatus?.branch === null && (
1148 <p className="px-2 pt-1 text-[10px] text-amber-500 dark:text-amber-300">
1149 Detached HEAD: push and PR actions are unavailable.
1150 </p>
1151 )}
1152 {gitStatus &&
1153 gitStatus.branch !== null &&
1154 !gitStatus.hasWorkingTreeChanges &&
1155 gitStatus.aheadCount === 0 &&
1156 gitStatus.behindCount > 0 && (
1157 <p className="px-3 pt-1 text-[10px] text-amber-500 dark:text-amber-300">
1158 Branch is behind upstream. Pull/rebase before opening a PR.
1159 </p>
1160 )}
1161 </div>
1162 )}
1163 </div>
1164 )}699 )}
1165 {/* Diff toggle */}700 {/* Diff toggle */}
1166 <button701 <button
...@@ -1678,180 +1213,6 @@ export default function ChatView() {...@@ -1678,180 +1213,6 @@ export default function ChatView() {
1678 </form>1213 </form>
1679 </div>1214 </div>
16801215
1681 {isGitModalOpen && gitModalAction && (
1682 <div
1683 className="fixed inset-0 z-[80] flex items-center justify-center bg-black/35 px-4 py-6"
1684 onMouseDown={() => {
1685 closeGitActionModal();
1686 }}
1687 >
1688 <div
1689 className="w-full max-w-[640px] rounded-3xl border border-border bg-popover p-6 shadow-2xl"
1690 role="dialog"
1691 aria-modal="true"
1692 aria-label="Git action confirmation"
1693 onMouseDown={(event) => {
1694 event.stopPropagation();
1695 }}
1696 >
1697 <div className="flex items-start justify-between gap-4">
1698 <div className="flex items-center gap-3">
1699 <div className="rounded-2xl bg-accent p-3">
1700 <GitActionIcon
1701 icon={
1702 gitModalAction === "commit"
1703 ? "commit"
1704 : gitModalAction === "commit_push"
1705 ? "push"
1706 : "pr"
1707 }
1708 disabled={false}
1709 />
1710 </div>
1711 <div>
1712 <p className="text-xs uppercase tracking-[0.14em] text-muted-foreground/70">
1713 Git actions
1714 </p>
1715 <h3 className="text-3xl font-semibold tracking-tight text-foreground">
1716 {gitActionModalTitle(gitModalAction)}
1717 </h3>
1718 </div>
1719 </div>
1720 <button
1721 type="button"
1722 className="rounded-md p-1 text-muted-foreground/60 transition-colors duration-150 hover:bg-accent hover:text-foreground disabled:cursor-not-allowed disabled:opacity-50"
1723 onClick={closeGitActionModal}
1724 disabled={isGitActionRunning}
1725 aria-label="Close git action dialog"
1726 >
1727 <XIcon className="h-5 w-5" />
1728 </button>
1729 </div>
1730
1731 <div className="mt-6 space-y-2 rounded-2xl border border-border/80 bg-card/40 px-4 py-3">
1732 <div className="flex items-center justify-between gap-4 text-sm">
1733 <span className="text-muted-foreground/70">Branch</span>
1734 <span className="font-mono text-foreground">
1735 {gitStatus?.branch ?? "(detached HEAD)"}
1736 </span>
1737 </div>
1738 <div className="flex items-center justify-between gap-4 text-sm">
1739 <span className="text-muted-foreground/70">Changes</span>
1740 <span className="text-foreground">
1741 {gitStatus?.hasWorkingTreeChanges ? "Working tree has changes" : "No local changes"}
1742 </span>
1743 </div>
1744 </div>
1745
1746 <div className="mt-6">
1747 <label
1748 htmlFor="git-commit-message"
1749 className="mb-2 block text-sm font-medium text-foreground"
1750 >
1751 Commit message
1752 </label>
1753 <textarea
1754 id="git-commit-message"
1755 rows={3}
1756 className="w-full resize-none rounded-2xl border border-border bg-background px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground/50 focus:border-ring focus:outline-none disabled:cursor-not-allowed disabled:opacity-70"
1757 placeholder="Leave blank to autogenerate a commit message"
1758 value={gitModalCommitMessage}
1759 onChange={(event) => setGitModalCommitMessage(event.target.value)}
1760 disabled={isGitActionRunning || gitModalResult !== null}
1761 />
1762 <p className="mt-1.5 text-xs text-muted-foreground/65">
1763 Leave this empty to use AI-generated commit text.
1764 </p>
1765 </div>
1766
1767 <div className="mt-6">
1768 <p className="text-sm font-medium text-foreground">Next steps</p>
1769 <div className="mt-2 overflow-hidden rounded-2xl border border-border">
1770 {gitModalSteps.map((step, index) => {
1771 const borderClass = index < gitModalSteps.length - 1 ? "border-b border-border/70" : "";
1772 const statusTextClass =
1773 step.status === "failed"
1774 ? "text-rose-500 dark:text-rose-300"
1775 : step.status === "completed"
1776 ? "text-emerald-600 dark:text-emerald-300"
1777 : "text-muted-foreground/70";
1778
1779 return (
1780 <div
1781 key={step.id}
1782 className={`flex items-start gap-3 bg-card/45 px-4 py-3 ${borderClass}`}
1783 >
1784 <span className="mt-0.5">
1785 {step.status === "running" ? (
1786 <Loader2Icon className="h-4 w-4 animate-spin text-foreground" />
1787 ) : step.status === "completed" ? (
1788 <CheckIcon className="h-4 w-4 text-emerald-600 dark:text-emerald-300" />
1789 ) : step.status === "skipped" ? (
1790 <MinusIcon className="h-4 w-4 text-muted-foreground/70" />
1791 ) : step.status === "failed" ? (
1792 <XIcon className="h-4 w-4 text-rose-500 dark:text-rose-300" />
1793 ) : (
1794 <CircleIcon className="h-4 w-4 text-muted-foreground/60" />
1795 )}
1796 </span>
1797 <div className="min-w-0">
1798 <p className="text-sm text-foreground">{step.label}</p>
1799 {step.detail && (
1800 <p className={`mt-0.5 text-xs ${statusTextClass}`}>{step.detail}</p>
1801 )}
1802 </div>
1803 </div>
1804 );
1805 })}
1806 </div>
1807 </div>
1808
1809 {(gitModalError ?? gitActionError) && (
1810 <div className="mt-4 rounded-lg border border-rose-300/50 bg-rose-500/10 px-3 py-2 text-xs text-rose-600 dark:text-rose-200">
1811 {gitModalError ?? gitActionError}
1812 </div>
1813 )}
1814
1815 {gitModalResult?.pr.url && (
1816 <div className="mt-4 rounded-lg border border-border bg-card/40 px-3 py-2 text-xs text-muted-foreground/80">
1817 PR:{" "}
1818 <a
1819 href={gitModalResult.pr.url}
1820 target="_blank"
1821 rel="noreferrer"
1822 className="text-foreground underline underline-offset-2"
1823 >
1824 {gitModalResult.pr.url}
1825 </a>
1826 </div>
1827 )}
1828
1829 <div className="mt-6 flex justify-end gap-2">
1830 <button
1831 type="button"
1832 className="rounded-xl border border-border px-4 py-2 text-sm text-foreground transition-colors duration-150 hover:bg-accent disabled:cursor-not-allowed disabled:opacity-60"
1833 onClick={closeGitActionModal}
1834 disabled={isGitActionRunning}
1835 >
1836 {gitModalResult ? "Done" : "Cancel"}
1837 </button>
1838 {!gitModalResult && (
1839 <button
1840 type="button"
1841 className="rounded-xl bg-foreground px-4 py-2 text-sm font-medium text-background transition-colors duration-150 hover:bg-foreground/90 disabled:cursor-not-allowed disabled:opacity-60"
1842 onClick={() => {
1843 void runGitAction();
1844 }}
1845 disabled={isGitActionRunning}
1846 >
1847 {isGitActionRunning ? "Running..." : runActionLabel(gitModalAction)}
1848 </button>
1849 )}
1850 </div>
1851 </div>
1852 </div>
1853 )}
1854
1855 <BranchToolbar envMode={envMode} onEnvModeChange={setEnvMode} envLocked={envLocked} />1216 <BranchToolbar envMode={envMode} onEnvModeChange={setEnvMode} envLocked={envLocked} />
18561217
1857 {activeThread.terminalOpen && api && activeProject && (1218 {activeThread.terminalOpen && api && activeProject && (
apps/web/src/components/GitActionsControl.tsx created+711
...@@ -0,0 +1,711 @@
1import {
2 type GitRunStackedActionResult,
3 type GitStackedAction,
4 type GitStatusResult,
5 type NativeApi,
6} from "@t3tools/contracts";
7import { useCallback, useEffect, useMemo, useRef, useState } from "react";
8import {
9 CheckIcon,
10 CircleIcon,
11 CloudUploadIcon,
12 GitCommitIcon,
13 GithubIcon,
14 Loader2Icon,
15 MinusIcon,
16 XIcon,
17} from "lucide-react";
18
19interface GitActionsControlProps {
20 api: NativeApi | undefined;
21 gitCwd: string | null;
22}
23
24interface GitActionMenuItem {
25 id: GitStackedAction;
26 label: string;
27 disabled: boolean;
28 icon: "commit" | "push" | "pr";
29}
30
31type GitProgressStepStatus =
32 | "pending"
33 | "running"
34 | "completed"
35 | "skipped"
36 | "failed";
37
38interface GitProgressStep {
39 id: "generate" | "commit" | "push" | "pr";
40 label: string;
41 status: GitProgressStepStatus;
42 detail?: string;
43}
44
45function GitActionIcon(props: { icon: GitActionMenuItem["icon"]; disabled: boolean }) {
46 const toneClass = props.disabled ? "text-muted-foreground/45" : "text-foreground/85";
47
48 if (props.icon === "commit") {
49 return <GitCommitIcon className={`h-5 w-5 shrink-0 ${toneClass}`} />;
50 }
51
52 if (props.icon === "push") {
53 return <CloudUploadIcon className={`h-5 w-5 shrink-0 ${toneClass}`} />;
54 }
55
56 return <GithubIcon className={`h-5 w-5 shrink-0 ${toneClass}`} />;
57}
58
59function gitActionModalTitle(action: GitStackedAction): string {
60 if (action === "commit") return "Commit your changes";
61 if (action === "commit_push") return "Commit and push changes";
62 return "Commit, push and open PR";
63}
64
65function initialGitProgressSteps(
66 action: GitStackedAction,
67 commitMessage: string,
68): GitProgressStep[] {
69 const hasCustomMessage = commitMessage.trim().length > 0;
70 const steps: GitProgressStep[] = [];
71
72 if (!hasCustomMessage) {
73 steps.push({
74 id: "generate",
75 label: "Generate commit message",
76 status: "pending",
77 });
78 }
79
80 steps.push({
81 id: "commit",
82 label: "Commit changes",
83 status: "pending",
84 });
85
86 if (action !== "commit") {
87 steps.push({
88 id: "push",
89 label: "Push branch",
90 status: "pending",
91 });
92 }
93
94 if (action === "commit_push_pr") {
95 steps.push({
96 id: "pr",
97 label: "Create or open PR",
98 status: "pending",
99 });
100 }
101
102 return steps;
103}
104
105function updateProgressStep(
106 steps: GitProgressStep[],
107 id: GitProgressStep["id"],
108 status: GitProgressStepStatus,
109 detail?: string,
110): GitProgressStep[] {
111 return steps.map((step) => {
112 if (step.id !== id) return step;
113 return {
114 ...step,
115 status,
116 ...(detail ? { detail } : {}),
117 };
118 });
119}
120
121function runActionLabel(action: GitStackedAction): string {
122 if (action === "commit") return "Commit";
123 if (action === "commit_push") return "Commit & Push";
124 return "Commit, Push & Open PR";
125}
126
127function isViewPrOnlyAction(
128 action: GitStackedAction,
129 gitStatus: GitStatusResult | null,
130): boolean {
131 if (action !== "commit_push_pr") return false;
132 if (!gitStatus?.openPr) return false;
133 return !gitStatus.hasWorkingTreeChanges && gitStatus.aheadCount === 0;
134}
135
136export default function GitActionsControl({ api, gitCwd }: GitActionsControlProps) {
137 const [isGitMenuOpen, setIsGitMenuOpen] = useState(false);
138 const [isGitActionRunning, setIsGitActionRunning] = useState(false);
139 const [gitStatus, setGitStatus] = useState<GitStatusResult | null>(null);
140 const [gitActionError, setGitActionError] = useState<string | null>(null);
141 const [gitModalAction, setGitModalAction] = useState<GitStackedAction | null>(null);
142 const [gitModalCommitMessage, setGitModalCommitMessage] = useState("");
143 const [gitModalProgress, setGitModalProgress] = useState<GitProgressStep[]>([]);
144 const [gitModalError, setGitModalError] = useState<string | null>(null);
145 const [gitModalResult, setGitModalResult] = useState<GitRunStackedActionResult | null>(null);
146 const gitMenuRef = useRef<HTMLDivElement>(null);
147 const latestGitCwdRef = useRef<string | null>(null);
148
149 const gitBaseDisabled = !api || !gitCwd || !gitStatus || isGitActionRunning;
150 const gitActionMenuItems = useMemo<GitActionMenuItem[]>(() => {
151 if (!gitStatus) return [];
152
153 const hasBranch = gitStatus.branch !== null;
154 const hasOpenPr = gitStatus.openPr !== null;
155 const canCommit = !gitBaseDisabled && gitStatus.hasWorkingTreeChanges;
156 const canPush =
157 !gitBaseDisabled &&
158 hasBranch &&
159 (gitStatus.hasWorkingTreeChanges || gitStatus.aheadCount > 0);
160 const canViewPr =
161 !gitBaseDisabled &&
162 hasBranch &&
163 gitStatus.behindCount === 0 &&
164 (gitStatus.hasWorkingTreeChanges || gitStatus.aheadCount > 0 || hasOpenPr);
165
166 return [
167 {
168 id: "commit",
169 label: "Commit",
170 disabled: !canCommit,
171 icon: "commit",
172 },
173 {
174 id: "commit_push",
175 label: "Push",
176 disabled: !canPush,
177 icon: "push",
178 },
179 {
180 id: "commit_push_pr",
181 label: "View PR",
182 disabled: !canViewPr,
183 icon: "pr",
184 },
185 ];
186 }, [gitBaseDisabled, gitStatus]);
187
188 const isGitModalOpen = gitModalAction !== null;
189 const gitModalPreviewSteps = useMemo(
190 () =>
191 gitModalAction
192 ? initialGitProgressSteps(gitModalAction, gitModalCommitMessage)
193 : ([] as GitProgressStep[]),
194 [gitModalAction, gitModalCommitMessage],
195 );
196 const gitModalSteps = gitModalProgress.length > 0 ? gitModalProgress : gitModalPreviewSteps;
197
198 useEffect(() => {
199 latestGitCwdRef.current = gitCwd;
200 }, [gitCwd]);
201
202 const refreshGitStatus = useCallback(async () => {
203 const requestCwd = gitCwd;
204 if (!api || !requestCwd) {
205 setGitStatus(null);
206 return;
207 }
208
209 const nextStatus = await api.git.status({ cwd: requestCwd });
210 if (latestGitCwdRef.current !== requestCwd) return;
211 setGitStatus(nextStatus);
212 setGitActionError(null);
213 }, [api, gitCwd]);
214
215 const openGitActionModal = useCallback((action: GitStackedAction) => {
216 setIsGitMenuOpen(false);
217 setGitModalAction(action);
218 setGitModalCommitMessage("");
219 setGitModalProgress([]);
220 setGitModalError(null);
221 setGitModalResult(null);
222 setGitActionError(null);
223 }, []);
224
225 const closeGitActionModal = useCallback(() => {
226 if (isGitActionRunning) return;
227 setGitModalAction(null);
228 setGitModalCommitMessage("");
229 setGitModalProgress([]);
230 setGitModalError(null);
231 setGitModalResult(null);
232 }, [isGitActionRunning]);
233
234 const runGitActionImmediately = useCallback(
235 async (action: GitStackedAction) => {
236 if (!api || !gitCwd) return;
237 const actionCwd = gitCwd;
238
239 setIsGitMenuOpen(false);
240 setGitActionError(null);
241 setGitModalError(null);
242 setIsGitActionRunning(true);
243
244 try {
245 await api.git.runStackedAction({
246 cwd: actionCwd,
247 action,
248 });
249 } catch (error) {
250 setGitActionError(error instanceof Error ? error.message : "Git action failed.");
251 } finally {
252 setIsGitActionRunning(false);
253 try {
254 if (latestGitCwdRef.current === actionCwd) {
255 await refreshGitStatus();
256 }
257 } catch {
258 setGitStatus(null);
259 }
260 }
261 },
262 [api, gitCwd, refreshGitStatus],
263 );
264
265 const runGitAction = useCallback(async () => {
266 if (!api || !gitCwd || !gitModalAction) return;
267 const actionCwd = gitCwd;
268 const action = gitModalAction;
269 const commitMessage = gitModalCommitMessage.trim();
270 const includeGeneratedCommitMessage = commitMessage.length === 0;
271
272 setIsGitActionRunning(true);
273 setGitModalError(null);
274 setGitActionError(null);
275 setGitModalResult(null);
276 setGitModalProgress(initialGitProgressSteps(action, commitMessage));
277
278 let commit: GitRunStackedActionResult["commit"] = {
279 status: "skipped_no_changes",
280 };
281 let push: GitRunStackedActionResult["push"] = {
282 status: "skipped_not_requested",
283 };
284 let pr: GitRunStackedActionResult["pr"] = {
285 status: "skipped_not_requested",
286 };
287
288 const updateStep = (
289 id: GitProgressStep["id"],
290 status: GitProgressStepStatus,
291 detail?: string,
292 ) => {
293 setGitModalProgress((steps) => updateProgressStep(steps, id, status, detail));
294 };
295
296 try {
297 if (includeGeneratedCommitMessage) {
298 updateStep("generate", "running");
299 } else {
300 updateStep("commit", "running");
301 }
302
303 const commitRun = await api.git.runStackedAction({
304 cwd: actionCwd,
305 action: "commit",
306 ...(commitMessage.length > 0 ? { commitMessage } : {}),
307 });
308 commit = commitRun.commit;
309
310 if (includeGeneratedCommitMessage) {
311 if (commitRun.commit.status === "created") {
312 updateStep(
313 "generate",
314 "completed",
315 commitRun.commit.subject
316 ? `Generated: ${commitRun.commit.subject}`
317 : "Generated commit message.",
318 );
319 } else {
320 updateStep("generate", "skipped", "No local changes to commit.");
321 }
322 }
323
324 if (commitRun.commit.status === "created") {
325 updateStep(
326 "commit",
327 "completed",
328 commitRun.commit.subject ?? "Committed local changes.",
329 );
330 } else {
331 updateStep("commit", "skipped", "No local changes to commit.");
332 }
333
334 if (action !== "commit") {
335 updateStep("push", "running");
336 const pushRun = await api.git.runStackedAction({
337 cwd: actionCwd,
338 action: "commit_push",
339 });
340 push = pushRun.push;
341 if (pushRun.push.status === "pushed") {
342 updateStep(
343 "push",
344 "completed",
345 pushRun.push.upstreamBranch
346 ? `Pushed to ${pushRun.push.upstreamBranch}.`
347 : "Pushed latest commits.",
348 );
349 } else {
350 updateStep("push", "skipped", "Branch already up to date.");
351 }
352 }
353
354 if (action === "commit_push_pr") {
355 updateStep("pr", "running");
356 const prRun = await api.git.runStackedAction({
357 cwd: actionCwd,
358 action: "commit_push_pr",
359 });
360 pr = prRun.pr;
361 if (prRun.pr.status === "opened_existing") {
362 updateStep(
363 "pr",
364 "completed",
365 prRun.pr.number
366 ? `Opened existing PR #${prRun.pr.number}.`
367 : "Opened existing PR.",
368 );
369 } else if (prRun.pr.status === "created") {
370 updateStep(
371 "pr",
372 "completed",
373 prRun.pr.number ? `Created PR #${prRun.pr.number}.` : "Created PR.",
374 );
375 } else {
376 updateStep("pr", "skipped", "PR step was not requested.");
377 }
378 }
379
380 setGitModalResult({ action, commit, push, pr });
381 } catch (error) {
382 const message = error instanceof Error ? error.message : "Git action failed.";
383 setGitModalError(message);
384 setGitActionError(message);
385 setGitModalProgress((steps) => {
386 const active = steps.find((step) => step.status === "running");
387 if (!active) return steps;
388 return updateProgressStep(steps, active.id, "failed", message);
389 });
390 } finally {
391 setIsGitActionRunning(false);
392 try {
393 if (latestGitCwdRef.current === actionCwd) {
394 await refreshGitStatus();
395 }
396 } catch {
397 setGitStatus(null);
398 }
399 }
400 }, [api, gitCwd, gitModalAction, gitModalCommitMessage, refreshGitStatus]);
401
402 useEffect(() => {
403 setGitActionError(null);
404 setGitModalError(null);
405 setGitModalAction(null);
406 setGitModalCommitMessage("");
407 setGitModalProgress([]);
408 setGitModalResult(null);
409 }, [gitCwd]);
410
411 useEffect(() => {
412 let cancelled = false;
413 if (!api || !gitCwd) {
414 setGitStatus(null);
415 return;
416 }
417
418 const load = async () => {
419 try {
420 const nextStatus = await api.git.status({ cwd: gitCwd });
421 if (!cancelled) {
422 setGitStatus(nextStatus);
423 setGitActionError(null);
424 }
425 } catch (error) {
426 if (!cancelled) {
427 setGitStatus(null);
428 setGitActionError(error instanceof Error ? error.message : "Failed to read git status.");
429 }
430 }
431 };
432
433 void load();
434 return () => {
435 cancelled = true;
436 };
437 }, [api, gitCwd]);
438
439 useEffect(() => {
440 if (!isGitMenuOpen) return;
441
442 const handleClickOutside = (event: MouseEvent) => {
443 if (!gitMenuRef.current) return;
444 if (event.target instanceof Node && !gitMenuRef.current.contains(event.target)) {
445 setIsGitMenuOpen(false);
446 }
447 };
448
449 window.addEventListener("mousedown", handleClickOutside);
450 return () => {
451 window.removeEventListener("mousedown", handleClickOutside);
452 };
453 }, [isGitMenuOpen]);
454
455 useEffect(() => {
456 if (!isGitModalOpen) return;
457
458 const handleEscape = (event: globalThis.KeyboardEvent) => {
459 if (event.key !== "Escape") return;
460 if (isGitActionRunning) return;
461 closeGitActionModal();
462 };
463
464 window.addEventListener("keydown", handleEscape);
465 return () => {
466 window.removeEventListener("keydown", handleEscape);
467 };
468 }, [closeGitActionModal, isGitActionRunning, isGitModalOpen]);
469
470 if (!gitCwd) return null;
471
472 return (
473 <>
474 <div className="relative" ref={gitMenuRef}>
475 <button
476 type="button"
477 className="inline-flex items-center gap-1.5 rounded-md border border-border px-2 py-1 text-[10px] text-muted-foreground/70 transition-colors duration-150 hover:bg-accent hover:text-foreground/80 disabled:cursor-not-allowed disabled:opacity-60"
478 onClick={() => {
479 if (!isGitMenuOpen) {
480 void refreshGitStatus().catch(() => undefined);
481 }
482 setIsGitMenuOpen((v) => !v);
483 }}
484 disabled={!gitStatus || isGitActionRunning}
485 >
486 {isGitActionRunning ? "Running..." : "Git actions"}
487 <span aria-hidden="true">▾</span>
488 </button>
489 {isGitMenuOpen && (
490 <div className="absolute right-0 top-full z-50 mt-1 w-[280px] rounded-3xl border border-border bg-popover p-3 shadow-xl">
491 <p className="px-3 pb-2 text-[13px] text-muted-foreground/75">Git actions</p>
492 {gitActionMenuItems.map((item) => {
493 return (
494 <button
495 key={item.id}
496 type="button"
497 className="mb-1.5 flex w-full items-center gap-3 rounded-2xl px-3 py-2.5 text-left text-[14px] text-foreground transition-colors duration-150 hover:bg-accent disabled:cursor-not-allowed disabled:text-muted-foreground/65"
498 disabled={item.disabled}
499 onClick={() => {
500 if (isViewPrOnlyAction(item.id, gitStatus)) {
501 void runGitActionImmediately(item.id);
502 return;
503 }
504 openGitActionModal(item.id);
505 }}
506 >
507 <GitActionIcon icon={item.icon} disabled={item.disabled} />
508 <span>{item.label}</span>
509 </button>
510 );
511 })}
512 {gitStatus?.branch === null && (
513 <p className="px-2 pt-1 text-[10px] text-amber-500 dark:text-amber-300">
514 Detached HEAD: push and PR actions are unavailable.
515 </p>
516 )}
517 {gitStatus &&
518 gitStatus.branch !== null &&
519 !gitStatus.hasWorkingTreeChanges &&
520 gitStatus.aheadCount === 0 &&
521 gitStatus.behindCount > 0 && (
522 <p className="px-3 pt-1 text-[10px] text-amber-500 dark:text-amber-300">
523 Branch is behind upstream. Pull/rebase before opening a PR.
524 </p>
525 )}
526 {gitActionError && (
527 <p className="px-3 pt-2 text-[11px] text-rose-500 dark:text-rose-300">
528 {gitActionError}
529 </p>
530 )}
531 </div>
532 )}
533 </div>
534
535 {isGitModalOpen && gitModalAction && (
536 <div
537 className="fixed inset-0 z-[80] flex items-center justify-center bg-black/35 px-4 py-6"
538 onMouseDown={() => {
539 closeGitActionModal();
540 }}
541 >
542 <div
543 className="w-full max-w-[640px] rounded-3xl border border-border bg-popover p-6 shadow-2xl"
544 role="dialog"
545 aria-modal="true"
546 aria-label="Git action confirmation"
547 onMouseDown={(event) => {
548 event.stopPropagation();
549 }}
550 >
551 <div className="flex items-start justify-between gap-4">
552 <div className="flex items-center gap-3">
553 <div className="rounded-2xl bg-accent p-3">
554 <GitActionIcon
555 icon={
556 gitModalAction === "commit"
557 ? "commit"
558 : gitModalAction === "commit_push"
559 ? "push"
560 : "pr"
561 }
562 disabled={false}
563 />
564 </div>
565 <div>
566 <p className="text-xs uppercase tracking-[0.14em] text-muted-foreground/70">
567 Git actions
568 </p>
569 <h3 className="text-3xl font-semibold tracking-tight text-foreground">
570 {gitActionModalTitle(gitModalAction)}
571 </h3>
572 </div>
573 </div>
574 <button
575 type="button"
576 className="rounded-md p-1 text-muted-foreground/60 transition-colors duration-150 hover:bg-accent hover:text-foreground disabled:cursor-not-allowed disabled:opacity-50"
577 onClick={closeGitActionModal}
578 disabled={isGitActionRunning}
579 aria-label="Close git action dialog"
580 >
581 <XIcon className="h-5 w-5" />
582 </button>
583 </div>
584
585 <div className="mt-6 space-y-2 rounded-2xl border border-border/80 bg-card/40 px-4 py-3">
586 <div className="flex items-center justify-between gap-4 text-sm">
587 <span className="text-muted-foreground/70">Branch</span>
588 <span className="font-mono text-foreground">
589 {gitStatus?.branch ?? "(detached HEAD)"}
590 </span>
591 </div>
592 <div className="flex items-center justify-between gap-4 text-sm">
593 <span className="text-muted-foreground/70">Changes</span>
594 <span className="text-foreground">
595 {gitStatus?.hasWorkingTreeChanges ? "Working tree has changes" : "No local changes"}
596 </span>
597 </div>
598 </div>
599
600 <div className="mt-6">
601 <label
602 htmlFor="git-commit-message"
603 className="mb-2 block text-sm font-medium text-foreground"
604 >
605 Commit message
606 </label>
607 <textarea
608 id="git-commit-message"
609 rows={3}
610 className="w-full resize-none rounded-2xl border border-border bg-background px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground/50 focus:border-ring focus:outline-none disabled:cursor-not-allowed disabled:opacity-70"
611 placeholder="Leave blank to autogenerate a commit message"
612 value={gitModalCommitMessage}
613 onChange={(event) => setGitModalCommitMessage(event.target.value)}
614 disabled={isGitActionRunning || gitModalResult !== null}
615 />
616 <p className="mt-1.5 text-xs text-muted-foreground/65">
617 Leave this empty to use AI-generated commit text.
618 </p>
619 </div>
620
621 <div className="mt-6">
622 <p className="text-sm font-medium text-foreground">Next steps</p>
623 <div className="mt-2 overflow-hidden rounded-2xl border border-border">
624 {gitModalSteps.map((step, index) => {
625 const borderClass =
626 index < gitModalSteps.length - 1 ? "border-b border-border/70" : "";
627 const statusTextClass =
628 step.status === "failed"
629 ? "text-rose-500 dark:text-rose-300"
630 : step.status === "completed"
631 ? "text-emerald-600 dark:text-emerald-300"
632 : "text-muted-foreground/70";
633
634 return (
635 <div
636 key={step.id}
637 className={`flex items-start gap-3 bg-card/45 px-4 py-3 ${borderClass}`}
638 >
639 <span className="mt-0.5">
640 {step.status === "running" ? (
641 <Loader2Icon className="h-4 w-4 animate-spin text-foreground" />
642 ) : step.status === "completed" ? (
643 <CheckIcon className="h-4 w-4 text-emerald-600 dark:text-emerald-300" />
644 ) : step.status === "skipped" ? (
645 <MinusIcon className="h-4 w-4 text-muted-foreground/70" />
646 ) : step.status === "failed" ? (
647 <XIcon className="h-4 w-4 text-rose-500 dark:text-rose-300" />
648 ) : (
649 <CircleIcon className="h-4 w-4 text-muted-foreground/60" />
650 )}
651 </span>
652 <div className="min-w-0">
653 <p className="text-sm text-foreground">{step.label}</p>
654 {step.detail && (
655 <p className={`mt-0.5 text-xs ${statusTextClass}`}>{step.detail}</p>
656 )}
657 </div>
658 </div>
659 );
660 })}
661 </div>
662 </div>
663
664 {(gitModalError ?? gitActionError) && (
665 <div className="mt-4 rounded-lg border border-rose-300/50 bg-rose-500/10 px-3 py-2 text-xs text-rose-600 dark:text-rose-200">
666 {gitModalError ?? gitActionError}
667 </div>
668 )}
669
670 {gitModalResult?.pr.url && (
671 <div className="mt-4 rounded-lg border border-border bg-card/40 px-3 py-2 text-xs text-muted-foreground/80">
672 PR:{" "}
673 <a
674 href={gitModalResult.pr.url}
675 target="_blank"
676 rel="noreferrer"
677 className="text-foreground underline underline-offset-2"
678 >
679 {gitModalResult.pr.url}
680 </a>
681 </div>
682 )}
683
684 <div className="mt-6 flex justify-end gap-2">
685 <button
686 type="button"
687 className="rounded-xl border border-border px-4 py-2 text-sm text-foreground transition-colors duration-150 hover:bg-accent disabled:cursor-not-allowed disabled:opacity-60"
688 onClick={closeGitActionModal}
689 disabled={isGitActionRunning}
690 >
691 {gitModalResult ? "Done" : "Cancel"}
692 </button>
693 {!gitModalResult && (
694 <button
695 type="button"
696 className="rounded-xl bg-foreground px-4 py-2 text-sm font-medium text-background transition-colors duration-150 hover:bg-foreground/90 disabled:cursor-not-allowed disabled:opacity-60"
697 onClick={() => {
698 void runGitAction();
699 }}
700 disabled={isGitActionRunning}
701 >
702 {isGitActionRunning ? "Running..." : runActionLabel(gitModalAction)}
703 </button>
704 )}
705 </div>
706 </div>
707 </div>
708 )}
709 </>
710 );
711}