Compare commits

...

4 Commits

Author SHA1 Message Date
Nikhil Sonti
72a2cc1d8a fix: enable alpha capabilities in development 2026-04-15 15:23:05 -07:00
Nikhil Sonti
fdee7f91f2 fix: gate agents page behind alpha 2026-04-15 14:58:03 -07:00
Nikhil Sonti
90691c03b9 fix: provide chat session for non-alpha home 2026-04-15 14:56:24 -07:00
Nikhil Sonti
fa07dc2a22 feat: gate agent alpha UI behind capabilities 2026-04-15 14:42:52 -07:00
12 changed files with 210 additions and 38 deletions

View File

@@ -15,6 +15,9 @@ VITE_PUBLIC_SENTRY_DSN=
# BrowserOS API URL
VITE_PUBLIC_BROWSEROS_API=https://api.browseros.com
# Feature flags
VITE_ALPHA_FEATURES=false
# GraphQL Schema Path (optional — falls back to schema/schema.graphql)
GRAPHQL_SCHEMA_PATH=

View File

@@ -80,9 +80,19 @@ const primarySettingsSections: NavSection[] = [
icon: Palette,
feature: Feature.CUSTOMIZATION_SUPPORT,
},
{ name: 'Tool Approvals', to: '/settings/approvals', icon: ShieldCheck },
{
name: 'Tool Approvals',
to: '/settings/approvals',
icon: ShieldCheck,
feature: Feature.ALPHA_FEATURES_SUPPORT,
},
{ name: 'BrowserOS as MCP', to: '/settings/mcp', icon: Server },
{ name: 'ACL Rules', to: '/settings/acl', icon: ShieldAlert },
{
name: 'ACL Rules',
to: '/settings/acl',
icon: ShieldAlert,
feature: Feature.ALPHA_FEATURES_SUPPORT,
},
{
name: 'Usage & Billing',
to: '/settings/usage',

View File

@@ -41,7 +41,12 @@ const primaryNavItems: NavItem[] = [
feature: Feature.MANAGED_MCP_SUPPORT,
},
{ name: 'Scheduled Tasks', to: '/scheduled', icon: CalendarClock },
{ name: 'Agents', to: '/agents', icon: Cpu },
{
name: 'Agents',
to: '/agents',
icon: Cpu,
feature: Feature.ALPHA_FEATURES_SUPPORT,
},
{
name: 'Skills',
to: '/home/skills',
@@ -60,7 +65,12 @@ const primaryNavItems: NavItem[] = [
icon: Sparkles,
feature: Feature.SOUL_SUPPORT,
},
{ name: 'Governance', to: '/admin', icon: Shield },
{
name: 'Governance',
to: '/admin',
icon: Shield,
feature: Feature.ALPHA_FEATURES_SUPPORT,
},
{ name: 'Settings', to: '/settings/ai', icon: Settings },
]

View File

@@ -1,5 +1,8 @@
import type { FC } from 'react'
import { HashRouter, Navigate, Route, Routes, useParams } from 'react-router'
import { Feature } from '@/lib/browseros/capabilities'
import { useCapabilities } from '@/lib/browseros/useCapabilities'
import { NewTab } from '../newtab/index/NewTab'
import { NewTabChat } from '../newtab/index/NewTabChat'
import { NewTabLayout } from '../newtab/layout/NewTabLayout'
import { Personalize } from '../newtab/personalize/Personalize'
@@ -65,6 +68,8 @@ const OptionsRedirect: FC = () => {
export const App: FC = () => {
const surveyParams = getSurveyParams()
const { supports } = useCapabilities()
const alphaEnabled = supports(Feature.ALPHA_FEATURES_SUPPORT)
return (
<HashRouter>
@@ -80,16 +85,25 @@ export const App: FC = () => {
{/* Main app with sidebar */}
<Route element={<SidebarLayout />}>
{/* Home routes */}
<Route path="home" element={<NewTabLayout />}>
<Route element={<AgentCommandLayout />}>
<Route index element={<AgentCommandHome />} />
<Route
path="agents/:agentId"
element={<AgentCommandConversation />}
/>
</Route>
<Route path="chat" element={<NewTabChat />} />
<Route path="personalize" element={<Personalize />} />
<Route
path="home"
element={<NewTabLayout useChatSessionOnHome={!alphaEnabled} />}
>
{alphaEnabled ? (
<>
<Route element={<AgentCommandLayout />}>
<Route index element={<AgentCommandHome />} />
<Route
path="agents/:agentId"
element={<AgentCommandConversation />}
/>
</Route>
<Route path="chat" element={<NewTabChat />} />
<Route path="personalize" element={<Personalize />} />
</>
) : (
<Route index element={<NewTab />} />
)}
<Route path="soul" element={<SoulPage />} />
<Route path="skills" element={<SkillsPage />} />
<Route path="memory" element={<MemoryPage />} />
@@ -98,8 +112,12 @@ export const App: FC = () => {
{/* Primary nav routes */}
<Route path="connect-apps" element={<ConnectMCP />} />
<Route path="scheduled" element={<ScheduledTasksPage />} />
<Route path="agents" element={<AgentsPage />} />
<Route path="admin" element={<AdminDashboardPage />} />
{alphaEnabled ? (
<Route path="agents" element={<AgentsPage />} />
) : null}
{alphaEnabled ? (
<Route path="admin" element={<AdminDashboardPage />} />
) : null}
</Route>
{/* Settings with dedicated sidebar */}
@@ -113,8 +131,12 @@ export const App: FC = () => {
<Route path="search" element={<SearchProviderPage />} />
<Route path="survey" element={<SurveyPage {...surveyParams} />} />
<Route path="usage" element={<UsagePage />} />
<Route path="acl" element={<AclSettingsPage />} />
<Route path="approvals" element={<ToolApprovalsPage />} />
{alphaEnabled ? (
<>
<Route path="acl" element={<AclSettingsPage />} />
<Route path="approvals" element={<ToolApprovalsPage />} />
</>
) : null}
</Route>
</Route>
@@ -130,7 +152,12 @@ export const App: FC = () => {
<Route path="/" element={<Navigate to="/home" replace />} />
<Route
path="/personalize"
element={<Navigate to="/home/personalize" replace />}
element={
<Navigate
to={alphaEnabled ? '/home/personalize' : '/home'}
replace
/>
}
/>
<Route
path="/settings/connect-mcp"
@@ -144,12 +171,18 @@ export const App: FC = () => {
path="/settings/skills"
element={<Navigate to="/home/skills" replace />}
/>
<Route path="/audit" element={<Navigate to="/admin" replace />} />
<Route
path="/audit"
element={<Navigate to={alphaEnabled ? '/admin' : '/home'} replace />}
/>
<Route
path="/observability"
element={<Navigate to="/admin" replace />}
element={<Navigate to={alphaEnabled ? '/admin' : '/home'} replace />}
/>
<Route
path="/executions"
element={<Navigate to={alphaEnabled ? '/admin' : '/home'} replace />}
/>
<Route path="/executions" element={<Navigate to="/admin" replace />} />
<Route path="/options/*" element={<OptionsRedirect />} />
{/* Fallback to home */}

View File

@@ -102,6 +102,7 @@ export const NewTab = () => {
})
const { selectedFolder } = useWorkspace()
const { supports } = useCapabilities()
const alphaEnabled = supports(Feature.ALPHA_FEATURES_SUPPORT)
const { providers, selectedProvider, handleSelectProvider } =
useChatSessionContext()
const { servers: mcpServers } = useMcpServers()
@@ -168,6 +169,8 @@ export const NewTab = () => {
selectedTabs,
})
const searchPlaceholder = `Ask BrowserOS or search ${providerConfig.name}...`
const supportsInlineChat =
alphaEnabled && supports(Feature.NEWTAB_CHAT_SUPPORT)
const {
isOpen,
@@ -357,7 +360,7 @@ export const NewTab = () => {
tabs_count: selectedTabs.length,
})
const searchQuery = `${item.name}${item.description ? ` - ${item.description}` : ''}}`
if (supports(Feature.NEWTAB_CHAT_SUPPORT)) {
if (supportsInlineChat) {
startInlineChat(searchQuery, 'agent', {
name: item.name,
description: item.description,
@@ -383,7 +386,7 @@ export const NewTab = () => {
mode: item.mode,
tabs_count: selectedTabs.length,
})
if (supports(Feature.NEWTAB_CHAT_SUPPORT)) {
if (supportsInlineChat) {
startInlineChat(item.message, item.mode)
} else {
const action = createBrowserOSAction({

View File

@@ -4,10 +4,19 @@ import { ChatSessionProvider } from '@/entrypoints/sidepanel/layout/ChatSessionC
import { NewTabFocusGrid } from './NewTabFocusGrid'
import { shouldHideFocusGrid, shouldUseChatSession } from './route-utils'
export const NewTabLayout: FC = () => {
interface NewTabLayoutProps {
useChatSessionOnHome?: boolean
}
export const NewTabLayout: FC<NewTabLayoutProps> = ({
useChatSessionOnHome = false,
}) => {
const location = useLocation()
const hideGrid = shouldHideFocusGrid(location.pathname)
const useChatSession = shouldUseChatSession(location.pathname)
const useChatSession = shouldUseChatSession(
location.pathname,
useChatSessionOnHome,
)
const content = (
<>
{!hideGrid && <NewTabFocusGrid />}

View File

@@ -13,6 +13,7 @@ describe('route-utils', () => {
expect(isAgentConversationPath('/home')).toBe(false)
expect(isAgentConversationPath('/home/agents/main')).toBe(true)
expect(shouldUseChatSession('/home')).toBe(false)
expect(shouldUseChatSession('/home', true)).toBe(true)
expect(shouldUseChatSession('/home/agents/main')).toBe(false)
expect(shouldUseChatSession('/home/chat')).toBe(true)
})

View File

@@ -19,6 +19,11 @@ export function shouldHideFocusGrid(pathname: string): boolean {
)
}
export function shouldUseChatSession(pathname: string): boolean {
return pathname === '/home/chat'
export function shouldUseChatSession(
pathname: string,
useChatSessionOnHome = false,
): boolean {
return (
pathname === '/home/chat' || (useChatSessionOnHome && pathname === '/home')
)
}

View File

@@ -0,0 +1,42 @@
import { describe, expect, it } from 'bun:test'
import { resolveStaticFeatureSupport } from './capabilities'
describe('resolveStaticFeatureSupport', () => {
it('enables alpha-gated features automatically in development', () => {
expect(
resolveStaticFeatureSupport({
isDevelopment: true,
alphaFeaturesEnabled: false,
requiresAlphaFlag: true,
}),
).toBe(true)
})
it('enables alpha-gated features only when explicitly opted in', () => {
expect(
resolveStaticFeatureSupport({
isDevelopment: false,
alphaFeaturesEnabled: true,
requiresAlphaFlag: true,
}),
).toBe(true)
})
it('keeps non-alpha features enabled in development', () => {
expect(
resolveStaticFeatureSupport({
isDevelopment: true,
alphaFeaturesEnabled: false,
}),
).toBe(true)
})
it('leaves non-alpha features unresolved in production', () => {
expect(
resolveStaticFeatureSupport({
isDevelopment: false,
alphaFeaturesEnabled: false,
}),
).toBeNull()
})
})

View File

@@ -1,3 +1,4 @@
import { env } from '../env'
import { BrowserOSAdapter } from './adapter'
const SERVER_VERSION_PREF = 'browseros.server.version'
@@ -7,16 +8,20 @@ type FeatureConfig = {
maxBrowserOSVersion?: string
minServerVersion?: string
maxServerVersion?: string
requiresAlphaFlag?: boolean
}
/**
* Features gated by BrowserOS version.
* Features gated by BrowserOS version or explicit environment flags.
* Add new features here with corresponding config in FEATURE_CONFIG.
*
* Note: In development mode, all features are enabled regardless of version.
* Note: In development mode, all features are enabled regardless of version
* or alpha flag.
* @public
*/
export enum Feature {
// Unfinished UI surfaces behind an explicit alpha opt-in
ALPHA_FEATURES_SUPPORT = 'ALPHA_FEATURES_SUPPORT',
// support for OpenAI-compatible provider
OPENAI_COMPATIBLE_SUPPORT = 'OPENAI_COMPATIBLE_SUPPORT',
// Managed MCP servers integration
@@ -61,9 +66,11 @@ export enum Feature {
* - maxServerVersion: feature enabled when server < this version (for deprecation)
*
* TypeScript enforces that every Feature has a config entry.
* Note: In development mode, all features are enabled regardless of version.
* In development mode, all features are enabled regardless of version or
* alpha flag.
*/
const FEATURE_CONFIG: { [K in Feature]: FeatureConfig } = {
[Feature.ALPHA_FEATURES_SUPPORT]: { requiresAlphaFlag: true },
[Feature.OPENAI_COMPATIBLE_SUPPORT]: { minBrowserOSVersion: '0.33.0.1' },
[Feature.MANAGED_MCP_SUPPORT]: { minBrowserOSVersion: '0.34.0.0' },
[Feature.PERSONALIZATION_SUPPORT]: { minBrowserOSVersion: '0.36.1.0' },
@@ -121,6 +128,24 @@ function checkVersionConstraints(
return true
}
export function resolveStaticFeatureSupport({
isDevelopment,
alphaFeaturesEnabled,
requiresAlphaFlag = false,
}: {
isDevelopment: boolean
alphaFeaturesEnabled: boolean
requiresAlphaFlag?: boolean
}): boolean | null {
if (isDevelopment) {
return true
}
if (requiresAlphaFlag) {
return alphaFeaturesEnabled
}
return null
}
type CapabilitiesState = {
browserOSVersion: number[] | null
serverVersion: number[] | null
@@ -128,6 +153,16 @@ type CapabilitiesState = {
let initPromise: Promise<CapabilitiesState> | null = null
function getStaticFeatureSupport(feature: Feature): boolean | null {
const config = FEATURE_CONFIG[feature]
if (!config) return false
return resolveStaticFeatureSupport({
isDevelopment: import.meta.env.DEV,
alphaFeaturesEnabled: env.VITE_ALPHA_FEATURES,
requiresAlphaFlag: config.requiresAlphaFlag,
})
}
async function doInitialize(): Promise<CapabilitiesState> {
const adapter = BrowserOSAdapter.getInstance()
const state: CapabilitiesState = {
@@ -205,12 +240,17 @@ function checkFeatureSupport(
* @public
*/
export const Capabilities = {
getStaticSupport(feature: Feature): boolean | null {
return getStaticFeatureSupport(feature)
},
/**
* Check if a feature is supported.
* In development mode, all features are enabled.
*/
async supports(feature: Feature): Promise<boolean> {
if (import.meta.env.DEV) return true
const staticSupport = getStaticFeatureSupport(feature)
if (staticSupport !== null) return staticSupport
const state = await ensureInitialized()
return checkFeatureSupport(state, feature)
},

View File

@@ -14,6 +14,19 @@ interface UseCapabilitiesResult {
serverVersion: string | null
}
function getInitialSupportedFeatures(): Map<Feature, boolean> {
return new Map(
Object.values(Feature)
.filter((value) => typeof value === 'string')
.flatMap((feature) => {
const supported = Capabilities.getStaticSupport(feature as Feature)
return supported === null
? []
: ([[feature as Feature, supported]] as const)
}),
)
}
/**
* React hook for version-gated feature checks.
* Auto-initializes Capabilities and caches feature support results.
@@ -28,11 +41,11 @@ interface UseCapabilitiesResult {
*/
export function useCapabilities(): UseCapabilitiesResult {
const [isLoading, setIsLoading] = useState(true)
const [state, setState] = useState<CapabilitiesState>({
const [state, setState] = useState<CapabilitiesState>(() => ({
browserOSVersion: null,
serverVersion: null,
supportedFeatures: new Map(),
})
supportedFeatures: getInitialSupportedFeatures(),
}))
useEffect(() => {
let cancelled = false
@@ -72,10 +85,9 @@ export function useCapabilities(): UseCapabilitiesResult {
const supports = useCallback(
(feature: Feature): boolean => {
if (isLoading) return false
return state.supportedFeatures.get(feature) ?? false
},
[isLoading, state.supportedFeatures],
[state.supportedFeatures],
)
return {

View File

@@ -2,11 +2,15 @@ import { ZodError, z } from 'zod'
const EnvSchema = z.object({
VITE_BROWSEROS_SERVER_PORT: z.coerce.number().optional(),
VITE_ALPHA_FEATURES: z
.string()
.optional()
.transform((value) => value === 'true'),
VITE_PUBLIC_POSTHOG_KEY: z.string().optional(),
VITE_PUBLIC_POSTHOG_HOST: z.string().optional(),
VITE_PUBLIC_SENTRY_DSN: z.string().optional(),
VITE_PUBLIC_BROWSEROS_API: z.string().optional(),
PROD: z.boolean(),
PROD: z.boolean().optional().default(false),
})
try {