| ... | ... | @@ -1,8 +1,5 @@ |
| 1 | 1 | import { |
| 2 | 2 | PROVIDER_SEND_TURN_MAX_INPUT_CHARS, |
| 3 | | type GitRunStackedActionResult, |
| 4 | | type GitStackedAction, |
| 5 | | type GitStatusResult, |
| 6 | 3 | type ProviderApprovalDecision, |
| 7 | 4 | type ProviderEvent, |
| 8 | 5 | } from "@t3tools/contracts"; |
| ... | ... | @@ -38,19 +35,10 @@ import { |
| 38 | 35 | } from "../session-logic"; |
| 39 | 36 | import { useStore } from "../store"; |
| 40 | 37 | import BranchToolbar from "./BranchToolbar"; |
| 38 | import GitActionsControl from "./GitActionsControl"; |
| 41 | 39 | import { isTerminalToggleShortcut } from "../terminal-shortcuts"; |
| 42 | 40 | import ChatMarkdown from "./ChatMarkdown"; |
| 43 | 41 | import ThreadTerminalDrawer from "./ThreadTerminalDrawer"; |
| 44 | | import { |
| 45 | | CheckIcon, |
| 46 | | CircleIcon, |
| 47 | | CloudUploadIcon, |
| 48 | | GitCommitIcon, |
| 49 | | GithubIcon, |
| 50 | | Loader2Icon, |
| 51 | | MinusIcon, |
| 52 | | XIcon, |
| 53 | | } from "lucide-react"; |
| 54 | 42 | |
| 55 | 43 | function formatMessageMeta(createdAt: string, duration: string | null): string { |
| 56 | 44 | if (!duration) return formatTimestamp(createdAt); |
| ... | ... | @@ -70,109 +58,6 @@ function editorLabel(editor: (typeof EDITORS)[number]): string { |
| 70 | 58 | const LAST_EDITOR_KEY = "t3code:last-editor"; |
| 71 | 59 | const MAX_VISIBLE_WORK_LOG_ENTRIES = 6; |
| 72 | 60 | |
| 73 | | interface GitActionMenuItem { |
| 74 | | id: GitStackedAction; |
| 75 | | label: string; |
| 76 | | disabled: boolean; |
| 77 | | icon: "commit" | "push" | "pr"; |
| 78 | | } |
| 79 | | |
| 80 | | function 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 | | |
| 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 | | |
| 176 | 61 | function workToneClass(tone: "thinking" | "tool" | "info" | "error"): string { |
| 177 | 62 | if (tone === "error") return "text-rose-300/50 dark:text-rose-300/50"; |
| 178 | 63 | if (tone === "tool") return "text-muted-foreground/70"; |
| ... | ... | @@ -254,15 +139,6 @@ export default function ChatView() { |
| 254 | 139 | const [isConnecting, setIsConnecting] = useState(false); |
| 255 | 140 | const [isModelMenuOpen, setIsModelMenuOpen] = useState(false); |
| 256 | 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 | 142 | const [lastEditor, setLastEditor] = useState<EditorId>(() => { |
| 267 | 143 | const stored = localStorage.getItem(LAST_EDITOR_KEY); |
| 268 | 144 | return EDITORS.some((e) => e.id === stored) ? (stored as EditorId) : EDITORS[0].id; |
| ... | ... | @@ -278,8 +154,6 @@ export default function ChatView() { |
| 278 | 154 | const textareaRef = useRef<HTMLTextAreaElement>(null); |
| 279 | 155 | const modelMenuRef = useRef<HTMLDivElement>(null); |
| 280 | 156 | const editorMenuRef = useRef<HTMLDivElement>(null); |
| 281 | | const gitMenuRef = useRef<HTMLDivElement>(null); |
| 282 | | const latestGitCwdRef = useRef<string | null>(null); |
| 283 | 157 | const terminalOpenByThreadRef = useRef<Record<string, boolean>>({}); |
| 284 | 158 | |
| 285 | 159 | const activeThread = state.threads.find((t) => t.id === state.activeThreadId); |
| ... | ... | @@ -384,227 +258,6 @@ export default function ChatView() { |
| 384 | 258 | sandboxMode: "workspace-write", |
| 385 | 259 | } as const); |
| 386 | 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]); |
| 608 | 261 | |
| 609 | 262 | const envLocked = Boolean( |
| 610 | 263 | activeThread && |
| ... | ... | @@ -668,43 +321,6 @@ export default function ChatView() { |
| 668 | 321 | |
| 669 | 322 | const activeWorktreePath = activeThread?.worktreePath; |
| 670 | 323 | |
| 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 | 324 | useEffect(() => { |
| 709 | 325 | if (!activeThread?.id) return; |
| 710 | 326 | setEnvMode(activeWorktreePath ? "worktree" : "local"); |
| ... | ... | @@ -760,37 +376,6 @@ export default function ChatView() { |
| 760 | 376 | }; |
| 761 | 377 | }, [isEditorMenuOpen]); |
| 762 | 378 | |
| 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 | 379 | // Cmd+O / Ctrl+O to open in last-used editor |
| 795 | 380 | useEffect(() => { |
| 796 | 381 | const handler = (e: globalThis.KeyboardEvent) => { |
| ... | ... | @@ -1110,57 +695,7 @@ export default function ChatView() { |
| 1110 | 695 | )} |
| 1111 | 696 | {/* Git actions */} |
| 1112 | 697 | {activeProject && ( |
| 1113 | | <div className="relative" ref={gitMenuRef}> |
| 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> |
| 698 | <GitActionsControl api={api} gitCwd={gitCwd} /> |
| 1164 | 699 | )} |
| 1165 | 700 | {/* Diff toggle */} |
| 1166 | 701 | <button |
| ... | ... | @@ -1678,180 +1213,6 @@ export default function ChatView() { |
| 1678 | 1213 | </form> |
| 1679 | 1214 | </div> |
| 1680 | 1215 | |
| 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 | 1216 | <BranchToolbar envMode={envMode} onEnvModeChange={setEnvMode} envLocked={envLocked} /> |
| 1856 | 1217 | |
| 1857 | 1218 | {activeThread.terminalOpen && api && activeProject && ( |