authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-02-12 12:22:05-08:00
committergravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-02-12 12:22:05-08:00
log36bc85f54dbfa9e8cd5a9d61bcda0189bfef762d
treecfcc577501a843a219c0da4400ac2705ef45f7e9
parent3c939e8e694bbba5b16bd13d617fb0d41f229eb0

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 Co-authored-by: codex <codex@users.noreply.github.com>

5 files changed, 538 insertions(+), 44 deletions(-)

apps/server/src/gitManager.test.ts+34
...@@ -248,6 +248,40 @@ describe("GitManager", () => {...@@ -248,6 +248,40 @@ describe("GitManager", () => {
248 );248 );
249 });249 });
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
251 it("skips commit when there are no uncommitted changes", async () => {285 it("skips commit when there are no uncommitted changes", async () => {
252 const repoDir = makeTempDir("t3code-git-manager-");286 const repoDir = makeTempDir("t3code-git-manager-");
253 await initRepo(repoDir);287 await initRepo(repoDir);
apps/server/src/gitManager.ts+41-13
...@@ -106,6 +106,24 @@ function sanitizeCommitMessage(...@@ -106,6 +106,24 @@ function sanitizeCommitMessage(
106 };106 };
107}107}
108108
109function 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
109function extractBranchFromRef(ref: string): string {127function extractBranchFromRef(ref: string): string {
110 const normalized = ref.trim();128 const normalized = ref.trim();
111129
...@@ -222,7 +240,11 @@ export class GitManager {...@@ -222,7 +240,11 @@ export class GitManager {
222 throw new Error("Cannot create a pull request from detached HEAD.");240 throw new Error("Cannot create a pull request from detached HEAD.");
223 }241 }
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
227 const push = wantsPush249 const push = wantsPush
228 ? await this.gitCore.pushCurrentBranch(input.cwd, initialStatus.branch)250 ? await this.gitCore.pushCurrentBranch(input.cwd, initialStatus.branch)
...@@ -243,6 +265,7 @@ export class GitManager {...@@ -243,6 +265,7 @@ export class GitManager {
243 private async runCommitStep(265 private async runCommitStep(
244 cwd: string,266 cwd: string,
245 branch: string | null,267 branch: string | null,
268 commitMessage?: string,
246 ): Promise<{269 ): Promise<{
247 status: "created" | "skipped_no_changes";270 status: "created" | "skipped_no_changes";
248 commitSha?: string | undefined;271 commitSha?: string | undefined;
...@@ -253,18 +276,23 @@ export class GitManager {...@@ -253,18 +276,23 @@ export class GitManager {
253 return { status: "skipped_no_changes" };276 return { status: "skipped_no_changes" };
254 }277 }
255278
256 let generated: CommitMessageGenerationResult;279 let generated: CommitMessageGenerationResult | null = parseCustomCommitMessage(
257 try {280 commitMessage ?? "",
258 generated = sanitizeCommitMessage(281 );
259 await this.textGenerator.generateCommitMessage({282
260 cwd,283 if (!generated) {
261 branch,284 try {
262 stagedSummary: limitContext(context.stagedSummary, 8_000),285 generated = sanitizeCommitMessage(
263 stagedPatch: limitContext(context.stagedPatch, 50_000),286 await this.textGenerator.generateCommitMessage({
264 }),287 cwd,
265 );288 branch,
266 } catch (error) {289 stagedSummary: limitContext(context.stagedSummary, 8_000),
267 throw asCommandNotFound("codex", error) ?? error;290 stagedPatch: limitContext(context.stagedPatch, 50_000),
291 }),
292 );
293 } catch (error) {
294 throw asCommandNotFound("codex", error) ?? error;
295 }
268 }296 }
269297
270 const { commitSha } = await this.gitCore.commit(cwd, generated.subject, generated.body);298 const { commitSha } = await this.gitCore.commit(cwd, generated.subject, generated.body);
apps/web/src/components/ChatView.tsx+453-31
...@@ -1,5 +1,6 @@...@@ -1,5 +1,6 @@
1import {1import {
2 PROVIDER_SEND_TURN_MAX_INPUT_CHARS,2 PROVIDER_SEND_TURN_MAX_INPUT_CHARS,
3 type GitRunStackedActionResult,
3 type GitStackedAction,4 type GitStackedAction,
4 type GitStatusResult,5 type GitStatusResult,
5 type ProviderApprovalDecision,6 type ProviderApprovalDecision,
...@@ -40,7 +41,16 @@ import BranchToolbar from "./BranchToolbar";...@@ -40,7 +41,16 @@ import BranchToolbar from "./BranchToolbar";
40import { isTerminalToggleShortcut } from "../terminal-shortcuts";41import { isTerminalToggleShortcut } from "../terminal-shortcuts";
41import ChatMarkdown from "./ChatMarkdown";42import ChatMarkdown from "./ChatMarkdown";
42import ThreadTerminalDrawer from "./ThreadTerminalDrawer";43import ThreadTerminalDrawer from "./ThreadTerminalDrawer";
43import { CloudUploadIcon, GitCommitIcon, GithubIcon } from "lucide-react";44import {
45 CheckIcon,
46 CircleIcon,
47 CloudUploadIcon,
48 GitCommitIcon,
49 GithubIcon,
50 Loader2Icon,
51 MinusIcon,
52 XIcon,
53} from "lucide-react";
4454
45function formatMessageMeta(createdAt: string, duration: string | null): string {55function formatMessageMeta(createdAt: string, duration: string | null): string {
46 if (!duration) return formatTimestamp(createdAt);56 if (!duration) return formatTimestamp(createdAt);
...@@ -81,6 +91,88 @@ function GitActionIcon(props: { icon: GitActionMenuItem["icon"]; disabled: boole...@@ -81,6 +91,88 @@ function GitActionIcon(props: { icon: GitActionMenuItem["icon"]; disabled: boole
81 return <GithubIcon className={`h-5 w-5 shrink-0 ${toneClass}`} />;91 return <GithubIcon className={`h-5 w-5 shrink-0 ${toneClass}`} />;
82}92}
8393
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
84function workToneClass(tone: "thinking" | "tool" | "info" | "error"): string {176function workToneClass(tone: "thinking" | "tool" | "info" | "error"): string {
85 if (tone === "error") return "text-rose-300/50 dark:text-rose-300/50";177 if (tone === "error") return "text-rose-300/50 dark:text-rose-300/50";
86 if (tone === "tool") return "text-muted-foreground/70";178 if (tone === "tool") return "text-muted-foreground/70";
...@@ -166,6 +258,11 @@ export default function ChatView() {...@@ -166,6 +258,11 @@ export default function ChatView() {
166 const [isGitActionRunning, setIsGitActionRunning] = useState(false);258 const [isGitActionRunning, setIsGitActionRunning] = useState(false);
167 const [gitStatus, setGitStatus] = useState<GitStatusResult | null>(null);259 const [gitStatus, setGitStatus] = useState<GitStatusResult | null>(null);
168 const [gitActionError, setGitActionError] = useState<string | 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);
169 const [lastEditor, setLastEditor] = useState<EditorId>(() => {266 const [lastEditor, setLastEditor] = useState<EditorId>(() => {
170 const stored = localStorage.getItem(LAST_EDITOR_KEY);267 const stored = localStorage.getItem(LAST_EDITOR_KEY);
171 return EDITORS.some((e) => e.id === stored) ? (stored as EditorId) : EDITORS[0].id;268 return EDITORS.some((e) => e.id === stored) ? (stored as EditorId) : EDITORS[0].id;
...@@ -325,6 +422,16 @@ export default function ChatView() {...@@ -325,6 +422,16 @@ export default function ChatView() {
325 },422 },
326 ];423 ];
327 }, [gitBaseDisabled, gitStatus]);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;
328435
329 useEffect(() => {436 useEffect(() => {
330 latestGitCwdRef.current = gitCwd;437 latestGitCwdRef.current = gitCwd;
...@@ -343,34 +450,161 @@ export default function ChatView() {...@@ -343,34 +450,161 @@ export default function ChatView() {
343 setGitActionError(null);450 setGitActionError(null);
344 }, [api, gitCwd]);451 }, [api, gitCwd]);
345452
346 const runGitAction = useCallback(453 const openGitActionModal = useCallback((action: GitStackedAction) => {
347 async (action: GitStackedAction) => {454 setIsGitMenuOpen(false);
348 if (!api || !gitCwd) return;455 setGitModalAction(action);
349 const actionCwd = gitCwd;456 setGitModalCommitMessage("");
457 setGitModalProgress([]);
458 setGitModalError(null);
459 setGitModalResult(null);
460 setGitActionError(null);
461 }, []);
350462
351 setIsGitMenuOpen(false);463 const closeGitActionModal = useCallback(() => {
352 setIsGitActionRunning(true);464 if (isGitActionRunning) return;
353 setGitActionError(null);465 setGitModalAction(null);
354 try {466 setGitModalCommitMessage("");
355 await api.git.runStackedAction({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({
356 cwd: actionCwd,544 cwd: actionCwd,
357 action,545 action: "commit_push",
358 });546 });
359 } catch (error) {547 push = pushRun.push;
360 setGitActionError(error instanceof Error ? error.message : "Git action failed.");548 if (pushRun.push.status === "pushed") {
361 } finally {549 updateStep(
362 setIsGitActionRunning(false);550 "push",
363 try {551 "completed",
364 if (latestGitCwdRef.current === actionCwd) {552 pushRun.push.upstreamBranch
365 await refreshGitStatus();553 ? `Pushed to ${pushRun.push.upstreamBranch}.`
366 }554 : "Pushed latest commits.",
367 } catch {555 );
368 setGitStatus(null);556 } else {
557 updateStep("push", "skipped", "Branch already up to date.");
369 }558 }
370 }559 }
371 },560
372 [api, gitCwd, refreshGitStatus],561 if (action === "commit_push_pr") {
373 );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
375 const envLocked = Boolean(609 const envLocked = Boolean(
376 activeThread &&610 activeThread &&
...@@ -436,6 +670,11 @@ export default function ChatView() {...@@ -436,6 +670,11 @@ export default function ChatView() {
436670
437 useEffect(() => {671 useEffect(() => {
438 setGitActionError(null);672 setGitActionError(null);
673 setGitModalError(null);
674 setGitModalAction(null);
675 setGitModalCommitMessage("");
676 setGitModalProgress([]);
677 setGitModalResult(null);
439 }, [activeProject?.id, activeWorktreePath]);678 }, [activeProject?.id, activeWorktreePath]);
440679
441 useEffect(() => {680 useEffect(() => {
...@@ -537,6 +776,21 @@ export default function ChatView() {...@@ -537,6 +776,21 @@ export default function ChatView() {
537 };776 };
538 }, [isGitMenuOpen]);777 }, [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
540 // Cmd+O / Ctrl+O to open in last-used editor794 // Cmd+O / Ctrl+O to open in last-used editor
541 useEffect(() => {795 useEffect(() => {
542 const handler = (e: globalThis.KeyboardEvent) => {796 const handler = (e: globalThis.KeyboardEvent) => {
...@@ -882,7 +1136,7 @@ export default function ChatView() {...@@ -882,7 +1136,7 @@ export default function ChatView() {
882 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"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"
883 disabled={item.disabled}1137 disabled={item.disabled}
884 onClick={() => {1138 onClick={() => {
885 void runGitAction(item.id);1139 openGitActionModal(item.id);
886 }}1140 }}
887 >1141 >
888 <GitActionIcon icon={item.icon} disabled={item.disabled} />1142 <GitActionIcon icon={item.icon} disabled={item.disabled} />
...@@ -941,12 +1195,6 @@ export default function ChatView() {...@@ -941,12 +1195,6 @@ export default function ChatView() {
941 </div>1195 </div>
942 )}1196 )}
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
950 {pendingApprovals.length > 0 && (1198 {pendingApprovals.length > 0 && (
951 <div className="mx-4 mt-3 space-y-2">1199 <div className="mx-4 mt-3 space-y-2">
952 {pendingApprovals.map((approval) => {1200 {pendingApprovals.map((approval) => {
...@@ -1430,6 +1678,180 @@ export default function ChatView() {...@@ -1430,6 +1678,180 @@ export default function ChatView() {
1430 </form>1678 </form>
1431 </div>1679 </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
1433 <BranchToolbar envMode={envMode} onEnvModeChange={setEnvMode} envLocked={envLocked} />1855 <BranchToolbar envMode={envMode} onEnvModeChange={setEnvMode} envLocked={envLocked} />
14341856
1435 {activeThread.terminalOpen && api && activeProject && (1857 {activeThread.terminalOpen && api && activeProject && (
packages/contracts/src/git.test.ts+9
...@@ -43,6 +43,15 @@ describe("git contracts", () => {...@@ -43,6 +43,15 @@ describe("git contracts", () => {
43 expect(parsed.action).toBe("commit_push");43 expect(parsed.action).toBe("commit_push");
44 });44 });
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
46 it("parses stacked action result with metadata", () => {55 it("parses stacked action result with metadata", () => {
47 const parsed = gitRunStackedActionResultSchema.parse({56 const parsed = gitRunStackedActionResultSchema.parse({
48 action: "commit_push_pr",57 action: "commit_push_pr",
packages/contracts/src/git.ts+1
...@@ -106,6 +106,7 @@ const gitPrStepStatusSchema = z.enum([...@@ -106,6 +106,7 @@ const gitPrStepStatusSchema = z.enum([
106export const gitRunStackedActionInputSchema = z.object({106export const gitRunStackedActionInputSchema = z.object({
107 cwd: z.string().trim().min(1),107 cwd: z.string().trim().min(1),
108 action: gitStackedActionSchema,108 action: gitStackedActionSchema,
109 commitMessage: z.string().trim().min(1).max(10_000).optional(),
109});110});
110111
111export const gitRunStackedActionResultSchema = z.object({112export const gitRunStackedActionResultSchema = z.object({