| ... | @@ -1,5 +1,6 @@ | ... | @@ -1,5 +1,6 @@ |
| 1 | import { | 1 | import { |
| 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"; |
| 40 | import { isTerminalToggleShortcut } from "../terminal-shortcuts"; | 41 | import { isTerminalToggleShortcut } from "../terminal-shortcuts"; |
| 41 | import ChatMarkdown from "./ChatMarkdown"; | 42 | import ChatMarkdown from "./ChatMarkdown"; |
| 42 | import ThreadTerminalDrawer from "./ThreadTerminalDrawer"; | 43 | 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"; |
| 44 | | 54 | |
| 45 | function formatMessageMeta(createdAt: string, duration: string | null): string { | 55 | function 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 | } |
| 83 | | 93 | |
| | 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 | |
| 84 | function workToneClass(tone: "thinking" | "tool" | "info" | "error"): string { | 176 | function 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; |
| 328 | | 435 | |
| 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]); |
| 345 | | 452 | |
| 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 | }, []); |
| 350 | | 462 | |
| 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]); |
| 374 | | 608 | |
| 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() { |
| 436 | | 670 | |
| 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]); |
| 440 | | 679 | |
| 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]); |
| 539 | | 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 | |
| 540 | // Cmd+O / Ctrl+O to open in last-used editor | 794 | // 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 | )} |
| 943 | | 1197 | |
| 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> |
| 1432 | | 1680 | |
| | 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} /> |
| 1434 | | 1856 | |
| 1435 | {activeThread.terminalOpen && api && activeProject && ( | 1857 | {activeThread.terminalOpen && api && activeProject && ( |