History

Add Git action modal with custom commit message support

- Add a Git action confirmation modal with live commit/push/PR progress states - Accept optional `commitMessage` input and skip AI message generation when provided - Expand server and contracts tests for custom commit message handling

Julius Marminge committed 9 months ago

Commit 36bc85f1 parent 3c939e85 files · +538 −44Browse files

Unified · Split

5 files
  1. apps/server/src/gitManager.test.ts +34 −0
  2. apps/server/src/gitManager.ts +41 −13
  3. apps/web/src/components/ChatView.tsx +453 −31
  4. packages/contracts/src/git.test.ts +9 −0
  5. packages/contracts/src/git.ts +1 −0
apps/server/src/gitManager.test.ts+34−0
@@ -248,6 +248,40 @@ describe("GitManager", () => { · 247 unmodified lines
248248 );
249249 });
250250
251+ it("uses custom commit message when provided", async () => {
252+ const repoDir = makeTempDir("t3code-git-manager-");
253+ await initRepo(repoDir);
254+ fs.writeFileSync(path.join(repoDir, "README.md"), "hello\ncustom\n");
255+ let generatedCount = 0;
256+
257+ const manager = new GitManager({
258+ textGenerator: createTextGenerator({
259+ generateCommitMessage: async () => {
260+ generatedCount += 1;
261+ return {
262+ subject: "this should not be used",
263+ body: "",
264+ };
265+ },
266+ }),
267+ });
268+ const result = await manager.runStackedAction({
269+ cwd: repoDir,
270+ action: "commit",
271+ commitMessage: "feat: custom summary line\n\n- details from user",
272+ });
273+
274+ expect(result.commit.status).toBe("created");
275+ expect(result.commit.subject).toBe("feat: custom summary line");
276+ expect(generatedCount).toBe(0);
277+ expect(await runGitStdout(repoDir, ["log", "-1", "--pretty=%s"])).toBe(
278+ "feat: custom summary line",
279+ );
280+ expect(await runGitStdout(repoDir, ["log", "-1", "--pretty=%b"])).toContain(
281+ "- details from user",
282+ );
283+ });
284+
251285 it("skips commit when there are no uncommitted changes", async () => {
252286 const repoDir = makeTempDir("t3code-git-manager-");
253287 await initRepo(repoDir);
apps/server/src/gitManager.ts+41−13
@@ -106,6 +106,24 @@ function sanitizeCommitMessage( · 105 unmodified lines
106106 };
107107 }
108108
109+function parseCustomCommitMessage(raw: string): CommitMessageGenerationResult | null {
110+ const normalized = raw.replace(/\r\n/g, "\n").trim();
111+ if (normalized.length === 0) {
112+ return null;
113+ }
114+
115+ const [firstLine, ...rest] = normalized.split("\n");
116+ const subject = firstLine?.trim() ?? "";
117+ if (subject.length === 0) {
118+ return null;
119+ }
120+
121+ return {
122+ subject,
123+ body: rest.join("\n").trim(),
124+ };
125+}
126+
109127 function extractBranchFromRef(ref: string): string {
110128 const normalized = ref.trim();
111129
@@ -222,7 +240,11 @@ export class GitManager { · 110 unmodified lines
222240 throw new Error("Cannot create a pull request from detached HEAD.");
223241 }
224242
225− const commit = await this.runCommitStep(input.cwd, initialStatus.branch);
243+ const commit = await this.runCommitStep(
244+ input.cwd,
245+ initialStatus.branch,
246+ input.commitMessage,
247+ );
226248
227249 const push = wantsPush
228250 ? await this.gitCore.pushCurrentBranch(input.cwd, initialStatus.branch)
@@ -243,6 +265,7 @@ export class GitManager { · 14 unmodified lines
243265 private async runCommitStep(
244266 cwd: string,
245267 branch: string | null,
268+ commitMessage?: string,
246269 ): Promise<{
247270 status: "created" | "skipped_no_changes";
248271 commitSha?: string | undefined;
@@ -253,18 +276,23 @@ export class GitManager { · 4 unmodified lines
253276 return { status: "skipped_no_changes" };
254277 }
255278
256− let generated: CommitMessageGenerationResult;
257− try {
258− generated = sanitizeCommitMessage(
259− await this.textGenerator.generateCommitMessage({
260− cwd,
261− branch,
262− stagedSummary: limitContext(context.stagedSummary, 8_000),
263− stagedPatch: limitContext(context.stagedPatch, 50_000),
264− }),
265− );
266− } catch (error) {
267− throw asCommandNotFound("codex", error) ?? error;
279+ let generated: CommitMessageGenerationResult | null = parseCustomCommitMessage(
280+ commitMessage ?? "",
281+ );
282+
283+ if (!generated) {
284+ try {
285+ generated = sanitizeCommitMessage(
286+ await this.textGenerator.generateCommitMessage({
287+ cwd,
288+ branch,
289+ stagedSummary: limitContext(context.stagedSummary, 8_000),
290+ stagedPatch: limitContext(context.stagedPatch, 50_000),
291+ }),
292+ );
293+ } catch (error) {
294+ throw asCommandNotFound("codex", error) ?? error;
295+ }
268296 }
269297
270298 const { commitSha } = await this.gitCore.commit(cwd, generated.subject, generated.body);
apps/web/src/components/ChatView.tsx+453−31
@@ -1,5 +1,6 @@
11 import {
22 PROVIDER_SEND_TURN_MAX_INPUT_CHARS,
3+ type GitRunStackedActionResult,
34 type GitStackedAction,
45 type GitStatusResult,
56 type ProviderApprovalDecision,
@@ -40,7 +41,16 @@ import BranchToolbar from "./BranchToolbar"; · 34 unmodified lines
4041 import { isTerminalToggleShortcut } from "../terminal-shortcuts";
4142 import ChatMarkdown from "./ChatMarkdown";
4243 import ThreadTerminalDrawer from "./ThreadTerminalDrawer";
43−import { CloudUploadIcon, GitCommitIcon, GithubIcon } from "lucide-react";
44+import {
45+ CheckIcon,
46+ CircleIcon,
47+ CloudUploadIcon,
48+ GitCommitIcon,
49+ GithubIcon,
50+ Loader2Icon,
51+ MinusIcon,
52+ XIcon,
53+} from "lucide-react";
4454
4555 function formatMessageMeta(createdAt: string, duration: string | null): string {
4656 if (!duration) return formatTimestamp(createdAt);
@@ -81,6 +91,88 @@ function GitActionIcon(props: { icon: GitActionMenuItem["icon"]; disabled: boole · 34 unmodified lines
8191 return <GithubIcon className={`h-5 w-5 shrink-0 ${toneClass}`} />;
8292 }
8393
94+type GitProgressStepStatus =
95+ | "pending"
96+ | "running"
97+ | "completed"
98+ | "skipped"
99+ | "failed";
100+
101+interface GitProgressStep {
102+ id: "generate" | "commit" | "push" | "pr";
103+ label: string;
104+ status: GitProgressStepStatus;
105+ detail?: string;
106+}
107+
108+function 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+
114+function 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+
154+function 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+
170+function 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+
84176 function workToneClass(tone: "thinking" | "tool" | "info" | "error"): string {
85177 if (tone === "error") return "text-rose-300/50 dark:text-rose-300/50";
86178 if (tone === "tool") return "text-muted-foreground/70";
@@ -166,6 +258,11 @@ export default function ChatView() { · 79 unmodified lines
166258 const [isGitActionRunning, setIsGitActionRunning] = useState(false);
167259 const [gitStatus, setGitStatus] = useState<GitStatusResult | null>(null);
168260 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);
169266 const [lastEditor, setLastEditor] = useState<EditorId>(() => {
170267 const stored = localStorage.getItem(LAST_EDITOR_KEY);
171268 return EDITORS.some((e) => e.id === stored) ? (stored as EditorId) : EDITORS[0].id;
@@ -325,6 +422,16 @@ export default function ChatView() { · 153 unmodified lines
325422 },
326423 ];
327424 }, [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;
328435
329436 useEffect(() => {
330437 latestGitCwdRef.current = gitCwd;
@@ -343,34 +450,161 @@ export default function ChatView() { · 12 unmodified lines
343450 setGitActionError(null);
344451 }, [api, gitCwd]);
345452
346− const runGitAction = useCallback(
347− async (action: GitStackedAction) => {
348− if (!api || !gitCwd) return;
349− const actionCwd = gitCwd;
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+ }, []);
350462
351− setIsGitMenuOpen(false);
352− setIsGitActionRunning(true);
353− setGitActionError(null);
354− try {
355− await api.git.runStackedAction({
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({
356544 cwd: actionCwd,
357− action,
545+ action: "commit_push",
358546 });
359− } catch (error) {
360− setGitActionError(error instanceof Error ? error.message : "Git action failed.");
361− } finally {
362− setIsGitActionRunning(false);
363− try {
364− if (latestGitCwdRef.current === actionCwd) {
365− await refreshGitStatus();
366− }
367− } catch {
368− setGitStatus(null);
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.");
369558 }
370559 }
371− },
372− [api, gitCwd, refreshGitStatus],
373− );
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]);
374608
375609 const envLocked = Boolean(
376610 activeThread &&
@@ -436,6 +670,11 @@ export default function ChatView() { · 59 unmodified lines
436670
437671 useEffect(() => {
438672 setGitActionError(null);
673+ setGitModalError(null);
674+ setGitModalAction(null);
675+ setGitModalCommitMessage("");
676+ setGitModalProgress([]);
677+ setGitModalResult(null);
439678 }, [activeProject?.id, activeWorktreePath]);
440679
441680 useEffect(() => {
@@ -537,6 +776,21 @@ export default function ChatView() { · 95 unmodified lines
537776 };
538777 }, [isGitMenuOpen]);
539778
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+
540794 // Cmd+O / Ctrl+O to open in last-used editor
541795 useEffect(() => {
542796 const handler = (e: globalThis.KeyboardEvent) => {
@@ -882,7 +1136,7 @@ export default function ChatView() { · 339 unmodified lines
8821136 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"
8831137 disabled={item.disabled}
8841138 onClick={() => {
885− void runGitAction(item.id);
1139+ openGitActionModal(item.id);
8861140 }}
8871141 >
8881142 <GitActionIcon icon={item.icon} disabled={item.disabled} />
@@ -941,12 +1195,6 @@ export default function ChatView() { · 52 unmodified lines
9411195 </div>
9421196 )}
9431197
944− {gitActionError && (
945− <div className="mx-4 mt-3 rounded-lg border border-rose-200 bg-rose-50 px-3 py-2 text-xs text-rose-800 dark:border-rose-400/20 dark:bg-rose-900/20 dark:text-rose-200">
946− {gitActionError}
947− </div>
948− )}
949−
9501198 {pendingApprovals.length > 0 && (
9511199 <div className="mx-4 mt-3 space-y-2">
9521200 {pendingApprovals.map((approval) => {
@@ -1430,6 +1678,180 @@ export default function ChatView() { · 477 unmodified lines
14301678 </form>
14311679 </div>
14321680
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+
14331855 <BranchToolbar envMode={envMode} onEnvModeChange={setEnvMode} envLocked={envLocked} />
14341856
14351857 {activeThread.terminalOpen && api && activeProject && (
packages/contracts/src/git.test.ts+9−0
@@ -43,6 +43,15 @@ describe("git contracts", () => { · 42 unmodified lines
4343 expect(parsed.action).toBe("commit_push");
4444 });
4545
46+ it("accepts custom commit message input", () => {
47+ const parsed = gitRunStackedActionInputSchema.parse({
48+ cwd: "/tmp/repo",
49+ action: "commit",
50+ commitMessage: " chore: update git action modal ",
51+ });
52+ expect(parsed.commitMessage).toBe("chore: update git action modal");
53+ });
54+
4655 it("parses stacked action result with metadata", () => {
4756 const parsed = gitRunStackedActionResultSchema.parse({
4857 action: "commit_push_pr",
packages/contracts/src/git.ts+1−0
@@ -106,6 +106,7 @@ const gitPrStepStatusSchema = z.enum([ · 105 unmodified lines
106106 export const gitRunStackedActionInputSchema = z.object({
107107 cwd: z.string().trim().min(1),
108108 action: gitStackedActionSchema,
109+ commitMessage: z.string().trim().min(1).max(10_000).optional(),
109110 });
110111
111112 export const gitRunStackedActionResultSchema = z.object({