diff --git a/apps/agent/components/sidebar/SettingsSidebar.tsx b/apps/agent/components/sidebar/SettingsSidebar.tsx
index 88e976ec4..b615a1913 100644
--- a/apps/agent/components/sidebar/SettingsSidebar.tsx
+++ b/apps/agent/components/sidebar/SettingsSidebar.tsx
@@ -16,6 +16,10 @@ import { NavLink, useLocation } from 'react-router'
import { ThemeToggle } from '@/components/elements/theme-toggle'
import { Feature } from '@/lib/browseros/capabilities'
import { useCapabilities } from '@/lib/browseros/useCapabilities'
+import {
+ getOnboardingFeaturesPath,
+ getOnboardingRevisitPath,
+} from '@/lib/onboarding/onboardingFlow'
import { cn } from '@/lib/utils'
type NavItem = {
@@ -43,8 +47,16 @@ const settingsNavItems: NavItem[] = [
feature: Feature.SOUL_SUPPORT,
},
{ name: 'Skills', to: '/settings/skills', icon: Wand2 },
- { name: 'Explore Features', to: '/onboarding/features', icon: Compass },
- { name: 'Revisit Onboarding', to: '/onboarding', icon: RotateCcw },
+ {
+ name: 'Explore Features',
+ to: getOnboardingFeaturesPath('settings'),
+ icon: Compass,
+ },
+ {
+ name: 'Revisit Onboarding',
+ to: getOnboardingRevisitPath(),
+ icon: RotateCcw,
+ },
]
export const SettingsSidebar: FC = () => {
@@ -78,7 +90,7 @@ export const SettingsSidebar: FC = () => {
{filteredItems.map((item) => {
const Icon = item.icon
- const isActive = location.pathname === item.to
+ const isActive = location.pathname === item.to.split('?')[0]
return (
{
} />
} />
- } />
+ }
+ />
} />
diff --git a/apps/agent/entrypoints/app/login/LoginPage.tsx b/apps/agent/entrypoints/app/login/LoginPage.tsx
index 2c7f02343..902c60010 100644
--- a/apps/agent/entrypoints/app/login/LoginPage.tsx
+++ b/apps/agent/entrypoints/app/login/LoginPage.tsx
@@ -21,6 +21,7 @@ import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Separator } from '@/components/ui/separator'
import { signIn, useSession } from '@/lib/auth/auth-client'
+import { authRedirectPathStorage } from '@/lib/onboarding/onboardingStorage'
type LoginState = 'idle' | 'loading' | 'magic-link-sent' | 'error'
@@ -45,6 +46,7 @@ export const LoginPage: FC = () => {
setError(null)
try {
+ await authRedirectPathStorage.removeValue()
const result = await signIn.magicLink({
email: email.trim(),
callbackURL: '/home',
@@ -68,6 +70,7 @@ export const LoginPage: FC = () => {
setError(null)
try {
+ await authRedirectPathStorage.removeValue()
await signIn.social({
provider: 'google',
callbackURL: '/home',
diff --git a/apps/agent/entrypoints/app/login/MagicLinkCallback.tsx b/apps/agent/entrypoints/app/login/MagicLinkCallback.tsx
index c64415020..4fee18ddc 100644
--- a/apps/agent/entrypoints/app/login/MagicLinkCallback.tsx
+++ b/apps/agent/entrypoints/app/login/MagicLinkCallback.tsx
@@ -12,6 +12,7 @@ import {
CardTitle,
} from '@/components/ui/card'
import { useSession } from '@/lib/auth/auth-client'
+import { authRedirectPathStorage } from '@/lib/onboarding/onboardingStorage'
export const MagicLinkCallback: FC = () => {
const navigate = useNavigate()
@@ -20,14 +21,27 @@ export const MagicLinkCallback: FC = () => {
const [error, setError] = useState(null)
useEffect(() => {
+ let cancelled = false
const errorParam = searchParams.get('error')
if (errorParam) {
setError(decodeURIComponent(errorParam))
return
}
- if (!isPending && session) {
- navigate('/home', { replace: true })
+ if (isPending || !session) return
+
+ const redirectAfterAuth = async () => {
+ const redirectPath = await authRedirectPathStorage.getValue()
+ if (redirectPath) {
+ await authRedirectPathStorage.removeValue()
+ }
+ if (cancelled) return
+ navigate(redirectPath || '/home', { replace: true })
+ }
+
+ void redirectAfterAuth()
+ return () => {
+ cancelled = true
}
}, [session, isPending, searchParams, navigate])
diff --git a/apps/agent/entrypoints/newtab/index/NewTab.tsx b/apps/agent/entrypoints/newtab/index/NewTab.tsx
index 391128a1c..e948404d6 100644
--- a/apps/agent/entrypoints/newtab/index/NewTab.tsx
+++ b/apps/agent/entrypoints/newtab/index/NewTab.tsx
@@ -49,6 +49,11 @@ import { useMcpServers } from '@/lib/mcp/mcpServerStorage'
import { useSyncRemoteIntegrations } from '@/lib/mcp/useSyncRemoteIntegrations'
import { openSidePanelWithSearch } from '@/lib/messaging/sidepanel/openSidepanelWithSearch'
import { track } from '@/lib/metrics/track'
+import {
+ getSearchActionFingerprint,
+ isSearchActionForTarget,
+ searchActionsStorage,
+} from '@/lib/search-actions/searchActionsStorage'
import { cn } from '@/lib/utils'
import { useWorkspace } from '@/lib/workspace/use-workspace'
import { ImportDataHint } from './ImportDataHint'
@@ -98,6 +103,16 @@ export const NewTab = () => {
const { messages, sendMessage, setMode, resetConversation } =
useChatSessionContext()
+ const sendMessageRef = useRef(sendMessage)
+ const setModeRef = useRef(setMode)
+ const resetConversationRef = useRef(resetConversation)
+ const processingSearchActionRef = useRef(null)
+
+ useEffect(() => {
+ sendMessageRef.current = sendMessage
+ setModeRef.current = setMode
+ resetConversationRef.current = resetConversation
+ }, [resetConversation, sendMessage, setMode])
const connectedManagedServers = mcpServers.filter((s) => {
if (s.type !== 'managed' || !s.managedServerName) return false
@@ -357,6 +372,61 @@ export const NewTab = () => {
setChatActive(false)
}
+ useEffect(() => {
+ let cancelled = false
+
+ const consumeSearchAction = async (
+ storageAction: Awaited>,
+ ) => {
+ const currentTab = await chrome.tabs.getCurrent().catch(() => undefined)
+ const currentTabId = currentTab?.id
+
+ if (
+ cancelled ||
+ !storageAction ||
+ !isSearchActionForTarget(storageAction, 'newtab', currentTabId)
+ ) {
+ return
+ }
+
+ const fingerprint = getSearchActionFingerprint(storageAction)
+ if (processingSearchActionRef.current === fingerprint) {
+ return
+ }
+
+ processingSearchActionRef.current = fingerprint
+
+ try {
+ await searchActionsStorage.removeValue()
+ if (cancelled) return
+
+ resetConversationRef.current()
+ setModeRef.current(storageAction.mode)
+ setChatActive(true)
+ sendMessageRef.current({
+ text: storageAction.query,
+ action: storageAction.action,
+ })
+ } finally {
+ processingSearchActionRef.current = null
+ }
+ }
+
+ searchActionsStorage
+ .getValue()
+ .then(consumeSearchAction)
+ .catch(() => {})
+
+ const unwatch = searchActionsStorage.watch((storageAction) => {
+ consumeSearchAction(storageAction).catch(() => {})
+ })
+
+ return () => {
+ cancelled = true
+ unwatch()
+ }
+ }, [])
+
const isSuggestionsVisible =
!mentionState.isOpen &&
((isOpen && inputValue.length) ||
diff --git a/apps/agent/entrypoints/onboarding/demo/OnboardingDemo.tsx b/apps/agent/entrypoints/onboarding/demo/OnboardingDemo.tsx
index c65b27ee2..e3f97efdd 100644
--- a/apps/agent/entrypoints/onboarding/demo/OnboardingDemo.tsx
+++ b/apps/agent/entrypoints/onboarding/demo/OnboardingDemo.tsx
@@ -1,164 +1,6 @@
-import { ArrowRight, Sparkles } from 'lucide-react'
-import { useEffect, useState } from 'react'
-import { Button } from '@/components/ui/button'
-import { Input } from '@/components/ui/input'
-import {
- ONBOARDING_COMPLETED_EVENT,
- ONBOARDING_DEMO_TRIGGERED_EVENT,
-} from '@/lib/constants/analyticsEvents'
-import { openSidePanelWithSearch } from '@/lib/messaging/sidepanel/openSidepanelWithSearch'
-import { track } from '@/lib/metrics/track'
-import {
- onboardingCompletedStorage,
- onboardingProfileStorage,
-} from '@/lib/onboarding/onboardingStorage'
+import type { FC } from 'react'
+import { Navigate } from 'react-router'
-function buildDemoSuggestions(company?: string) {
- return [
- company
- ? {
- label: `Search for ${company} and summarize the latest news`,
- query: `Search for ${company} and summarize the latest news about them`,
- mode: 'agent' as const,
- }
- : {
- label: "What's the top tech news today",
- query: "What's the top tech news today? Give me a brief summary",
- mode: 'agent' as const,
- },
- {
- label: "What's the top news today",
- query:
- "What's the top news today? Give me a brief summary of the biggest stories",
- mode: 'agent' as const,
- },
- {
- label: 'Find me a good restaurant nearby',
- query: 'Find me a good restaurant nearby',
- mode: 'agent' as const,
- },
- ]
-}
-
-export const OnboardingDemo = () => {
- const [customQuery, setCustomQuery] = useState('')
- const [demoSuggestions, setDemoSuggestions] = useState(() =>
- buildDemoSuggestions(),
- )
-
- useEffect(() => {
- onboardingProfileStorage.getValue().then((profile) => {
- if (profile?.company) {
- setDemoSuggestions(buildDemoSuggestions(profile.company))
- }
- })
- }, [])
-
- const completeOnboarding = async () => {
- await onboardingCompletedStorage.setValue(true)
- track(ONBOARDING_COMPLETED_EVENT)
- }
-
- const handleDemoTask = async (
- query: string,
- mode: 'chat' | 'agent',
- index: number,
- ) => {
- track(ONBOARDING_DEMO_TRIGGERED_EVENT, {
- query,
- mode,
- source: 'suggestion',
- suggestion_index: index,
- })
- await completeOnboarding()
-
- await chrome.tabs.create({ active: true })
- await new Promise((resolve) => setTimeout(resolve, 500))
- openSidePanelWithSearch('open', { query, mode })
- }
-
- const handleCustomQuery = async (e: React.FormEvent) => {
- e.preventDefault()
- if (!customQuery.trim()) return
-
- track(ONBOARDING_DEMO_TRIGGERED_EVENT, {
- query: customQuery.trim(),
- mode: 'agent',
- source: 'custom',
- })
- await completeOnboarding()
-
- await chrome.tabs.create({ active: true })
- await new Promise((resolve) => setTimeout(resolve, 500))
- openSidePanelWithSearch('open', {
- query: customQuery.trim(),
- mode: 'agent',
- })
- }
-
- const handleSkip = async () => {
- track(ONBOARDING_DEMO_TRIGGERED_EVENT, { skipped: true })
- await completeOnboarding()
- window.location.href = chrome.runtime.getURL('app.html#/home')
- }
-
- return (
-
-
-
-
-
-
-
- Try your first task
-
-
- Pick a suggestion or type your own to see BrowserOS in action
-
-
-
-
- {demoSuggestions.map((suggestion, index) => (
-
- handleDemoTask(suggestion.query, suggestion.mode, index)
- }
- className="flex w-full items-center justify-between rounded-lg border border-border bg-card p-4 text-left transition-colors hover:border-[var(--accent-orange)]/50 hover:bg-accent"
- >
- {suggestion.label}
-
-
- ))}
-
-
-
-
-
-
- Skip and go to homepage
-
-
-
-
- )
+export const OnboardingDemo: FC = () => {
+ return
}
diff --git a/apps/agent/entrypoints/onboarding/features/Features.tsx b/apps/agent/entrypoints/onboarding/features/Features.tsx
index a7414d115..483f71993 100644
--- a/apps/agent/entrypoints/onboarding/features/Features.tsx
+++ b/apps/agent/entrypoints/onboarding/features/Features.tsx
@@ -11,6 +11,7 @@ import {
SplitSquareHorizontal,
} from 'lucide-react'
import { type FC, useEffect, useState } from 'react'
+import { NavLink, useSearchParams } from 'react-router'
import DiscordLogo from '@/assets/discord-logo.svg'
import GithubLogo from '@/assets/github-logo.svg'
import SlackLogo from '@/assets/slack-logo.svg'
@@ -31,6 +32,11 @@ import {
productRepositoryUrl,
slackUrl,
} from '@/lib/constants/productUrls'
+import {
+ getOnboardingFlowSource,
+ getOnboardingStepPath,
+ ONBOARDING_ENTRY_PATH,
+} from '@/lib/onboarding/onboardingFlow'
import { cn } from '@/lib/utils'
import { BentoCard, type Feature } from './BentoCard'
import { VideoFrame } from './VideoFrame'
@@ -153,7 +159,10 @@ const features: Feature[] = [
* @public
*/
export const FeaturesPage: FC = () => {
+ const [searchParams] = useSearchParams()
const [mounted, setMounted] = useState(false)
+ const source = getOnboardingFlowSource(searchParams)
+ const isSetupFlow = source === 'setup'
useEffect(() => {
setMounted(true)
@@ -206,8 +215,9 @@ export const FeaturesPage: FC = () => {
: 'translate-y-4 opacity-0',
)}
>
- Watch our launch video to understand the vision of BrowserOS
- and key features!
+ {isSetupFlow
+ ? 'Before setup, here is the fastest way to understand what BrowserOS can actually do.'
+ : 'Watch our launch video to understand the vision of BrowserOS and key features!'}
@@ -395,14 +405,39 @@ export const FeaturesPage: FC = () => {
-
- Start Using BrowserOS
-
-
+ {isSetupFlow ? (
+ <>
+
+
+ Continue Setup
+
+
+
+
+ Back to Welcome
+
+ >
+ ) : (
+ <>
+
+ Start Using BrowserOS
+
+
+
+
+ Revisit Guided Setup
+
+
+ >
+ )}
diff --git a/apps/agent/entrypoints/onboarding/index/Onboarding.tsx b/apps/agent/entrypoints/onboarding/index/Onboarding.tsx
index 7e22193f8..3749431ba 100644
--- a/apps/agent/entrypoints/onboarding/index/Onboarding.tsx
+++ b/apps/agent/entrypoints/onboarding/index/Onboarding.tsx
@@ -1,4 +1,11 @@
-import { ArrowRight } from 'lucide-react'
+import {
+ ArrowRight,
+ BrainCircuit,
+ CalendarClock,
+ KeyRound,
+ Upload,
+ Wand2,
+} from 'lucide-react'
import { type FC, useEffect, useState } from 'react'
import { NavLink } from 'react-router'
import { PillIndicator } from '@/components/elements/pill-indicator'
@@ -10,6 +17,37 @@ import { track } from '@/lib/metrics/track'
import { FocusGrid } from './FocusGrid'
import { OnboardingHeader } from './OnboardingHeader'
+const setupSteps = [
+ 'Meet your agent and set the names',
+ 'Import Chrome context',
+ 'Connect Google, Gmail, and Calendar',
+ 'Preview soul, skills, and schedules',
+ 'Launch a LinkedIn-aware BrowserOS chat',
+]
+
+const capabilityCards = [
+ {
+ title: 'SOUL.md',
+ description: 'Adjust tone, boundaries, and personality.',
+ Icon: BrainCircuit,
+ },
+ {
+ title: 'Skills',
+ description: 'Teach repeatable workflows with custom instructions.',
+ Icon: Wand2,
+ },
+ {
+ title: 'Bring Your Own Keys',
+ description: 'Run on your own models and providers.',
+ Icon: KeyRound,
+ },
+ {
+ title: 'Scheduled Tasks',
+ description: 'Turn useful prompts into daily automation.',
+ Icon: CalendarClock,
+ },
+]
+
export const Onboarding: FC = () => {
const [mounted, setMounted] = useState(false)
@@ -22,51 +60,70 @@ export const Onboarding: FC = () => {
-
+
-
-
-
+
+
+
+
-
- Welcome to{' '}
-
- BrowserOS
-
-
+
+
+ Onboard into{' '}
+ BrowserOS {' '}
+ like it can actually do something.
+
+
+ We'll import your Chrome context, wire up Google, attach
+ LinkedIn to the first chat, and show you where soul, skills,
+ your own model keys, and scheduled tasks live from day one.
+
+
+
-
- Turn your words into actions. Privacy-first alternative to ChatGPT
- Atlas, Perplexity Comet and Dia!
-
+ {capabilityCards.map(({ title, description, Icon }) => (
+
+
+
+
+
+
{title}
+
+ {description}
+
+
+
+ ))}
+
- Get Started
+ Start setup
-
+
{
+
+
+
+
+
+
+
+
+
+ Five-step setup
+
+
What happens next
+
+
+
+
+ {setupSteps.map((step, index) => (
+
+
+ {(index + 1).toString()}
+
+
+ {step}
+
+
+ ))}
+
+
+
diff --git a/apps/agent/entrypoints/onboarding/steps/CapabilitiesStep.tsx b/apps/agent/entrypoints/onboarding/steps/CapabilitiesStep.tsx
new file mode 100644
index 000000000..8a011fc37
--- /dev/null
+++ b/apps/agent/entrypoints/onboarding/steps/CapabilitiesStep.tsx
@@ -0,0 +1,165 @@
+import {
+ BrainCircuit,
+ CalendarClock,
+ KeyRound,
+ Sparkles,
+ Wand2,
+} from 'lucide-react'
+import type { FC } from 'react'
+import { Badge } from '@/components/ui/badge'
+import { Button } from '@/components/ui/button'
+import { ONBOARDING_STEP_COMPLETED_EVENT } from '@/lib/constants/analyticsEvents'
+import { track } from '@/lib/metrics/track'
+import { onboardingProfileStorage } from '@/lib/onboarding/onboardingStorage'
+import { StepScaffold } from './StepScaffold'
+import { type StepDirection, StepTransition } from './StepTransition'
+
+interface CapabilitiesStepProps {
+ direction: StepDirection
+ onContinue: () => void
+}
+
+const capabilityCards = [
+ {
+ title: 'Evolve your `SOUL.md`',
+ description:
+ 'BrowserOS can change how it behaves, not just what it knows. Tone, boundaries, and working style live in your soul.',
+ route: '/settings/soul',
+ Icon: BrainCircuit,
+ },
+ {
+ title: 'Create custom skills',
+ description:
+ 'Teach the agent repeatable workflows with your own instructions, templates, and execution rules.',
+ route: '/settings/skills',
+ Icon: Wand2,
+ },
+ {
+ title: 'Bring your own model',
+ description:
+ 'Use your own providers and API keys so BrowserOS runs with the stack and budget you prefer.',
+ route: '/settings/ai',
+ Icon: KeyRound,
+ },
+ {
+ title: 'Schedule recurring work',
+ description:
+ 'Turn useful prompts into daily or hourly automations that run inside BrowserOS for you.',
+ route: '/scheduled',
+ Icon: CalendarClock,
+ },
+]
+
+export const CapabilitiesStep: FC
= ({
+ direction,
+ onContinue,
+}) => {
+ const handleContinue = async () => {
+ const profile = await onboardingProfileStorage.getValue()
+ track(ONBOARDING_STEP_COMPLETED_EVENT, {
+ step: 4,
+ step_name: 'teach_agent',
+ assistant_name: profile?.assistantName ?? 'BrowserOS',
+ })
+ onContinue()
+ }
+
+ const openRoute = async (path: string) => {
+ await chrome.tabs.create({
+ url: chrome.runtime.getURL(`app.html#${path}`),
+ active: false,
+ })
+ }
+
+ return (
+
+
+
+
+ What the first chat will do
+
+
+ BrowserOS will open with your LinkedIn context attached, greet
+ you by name, explain these capabilities in plain English, and
+ ask whether it can learn more from Gmail and Calendar.
+
+
+
+
+
+
+ By the time you land on home, BrowserOS should feel like a
+ product that can browse, learn, adapt, and schedule work, not
+ just answer prompts.
+
+
+
+ }
+ >
+
+
+ {capabilityCards.map(({ title, description, route, Icon }) => (
+
+
+
+
+
+
openRoute(route)}
+ >
+ Preview
+
+
+
+
{title}
+
+ {description}
+
+
+
+ ))}
+
+
+
+
What happens after this
+
+ BrowserOS will open a chat on the main home page, inspect the
+ LinkedIn tab we attach for context, and then offer to go deeper
+ with connected Gmail and Calendar. It will also surface the idea
+ of a daily 9:00 AM briefing task instead of waiting for you to
+ discover schedules later.
+
+
+
+
+
+ Continue
+
+
+
+
+
+ )
+}
diff --git a/apps/agent/entrypoints/onboarding/steps/ImportChromeStep.tsx b/apps/agent/entrypoints/onboarding/steps/ImportChromeStep.tsx
new file mode 100644
index 000000000..b0a0d5777
--- /dev/null
+++ b/apps/agent/entrypoints/onboarding/steps/ImportChromeStep.tsx
@@ -0,0 +1,197 @@
+import { ArrowUpRight, Bookmark, History, KeyRound, Upload } from 'lucide-react'
+import { type FC, useEffect, useState } from 'react'
+import { Badge } from '@/components/ui/badge'
+import { Button } from '@/components/ui/button'
+import {
+ ONBOARDING_STEP_COMPLETED_EVENT,
+ ONBOARDING_STEP_VIEWED_EVENT,
+} from '@/lib/constants/analyticsEvents'
+import { track } from '@/lib/metrics/track'
+import {
+ importHintDismissedAtStorage,
+ onboardingProfileStorage,
+} from '@/lib/onboarding/onboardingStorage'
+import { StepScaffold } from './StepScaffold'
+import { type StepDirection, StepTransition } from './StepTransition'
+
+interface ImportChromeStepProps {
+ direction: StepDirection
+ onContinue: () => void
+}
+
+const IMPORT_SETTINGS_URL = 'chrome://settings/importData'
+
+export const ImportChromeStep: FC = ({
+ direction,
+ onContinue,
+}) => {
+ const [hasOpenedImport, setHasOpenedImport] = useState(false)
+
+ useEffect(() => {
+ onboardingProfileStorage.getValue().then((profile) => {
+ if (profile?.importStatus === 'imported') {
+ setHasOpenedImport(true)
+ }
+ })
+ }, [])
+
+ const completeStep = async (status: 'imported' | 'skipped') => {
+ const existingProfile = await onboardingProfileStorage.getValue()
+ if (existingProfile) {
+ await onboardingProfileStorage.setValue({
+ ...existingProfile,
+ importStatus: status,
+ })
+ }
+ await importHintDismissedAtStorage.setValue(
+ Date.now() + 100 * 365 * 24 * 60 * 60 * 1000,
+ )
+ track(ONBOARDING_STEP_COMPLETED_EVENT, {
+ step: 2,
+ step_name: 'import_chrome',
+ import_status: status,
+ })
+ onContinue()
+ }
+
+ const handleOpenImport = async () => {
+ setHasOpenedImport(true)
+ await chrome.tabs.create({ url: IMPORT_SETTINGS_URL })
+ track(ONBOARDING_STEP_VIEWED_EVENT, {
+ step: 2,
+ step_name: 'import_chrome_settings_opened',
+ })
+ }
+
+ return (
+
+
+
+
+ One native handoff
+
+
+ BrowserOS uses Chrome's native import flow here, so the setup
+ stays familiar and you stay in control of what comes over.
+
+
+
+
+ {[
+ {
+ icon: Bookmark,
+ title: 'Bookmarks',
+ description:
+ 'Better first-run suggestions and a more useful browser memory.',
+ },
+ {
+ icon: History,
+ title: 'History',
+ description:
+ 'Recent browsing patterns help the agent understand your workflow faster.',
+ },
+ {
+ icon: KeyRound,
+ title: 'Saved logins',
+ description:
+ 'Makes it easier to use authenticated sites from day one.',
+ },
+ ].map(({ icon: Icon, title, description }) => (
+
+ ))}
+
+
+ }
+ >
+
+
+
+
+
+
+
+ Open Chrome's import sheet
+
+
+ This opens `chrome://settings/importData` in a new tab. Import
+ what you want, then come back here to keep going.
+
+
+
+
+
+
+
+
+ Bring over history, passwords, and bookmarks.
+
+
+
+
completeStep('skipped')}
+ >
+ Skip for now
+
+ You can still import later from BrowserOS home.
+
+
+
+
+
+
+ {hasOpenedImport
+ ? 'Once you have finished importing in Chrome settings, continue here.'
+ : 'If you do not want to import yet, you can skip and keep the flow moving.'}
+
+
+ completeStep(hasOpenedImport ? 'imported' : 'skipped')
+ }
+ >
+ {hasOpenedImport ? 'I finished importing' : 'Continue'}
+
+
+
+
+
+ )
+}
diff --git a/apps/agent/entrypoints/onboarding/steps/LaunchStep.tsx b/apps/agent/entrypoints/onboarding/steps/LaunchStep.tsx
new file mode 100644
index 000000000..8fc5b29ca
--- /dev/null
+++ b/apps/agent/entrypoints/onboarding/steps/LaunchStep.tsx
@@ -0,0 +1,218 @@
+import {
+ CheckCircle2,
+ Linkedin,
+ Loader2,
+ MessageCircleCode,
+ Zap,
+} from 'lucide-react'
+import { type FC, useMemo, useState } from 'react'
+import { Button } from '@/components/ui/button'
+import {
+ ONBOARDING_COMPLETED_EVENT,
+ ONBOARDING_STEP_COMPLETED_EVENT,
+} from '@/lib/constants/analyticsEvents'
+import { track } from '@/lib/metrics/track'
+import {
+ buildOnboardingScheduledTaskUrl,
+ seedOnboardingHomeChat,
+} from '@/lib/onboarding/launchOnboardingChat'
+import {
+ firstRunConfettiShownStorage,
+ importHintDismissedAtStorage,
+ onboardingCompletedStorage,
+ onboardingProfileStorage,
+ signInHintDismissedAtStorage,
+} from '@/lib/onboarding/onboardingStorage'
+import { useGetUserMCPIntegrations } from '../../app/connect-mcp/useGetUserMCPIntegrations'
+import { StepScaffold } from './StepScaffold'
+import { type StepDirection, StepTransition } from './StepTransition'
+
+interface LaunchStepProps {
+ direction: StepDirection
+ onContinue: () => void
+}
+
+const VERY_LONG_TIME_MS = 100 * 365 * 24 * 60 * 60 * 1000
+
+export const LaunchStep: FC = ({ direction, onContinue }) => {
+ const { data: integrations } = useGetUserMCPIntegrations()
+ const [isLaunching, setIsLaunching] = useState(false)
+
+ const gmailConnected =
+ integrations?.integrations?.find((item) => item.name === 'Gmail')
+ ?.is_authenticated ?? false
+ const calendarConnected =
+ integrations?.integrations?.find((item) => item.name === 'Google Calendar')
+ ?.is_authenticated ?? false
+
+ const connectedAppsSummary = useMemo(() => {
+ if (gmailConnected && calendarConnected) {
+ return 'BrowserOS will mention that Gmail and Google Calendar are already connected, then ask whether it may read a small amount of recent context.'
+ }
+ if (gmailConnected || calendarConnected) {
+ return 'BrowserOS will explain that one Google source is already connected and that adding the other one later gives it a better picture of your work.'
+ }
+ return 'BrowserOS will explain how Gmail and Google Calendar make the first-run chat more useful once you connect them.'
+ }, [calendarConnected, gmailConnected])
+
+ const handleSchedulePreview = async () => {
+ await chrome.tabs.create({
+ url: buildOnboardingScheduledTaskUrl(),
+ active: false,
+ })
+ }
+
+ const handleLaunch = async () => {
+ setIsLaunching(true)
+
+ try {
+ const profile = await onboardingProfileStorage.getValue()
+ await seedOnboardingHomeChat({
+ profile,
+ gmailConnected,
+ calendarConnected,
+ })
+
+ const dismissUntil = Date.now() + VERY_LONG_TIME_MS
+ await onboardingCompletedStorage.setValue(true)
+ await importHintDismissedAtStorage.setValue(dismissUntil)
+ await signInHintDismissedAtStorage.setValue(dismissUntil)
+ await firstRunConfettiShownStorage.setValue(false)
+
+ track(ONBOARDING_STEP_COMPLETED_EVENT, {
+ step: 5,
+ step_name: 'launch',
+ gmail_connected: gmailConnected,
+ calendar_connected: calendarConnected,
+ })
+ track(ONBOARDING_COMPLETED_EVENT, {
+ gmail_connected: gmailConnected,
+ calendar_connected: calendarConnected,
+ })
+
+ onContinue()
+ } finally {
+ setIsLaunching(false)
+ }
+ }
+
+ return (
+
+
+
+
+ What the first reply will do
+
+
+ BrowserOS will greet you by name, inspect the LinkedIn tab we
+ attach in the background, then explain how it can personalize
+ soul, skills, models, and scheduled tasks.
+
+
+
+
+
+
+
+ BrowserOS uses LinkedIn as the first context source instead of
+ a blank introduction.
+
+
+
+
+
+
+
Consent stays explicit
+
+
+ {connectedAppsSummary}
+
+
+
+
+
+
+ The launch chat will mention a recurring inbox and calendar
+ briefing instead of waiting for you to discover schedules
+ later.
+
+
+
+
+ }
+ >
+
+
+ {[
+ 'Open BrowserOS home and start inline chat automatically.',
+ 'Attach LinkedIn in the background so the first response has real context.',
+ 'Ask before reading Gmail and Google Calendar, even when they are connected.',
+ 'Suggest a daily 9:00 AM automation path for your inbox and calendar.',
+ ].map((item) => (
+
+ ))}
+
+
+
+
Optional preview
+
+ If you want to see the schedule UI immediately, open the prefilled
+ daily-briefing draft in the background now. The first chat will
+ still suggest it again after launch.
+
+
+ Preview the 9:00 AM schedule draft
+
+
+
+
+
+ The launch target is the main home page, not the old onboarding
+ demo route.
+
+
+ {isLaunching ? (
+ <>
+
+ Launching BrowserOS...
+ >
+ ) : (
+ 'Open BrowserOS home'
+ )}
+
+
+
+
+
+ )
+}
diff --git a/apps/agent/entrypoints/onboarding/steps/ManagedAppConnectionCard.tsx b/apps/agent/entrypoints/onboarding/steps/ManagedAppConnectionCard.tsx
new file mode 100644
index 000000000..f14e65c06
--- /dev/null
+++ b/apps/agent/entrypoints/onboarding/steps/ManagedAppConnectionCard.tsx
@@ -0,0 +1,219 @@
+import {
+ CheckCircle2,
+ ExternalLink,
+ Loader2,
+ type LucideIcon,
+} from 'lucide-react'
+import { type FC, useMemo, useState } from 'react'
+import { toast } from 'sonner'
+import { Button } from '@/components/ui/button'
+import { Card, CardContent } from '@/components/ui/card'
+import { MANAGED_MCP_ADDED_EVENT } from '@/lib/constants/analyticsEvents'
+import { type McpServer, useMcpServers } from '@/lib/mcp/mcpServerStorage'
+import { useSyncRemoteIntegrations } from '@/lib/mcp/useSyncRemoteIntegrations'
+import { track } from '@/lib/metrics/track'
+import { sentry } from '@/lib/sentry/sentry'
+import { ApiKeyDialog } from '../../app/connect-mcp/ApiKeyDialog'
+import { useAddManagedServer } from '../../app/connect-mcp/useAddManagedServer'
+import { useGetUserMCPIntegrations } from '../../app/connect-mcp/useGetUserMCPIntegrations'
+import { useSubmitApiKey } from '../../app/connect-mcp/useSubmitApiKey'
+
+interface ManagedAppConnectionCardProps {
+ appName: string
+ description: string
+ Icon: LucideIcon
+ disabled?: boolean
+ disabledReason?: string
+}
+
+export const ManagedAppConnectionCard: FC = ({
+ appName,
+ description,
+ Icon,
+ disabled = false,
+ disabledReason,
+}) => {
+ const { servers, addServer } = useMcpServers()
+ const { data: integrations, mutate: mutateIntegrations } =
+ useGetUserMCPIntegrations()
+ const { trigger: addManagedServerMutation } = useAddManagedServer()
+ const { trigger: submitApiKeyMutation, isMutating: isSubmittingApiKey } =
+ useSubmitApiKey()
+ const [isConnecting, setIsConnecting] = useState(false)
+ const [hasPendingOauth, setHasPendingOauth] = useState(false)
+ const [apiKeyServer, setApiKeyServer] = useState<{
+ name: string
+ apiKeyUrl: string
+ } | null>(null)
+
+ useSyncRemoteIntegrations()
+
+ const localServer = useMemo(
+ () => servers.find((server) => server.managedServerName === appName),
+ [appName, servers],
+ )
+
+ const isConnected =
+ integrations?.integrations?.find((item) => item.name === appName)
+ ?.is_authenticated ?? false
+ const showConnected = !disabled && isConnected
+
+ const ensureLocalServer = async () => {
+ if (localServer) return
+
+ const server: McpServer = {
+ id: `${Date.now()}-${appName}`,
+ displayName: appName,
+ type: 'managed',
+ managedServerName: appName,
+ managedServerDescription: description,
+ }
+ await addServer(server)
+ track(MANAGED_MCP_ADDED_EVENT, { server_name: appName })
+ }
+
+ const handleConnect = async () => {
+ if (disabled) return
+
+ setIsConnecting(true)
+ try {
+ const response = await addManagedServerMutation({
+ serverName: appName,
+ })
+
+ await ensureLocalServer()
+
+ if (response.apiKeyUrl) {
+ setApiKeyServer({ name: appName, apiKeyUrl: response.apiKeyUrl })
+ return
+ }
+
+ if (!response.oauthUrl) {
+ throw new Error(`No authorization URL returned for ${appName}`)
+ }
+
+ window.open(response.oauthUrl, '_blank')?.focus()
+ setHasPendingOauth(true)
+ } catch (error) {
+ toast.error(
+ `Failed to connect ${appName}: ${error instanceof Error ? error.message : 'Unknown error'}`,
+ )
+ sentry.captureException(error)
+ } finally {
+ setIsConnecting(false)
+ }
+ }
+
+ const handleRefresh = async () => {
+ setIsConnecting(true)
+ try {
+ const refreshed = await mutateIntegrations()
+ const isNowConnected =
+ refreshed?.integrations?.find((item) => item.name === appName)
+ ?.is_authenticated ?? false
+ if (isNowConnected) {
+ setHasPendingOauth(false)
+ }
+ } finally {
+ setIsConnecting(false)
+ }
+ }
+
+ const handleSubmitApiKey = async (apiKey: string) => {
+ if (!apiKeyServer) return
+
+ try {
+ await submitApiKeyMutation({
+ serverName: apiKeyServer.name,
+ apiKey,
+ apiKeyUrl: apiKeyServer.apiKeyUrl,
+ })
+ await ensureLocalServer()
+ await mutateIntegrations()
+ setApiKeyServer(null)
+ setHasPendingOauth(false)
+ toast.success(`${apiKeyServer.name} connected`)
+ } catch (error) {
+ toast.error(
+ `Failed to connect ${apiKeyServer.name}: ${error instanceof Error ? error.message : 'Unknown error'}`,
+ )
+ sentry.captureException(error)
+ }
+ }
+
+ const statusLabel = disabled
+ ? disabledReason
+ : showConnected
+ ? 'Connected'
+ : hasPendingOauth || localServer
+ ? 'Finish authorization'
+ : 'Not connected'
+
+ return (
+ <>
+
+
+
+
+
+
+
+
+
{appName}
+ {showConnected && (
+
+
+ Ready
+
+ )}
+
+
{description}
+
+
+
+
+ {statusLabel}
+
+
+
+
+ {isConnecting ? (
+
+ ) : (
+
+ )}
+ {showConnected ? 'Connected' : `Connect ${appName}`}
+
+
+ {!disabled &&
+ !showConnected &&
+ (hasPendingOauth || localServer) && (
+
+ I finished authorizing
+
+ )}
+
+
+
+
+ {
+ if (!open) setApiKeyServer(null)
+ }}
+ onSubmit={handleSubmitApiKey}
+ />
+ >
+ )
+}
diff --git a/apps/agent/entrypoints/onboarding/steps/OnboardingProgress.tsx b/apps/agent/entrypoints/onboarding/steps/OnboardingProgress.tsx
new file mode 100644
index 000000000..595f8a3eb
--- /dev/null
+++ b/apps/agent/entrypoints/onboarding/steps/OnboardingProgress.tsx
@@ -0,0 +1,58 @@
+import { Check } from 'lucide-react'
+import { onboardingProgressSteps } from '@/lib/onboarding/onboardingFlow'
+
+export const OnboardingProgress = ({
+ currentStep,
+}: {
+ currentStep: 1 | 2 | 3
+}) => {
+ return (
+
+
+
+ {onboardingProgressSteps.map((step) => {
+ const isCompleted = step.id < currentStep
+ const isActive = step.id === currentStep
+
+ return (
+
+
+
+ {isActive && (
+
+ )}
+
+ {isCompleted ? : step.id}
+
+
+
+
+
+ )
+ })}
+
+
+
+ )
+}
diff --git a/apps/agent/entrypoints/onboarding/steps/StepOne.tsx b/apps/agent/entrypoints/onboarding/steps/StepOne.tsx
index aef080e38..587bcb6ff 100644
--- a/apps/agent/entrypoints/onboarding/steps/StepOne.tsx
+++ b/apps/agent/entrypoints/onboarding/steps/StepOne.tsx
@@ -1,8 +1,15 @@
import { zodResolver } from '@hookform/resolvers/zod'
-import { Check, ChevronsUpDown } from 'lucide-react'
-import { useState } from 'react'
+import {
+ BriefcaseBusiness,
+ Check,
+ ChevronsUpDown,
+ Sparkles,
+ UserRound,
+} from 'lucide-react'
+import { useEffect, useState } from 'react'
import { useForm } from 'react-hook-form'
import { z } from 'zod/v3'
+import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import {
Command,
@@ -35,6 +42,7 @@ import { track } from '@/lib/metrics/track'
import { onboardingProfileStorage } from '@/lib/onboarding/onboardingStorage'
import { personalizationStorage } from '@/lib/personalization/personalizationStorage'
import { cn } from '@/lib/utils'
+import { StepScaffold } from './StepScaffold'
import { type StepDirection, StepTransition } from './StepTransition'
interface StepOneProps {
@@ -43,6 +51,7 @@ interface StepOneProps {
}
const roles = [
+ 'Founder / Co-Founder',
'Software Engineer',
'Frontend Engineer',
'Backend Engineer',
@@ -50,30 +59,22 @@ const roles = [
'DevOps Engineer',
'Data Engineer',
'ML Engineer',
- 'Engineering Manager',
'Tech Lead',
'CTO',
- 'VP of Engineering',
'Product Manager',
'Product Designer',
- 'UX Researcher',
- 'QA Engineer',
- 'Solutions Architect',
- 'Developer Advocate',
- 'Data Scientist',
- 'Founder / Co-Founder',
- 'CEO',
- 'COO',
+ 'Researcher',
'Growth / Marketing',
- 'Sales Engineer',
- 'Customer Success',
+ 'Sales',
+ 'Operations',
]
const formSchema = z.object({
- name: z.string().min(1, 'Name is required'),
+ name: z.string().min(1, 'Tell us what to call you'),
role: z.string().min(1, 'Role is required'),
- company: z.string().min(1, 'Company is required'),
- description: z.string().optional(),
+ company: z.string().optional(),
+ description: z.string().min(1, 'Tell us a bit about your work'),
+ assistantName: z.string().min(1, 'Give your BrowserOS agent a name'),
})
type FormValues = z.infer
@@ -89,83 +90,166 @@ export const StepOne = ({ direction, onContinue }: StepOneProps) => {
role: '',
company: '',
description: '',
+ assistantName: 'BrowserOS',
},
})
+ useEffect(() => {
+ onboardingProfileStorage.getValue().then((profile) => {
+ if (!profile) return
+ form.reset({
+ name: profile.name,
+ role: profile.role,
+ company: profile.company ?? '',
+ description: profile.description ?? '',
+ assistantName: profile.assistantName ?? 'BrowserOS',
+ })
+ })
+ }, [form])
+
const handleSubmit = async (values: FormValues) => {
const name = values.name.trim()
const role = values.role.trim()
- const company = values.company.trim()
- const description = values.description?.trim() || undefined
+ const company = values.company?.trim() || undefined
+ const description = values.description.trim()
+ const assistantName = values.assistantName.trim()
+ const existingProfile = await onboardingProfileStorage.getValue()
await onboardingProfileStorage.setValue({
+ ...existingProfile,
name,
role,
company,
description,
+ assistantName,
})
const parts: string[] = []
- parts.push(`Name: ${name}`)
+ parts.push(`Call the user: ${name}`)
parts.push(`Role: ${role}`)
- parts.push(`Company: ${company}`)
- if (description) parts.push(`About: ${description}`)
+ if (company) parts.push(`Company: ${company}`)
+ parts.push(`What they do: ${description}`)
+ parts.push(`Preferred assistant name: ${assistantName}`)
await personalizationStorage.setValue(parts.join('\n'))
track(ONBOARDING_ABOUT_SUBMITTED_EVENT, {
fields_filled: parts.length,
- has_name: true,
- has_role: true,
- has_company: true,
- has_description: !!description,
+ has_company: !!company,
+ has_description: true,
role,
+ assistant_name: assistantName,
+ })
+ track(ONBOARDING_STEP_COMPLETED_EVENT, {
+ step: 1,
+ step_name: 'about_you',
})
-
- track(ONBOARDING_STEP_COMPLETED_EVENT, { step: 1, step_name: 'about' })
onContinue()
}
return (
-
-
-
-
- Tell us about yourself
-
-
- Help us personalize your experience
-
-
+
+
+
+ First-run context
+
+
+
What BrowserOS will do
+
+ Use your intro to personalize the launch prompt, shape your
+ first `SOUL.md` update, and suggest useful skills or recurring
+ tasks.
+
+
+
+
+
+
+
+
+ Name, role, company, and the kind of work you want help with.
+
+
-
+
+ }
+ >
+
+
+
+
+
)
}
diff --git a/apps/agent/entrypoints/onboarding/steps/StepScaffold.tsx b/apps/agent/entrypoints/onboarding/steps/StepScaffold.tsx
new file mode 100644
index 000000000..5bbefb151
--- /dev/null
+++ b/apps/agent/entrypoints/onboarding/steps/StepScaffold.tsx
@@ -0,0 +1,50 @@
+import type { FC, ReactNode } from 'react'
+import { Badge } from '@/components/ui/badge'
+import { Card } from '@/components/ui/card'
+
+interface StepScaffoldProps {
+ badge: string
+ title: string
+ description: string
+ children: ReactNode
+ aside?: ReactNode
+}
+
+export const StepScaffold: FC = ({
+ badge,
+ title,
+ description,
+ children,
+ aside,
+}) => {
+ return (
+
+
+
+
+
+ {badge}
+
+
+
+ {title}
+
+
+ {description}
+
+
+
+
+ {children}
+
+
+
+ {aside}
+
+
+
+ )
+}
diff --git a/apps/agent/entrypoints/onboarding/steps/StepTransition.tsx b/apps/agent/entrypoints/onboarding/steps/StepTransition.tsx
index f72bb667e..3c66fed76 100644
--- a/apps/agent/entrypoints/onboarding/steps/StepTransition.tsx
+++ b/apps/agent/entrypoints/onboarding/steps/StepTransition.tsx
@@ -35,7 +35,7 @@ export const StepTransition: FC> = ({
variants={variants}
initial="enter"
animate="center"
- className="absolute inset-0 h-[550px]"
+ className="absolute inset-0"
exit="exit"
custom={direction}
transition={{
diff --git a/apps/agent/entrypoints/onboarding/steps/StepTwo.tsx b/apps/agent/entrypoints/onboarding/steps/StepTwo.tsx
index 341ab0fb0..0dcc38a7e 100644
--- a/apps/agent/entrypoints/onboarding/steps/StepTwo.tsx
+++ b/apps/agent/entrypoints/onboarding/steps/StepTwo.tsx
@@ -1,18 +1,31 @@
-import { AlertCircle, CheckCircle2, Loader2, Mail } from 'lucide-react'
-import { useState } from 'react'
+import {
+ AlertCircle,
+ CalendarDays,
+ CheckCircle2,
+ Loader2,
+ LockKeyhole,
+ Mail,
+ ShieldCheck,
+} from 'lucide-react'
+import { useMemo, useState } from 'react'
import { Alert, AlertDescription } from '@/components/ui/alert'
+import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
-import { Input } from '@/components/ui/input'
-import { Label } from '@/components/ui/label'
-import { Separator } from '@/components/ui/separator'
import { signIn } from '@/lib/auth/auth-client'
+import { useSessionInfo } from '@/lib/auth/sessionStorage'
import {
ONBOARDING_SIGNIN_COMPLETED_EVENT,
ONBOARDING_SIGNIN_SKIPPED_EVENT,
ONBOARDING_STEP_COMPLETED_EVENT,
} from '@/lib/constants/analyticsEvents'
import { track } from '@/lib/metrics/track'
-import { authRedirectPathStorage } from '@/lib/onboarding/onboardingStorage'
+import {
+ authRedirectPathStorage,
+ signInHintDismissedAtStorage,
+} from '@/lib/onboarding/onboardingStorage'
+import { useGetUserMCPIntegrations } from '../../app/connect-mcp/useGetUserMCPIntegrations'
+import { ManagedAppConnectionCard } from './ManagedAppConnectionCard'
+import { StepScaffold } from './StepScaffold'
import { type StepDirection, StepTransition } from './StepTransition'
interface StepTwoProps {
@@ -20,196 +33,245 @@ interface StepTwoProps {
onContinue: () => void
}
-type SignInState = 'idle' | 'loading' | 'magic-link-sent' | 'error'
-
export const StepTwo = ({ direction, onContinue }: StepTwoProps) => {
- const [email, setEmail] = useState('')
- const [state, setState] = useState('idle')
+ const { sessionInfo } = useSessionInfo()
+ const { data: integrations } = useGetUserMCPIntegrations()
+ const [state, setState] = useState<'idle' | 'loading' | 'local-only'>('idle')
const [error, setError] = useState(null)
- const handleSkip = () => {
- track(ONBOARDING_SIGNIN_SKIPPED_EVENT)
- track(ONBOARDING_STEP_COMPLETED_EVENT, {
- step: 2,
- step_name: 'signin',
- skipped: true,
- })
- onContinue()
- }
-
- const handleMagicLink = async (e: React.FormEvent) => {
- e.preventDefault()
- if (!email.trim()) return
-
- setState('loading')
- setError(null)
-
- try {
- const result = await signIn.magicLink({
- email: email.trim(),
- callbackURL: '/home',
- })
+ const isSignedIn = !!sessionInfo?.user
- if (result.error) {
- setState('error')
- setError(result.error.message || 'Failed to send magic link')
- return
- }
-
- setState('magic-link-sent')
- track(ONBOARDING_SIGNIN_COMPLETED_EVENT, { method: 'magic_link' })
- track(ONBOARDING_STEP_COMPLETED_EVENT, { step: 2, step_name: 'signin' })
- } catch (err) {
- setState('error')
- setError(err instanceof Error ? err.message : 'Failed to send magic link')
+ const connectedApps = useMemo(() => {
+ const items = integrations?.integrations ?? []
+ return {
+ gmail:
+ items.find((integration) => integration.name === 'Gmail')
+ ?.is_authenticated ?? false,
+ calendar:
+ items.find((integration) => integration.name === 'Google Calendar')
+ ?.is_authenticated ?? false,
}
- }
+ }, [integrations])
const handleGoogleSignIn = async () => {
setState('loading')
setError(null)
try {
+ await authRedirectPathStorage.setValue('/onboarding/steps/3')
track(ONBOARDING_SIGNIN_COMPLETED_EVENT, { method: 'google' })
- track(ONBOARDING_STEP_COMPLETED_EVENT, { step: 2, step_name: 'signin' })
-
- await authRedirectPathStorage.setValue('/onboarding/demo')
await signIn.social({
provider: 'google',
callbackURL: '/home',
})
} catch (err) {
- setState('error')
+ setState('idle')
setError(
err instanceof Error ? err.message : 'Failed to sign in with Google',
)
}
}
- if (state === 'magic-link-sent') {
- return (
-
-
-
-
-
-
-
-
- Check your email
-
-
- We sent a magic link to {email} . Click the link
- to sign in.
-
-
-
- {
- setState('idle')
- setEmail('')
- }}
- >
- Use a different email
-
-
- Continue without signing in
-
-
-
-
-
+ const handleLocalOnly = async () => {
+ setState('local-only')
+ setError(null)
+ track(ONBOARDING_SIGNIN_SKIPPED_EVENT)
+ await signInHintDismissedAtStorage.setValue(
+ Date.now() + 100 * 365 * 24 * 60 * 60 * 1000,
)
}
+ const handleContinue = async () => {
+ await signInHintDismissedAtStorage.setValue(
+ Date.now() + 100 * 365 * 24 * 60 * 60 * 1000,
+ )
+ track(ONBOARDING_STEP_COMPLETED_EVENT, {
+ step: 3,
+ step_name: 'connect_google',
+ signed_in: isSignedIn,
+ gmail_connected: connectedApps.gmail,
+ calendar_connected: connectedApps.calendar,
+ local_only: state === 'local-only',
+ })
+ onContinue()
+ }
+
+ const canContinue = isSignedIn || state === 'local-only'
+
return (
-
-
-
-
- Sign in to BrowserOS
-
-
- Sync your settings and unlock cloud features
-
+
+
+
+ Why this matters
+
+
+ Strawberry's best move is earning context before the first ask.
+ This step gives BrowserOS the same setup path without hiding the
+ user's consent.
+
+
+
+
+ {[
+ {
+ icon: ShieldCheck,
+ title: 'BrowserOS account',
+ description:
+ 'Sync chat history, providers, schedules, and onboarding profile across devices.',
+ },
+ {
+ icon: Mail,
+ title: 'Gmail',
+ description:
+ 'Lets the agent ask to inspect recent inbox threads when you want it to know your work better.',
+ },
+ {
+ icon: CalendarDays,
+ title: 'Google Calendar',
+ description:
+ 'Gives BrowserOS the option to understand your upcoming week and schedule recurring automation.',
+ },
+ ].map(({ icon: Icon, title, description }) => (
+
+ ))}
+
+ }
+ >
+
+
+
+
+
+
+ {isSignedIn
+ ? `Signed in as ${sessionInfo.user?.email ?? 'your account'}`
+ : 'Use Google to unlock cloud sync and app connections'}
+
+
+ This is the foundation for connected apps and a richer launch
+ conversation. If you prefer, you can stay local and connect
+ everything later.
+
+
- {error && (
-
-
- {error}
-
- )}
+ {isSignedIn ? (
+
+
+ Connected
+
+ ) : null}
+
+
+ {error && (
+
+
+ {error}
+
+ )}
-
- {state === 'loading' ? (
-
+ {!isSignedIn ? (
+
+
+ {state === 'loading' ? (
+
+ ) : (
+
+ )}
+ Continue with Google
+
+
+ Keep this local for now
+
+
) : (
-
+
+ You can go ahead and connect Gmail and Google Calendar below.
+
)}
- Continue with Google
-
+
-
-
-
-
-
-
- Or continue with email
-
-
+
+
+
-
-
- Email
- setEmail(e.target.value)}
- disabled={state === 'loading'}
- required
- />
-
-
- {state === 'loading' ? (
-
- ) : (
-
- )}
- Send Magic Link
-
-
+ {state === 'local-only' && (
+
+
+
+ You can finish onboarding locally. BrowserOS will still explain
+ soul, skills, BYO keys, and schedules, and you can connect apps
+ later from settings.
+
+
+ )}
-
+
+
+ {isSignedIn
+ ? `Connected now: ${connectedApps.gmail ? 'Gmail' : 'Gmail pending'}, ${connectedApps.calendar ? 'Google Calendar' : 'Google Calendar pending'}.`
+ : 'Sign in for the full connected-apps path, or continue locally and wire these in later.'}
+
- Skip for now
+ Continue
-
+
)
}
diff --git a/apps/agent/entrypoints/onboarding/steps/StepsLayout.tsx b/apps/agent/entrypoints/onboarding/steps/StepsLayout.tsx
index c402a0ae2..9aec7aa21 100644
--- a/apps/agent/entrypoints/onboarding/steps/StepsLayout.tsx
+++ b/apps/agent/entrypoints/onboarding/steps/StepsLayout.tsx
@@ -1,17 +1,27 @@
-import { ArrowLeft, Check } from 'lucide-react'
+import { ArrowLeft, Check, Sparkles } from 'lucide-react'
import { AnimatePresence } from 'motion/react'
import { useEffect, useState } from 'react'
import { NavLink, useNavigate, useParams } from 'react-router'
import { Button } from '@/components/ui/button'
import { ONBOARDING_STEP_VIEWED_EVENT } from '@/lib/constants/analyticsEvents'
import { track } from '@/lib/metrics/track'
+import { FocusGrid } from '../index/FocusGrid'
+import { OnboardingHeader } from '../index/OnboardingHeader'
import type { StepDirection } from './StepTransition'
import { steps } from './steps'
+const launchBullets = [
+ 'LinkedIn-aware first chat',
+ 'SOUL.md personalization',
+ 'Skills and BYO model setup',
+ 'Daily task suggestions',
+]
+
export const StepsLayout = () => {
const { stepId } = useParams()
const navigate = useNavigate()
const [direction, setDirection] = useState
(1)
+ const [mounted, setMounted] = useState(false)
const currentStep = Number(stepId)
const isLastStep = currentStep >= steps.length
@@ -20,6 +30,10 @@ export const StepsLayout = () => {
const stepEntry = steps.find((each) => each.id === currentStep)
const ActiveStep = stepEntry?.component ?? (() => null)
+ useEffect(() => {
+ setMounted(true)
+ }, [])
+
// biome-ignore lint/correctness/useExhaustiveDependencies: track on step navigation only, stepEntry is derived from currentStep
useEffect(() => {
if (stepEntry) {
@@ -33,89 +47,123 @@ export const StepsLayout = () => {
const onContinue = () => {
setDirection(1)
if (isLastStep) {
- navigate('/onboarding/demo')
- } else {
- navigate(`/onboarding/steps/${currentStep + 1}`)
+ navigate('/home')
+ return
}
+ navigate(`/onboarding/steps/${currentStep + 1}`)
}
return (
-
- {/* Progress Indicator */}
-
-
-
- {steps.map((step) => {
- const isCompleted = step.id < currentStep
- const isActive = step.id === currentStep
+
+
- return (
-
-
-
- {isActive && (
-
- )}
-
- {isCompleted ? : step.id}
-
-
-
+
+
+
+
+
+
+
+
+
+ Step {currentStep} of {steps.length}
+
+
+ {steps.map((step) => {
+ const isCompleted = step.id < currentStep
+ const isActive = step.id === currentStep
+
+ return (
+
+
+ {isCompleted ? (
+
+ ) : (
+ step.id
+ )}
+
+
+
{step.name}
+
+ {step.id === 1 && 'Context and names'}
+ {step.id === 2 && 'Chrome import'}
+ {step.id === 3 && 'Google apps'}
+ {step.id === 4 && 'Power features'}
+ {step.id === 5 && 'Seed first chat'}
+
+
+
+ )
+ })}
+
+
+
+
+
+
+
What unlocks next
+
+
+ {launchBullets.map((bullet) => (
- {step.name}
+ {bullet}
-
+ ))}
- )
- })}
-
-
-
+
+
- {/* Main Content */}
-
-
-
-
-
- setDirection(-1)}
- to={
- canGoPrevious
- ? `/onboarding/steps/${currentStep - 1}`
- : '/onboarding'
- }
- >
-
- Back
-
-
+
+
+
+
+
+ setDirection(-1)}
+ to={
+ canGoPrevious
+ ? `/onboarding/steps/${currentStep - 1}`
+ : '/onboarding'
+ }
+ >
+
+ Back
+
+
+
+
+ BrowserOS is setting up a first-run experience that starts on
+ the main home page, not in a dead-end demo.
+
+
+
diff --git a/apps/agent/entrypoints/onboarding/steps/steps.ts b/apps/agent/entrypoints/onboarding/steps/steps.ts
index fb44bd494..df3b80ad0 100644
--- a/apps/agent/entrypoints/onboarding/steps/steps.ts
+++ b/apps/agent/entrypoints/onboarding/steps/steps.ts
@@ -1,3 +1,6 @@
+import { CapabilitiesStep } from './CapabilitiesStep'
+import { ImportChromeStep } from './ImportChromeStep'
+import { LaunchStep } from './LaunchStep'
import { StepOne } from './StepOne'
import { StepTwo } from './StepTwo'
@@ -9,7 +12,22 @@ export const steps = [
},
{
id: 2,
- name: 'Sign In',
+ name: 'Import Chrome',
+ component: ImportChromeStep,
+ },
+ {
+ id: 3,
+ name: 'Connect Google',
component: StepTwo,
},
+ {
+ id: 4,
+ name: 'Teach Your Agent',
+ component: CapabilitiesStep,
+ },
+ {
+ id: 5,
+ name: 'Launch',
+ component: LaunchStep,
+ },
]
diff --git a/apps/agent/entrypoints/sidepanel/index/useChatSession.ts b/apps/agent/entrypoints/sidepanel/index/useChatSession.ts
index c9d13e7a9..0fb52785d 100644
--- a/apps/agent/entrypoints/sidepanel/index/useChatSession.ts
+++ b/apps/agent/entrypoints/sidepanel/index/useChatSession.ts
@@ -25,7 +25,10 @@ import { declinedAppsStorage } from '@/lib/declined-apps/storage'
import { useGraphqlQuery } from '@/lib/graphql/useGraphqlQuery'
import { useLlmProviders } from '@/lib/llm-providers/useLlmProviders'
import { track } from '@/lib/metrics/track'
-import { searchActionsStorage } from '@/lib/search-actions/searchActionsStorage'
+import {
+ isSearchActionForTarget,
+ searchActionsStorage,
+} from '@/lib/search-actions/searchActionsStorage'
import { stopAgentStorage } from '@/lib/stop-agent/stop-agent-storage'
import { selectedWorkspaceStorage } from '@/lib/workspace/workspace-storage'
import type { ChatMode } from './chatTypes'
@@ -74,6 +77,7 @@ export interface ChatSessionOptions {
const NEWTAB_SYSTEM_PROMPT = `IMPORTANT: The user is chatting from the New Tab page. When performing browser actions, ALWAYS open content in a NEW TAB rather than navigating the current tab. The user's new tab page should remain accessible.`
export const useChatSession = (options?: ChatSessionOptions) => {
+ const origin = options?.origin ?? 'sidepanel'
const {
selectedLlmProviderRef,
enabledMcpServersRef,
@@ -427,14 +431,22 @@ export const useChatSession = (options?: ChatSessionOptions) => {
// biome-ignore lint/correctness/useExhaustiveDependencies: only need to run this once
useEffect(() => {
+ if (origin !== 'sidepanel') return
+
const unwatch = searchActionsStorage.watch((storageAction) => {
- if (storageAction) {
- setMode(storageAction.mode)
- sendMessage({ text: storageAction.query, action: storageAction.action })
+ if (
+ !storageAction ||
+ !isSearchActionForTarget(storageAction, 'sidepanel')
+ ) {
+ return
}
+
+ searchActionsStorage.removeValue().catch(() => {})
+ setMode(storageAction.mode)
+ sendMessage({ text: storageAction.query, action: storageAction.action })
})
return () => unwatch()
- }, [])
+ }, [origin])
// biome-ignore lint/correctness/useExhaustiveDependencies: only need to run this once
useEffect(() => {
diff --git a/apps/agent/lib/onboarding/buildOnboardingLaunchPrompt.ts b/apps/agent/lib/onboarding/buildOnboardingLaunchPrompt.ts
new file mode 100644
index 000000000..fd17d27c9
--- /dev/null
+++ b/apps/agent/lib/onboarding/buildOnboardingLaunchPrompt.ts
@@ -0,0 +1,58 @@
+import type { OnboardingProfile } from './onboardingStorage'
+
+interface BuildOnboardingLaunchPromptParams {
+ profile: OnboardingProfile | null
+ gmailConnected: boolean
+ calendarConnected: boolean
+}
+
+export function buildOnboardingLaunchPrompt({
+ profile,
+ gmailConnected,
+ calendarConnected,
+}: BuildOnboardingLaunchPromptParams): string {
+ const name = profile?.name || 'the user'
+ const assistantName = profile?.assistantName || 'BrowserOS'
+ const roleLine = profile?.role ? `They work as ${profile.role}.` : ''
+ const companyLine = profile?.company
+ ? `They are currently at ${profile.company}.`
+ : ''
+ const descriptionLine = profile?.description
+ ? `Their day-to-day: ${profile.description}`
+ : ''
+ const importLine =
+ profile?.importStatus === 'imported'
+ ? 'They already imported their Chrome profile.'
+ : 'They skipped browser import for now.'
+
+ let appInstruction =
+ 'Explain that Gmail and Google Calendar are not connected yet, and that connecting them later will let you understand their inbox and schedule before taking action.'
+
+ if (gmailConnected && calendarConnected) {
+ appInstruction =
+ 'Tell them Gmail and Google Calendar are connected, then ask if you may read up to 10 recent emails and up to 10 upcoming calendar events to get to know them better before doing anything else. Wait for permission before reading either service.'
+ } else if (gmailConnected) {
+ appInstruction =
+ 'Tell them Gmail is connected, then ask if you may read up to 10 recent emails to get to know them better before doing anything else. Wait for permission before reading Gmail.'
+ } else if (calendarConnected) {
+ appInstruction =
+ 'Tell them Google Calendar is connected, then ask if you may read up to 10 upcoming calendar events to get to know them better before doing anything else. Wait for permission before reading Calendar.'
+ }
+
+ return [
+ `You are kicking off BrowserOS onboarding as ${assistantName}.`,
+ `Call the user ${name}.`,
+ roleLine,
+ companyLine,
+ descriptionLine,
+ importLine,
+ 'The attached browser tab is their LinkedIn context.',
+ 'First, inspect the attached LinkedIn tab and give one concise summary of who they seem to be and what they do.',
+ "Then briefly explain that BrowserOS can personalize SOUL.md, create and use skills, work with the user's own API keys, and automate scheduled tasks.",
+ appInstruction,
+ 'Before you finish the first response, offer to co-create a daily 09:00 AM BrowserOS task that summarizes their inbox and calendar.',
+ 'Keep the first response warm, concise, and concrete.',
+ ]
+ .filter(Boolean)
+ .join('\n')
+}
diff --git a/apps/agent/lib/onboarding/launchOnboardingChat.ts b/apps/agent/lib/onboarding/launchOnboardingChat.ts
new file mode 100644
index 000000000..5186e15be
--- /dev/null
+++ b/apps/agent/lib/onboarding/launchOnboardingChat.ts
@@ -0,0 +1,59 @@
+import { createBrowserOSAction } from '@/lib/chat-actions/types'
+import { searchActionsStorage } from '@/lib/search-actions/searchActionsStorage'
+import { buildOnboardingLaunchPrompt } from './buildOnboardingLaunchPrompt'
+import type { OnboardingProfile } from './onboardingStorage'
+
+export interface SeedOnboardingHomeChatParams {
+ profile: OnboardingProfile | null
+ gmailConnected: boolean
+ calendarConnected: boolean
+}
+
+export function buildOnboardingScheduledTaskUrl() {
+ const params = new URLSearchParams({
+ openDialog: 'true',
+ name: 'Daily inbox and calendar brief',
+ query:
+ 'Every morning at 09:00, review my recent Gmail inbox and upcoming Google Calendar events, then summarize what matters most today.',
+ scheduleType: 'daily',
+ scheduleTime: '09:00',
+ })
+
+ return chrome.runtime.getURL(`app.html#/scheduled?${params.toString()}`)
+}
+
+export async function seedOnboardingHomeChat({
+ profile,
+ gmailConnected,
+ calendarConnected,
+}: SeedOnboardingHomeChatParams) {
+ const currentTab = await chrome.tabs.getCurrent().catch(() => undefined)
+ const prompt = buildOnboardingLaunchPrompt({
+ profile,
+ gmailConnected,
+ calendarConnected,
+ })
+
+ let linkedInTab: chrome.tabs.Tab | undefined
+ try {
+ const created = await chrome.tabs.create({
+ url: 'https://www.linkedin.com/in/me/',
+ active: false,
+ })
+ linkedInTab = created.id ? await chrome.tabs.get(created.id) : created
+ } catch {
+ linkedInTab = undefined
+ }
+
+ await searchActionsStorage.setValue({
+ query: prompt,
+ mode: 'agent',
+ target: 'newtab',
+ targetTabId: currentTab?.id,
+ action: createBrowserOSAction({
+ mode: 'agent',
+ message: prompt,
+ tabs: linkedInTab ? [linkedInTab] : undefined,
+ }),
+ })
+}
diff --git a/apps/agent/lib/onboarding/onboardingFlow.ts b/apps/agent/lib/onboarding/onboardingFlow.ts
new file mode 100644
index 000000000..e09d56e6f
--- /dev/null
+++ b/apps/agent/lib/onboarding/onboardingFlow.ts
@@ -0,0 +1,49 @@
+export type OnboardingFlowSource = 'setup' | 'settings'
+
+export const ONBOARDING_HOME_PATH = '/home'
+export const ONBOARDING_ENTRY_PATH = '/onboarding'
+export const ONBOARDING_DEMO_PATH = '/onboarding/demo'
+
+export const onboardingProgressSteps = [
+ {
+ id: 1,
+ name: 'About You',
+ },
+ {
+ id: 2,
+ name: 'Sign In',
+ },
+ {
+ id: 3,
+ name: 'First Task',
+ },
+] as const
+
+export type OnboardingProgressStep =
+ (typeof onboardingProgressSteps)[number]['id']
+
+export function getOnboardingFlowSource(
+ searchParams: URLSearchParams,
+): OnboardingFlowSource {
+ const source = searchParams.get('source')
+ return source === 'settings' || source === 'revisit' ? 'settings' : 'setup'
+}
+
+export function getOnboardingFeaturesPath(source: OnboardingFlowSource) {
+ return `${ONBOARDING_ENTRY_PATH}/features?source=${source}`
+}
+
+export function getOnboardingStepPath(
+ step: 1 | 2,
+ source: OnboardingFlowSource,
+) {
+ return `${ONBOARDING_ENTRY_PATH}/steps/${step}?source=${source}`
+}
+
+export function getOnboardingDemoPath(source: OnboardingFlowSource) {
+ return `${ONBOARDING_DEMO_PATH}?source=${source}`
+}
+
+export function getOnboardingRevisitPath() {
+ return `${ONBOARDING_ENTRY_PATH}?source=revisit`
+}
diff --git a/apps/agent/lib/onboarding/onboardingStorage.ts b/apps/agent/lib/onboarding/onboardingStorage.ts
index c1fdea0ca..e7dddce12 100644
--- a/apps/agent/lib/onboarding/onboardingStorage.ts
+++ b/apps/agent/lib/onboarding/onboardingStorage.ts
@@ -3,8 +3,10 @@ import { storage } from '@wxt-dev/storage'
export interface OnboardingProfile {
name: string
role: string
- company: string
+ company?: string
description?: string
+ assistantName?: string
+ importStatus?: 'imported' | 'skipped'
}
export const onboardingCompletedStorage = storage.defineItem
(
diff --git a/apps/agent/lib/onboarding/syncOnboardingProfile.ts b/apps/agent/lib/onboarding/syncOnboardingProfile.ts
index fefbb7c96..3c2cc0116 100644
--- a/apps/agent/lib/onboarding/syncOnboardingProfile.ts
+++ b/apps/agent/lib/onboarding/syncOnboardingProfile.ts
@@ -20,6 +20,7 @@ export async function syncOnboardingProfile(userId: string): Promise {
if (profile.role) preferences.role = profile.role
if (profile.company) preferences.company = profile.company
if (profile.description) preferences.description = profile.description
+ if (profile.assistantName) preferences.assistant_name = profile.assistantName
await execute(UpdateProfileByUserIdDocument, {
userId,
diff --git a/apps/agent/lib/search-actions/searchActionsStorage.ts b/apps/agent/lib/search-actions/searchActionsStorage.ts
index ef7ce2b16..87e3ff1c9 100644
--- a/apps/agent/lib/search-actions/searchActionsStorage.ts
+++ b/apps/agent/lib/search-actions/searchActionsStorage.ts
@@ -1,6 +1,8 @@
import { storage } from '@wxt-dev/storage'
import type { ChatAction } from '@/lib/chat-actions/types'
+export type SearchActionTarget = 'sidepanel' | 'newtab'
+
/**
* @public
*/
@@ -8,6 +10,28 @@ export interface SearchActionStorage {
query: string
mode: 'chat' | 'agent'
action?: ChatAction
+ target?: SearchActionTarget
+ targetTabId?: number
+}
+
+export function isSearchActionForTarget(
+ searchAction: SearchActionStorage | null | undefined,
+ target: SearchActionTarget,
+ tabId?: number,
+) {
+ if (!searchAction) return false
+ if (!searchAction.target && target !== 'sidepanel') return false
+ if (searchAction.target && searchAction.target !== target) return false
+ if (searchAction.targetTabId === undefined) return true
+ return tabId !== undefined && searchAction.targetTabId === tabId
+}
+
+export function getSearchActionFingerprint(searchAction: SearchActionStorage) {
+ return JSON.stringify({
+ ...searchAction,
+ target: searchAction.target ?? 'sidepanel',
+ targetTabId: searchAction.targetTabId ?? null,
+ })
}
/**