From 55f0f034c67483e577fb5412e651d15ca0e07f11 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 8 Sep 2026 13:54:26 -0700 Subject: [PATCH] refactor(web): consolidate setup wizards into shared components --- apps/web/src/components/GitActionsControl.tsx | 695 ++++++++---------- .../cloud/ConnectOnboardingDialog.tsx | 105 ++- .../components/onboarding/WelcomeWizard.tsx | 110 +-- .../settings/AddProviderInstanceDialog.tsx | 379 +++++----- .../settings/SnapShotSetupDialog.tsx | 42 +- apps/web/src/components/ui/wizard-steps.tsx | 56 -- apps/web/src/components/ui/wizard.tsx | 74 +- 7 files changed, 690 insertions(+), 771 deletions(-) delete mode 100644 apps/web/src/components/ui/wizard-steps.tsx diff --git a/apps/web/src/components/GitActionsControl.tsx b/apps/web/src/components/GitActionsControl.tsx index b0e74159da89..b766a846931c 100644 --- a/apps/web/src/components/GitActionsControl.tsx +++ b/apps/web/src/components/GitActionsControl.tsx @@ -58,7 +58,7 @@ import { resolveQuickAction, resolveThreadBranchUpdate, } from "./GitActionsControl.logic"; -import { AnimatedHeight } from "./AnimatedHeight"; +import { WizardPopup, WizardHeader, WizardSteps, WizardPanel, WizardFooter } from "./ui/wizard"; import { StartTruncatedPath } from "./StartTruncatedPath"; import { Button } from "~/components/ui/button"; import { Checkbox } from "~/components/ui/checkbox"; @@ -563,419 +563,360 @@ function PublishRepositoryDialog(props: PublishRepositoryDialogProps) { return ( - -
- - Publish repository - - Pick where to host it, then point us at a repo to push to. - -
- {publishWizardSteps.map((label, index) => { - const isComplete = index < publishWizardStep; - const isClickable = - publishWizardStep !== 2 && - index < publishWizardSteps.length - 1 && - index <= publishWizardStep; + + + + publishWizardStep === 2 || + index >= publishWizardSteps.length - 1 || + index > publishWizardStep + } + onStepChange={setPublishWizardStep} + /> + + + +
+ + Provider + + { + setSelectedPublishProvider(value as PublishProviderKind); + setPublishRepositoryOverride(null); + }} + aria-labelledby="publish-provider-cards-label" + className="grid grid-cols-2 gap-2.5" + > + {sortedPublishProviderOptions.map((option) => { + const readiness = publishProviderReadiness[option.value]; + const isSelected = publishProvider === option.value && readiness.ready; + if (!readiness.ready) { + return ( +
+ + + {option.label} + + + { + event.preventDefault(); + event.stopPropagation(); + openSourceControlSettings(); + }} + > + Setup Required + + } + /> + + {readiness.hint ?? + "Open Settings -> Source Control to configure this provider."} + + +
+ ); + } + return ( - + ); })} -
- - - - -
- - Provider + +
+ +
+
+ +
+ + + {publishHost}/ - { - setSelectedPublishProvider(value as PublishProviderKind); - setPublishRepositoryOverride(null); + { + setPublishRepositoryOverride(event.target.value); }} - aria-labelledby="publish-provider-cards-label" - className="grid grid-cols-2 gap-2.5" - > - {sortedPublishProviderOptions.map((option) => { - const readiness = publishProviderReadiness[option.value]; - const isSelected = publishProvider === option.value && readiness.ready; - if (!readiness.ready) { - return ( -
- - - {option.label} - - - { - event.preventDefault(); - event.stopPropagation(); - openSourceControlSettings(); - }} - > - Setup Required - - } - /> - - {readiness.hint ?? - "Open Settings -> Source Control to configure this provider."} - - -
- ); + onKeyDown={(event) => { + if (event.key === "Enter") { + event.preventDefault(); + submitPublishRepository(); } + }} + placeholder={publishPathPlaceholder} + disabled={publishRepositoryAction.isPending} + className="w-full bg-transparent px-3 py-2 font-mono text-sm placeholder:text-muted-foreground/60 focus:outline-none" + /> +
+
- return ( - - - +
+ + Visibility + + + setPublishVisibility(value as SourceControlRepositoryVisibility) + } + aria-labelledby="publish-visibility-cards-label" + disabled={publishRepositoryAction.isPending} + className="grid grid-cols-2 gap-2.5" + > + {[ + { + value: "private" as const, + label: "Private", + description: "Only invited people", + Icon: LockIcon, + }, + { + value: "public" as const, + label: "Public", + description: "Anyone on the web", + Icon: GlobeIcon, + }, + ].map((option) => { + const isSelected = publishVisibility === option.value; + return ( + + + + {option.label} - - ); - })} - -
+ + {option.description} + +
+
+ ); + })} + +
-
-
-
+ + + + {publishWizardStep === 2 ? ( + + ) : ( + <> + + {publishWizardStep < 1 ? ( + + ) : ( + + )} + + )} + +
); } diff --git a/apps/web/src/components/cloud/ConnectOnboardingDialog.tsx b/apps/web/src/components/cloud/ConnectOnboardingDialog.tsx index 656fb26bdcc7..b605ba3facd6 100644 --- a/apps/web/src/components/cloud/ConnectOnboardingDialog.tsx +++ b/apps/web/src/components/cloud/ConnectOnboardingDialog.tsx @@ -15,18 +15,10 @@ import { useEnvironments, usePrimaryEnvironment } from "~/state/environments"; import { CloudEnvironmentConnectRows } from "./CloudEnvironmentConnectList"; import { Button } from "../ui/button"; import { Checkbox } from "../ui/checkbox"; -import { - Dialog, - DialogDescription, - DialogFooter, - DialogHeader, - DialogPanel, - DialogPopup, - DialogTitle, -} from "../ui/dialog"; +import { Dialog } from "../ui/dialog"; import { Switch } from "../ui/switch"; import { toastManager } from "../ui/toast"; -import { WizardSteps } from "../ui/wizard-steps"; +import { WizardSteps, WizardPopup, WizardHeader, WizardPanel, WizardFooter } from "../ui/wizard"; /** * Post-sign-in onboarding wizard for T3 Connect. Opens on every in-session @@ -216,23 +208,29 @@ function ConfiguredConnectOnboardingDialog() { if (!open && !isApplying) complete(); }} > - - - Set up T3 Connect - - Mesh your devices together — publish this environment and connect the rest, all in one - place. - + + + Mesh your devices together — publish this environment and connect the rest, all in one + place. + + } + > {steps.length > 1 ? ( ({ id, label: STEP_LABELS[id] }))} - currentStep={step} - disabled={isApplying} - onStepSelect={setStep} + steps={steps.map((id) => STEP_LABELS[id])} + currentStep={steps.indexOf(step)} + isStepDisabled={() => isApplying} + onStepChange={(index) => { + const next = steps[index]; + if (next) setStep(next); + }} /> ) : null} - - + + {step === "publish" ? ( )} - - - -
- {step === "publish" ? ( - <> - - - - ) : ( - - )} -
-
-
+ + + ) : ( + + )} + + ); } diff --git a/apps/web/src/components/onboarding/WelcomeWizard.tsx b/apps/web/src/components/onboarding/WelcomeWizard.tsx index ca187b3475d1..5c6517e154a1 100644 --- a/apps/web/src/components/onboarding/WelcomeWizard.tsx +++ b/apps/web/src/components/onboarding/WelcomeWizard.tsx @@ -71,8 +71,8 @@ import { Input } from "../ui/input"; import { Tooltip, TooltipTrigger, TooltipPopup } from "../ui/tooltip"; import { ScrollArea } from "../ui/scroll-area"; import { Spinner } from "../ui/spinner"; -import { WizardPanel, WizardSteps } from "../ui/wizard"; -import { Dialog, DialogHeader, DialogPopup, DialogTitle } from "../ui/dialog"; +import { WizardPanel, WizardSteps, WizardPopup, WizardHeader } from "../ui/wizard"; +import { Dialog } from "../ui/dialog"; import { toastManager } from "../ui/toast"; import { cn } from "../../lib/utils"; import { formatRelativeTime } from "../../timestampFormat"; @@ -183,71 +183,71 @@ export function WelcomeWizard({ return ( event.cancel()}> - document.getElementById("onboarding-pairing-url") ?? true} > - Set up T3 Code -
- + Code
- isImporting || index >= stageIndex} - onStepChange={(index) => { - if (isImporting || index > stageIndex) return; - setStep(index === 0 ? "connection" : "agents"); + } + > + isImporting || index >= stageIndex} + onStepChange={(index) => { + if (isImporting || index > stageIndex) return; + setStep(index === 0 ? "connection" : "agents"); + }} + /> + + + + {step === "connection" ? ( + + setSelection((current) => { + const next = new Set(current ?? selectedIds); + if (checked) next.add(environmentId); + else next.delete(environmentId); + return next; + }) + } + onContinue={() => + startSetup( + environments + .filter((environment) => selectedIds.has(environment.environmentId)) + .map((environment) => environment.environmentId), + ) + } + onPaired={(environmentId) => { + setSelection(new Set([...selectedIds, environmentId])); }} /> - - - - {step === "connection" ? ( - - setSelection((current) => { - const next = new Set(current ?? selectedIds); - if (checked) next.add(environmentId); - else next.delete(environmentId); - return next; - }) - } - onContinue={() => - startSetup( - environments - .filter((environment) => selectedIds.has(environment.environmentId)) - .map((environment) => environment.environmentId), - ) - } - onPaired={(environmentId) => { - setSelection(new Set([...selectedIds, environmentId])); - }} - /> - ) : step === "agents" ? ( - setStep("import")} /> - ) : ( - - )} - - -
+ ) : step === "agents" ? ( + setStep("import")} /> + ) : ( + + )} + +
); } diff --git a/apps/web/src/components/settings/AddProviderInstanceDialog.tsx b/apps/web/src/components/settings/AddProviderInstanceDialog.tsx index d29d5fd04fe1..dc61f006fcab 100644 --- a/apps/web/src/components/settings/AddProviderInstanceDialog.tsx +++ b/apps/web/src/components/settings/AddProviderInstanceDialog.tsx @@ -15,21 +15,14 @@ import { cn } from "../../lib/utils"; import { normalizeProviderAccentColor } from "../../providerInstances"; import { Button } from "../ui/button"; import { ACPRegistryIcon, Gemini, GithubCopilotIcon, PiAgentIcon, type Icon } from "../Icons"; -import { - Dialog, - DialogDescription, - DialogFooter, - DialogHeader, - DialogPopup, - DialogTitle, -} from "../ui/dialog"; +import { Dialog } from "../ui/dialog"; import { Badge } from "../ui/badge"; import { Input } from "../ui/input"; import { RadioGroup } from "../ui/radio-group"; import { toastManager } from "../ui/toast"; import { DRIVER_OPTION_BY_VALUE, DRIVER_OPTIONS } from "./providerDriverMeta"; import { ProviderSettingsForm, deriveProviderSettingsFields } from "./ProviderSettingsForm"; -import { WizardPanel } from "../ui/wizard"; +import { WizardPanel, WizardPopup, WizardHeader, WizardFooter } from "../ui/wizard"; import { ADD_PROVIDER_WIZARD_STEPS, resolveWizardNavigation, @@ -230,206 +223,204 @@ export function AddProviderInstanceDialog({ return ( - -
- - Add provider instance - + + Configure an additional provider instance on {environmentLabel} — for example, a second Codex install pointed at a different workspace. - - - + + } + > + + - -
-
- Driver -
- setDriver(ProviderDriverKind.make(value))} - aria-labelledby="add-instance-driver-label" - className="grid grid-cols-1 gap-2 sm:grid-cols-2" - > - {DRIVER_OPTIONS.map((option) => { - const IconComponent = option.icon; - return ( - - - - {option.label} - - - - - {option.badgeLabel ? ( - - {option.badgeLabel} - - ) : null} - - ); - })} - {COMING_SOON_DRIVER_OPTIONS.map((option) => { - const IconComponent = option.icon; - return ( - +
+
+ Driver +
+ setDriver(ProviderDriverKind.make(value))} + aria-labelledby="add-instance-driver-label" + className="grid grid-cols-1 gap-2 sm:grid-cols-2" + > + {DRIVER_OPTIONS.map((option) => { + const IconComponent = option.icon; + return ( + + + + {option.label} + + - - - {option.label} - + + + {option.badgeLabel ? ( - Coming Soon + {option.badgeLabel} - - ); - })} - -
+ ) : null} +
+ ); + })} + {COMING_SOON_DRIVER_OPTIONS.map((option) => { + const IconComponent = option.icon; + return ( + + + + {option.label} + + + Coming Soon + + + ); + })} +
+
- -
); } diff --git a/apps/web/src/components/settings/SnapShotSetupDialog.tsx b/apps/web/src/components/settings/SnapShotSetupDialog.tsx index fdf980236395..3b4689f044b7 100644 --- a/apps/web/src/components/settings/SnapShotSetupDialog.tsx +++ b/apps/web/src/components/settings/SnapShotSetupDialog.tsx @@ -7,16 +7,8 @@ import { CircleCheckIcon } from "lucide-react"; import { useEffect, useId, useState, type ReactNode } from "react"; import { CaptureShortcutConfig } from "./CaptureShortcutConfig"; import { Button } from "../ui/button"; -import { - Dialog, - DialogDescription, - DialogFooter, - DialogHeader, - DialogPanel, - DialogPopup, - DialogTitle, -} from "../ui/dialog"; -import { WizardSteps } from "../ui/wizard-steps"; +import { Dialog, DialogDescription } from "../ui/dialog"; +import { WizardSteps, WizardPopup, WizardHeader, WizardPanel, WizardFooter } from "../ui/wizard"; import { captureSetupAccessReady, captureSetupBackend, @@ -335,21 +327,19 @@ export function SnapShotSetupDialog({ if (!open && !busy) void onClose(false); }} > - - - - {desktop ? `Set up snapshots for ${desktop}` : "Set up snapshots"} - + + ({ ...item, disabled: index > stepIndex }))} - currentStep={step} - disabled={busy} - onStepSelect={(next) => { - if (next !== step) changeStep(next); + steps={SETUP_STEPS.map((item) => item.label)} + currentStep={stepIndex} + isStepDisabled={(index) => busy || index > stepIndex} + onStepChange={(index) => { + const next = SETUP_STEPS[index]; + if (next && next.id !== step) changeStep(next.id); }} /> - - + +

{title}

@@ -479,8 +469,8 @@ export function SnapShotSetupDialog({ ) : null}
- - + + {step !== "access" ? ( ) : null} - - + + ); } diff --git a/apps/web/src/components/ui/wizard-steps.tsx b/apps/web/src/components/ui/wizard-steps.tsx deleted file mode 100644 index ccb639dd0fbc..000000000000 --- a/apps/web/src/components/ui/wizard-steps.tsx +++ /dev/null @@ -1,56 +0,0 @@ -import { CheckIcon } from "lucide-react"; -import { cn } from "~/lib/utils"; - -export function WizardSteps({ - steps, - currentStep, - disabled = false, - onStepSelect, -}: { - steps: readonly { id: Step; label: string; disabled?: boolean }[]; - currentStep: Step; - disabled?: boolean; - onStepSelect: (step: Step) => void; -}) { - const currentIndex = steps.findIndex((step) => step.id === currentStep); - return ( -
    - {steps.map((step, index) => ( -
  1. - -
  2. - ))} -
- ); -} diff --git a/apps/web/src/components/ui/wizard.tsx b/apps/web/src/components/ui/wizard.tsx index 8843e265e038..925b263f4530 100644 --- a/apps/web/src/components/ui/wizard.tsx +++ b/apps/web/src/components/ui/wizard.tsx @@ -1,19 +1,75 @@ import { CheckIcon } from "lucide-react"; -import type { ComponentProps } from "react"; +import type { ComponentProps, ReactNode } from "react"; import { cn } from "../../lib/utils"; import { AnimatedHeight } from "../AnimatedHeight"; +import { DialogPopup, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from "./dialog"; + +/** Compose a wizard from its header, panel, and footer; flow logic stays with the caller. */ +export function WizardPopup({ + children, + ...props +}: Omit, "className" | "style">) { + return ( + +
{children}
+
+ ); +} + +export function WizardHeader({ + title, + description, + identity, + children, +}: { + readonly title: ReactNode; + readonly description?: ReactNode; + /** Optional branding shown in place of the visible title. The title remains accessible. */ + readonly identity?: ReactNode; + readonly children?: ReactNode; +}) { + return ( + + {title} + {identity} + {description ? {description} : null} + {children} + + ); +} + +export function WizardFooter({ + children, + leading, +}: { + readonly children: ReactNode; + readonly leading?: ReactNode; +}) { + return ( + + {leading} + {leading ? ( +
{children}
+ ) : ( + children + )} +
+ ); +} export function WizardSteps({ steps, currentStep, summaries, + showSummaries = false, onStepChange, isStepDisabled, }: { readonly steps: readonly string[]; readonly currentStep: number; readonly summaries?: readonly (string | null)[]; + readonly showSummaries?: boolean; readonly isStepDisabled?: (step: number) => boolean; readonly onStepChange?: (step: number) => void; }) { @@ -61,6 +117,9 @@ export function WizardSteps({ )} > {step} + {showSummaries && index < currentStep && summaries?.[index] + ? `: ${summaries[index]}` + : null}
@@ -70,19 +129,16 @@ export function WizardSteps({ } export function WizardPanel({ - className, children, holdHeight = false, - ...props -}: ComponentProps<"div"> & { readonly holdHeight?: boolean }) { +}: { + readonly children: ReactNode; + readonly holdHeight?: boolean; +}) { return (
{children}