authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-09-08 14:05:20-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-08 14:05:20-07:00
log3faeee49ac67dfd9534369f1e1c627c0356b75ac
tree03442461ac3cf257151c77f8b66ad2fa118d4312
parent5e6cc2b89534a8e01772bf647b79a1f2da2f9664
signature Signed by PGP key B5690EEEBB952194

refactor(web): consolidate setup wizards into shared components (#10832)


7 files changed, 690 insertions(+), 771 deletions(-)

apps/web/src/components/GitActionsControl.tsx+318-377
...@@ -58,7 +58,7 @@ import {...@@ -58,7 +58,7 @@ import {
58 resolveQuickAction,58 resolveQuickAction,
59 resolveThreadBranchUpdate,59 resolveThreadBranchUpdate,
60} from "./GitActionsControl.logic";60} from "./GitActionsControl.logic";
61import { AnimatedHeight } from "./AnimatedHeight";61import { WizardPopup, WizardHeader, WizardSteps, WizardPanel, WizardFooter } from "./ui/wizard";
62import { StartTruncatedPath } from "./StartTruncatedPath";62import { StartTruncatedPath } from "./StartTruncatedPath";
63import { Button } from "~/components/ui/button";63import { Button } from "~/components/ui/button";
64import { Checkbox } from "~/components/ui/checkbox";64import { Checkbox } from "~/components/ui/checkbox";
...@@ -563,419 +563,360 @@ function PublishRepositoryDialog(props: PublishRepositoryDialogProps) {...@@ -563,419 +563,360 @@ function PublishRepositoryDialog(props: PublishRepositoryDialogProps) {
563563
564 return (564 return (
565 <Dialog open={props.open} onOpenChange={handleOpenChange}>565 <Dialog open={props.open} onOpenChange={handleOpenChange}>
566 <DialogPopup className="max-w-xl overflow-hidden">566 <WizardPopup>
567 <div className="flex min-h-0 flex-col overflow-hidden border-foreground/10 bg-transparent">567 <WizardHeader
568 <DialogHeader className="border-b border-border/70 bg-foreground/[0.025] dark:border-transparent dark:bg-transparent">568 title="Publish repository"
569 <DialogTitle>Publish repository</DialogTitle>569 description="Pick where to host it, then point us at a repo to push to."
570 <DialogDescription>570 >
571 Pick where to host it, then point us at a repo to push to.571 <WizardSteps
572 </DialogDescription>572 steps={publishWizardSteps}
573 <div className="grid grid-cols-3 gap-2">573 currentStep={publishWizardStep}
574 {publishWizardSteps.map((label, index) => {574 summaries={publishWizardStepSummaries}
575 const isComplete = index < publishWizardStep;575 showSummaries
576 const isClickable =576 isStepDisabled={(index) =>
577 publishWizardStep !== 2 &&577 publishWizardStep === 2 ||
578 index < publishWizardSteps.length - 1 &&578 index >= publishWizardSteps.length - 1 ||
579 index <= publishWizardStep;579 index > publishWizardStep
580 }
581 onStepChange={setPublishWizardStep}
582 />
583 </WizardHeader>
584
585 <WizardPanel>
586 <div className={cn("space-y-2", publishWizardStep !== 0 && "hidden")}>
587 <span id="publish-provider-cards-label" className="text-xs font-medium text-foreground">
588 Provider
589 </span>
590 <RadioGroup
591 value={publishProvider}
592 onValueChange={(value) => {
593 setSelectedPublishProvider(value as PublishProviderKind);
594 setPublishRepositoryOverride(null);
595 }}
596 aria-labelledby="publish-provider-cards-label"
597 className="grid grid-cols-2 gap-2.5"
598 >
599 {sortedPublishProviderOptions.map((option) => {
600 const readiness = publishProviderReadiness[option.value];
601 const isSelected = publishProvider === option.value && readiness.ready;
602 if (!readiness.ready) {
603 return (
604 <div
605 key={option.value}
606 className="relative flex cursor-not-allowed items-center gap-3 rounded-lg border border-border bg-background px-3 py-3 text-left opacity-55 dark:border-transparent dark:bg-white/[0.035]"
607 >
608 <option.Icon className="size-5 shrink-0 text-muted-foreground" aria-hidden />
609 <span className="min-w-0 flex-1 truncate text-sm font-medium text-foreground">
610 {option.label}
611 </span>
612 <Tooltip>
613 <TooltipTrigger
614 render={
615 <Button
616 variant="outline"
617 size="xs"
618 className="h-5 rounded-[.25rem] px-1.5 text-[10px] text-warning-foreground"
619 onClick={(event) => {
620 event.preventDefault();
621 event.stopPropagation();
622 openSourceControlSettings();
623 }}
624 >
625 Setup Required
626 </Button>
627 }
628 />
629 <TooltipPopup side="top" align="end" className="max-w-72">
630 {readiness.hint ??
631 "Open Settings -> Source Control to configure this provider."}
632 </TooltipPopup>
633 </Tooltip>
634 </div>
635 );
636 }
637
580 return (638 return (
581 <button639 <RadioPrimitive.Root
582 key={label}640 key={option.value}
583 type="button"641 value={option.value}
584 onClick={isClickable ? () => setPublishWizardStep(index) : undefined}
585 disabled={!isClickable}
586 className={cn(642 className={cn(
587 "grid min-w-0 grid-cols-[1rem_minmax(0,1fr)] gap-x-2 rounded-lg border px-3 py-2 text-left",643 "relative flex cursor-pointer items-center gap-3 rounded-lg border px-3 py-3 text-left outline-none transition-[background-color,border-color,box-shadow]",
588 index === publishWizardStep644 "focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1 focus-visible:ring-offset-background",
589 ? "border-primary bg-primary/10 ring-1 ring-primary/25 dark:border-transparent"645 isSelected
590 : isComplete646 ? "border-primary bg-background shadow-sm ring-2 ring-primary/35 dark:border-transparent dark:bg-primary/10 dark:shadow-none dark:ring-1 dark:ring-primary/30"
591 ? "border-border bg-background dark:border-transparent dark:bg-white/[0.05]"647 : "border-border bg-background hover:border-foreground/20 hover:bg-muted/50 dark:border-transparent dark:bg-white/[0.035] dark:hover:bg-accent",
592 : "border-border bg-muted/40 dark:border-transparent dark:bg-white/[0.025]",
593 !isClickable && "cursor-default",
594 )}648 )}
595 >649 >
596 <span650 <option.Icon className="size-5 shrink-0" aria-hidden />
597 aria-hidden651 <span className="min-w-0 flex-1 truncate text-sm font-medium text-foreground">
598 className={cn(652 {option.label}
599 "row-span-2 mt-0.5 grid size-4 place-items-center rounded-full border",
600 isComplete
601 ? "border-primary bg-primary text-primary-foreground"
602 : index === publishWizardStep
603 ? "border-primary bg-background"
604 : "border-muted-foreground/35 bg-background",
605 )}
606 >
607 {isComplete ? <CheckIcon className="size-3" /> : null}
608 </span>
609 <span className="text-[10px] font-medium uppercase text-muted-foreground">
610 Step {index + 1}
611 </span>653 </span>
612 <span className="truncate text-xs font-semibold text-foreground">654 </RadioPrimitive.Root>
613 {label}
614 {isComplete && publishWizardStepSummaries[index]
615 ? `: ${publishWizardStepSummaries[index]}`
616 : ""}
617 </span>
618 </button>
619 );655 );
620 })}656 })}
621 </div>657 </RadioGroup>
622 </DialogHeader>658 </div>
623659
624 <DialogPanel className="space-y-5 border-b border-border/70 bg-muted/20 px-6 py-5 dark:border-transparent dark:bg-transparent">660 <div className={cn("space-y-5", publishWizardStep !== 1 && "hidden")}>
625 <AnimatedHeight>661 <div className="space-y-2">
626 <div className={cn("space-y-2", publishWizardStep !== 0 && "hidden")}>662 <label
627 <span663 htmlFor="publish-repository-path"
628 id="publish-provider-cards-label"664 className="text-xs font-medium text-foreground"
629 className="text-xs font-medium text-foreground"665 >
630 >666 Repository
631 Provider667 </label>
668 <div className="flex items-stretch overflow-hidden rounded-md border border-input bg-background focus-within:outline-2 focus-within:-outline-offset-1 focus-within:outline-ring">
669 <span className="flex shrink-0 items-center gap-1.5 border-r border-input bg-muted/50 px-2.5 font-mono text-xs text-muted-foreground">
670 <currentPublishProvider.Icon className="size-3.5" />
671 {publishHost}/
632 </span>672 </span>
633 <RadioGroup673 <input
634 value={publishProvider}674 id="publish-repository-path"
635 onValueChange={(value) => {675 name="publish-repository-path"
636 setSelectedPublishProvider(value as PublishProviderKind);676 value={publishRepository}
637 setPublishRepositoryOverride(null);677 onChange={(event) => {
678 setPublishRepositoryOverride(event.target.value);
638 }}679 }}
639 aria-labelledby="publish-provider-cards-label"680 onKeyDown={(event) => {
640 className="grid grid-cols-2 gap-2.5"681 if (event.key === "Enter") {
641 >682 event.preventDefault();
642 {sortedPublishProviderOptions.map((option) => {683 submitPublishRepository();
643 const readiness = publishProviderReadiness[option.value];
644 const isSelected = publishProvider === option.value && readiness.ready;
645 if (!readiness.ready) {
646 return (
647 <div
648 key={option.value}
649 className="relative flex cursor-not-allowed items-center gap-3 rounded-lg border border-border bg-background px-3 py-3 text-left opacity-55 dark:border-transparent dark:bg-white/[0.035]"
650 >
651 <option.Icon
652 className="size-5 shrink-0 text-muted-foreground"
653 aria-hidden
654 />
655 <span className="min-w-0 flex-1 truncate text-sm font-medium text-foreground">
656 {option.label}
657 </span>
658 <Tooltip>
659 <TooltipTrigger
660 render={
661 <Button
662 variant="outline"
663 size="xs"
664 className="h-5 rounded-[.25rem] px-1.5 text-[10px] text-warning-foreground"
665 onClick={(event) => {
666 event.preventDefault();
667 event.stopPropagation();
668 openSourceControlSettings();
669 }}
670 >
671 Setup Required
672 </Button>
673 }
674 />
675 <TooltipPopup side="top" align="end" className="max-w-72">
676 {readiness.hint ??
677 "Open Settings -> Source Control to configure this provider."}
678 </TooltipPopup>
679 </Tooltip>
680 </div>
681 );
682 }684 }
685 }}
686 placeholder={publishPathPlaceholder}
687 disabled={publishRepositoryAction.isPending}
688 className="w-full bg-transparent px-3 py-2 font-mono text-sm placeholder:text-muted-foreground/60 focus:outline-none"
689 />
690 </div>
691 </div>
683692
684 return (693 <div className="space-y-2">
685 <RadioPrimitive.Root694 <span
686 key={option.value}695 id="publish-visibility-cards-label"
687 value={option.value}696 className="text-xs font-medium text-foreground"
688 className={cn(697 >
689 "relative flex cursor-pointer items-center gap-3 rounded-lg border px-3 py-3 text-left outline-none transition-[background-color,border-color,box-shadow]",698 Visibility
690 "focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1 focus-visible:ring-offset-background",699 </span>
691 isSelected700 <RadioGroup
692 ? "border-primary bg-background shadow-sm ring-2 ring-primary/35 dark:border-transparent dark:bg-primary/10 dark:shadow-none dark:ring-1 dark:ring-primary/30"701 value={publishVisibility}
693 : "border-border bg-background hover:border-foreground/20 hover:bg-muted/50 dark:border-transparent dark:bg-white/[0.035] dark:hover:bg-accent",702 onValueChange={(value) =>
694 )}703 setPublishVisibility(value as SourceControlRepositoryVisibility)
695 >704 }
696 <option.Icon className="size-5 shrink-0" aria-hidden />705 aria-labelledby="publish-visibility-cards-label"
697 <span className="min-w-0 flex-1 truncate text-sm font-medium text-foreground">706 disabled={publishRepositoryAction.isPending}
707 className="grid grid-cols-2 gap-2.5"
708 >
709 {[
710 {
711 value: "private" as const,
712 label: "Private",
713 description: "Only invited people",
714 Icon: LockIcon,
715 },
716 {
717 value: "public" as const,
718 label: "Public",
719 description: "Anyone on the web",
720 Icon: GlobeIcon,
721 },
722 ].map((option) => {
723 const isSelected = publishVisibility === option.value;
724 return (
725 <RadioPrimitive.Root
726 key={option.value}
727 value={option.value}
728 className={cn(
729 "relative flex cursor-pointer items-center gap-3 rounded-lg border px-3 py-2.5 text-left outline-none transition-[background-color,border-color,box-shadow]",
730 "focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1 focus-visible:ring-offset-background",
731 isSelected
732 ? "border-primary bg-background shadow-sm ring-2 ring-primary/35 dark:border-transparent dark:bg-primary/10 dark:shadow-none dark:ring-1 dark:ring-primary/30"
733 : "border-border bg-background hover:border-foreground/20 hover:bg-muted/50 dark:border-transparent dark:bg-white/[0.035] dark:hover:bg-accent",
734 )}
735 >
736 <option.Icon className="size-4 shrink-0 text-muted-foreground" aria-hidden />
737 <span className="min-w-0 flex-1">
738 <span className="block text-sm font-medium text-foreground">
698 {option.label}739 {option.label}
699 </span>740 </span>
700 </RadioPrimitive.Root>741 <span className="block text-xs text-muted-foreground">
701 );742 {option.description}
702 })}743 </span>
703 </RadioGroup>744 </span>
704 </div>745 </RadioPrimitive.Root>
746 );
747 })}
748 </RadioGroup>
749 </div>
705750
706 <div className={cn("space-y-5", publishWizardStep !== 1 && "hidden")}>751 <div>
707 <div className="space-y-2">752 <button
708 <label753 type="button"
709 htmlFor="publish-repository-path"754 onClick={() => setPublishAdvancedOpen((prev) => !prev)}
710 className="text-xs font-medium text-foreground"755 aria-expanded={publishAdvancedOpen}
711 >756 className="flex items-center gap-1 text-xs font-medium text-muted-foreground transition-colors hover:text-foreground"
712 Repository757 >
758 <ChevronDownIcon
759 className={cn(
760 "size-3.5 transition-transform",
761 publishAdvancedOpen ? "" : "-rotate-90",
762 )}
763 />
764 Advanced
765 </button>
766 {publishAdvancedOpen ? (
767 <div className="mt-3 grid gap-3 sm:grid-cols-2">
768 <label className="space-y-1.5" htmlFor="publish-remote-name">
769 <span className="text-xs font-medium text-foreground">Remote</span>
770 <Input
771 id="publish-remote-name"
772 value={publishRemoteName}
773 onChange={(event) => setPublishRemoteName(event.target.value)}
774 placeholder="origin"
775 disabled={publishRepositoryAction.isPending}
776 />
713 </label>777 </label>
714 <div className="flex items-stretch overflow-hidden rounded-md border border-input bg-background focus-within:outline-2 focus-within:-outline-offset-1 focus-within:outline-ring">778 <div className="space-y-1.5">
715 <span className="flex shrink-0 items-center gap-1.5 border-r border-input bg-muted/50 px-2.5 font-mono text-xs text-muted-foreground">779 <span
716 <currentPublishProvider.Icon className="size-3.5" />780 id="publish-protocol-label"
717 {publishHost}/781 className="text-xs font-medium text-foreground"
782 >
783 Protocol
718 </span>784 </span>
719 <input785 <RadioGroup
720 id="publish-repository-path"786 className="w-fit flex-row gap-0.5 rounded-lg bg-input/40 p-0.5"
721 name="publish-repository-path"787 value={publishProtocol}
722 value={publishRepository}788 onValueChange={(protocol) => {
723 onChange={(event) => {789 if (protocol === "ssh" || protocol === "https") {
724 setPublishRepositoryOverride(event.target.value);790 setPublishProtocol(protocol);
725 }}
726 onKeyDown={(event) => {
727 if (event.key === "Enter") {
728 event.preventDefault();
729 submitPublishRepository();
730 }791 }
731 }}792 }}
732 placeholder={publishPathPlaceholder}793 aria-labelledby="publish-protocol-label"
733 disabled={publishRepositoryAction.isPending}794 disabled={publishRepositoryAction.isPending}
734 className="w-full bg-transparent px-3 py-2 font-mono text-sm placeholder:text-muted-foreground/60 focus:outline-none"795 >
735 />796 {(["ssh", "https"] as const).map((protocol) => (
736 </div>
737 </div>
738
739 <div className="space-y-2">
740 <span
741 id="publish-visibility-cards-label"
742 className="text-xs font-medium text-foreground"
743 >
744 Visibility
745 </span>
746 <RadioGroup
747 value={publishVisibility}
748 onValueChange={(value) =>
749 setPublishVisibility(value as SourceControlRepositoryVisibility)
750 }
751 aria-labelledby="publish-visibility-cards-label"
752 disabled={publishRepositoryAction.isPending}
753 className="grid grid-cols-2 gap-2.5"
754 >
755 {[
756 {
757 value: "private" as const,
758 label: "Private",
759 description: "Only invited people",
760 Icon: LockIcon,
761 },
762 {
763 value: "public" as const,
764 label: "Public",
765 description: "Anyone on the web",
766 Icon: GlobeIcon,
767 },
768 ].map((option) => {
769 const isSelected = publishVisibility === option.value;
770 return (
771 <RadioPrimitive.Root797 <RadioPrimitive.Root
772 key={option.value}798 key={protocol}
773 value={option.value}799 value={protocol}
774 className={cn(800 data-pressed={publishProtocol === protocol ? "" : undefined}
775 "relative flex cursor-pointer items-center gap-3 rounded-lg border px-3 py-2.5 text-left outline-none transition-[background-color,border-color,box-shadow]",801 className={toggleVariants({
776 "focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1 focus-visible:ring-offset-background",802 variant: "segmented",
777 isSelected803 size: "segmented",
778 ? "border-primary bg-background shadow-sm ring-2 ring-primary/35 dark:border-transparent dark:bg-primary/10 dark:shadow-none dark:ring-1 dark:ring-primary/30"804 })}
779 : "border-border bg-background hover:border-foreground/20 hover:bg-muted/50 dark:border-transparent dark:bg-white/[0.035] dark:hover:bg-accent",
780 )}
781 >805 >
782 <option.Icon806 {protocol.toUpperCase()}
783 className="size-4 shrink-0 text-muted-foreground"
784 aria-hidden
785 />
786 <span className="min-w-0 flex-1">
787 <span className="block text-sm font-medium text-foreground">
788 {option.label}
789 </span>
790 <span className="block text-xs text-muted-foreground">
791 {option.description}
792 </span>
793 </span>
794 </RadioPrimitive.Root>807 </RadioPrimitive.Root>
795 );808 ))}
796 })}809 </RadioGroup>
797 </RadioGroup>810 </div>
798 </div>
799
800 <div>
801 <button
802 type="button"
803 onClick={() => setPublishAdvancedOpen((prev) => !prev)}
804 aria-expanded={publishAdvancedOpen}
805 className="flex items-center gap-1 text-xs font-medium text-muted-foreground transition-colors hover:text-foreground"
806 >
807 <ChevronDownIcon
808 className={cn(
809 "size-3.5 transition-transform",
810 publishAdvancedOpen ? "" : "-rotate-90",
811 )}
812 />
813 Advanced
814 </button>
815 {publishAdvancedOpen ? (
816 <div className="mt-3 grid gap-3 sm:grid-cols-2">
817 <label className="space-y-1.5" htmlFor="publish-remote-name">
818 <span className="text-xs font-medium text-foreground">Remote</span>
819 <Input
820 id="publish-remote-name"
821 value={publishRemoteName}
822 onChange={(event) => setPublishRemoteName(event.target.value)}
823 placeholder="origin"
824 disabled={publishRepositoryAction.isPending}
825 />
826 </label>
827 <div className="space-y-1.5">
828 <span
829 id="publish-protocol-label"
830 className="text-xs font-medium text-foreground"
831 >
832 Protocol
833 </span>
834 <RadioGroup
835 className="w-fit flex-row gap-0.5 rounded-lg bg-input/40 p-0.5"
836 value={publishProtocol}
837 onValueChange={(protocol) => {
838 if (protocol === "ssh" || protocol === "https") {
839 setPublishProtocol(protocol);
840 }
841 }}
842 aria-labelledby="publish-protocol-label"
843 disabled={publishRepositoryAction.isPending}
844 >
845 {(["ssh", "https"] as const).map((protocol) => (
846 <RadioPrimitive.Root
847 key={protocol}
848 value={protocol}
849 data-pressed={publishProtocol === protocol ? "" : undefined}
850 className={toggleVariants({
851 variant: "segmented",
852 size: "segmented",
853 })}
854 >
855 {protocol.toUpperCase()}
856 </RadioPrimitive.Root>
857 ))}
858 </RadioGroup>
859 </div>
860 </div>
861 ) : null}
862 </div>811 </div>
812 ) : null}
813 </div>
863814
864 {publishRepositoryAction.isPending ? (815 {publishRepositoryAction.isPending ? (
865 <div816 <div
866 role="status"817 role="status"
867 aria-live="polite"818 aria-live="polite"
868 className="flex items-center gap-2 rounded-md border border-input bg-muted/40 px-3 py-2 text-xs text-muted-foreground dark:border-transparent dark:bg-white/[0.035]"819 className="flex items-center gap-2 rounded-md border border-input bg-muted/40 px-3 py-2 text-xs text-muted-foreground dark:border-transparent dark:bg-white/[0.035]"
869 >820 >
870 <Spinner className="size-3.5" aria-hidden />821 <Spinner className="size-3.5" aria-hidden />
871 Publishing repository to {publishProviderLabel}...822 Publishing repository to {publishProviderLabel}...
872 </div>
873 ) : null}
874 {publishError && !publishRepositoryAction.isPending ? (
875 <div
876 role="alert"
877 className="rounded-md border border-destructive/40 bg-destructive/10 px-3 py-2 text-xs text-destructive"
878 >
879 <p className="font-medium">Publish failed</p>
880 <p className="mt-0.5 text-destructive/90">{publishError}</p>
881 </div>
882 ) : null}
883 </div>823 </div>
884824 ) : null}
885 <div className={cn("space-y-4", publishWizardStep !== 2 && "hidden")}>825 {publishError && !publishRepositoryAction.isPending ? (
886 {publishResult ? (826 <div
887 <>827 role="alert"
888 <div className="flex flex-col items-center gap-2 py-1 text-center">828 className="rounded-md border border-destructive/40 bg-destructive/10 px-3 py-2 text-xs text-destructive"
889 <span className="grid size-8 place-items-center rounded-full bg-success/15 text-success">829 >
890 <CheckIcon className="size-4" aria-hidden />830 <p className="font-medium">Publish failed</p>
891 </span>831 <p className="mt-0.5 text-destructive/90">{publishError}</p>
892 <h3 className="text-sm font-semibold text-foreground">
893 {publishResult.status === "pushed"
894 ? "Repository published"
895 : "Repository created"}
896 </h3>
897 <p className="max-w-xs text-pretty text-xs text-muted-foreground">
898 {publishResult.status === "pushed"
899 ? `${publishResult.branch} is now live on ${publishProviderLabel}.`
900 : `Remote "${publishResult.remoteName}" is set up. Make a commit and push it to share your code.`}
901 </p>
902 </div>
903 <div className="flex items-center gap-2 rounded-lg border border-input bg-muted/40 px-3 py-2 dark:border-transparent dark:bg-white/[0.035]">
904 <currentPublishProvider.Icon className="size-3.5 shrink-0 text-muted-foreground" />
905 <span className="min-w-0 flex-1 truncate font-mono text-xs text-foreground">
906 {publishResult.repository.nameWithOwner}
907 </span>
908 </div>
909 <Button
910 variant="outline"
911 size="sm"
912 className="w-full"
913 onClick={() => {
914 void openLink(publishResult.repository.url).catch(() => undefined);
915 }}
916 >
917 Open on {publishProviderLabel}
918 </Button>
919 </>
920 ) : (
921 <div className="rounded-md border border-input bg-background px-3 py-2 text-xs text-muted-foreground dark:border-transparent dark:bg-white/[0.035]">
922 Publish result unavailable.
923 </div>
924 )}
925 </div>832 </div>
926 </AnimatedHeight>833 ) : null}
927 </DialogPanel>834 </div>
928835
929 <DialogFooter className="dark:border-transparent dark:bg-transparent">836 <div className={cn("space-y-4", publishWizardStep !== 2 && "hidden")}>
930 {publishWizardStep === 2 ? (837 {publishResult ? (
931 <Button size="sm" onClick={() => handleOpenChange(false)}>
932 Done
933 </Button>
934 ) : (
935 <>838 <>
839 <div className="flex flex-col items-center gap-2 py-1 text-center">
840 <span className="grid size-8 place-items-center rounded-full bg-success/15 text-success">
841 <CheckIcon className="size-4" aria-hidden />
842 </span>
843 <h3 className="text-sm font-semibold text-foreground">
844 {publishResult.status === "pushed"
845 ? "Repository published"
846 : "Repository created"}
847 </h3>
848 <p className="max-w-xs text-pretty text-xs text-muted-foreground">
849 {publishResult.status === "pushed"
850 ? `${publishResult.branch} is now live on ${publishProviderLabel}.`
851 : `Remote "${publishResult.remoteName}" is set up. Make a commit and push it to share your code.`}
852 </p>
853 </div>
854 <div className="flex items-center gap-2 rounded-lg border border-input bg-muted/40 px-3 py-2 dark:border-transparent dark:bg-white/[0.035]">
855 <currentPublishProvider.Icon className="size-3.5 shrink-0 text-muted-foreground" />
856 <span className="min-w-0 flex-1 truncate font-mono text-xs text-foreground">
857 {publishResult.repository.nameWithOwner}
858 </span>
859 </div>
936 <Button860 <Button
937 variant="outline"861 variant="outline"
938 size="sm"862 size="sm"
939 disabled={publishRepositoryAction.isPending}863 className="w-full"
940 onClick={() => {864 onClick={() => {
941 if (publishWizardStep === 0) {865 void openLink(publishResult.repository.url).catch(() => undefined);
942 handleOpenChange(false);
943 return;
944 }
945 setPublishWizardStep((step) => Math.max(0, step - 1));
946 }}866 }}
947 >867 >
948 {publishWizardStep === 0 ? "Cancel" : "Back"}868 Open on {publishProviderLabel}
949 </Button>869 </Button>
950 {publishWizardStep < 1 ? (
951 <Button
952 size="sm"
953 disabled={!hasReadyPublishProvider || !selectedPublishProviderReadiness.ready}
954 onClick={() => setPublishWizardStep((step) => Math.min(1, step + 1))}
955 >
956 Next
957 </Button>
958 ) : (
959 <Button
960 size="sm"
961 disabled={!canSubmitPublishRepository}
962 onClick={submitPublishRepository}
963 >
964 {publishRepositoryAction.isPending ? (
965 <>
966 <Spinner className="size-3.5" aria-hidden />
967 Publishing...
968 </>
969 ) : (
970 "Publish"
971 )}
972 </Button>
973 )}
974 </>870 </>
871 ) : (
872 <div className="rounded-md border border-input bg-background px-3 py-2 text-xs text-muted-foreground dark:border-transparent dark:bg-white/[0.035]">
873 Publish result unavailable.
874 </div>
975 )}875 )}
976 </DialogFooter>876 </div>
977 </div>877 </WizardPanel>
978 </DialogPopup>878
879 <WizardFooter>
880 {publishWizardStep === 2 ? (
881 <Button onClick={() => handleOpenChange(false)}>Done</Button>
882 ) : (
883 <>
884 <Button
885 variant="outline"
886 disabled={publishRepositoryAction.isPending}
887 onClick={() => {
888 if (publishWizardStep === 0) {
889 handleOpenChange(false);
890 return;
891 }
892 setPublishWizardStep((step) => Math.max(0, step - 1));
893 }}
894 >
895 {publishWizardStep === 0 ? "Cancel" : "Back"}
896 </Button>
897 {publishWizardStep < 1 ? (
898 <Button
899 disabled={!hasReadyPublishProvider || !selectedPublishProviderReadiness.ready}
900 onClick={() => setPublishWizardStep((step) => Math.min(1, step + 1))}
901 >
902 Next
903 </Button>
904 ) : (
905 <Button disabled={!canSubmitPublishRepository} onClick={submitPublishRepository}>
906 {publishRepositoryAction.isPending ? (
907 <>
908 <Spinner className="size-3.5" aria-hidden />
909 Publishing...
910 </>
911 ) : (
912 "Publish"
913 )}
914 </Button>
915 )}
916 </>
917 )}
918 </WizardFooter>
919 </WizardPopup>
979 </Dialog>920 </Dialog>
980 );921 );
981}922}
apps/web/src/components/cloud/ConnectOnboardingDialog.tsx+51-54
...@@ -15,18 +15,10 @@ import { useEnvironments, usePrimaryEnvironment } from "~/state/environments";...@@ -15,18 +15,10 @@ import { useEnvironments, usePrimaryEnvironment } from "~/state/environments";
15import { CloudEnvironmentConnectRows } from "./CloudEnvironmentConnectList";15import { CloudEnvironmentConnectRows } from "./CloudEnvironmentConnectList";
16import { Button } from "../ui/button";16import { Button } from "../ui/button";
17import { Checkbox } from "../ui/checkbox";17import { Checkbox } from "../ui/checkbox";
18import {18import { Dialog } from "../ui/dialog";
19 Dialog,
20 DialogDescription,
21 DialogFooter,
22 DialogHeader,
23 DialogPanel,
24 DialogPopup,
25 DialogTitle,
26} from "../ui/dialog";
27import { Switch } from "../ui/switch";19import { Switch } from "../ui/switch";
28import { toastManager } from "../ui/toast";20import { toastManager } from "../ui/toast";
29import { WizardSteps } from "../ui/wizard-steps";21import { WizardSteps, WizardPopup, WizardHeader, WizardPanel, WizardFooter } from "../ui/wizard";
3022
31/**23/**
32 * Post-sign-in onboarding wizard for T3 Connect. Opens on every in-session24 * Post-sign-in onboarding wizard for T3 Connect. Opens on every in-session
...@@ -216,23 +208,29 @@ function ConfiguredConnectOnboardingDialog() {...@@ -216,23 +208,29 @@ function ConfiguredConnectOnboardingDialog() {
216 if (!open && !isApplying) complete();208 if (!open && !isApplying) complete();
217 }}209 }}
218 >210 >
219 <DialogPopup className="max-w-xl">211 <WizardPopup>
220 <DialogHeader>212 <WizardHeader
221 <DialogTitle>Set up T3 Connect</DialogTitle>213 title="Set up T3 Connect"
222 <DialogDescription>214 description={
223 Mesh your devices together — publish this environment and connect the rest, all in one215 <>
224 place.216 Mesh your devices together — publish this environment and connect the rest, all in one
225 </DialogDescription>217 place.
218 </>
219 }
220 >
226 {steps.length > 1 ? (221 {steps.length > 1 ? (
227 <WizardSteps222 <WizardSteps
228 steps={steps.map((id) => ({ id, label: STEP_LABELS[id] }))}223 steps={steps.map((id) => STEP_LABELS[id])}
229 currentStep={step}224 currentStep={steps.indexOf(step)}
230 disabled={isApplying}225 isStepDisabled={() => isApplying}
231 onStepSelect={setStep}226 onStepChange={(index) => {
227 const next = steps[index];
228 if (next) setStep(next);
229 }}
232 />230 />
233 ) : null}231 ) : null}
234 </DialogHeader>232 </WizardHeader>
235 <DialogPanel>233 <WizardPanel>
236 {step === "publish" ? (234 {step === "publish" ? (
237 <PublishStep235 <PublishStep
238 exposeEnvironment={exposeEnvironment}236 exposeEnvironment={exposeEnvironment}
...@@ -245,38 +243,37 @@ function ConfiguredConnectOnboardingDialog() {...@@ -245,38 +243,37 @@ function ConfiguredConnectOnboardingDialog() {
245 ) : (243 ) : (
246 <DevicesStep />244 <DevicesStep />
247 )}245 )}
248 </DialogPanel>246 </WizardPanel>
249 <DialogFooter variant="bare" className="sm:justify-between">247 <WizardFooter
250 <label className="flex cursor-pointer items-center gap-2 self-start text-xs text-muted-foreground sm:self-center">248 leading={
251 <Checkbox249 <label className="flex cursor-pointer items-center gap-2 self-start text-xs text-muted-foreground sm:self-center">
252 checked={dontShowAgain}250 <Checkbox
253 onCheckedChange={(checked) => setDontShowAgain(checked === true)}251 checked={dontShowAgain}
254 />252 onCheckedChange={(checked) => setDontShowAgain(checked === true)}
255 Don&apos;t show this again253 />
256 </label>254 Don&apos;t show this again
257 <div className="flex flex-col-reverse gap-2 sm:flex-row">255 </label>
258 {step === "publish" ? (256 }
259 <>257 >
260 <Button variant="ghost" disabled={isApplying} onClick={() => setStep("devices")}>258 {step === "publish" ? (
261 Not now259 <>
262 </Button>260 <Button variant="ghost" disabled={isApplying} onClick={() => setStep("devices")}>
263 <Button261 Not now
264 disabled={
265 isApplying || (controller.linkState.isPending && linkStateData === null)
266 }
267 onClick={() => void applyPublishSelection()}
268 >
269 {isApplying ? "Enabling…" : "Continue"}
270 </Button>
271 </>
272 ) : (
273 <Button disabled={isApplying} onClick={complete}>
274 Done
275 </Button>262 </Button>
276 )}263 <Button
277 </div>264 disabled={isApplying || (controller.linkState.isPending && linkStateData === null)}
278 </DialogFooter>265 onClick={() => void applyPublishSelection()}
279 </DialogPopup>266 >
267 {isApplying ? "Enabling…" : "Continue"}
268 </Button>
269 </>
270 ) : (
271 <Button disabled={isApplying} onClick={complete}>
272 Done
273 </Button>
274 )}
275 </WizardFooter>
276 </WizardPopup>
280 </Dialog>277 </Dialog>
281 );278 );
282}279}
apps/web/src/components/onboarding/WelcomeWizard.tsx+55-55
...@@ -71,8 +71,8 @@ import { Input } from "../ui/input";...@@ -71,8 +71,8 @@ import { Input } from "../ui/input";
71import { Tooltip, TooltipTrigger, TooltipPopup } from "../ui/tooltip";71import { Tooltip, TooltipTrigger, TooltipPopup } from "../ui/tooltip";
72import { ScrollArea } from "../ui/scroll-area";72import { ScrollArea } from "../ui/scroll-area";
73import { Spinner } from "../ui/spinner";73import { Spinner } from "../ui/spinner";
74import { WizardPanel, WizardSteps } from "../ui/wizard";74import { WizardPanel, WizardSteps, WizardPopup, WizardHeader } from "../ui/wizard";
75import { Dialog, DialogHeader, DialogPopup, DialogTitle } from "../ui/dialog";75import { Dialog } from "../ui/dialog";
76import { toastManager } from "../ui/toast";76import { toastManager } from "../ui/toast";
77import { cn } from "../../lib/utils";77import { cn } from "../../lib/utils";
78import { formatRelativeTime } from "../../timestampFormat";78import { formatRelativeTime } from "../../timestampFormat";
...@@ -183,71 +183,71 @@ export function WelcomeWizard({...@@ -183,71 +183,71 @@ export function WelcomeWizard({
183183
184 return (184 return (
185 <Dialog open disablePointerDismissal onOpenChange={(_, event) => event.cancel()}>185 <Dialog open disablePointerDismissal onOpenChange={(_, event) => event.cancel()}>
186 <DialogPopup186 <WizardPopup
187 className="max-w-xl overflow-x-hidden overflow-y-auto"
188 bottomStickOnMobile={false}187 bottomStickOnMobile={false}
189 showCloseButton={false}188 showCloseButton={false}
190 initialFocus={() => document.getElementById("onboarding-pairing-url") ?? true}189 initialFocus={() => document.getElementById("onboarding-pairing-url") ?? true}
191 >190 >
192 <DialogTitle className="sr-only">Set up T3 Code</DialogTitle>191 <WizardHeader
193 <div className="flex min-h-0 flex-col">192 title="Set up T3 Code"
194 <DialogHeader className="gap-4">193 identity={
195 <div className="flex items-baseline gap-1.5" role="img" aria-label="T3 Code">194 <div className="flex items-baseline gap-1.5" role="img" aria-label="T3 Code">
196 <T3Wordmark className="h-4 w-auto shrink-0" aria-hidden />195 <T3Wordmark className="h-4 w-auto shrink-0" aria-hidden />
197 <span className="text-[1.4rem] font-medium tracking-tight text-muted-foreground">196 <span className="text-[1.4rem] font-medium tracking-tight text-muted-foreground">
198 Code197 Code
199 </span>198 </span>
200 </div>199 </div>
201 <WizardSteps200 }
202 steps={ONBOARDING_STAGES}201 >
203 currentStep={stageIndex}202 <WizardSteps
204 isStepDisabled={(index) => isImporting || index >= stageIndex}203 steps={ONBOARDING_STAGES}
205 onStepChange={(index) => {204 currentStep={stageIndex}
206 if (isImporting || index > stageIndex) return;205 isStepDisabled={(index) => isImporting || index >= stageIndex}
207 setStep(index === 0 ? "connection" : "agents");206 onStepChange={(index) => {
207 if (isImporting || index > stageIndex) return;
208 setStep(index === 0 ? "connection" : "agents");
209 }}
210 />
211 </WizardHeader>
212
213 <WizardPanel holdHeight={isLoadingProjects}>
214 {step === "connection" ? (
215 <ConnectionStep
216 expandPairingInitially={!localAvailable && !hasCloudPublicConfig()}
217 selectedIds={selectedIds}
218 autoSelectedComputers={autoSelectedComputers.current}
219 onSelectionChange={setSelection}
220 onToggleEnvironment={(environmentId, checked) =>
221 setSelection((current) => {
222 const next = new Set(current ?? selectedIds);
223 if (checked) next.add(environmentId);
224 else next.delete(environmentId);
225 return next;
226 })
227 }
228 onContinue={() =>
229 startSetup(
230 environments
231 .filter((environment) => selectedIds.has(environment.environmentId))
232 .map((environment) => environment.environmentId),
233 )
234 }
235 onPaired={(environmentId) => {
236 setSelection(new Set([...selectedIds, environmentId]));
208 }}237 }}
209 />238 />
210 </DialogHeader>239 ) : step === "agents" ? (
211240 <AgentsStep environmentIds={setupIds} onContinue={() => setStep("import")} />
212 <WizardPanel className="min-w-0" holdHeight={isLoadingProjects}>241 ) : (
213 {step === "connection" ? (242 <ImportStep
214 <ConnectionStep243 scans={scans}
215 expandPairingInitially={!localAvailable && !hasCloudPublicConfig()}244 isImporting={isImporting}
216 selectedIds={selectedIds}245 setIsImporting={setIsImporting}
217 autoSelectedComputers={autoSelectedComputers.current}246 onDone={finish}
218 onSelectionChange={setSelection}247 />
219 onToggleEnvironment={(environmentId, checked) =>248 )}
220 setSelection((current) => {249 </WizardPanel>
221 const next = new Set(current ?? selectedIds);250 </WizardPopup>
222 if (checked) next.add(environmentId);
223 else next.delete(environmentId);
224 return next;
225 })
226 }
227 onContinue={() =>
228 startSetup(
229 environments
230 .filter((environment) => selectedIds.has(environment.environmentId))
231 .map((environment) => environment.environmentId),
232 )
233 }
234 onPaired={(environmentId) => {
235 setSelection(new Set([...selectedIds, environmentId]));
236 }}
237 />
238 ) : step === "agents" ? (
239 <AgentsStep environmentIds={setupIds} onContinue={() => setStep("import")} />
240 ) : (
241 <ImportStep
242 scans={scans}
243 isImporting={isImporting}
244 setIsImporting={setIsImporting}
245 onDone={finish}
246 />
247 )}
248 </WizardPanel>
249 </div>
250 </DialogPopup>
251 </Dialog>251 </Dialog>
252 );252 );
253}253}
apps/web/src/components/settings/AddProviderInstanceDialog.tsx+185-194
...@@ -15,21 +15,14 @@ import { cn } from "../../lib/utils";...@@ -15,21 +15,14 @@ import { cn } from "../../lib/utils";
15import { normalizeProviderAccentColor } from "../../providerInstances";15import { normalizeProviderAccentColor } from "../../providerInstances";
16import { Button } from "../ui/button";16import { Button } from "../ui/button";
17import { ACPRegistryIcon, Gemini, GithubCopilotIcon, PiAgentIcon, type Icon } from "../Icons";17import { ACPRegistryIcon, Gemini, GithubCopilotIcon, PiAgentIcon, type Icon } from "../Icons";
18import {18import { Dialog } from "../ui/dialog";
19 Dialog,
20 DialogDescription,
21 DialogFooter,
22 DialogHeader,
23 DialogPopup,
24 DialogTitle,
25} from "../ui/dialog";
26import { Badge } from "../ui/badge";19import { Badge } from "../ui/badge";
27import { Input } from "../ui/input";20import { Input } from "../ui/input";
28import { RadioGroup } from "../ui/radio-group";21import { RadioGroup } from "../ui/radio-group";
29import { toastManager } from "../ui/toast";22import { toastManager } from "../ui/toast";
30import { DRIVER_OPTION_BY_VALUE, DRIVER_OPTIONS } from "./providerDriverMeta";23import { DRIVER_OPTION_BY_VALUE, DRIVER_OPTIONS } from "./providerDriverMeta";
31import { ProviderSettingsForm, deriveProviderSettingsFields } from "./ProviderSettingsForm";24import { ProviderSettingsForm, deriveProviderSettingsFields } from "./ProviderSettingsForm";
32import { WizardPanel } from "../ui/wizard";25import { WizardPanel, WizardPopup, WizardHeader, WizardFooter } from "../ui/wizard";
33import {26import {
34 ADD_PROVIDER_WIZARD_STEPS,27 ADD_PROVIDER_WIZARD_STEPS,
35 resolveWizardNavigation,28 resolveWizardNavigation,
...@@ -230,206 +223,204 @@ export function AddProviderInstanceDialog({...@@ -230,206 +223,204 @@ export function AddProviderInstanceDialog({
230223
231 return (224 return (
232 <Dialog open={open} onOpenChange={onOpenChange}>225 <Dialog open={open} onOpenChange={onOpenChange}>
233 <DialogPopup className="max-w-xl overflow-hidden">226 <WizardPopup>
234 <div className="flex min-h-0 flex-col overflow-hidden">227 <WizardHeader
235 <DialogHeader>228 title="Add provider instance"
236 <DialogTitle>Add provider instance</DialogTitle>229 description={
237 <DialogDescription>230 <>
238 Configure an additional provider instance on {environmentLabel} — for example, a231 Configure an additional provider instance on {environmentLabel} — for example, a
239 second Codex install pointed at a different workspace.232 second Codex install pointed at a different workspace.
240 </DialogDescription>233 </>
241 <AddProviderInstanceWizardSteps234 }
242 currentStep={wizardStep}235 >
243 summaries={wizardStepSummaries}236 <AddProviderInstanceWizardSteps
244 instanceIdError={instanceIdError}237 currentStep={wizardStep}
245 onNavigation={applyWizardNavigation}238 summaries={wizardStepSummaries}
246 />239 instanceIdError={instanceIdError}
247 </DialogHeader>240 onNavigation={applyWizardNavigation}
241 />
242 </WizardHeader>
248243
249 <WizardPanel>244 <WizardPanel>
250 <div className={cn("grid gap-2", wizardStep !== 0 && "hidden")}>245 <div className={cn("grid gap-2", wizardStep !== 0 && "hidden")}>
251 <div id="add-instance-driver-label" className="text-sm font-medium text-foreground">246 <div id="add-instance-driver-label" className="text-sm font-medium text-foreground">
252 Driver247 Driver
253 </div>248 </div>
254 <RadioGroup249 <RadioGroup
255 value={driver}250 value={driver}
256 onValueChange={(value) => setDriver(ProviderDriverKind.make(value))}251 onValueChange={(value) => setDriver(ProviderDriverKind.make(value))}
257 aria-labelledby="add-instance-driver-label"252 aria-labelledby="add-instance-driver-label"
258 className="grid grid-cols-1 gap-2 sm:grid-cols-2"253 className="grid grid-cols-1 gap-2 sm:grid-cols-2"
259 >254 >
260 {DRIVER_OPTIONS.map((option) => {255 {DRIVER_OPTIONS.map((option) => {
261 const IconComponent = option.icon;256 const IconComponent = option.icon;
262 return (257 return (
263 <RadioPrimitive.Root258 <RadioPrimitive.Root
264 key={option.value}259 key={option.value}
265 value={option.value}260 value={option.value}
266 className="relative flex cursor-pointer items-center gap-3 rounded-lg bg-card px-3 py-3 text-left text-muted-foreground outline-none ring-1 ring-black/5 hover:bg-zinc-50 focus-visible:ring-2 focus-visible:ring-ring data-checked:bg-primary/8 data-checked:text-foreground data-checked:ring-2 data-checked:ring-primary data-checked:hover:bg-primary/8 dark:bg-white/3 dark:ring-white/5 dark:hover:bg-white/5 dark:data-checked:bg-primary/15 dark:data-checked:ring-primary dark:data-checked:hover:bg-primary/15"261 className="relative flex cursor-pointer items-center gap-3 rounded-lg bg-card px-3 py-3 text-left text-muted-foreground outline-none ring-1 ring-black/5 hover:bg-zinc-50 focus-visible:ring-2 focus-visible:ring-ring data-checked:bg-primary/8 data-checked:text-foreground data-checked:ring-2 data-checked:ring-primary data-checked:hover:bg-primary/8 dark:bg-white/3 dark:ring-white/5 dark:hover:bg-white/5 dark:data-checked:bg-primary/15 dark:data-checked:ring-primary dark:data-checked:hover:bg-primary/15"
267 >262 >
268 <IconComponent className="size-4 shrink-0" aria-hidden />263 <IconComponent className="size-4 shrink-0" aria-hidden />
269 <span className="min-w-0 flex-1 truncate text-sm font-medium text-foreground">264 <span className="min-w-0 flex-1 truncate text-sm font-medium text-foreground">
270 {option.label}265 {option.label}
271 </span>266 </span>
272 <RadioPrimitive.Indicator267 <RadioPrimitive.Indicator
273 className="grid size-5 shrink-0 place-items-center rounded-full bg-primary text-primary-foreground"268 className="grid size-5 shrink-0 place-items-center rounded-full bg-primary text-primary-foreground"
274 aria-hidden269 aria-hidden
275 >
276 <CheckIcon className="size-3.5 shrink-0" />
277 </RadioPrimitive.Indicator>
278 {option.badgeLabel ? (
279 <Badge variant="warning" size="sm">
280 {option.badgeLabel}
281 </Badge>
282 ) : null}
283 </RadioPrimitive.Root>
284 );
285 })}
286 {COMING_SOON_DRIVER_OPTIONS.map((option) => {
287 const IconComponent = option.icon;
288 return (
289 <RadioPrimitive.Root
290 key={option.value}
291 value={option.value}
292 disabled
293 className={cn(
294 "relative flex cursor-not-allowed items-center gap-3 rounded-lg bg-card/60 px-3 py-3 text-left opacity-55 outline-none ring-1 ring-black/5 dark:bg-white/2 dark:ring-white/5",
295 )}
296 >270 >
297 <IconComponent271 <CheckIcon className="size-3.5 shrink-0" />
298 className="size-4 shrink-0 text-muted-foreground"272 </RadioPrimitive.Indicator>
299 aria-hidden273 {option.badgeLabel ? (
300 />
301 <span className="min-w-0 flex-1 truncate text-sm font-medium text-foreground">
302 {option.label}
303 </span>
304 <Badge variant="warning" size="sm">274 <Badge variant="warning" size="sm">
305 Coming Soon275 {option.badgeLabel}
306 </Badge>276 </Badge>
307 </RadioPrimitive.Root>277 ) : null}
308 );278 </RadioPrimitive.Root>
309 })}279 );
310 </RadioGroup>280 })}
311 </div>281 {COMING_SOON_DRIVER_OPTIONS.map((option) => {
282 const IconComponent = option.icon;
283 return (
284 <RadioPrimitive.Root
285 key={option.value}
286 value={option.value}
287 disabled
288 className={cn(
289 "relative flex cursor-not-allowed items-center gap-3 rounded-lg bg-card/60 px-3 py-3 text-left opacity-55 outline-none ring-1 ring-black/5 dark:bg-white/2 dark:ring-white/5",
290 )}
291 >
292 <IconComponent className="size-4 shrink-0 text-muted-foreground" aria-hidden />
293 <span className="min-w-0 flex-1 truncate text-sm font-medium text-foreground">
294 {option.label}
295 </span>
296 <Badge variant="warning" size="sm">
297 Coming Soon
298 </Badge>
299 </RadioPrimitive.Root>
300 );
301 })}
302 </RadioGroup>
303 </div>
312304
313 <label className={cn("grid gap-2", wizardStep !== 1 && "hidden")}>305 <label className={cn("grid gap-2", wizardStep !== 1 && "hidden")}>
314 <span className="text-xs font-medium text-foreground">Label</span>306 <span className="text-xs font-medium text-foreground">Label</span>
315 <Input307 <Input
316 className="bg-background"308 className="bg-background"
317 placeholder="e.g. Work"309 placeholder="e.g. Work"
318 value={label}310 value={label}
319 onChange={(event) => setLabel(event.target.value)}311 onChange={(event) => setLabel(event.target.value)}
320 />312 />
313 <span className="text-[11px] text-muted-foreground">
314 Shown in the provider list. Optional.
315 </span>
316 </label>
317
318 <label className={cn("grid gap-2", wizardStep !== 1 && "hidden")}>
319 <span className="text-xs font-medium text-foreground">Instance ID</span>
320 <Input
321 className="bg-background"
322 placeholder={`${driver}_work`}
323 value={instanceId}
324 onChange={(event) => {
325 setInstanceIdOverride(event.target.value);
326 }}
327 aria-invalid={showInstanceIdError}
328 />
329 {showInstanceIdError ? (
330 <span className="text-[11px] text-destructive">{instanceIdError}</span>
331 ) : (
321 <span className="text-[11px] text-muted-foreground">332 <span className="text-[11px] text-muted-foreground">
322 Shown in the provider list. Optional.333 Routing key used by threads and sessions. Letters, digits, '-', or '_'.
323 </span>334 </span>
324 </label>335 )}
336 </label>
325337
326 <label className={cn("grid gap-2", wizardStep !== 1 && "hidden")}>338 <div className={cn("grid gap-2", wizardStep !== 1 && "hidden")}>
327 <span className="text-xs font-medium text-foreground">Instance ID</span>339 <span className="text-xs font-medium text-foreground">Accent color</span>
328 <Input340 <div className="flex min-w-0 flex-wrap items-center gap-2">
329 className="bg-background"341 <input
330 placeholder={`${driver}_work`}342 type="color"
331 value={instanceId}343 value={normalizeProviderAccentColor(accentColor) ?? PROVIDER_ACCENT_SWATCHES[0]}
332 onChange={(event) => {344 onChange={(event) => setAccentColor(event.target.value)}
333 setInstanceIdOverride(event.target.value);345 aria-label="Provider instance accent color"
334 }}346 className="h-8 w-10 cursor-pointer rounded-xl border border-input bg-background p-0.5"
335 aria-invalid={showInstanceIdError}
336 />347 />
337 {showInstanceIdError ? (348 <div className="flex flex-wrap gap-1.5">
338 <span className="text-[11px] text-destructive">{instanceIdError}</span>349 {PROVIDER_ACCENT_SWATCHES.map((swatch) => {
339 ) : (350 const selected = accentColor.toLowerCase() === swatch;
340 <span className="text-[11px] text-muted-foreground">351 return (
341 Routing key used by threads and sessions. Letters, digits, '-', or '_'.352 <button
342 </span>353 key={swatch}
343 )}354 type="button"
344 </label>355 className={cn(
345356 "size-6 cursor-pointer rounded-full border transition",
346 <div className={cn("grid gap-2", wizardStep !== 1 && "hidden")}>357 selected
347 <span className="text-xs font-medium text-foreground">Accent color</span>358 ? "scale-110 border-foreground ring-2 ring-ring ring-offset-1 ring-offset-background"
348 <div className="flex min-w-0 flex-wrap items-center gap-2">359 : "border-black/10 hover:scale-105 dark:border-white/20",
349 <input360 )}
350 type="color"361 style={{ backgroundColor: swatch }}
351 value={normalizeProviderAccentColor(accentColor) ?? PROVIDER_ACCENT_SWATCHES[0]}362 onClick={() => setAccentColor(swatch)}
352 onChange={(event) => setAccentColor(event.target.value)}363 aria-label={`Use ${swatch} accent`}
353 aria-label="Provider instance accent color"364 />
354 className="h-8 w-10 cursor-pointer rounded-xl border border-input bg-background p-0.5"365 );
355 />366 })}
356 <div className="flex flex-wrap gap-1.5">
357 {PROVIDER_ACCENT_SWATCHES.map((swatch) => {
358 const selected = accentColor.toLowerCase() === swatch;
359 return (
360 <button
361 key={swatch}
362 type="button"
363 className={cn(
364 "size-6 cursor-pointer rounded-full border transition",
365 selected
366 ? "scale-110 border-foreground ring-2 ring-ring ring-offset-1 ring-offset-background"
367 : "border-black/10 hover:scale-105 dark:border-white/20",
368 )}
369 style={{ backgroundColor: swatch }}
370 onClick={() => setAccentColor(swatch)}
371 aria-label={`Use ${swatch} accent`}
372 />
373 );
374 })}
375 </div>
376 {accentColor ? (
377 <Button
378 type="button"
379 size="xs"
380 variant="ghost"
381 className="text-muted-foreground"
382 onClick={() => setAccentColor("")}
383 >
384 Clear
385 </Button>
386 ) : null}
387 </div>367 </div>
388 <span className="text-[11px] text-muted-foreground">368 {accentColor ? (
389 Optional marker shown in the picker.369 <Button
390 </span>370 type="button"
371 size="xs"
372 variant="ghost"
373 className="text-muted-foreground"
374 onClick={() => setAccentColor("")}
375 >
376 Clear
377 </Button>
378 ) : null}
391 </div>379 </div>
380 <span className="text-[11px] text-muted-foreground">
381 Optional marker shown in the picker.
382 </span>
383 </div>
392384
393 {driverSettingsFields.length > 0 ? (385 {driverSettingsFields.length > 0 ? (
394 <div className={cn("grid gap-4", wizardStep !== 2 && "hidden")}>386 <div className={cn("grid gap-4", wizardStep !== 2 && "hidden")}>
395 <ProviderSettingsForm387 <ProviderSettingsForm
396 definition={driverOption}388 definition={driverOption}
397 value={configDraft}389 value={configDraft}
398 idPrefix={`add-provider-${driver}`}390 idPrefix={`add-provider-${driver}`}
399 variant="dialog"391 variant="dialog"
400 onChange={setConfigDraft}392 onChange={setConfigDraft}
401 />393 />
402 </div>394 </div>
403 ) : wizardStep === 2 ? (395 ) : wizardStep === 2 ? (
404 <div className="grid gap-2">396 <div className="grid gap-2">
405 <p className="text-sm text-muted-foreground">397 <p className="text-sm text-muted-foreground">
406 This driver has no required configuration. You can add the instance now.398 This driver has no required configuration. You can add the instance now.
407 </p>399 </p>
408 </div>400 </div>
409 ) : null}401 ) : null}
410 </WizardPanel>402 </WizardPanel>
411403
412 <DialogFooter variant="bare">404 <WizardFooter>
413 <Button405 <Button
414 variant="outline"406 variant="outline"
415 onClick={() => {407 onClick={() => {
416 if (wizardStep === 0) {408 if (wizardStep === 0) {
417 onOpenChange(false);409 onOpenChange(false);
418 return;410 return;
419 }411 }
420 setWizardStep((step) => Math.max(0, step - 1));412 setWizardStep((step) => Math.max(0, step - 1));
421 }}413 }}
422 >414 >
423 {wizardStep === 0 ? "Cancel" : "Back"}415 {wizardStep === 0 ? "Cancel" : "Back"}
424 </Button>416 </Button>
425 {wizardStep < ADD_PROVIDER_WIZARD_STEPS.length - 1 ? (417 {wizardStep < ADD_PROVIDER_WIZARD_STEPS.length - 1 ? (
426 <Button onClick={() => navigateToStep(wizardStep + 1)}>Next</Button>418 <Button onClick={() => navigateToStep(wizardStep + 1)}>Next</Button>
427 ) : (419 ) : (
428 <Button onClick={handleSave}>Add instance</Button>420 <Button onClick={handleSave}>Add instance</Button>
429 )}421 )}
430 </DialogFooter>422 </WizardFooter>
431 </div>423 </WizardPopup>
432 </DialogPopup>
433 </Dialog>424 </Dialog>
434 );425 );
435}426}
apps/web/src/components/settings/SnapShotSetupDialog.tsx+16-26
...@@ -7,16 +7,8 @@ import { CircleCheckIcon } from "lucide-react";...@@ -7,16 +7,8 @@ import { CircleCheckIcon } from "lucide-react";
7import { useEffect, useId, useState, type ReactNode } from "react";7import { useEffect, useId, useState, type ReactNode } from "react";
8import { CaptureShortcutConfig } from "./CaptureShortcutConfig";8import { CaptureShortcutConfig } from "./CaptureShortcutConfig";
9import { Button } from "../ui/button";9import { Button } from "../ui/button";
10import {10import { Dialog, DialogDescription } from "../ui/dialog";
11 Dialog,11import { WizardSteps, WizardPopup, WizardHeader, WizardPanel, WizardFooter } from "../ui/wizard";
12 DialogDescription,
13 DialogFooter,
14 DialogHeader,
15 DialogPanel,
16 DialogPopup,
17 DialogTitle,
18} from "../ui/dialog";
19import { WizardSteps } from "../ui/wizard-steps";
20import {12import {
21 captureSetupAccessReady,13 captureSetupAccessReady,
22 captureSetupBackend,14 captureSetupBackend,
...@@ -335,21 +327,19 @@ export function SnapShotSetupDialog({...@@ -335,21 +327,19 @@ export function SnapShotSetupDialog({
335 if (!open && !busy) void onClose(false);327 if (!open && !busy) void onClose(false);
336 }}328 }}
337 >329 >
338 <DialogPopup className="max-w-xl" showCloseButton={!busy}>330 <WizardPopup showCloseButton={!busy}>
339 <DialogHeader>331 <WizardHeader title={desktop ? `Set up snapshots for ${desktop}` : "Set up snapshots"}>
340 <DialogTitle>
341 {desktop ? `Set up snapshots for ${desktop}` : "Set up snapshots"}
342 </DialogTitle>
343 <WizardSteps332 <WizardSteps
344 steps={SETUP_STEPS.map((item, index) => ({ ...item, disabled: index > stepIndex }))}333 steps={SETUP_STEPS.map((item) => item.label)}
345 currentStep={step}334 currentStep={stepIndex}
346 disabled={busy}335 isStepDisabled={(index) => busy || index > stepIndex}
347 onStepSelect={(next) => {336 onStepChange={(index) => {
348 if (next !== step) changeStep(next);337 const next = SETUP_STEPS[index];
338 if (next && next.id !== step) changeStep(next.id);
349 }}339 }}
350 />340 />
351 </DialogHeader>341 </WizardHeader>
352 <DialogPanel>342 <WizardPanel>
353 <div className="space-y-4 text-sm">343 <div className="space-y-4 text-sm">
354 <div className="space-y-2" aria-live="polite">344 <div className="space-y-2" aria-live="polite">
355 <h3 className="flex items-center gap-2 font-medium">{title}</h3>345 <h3 className="flex items-center gap-2 font-medium">{title}</h3>
...@@ -479,8 +469,8 @@ export function SnapShotSetupDialog({...@@ -479,8 +469,8 @@ export function SnapShotSetupDialog({
479 </details>469 </details>
480 ) : null}470 ) : null}
481 </div>471 </div>
482 </DialogPanel>472 </WizardPanel>
483 <DialogFooter variant="bare">473 <WizardFooter>
484 {step !== "access" ? (474 {step !== "access" ? (
485 <Button variant="ghost" disabled={busy} onClick={() => changeStep("access")}>475 <Button variant="ghost" disabled={busy} onClick={() => changeStep("access")}>
486 Back476 Back
...@@ -566,8 +556,8 @@ export function SnapShotSetupDialog({...@@ -566,8 +556,8 @@ export function SnapShotSetupDialog({
566 {busy ? "Saving…" : shortcutChanged ? "Save and finish" : "Done"}556 {busy ? "Saving…" : shortcutChanged ? "Save and finish" : "Done"}
567 </Button>557 </Button>
568 ) : null}558 ) : null}
569 </DialogFooter>559 </WizardFooter>
570 </DialogPopup>560 </WizardPopup>
571 </Dialog>561 </Dialog>
572 );562 );
573}563}
apps/web/src/components/ui/wizard-steps.tsx deleted-56
...@@ -1,56 +0,0 @@
1import { CheckIcon } from "lucide-react";
2import { cn } from "~/lib/utils";
3
4export function WizardSteps<Step extends string>({
5 steps,
6 currentStep,
7 disabled = false,
8 onStepSelect,
9}: {
10 steps: readonly { id: Step; label: string; disabled?: boolean }[];
11 currentStep: Step;
12 disabled?: boolean;
13 onStepSelect: (step: Step) => void;
14}) {
15 const currentIndex = steps.findIndex((step) => step.id === currentStep);
16 return (
17 <ol className="grid auto-cols-fr grid-flow-col gap-2" aria-label="Setup steps">
18 {steps.map((step, index) => (
19 <li key={step.id} className="min-w-0">
20 <button
21 type="button"
22 disabled={disabled || step.disabled}
23 aria-current={index === currentIndex ? "step" : undefined}
24 className={cn(
25 "grid w-full min-w-0 grid-cols-[1rem_minmax(0,1fr)] gap-x-2 rounded-lg border px-3 py-2 text-left outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-default",
26 index === currentIndex
27 ? "border-primary bg-primary/10 ring-1 ring-primary/25"
28 : index < currentIndex
29 ? "border-border bg-background"
30 : "border-border bg-muted/40",
31 )}
32 onClick={() => onStepSelect(step.id)}
33 >
34 <span
35 className={cn(
36 "row-span-2 mt-0.5 grid size-4 place-items-center rounded-full border",
37 index < currentIndex
38 ? "border-primary bg-primary text-primary-foreground"
39 : index === currentIndex
40 ? "border-primary bg-background"
41 : "border-muted-foreground/35 bg-background",
42 )}
43 aria-hidden
44 >
45 {index < currentIndex ? <CheckIcon className="size-3" /> : null}
46 </span>
47 <span className="text-[10px] font-medium uppercase text-muted-foreground">
48 Step {index + 1}
49 </span>
50 <span className="truncate text-xs font-semibold text-foreground">{step.label}</span>
51 </button>
52 </li>
53 ))}
54 </ol>
55 );
56}
apps/web/src/components/ui/wizard.tsx+65-9
...@@ -1,19 +1,75 @@...@@ -1,19 +1,75 @@
1import { CheckIcon } from "lucide-react";1import { CheckIcon } from "lucide-react";
2import type { ComponentProps } from "react";2import type { ComponentProps, ReactNode } from "react";
33
4import { cn } from "../../lib/utils";4import { cn } from "../../lib/utils";
5import { AnimatedHeight } from "../AnimatedHeight";5import { AnimatedHeight } from "../AnimatedHeight";
6import { DialogPopup, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from "./dialog";
7
8/** Compose a wizard from its header, panel, and footer; flow logic stays with the caller. */
9export function WizardPopup({
10 children,
11 ...props
12}: Omit<ComponentProps<typeof DialogPopup>, "className" | "style">) {
13 return (
14 <DialogPopup {...props} className="max-w-xl overflow-x-hidden overflow-y-auto">
15 <div className="flex min-h-0 flex-col">{children}</div>
16 </DialogPopup>
17 );
18}
19
20export function WizardHeader({
21 title,
22 description,
23 identity,
24 children,
25}: {
26 readonly title: ReactNode;
27 readonly description?: ReactNode;
28 /** Optional branding shown in place of the visible title. The title remains accessible. */
29 readonly identity?: ReactNode;
30 readonly children?: ReactNode;
31}) {
32 return (
33 <DialogHeader>
34 <DialogTitle className={identity ? "sr-only" : undefined}>{title}</DialogTitle>
35 {identity}
36 {description ? <DialogDescription>{description}</DialogDescription> : null}
37 {children}
38 </DialogHeader>
39 );
40}
41
42export function WizardFooter({
43 children,
44 leading,
45}: {
46 readonly children: ReactNode;
47 readonly leading?: ReactNode;
48}) {
49 return (
50 <DialogFooter variant="bare" className={leading ? "sm:justify-between" : undefined}>
51 {leading}
52 {leading ? (
53 <div className="flex flex-col-reverse gap-2 sm:flex-row">{children}</div>
54 ) : (
55 children
56 )}
57 </DialogFooter>
58 );
59}
660
7export function WizardSteps({61export function WizardSteps({
8 steps,62 steps,
9 currentStep,63 currentStep,
10 summaries,64 summaries,
65 showSummaries = false,
11 onStepChange,66 onStepChange,
12 isStepDisabled,67 isStepDisabled,
13}: {68}: {
14 readonly steps: readonly string[];69 readonly steps: readonly string[];
15 readonly currentStep: number;70 readonly currentStep: number;
16 readonly summaries?: readonly (string | null)[];71 readonly summaries?: readonly (string | null)[];
72 readonly showSummaries?: boolean;
17 readonly isStepDisabled?: (step: number) => boolean;73 readonly isStepDisabled?: (step: number) => boolean;
18 readonly onStepChange?: (step: number) => void;74 readonly onStepChange?: (step: number) => void;
19}) {75}) {
...@@ -61,6 +117,9 @@ export function WizardSteps({...@@ -61,6 +117,9 @@ export function WizardSteps({
61 )}117 )}
62 >118 >
63 {step}119 {step}
120 {showSummaries && index < currentStep && summaries?.[index]
121 ? `: ${summaries[index]}`
122 : null}
64 </span>123 </span>
65 </Step>124 </Step>
66 </li>125 </li>
...@@ -70,19 +129,16 @@ export function WizardSteps({...@@ -70,19 +129,16 @@ export function WizardSteps({
70}129}
71130
72export function WizardPanel({131export function WizardPanel({
73 className,
74 children,132 children,
75 holdHeight = false,133 holdHeight = false,
76 ...props134}: {
77}: ComponentProps<"div"> & { readonly holdHeight?: boolean }) {135 readonly children: ReactNode;
136 readonly holdHeight?: boolean;
137}) {
78 return (138 return (
79 <div139 <div
80 data-slot="dialog-panel"140 data-slot="dialog-panel"
81 className={cn(141 className="min-w-0 space-y-4 bg-zinc-25/80 px-6 py-5 ring-1 ring-black/5 dark:bg-white/2 dark:ring-white/5"
82 "space-y-4 bg-zinc-25/80 px-6 py-5 ring-1 ring-black/5 dark:bg-white/2 dark:ring-white/5",
83 className,
84 )}
85 {...props}
86 >142 >
87 <AnimatedHeight holdHeight={holdHeight}>{children}</AnimatedHeight>143 <AnimatedHeight holdHeight={holdHeight}>{children}</AnimatedHeight>
88 </div>144 </div>