authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-02-13 15:07:17-08:00
committergravatar for jmarminge@gmail.comJulius Marminge <jmarminge@gmail.com> 2026-02-13 15:26:36-08:00
log38de3417b8fe79ff97711631718d0a4929b079a7
treec67ffb4ffe300f75df7ae0155bfc0386af98ccf3
parent4fff00c8c3bed8864530d31617fb45e4788b9d83

Move git action progress to toast and stop auto-opening PRs

- Keep the git action modal in selection mode while actions run in a dismissible progress toast - Remove automatic `gh pr view --web` calls; only return PR metadata and open links on explicit user action - Update server/web tests to assert no implicit PR browser open behavior

3 files changed, 185 insertions(+), 130 deletions(-)

apps/server/src/gitManager.test.ts+3-4
......@@ -343,7 +343,7 @@ describe("GitManager", () => {
343343 expect(result.push.status).toBe("skipped_up_to_date");
344344 });
345345
346 it("opens existing PR for commit/push/pr action", async () => {
346 it("returns existing PR metadata for commit/push/pr action", async () => {
347347 const repoDir = makeTempDir("t3code-git-manager-");
348348 await initRepo(repoDir);
349349 await runGit(repoDir, ["checkout", "-b", "feature/existing-pr"]);
......@@ -376,9 +376,7 @@ describe("GitManager", () => {
376376
377377 expect(result.pr.status).toBe("opened_existing");
378378 expect(result.pr.number).toBe(42);
379 expect(
380 ghCalls.some((call) => call.startsWith("pr view feature/existing-pr --web")),
381 ).toBe(true);
379 expect(ghCalls.some((call) => call.startsWith("pr view "))).toBe(false);
382380 });
383381
384382 it("creates PR when one does not already exist", async () => {
......@@ -422,6 +420,7 @@ describe("GitManager", () => {
422420 expect(
423421 ghCalls.some((call) => call.includes("pr create --base main --head feature-create-pr")),
424422 ).toBe(true);
423 expect(ghCalls.some((call) => call.startsWith("pr view "))).toBe(false);
425424 });
426425
427426 it("rejects push/pr actions from detached HEAD", async () => {
apps/server/src/gitManager.ts-10
......@@ -325,7 +325,6 @@ export class GitManager {
325325
326326 const existing = await this.findOpenPr(cwd, branch);
327327 if (existing) {
328 await this.openPrInBrowser(cwd, branch);
329328 return {
330329 status: "opened_existing",
331330 url: existing.url,
......@@ -381,7 +380,6 @@ export class GitManager {
381380 }
382381
383382 const created = await this.findOpenPr(cwd, branch);
384 await this.openPrInBrowser(cwd, branch);
385383
386384 if (!created) {
387385 return {
......@@ -402,14 +400,6 @@ export class GitManager {
402400 };
403401 }
404402
405 private async openPrInBrowser(cwd: string, branch: string): Promise<void> {
406 try {
407 await this.runGh(cwd, ["pr", "view", branch, "--web"]);
408 } catch {
409 // Opening the browser is best-effort.
410 }
411 }
412
413403 private async findOpenPr(
414404 cwd: string,
415405 branch: string,
apps/web/src/components/GitActionsControl.tsx+182-116
......@@ -64,6 +64,22 @@ function GitActionIcon(props: { icon: GitActionIconName; disabled: boolean }) {
6464 return <GitHubIcon className={`h-5 w-5 shrink-0 ${toneClass}`} />;
6565}
6666
67function GitProgressStatusIcon(props: { status: GitProgressStepStatus }) {
68 if (props.status === "running") {
69 return <Loader2Icon className="h-4 w-4 animate-spin text-foreground" />;
70 }
71 if (props.status === "completed") {
72 return <CheckIcon className="h-4 w-4 text-emerald-600 dark:text-emerald-300" />;
73 }
74 if (props.status === "skipped") {
75 return <MinusIcon className="h-4 w-4 text-muted-foreground/70" />;
76 }
77 if (props.status === "failed") {
78 return <XIcon className="h-4 w-4 text-rose-500 dark:text-rose-300" />;
79 }
80 return <CircleIcon className="h-4 w-4 text-muted-foreground/60" />;
81}
82
6783function gitActionModalTitle(): string {
6884 return "Commit your changes";
6985}
......@@ -249,6 +265,7 @@ export default function GitActionsControl({ api, gitCwd }: GitActionsControlProp
249265 const [gitModalProgress, setGitModalProgress] = useState<GitProgressStep[]>([]);
250266 const [gitModalError, setGitModalError] = useState<string | null>(null);
251267 const [gitModalResult, setGitModalResult] = useState<GitRunStackedActionResult | null>(null);
268 const [isGitProgressToastVisible, setIsGitProgressToastVisible] = useState(false);
252269 const gitMenuRef = useRef<HTMLDivElement>(null);
253270
254271 const { data: gitStatus = null, error: gitStatusError } = useQuery(
......@@ -291,10 +308,13 @@ export default function GitActionsControl({ api, gitCwd }: GitActionsControlProp
291308 [gitModalActionOptions, gitModalSelectedAction],
292309 );
293310 const visibleGitActionError = gitActionError ?? gitStatusError?.message;
294 const gitModalOpenPrUrl = gitModalResult?.pr.url ?? gitStatus?.openPr?.url ?? null;
295 const gitModalHasProgress = gitModalProgress.length > 0;
296 const gitModalSelectionMode = !gitModalHasProgress && gitModalResult === null;
297 const gitModalSteps = gitModalProgress;
311 const gitProgressToastOpenPrUrl = gitModalResult?.pr.url ?? gitStatus?.openPr?.url ?? null;
312 const showGitProgressToast =
313 isGitProgressToastVisible &&
314 (isGitModalActionRunning ||
315 gitModalProgress.length > 0 ||
316 gitModalError !== null ||
317 gitModalResult !== null);
298318
299319 const refreshGitStatus = useCallback(async () => {
300320 if (!api || !gitCwd) return;
......@@ -307,6 +327,7 @@ export default function GitActionsControl({ api, gitCwd }: GitActionsControlProp
307327 setIsGitModalOpen(true);
308328 setGitModalSelectedAction("commit");
309329 setGitModalCommitMessage("");
330 setIsGitProgressToastVisible(false);
310331 setGitModalProgress([]);
311332 setGitModalError(null);
312333 setGitModalResult(null);
......@@ -318,9 +339,6 @@ export default function GitActionsControl({ api, gitCwd }: GitActionsControlProp
318339 setIsGitModalOpen(false);
319340 setGitModalSelectedAction("commit");
320341 setGitModalCommitMessage("");
321 setGitModalProgress([]);
322 setGitModalError(null);
323 setGitModalResult(null);
324342 }, [isGitActionRunning]);
325343
326344 const runGitActionImmediately = useCallback(
......@@ -349,28 +367,39 @@ export default function GitActionsControl({ api, gitCwd }: GitActionsControlProp
349367 });
350368 }, [api, gitStatus?.openPr?.url]);
351369
352 const openPrFromModal = useCallback(() => {
370 const openPrFromToast = useCallback(() => {
353371 if (!api) {
354372 setGitModalError("Link opening is unavailable.");
355373 return;
356374 }
357375
358 const prUrl = gitModalOpenPrUrl;
376 const prUrl = gitProgressToastOpenPrUrl;
359377 if (!prUrl) return;
360378
361379 void api.shell.openExternal(prUrl).catch((error) => {
362380 setGitModalError(error instanceof Error ? error.message : "Unable to open PR link.");
363381 });
364 }, [api, gitModalOpenPrUrl]);
382 }, [api, gitProgressToastOpenPrUrl]);
383
384 const dismissGitProgressToast = useCallback(() => {
385 if (isGitModalActionRunning) return;
386 setIsGitProgressToastVisible(false);
387 setGitModalProgress([]);
388 setGitModalError(null);
389 setGitModalResult(null);
390 }, [isGitModalActionRunning]);
365391
366392 const runGitAction = useCallback(async () => {
367 if (!api || !gitCwd || !isGitModalOpen) return;
393 if (!api || !gitCwd) return;
368394 if (!selectedGitModalActionOption || selectedGitModalActionOption.disabled) return;
369395 const actionCwd = gitCwd;
370396 const action = selectedGitModalActionOption.action;
371397 const commitMessage = gitModalCommitMessage.trim();
372398 const includeGeneratedCommitMessage = commitMessage.length === 0;
373399
400 setIsGitMenuOpen(false);
401 setIsGitModalOpen(false);
402 setIsGitProgressToastVisible(true);
374403 setIsGitModalActionRunning(true);
375404 setGitModalError(null);
376405 setGitActionError(null);
......@@ -488,7 +517,6 @@ export default function GitActionsControl({ api, gitCwd }: GitActionsControlProp
488517 api,
489518 gitCwd,
490519 gitModalCommitMessage,
491 isGitModalOpen,
492520 refreshGitStatus,
493521 selectedGitModalActionOption,
494522 ]);
......@@ -497,6 +525,7 @@ export default function GitActionsControl({ api, gitCwd }: GitActionsControlProp
497525 setGitActionError(null);
498526 setGitModalError(null);
499527 setIsGitModalOpen(false);
528 setIsGitProgressToastVisible(false);
500529 setGitModalSelectedAction("commit");
501530 setGitModalCommitMessage("");
502531 setGitModalProgress([]);
......@@ -690,92 +719,57 @@ export default function GitActionsControl({ api, gitCwd }: GitActionsControlProp
690719 placeholder="Leave blank to autogenerate a commit message"
691720 value={gitModalCommitMessage}
692721 onChange={(event) => setGitModalCommitMessage(event.target.value)}
693 disabled={!gitModalSelectionMode || isGitActionRunning}
722 disabled={isGitActionRunning}
694723 />
695724 </div>
696725 <div className="mt-6">
697726 <p className="text-sm font-medium text-foreground">Next steps</p>
698727 <div className="mt-2 overflow-hidden rounded-2xl border border-border">
699 {gitModalSelectionMode &&
700 gitModalActionOptions.map((option, index) => {
701 const borderClass =
702 index < gitModalActionOptions.length - 1 ? "border-b border-border/70" : "";
703 const selected = option.action === gitModalSelectedAction && !option.disabled;
704 const disabled = option.disabled || isGitActionRunning;
705
706 return (
707 <button
708 key={option.action}
709 type="button"
710 className={`flex w-full items-start gap-3 px-4 py-3 text-left transition-colors duration-150 ${borderClass} ${
711 selected
712 ? "bg-accent/55"
713 : disabled
714 ? "bg-card/25"
715 : "bg-card/45 hover:bg-accent/35"
716 } disabled:cursor-not-allowed`}
717 disabled={disabled}
718 onClick={() => {
719 setGitModalSelectedAction(option.action);
720 }}
721 >
722 <span className="mt-0.5">
723 <GitActionIcon icon={option.icon} disabled={option.disabled} />
724 </span>
725 <div className="min-w-0 flex-1">
728 {gitModalActionOptions.map((option, index) => {
729 const borderClass =
730 index < gitModalActionOptions.length - 1 ? "border-b border-border/70" : "";
731 const selected = option.action === gitModalSelectedAction && !option.disabled;
732 const disabled = option.disabled || isGitActionRunning;
733
734 return (
735 <button
736 key={option.action}
737 type="button"
738 className={`flex w-full items-start gap-3 px-4 py-3 text-left transition-colors duration-150 ${borderClass} ${
739 selected
740 ? "bg-accent/55"
741 : disabled
742 ? "bg-card/25"
743 : "bg-card/45 hover:bg-accent/35"
744 } disabled:cursor-not-allowed`}
745 disabled={disabled}
746 onClick={() => {
747 setGitModalSelectedAction(option.action);
748 }}
749 >
750 <span className="mt-0.5">
751 <GitActionIcon icon={option.icon} disabled={option.disabled} />
752 </span>
753 <div className="min-w-0 flex-1">
754 <p
755 className={`text-sm ${disabled ? "text-muted-foreground/60" : "text-foreground"}`}
756 >
757 {option.label}
758 </p>
759 {option.detail && (
726760 <p
727 className={`text-sm ${disabled ? "text-muted-foreground/60" : "text-foreground"}`}
761 className={`mt-0.5 text-xs ${disabled ? "text-muted-foreground/55" : "text-muted-foreground/70"}`}
728762 >
729 {option.label}
763 {option.detail}
730764 </p>
731 {option.detail && (
732 <p
733 className={`mt-0.5 text-xs ${disabled ? "text-muted-foreground/55" : "text-muted-foreground/70"}`}
734 >
735 {option.detail}
736 </p>
737 )}
738 </div>
739 {selected && !disabled ? (
740 <CheckIcon className="mt-0.5 h-4 w-4 shrink-0 text-foreground" />
741 ) : null}
742 </button>
743 );
744 })}
745 {!gitModalSelectionMode &&
746 gitModalSteps.map((step, index) => {
747 const borderClass =
748 index < gitModalSteps.length - 1 ? "border-b border-border/70" : "";
749
750 return (
751 <div
752 key={step.id}
753 className={`flex items-start gap-3 bg-card/45 px-4 py-3 ${borderClass}`}
754 >
755 <span className="mt-0.5">
756 {step.status === "running" ? (
757 <Loader2Icon className="h-4 w-4 animate-spin text-foreground" />
758 ) : step.status === "completed" ? (
759 <CheckIcon className="h-4 w-4 text-emerald-600 dark:text-emerald-300" />
760 ) : step.status === "skipped" ? (
761 <MinusIcon className="h-4 w-4 text-muted-foreground/70" />
762 ) : step.status === "failed" ? (
763 <XIcon className="h-4 w-4 text-rose-500 dark:text-rose-300" />
764 ) : (
765 <CircleIcon className="h-4 w-4 text-muted-foreground/60" />
766 )}
767 </span>
768 <div className="min-w-0">
769 <p className="text-sm text-foreground">{step.label}</p>
770 {step.status === "failed" && step.detail && (
771 <p className="mt-0.5 text-xs text-rose-500 dark:text-rose-300">
772 {step.detail}
773 </p>
774 )}
775 </div>
765 )}
776766 </div>
777 );
778 })}
767 {selected && !disabled ? (
768 <CheckIcon className="mt-0.5 h-4 w-4 shrink-0 text-foreground" />
769 ) : null}
770 </button>
771 );
772 })}
779773 </div>
780774 </div>
781775 {(gitModalError ?? visibleGitActionError) && (
......@@ -784,44 +778,116 @@ export default function GitActionsControl({ api, gitCwd }: GitActionsControlProp
784778 </div>
785779 )}
786780 <div className="mt-6 flex justify-end gap-2">
787 {gitModalResult && gitModalOpenPrUrl && (
788 <button
789 type="button"
790 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"
791 onClick={openPrFromModal}
792 disabled={isGitActionRunning}
793 >
794 Open PR
795 </button>
796 )}
797781 <button
798782 type="button"
799 className={
800 gitModalResult
801 ? "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"
802 : "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"
803 }
783 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"
804784 onClick={closeGitActionModal}
805785 disabled={isGitActionRunning}
806786 >
807 {gitModalResult ? "Done" : "Cancel"}
787 Cancel
788 </button>
789 <button
790 type="button"
791 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"
792 onClick={() => {
793 void runGitAction();
794 }}
795 disabled={isGitActionRunning || !selectedGitModalActionOption}
796 >
797 {runActionLabel(gitModalSelectedAction)}
798 </button>
799 </div>
800 </div>
801 </div>
802 )}
803
804 {showGitProgressToast && (
805 <div
806 className="fixed bottom-4 right-4 z-[90] w-[min(420px,calc(100vw-2rem))]"
807 role="status"
808 aria-live="polite"
809 >
810 <div className="rounded-2xl border border-border bg-popover/95 p-4 shadow-2xl backdrop-blur">
811 <div className="flex items-start justify-between gap-3">
812 <div className="min-w-0">
813 <p className="text-[11px] uppercase tracking-[0.14em] text-muted-foreground/65">
814 Git actions
815 </p>
816 <p className="mt-1 text-sm font-medium text-foreground">
817 {isGitModalActionRunning
818 ? "Running action..."
819 : gitModalError
820 ? "Action failed"
821 : "Action complete"}
822 </p>
823 </div>
824 <button
825 type="button"
826 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"
827 onClick={dismissGitProgressToast}
828 disabled={isGitModalActionRunning}
829 aria-label="Dismiss git action progress toast"
830 >
831 <XIcon className="h-4 w-4" />
808832 </button>
809 {!gitModalResult && (
833 </div>
834 {gitModalProgress.length > 0 && (
835 <div className="mt-3 overflow-hidden rounded-xl border border-border/80">
836 {gitModalProgress.map((step, index) => {
837 const borderClass =
838 index < gitModalProgress.length - 1 ? "border-b border-border/70" : "";
839
840 return (
841 <div
842 key={step.id}
843 className={`flex items-start gap-3 bg-card/45 px-3.5 py-2.5 ${borderClass}`}
844 >
845 <span className="mt-0.5">
846 <GitProgressStatusIcon status={step.status} />
847 </span>
848 <div className="min-w-0">
849 <p className="text-sm text-foreground">{step.label}</p>
850 {step.detail && (
851 <p
852 className={`mt-0.5 text-xs ${
853 step.status === "failed"
854 ? "text-rose-500 dark:text-rose-300"
855 : "text-muted-foreground/70"
856 }`}
857 >
858 {step.detail}
859 </p>
860 )}
861 </div>
862 </div>
863 );
864 })}
865 </div>
866 )}
867 {gitModalError && (
868 <div className="mt-3 rounded-lg border border-rose-300/50 bg-rose-500/10 px-3 py-2 text-xs text-rose-600 dark:text-rose-200">
869 {gitModalError}
870 </div>
871 )}
872 <div className="mt-3 flex justify-end gap-2">
873 {!isGitModalActionRunning && gitModalResult && gitProgressToastOpenPrUrl && (
810874 <button
811875 type="button"
812 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"
813 onClick={() => {
814 void runGitAction();
815 }}
816 disabled={isGitActionRunning || !selectedGitModalActionOption}
876 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"
877 onClick={openPrFromToast}
878 disabled={isGitModalActionRunning}
817879 >
818 {isGitActionRunning
819 ? "Running..."
820 : gitModalSelectionMode
821 ? "Continue"
822 : runActionLabel(gitModalSelectedAction)}
880 Open PR
823881 </button>
824882 )}
883 <button
884 type="button"
885 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"
886 onClick={dismissGitProgressToast}
887 disabled={isGitModalActionRunning}
888 >
889 Done
890 </button>
825891 </div>
826892 </div>
827893 </div>