History

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

Julius Marminge committed 2 months ago

Commit 3faeee41 parent 5e6cc2b7 files · +690 −771Browse files

Unified · Split

7 files
  1. apps/web/src/components/GitActionsControl.tsx +318 −377
  2. apps/web/src/components/cloud/ConnectOnboardingDialog.tsx +51 −54
  3. apps/web/src/components/onboarding/WelcomeWizard.tsx +55 −55
  4. apps/web/src/components/settings/AddProviderInstanceDialog.tsx +185 −194
  5. apps/web/src/components/settings/SnapShotSetupDialog.tsx +16 −26
  6. apps/web/src/components/ui/wizard-steps.tsx +0 −56
  7. apps/web/src/components/ui/wizard.tsx +65 −9
apps/web/src/components/GitActionsControl.tsx+318−377
@@ -58,7 +58,7 @@ import { · 57 unmodified lines
5858 resolveQuickAction,
5959 resolveThreadBranchUpdate,
6060 } from "./GitActionsControl.logic";
61−import { AnimatedHeight } from "./AnimatedHeight";
61+import { WizardPopup, WizardHeader, WizardSteps, WizardPanel, WizardFooter } from "./ui/wizard";
6262 import { StartTruncatedPath } from "./StartTruncatedPath";
6363 import { Button } from "~/components/ui/button";
6464 import { Checkbox } from "~/components/ui/checkbox";
@@ -563,419 +563,360 @@ function PublishRepositoryDialog(props: PublishRepositoryDialogProps) { · 498 unmodified lines
563563
564564 return (
565565 <Dialog open={props.open} onOpenChange={handleOpenChange}>
566− <DialogPopup className="max-w-xl overflow-hidden">
567− <div className="flex min-h-0 flex-col overflow-hidden border-foreground/10 bg-transparent">
568− <DialogHeader className="border-b border-border/70 bg-foreground/[0.025] dark:border-transparent dark:bg-transparent">
569− <DialogTitle>Publish repository</DialogTitle>
570− <DialogDescription>
571− Pick where to host it, then point us at a repo to push to.
572− </DialogDescription>
573− <div className="grid grid-cols-3 gap-2">
574− {publishWizardSteps.map((label, index) => {
575− const isComplete = index < publishWizardStep;
576− const isClickable =
577− publishWizardStep !== 2 &&
578− index < publishWizardSteps.length - 1 &&
579− index <= publishWizardStep;
566+ <WizardPopup>
567+ <WizardHeader
568+ title="Publish repository"
569+ description="Pick where to host it, then point us at a repo to push to."
570+ >
571+ <WizardSteps
572+ steps={publishWizardSteps}
573+ currentStep={publishWizardStep}
574+ summaries={publishWizardStepSummaries}
575+ showSummaries
576+ isStepDisabled={(index) =>
577+ publishWizardStep === 2 ||
578+ index >= publishWizardSteps.length - 1 ||
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+
580638 return (
581− <button
582− key={label}
583− type="button"
584− onClick={isClickable ? () => setPublishWizardStep(index) : undefined}
585− disabled={!isClickable}
639+ <RadioPrimitive.Root
640+ key={option.value}
641+ value={option.value}
586642 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",
588− index === publishWizardStep
589− ? "border-primary bg-primary/10 ring-1 ring-primary/25 dark:border-transparent"
590− : isComplete
591− ? "border-border bg-background dark:border-transparent dark:bg-white/[0.05]"
592− : "border-border bg-muted/40 dark:border-transparent dark:bg-white/[0.025]",
593− !isClickable && "cursor-default",
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]",
644+ "focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1 focus-visible:ring-offset-background",
645+ isSelected
646+ ? "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"
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",
594648 )}
595649 >
596− <span
597− aria-hidden
598− className={cn(
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}
650+ <option.Icon className="size-5 shrink-0" aria-hidden />
651+ <span className="min-w-0 flex-1 truncate text-sm font-medium text-foreground">
652+ {option.label}
611653 </span>
612− <span className="truncate text-xs font-semibold text-foreground">
613− {label}
614− {isComplete && publishWizardStepSummaries[index]
615− ? `: ${publishWizardStepSummaries[index]}`
616− : ""}
617− </span>
618− </button>
654+ </RadioPrimitive.Root>
619655 );
620656 })}
621− </div>
622− </DialogHeader>
623−
624− <DialogPanel className="space-y-5 border-b border-border/70 bg-muted/20 px-6 py-5 dark:border-transparent dark:bg-transparent">
625− <AnimatedHeight>
626− <div className={cn("space-y-2", publishWizardStep !== 0 && "hidden")}>
627− <span
628− id="publish-provider-cards-label"
629− className="text-xs font-medium text-foreground"
630− >
631− Provider
657+ </RadioGroup>
658+ </div>
659+
660+ <div className={cn("space-y-5", publishWizardStep !== 1 && "hidden")}>
661+ <div className="space-y-2">
662+ <label
663+ htmlFor="publish-repository-path"
664+ className="text-xs font-medium text-foreground"
665+ >
666+ Repository
667+ </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}/
632672 </span>
633− <RadioGroup
634− value={publishProvider}
635− onValueChange={(value) => {
636− setSelectedPublishProvider(value as PublishProviderKind);
637− setPublishRepositoryOverride(null);
673+ <input
674+ id="publish-repository-path"
675+ name="publish-repository-path"
676+ value={publishRepository}
677+ onChange={(event) => {
678+ setPublishRepositoryOverride(event.target.value);
638679 }}
639− aria-labelledby="publish-provider-cards-label"
640− className="grid grid-cols-2 gap-2.5"
641− >
642− {sortedPublishProviderOptions.map((option) => {
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− );
680+ onKeyDown={(event) => {
681+ if (event.key === "Enter") {
682+ event.preventDefault();
683+ submitPublishRepository();
682684 }
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 (
685− <RadioPrimitive.Root
686− key={option.value}
687− value={option.value}
688− className={cn(
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]",
690− "focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1 focus-visible:ring-offset-background",
691− isSelected
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"
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",
694− )}
695− >
696− <option.Icon className="size-5 shrink-0" aria-hidden />
697− <span className="min-w-0 flex-1 truncate text-sm font-medium text-foreground">
693+ <div className="space-y-2">
694+ <span
695+ id="publish-visibility-cards-label"
696+ className="text-xs font-medium text-foreground"
697+ >
698+ Visibility
699+ </span>
700+ <RadioGroup
701+ value={publishVisibility}
702+ onValueChange={(value) =>
703+ setPublishVisibility(value as SourceControlRepositoryVisibility)
704+ }
705+ aria-labelledby="publish-visibility-cards-label"
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">
698739 {option.label}
699740 </span>
700− </RadioPrimitive.Root>
701− );
702− })}
703− </RadioGroup>
704− </div>
741+ <span className="block text-xs text-muted-foreground">
742+ {option.description}
743+ </span>
744+ </span>
745+ </RadioPrimitive.Root>
746+ );
747+ })}
748+ </RadioGroup>
749+ </div>
705750
706− <div className={cn("space-y-5", publishWizardStep !== 1 && "hidden")}>
707− <div className="space-y-2">
708− <label
709− htmlFor="publish-repository-path"
710− className="text-xs font-medium text-foreground"
711− >
712− Repository
751+ <div>
752+ <button
753+ type="button"
754+ onClick={() => setPublishAdvancedOpen((prev) => !prev)}
755+ aria-expanded={publishAdvancedOpen}
756+ className="flex items-center gap-1 text-xs font-medium text-muted-foreground transition-colors hover:text-foreground"
757+ >
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+ />
713777 </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">
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">
716− <currentPublishProvider.Icon className="size-3.5" />
717− {publishHost}/
778+ <div className="space-y-1.5">
779+ <span
780+ id="publish-protocol-label"
781+ className="text-xs font-medium text-foreground"
782+ >
783+ Protocol
718784 </span>
719− <input
720− id="publish-repository-path"
721− name="publish-repository-path"
722− value={publishRepository}
723− onChange={(event) => {
724− setPublishRepositoryOverride(event.target.value);
725− }}
726− onKeyDown={(event) => {
727− if (event.key === "Enter") {
728− event.preventDefault();
729− submitPublishRepository();
785+ <RadioGroup
786+ className="w-fit flex-row gap-0.5 rounded-lg bg-input/40 p-0.5"
787+ value={publishProtocol}
788+ onValueChange={(protocol) => {
789+ if (protocol === "ssh" || protocol === "https") {
790+ setPublishProtocol(protocol);
730791 }
731792 }}
732− placeholder={publishPathPlaceholder}
793+ aria-labelledby="publish-protocol-label"
733794 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"
735− />
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 (
795+ >
796+ {(["ssh", "https"] as const).map((protocol) => (
771797 <RadioPrimitive.Root
772− key={option.value}
773− value={option.value}
774− className={cn(
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]",
776− "focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1 focus-visible:ring-offset-background",
777− isSelected
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"
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− )}
798+ key={protocol}
799+ value={protocol}
800+ data-pressed={publishProtocol === protocol ? "" : undefined}
801+ className={toggleVariants({
802+ variant: "segmented",
803+ size: "segmented",
804+ })}
781805 >
782− <option.Icon
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>
806+ {protocol.toUpperCase()}
794807 </RadioPrimitive.Root>
795− );
796− })}
797− </RadioGroup>
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}
808+ ))}
809+ </RadioGroup>
810+ </div>
862811 </div>
812+ ) : null}
813+ </div>
863814
864− {publishRepositoryAction.isPending ? (
865− <div
866− role="status"
867− 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]"
869− >
870− <Spinner className="size-3.5" aria-hidden />
871− 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}
815+ {publishRepositoryAction.isPending ? (
816+ <div
817+ role="status"
818+ aria-live="polite"
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]"
820+ >
821+ <Spinner className="size-3.5" aria-hidden />
822+ Publishing repository to {publishProviderLabel}...
883823 </div>
884−
885− <div className={cn("space-y-4", publishWizardStep !== 2 && "hidden")}>
886− {publishResult ? (
887− <>
888− <div className="flex flex-col items-center gap-2 py-1 text-center">
889− <span className="grid size-8 place-items-center rounded-full bg-success/15 text-success">
890− <CheckIcon className="size-4" aria-hidden />
891− </span>
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− )}
824+ ) : null}
825+ {publishError && !publishRepositoryAction.isPending ? (
826+ <div
827+ role="alert"
828+ className="rounded-md border border-destructive/40 bg-destructive/10 px-3 py-2 text-xs text-destructive"
829+ >
830+ <p className="font-medium">Publish failed</p>
831+ <p className="mt-0.5 text-destructive/90">{publishError}</p>
925832 </div>
926− </AnimatedHeight>
927− </DialogPanel>
833+ ) : null}
834+ </div>
928835
929− <DialogFooter className="dark:border-transparent dark:bg-transparent">
930− {publishWizardStep === 2 ? (
931− <Button size="sm" onClick={() => handleOpenChange(false)}>
932− Done
933− </Button>
934− ) : (
836+ <div className={cn("space-y-4", publishWizardStep !== 2 && "hidden")}>
837+ {publishResult ? (
935838 <>
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>
936860 <Button
937861 variant="outline"
938862 size="sm"
939− disabled={publishRepositoryAction.isPending}
863+ className="w-full"
940864 onClick={() => {
941− if (publishWizardStep === 0) {
942− handleOpenChange(false);
943− return;
944− }
945− setPublishWizardStep((step) => Math.max(0, step - 1));
865+ void openLink(publishResult.repository.url).catch(() => undefined);
946866 }}
947867 >
948− {publishWizardStep === 0 ? "Cancel" : "Back"}
868+ Open on {publishProviderLabel}
949869 </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− )}
974870 </>
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>
975875 )}
976− </DialogFooter>
977− </div>
978− </DialogPopup>
876+ </div>
877+ </WizardPanel>
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>
979920 </Dialog>
980921 );
981922 }
apps/web/src/components/cloud/ConnectOnboardingDialog.tsx+51−54
@@ -15,18 +15,10 @@ import { useEnvironments, usePrimaryEnvironment } from "~/state/environments"; · 14 unmodified lines
1515 import { CloudEnvironmentConnectRows } from "./CloudEnvironmentConnectList";
1616 import { Button } from "../ui/button";
1717 import { Checkbox } from "../ui/checkbox";
18−import {
19− Dialog,
20− DialogDescription,
21− DialogFooter,
22− DialogHeader,
23− DialogPanel,
24− DialogPopup,
25− DialogTitle,
26−} from "../ui/dialog";
18+import { Dialog } from "../ui/dialog";
2719 import { Switch } from "../ui/switch";
2820 import { toastManager } from "../ui/toast";
29−import { WizardSteps } from "../ui/wizard-steps";
21+import { WizardSteps, WizardPopup, WizardHeader, WizardPanel, WizardFooter } from "../ui/wizard";
3022
3123 /**
3224 * Post-sign-in onboarding wizard for T3 Connect. Opens on every in-session
@@ -216,23 +208,29 @@ function ConfiguredConnectOnboardingDialog() { · 183 unmodified lines
216208 if (!open && !isApplying) complete();
217209 }}
218210 >
219− <DialogPopup className="max-w-xl">
220− <DialogHeader>
221− <DialogTitle>Set up T3 Connect</DialogTitle>
222− <DialogDescription>
223− Mesh your devices together — publish this environment and connect the rest, all in one
224− place.
225− </DialogDescription>
211+ <WizardPopup>
212+ <WizardHeader
213+ title="Set up T3 Connect"
214+ description={
215+ <>
216+ Mesh your devices together — publish this environment and connect the rest, all in one
217+ place.
218+ </>
219+ }
220+ >
226221 {steps.length > 1 ? (
227222 <WizardSteps
228− steps={steps.map((id) => ({ id, label: STEP_LABELS[id] }))}
229− currentStep={step}
230− disabled={isApplying}
231− onStepSelect={setStep}
223+ steps={steps.map((id) => STEP_LABELS[id])}
224+ currentStep={steps.indexOf(step)}
225+ isStepDisabled={() => isApplying}
226+ onStepChange={(index) => {
227+ const next = steps[index];
228+ if (next) setStep(next);
229+ }}
232230 />
233231 ) : null}
234− </DialogHeader>
235− <DialogPanel>
232+ </WizardHeader>
233+ <WizardPanel>
236234 {step === "publish" ? (
237235 <PublishStep
238236 exposeEnvironment={exposeEnvironment}
@@ -245,38 +243,37 @@ function ConfiguredConnectOnboardingDialog() { · 6 unmodified lines
245243 ) : (
246244 <DevicesStep />
247245 )}
248− </DialogPanel>
249− <DialogFooter variant="bare" className="sm:justify-between">
250− <label className="flex cursor-pointer items-center gap-2 self-start text-xs text-muted-foreground sm:self-center">
251− <Checkbox
252− checked={dontShowAgain}
253− onCheckedChange={(checked) => setDontShowAgain(checked === true)}
254− />
255− Don&apos;t show this again
256− </label>
257− <div className="flex flex-col-reverse gap-2 sm:flex-row">
258− {step === "publish" ? (
259− <>
260− <Button variant="ghost" disabled={isApplying} onClick={() => setStep("devices")}>
261− Not now
262− </Button>
263− <Button
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
246+ </WizardPanel>
247+ <WizardFooter
248+ leading={
249+ <label className="flex cursor-pointer items-center gap-2 self-start text-xs text-muted-foreground sm:self-center">
250+ <Checkbox
251+ checked={dontShowAgain}
252+ onCheckedChange={(checked) => setDontShowAgain(checked === true)}
253+ />
254+ Don&apos;t show this again
255+ </label>
256+ }
257+ >
258+ {step === "publish" ? (
259+ <>
260+ <Button variant="ghost" disabled={isApplying} onClick={() => setStep("devices")}>
261+ Not now
275262 </Button>
276− )}
277− </div>
278− </DialogFooter>
279− </DialogPopup>
263+ <Button
264+ disabled={isApplying || (controller.linkState.isPending && linkStateData === null)}
265+ onClick={() => void applyPublishSelection()}
266+ >
267+ {isApplying ? "Enabling…" : "Continue"}
268+ </Button>
269+ </>
270+ ) : (
271+ <Button disabled={isApplying} onClick={complete}>
272+ Done
273+ </Button>
274+ )}
275+ </WizardFooter>
276+ </WizardPopup>
280277 </Dialog>
281278 );
282279 }
apps/web/src/components/onboarding/WelcomeWizard.tsx+55−55
@@ -71,8 +71,8 @@ import { Input } from "../ui/input"; · 70 unmodified lines
7171 import { Tooltip, TooltipTrigger, TooltipPopup } from "../ui/tooltip";
7272 import { ScrollArea } from "../ui/scroll-area";
7373 import { Spinner } from "../ui/spinner";
74−import { WizardPanel, WizardSteps } from "../ui/wizard";
75−import { Dialog, DialogHeader, DialogPopup, DialogTitle } from "../ui/dialog";
74+import { WizardPanel, WizardSteps, WizardPopup, WizardHeader } from "../ui/wizard";
75+import { Dialog } from "../ui/dialog";
7676 import { toastManager } from "../ui/toast";
7777 import { cn } from "../../lib/utils";
7878 import { formatRelativeTime } from "../../timestampFormat";
@@ -183,71 +183,71 @@ export function WelcomeWizard({ · 104 unmodified lines
183183
184184 return (
185185 <Dialog open disablePointerDismissal onOpenChange={(_, event) => event.cancel()}>
186− <DialogPopup
187− className="max-w-xl overflow-x-hidden overflow-y-auto"
186+ <WizardPopup
188187 bottomStickOnMobile={false}
189188 showCloseButton={false}
190189 initialFocus={() => document.getElementById("onboarding-pairing-url") ?? true}
191190 >
192− <DialogTitle className="sr-only">Set up T3 Code</DialogTitle>
193− <div className="flex min-h-0 flex-col">
194− <DialogHeader className="gap-4">
191+ <WizardHeader
192+ title="Set up T3 Code"
193+ identity={
195194 <div className="flex items-baseline gap-1.5" role="img" aria-label="T3 Code">
196195 <T3Wordmark className="h-4 w-auto shrink-0" aria-hidden />
197196 <span className="text-[1.4rem] font-medium tracking-tight text-muted-foreground">
198197 Code
199198 </span>
200199 </div>
201− <WizardSteps
202− steps={ONBOARDING_STAGES}
203− currentStep={stageIndex}
204− isStepDisabled={(index) => isImporting || index >= stageIndex}
205− onStepChange={(index) => {
206− if (isImporting || index > stageIndex) return;
207− setStep(index === 0 ? "connection" : "agents");
200+ }
201+ >
202+ <WizardSteps
203+ steps={ONBOARDING_STAGES}
204+ currentStep={stageIndex}
205+ isStepDisabled={(index) => isImporting || index >= stageIndex}
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]));
208237 }}
209238 />
210− </DialogHeader>
211−
212− <WizardPanel className="min-w-0" holdHeight={isLoadingProjects}>
213− {step === "connection" ? (
214− <ConnectionStep
215− expandPairingInitially={!localAvailable && !hasCloudPublicConfig()}
216− selectedIds={selectedIds}
217− autoSelectedComputers={autoSelectedComputers.current}
218− onSelectionChange={setSelection}
219− onToggleEnvironment={(environmentId, checked) =>
220− setSelection((current) => {
221− const next = new Set(current ?? selectedIds);
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>
239+ ) : step === "agents" ? (
240+ <AgentsStep environmentIds={setupIds} onContinue={() => setStep("import")} />
241+ ) : (
242+ <ImportStep
243+ scans={scans}
244+ isImporting={isImporting}
245+ setIsImporting={setIsImporting}
246+ onDone={finish}
247+ />
248+ )}
249+ </WizardPanel>
250+ </WizardPopup>
251251 </Dialog>
252252 );
253253 }
apps/web/src/components/settings/AddProviderInstanceDialog.tsx+185−194
@@ -15,21 +15,14 @@ import { cn } from "../../lib/utils"; · 14 unmodified lines
1515 import { normalizeProviderAccentColor } from "../../providerInstances";
1616 import { Button } from "../ui/button";
1717 import { ACPRegistryIcon, Gemini, GithubCopilotIcon, PiAgentIcon, type Icon } from "../Icons";
18−import {
19− Dialog,
20− DialogDescription,
21− DialogFooter,
22− DialogHeader,
23− DialogPopup,
24− DialogTitle,
25−} from "../ui/dialog";
18+import { Dialog } from "../ui/dialog";
2619 import { Badge } from "../ui/badge";
2720 import { Input } from "../ui/input";
2821 import { RadioGroup } from "../ui/radio-group";
2922 import { toastManager } from "../ui/toast";
3023 import { DRIVER_OPTION_BY_VALUE, DRIVER_OPTIONS } from "./providerDriverMeta";
3124 import { ProviderSettingsForm, deriveProviderSettingsFields } from "./ProviderSettingsForm";
32−import { WizardPanel } from "../ui/wizard";
25+import { WizardPanel, WizardPopup, WizardHeader, WizardFooter } from "../ui/wizard";
3326 import {
3427 ADD_PROVIDER_WIZARD_STEPS,
3528 resolveWizardNavigation,
@@ -230,206 +223,204 @@ export function AddProviderInstanceDialog({ · 194 unmodified lines
230223
231224 return (
232225 <Dialog open={open} onOpenChange={onOpenChange}>
233− <DialogPopup className="max-w-xl overflow-hidden">
234− <div className="flex min-h-0 flex-col overflow-hidden">
235− <DialogHeader>
236− <DialogTitle>Add provider instance</DialogTitle>
237− <DialogDescription>
226+ <WizardPopup>
227+ <WizardHeader
228+ title="Add provider instance"
229+ description={
230+ <>
238231 Configure an additional provider instance on {environmentLabel} — for example, a
239232 second Codex install pointed at a different workspace.
240− </DialogDescription>
241− <AddProviderInstanceWizardSteps
242− currentStep={wizardStep}
243− summaries={wizardStepSummaries}
244− instanceIdError={instanceIdError}
245− onNavigation={applyWizardNavigation}
246− />
247− </DialogHeader>
233+ </>
234+ }
235+ >
236+ <AddProviderInstanceWizardSteps
237+ currentStep={wizardStep}
238+ summaries={wizardStepSummaries}
239+ instanceIdError={instanceIdError}
240+ onNavigation={applyWizardNavigation}
241+ />
242+ </WizardHeader>
248243
249− <WizardPanel>
250− <div className={cn("grid gap-2", wizardStep !== 0 && "hidden")}>
251− <div id="add-instance-driver-label" className="text-sm font-medium text-foreground">
252− Driver
253− </div>
254− <RadioGroup
255− value={driver}
256− onValueChange={(value) => setDriver(ProviderDriverKind.make(value))}
257− aria-labelledby="add-instance-driver-label"
258− className="grid grid-cols-1 gap-2 sm:grid-cols-2"
259− >
260− {DRIVER_OPTIONS.map((option) => {
261− const IconComponent = option.icon;
262− return (
263− <RadioPrimitive.Root
264− key={option.value}
265− 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"
267− >
268− <IconComponent className="size-4 shrink-0" aria-hidden />
269− <span className="min-w-0 flex-1 truncate text-sm font-medium text-foreground">
270− {option.label}
271− </span>
272− <RadioPrimitive.Indicator
273− className="grid size-5 shrink-0 place-items-center rounded-full bg-primary text-primary-foreground"
274− 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− )}
244+ <WizardPanel>
245+ <div className={cn("grid gap-2", wizardStep !== 0 && "hidden")}>
246+ <div id="add-instance-driver-label" className="text-sm font-medium text-foreground">
247+ Driver
248+ </div>
249+ <RadioGroup
250+ value={driver}
251+ onValueChange={(value) => setDriver(ProviderDriverKind.make(value))}
252+ aria-labelledby="add-instance-driver-label"
253+ className="grid grid-cols-1 gap-2 sm:grid-cols-2"
254+ >
255+ {DRIVER_OPTIONS.map((option) => {
256+ const IconComponent = option.icon;
257+ return (
258+ <RadioPrimitive.Root
259+ key={option.value}
260+ value={option.value}
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"
262+ >
263+ <IconComponent className="size-4 shrink-0" aria-hidden />
264+ <span className="min-w-0 flex-1 truncate text-sm font-medium text-foreground">
265+ {option.label}
266+ </span>
267+ <RadioPrimitive.Indicator
268+ className="grid size-5 shrink-0 place-items-center rounded-full bg-primary text-primary-foreground"
269+ aria-hidden
296270 >
297− <IconComponent
298− className="size-4 shrink-0 text-muted-foreground"
299− aria-hidden
300− />
301− <span className="min-w-0 flex-1 truncate text-sm font-medium text-foreground">
302− {option.label}
303− </span>
271+ <CheckIcon className="size-3.5 shrink-0" />
272+ </RadioPrimitive.Indicator>
273+ {option.badgeLabel ? (
304274 <Badge variant="warning" size="sm">
305− Coming Soon
275+ {option.badgeLabel}
306276 </Badge>
307− </RadioPrimitive.Root>
308− );
309− })}
310− </RadioGroup>
311− </div>
277+ ) : null}
278+ </RadioPrimitive.Root>
279+ );
280+ })}
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")}>
314− <span className="text-xs font-medium text-foreground">Label</span>
315− <Input
316− className="bg-background"
317− placeholder="e.g. Work"
318− value={label}
319− onChange={(event) => setLabel(event.target.value)}
320− />
305+ <label className={cn("grid gap-2", wizardStep !== 1 && "hidden")}>
306+ <span className="text-xs font-medium text-foreground">Label</span>
307+ <Input
308+ className="bg-background"
309+ placeholder="e.g. Work"
310+ value={label}
311+ onChange={(event) => setLabel(event.target.value)}
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+ ) : (
321332 <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 '_'.
323334 </span>
324− </label>
335+ )}
336+ </label>
325337
326− <label className={cn("grid gap-2", wizardStep !== 1 && "hidden")}>
327− <span className="text-xs font-medium text-foreground">Instance ID</span>
328− <Input
329− className="bg-background"
330− placeholder={`${driver}_work`}
331− value={instanceId}
332− onChange={(event) => {
333− setInstanceIdOverride(event.target.value);
334− }}
335− aria-invalid={showInstanceIdError}
338+ <div className={cn("grid gap-2", wizardStep !== 1 && "hidden")}>
339+ <span className="text-xs font-medium text-foreground">Accent color</span>
340+ <div className="flex min-w-0 flex-wrap items-center gap-2">
341+ <input
342+ type="color"
343+ value={normalizeProviderAccentColor(accentColor) ?? PROVIDER_ACCENT_SWATCHES[0]}
344+ onChange={(event) => setAccentColor(event.target.value)}
345+ aria-label="Provider instance accent color"
346+ className="h-8 w-10 cursor-pointer rounded-xl border border-input bg-background p-0.5"
336347 />
337− {showInstanceIdError ? (
338− <span className="text-[11px] text-destructive">{instanceIdError}</span>
339− ) : (
340− <span className="text-[11px] text-muted-foreground">
341− Routing key used by threads and sessions. Letters, digits, '-', or '_'.
342− </span>
343− )}
344− </label>
345−
346− <div className={cn("grid gap-2", wizardStep !== 1 && "hidden")}>
347− <span className="text-xs font-medium text-foreground">Accent color</span>
348− <div className="flex min-w-0 flex-wrap items-center gap-2">
349− <input
350− type="color"
351− value={normalizeProviderAccentColor(accentColor) ?? PROVIDER_ACCENT_SWATCHES[0]}
352− onChange={(event) => setAccentColor(event.target.value)}
353− aria-label="Provider instance accent color"
354− className="h-8 w-10 cursor-pointer rounded-xl border border-input bg-background p-0.5"
355− />
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}
348+ <div className="flex flex-wrap gap-1.5">
349+ {PROVIDER_ACCENT_SWATCHES.map((swatch) => {
350+ const selected = accentColor.toLowerCase() === swatch;
351+ return (
352+ <button
353+ key={swatch}
354+ type="button"
355+ className={cn(
356+ "size-6 cursor-pointer rounded-full border transition",
357+ selected
358+ ? "scale-110 border-foreground ring-2 ring-ring ring-offset-1 ring-offset-background"
359+ : "border-black/10 hover:scale-105 dark:border-white/20",
360+ )}
361+ style={{ backgroundColor: swatch }}
362+ onClick={() => setAccentColor(swatch)}
363+ aria-label={`Use ${swatch} accent`}
364+ />
365+ );
366+ })}
387367 </div>
388− <span className="text-[11px] text-muted-foreground">
389− Optional marker shown in the picker.
390− </span>
368+ {accentColor ? (
369+ <Button
370+ type="button"
371+ size="xs"
372+ variant="ghost"
373+ className="text-muted-foreground"
374+ onClick={() => setAccentColor("")}
375+ >
376+ Clear
377+ </Button>
378+ ) : null}
391379 </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 ? (
394− <div className={cn("grid gap-4", wizardStep !== 2 && "hidden")}>
395− <ProviderSettingsForm
396− definition={driverOption}
397− value={configDraft}
398− idPrefix={`add-provider-${driver}`}
399− variant="dialog"
400− onChange={setConfigDraft}
401− />
402− </div>
403− ) : wizardStep === 2 ? (
404− <div className="grid gap-2">
405− <p className="text-sm text-muted-foreground">
406− This driver has no required configuration. You can add the instance now.
407− </p>
408− </div>
409− ) : null}
410− </WizardPanel>
385+ {driverSettingsFields.length > 0 ? (
386+ <div className={cn("grid gap-4", wizardStep !== 2 && "hidden")}>
387+ <ProviderSettingsForm
388+ definition={driverOption}
389+ value={configDraft}
390+ idPrefix={`add-provider-${driver}`}
391+ variant="dialog"
392+ onChange={setConfigDraft}
393+ />
394+ </div>
395+ ) : wizardStep === 2 ? (
396+ <div className="grid gap-2">
397+ <p className="text-sm text-muted-foreground">
398+ This driver has no required configuration. You can add the instance now.
399+ </p>
400+ </div>
401+ ) : null}
402+ </WizardPanel>
411403
412− <DialogFooter variant="bare">
413− <Button
414− variant="outline"
415− onClick={() => {
416− if (wizardStep === 0) {
417− onOpenChange(false);
418− return;
419− }
420− setWizardStep((step) => Math.max(0, step - 1));
421− }}
422− >
423− {wizardStep === 0 ? "Cancel" : "Back"}
424− </Button>
425− {wizardStep < ADD_PROVIDER_WIZARD_STEPS.length - 1 ? (
426− <Button onClick={() => navigateToStep(wizardStep + 1)}>Next</Button>
427− ) : (
428− <Button onClick={handleSave}>Add instance</Button>
429− )}
430− </DialogFooter>
431− </div>
432− </DialogPopup>
404+ <WizardFooter>
405+ <Button
406+ variant="outline"
407+ onClick={() => {
408+ if (wizardStep === 0) {
409+ onOpenChange(false);
410+ return;
411+ }
412+ setWizardStep((step) => Math.max(0, step - 1));
413+ }}
414+ >
415+ {wizardStep === 0 ? "Cancel" : "Back"}
416+ </Button>
417+ {wizardStep < ADD_PROVIDER_WIZARD_STEPS.length - 1 ? (
418+ <Button onClick={() => navigateToStep(wizardStep + 1)}>Next</Button>
419+ ) : (
420+ <Button onClick={handleSave}>Add instance</Button>
421+ )}
422+ </WizardFooter>
423+ </WizardPopup>
433424 </Dialog>
434425 );
435426 }
apps/web/src/components/settings/SnapShotSetupDialog.tsx+16−26
@@ -7,16 +7,8 @@ import { CircleCheckIcon } from "lucide-react"; · 6 unmodified lines
77 import { useEffect, useId, useState, type ReactNode } from "react";
88 import { CaptureShortcutConfig } from "./CaptureShortcutConfig";
99 import { Button } from "../ui/button";
10−import {
11− Dialog,
12− DialogDescription,
13− DialogFooter,
14− DialogHeader,
15− DialogPanel,
16− DialogPopup,
17− DialogTitle,
18−} from "../ui/dialog";
19−import { WizardSteps } from "../ui/wizard-steps";
10+import { Dialog, DialogDescription } from "../ui/dialog";
11+import { WizardSteps, WizardPopup, WizardHeader, WizardPanel, WizardFooter } from "../ui/wizard";
2012 import {
2113 captureSetupAccessReady,
2214 captureSetupBackend,
@@ -335,21 +327,19 @@ export function SnapShotSetupDialog({ · 312 unmodified lines
335327 if (!open && !busy) void onClose(false);
336328 }}
337329 >
338− <DialogPopup className="max-w-xl" showCloseButton={!busy}>
339− <DialogHeader>
340− <DialogTitle>
341− {desktop ? `Set up snapshots for ${desktop}` : "Set up snapshots"}
342− </DialogTitle>
330+ <WizardPopup showCloseButton={!busy}>
331+ <WizardHeader title={desktop ? `Set up snapshots for ${desktop}` : "Set up snapshots"}>
343332 <WizardSteps
344− steps={SETUP_STEPS.map((item, index) => ({ ...item, disabled: index > stepIndex }))}
345− currentStep={step}
346− disabled={busy}
347− onStepSelect={(next) => {
348− if (next !== step) changeStep(next);
333+ steps={SETUP_STEPS.map((item) => item.label)}
334+ currentStep={stepIndex}
335+ isStepDisabled={(index) => busy || index > stepIndex}
336+ onStepChange={(index) => {
337+ const next = SETUP_STEPS[index];
338+ if (next && next.id !== step) changeStep(next.id);
349339 }}
350340 />
351− </DialogHeader>
352− <DialogPanel>
341+ </WizardHeader>
342+ <WizardPanel>
353343 <div className="space-y-4 text-sm">
354344 <div className="space-y-2" aria-live="polite">
355345 <h3 className="flex items-center gap-2 font-medium">{title}</h3>
@@ -479,8 +469,8 @@ export function SnapShotSetupDialog({ · 123 unmodified lines
479469 </details>
480470 ) : null}
481471 </div>
482− </DialogPanel>
483− <DialogFooter variant="bare">
472+ </WizardPanel>
473+ <WizardFooter>
484474 {step !== "access" ? (
485475 <Button variant="ghost" disabled={busy} onClick={() => changeStep("access")}>
486476 Back
@@ -566,8 +556,8 @@ export function SnapShotSetupDialog({ · 79 unmodified lines
566556 {busy ? "Saving…" : shortcutChanged ? "Save and finish" : "Done"}
567557 </Button>
568558 ) : null}
569− </DialogFooter>
570− </DialogPopup>
559+ </WizardFooter>
560+ </WizardPopup>
571561 </Dialog>
572562 );
573563 }
apps/web/src/components/ui/wizard-steps.tsx+0−56
@@ -1,56 +0,0 @@
1−import { CheckIcon } from "lucide-react";
2−import { cn } from "~/lib/utils";
3−
4−export 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 @@
11 import { CheckIcon } from "lucide-react";
2−import type { ComponentProps } from "react";
2+import type { ComponentProps, ReactNode } from "react";
33
44 import { cn } from "../../lib/utils";
55 import { AnimatedHeight } from "../AnimatedHeight";
6+import { DialogPopup, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from "./dialog";
7+
8+/** Compose a wizard from its header, panel, and footer; flow logic stays with the caller. */
9+export 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+
20+export 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+
42+export 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
761 export function WizardSteps({
862 steps,
963 currentStep,
1064 summaries,
65+ showSummaries = false,
1166 onStepChange,
1267 isStepDisabled,
1368 }: {
1469 readonly steps: readonly string[];
1570 readonly currentStep: number;
1671 readonly summaries?: readonly (string | null)[];
72+ readonly showSummaries?: boolean;
1773 readonly isStepDisabled?: (step: number) => boolean;
1874 readonly onStepChange?: (step: number) => void;
1975 }) {
@@ -61,6 +117,9 @@ export function WizardSteps({ · 41 unmodified lines
61117 )}
62118 >
63119 {step}
120+ {showSummaries && index < currentStep && summaries?.[index]
121+ ? `: ${summaries[index]}`
122+ : null}
64123 </span>
65124 </Step>
66125 </li>
@@ -70,19 +129,16 @@ export function WizardSteps({ · 3 unmodified lines
70129 }
71130
72131 export function WizardPanel({
73− className,
74132 children,
75133 holdHeight = false,
76− ...props
77−}: ComponentProps<"div"> & { readonly holdHeight?: boolean }) {
134+}: {
135+ readonly children: ReactNode;
136+ readonly holdHeight?: boolean;
137+}) {
78138 return (
79139 <div
80140 data-slot="dialog-panel"
81− className={cn(
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}
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"
86142 >
87143 <AnimatedHeight holdHeight={holdHeight}>{children}</AnimatedHeight>
88144 </div>