From ece0a3ae7301ee6d9451f4da131951df3d7711d7 Mon Sep 17 00:00:00 2001 From: Tanner Linsley Date: Thu, 18 Jun 2026 01:49:52 -0600 Subject: [PATCH] Refine builder partner prompt flow --- src/components/ApplicationStarter.tsx | 1317 +++++++++-------- .../ApplicationStarterHotkeys.client.tsx | 6 +- src/components/application-builder/parts.tsx | 55 +- src/components/application-builder/shared.ts | 32 +- .../useApplicationBuilder.tsx | 862 ++++------- src/components/builder/BuilderWorkspace.tsx | 53 +- src/components/builder/DeployDialog.tsx | 42 +- src/components/builder/useBuilderUrl.ts | 4 +- src/components/deploy/shared.ts | 29 +- .../home/HomeApplicationStarter.tsx | 2 +- src/components/landing/RouterLanding.tsx | 2 +- src/components/landing/StartLanding.tsx | 2 +- src/components/stack/CategoryArticle.tsx | 2 +- src/images/lovable-black.svg | 53 + src/images/lovable-white.svg | 53 + src/utils/application-starter.server.ts | 633 +------- src/utils/application-starter.ts | 26 +- src/utils/partner-pages.ts | 6 + src/utils/partners.tsx | 376 ++++- 19 files changed, 1593 insertions(+), 1962 deletions(-) create mode 100644 src/images/lovable-black.svg create mode 100644 src/images/lovable-white.svg diff --git a/src/components/ApplicationStarter.tsx b/src/components/ApplicationStarter.tsx index d675bfbb3..b2a4a6c82 100644 --- a/src/components/ApplicationStarter.tsx +++ b/src/components/ApplicationStarter.tsx @@ -1,24 +1,19 @@ import * as React from 'react' import { ClientOnly } from '@tanstack/react-router' import { + ArrowRight, + Check, ChevronDown, Copy, Download, Loader2, Rocket, - Sparkles, - Wand2, } from 'lucide-react' import { twMerge } from 'tailwind-merge' import anthropicDarkLogo from '~/images/anthropic-dark.svg' import anthropicLightLogo from '~/images/anthropic-light.svg' -import cloudflareBlackLogo from '~/images/cloudflare-black.svg' -import cloudflareWhiteLogo from '~/images/cloudflare-white.svg' -import netlifyDarkLogo from '~/images/netlify-dark.svg' -import netlifyLightLogo from '~/images/netlify-light.svg' import openaiDarkLogo from '~/images/openai-dark.svg' import openaiLightLogo from '~/images/openai-light.svg' -import railwayBlackLogo from '~/images/railway-black.svg' import type { ApplicationStarterContext, ApplicationStarterResult, @@ -37,20 +32,16 @@ import { StarterTooltipProvider, } from '~/components/application-builder/parts' import { + buildStarterPromptDeployUrl, toneClasses, type ApplicationStarterBuilderIntegration, + type StarterPromptDeployProvider, type StarterTone, } from '~/components/application-builder/shared' import { useApplicationBuilder } from '~/components/application-builder/useApplicationBuilder' -import { - deploymentProviderIds, - type DeploymentProviderId, - useDeploymentProviderPlacement, -} from '~/utils/useDeploymentProviderPlacement' import { Button, GitHub } from '~/ui' export interface ApplicationStarterProps { - alwaysShowPostAnalysisSection?: boolean builderIntegration?: ApplicationStarterBuilderIntegration className?: string context: ApplicationStarterContext @@ -63,7 +54,7 @@ export interface ApplicationStarterProps { onDirtyStateChange?: (dirty: boolean) => void onResolvedResult?: (result: ApplicationStarterResult | null) => void primaryActionLabel?: string - primaryButtonColor?: 'cyan' | 'emerald' | 'purple' | 'yellow' + revealOptionsImmediately?: boolean secondaryActionLabel?: string showCliExportActions?: boolean showPromptPreview?: boolean @@ -88,8 +79,51 @@ const LazyDeployDialog = React.lazy(() => const starterPackageManagers = ['pnpm', 'npm', 'yarn', 'bun'] as const const starterToolchains = ['biome', 'eslint'] as const +type HostingDeployPartnerId = 'cloudflare' | 'lovable' | 'netlify' | 'railway' +type StarterTransientAction = + | 'claude' + | 'clone' + | 'codex' + | 'cursor' + | 'deploy' + | 'download' + | 'netlify' + +const hostingDeployPartnerLabels: Record = { + cloudflare: 'Cloudflare', + lovable: 'Lovable', + netlify: 'Netlify', + railway: 'Railway', +} + +function getHostingDeployPartnerId( + partnerId: string, +): HostingDeployPartnerId | undefined { + switch (partnerId) { + case 'cloudflare': + case 'lovable': + case 'netlify': + case 'railway': + return partnerId + default: + return undefined + } +} + +function getPromptDeployProvider( + partnerId: HostingDeployPartnerId, +): StarterPromptDeployProvider | undefined { + switch (partnerId) { + case 'lovable': + case 'netlify': + return partnerId + case 'cloudflare': + case 'railway': + return undefined + } +} + export function ApplicationStarter({ - alwaysShowPostAnalysisSection = false, builderIntegration, className, context, @@ -101,8 +135,8 @@ export function ApplicationStarter({ mode = 'full', onDirtyStateChange, onResolvedResult, - primaryActionLabel = 'Generate Prompt', - primaryButtonColor, + primaryActionLabel = 'Copy Prompt', + revealOptionsImmediately = false, secondaryActionLabel = 'Build with Netlify', showCliExportActions = true, showPromptPreview = true, @@ -112,29 +146,22 @@ export function ApplicationStarter({ tone = 'cyan', }: ApplicationStarterProps) { const { - analysis, - anonymousGenerationQuota, copiedKind, copyResultValue, dismissPromptCopyNotice, deployDialogProvider, - enableLuckyActions, generatePrompt, hasGeneratedPrompt, - hasFreshAnalysis, + hasRevealedOptions, hasInput, hasMigrationRepositoryUrlError, input, isDeployDialogOpen, - isAnalysisStale, - isAnalyzing, isGenerating, isGeneratingNetlify, isGeneratingPrompt, - isLocked, isModHeld, loadingPhrase, - lockMessage, migrationRepositoryInputRef, migrationRepositoryUrl, navigateToResult, @@ -142,7 +169,7 @@ export function ApplicationStarter({ openCursorStart, openCodexStart, openDeployDialog, - openLogin, + openLovableStart, openNetlifyStart, partnerSuggestions, promptCopyNotice, @@ -154,10 +181,8 @@ export function ApplicationStarter({ selectedToolchain, setIsDeployDialogOpen, setIsModHeld, - setSessionMode, showMigrationRepositoryInput, trackActivation, - showLuckyActions, submitCurrentInput, suggestions, toggleLibrary, @@ -173,139 +198,120 @@ export function ApplicationStarter({ mode, onDirtyStateChange, onResolvedResult, + revealOptionsImmediately, suggestionContext, }) - const orderedDeploymentProviders = useDeploymentProviderPlacement({ - availableProviders: deploymentProviderIds, - surface: `application_starter_deploy_actions:${context}`, - }) const palette = toneClasses[tone] const compact = mode === 'compact' - const buttonColor = primaryButtonColor ?? palette.button const [showMoreActions, setShowMoreActions] = React.useState(false) + const [pendingHostingDeployPartner, setPendingHostingDeployPartner] = + React.useState(null) + const [transientAction, setTransientAction] = + React.useState(null) const [hasFocusedPromptInput, setHasFocusedPromptInput] = React.useState(false) const [isPromptFocused, setIsPromptFocused] = React.useState(false) const [isMacShortcutPlatform, setIsMacShortcutPlatform] = React.useState(false) - const [showConfidentOptions, setShowConfidentOptions] = React.useState(false) const [showPackageManagerOptions, setShowPackageManagerOptions] = React.useState(false) - const [showToolchainOptions, setShowToolchainOptions] = React.useState( - alwaysShowPostAnalysisSection, - ) - const canContinue = + const [showToolchainOptions, setShowToolchainOptions] = React.useState(false) + const canRevealOptions = hasInput && !hasMigrationRepositoryUrlError && !isGenerating - const canUseLuckyAction = - hasInput && - !hasMigrationRepositoryUrlError && - !isGenerating && - (!showLuckyActions || isAnalysisStale) - const canUseConfidentAction = - alwaysShowPostAnalysisSection && - hasInput && - !hasMigrationRepositoryUrlError && - !isGenerating && - !hasFreshAnalysis && - !hasGeneratedPrompt && - !showConfidentOptions const canUseFinalActions = - (hasFreshAnalysis || showLuckyActions || showConfidentOptions) && + hasRevealedOptions && hasInput && !hasMigrationRepositoryUrlError && !isGenerating - const renderDeploymentProviderButton = (provider: DeploymentProviderId) => { - switch (provider) { - case 'cloudflare': - return ( - - ) - case 'netlify': - return ( - - ) - case 'railway': - return ( - - ) + const transientActionTimerRef = React.useRef | null>(null) + const showTransientActionFeedback = React.useCallback( + (action: StarterTransientAction) => { + if (transientActionTimerRef.current) { + clearTimeout(transientActionTimerRef.current) + } + + setTransientAction(action) + transientActionTimerRef.current = setTimeout(() => { + setTransientAction((current) => (current === action ? null : current)) + transientActionTimerRef.current = null + }, 1800) + }, + [], + ) + const selectedHostingDeployPartner = React.useMemo( + () => + selectedPartners.flatMap((partnerId) => { + const hostingPartnerId = getHostingDeployPartnerId(partnerId) + + return hostingPartnerId ? [hostingPartnerId] : [] + })[0], + [selectedPartners], + ) + const isSelectedHostingDeployPending = + pendingHostingDeployPartner !== null && + pendingHostingDeployPartner === selectedHostingDeployPartner + const isDeployFeedbackActive = + isSelectedHostingDeployPending || transientAction === 'deploy' + const isPromptCopied = copiedKind === 'prompt' + const isCommandCopied = copiedKind === 'command' + const selectedPromptDeployProvider = selectedHostingDeployPartner + ? getPromptDeployProvider(selectedHostingDeployPartner) + : undefined + const selectedHostingDeployHref = React.useMemo( + () => + selectedPromptDeployProvider && result?.prompt + ? buildStarterPromptDeployUrl( + selectedPromptDeployProvider, + result.prompt, + ) + : undefined, + [result?.prompt, selectedPromptDeployProvider], + ) + const trackSelectedHostingDeployLink = React.useCallback(() => { + if (!selectedHostingDeployPartner) { + return + } + + trackActivation({ + action: + selectedHostingDeployPartner === 'netlify' ? 'netlify_start' : 'deploy', + surface: 'result_panel', + provider: selectedHostingDeployPartner, + }) + }, [selectedHostingDeployPartner, trackActivation]) + const deployToSelectedHostingPartner = async () => { + if (!selectedHostingDeployPartner) { + return + } + + setPendingHostingDeployPartner(selectedHostingDeployPartner) + + try { + switch (selectedHostingDeployPartner) { + case 'cloudflare': + await openDeployDialog('cloudflare') + break + case 'lovable': + await openLovableStart() + break + case 'netlify': + await openNetlifyStart() + break + case 'railway': + await openDeployDialog('railway') + break + } + } finally { + setPendingHostingDeployPartner(null) } } - const renderGeneratePromptButton = () => ( + const renderCopyPromptButton = () => ( + ) + const renderCopyCliCommandButton = () => ( + ) - const showPostAnalysisSection = - alwaysShowPostAnalysisSection || hasFreshAnalysis || hasGeneratedPrompt - const showActionSection = - alwaysShowPostAnalysisSection || hasFreshAnalysis || showLuckyActions - const postAnalysisSectionDisabled = - !hasFreshAnalysis && !hasGeneratedPrompt && !showConfidentOptions - const actionSectionDisabled = - alwaysShowPostAnalysisSection && - !hasFreshAnalysis && - !showLuckyActions && - !showConfidentOptions - const analysisMessage = isAnalysisStale - ? 'Prompt changed. Analyze again to refresh recommendations.' - : analysis - ? 'Review or adjust the selected chips below, then generate the final prompt.' - : null + const renderSelectedHostingDeployButton = () => { + if (!selectedHostingDeployPartner) { + return null + } + + if (selectedHostingDeployHref) { + const disabled = !canUseFinalActions || transientAction === 'deploy' + + return ( + + ) + } + + return ( + + ) + } + const showOptionsSection = hasRevealedOptions || hasGeneratedPrompt + const showActionSection = hasRevealedOptions || hasGeneratedPrompt + + React.useEffect(() => { + return () => { + if (transientActionTimerRef.current) { + clearTimeout(transientActionTimerRef.current) + } + } + }, []) React.useEffect(() => { if (typeof navigator === 'undefined') { @@ -350,8 +447,12 @@ export function ApplicationStarter({ { - void submitCurrentInput() + onSubmit={() => { + if (showActionSection) { + void generatePrompt() + } else { + void submitCurrentInput() + } }} onModKeyChange={setIsModHeld} promptFocused={isPromptFocused} @@ -471,160 +572,109 @@ export function ApplicationStarter({ /> -
- - {analysisMessage ? ( -
- {analysisMessage} -
- ) : null} -
+ {!showOptionsSection ? ( +
+ +
+ ) : null} - + - -
-
-
- TanStack Libraries -
- -
- -
-
+ {showOptionsSection ? ( + +
+
+
+ TanStack Libraries +
-
- Partner Integrations -
- - -
- {starterToolchains.map((toolchain) => ( - + { - toggleToolchain(toolchain) - }} - palette={palette} - selected={selectedToolchain === toolchain} - > - {toolchain} - - ))} + selectedLibraries={selectedLibraries} + toggleLibrary={toggleLibrary} + /> +
- - -
- {starterPackageManagers.map((packageManager) => ( - { - togglePackageManager(packageManager) - }} - palette={palette} - selected={ - selectedPackageManager === packageManager - } - > - {packageManager} - - ))} + +
+ Partner Integrations
- - -
- + + +
+ {starterToolchains.map((toolchain) => ( + { + toggleToolchain(toolchain) + }} + palette={palette} + selected={selectedToolchain === toolchain} + > + {toolchain} + + ))} +
+
+ +
+ {starterPackageManagers.map((packageManager) => ( + { + togglePackageManager(packageManager) + }} + palette={palette} + selected={ + selectedPackageManager === packageManager + } + > + {packageManager} + + ))} +
+
+
+
+ ) : null} ) : (
- {isLocked ? ( -
-
-
- Sign in to unlock more generations -
-
- {lockMessage || - 'Anonymous generations are limited. Sign in to keep going.'} -
-
- -
-
-
- ) : null} - -
+

@@ -697,7 +747,11 @@ export function ApplicationStarter({ onClick={(event) => { if (enableHotkeys && isModHeld) { event.preventDefault() - void generatePrompt() + if (showActionSection) { + void generatePrompt() + } else { + void submitCurrentInput() + } } }} rows={4} @@ -708,366 +762,358 @@ export function ApplicationStarter({ )} /> -
-
- - {!showActionSection ? ( + {!showActionSection ? ( +
+
- ) : null} - {canUseConfidentAction ? ( - - ) : null} - {analysisMessage ? ( -
- {analysisMessage} -
- ) : null} +
-
+ ) : null}
- + -
- -
-
-
- TanStack Libraries -
+ {showOptionsSection ? ( +
+ +
+
+
+ TanStack Libraries +
-
- +
+ +
-
-
- Add Integrations -
-
- -
- -
- {starterToolchains.map((toolchain) => ( - { - toggleToolchain(toolchain) - }} - palette={palette} - selected={selectedToolchain === toolchain} - size="compact" - > - {toolchain} - - ))} +
+ Add Integrations
- - -
- {starterPackageManagers.map((packageManager) => ( - { - togglePackageManager(packageManager) - }} - palette={palette} - selected={ - selectedPackageManager === packageManager - } - size="compact" - > - {packageManager} - - ))} +
+
- -
- - - + +
+ {starterToolchains.map((toolchain) => ( + { + toggleToolchain(toolchain) + }} + palette={palette} + selected={selectedToolchain === toolchain} + size="compact" + > + {toolchain} + + ))} +
+
+ +
+ {starterPackageManagers.map( + (packageManager) => ( + { + togglePackageManager(packageManager) + }} + palette={palette} + selected={ + selectedPackageManager === + packageManager + } + size="compact" + > + {packageManager} + + ), + )} +
+
+
+ - {footerContent ? ( -
{footerContent}
- ) : null} -
+ {footerContent ? ( +
{footerContent}
+ ) : null} +
+ ) : null} -
-
-
- {showCliExportActions ? ( -
-
- Deploy to -
-
- {orderedDeploymentProviders.map((provider) => - renderDeploymentProviderButton(provider), - )} -
- - {!showMoreActions ? ( + {showActionSection ? ( +
+
+ {!showCliExportActions ? ( +
+ {!selectedHostingDeployPartner ? ( ) : null} -
- ) : ( - <> + +
+ ) : null} +
+ {renderSelectedHostingDeployButton()} + {renderCopyPromptButton()} + {showCliExportActions + ? renderCopyCliCommandButton() + : null} +
+ + {showCliExportActions && !showMoreActions ? ( +
- - )} -
- - {showCliExportActions && showMoreActions ? ( -
- - - +
+ ) : null} - + {showCliExportActions && showMoreActions ? ( +
+ - + - + - -
- ) : null} + -
- {renderGeneratePromptButton()} + +
+ ) : null}
-
+ ) : null} @@ -1103,7 +1149,7 @@ function CursorIcon({ className }: { className?: string }) { ) } -function AnalyzeShortcutHint({ isMac }: { isMac: boolean }) { +function SubmitShortcutHint({ isMac }: { isMac: boolean }) { return ( @@ -1146,37 +1192,8 @@ function StarterCustomizationSection({ )} /> - {children} + {open ? children : null}
) } - -function AnonymousGenerationLimitNotice({ - quota, -}: { - quota: { - limit: number - remaining: number - resetAt: string - } | null -}) { - if (!quota) { - return null - } - - if (quota.limit >= 1_000_000) { - return null - } - - return ( -
- {quota.remaining > 0 - ? `${quota.remaining} anonymous generation${quota.remaining === 1 ? '' : 's'} left today.` - : 'No anonymous generations left today.'}{' '} - - Sign in to remove the limit. - -
- ) -} diff --git a/src/components/ApplicationStarterHotkeys.client.tsx b/src/components/ApplicationStarterHotkeys.client.tsx index 22a3000a5..4bcf5f923 100644 --- a/src/components/ApplicationStarterHotkeys.client.tsx +++ b/src/components/ApplicationStarterHotkeys.client.tsx @@ -2,13 +2,13 @@ import * as React from 'react' import { useHeldKeys, useHotkey } from '@tanstack/react-hotkeys' interface ApplicationStarterHotkeysProps { - onAnalyze: () => void + onSubmit: () => void onModKeyChange: (isHeld: boolean) => void promptFocused: boolean } export function ApplicationStarterHotkeys({ - onAnalyze, + onSubmit, onModKeyChange, promptFocused, }: ApplicationStarterHotkeysProps) { @@ -20,7 +20,7 @@ export function ApplicationStarterHotkeys({ return } - onAnalyze() + onSubmit() }) React.useEffect(() => { diff --git a/src/components/application-builder/parts.tsx b/src/components/application-builder/parts.tsx index e1d3c5551..6dba557b2 100644 --- a/src/components/application-builder/parts.tsx +++ b/src/components/application-builder/parts.tsx @@ -3,6 +3,7 @@ import * as TooltipPrimitive from '@radix-ui/react-tooltip' import { Check, Copy, Sparkles, X } from 'lucide-react' import { twMerge } from 'tailwind-merge' import { + getApplicationStarterConflictingPartnerIds, type ApplicationStarterPartnerSuggestion, PartnerImage, } from '~/utils/partners' @@ -196,12 +197,14 @@ const StarterPartnerButton = React.forwardRef< compact?: boolean palette: StarterPalette partner: ApplicationStarterPartnerSuggestion + muted: boolean selected: boolean size?: 'compact' | 'default' } & React.ComponentPropsWithoutRef<'button'> >(function StarterPartnerButton( { compact = false, + muted, palette, partner, selected, @@ -240,20 +243,24 @@ const StarterPartnerButton = React.forwardRef< const tierOneTone = isTierOne ? selected ? 'translate-y-[-1px] border-transparent' - : 'border-gray-200 bg-white hover:border-[var(--starter-partner-hover-border-color)] dark:border-gray-800 dark:bg-gray-950 dark:hover:border-[var(--starter-partner-hover-border-color)]' + : 'border-gray-200 bg-white hover:border-[var(--starter-partner-hover-border-color)] active:border-[var(--starter-partner-active-border-color)] dark:border-gray-800 dark:bg-gray-950 dark:hover:border-[var(--starter-partner-hover-border-color)] dark:active:border-[var(--starter-partner-active-border-color)]' : null const tierThreeTone = isTierThree ? selected ? 'translate-y-[-1px] border-current bg-white shadow-[0_4px_12px_rgba(15,23,42,0.08)] dark:bg-gray-950' - : 'border-gray-200 bg-white text-gray-700 hover:border-[var(--starter-partner-hover-border-color)] hover:text-current dark:border-gray-800 dark:bg-gray-950 dark:text-gray-200 dark:hover:border-[var(--starter-partner-hover-border-color)]' + : 'border-gray-200 bg-white text-gray-700 hover:border-[var(--starter-partner-hover-border-color)] hover:text-current active:border-[var(--starter-partner-active-border-color)] dark:border-gray-800 dark:bg-gray-950 dark:text-gray-200 dark:hover:border-[var(--starter-partner-hover-border-color)] dark:active:border-[var(--starter-partner-active-border-color)]' : null + const hoverBorderColor = colorWithAlpha(accent, 0.5) ?? accent const style: StarterPartnerButtonStyle = { - '--starter-partner-border-hover': usesPaletteSurface ? accent : undefined, - '--starter-partner-hover-border-color': accent, + '--starter-partner-active-border-color': accent, + '--starter-partner-border-hover': usesPaletteSurface + ? hoverBorderColor + : undefined, + '--starter-partner-hover-border-color': hoverBorderColor, backgroundColor: undefined, borderColor: isTierOne && selected - ? colorWithAlpha(accent, 0.92) + ? accent : selected && usesPaletteSurface ? accent : undefined, @@ -273,7 +280,11 @@ const StarterPartnerButton = React.forwardRef< ref={ref} type="button" aria-pressed={selected} - aria-label={accessibleLabel} + aria-label={ + muted + ? `${accessibleLabel}, inactive while another exclusive partner is selected` + : accessibleLabel + } className={twMerge( 'inline-flex items-center text-gray-800 transition-all duration-200 dark:text-gray-100', 'border-2', @@ -283,8 +294,10 @@ const StarterPartnerButton = React.forwardRef< tierOneTone, usesPaletteSurface && palette.chip, usesPaletteSurface && - 'hover:border-[var(--starter-partner-border-hover)]', + 'hover:border-[var(--starter-partner-border-hover)] active:border-[var(--starter-partner-active-border-color)]', tierThreeTone, + muted && + 'border-transparent opacity-60 saturate-50 grayscale hover:border-transparent hover:text-gray-700 active:border-transparent dark:border-transparent dark:hover:border-transparent dark:hover:text-gray-200 dark:active:border-transparent', buttonProps.className, )} style={style} @@ -353,6 +366,32 @@ export function StarterPartnerRows({ selected: boolean, ) => void }) { + const mutedPartnerIds = React.useMemo(() => { + const partnerIds = new Set() + + for (const selectedPartnerId of selectedPartners) { + const selectedPartner = partnerSuggestions.find( + (partner) => partner.id === selectedPartnerId, + ) + + if (!selectedPartner) { + continue + } + + for (const partnerId of getApplicationStarterConflictingPartnerIds( + selectedPartner, + partnerSuggestions, + )) { + partnerIds.add(partnerId) + } + } + + for (const selectedPartnerId of selectedPartners) { + partnerIds.delete(selectedPartnerId) + } + + return partnerIds + }, [partnerSuggestions, selectedPartners]) const rows = ([1, 2, 3] as const) .map((tier) => ({ tier, @@ -366,6 +405,7 @@ export function StarterPartnerRows({
{row.partners.map((partner) => { const selected = selectedPartners.includes(partner.id) + const muted = mutedPartnerIds.has(partner.id) return ( togglePartner(partner, selected)} + muted={muted} palette={palette} partner={partner} selected={selected} diff --git a/src/components/application-builder/shared.ts b/src/components/application-builder/shared.ts index bece2adfc..d23386c2d 100644 --- a/src/components/application-builder/shared.ts +++ b/src/components/application-builder/shared.ts @@ -10,6 +10,7 @@ import { export type StarterTone = 'cyan' | 'emerald' | 'violet' export type StarterDeployProvider = 'cloudflare' | 'netlify' | 'railway' +export type StarterPromptDeployProvider = 'lovable' | 'netlify' export type StarterPackageManager = 'bun' | 'npm' | 'pnpm' | 'yarn' export type StarterToolchain = 'biome' | 'eslint' @@ -21,7 +22,10 @@ export interface StarterPalette { } export interface ApplicationStarterBuilderIntegration { - applyResult: (result: ApplicationStarterResult) => Promise + applyResult: ( + result: ApplicationStarterResult, + options?: { silent?: boolean }, + ) => Promise } export interface ApplicationStarterAnonymousQuota { @@ -45,6 +49,7 @@ export interface StarterTryLibrary { } export type StarterPartnerButtonStyle = CSSProperties & { + '--starter-partner-active-border-color'?: string '--starter-partner-border-hover'?: string '--starter-partner-hover-border-color'?: string } @@ -138,6 +143,31 @@ export const starterLoadingPhrases = [ 'Finding calmer waters...', ] +export function buildStarterPromptDeployUrl( + provider: StarterPromptDeployProvider, + prompt: string, +) { + switch (provider) { + case 'lovable': { + const url = new URL('https://lovable.dev/') + + url.searchParams.set('autosubmit', 'true') + url.searchParams.set('utm_source', 'tanstack') + url.hash = `prompt=${encodeURIComponent(prompt)}` + + return url.toString() + } + case 'netlify': { + const url = new URL('https://app.netlify.com/start') + + url.searchParams.set('prompt', prompt) + url.searchParams.set('utm_source', 'tanstack') + + return url.toString() + } + } +} + export function isPinnedStarterLibrary(libraryId: LibraryId) { return starterPinnedLibraryIds.some( (pinnedLibraryId) => pinnedLibraryId === libraryId, diff --git a/src/components/application-builder/useApplicationBuilder.tsx b/src/components/application-builder/useApplicationBuilder.tsx index 337ab3b10..c68e41dde 100644 --- a/src/components/application-builder/useApplicationBuilder.tsx +++ b/src/components/application-builder/useApplicationBuilder.tsx @@ -1,41 +1,37 @@ import * as React from 'react' -import { useMutation } from '@tanstack/react-query' -import { useCurrentUser } from '~/hooks/useCurrentUser' -import { useLoginModal } from '~/contexts/LoginModalContext' +import { useDebouncedValue } from '@tanstack/react-pacer' import { useToast } from '~/components/ToastProvider' import { trackEvent, defaultBuilderSessionContext, type BuilderAction, - type BuilderMode, type BuilderSessionContext, } from '~/utils/analytics' import { extractMigrationRepositoryUrl, - type ApplicationStarterAnalysis, getApplicationStarterSuggestions, + resolveApplicationStarterDeterministically, type ApplicationStarterContext, - type ApplicationStarterRequest, type ApplicationStarterResult, } from '~/utils/application-starter' import { + getApplicationStarterCompatiblePartnerIds, + getApplicationStarterConflictingPartnerIds, getApplicationStarterInferredPartnerIds, getApplicationStarterPartnerSuggestions, getApplicationStarterSelectedPartnerIds, + getApplicationStarterVisiblePartnerSuggestions, type ApplicationStarterPartnerSuggestion, } from '~/utils/partners' +import { usePartnerPlacementContext } from '~/utils/usePartnerPlacementContext' import type { LibraryId } from '~/libraries' import { - analyzeApplicationStarter, - type ApplicationStarterAnonymousQuota, - ApplicationStarterError, + buildStarterPromptDeployUrl, composeStarterInput, - isApplicationStarterStatusResponse, isNextJsMigrationInput, isPinnedStarterLibrary, isValidMigrationRepositoryUrl, normalizeMigrationRepositoryUrl, - resolveApplicationStarter, type StarterPackageManager, starterAddonLibraryIds, starterLoadingPhrases, @@ -52,71 +48,39 @@ interface UseApplicationBuilderOptions { mode: 'compact' | 'full' onDirtyStateChange?: (dirty: boolean) => void onResolvedResult?: (result: ApplicationStarterResult | null) => void + revealOptionsImmediately?: boolean suggestionContext?: ApplicationStarterContext } type CopyTrigger = 'automatic' | 'user' -const starterFeatureLibraryMap: Record = { - ai: 'ai', - form: 'form', - hotkeys: 'hotkeys', - pacer: 'pacer', - store: 'store', - table: 'table', - 'tanstack-query': 'query', - virtual: 'virtual', -} - -function getGeneratedLibraryIds({ - featureIds, - promptText, -}: { - featureIds: Array - promptText: string -}) { - const libraryIds = Array() - - for (const featureId of featureIds) { - const libraryId = starterFeatureLibraryMap[featureId] - - if (!libraryId || libraryIds.includes(libraryId)) { - continue - } - - libraryIds.push(libraryId) - } - - if (promptText.includes('tanstack db') && !libraryIds.includes('db')) { - libraryIds.push('db') - } - - return libraryIds -} - export function useApplicationBuilder({ builderIntegration, context, forceRouterOnly = false, - mode, onDirtyStateChange, onResolvedResult, + revealOptionsImmediately = false, suggestionContext = context, }: UseApplicationBuilderOptions) { const { notify } = useToast() - const currentUser = useCurrentUser() - const { openLoginModal } = useLoginModal() const suggestions = getApplicationStarterSuggestions(suggestionContext) - const partnerSuggestions = getApplicationStarterPartnerSuggestions() + const partnerPlacementContext = usePartnerPlacementContext({ + orderStrategy: 'tier-rotated', + surface: 'application_starter_suggestions', + }) + const partnerSuggestions = React.useMemo( + () => getApplicationStarterPartnerSuggestions(partnerPlacementContext), + [partnerPlacementContext], + ) const [input, setInput] = React.useState(() => suggestions[0]?.input ?? '') - const [analysis, setAnalysis] = - React.useState(null) - const [isAnalysisStale, setIsAnalysisStale] = React.useState(false) + const [hasRevealedOptions, setHasRevealedOptions] = React.useState( + revealOptionsImmediately, + ) const [result, setResult] = React.useState( null, ) const [copiedKind, setCopiedKind] = React.useState(null) - const [showLuckyActions, setShowLuckyActions] = React.useState(false) const [showPromptCopyNotice, setShowPromptCopyNotice] = React.useState(false) const [loadingPhrase, setLoadingPhrase] = React.useState( starterLoadingPhrases[0]!, @@ -126,14 +90,16 @@ export function useApplicationBuilder({ const [isDeployDialogOpen, setIsDeployDialogOpen] = React.useState(false) const [isDirtySinceLastResult, setIsDirtySinceLastResult] = React.useState(false) + const [isRebuildingResult, setIsRebuildingResult] = React.useState(false) const [isModHeld, setIsModHeld] = React.useState(false) - const [isLocked, setIsLocked] = React.useState(false) - const [lockMessage, setLockMessage] = React.useState(null) - const [anonymousGenerationQuota, setAnonymousGenerationQuota] = - React.useState(null) const [migrationRepositoryUrl, setMigrationRepositoryUrl] = React.useState( () => extractMigrationRepositoryUrl(suggestions[0]?.input ?? '') ?? '', ) + const [debouncedInput] = useDebouncedValue(input, { wait: 300 }) + const [debouncedMigrationRepositoryUrl] = useDebouncedValue( + migrationRepositoryUrl, + { wait: 300 }, + ) const [explicitLibrarySelections, setExplicitLibrarySelections] = React.useState>>({}) const [explicitPartnerSelections, setExplicitPartnerSelections] = @@ -145,25 +111,10 @@ export function useApplicationBuilder({ StarterToolchain | undefined >(undefined) const latestRequestIdRef = React.useRef(0) - const generationCountRef = React.useRef(0) + const hasUserEditedStarterRef = React.useRef(false) const migrationRepositoryInputRef = React.useRef( null, ) - const analyzedPartnerIds = React.useMemo( - () => analysis?.inferredPartnerIds ?? [], - [analysis], - ) - const analyzedLibraryIds = React.useMemo( - () => analysis?.inferredLibraryIds ?? [], - [analysis], - ) - const effectiveInferredPartners = React.useMemo( - () => - analyzedPartnerIds.filter( - (partnerId) => explicitPartnerSelections[partnerId] === undefined, - ), - [analyzedPartnerIds, explicitPartnerSelections], - ) const explicitlySelectedPartners = React.useMemo( () => partnerSuggestions.flatMap((partner) => @@ -172,17 +123,20 @@ export function useApplicationBuilder({ [explicitPartnerSelections, partnerSuggestions], ) const selectedPartners = React.useMemo( - () => [ - ...new Set([...explicitlySelectedPartners, ...effectiveInferredPartners]), - ], - [effectiveInferredPartners, explicitlySelectedPartners], + () => + getApplicationStarterCompatiblePartnerIds( + explicitlySelectedPartners, + partnerSuggestions, + ), + [explicitlySelectedPartners, partnerSuggestions], ) - const effectiveInferredLibraries = React.useMemo( + const visiblePartnerSuggestions = React.useMemo( () => - analyzedLibraryIds.filter( - (libraryId) => explicitLibrarySelections[libraryId] === undefined, + getApplicationStarterVisiblePartnerSuggestions( + partnerSuggestions, + selectedPartners, ), - [analyzedLibraryIds, explicitLibrarySelections], + [partnerSuggestions, selectedPartners], ) const explicitlySelectedLibraries = React.useMemo( () => @@ -191,15 +145,9 @@ export function useApplicationBuilder({ ), [explicitLibrarySelections], ) - const selectedLibraries = React.useMemo( - () => [ - ...starterPinnedLibraryIds, - ...new Set([ - ...explicitlySelectedLibraries, - ...effectiveInferredLibraries, - ]), - ], - [effectiveInferredLibraries, explicitlySelectedLibraries], + const selectedLibraries = React.useMemo>( + () => [...starterPinnedLibraryIds, ...explicitlySelectedLibraries], + [explicitlySelectedLibraries], ) // Session context (mode_used, idea_used) is stamped on every builder // event so any breakdown works without joining sessions in BigQuery. @@ -208,13 +156,6 @@ export function useApplicationBuilder({ defaultBuilderSessionContext, ) - const setSessionMode = React.useCallback((nextMode: BuilderMode) => { - sessionContextRef.current = { - ...sessionContextRef.current, - mode_used: nextMode, - } - }, []) - const setSessionIdea = React.useCallback((label: string) => { sessionContextRef.current = { ...sessionContextRef.current, @@ -222,25 +163,38 @@ export function useApplicationBuilder({ } }, []) - const invalidateResult = React.useCallback(() => { - latestRequestIdRef.current += 1 + const invalidateResult = React.useCallback( + (options?: { clearResult?: boolean }) => { + latestRequestIdRef.current += 1 - if (result) { - setIsDirtySinceLastResult(true) - } + if (result) { + setIsDirtySinceLastResult(true) + } - setResult(null) - setShowPromptCopyNotice(false) - onResolvedResult?.(null) - }, [onResolvedResult, result]) + if (options?.clearResult ?? true) { + setResult(null) + onResolvedResult?.(null) + } + + setShowPromptCopyNotice(false) + }, + [onResolvedResult, result], + ) - const markAnalysisStale = React.useCallback(() => { - invalidateResult() + const markUserEditedStarter = React.useCallback(() => { + hasUserEditedStarterRef.current = true + }, []) - if (analysis) { - setIsAnalysisStale(true) + const markInputDirty = React.useCallback(() => { + markUserEditedStarter() + invalidateResult({ clearResult: false }) + }, [invalidateResult, markUserEditedStarter]) + + React.useEffect(() => { + if (revealOptionsImmediately) { + setHasRevealedOptions(true) } - }, [analysis, invalidateResult]) + }, [revealOptionsImmediately]) React.useEffect(() => { onDirtyStateChange?.(isDirtySinceLastResult) @@ -272,7 +226,7 @@ export function useApplicationBuilder({ const buildSubmittedInput = React.useCallback( ( nextSelectedPartners: Array = explicitlySelectedPartners, - nextInferredPartners: Array = effectiveInferredPartners, + nextInferredPartners: Array = [], nextSelectedLibraries: Array = selectedLibraries, ) => composeStarterInput({ @@ -286,7 +240,6 @@ export function useApplicationBuilder({ toolchain: selectedToolchain, }), [ - effectiveInferredPartners, explicitlySelectedPartners, forceRouterOnly, input, @@ -297,12 +250,40 @@ export function useApplicationBuilder({ ], ) + const buildDebouncedSubmittedInput = React.useCallback( + ( + nextSelectedPartners: Array = explicitlySelectedPartners, + nextInferredPartners: Array = [], + nextSelectedLibraries: Array = selectedLibraries, + ) => + composeStarterInput({ + forceRouterOnly, + inferredPartners: nextInferredPartners, + input: debouncedInput, + migrationRepositoryUrl: debouncedMigrationRepositoryUrl, + packageManager: selectedPackageManager, + selectedLibraries: nextSelectedLibraries, + selectedPartners: nextSelectedPartners, + toolchain: selectedToolchain, + }), + [ + debouncedInput, + debouncedMigrationRepositoryUrl, + explicitlySelectedPartners, + forceRouterOnly, + selectedPackageManager, + selectedLibraries, + selectedToolchain, + ], + ) + const toggleLibrary = React.useCallback( (libraryId: LibraryId) => { if (isPinnedStarterLibrary(libraryId)) { return } + markUserEditedStarter() invalidateResult() const selected = selectedLibraries.includes(libraryId) @@ -311,81 +292,59 @@ export function useApplicationBuilder({ [libraryId]: !selected, })) }, - [invalidateResult, selectedLibraries], + [invalidateResult, markUserEditedStarter, selectedLibraries], ) const togglePartner = React.useCallback( (partner: ApplicationStarterPartnerSuggestion, selected: boolean) => { + markUserEditedStarter() invalidateResult() - setExplicitPartnerSelections((current) => ({ - ...current, - [partner.id]: !selected, - })) + setExplicitPartnerSelections((current) => { + const nextSelected = !selected + const next = { + ...current, + [partner.id]: nextSelected, + } + + if (nextSelected) { + for (const partnerId of getApplicationStarterConflictingPartnerIds( + partner, + partnerSuggestions, + )) { + next[partnerId] = false + } + } + + return next + }) }, - [invalidateResult], + [invalidateResult, markUserEditedStarter, partnerSuggestions], ) const togglePackageManager = React.useCallback( (packageManager: StarterPackageManager) => { + markUserEditedStarter() invalidateResult() setSelectedPackageManager((current) => current === packageManager ? undefined : packageManager, ) }, - [invalidateResult], + [invalidateResult, markUserEditedStarter], ) const toggleToolchain = React.useCallback( (toolchain: StarterToolchain) => { + markUserEditedStarter() invalidateResult() setSelectedToolchain((current) => current === toolchain ? undefined : toolchain, ) }, - [invalidateResult], + [invalidateResult, markUserEditedStarter], ) - React.useEffect(() => { - if (currentUser) { - setIsLocked(false) - setLockMessage(null) - } - }, [currentUser]) - - const refreshAnonymousQuota = React.useCallback(async () => { - if (currentUser) { - setAnonymousGenerationQuota(null) - return - } - - try { - const response = await fetch('/api/application-starter/resolve', { - method: 'GET', - cache: 'no-store', - }) - - if (!response.ok) { - return - } - - const payload = await response.json() - - if (!isApplicationStarterStatusResponse(payload)) { - return - } - - setAnonymousGenerationQuota(payload.anonymousGenerationQuota) - } catch { - // Ignore silent status refresh failures and fall back to existing UI. - } - }, [currentUser]) - - React.useEffect(() => { - void refreshAnonymousQuota() - }, [refreshAnonymousQuota]) - const dismissPromptCopyNotice = React.useCallback(() => { setShowPromptCopyNotice(false) }, []) @@ -456,160 +415,87 @@ export function useApplicationBuilder({ [markCopied, notify], ) - const finishWithResult = React.useCallback( - async (nextResult: ApplicationStarterResult) => { + const applyResolvedResultState = React.useCallback( + (nextResult: ApplicationStarterResult) => { setIsDirtySinceLastResult(false) setResult(nextResult) onResolvedResult?.(nextResult) - - if (mode !== 'compact') { - void handleCopy(nextResult.prompt, 'prompt', { - notify: false, - trigger: 'automatic', - }) - } - - if (!builderIntegration) { - return - } - - const applied = await builderIntegration.applyResult(nextResult) - - if (applied) { - notify( -
-
Builder configured
-
- The prompt was applied to the builder immediately. -
-
, - ) - } }, - [builderIntegration, handleCopy, mode, notify, onResolvedResult], + [onResolvedResult], ) - const handleResolveMutate = React.useCallback(() => { - const phraseIndex = Math.floor(Math.random() * starterLoadingPhrases.length) - setLoadingPhrase(starterLoadingPhrases[phraseIndex]!) - }, []) - - const handleAnalysisError = React.useCallback( - (error: unknown, variables: { requestId: number }) => { - if (variables.requestId !== latestRequestIdRef.current) { - return + const resolveSubmittedInput = React.useCallback( + async ( + submittedInput: string, + options?: { applyBuilder?: boolean; silentBuilder?: boolean }, + ) => { + const trimmed = submittedInput.trim() + if (!trimmed) { + return null } - trackEvent('builder_failed', { - ...sessionContextRef.current, - stage: 'analysis', - error_message: error instanceof Error ? error.message : 'unknown_error', - }) - - notify( -
-
Could not analyze the prompt
-
- {error instanceof Error ? error.message : 'Please try again.'} -
-
, + const requestId = latestRequestIdRef.current + 1 + latestRequestIdRef.current = requestId + const phraseIndex = Math.floor( + Math.random() * starterLoadingPhrases.length, ) - }, - [notify], - ) - - const handleAnalysisSuccess = React.useCallback( - ( - nextAnalysis: ApplicationStarterAnalysis, - variables: { requestId: number }, - ) => { - if (variables.requestId !== latestRequestIdRef.current) { - return - } + setLoadingPhrase(starterLoadingPhrases[phraseIndex]!) + setIsRebuildingResult(true) - setAnalysis(nextAnalysis) - setIsAnalysisStale(false) - setIsLocked(false) - setLockMessage(null) + try { + const nextResult = await resolveApplicationStarterDeterministically({ + context, + input: trimmed, + }) - trackEvent('builder_analyzed', { - ...sessionContextRef.current, - analysis_deployment: nextAnalysis.recipe.deployment, - inferred_library_count: nextAnalysis.inferredLibraryIds.length, - inferred_partner_count: nextAnalysis.inferredPartnerIds.length, - feature_count: nextAnalysis.recipe.features?.length ?? 0, - }) - }, - [], - ) + if (requestId !== latestRequestIdRef.current) { + return null + } - const handleResolveError = React.useCallback( - (error: unknown, variables: { requestId: number }) => { - if (variables.requestId !== latestRequestIdRef.current) { - return - } + applyResolvedResultState(nextResult) - void refreshAnonymousQuota() + if (options?.applyBuilder && builderIntegration) { + await builderIntegration.applyResult(nextResult, { + silent: options.silentBuilder, + }) + } - notify( -
-
Could not generate a prompt
-
- {error instanceof Error ? error.message : 'Please try again.'} -
-
, - ) + return nextResult + } catch (error) { + if (requestId === latestRequestIdRef.current) { + notify( +
+
Could not rebuild the prompt
+
+ {error instanceof Error ? error.message : 'Please try again.'} +
+
, + ) - const isLoginRequired = - error instanceof ApplicationStarterError && !!error.loginRequired - - // login_blocked is a more specific failure than generation — emit - // only one event per failure to avoid double-counting in dashboards. - if (isLoginRequired && !currentUser) { - setIsLocked(true) - setLockMessage( - error.retryAfter - ? `Anonymous generations are limited. Sign in to unlock more, or wait about ${Math.max(1, Math.ceil(error.retryAfter / 60))} minute${Math.ceil(error.retryAfter / 60) === 1 ? '' : 's'}.` - : 'Anonymous generations are limited. Sign in to unlock more.', - ) + trackEvent('builder_failed', { + ...sessionContextRef.current, + stage: 'generation', + error_message: + error instanceof Error ? error.message : 'unknown_error', + }) + } - trackEvent('builder_failed', { - ...sessionContextRef.current, - stage: 'login_blocked', - retry_after: error.retryAfter, - }) - } else { - trackEvent('builder_failed', { - ...sessionContextRef.current, - stage: 'generation', - error_message: - error instanceof Error ? error.message : 'unknown_error', - }) + return null + } finally { + if (requestId === latestRequestIdRef.current) { + setIsRebuildingResult(false) + } } }, - [currentUser, notify, refreshAnonymousQuota], + [applyResolvedResultState, builderIntegration, context, notify], ) - const handleResolveSuccess = React.useCallback( - async ( - nextResult: ApplicationStarterResult, - variables: { request: ApplicationStarterRequest; requestId: number }, - ) => { - if (variables.requestId !== latestRequestIdRef.current) { - return - } - - setIsLocked(false) - setLockMessage(null) - void refreshAnonymousQuota() - generationCountRef.current += 1 - - const selectedPartnerIds = getApplicationStarterSelectedPartnerIds( - variables.request.input, - ) - const inferredPartnerIds = getApplicationStarterInferredPartnerIds( - variables.request.input, - ) + const trackGeneratedResult = React.useCallback( + (nextResult: ApplicationStarterResult, submittedInput: string) => { + const selectedPartnerIds = + getApplicationStarterSelectedPartnerIds(submittedInput) + const inferredPartnerIds = + getApplicationStarterInferredPartnerIds(submittedInput) const finalPartnerIds = [ ...new Set([...selectedPartnerIds, ...inferredPartnerIds]), ] @@ -624,63 +510,10 @@ export function useApplicationBuilder({ ] const promptText = `${nextResult.prompt}\n${nextResult.cliCommand}`.toLowerCase() - const finalPromptPartnerIds = partnerSuggestions - .filter((partner) => { - const needles = [partner.id, partner.label].map((value) => - value.toLowerCase(), - ) - - return needles.some((needle) => promptText.includes(needle)) - }) - .map((partner) => partner.id) const finalPromptFeatureIds = finalFeatureIds.filter((featureId) => promptText.includes(featureId.toLowerCase()), ) - if (!analysis || isAnalysisStale) { - const generatedLibraryIds = getGeneratedLibraryIds({ - featureIds: finalPromptFeatureIds, - promptText, - }) - const generatedPartnerIds = Array.from( - new Set( - [...finalPromptPartnerIds, nextResult.recipe.deployment].filter( - (partnerId): partnerId is string => !!partnerId, - ), - ), - ) - - if (generatedLibraryIds.length > 0) { - setExplicitLibrarySelections((current) => { - const next = { ...current } - - for (const libraryId of generatedLibraryIds) { - next[libraryId] = true - } - - return next - }) - } - - if (generatedPartnerIds.length > 0) { - setExplicitPartnerSelections((current) => { - const next = { ...current } - - for (const partnerId of generatedPartnerIds) { - next[partnerId] = true - } - - return next - }) - } - - setSelectedPackageManager(nextResult.recipe.packageManager) - - if (nextResult.recipe.toolchain) { - setSelectedToolchain(nextResult.recipe.toolchain) - } - } - trackEvent('builder_generated', { ...sessionContextRef.current, final_deployment: nextResult.recipe.deployment, @@ -688,150 +521,31 @@ export function useApplicationBuilder({ final_library_count: selectedLibraries.length, final_partner_count: finalPartnerIds.length, final_addon_count: finalPromptFeatureIds.length, - // Joined arrays — use SPLIT() in BigQuery for top-N analysis. + // Joined arrays - use SPLIT() in BigQuery for top-N reporting. library_ids: selectedLibraries.join(','), partner_ids: finalPartnerIds.join(','), addon_ids: finalPromptFeatureIds.join(','), }) - - await finishWithResult(nextResult) }, - [ - analysis, - finishWithResult, - isAnalysisStale, - partnerSuggestions, - refreshAnonymousQuota, - selectedLibraries, - ], + [selectedLibraries], ) - const promptResolveMutation = useMutation({ - mutationFn: async ({ - request, - }: { - request: ApplicationStarterRequest - requestId: number - }) => resolveApplicationStarter(request), - onMutate: handleResolveMutate, - onError: handleResolveError, - onSuccess: handleResolveSuccess, - }) - - const analysisMutation = useMutation({ - mutationFn: async ({ - request, - }: { - request: ApplicationStarterRequest - requestId: number - }) => analyzeApplicationStarter(request), - onMutate: handleResolveMutate, - onError: handleAnalysisError, - onSuccess: handleAnalysisSuccess, - }) - - const netlifyResolveMutation = useMutation({ - mutationFn: async ({ - request, - }: { - request: ApplicationStarterRequest - requestId: number - }) => resolveApplicationStarter(request), - onMutate: handleResolveMutate, - onError: handleResolveError, - onSuccess: handleResolveSuccess, - }) - - const submit = React.useCallback( - async (submittedInput: string) => { - const trimmed = submittedInput.trim() - if (!trimmed) { - return null - } - - const requestId = latestRequestIdRef.current + 1 - latestRequestIdRef.current = requestId - - try { - const nextResult = await promptResolveMutation.mutateAsync({ - request: { - context, - input: trimmed, - }, - requestId, - }) - - if (requestId !== latestRequestIdRef.current) { - return null - } - - return nextResult - } catch { - return null - } - }, - [context, promptResolveMutation], - ) - - const submitAnalysis = React.useCallback(async () => { - const trimmedInput = input.trim() - - if (!trimmedInput) { - return null + React.useEffect(() => { + if (!hasRevealedOptions) { + return } - const requestId = latestRequestIdRef.current + 1 - latestRequestIdRef.current = requestId - - try { - const nextAnalysis = await analysisMutation.mutateAsync({ - request: { - context, - input: trimmedInput, - }, - requestId, - }) - - if (requestId !== latestRequestIdRef.current) { - return null - } + const submittedInput = buildDebouncedSubmittedInput() - return nextAnalysis - } catch { - return null + if (!submittedInput.trim()) { + return } - }, [analysisMutation, context, input]) - - const handleNetlifySubmit = React.useCallback( - async (submittedInput: string) => { - const trimmed = submittedInput.trim() - if (!trimmed) { - return null - } - - const requestId = latestRequestIdRef.current + 1 - latestRequestIdRef.current = requestId - - try { - const nextResult = await netlifyResolveMutation.mutateAsync({ - request: { - context, - input: trimmed, - }, - requestId, - }) - - if (requestId !== latestRequestIdRef.current) { - return null - } - return nextResult - } catch { - return null - } - }, - [context, netlifyResolveMutation], - ) + void resolveSubmittedInput(submittedInput, { + applyBuilder: hasUserEditedStarterRef.current, + silentBuilder: true, + }) + }, [buildDebouncedSubmittedInput, hasRevealedOptions, resolveSubmittedInput]) const selectSuggestion = React.useCallback( async ({ @@ -839,7 +553,7 @@ export function useApplicationBuilder({ }: { suggestion: { input: string; label: string } }) => { - markAnalysisStale() + markInputDirty() setInput(suggestion.input) if (!isNextJsMigrationInput(suggestion.input)) { @@ -850,24 +564,10 @@ export function useApplicationBuilder({ // outcome (analyzed/generated/activated) carries this attribution. setSessionIdea(suggestion.label) }, - [markAnalysisStale, setSessionIdea], + [markInputDirty, setSessionIdea], ) const ensureResolvedResult = React.useCallback(async () => { - if ((!analysis || isAnalysisStale) && !showLuckyActions) { - notify( -
-
Refresh recommendations first
-
- Analyze again to refresh integrations and libraries before - generating. -
-
, - ) - - return null - } - const submittedInput = buildSubmittedInput() if (!submittedInput.trim()) { @@ -878,16 +578,12 @@ export function useApplicationBuilder({ return result } - return submit(submittedInput) + return resolveSubmittedInput(submittedInput) }, [ - analysis, buildSubmittedInput, - isAnalysisStale, isDirtySinceLastResult, - notify, + resolveSubmittedInput, result, - showLuckyActions, - submit, ]) const copyResultValue = React.useCallback( @@ -1009,28 +705,38 @@ export function useApplicationBuilder({ ) const openNetlifyStart = React.useCallback(async () => { - const nextSelectedPartners = explicitlySelectedPartners.filter( - (partnerId) => partnerId !== 'cloudflare', + const netlifyPartner = partnerSuggestions.find( + (partner) => partner.id === 'netlify', ) - const nextInferredPartners = effectiveInferredPartners.filter( - (partnerId) => partnerId !== 'cloudflare', + const netlifyConflictIds = netlifyPartner + ? getApplicationStarterConflictingPartnerIds( + netlifyPartner, + partnerSuggestions, + ) + : ['cloudflare'] + const nextSelectedPartners = explicitlySelectedPartners.filter( + (partnerId) => !netlifyConflictIds.includes(partnerId), ) - const removedCloudflare = - nextSelectedPartners.length !== explicitlySelectedPartners.length || - nextInferredPartners.length !== effectiveInferredPartners.length + const removedConflictingPartner = + nextSelectedPartners.length !== explicitlySelectedPartners.length - if (removedCloudflare) { - setExplicitPartnerSelections((current) => ({ - ...current, - cloudflare: false, - })) + if (removedConflictingPartner) { + setExplicitPartnerSelections((current) => { + const next = { ...current } + + for (const partnerId of netlifyConflictIds) { + next[partnerId] = false + } + + return next + }) invalidateResult() } const nextSelectedLibraries = selectedLibraries const submittedInput = buildSubmittedInput( nextSelectedPartners, - nextInferredPartners, + [], nextSelectedLibraries, ) @@ -1039,9 +745,9 @@ export function useApplicationBuilder({ } const nextResult = - !removedCloudflare && result + !removedConflictingPartner && result && !isDirtySinceLastResult ? result - : await handleNetlifySubmit(submittedInput) + : await resolveSubmittedInput(submittedInput) if (!nextResult?.prompt) { return @@ -1056,20 +762,37 @@ export function useApplicationBuilder({ }) window.open( - `https://app.netlify.com/start?prompt=${encodeURIComponent(nextResult.prompt)}&utm_source=tanstack`, + buildStarterPromptDeployUrl('netlify', nextResult.prompt), '_blank', 'noopener,noreferrer', ) }, [ buildSubmittedInput, - effectiveInferredPartners, explicitlySelectedPartners, - handleNetlifySubmit, invalidateResult, + isDirtySinceLastResult, + partnerSuggestions, result, + resolveSubmittedInput, selectedLibraries, ]) + const openLovableStart = React.useCallback(async () => { + await withResolvedPrompt((nextResult) => { + trackActivation({ + action: 'deploy', + surface: 'result_panel', + provider: 'lovable', + }) + + window.open( + buildStarterPromptDeployUrl('lovable', nextResult.prompt), + '_blank', + 'noopener,noreferrer', + ) + }) + }, [trackActivation, withResolvedPrompt]) + const openCodexStart = React.useCallback(async () => { await withResolvedPrompt((nextResult) => { trackAction('open_codex') @@ -1102,15 +825,33 @@ export function useApplicationBuilder({ }, [trackAction, withResolvedPrompt]) const generatePrompt = React.useCallback(async () => { - if ((!analysis || isAnalysisStale) && !showLuckyActions) { - await submitAnalysis() + const submittedInput = buildSubmittedInput() + const nextResult = + result && !isDirtySinceLastResult + ? result + : await resolveSubmittedInput(submittedInput) + + if (!nextResult) { return } - const nextResult = await submit(buildSubmittedInput()) + trackGeneratedResult(nextResult, submittedInput) - if (!nextResult) { - return + if (builderIntegration) { + const applied = await builderIntegration.applyResult(nextResult, { + silent: false, + }) + + if (applied) { + notify( +
+
Builder configured
+
+ The prompt was applied to the builder immediately. +
+
, + ) + } } await handleCopy(nextResult.prompt, 'prompt', { @@ -1118,70 +859,55 @@ export function useApplicationBuilder({ showPromptNotice: true, }) }, [ - analysis, buildSubmittedInput, + builderIntegration, handleCopy, - isAnalysisStale, - showLuckyActions, - submit, - submitAnalysis, + isDirtySinceLastResult, + notify, + resolveSubmittedInput, + result, + trackGeneratedResult, ]) - const enableLuckyActions = React.useCallback(() => { - setShowLuckyActions(true) - }, []) - const hasInput = buildSubmittedInput().trim().length > 0 - const hasFreshAnalysis = !!analysis && !isAnalysisStale const hasGeneratedPrompt = !!result?.prompt - const isAnalyzing = analysisMutation.isPending - const isGeneratingPrompt = promptResolveMutation.isPending - const isGeneratingNetlify = netlifyResolveMutation.isPending - const isGenerating = isAnalyzing || isGeneratingPrompt || isGeneratingNetlify + const isGeneratingPrompt = isRebuildingResult + const isGeneratingNetlify = false + const isGenerating = isRebuildingResult const updateInput = React.useCallback( (value: string) => { - markAnalysisStale() + markInputDirty() setInput(value) }, - [markAnalysisStale], + [markInputDirty], ) const updateMigrationRepositoryUrl = React.useCallback( (value: string) => { + markUserEditedStarter() invalidateResult() setMigrationRepositoryUrl(value) }, - [invalidateResult], + [invalidateResult, markUserEditedStarter], ) const submitCurrentInput = React.useCallback(async () => { - await submitAnalysis() - }, [submitAnalysis]) - - const openLogin = React.useCallback( - (onSuccess?: () => void) => { - openLoginModal({ - onSuccess: () => { - setIsLocked(false) - setLockMessage(null) - setAnonymousGenerationQuota(null) - onSuccess?.() - }, - }) - }, - [openLoginModal], - ) + if (!input.trim()) { + return + } + + setHasRevealedOptions(true) + }, [input]) return { - anonymousGenerationQuota, copiedKind, copyResultValue, dismissPromptCopyNotice, deployDialogProvider, - enableLuckyActions, generatePrompt, hasGeneratedPrompt, + hasRevealedOptions, hasInput, hasMigrationRepositoryUrlError, input, @@ -1189,10 +915,8 @@ export function useApplicationBuilder({ isGenerating, isGeneratingNetlify, isGeneratingPrompt, - isLocked, isModHeld, loadingPhrase, - lockMessage, migrationRepositoryInputRef, migrationRepositoryUrl, navigateToResult, @@ -1200,24 +924,18 @@ export function useApplicationBuilder({ openCursorStart, openCodexStart, openDeployDialog, - openLogin, + openLovableStart, openNetlifyStart, - partnerSuggestions, + partnerSuggestions: visiblePartnerSuggestions, promptCopyNotice: showPromptCopyNotice, result, selectSuggestion, - analysis, - hasFreshAnalysis, - isAnalysisStale, - isAnalyzing, - showLuckyActions, selectedPackageManager, selectedLibraries, selectedPartners, selectedToolchain, setIsDeployDialogOpen, setIsModHeld, - setSessionMode, showMigrationRepositoryInput, trackActivation, submitCurrentInput, diff --git a/src/components/builder/BuilderWorkspace.tsx b/src/components/builder/BuilderWorkspace.tsx index 815b1ff7c..14c4d6d4e 100644 --- a/src/components/builder/BuilderWorkspace.tsx +++ b/src/components/builder/BuilderWorkspace.tsx @@ -15,6 +15,8 @@ export function BuilderWorkspace() { useState(null) const [isPromptDirtySinceGenerate, setIsPromptDirtySinceGenerate] = useState(false) + const [latestStarterResult, setLatestStarterResult] = + useState(null) const features = useBuilderStore((state) => state.features) const featureOptions = useBuilderStore((state) => state.featureOptions) const framework = useBuilderStore((state) => state.framework) @@ -27,6 +29,17 @@ export function BuilderWorkspace() { ) const summary = useBuilderSummaryData() const { notify } = useToast() + const displayedSummary = useMemo( + () => + latestStarterResult + ? { + ...summary, + cliCommand: latestStarterResult.cliCommand, + prompt: latestStarterResult.prompt, + } + : summary, + [latestStarterResult, summary], + ) const currentBuilderSignature = useMemo( () => @@ -57,21 +70,26 @@ export function BuilderWorkspace() { ? { title: 'Summary out of date', description: - 'The prompt or builder options changed. Generate again to refresh this summary.', + 'The prompt or builder options changed. Copy the prompt again to refresh this summary.', } : null const applyStarterResult = useCallback( - async (result: ApplicationStarterResult) => { + async ( + result: ApplicationStarterResult, + options?: { silent?: boolean }, + ) => { if (result.recipe.target === 'router') { - notify( -
-
Router-only stays prompt-first
-
- `/builder` remains a TanStack Start advanced surface. -
-
, - ) + if (!options?.silent) { + notify( +
+
Router-only stays prompt-first
+
+ `/builder` remains a TanStack Start advanced surface. +
+
, + ) + } return false } @@ -85,6 +103,12 @@ export function BuilderWorkspace() { }, [applyStarterRecipe, notify], ) + const starterBuilderIntegration = useMemo( + () => ({ + applyResult: applyStarterResult, + }), + [applyStarterResult], + ) return (
@@ -109,10 +133,7 @@ export function BuilderWorkspace() { ) : null} @@ -147,7 +170,7 @@ export function BuilderWorkspace() {
diff --git a/src/components/builder/DeployDialog.tsx b/src/components/builder/DeployDialog.tsx index a9eb0cdf9..e2d62f836 100644 --- a/src/components/builder/DeployDialog.tsx +++ b/src/components/builder/DeployDialog.tsx @@ -504,23 +504,31 @@ export function DeployDialog({ Redirecting to {providerInfo.name} in {countdown}s...

)} - + {(() => { + const deployUrl = providerInfo.deployUrl( + state.owner, + state.repoName, + ) + + return ( + + ) + })()} ) : (