diff --git a/.changeset/core-reusable-sidebar-and-alpha-badge.md b/.changeset/core-reusable-sidebar-and-alpha-badge.md new file mode 100644 index 00000000000..f0c8d069a3b --- /dev/null +++ b/.changeset/core-reusable-sidebar-and-alpha-badge.md @@ -0,0 +1,7 @@ +--- +"@agent-native/core": patch +"@agent-native/dispatch": patch +"@agent-native/toolkit": patch +--- + +Add reusable AppSidebar in toolkit and core, support top-left configurable alpha badges, and update app layouts to match the new sidebar design. diff --git a/.claude/launch.json b/.claude/launch.json index b7d8ff467bd..f150d373c74 100644 --- a/.claude/launch.json +++ b/.claude/launch.json @@ -684,6 +684,58 @@ "3197" ], "port": 3197 + }, + { + "name": "dispatch-vault-verify", + "runtimeExecutable": "pnpm", + "runtimeArgs": [ + "exec", + "tsx", + "scripts/claude-launch.ts", + "--name", + "dispatch-vault-verify", + "--dir", + "templates/dispatch", + "--env", + "DATABASE_URL=", + "--env", + "SLACK_BOT_TOKEN=", + "--env", + "SLACK_SIGNING_SECRET=", + "--env", + "TELEGRAM_BOT_TOKEN=", + "--env", + "SENDGRID_API_KEY=", + "--env", + "WORKSPACE_GATEWAY_URL=", + "--env", + "AGENT_NATIVE_ENABLE_RECURRING_JOBS=0", + "--env", + "SENTRY_DSN=", + "--env", + "SENTRY_SERVER_DSN=", + "--env", + "SENTRY_CLIENT_DSN=", + "--env", + "VITE_SENTRY_CLIENT_DSN=", + "--env", + "AGENT_NATIVE_ANALYTICS_ENDPOINT=", + "--env", + "VITE_AGENT_NATIVE_ANALYTICS_ENDPOINT=", + "--env", + "AMPLITUDE_API_KEY=", + "--env", + "VITE_AMPLITUDE_API_KEY=", + "--env", + "VITE_AGENT_NATIVE_SESSION_REPLAY_ENABLED=0", + "--env", + "AGENT_NATIVE_BUILDER_SSO_SECRET=", + "--", + "dev", + "--port", + "3190" + ], + "port": 3190 } ] } diff --git a/packages/core/docs/content/template-clips-anonymous-intake.mdx b/packages/core/docs/content/template-clips-anonymous-intake.mdx index b7b070c5d3a..b5866379892 100644 --- a/packages/core/docs/content/template-clips-anonymous-intake.mdx +++ b/packages/core/docs/content/template-clips-anonymous-intake.mdx @@ -12,11 +12,13 @@ The intake URL is a narrow upload capability. It is not a share link, a library ## Capability boundaries -| Capability | Allows | Does not allow | Lifetime | -| --- | --- | --- | --- | -| Intake URL | Create one private recording, upload its chunks, finalize it, or abort it | List recordings, read media, open the library, or use normal agent APIs | One hour by default. Four hours maximum | -| Agent context link | Read the completed recording through the normal scoped Clips agent surface after an authenticated host-side exchange | Be minted by the anonymous intake bearer, read any other recording, or act as the recording owner | Seven days for bug-report completions | -| Clips session | Mint intake URLs and use the normal authenticated Clips UI | Nothing is added to the anonymous visitor's browser | Controlled by normal Clips auth | + + +| Capability | Allows | Does not allow | Lifetime | +| ------------------ | -------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------- | --------------------------------------- | +| Intake URL | Create one private recording, upload its chunks, finalize it, or abort it | List recordings, read media, open the library, or use normal agent APIs | One hour by default. Four hours maximum | +| Agent context link | Read the completed recording through the normal scoped Clips agent surface after an authenticated host-side exchange | Be minted by the anonymous intake bearer, read any other recording, or act as the recording owner | Seven days for bug-report completions | +| Clips session | Mint intake URLs and use the normal authenticated Clips UI | Nothing is added to the anonymous visitor's browser | Controlled by normal Clips auth | The intake token is signed with the existing scoped-token mechanism. The token is never stored in SQL. The database stores only the intake id, owner and organization binding, recording id, status, and expiry. diff --git a/packages/core/docs/content/template-clips-developers.mdx b/packages/core/docs/content/template-clips-developers.mdx index 6825889216a..de1fae38e66 100644 --- a/packages/core/docs/content/template-clips-developers.mdx +++ b/packages/core/docs/content/template-clips-developers.mdx @@ -273,7 +273,7 @@ Two things live outside this schema on purpose: organization/member/invitation r | `recording_ctas` | Call-to-action buttons (label, url, color, placement) overlaid on a recording | | `recording_comments` | Threaded, timestamped comments with emoji-reaction map and resolved flag | | `recording_bug_reports` | Redacted host-product bug report metadata keyed by `recording_id` | -| `clips_intake_sessions` | One-use anonymous intake capabilities bound to an owner, organization, and recording | +| `clips_intake_sessions` | One-use anonymous intake capabilities bound to an owner, organization, and recording | | `recording_reactions` | Emoji reactions pinned to a video timestamp (anonymous viewers allowed) | | `recording_viewers` / `recording_events` | View analytics: per-viewer watch time and completion, plus granular events (view-start, watch-progress, seek, pause, cta-click, reaction) | | `recording_views` | Append-only per-view log (who viewed, when) — backs the owner-facing "Viewed by" popover, kept separate from the aggregate counters above | diff --git a/packages/core/src/client/EnvironmentBadge.render.spec.tsx b/packages/core/src/client/EnvironmentBadge.render.spec.tsx index e0019729757..cf468e098e7 100644 --- a/packages/core/src/client/EnvironmentBadge.render.spec.tsx +++ b/packages/core/src/client/EnvironmentBadge.render.spec.tsx @@ -57,7 +57,7 @@ describe("EnvironmentBadge render", () => { vi.clearAllMocks(); }); - it("renders a non-navigating dev pill for configured local development", () => { + it("renders a non-navigating alpha pill for configured local development", () => { Object.defineProperty(window, "location", { configurable: true, value: { @@ -77,7 +77,7 @@ describe("EnvironmentBadge render", () => { act(() => root.render()); const badge = container.querySelector('[role="status"]'); - expect(badge?.textContent).toBe("dev"); + expect(badge?.textContent).toBe("alpha"); expect(badge?.getAttribute("aria-label")).toBe( "Local development environment", ); @@ -87,7 +87,7 @@ describe("EnvironmentBadge render", () => { expect(container.querySelector("a")).toBeNull(); }); - it("supports an inline dev pill for app-owned brand slots", () => { + it("supports an inline alpha pill for app-owned brand slots", () => { Object.defineProperty(window, "location", { configurable: true, value: { @@ -107,14 +107,14 @@ describe("EnvironmentBadge render", () => { act(() => root.render()); const badge = container.querySelector('[role="status"]'); - expect(badge?.textContent).toBe("dev"); + expect(badge?.textContent).toBe("alpha"); expect(badge?.className).toContain("inline-flex"); expect(badge?.className).toContain("h-5"); expect(badge?.className).not.toContain("fixed"); expect(badge?.className).not.toContain("bottom-3"); }); - it("defers the dev pill to a post-mount effect so the first client commit matches SSR's null output", async () => { + it("defers the alpha pill to a post-mount effect so the first client commit matches SSR's null output", async () => { Object.defineProperty(window, "location", { configurable: true, value: { @@ -141,7 +141,9 @@ describe("EnvironmentBadge render", () => { await act(async () => {}); - expect(container.querySelector('[role="status"]')?.textContent).toBe("dev"); + expect(container.querySelector('[role="status"]')?.textContent).toBe( + "alpha", + ); }); it.each([ @@ -162,7 +164,7 @@ describe("EnvironmentBadge render", () => { expect(container.innerHTML).toBe(""); }); - it("renders the beta chip for signed-out visitors", () => { + it("renders the badge for signed-out visitors without interaction", () => { useSessionMock.mockReturnValue({ session: null, status: "unauthenticated", @@ -170,18 +172,16 @@ describe("EnvironmentBadge render", () => { act(() => root.render()); - const trigger = container.querySelector("button"); - expect(trigger?.textContent).toContain("beta"); - expect(trigger?.className).toContain("border-primary/80"); - expect(trigger?.className).toContain("bottom-3"); - expect(trigger?.className).toContain("left-3"); - expect(trigger?.className).not.toContain("top-3"); - expect(trigger?.className).not.toContain("right-3"); - expect(trigger?.className).not.toContain("bg-background/95"); - expect(container.textContent).toContain("beta"); + const badge = container.querySelector('[role="status"]'); + expect(badge?.textContent).toBe("alpha"); + expect(badge?.className).toContain("border-primary/80"); + expect(badge?.className).toContain("bottom-3"); + expect(badge?.className).toContain("left-3"); + expect(container.querySelector("button")).toBeNull(); + expect(container.textContent).toContain("alpha"); }); - it("renders the beta chip for non-builder users", () => { + it("renders the badge for non-builder users without interaction", () => { useSessionMock.mockReturnValue({ session: { email: "person@example.com" }, status: "authenticated", @@ -189,20 +189,22 @@ describe("EnvironmentBadge render", () => { act(() => root.render()); - expect(container.querySelector("button")?.textContent).toContain("beta"); - expect(container.textContent).toContain("beta"); + expect(container.querySelector("button")).toBeNull(); + const badge = container.querySelector('[role="status"]'); + expect(badge?.textContent).toBe("alpha"); + expect(container.textContent).toContain("alpha"); }); - it("opens an inline beta chip below an app-owned brand slot", () => { + it("opens an inline chip popover for @builder.io employees", () => { useSessionMock.mockReturnValue({ - session: null, - status: "unauthenticated", + session: { email: "shawn@builder.io" }, + status: "authenticated", }); act(() => root.render()); const trigger = container.querySelector("button"); - expect(trigger?.textContent).toContain("beta"); + expect(trigger?.textContent).toBe("alpha"); expect(trigger?.className).toContain("inline-flex"); expect(trigger?.className).not.toContain("fixed"); @@ -216,10 +218,10 @@ describe("EnvironmentBadge render", () => { expect(document.body.querySelector('[data-side="bottom"]')).not.toBeNull(); }); - it("keeps the beta chip linked to production for every visitor", () => { + it("keeps the chip linked to production for builder visitors", () => { useSessionMock.mockReturnValue({ - session: null, - status: "unauthenticated", + session: { email: "steve@builder.io" }, + status: "authenticated", }); act(() => root.render()); @@ -251,8 +253,8 @@ describe("EnvironmentBadge render", () => { it("hides the badge for the current page without persisting the choice", () => { useSessionMock.mockReturnValue({ - session: null, - status: "unauthenticated", + session: { email: "steve@builder.io" }, + status: "authenticated", }); act(() => root.render()); @@ -278,13 +280,28 @@ describe("EnvironmentBadge render", () => { act(() => hideButton?.click()); expect(container.innerHTML).toBe(""); - expect(document.body.querySelector('[data-side="top"]')).toBeNull(); - expect(window.sessionStorage.getItem("agent-native:force-production")).toBe( - null, - ); + expect( + window.localStorage?.getItem("agent-native:beta-opt-out-until"), + ).toBeNull(); + }); + + it("supports configurable badgeText prop and config", () => { + useSessionMock.mockReturnValue({ + session: { email: "steve@builder.io" }, + status: "authenticated", + }); + + act(() => root.render()); + expect(container.querySelector("button")?.textContent).toBe("beta"); + + injectedAgentNativeConfigMock.mockReturnValue({ + deployment: { badgeText: "custom" }, + }); + act(() => root.render()); + expect(container.querySelector("button")?.textContent).toBe("custom"); }); - it("hides the production chip for non-employee sessions", () => { + it("renders non-interactive chip on production for non-employee sessions", () => { Object.defineProperty(window, "location", { configurable: true, value: { diff --git a/packages/core/src/client/EnvironmentBadge.tsx b/packages/core/src/client/EnvironmentBadge.tsx index 5e01be68666..1e58f0817a0 100644 --- a/packages/core/src/client/EnvironmentBadge.tsx +++ b/packages/core/src/client/EnvironmentBadge.tsx @@ -196,12 +196,18 @@ function EnvironmentBadgeContent({ environment, placement, targets, + badgeText, + className, }: { environment: "beta" | "production"; placement: EnvironmentBadgePlacement; targets: EnvironmentBadgeTargets; + badgeText?: string; + className?: string; }) { const [isHidden, setIsHidden] = useState(false); + const { session } = useSession(); + const isBuilder = isBuilderIoEmployee(session?.email); if (typeof window === "undefined") return null; if (isHidden) return null; @@ -214,23 +220,44 @@ function EnvironmentBadgeContent({ ); if (environment === "beta" ? !productionHref : !betaHref) return null; - const label = environment === "beta" ? "beta" : "prod"; + const label = badgeText ?? "alpha"; const title = environment === "beta" - ? "You're on Agent-Native Beta" + ? `You're on Agent-Native ${label.charAt(0).toUpperCase() + label.slice(1)}` : "You're on Agent-Native Production"; + const badgeClasses = cn( + environmentBadgePlacementClasses[placement], + environment === "beta" + ? "border-primary/80" + : "border-border/80 bg-background/95 text-foreground", + className, + ); + + if (!isBuilder) { + return ( +
+ {label} +
+ ); + } + return ( @@ -581,22 +598,23 @@ function FeedbackPopoverButton({ className, )} > - + {resolvedLabel} ); } else { + // Sidebar variant matches Clips footer feedback row spacing/density. trigger = ( diff --git a/packages/core/src/client/app-providers.tsx b/packages/core/src/client/app-providers.tsx index 07511796cf9..b682722690d 100644 --- a/packages/core/src/client/app-providers.tsx +++ b/packages/core/src/client/app-providers.tsx @@ -365,7 +365,7 @@ export function AppProviders({ clientOnlyFallback, sessionBypass = false, disableWebMcp = false, - showEnvironmentBadge = true, + showEnvironmentBadge = false, defaultTheme, themeAttribute, tooltipDelayDuration, diff --git a/packages/core/src/client/auth/AuthPage.tsx b/packages/core/src/client/auth/AuthPage.tsx index 117e06e214f..5974196a222 100644 --- a/packages/core/src/client/auth/AuthPage.tsx +++ b/packages/core/src/client/auth/AuthPage.tsx @@ -719,10 +719,6 @@ export function AuthPage(props: AuthPageProps) { }, []); const [googleBusy, setGoogleBusy] = React.useState(false); const [magicLinkBusy, setMagicLinkBusy] = React.useState(false); - const [environmentVisible, setEnvironmentVisible] = React.useState(false); - const [environmentOpen, setEnvironmentOpen] = React.useState(false); - const [environmentProductionUrl, setEnvironmentProductionUrl] = - React.useState(""); const [copiedLocalMode, setCopiedLocalMode] = React.useState(false); const signupViewTrackedRef = React.useRef(false); const pendingSignupPassword = React.useRef(""); @@ -1202,34 +1198,6 @@ export function AuthPage(props: AuthPageProps) { } }, [localDevAllowed, props]); - React.useEffect(() => { - if (window.parent !== window) return; - const hostname = window.location.hostname.toLowerCase().replace(/\.$/, ""); - const productionHost = hostname.startsWith("beta.") - ? hostname.slice("beta.".length) - : ""; - if ( - !productionHost || - props.environmentBetaHosts[productionHost] !== hostname - ) { - return; - } - try { - const productionUrl = new URL(window.location.href); - productionUrl.protocol = "https:"; - productionUrl.hostname = productionHost; - productionUrl.port = ""; - productionUrl.searchParams.set( - props.betaOptOutQueryParam, - String(Date.now() + props.betaOptOutDurationMs), - ); - setEnvironmentProductionUrl(productionUrl.toString()); - setEnvironmentVisible(true); - } catch { - // coercion-ok: malformed host metadata cannot produce a useful switcher. - } - }, [props]); - const stopOAuthPolling = React.useCallback(() => { if (oauthPollTimer.current !== null) { window.clearInterval(oauthPollTimer.current); @@ -2803,59 +2771,6 @@ export function AuthPage(props: AuthPageProps) { ); - const environmentBadge = ( - - ); const marketingSurface = marketingCopy ? ( {localePicker} - {environmentBadge} {initialPrompt ? (
{authCard}
) : ( diff --git a/packages/core/src/client/components/icons/AgentNativeIcon.tsx b/packages/core/src/client/components/icons/AgentNativeIcon.tsx index ceb6307f833..37301c1492d 100644 --- a/packages/core/src/client/components/icons/AgentNativeIcon.tsx +++ b/packages/core/src/client/components/icons/AgentNativeIcon.tsx @@ -23,6 +23,7 @@ export function AgentNativeIcon({ }: AgentNativeIconProps) { return ( { }); const fetchMock = vi.mocked(globalThis.fetch); - await vi.waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(2)); - expect( - JSON.parse(String(fetchMock.mock.calls[1]?.[1]?.body)), - ).toMatchObject({ + await vi.waitFor(() => + expect(fetchMock.mock.calls.length).toBeGreaterThanOrEqual(2), + ); + const textCall = fetchMock.mock.calls.find((call) => { + try { + const body = JSON.parse(String(call[1]?.body)); + return body?.feedbackType === "text"; + } catch { + return false; + } + }); + expect(textCall).toBeDefined(); + expect(JSON.parse(String(textCall?.[1]?.body))).toMatchObject({ threadId: "thread-1", runId: "run-1", messageSeq: 1, @@ -128,9 +137,7 @@ describe("ThumbsFeedback localization", () => { value: "The answer used the wrong source.", }); expect( - (fetchMock.mock.calls[1]?.[1]?.headers as Record)[ - "Idempotency-Key" - ], + (textCall?.[1]?.headers as Record)["Idempotency-Key"], ).toEqual(expect.any(String)); }); diff --git a/packages/core/src/client/ui/AppSidebar.tsx b/packages/core/src/client/ui/AppSidebar.tsx new file mode 100644 index 00000000000..2eaa30e4235 --- /dev/null +++ b/packages/core/src/client/ui/AppSidebar.tsx @@ -0,0 +1,178 @@ +import { + AppSidebar as ToolkitAppSidebar, + AppSidebarHeader as ToolkitAppSidebarHeader, + AppSidebarFooter as ToolkitAppSidebarFooter, + useAppSidebar, + type AppSidebarLinkComponent, + type AppSidebarProps as ToolkitAppSidebarProps, + type AppSidebarHeaderProps as ToolkitAppSidebarHeaderProps, + type AppSidebarFooterProps as ToolkitAppSidebarFooterProps, +} from "@agent-native/toolkit/app-shell"; +import { forwardRef, type MouseEvent, type ReactNode } from "react"; +import { Link } from "react-router"; + +import { AgentNativeIcon } from "../components/icons/AgentNativeIcon.js"; +import { EnvironmentBadge } from "../EnvironmentBadge.js"; +import { FeedbackButton } from "../FeedbackButton.js"; + +function RouterSidebarLink({ + to, + href, + className, + onClick, + children, + "aria-label": ariaLabel, +}: { + to?: string; + href?: string; + className?: string; + onClick?: (event: MouseEvent) => void; + children?: ReactNode; + "aria-label"?: string; +}) { + return ( + + {children} + + ); +} + +export interface AppSidebarHeaderProps extends ToolkitAppSidebarHeaderProps { + badgeText?: string; + showBadge?: boolean; +} + +export const AppSidebarHeader = forwardRef< + HTMLDivElement, + AppSidebarHeaderProps +>( + ( + { + brandIcon, + badge, + badgeText, + showBadge = true, + brandLink, + brandHref = "/", + ...props + }, + ref, + ) => { + const resolvedBrandIcon = brandIcon ?? ( + + ); + + const resolvedBadge = + badge ?? + (showBadge ? ( + + ) : undefined); + + return ( + + ); + }, +); +AppSidebarHeader.displayName = "AppSidebarHeader"; + +export interface AppSidebarFooterProps extends ToolkitAppSidebarFooterProps {} + +export const AppSidebarFooter = forwardRef< + HTMLDivElement, + AppSidebarFooterProps +>(({ feedback, collapsed: propCollapsed, ...props }, ref) => { + const context = useAppSidebar(); + const collapsed = propCollapsed ?? context.collapsed; + const resolvedFeedback = + feedback !== undefined ? ( + feedback + ) : ( + + ); + + return ( + + ); +}); +AppSidebarFooter.displayName = "AppSidebarFooter"; + +export interface AppSidebarProps extends ToolkitAppSidebarProps { + badgeText?: string; + showBadge?: boolean; +} + +export const AppSidebar = forwardRef( + ( + { brandIcon, badge, badgeText, showBadge = true, linkComponent, ...props }, + ref, + ) => { + const resolvedBrandIcon = brandIcon ?? ( + + ); + + const resolvedBadge = + badge ?? + (showBadge ? ( + + ) : undefined); + + const resolvedLinkComponent: AppSidebarLinkComponent = + linkComponent ?? RouterSidebarLink; + + const resolvedFeedback = + props.feedback !== undefined ? ( + props.feedback + ) : ( + + ); + + return ( + + ); + }, +); +AppSidebar.displayName = "AppSidebar"; + +export { + AppSidebarNavItem, + AppSidebarNavGroup, + AppSidebarSection, + AppSidebarFeedbackButton, + useAppSidebar, + type AppSidebarNavItemProps, + type AppSidebarNavGroupProps, + type AppSidebarSectionProps, + type AppSidebarFeedbackButtonProps, + type AppSidebarItemDefinition, + type AppSidebarContextValue, + type AppSidebarLinkComponent, +} from "@agent-native/toolkit/app-shell"; diff --git a/packages/core/src/client/ui/index.ts b/packages/core/src/client/ui/index.ts index 3dbce6648df..7d3b74a8440 100644 --- a/packages/core/src/client/ui/index.ts +++ b/packages/core/src/client/ui/index.ts @@ -76,3 +76,22 @@ export { type AppearancePickerProps, } from "../AppearancePicker.js"; export { AgentNativeIcon } from "../components/icons/AgentNativeIcon.js"; +export { + AppSidebar, + AppSidebarHeader, + AppSidebarNavItem, + AppSidebarNavGroup, + AppSidebarSection, + AppSidebarFeedbackButton, + AppSidebarFooter, + useAppSidebar, + type AppSidebarProps, + type AppSidebarHeaderProps, + type AppSidebarNavItemProps, + type AppSidebarNavGroupProps, + type AppSidebarSectionProps, + type AppSidebarFeedbackButtonProps, + type AppSidebarFooterProps, + type AppSidebarItemDefinition, + type AppSidebarContextValue, +} from "./AppSidebar.js"; diff --git a/packages/core/src/config.ts b/packages/core/src/config.ts index 358c7d555e3..4d44ab90602 100644 --- a/packages/core/src/config.ts +++ b/packages/core/src/config.ts @@ -61,6 +61,8 @@ export type AgentNativeDeploymentEnvironment = export interface AgentNativeDeploymentConfig { /** The release lane that produced the currently running client bundle. */ environment?: AgentNativeDeploymentEnvironment; + /** Badge text override shown in the top-left sidebar header badge (e.g. "alpha" or "beta"). Defaults to "alpha". */ + badgeText?: string; } export interface AgentNativeDiagnosticsConfig { @@ -110,6 +112,8 @@ export interface AgentNativeConfig { onboarding?: AgentNativeOnboardingConfig; runtime?: AgentNativeRuntimeConfig; deployment?: AgentNativeDeploymentConfig; + /** Badge text override shown in the top-left sidebar header badge (e.g. "alpha" or "beta"). Defaults to "alpha". */ + badgeText?: string; diagnostics?: AgentNativeDiagnosticsConfig; instructions?: AgentNativeInstructionsConfig; translations?: AgentNativeTranslationsConfig; @@ -479,6 +483,7 @@ export function normalizeAgentNativeConfig( const onboardingValue = input.onboarding; const runtimeValue = input.runtime; const deploymentValue = input.deployment; + const badgeTextValue = input.badgeText; const diagnosticsValue = input.diagnostics; const instructionsValue = input.instructions; const translationsValue = input.translations; @@ -491,6 +496,13 @@ export function normalizeAgentNativeConfig( : { version: AGENT_NATIVE_CONFIG_VERSION }), }; + if (badgeTextValue !== undefined) { + if (typeof badgeTextValue !== "string") { + throw new Error(`${source}.badgeText must be a string`); + } + normalized.badgeText = badgeTextValue; + } + if (onboardingValue !== undefined) { if (!isRecord(onboardingValue)) { throw new Error(`${source}.onboarding must be an object`); @@ -617,6 +629,7 @@ export function mergeAgentNativeConfigs( ...override.deployment, } : undefined, + badgeText: override.badgeText ?? base.badgeText, diagnostics: base.diagnostics || override.diagnostics ? { @@ -766,13 +779,22 @@ function normalizeDeploymentConfig( throw new Error(`${source} must be an object`); } const environment = value.environment; - if (environment === undefined) return {}; + const badgeText = value.badgeText; + if (badgeText !== undefined && typeof badgeText !== "string") { + throw new Error(`${source}.badgeText must be a string`); + } + if (environment === undefined) { + return badgeText === undefined ? {} : { badgeText }; + } if (!isAgentNativeDeploymentEnvironment(environment)) { throw new Error( `${source}.environment must be "local", "beta", "production", or "preview"`, ); } - return { environment }; + return { + environment, + ...(badgeText === undefined ? {} : { badgeText }), + }; } function normalizeDiagnosticsConfig( diff --git a/packages/core/src/server/beta-opt-out-html.ts b/packages/core/src/server/beta-opt-out-html.ts index 87b71303588..e6f08f7b980 100644 --- a/packages/core/src/server/beta-opt-out-html.ts +++ b/packages/core/src/server/beta-opt-out-html.ts @@ -49,9 +49,9 @@ function betaRedirectBasePath(requestPath?: string): string { } const environmentSwitcherMarkup = `