From 532672bd4c37fc78c884251d715a6f03a80ec5e4 Mon Sep 17 00:00:00 2001 From: ciregenz Date: Sat, 13 Jun 2026 23:15:09 -0700 Subject: [PATCH 01/27] [eric] onboarding: reveal the panel only after the first agent COMPLETES (not mid-run), and drop the redundant center intent box (chat composer is the freeform input) --- .../components/Onboarding/OnboardingRoot.tsx | 14 ++-- .../Onboarding/steps/skipPredicates.ts | 7 ++ .../Dashboard/canvas/DashboardEmptyState.tsx | 65 +------------------ 3 files changed, 15 insertions(+), 71 deletions(-) diff --git a/frontend/src/app/components/Onboarding/OnboardingRoot.tsx b/frontend/src/app/components/Onboarding/OnboardingRoot.tsx index 01a77766..89d9fd03 100644 --- a/frontend/src/app/components/Onboarding/OnboardingRoot.tsx +++ b/frontend/src/app/components/Onboarding/OnboardingRoot.tsx @@ -18,7 +18,7 @@ import { import AgenticCursor, { type AgenticCursorHandle } from './ac/AgenticCursor'; import { onboardingDirector } from './OnboardingDirector'; import { STEPS } from './steps'; -import { hasAnyAgentLaunched } from './steps/skipPredicates'; +import { hasAnyAgentCompleted } from './steps/skipPredicates'; import OnboardingPanel from './OnboardingPanel'; import { onboardingBus } from './eventBus'; import { report } from './telemetry'; @@ -32,18 +32,18 @@ const OnboardingRoot: React.FC = () => { const tokens = useClaudeTokens(); const progress = useAppSelector((s) => s.onboardingProgress); const settingsLoaded = useAppSelector((s) => s.settings.loaded); - const firstAgentLaunched = useAppSelector(hasAnyAgentLaunched); + const firstAgentDone = useAppSelector(hasAnyAgentCompleted); - // The one gentle nudge: after the first agent win, open the quiet pill ONCE so - // the user sees "here's what's next". Respects a panel they've hidden or already - // expanded themselves, and never re-fires (revealedAfterWin sticks). + // The one gentle nudge: open the quiet pill ONCE, but only AFTER the first agent + // actually finishes, so it never pops mid-run. Respects a panel they've hidden or + // already expanded themselves, and never re-fires (revealedAfterWin sticks). useEffect(() => { - if (!progress.initialized || !firstAgentLaunched) return; + if (!progress.initialized || !firstAgentDone) return; if (progress.revealedAfterWin || progress.panelMode !== 'pill') return; dispatch(setPanelMode('expanded')); dispatch(markRevealedAfterWin()); }, [ - firstAgentLaunched, + firstAgentDone, progress.initialized, progress.revealedAfterWin, progress.panelMode, diff --git a/frontend/src/app/components/Onboarding/steps/skipPredicates.ts b/frontend/src/app/components/Onboarding/steps/skipPredicates.ts index b4907e4a..902ac30d 100644 --- a/frontend/src/app/components/Onboarding/steps/skipPredicates.ts +++ b/frontend/src/app/components/Onboarding/steps/skipPredicates.ts @@ -62,6 +62,13 @@ export function hasAnyAgentLaunched(s: RootState): boolean { return Object.keys(sessions).length > 0; } +/** True once any agent has actually FINISHED (not just started). Used to hold the + * onboarding reveal until after the first win, so it never pops mid-run. */ +export function hasAnyAgentCompleted(s: RootState): boolean { + const sessions = s.agents?.sessions ?? {}; + return Object.values(sessions).some((x: any) => x?.status === 'completed'); +} + export function hasAnySkillInstalled(s: RootState): boolean { const items = s.skills?.items ?? []; if (Array.isArray(items)) return items.length > 0; diff --git a/frontend/src/app/pages/Dashboard/canvas/DashboardEmptyState.tsx b/frontend/src/app/pages/Dashboard/canvas/DashboardEmptyState.tsx index 6ed7c5ba..7f2fee9d 100644 --- a/frontend/src/app/pages/Dashboard/canvas/DashboardEmptyState.tsx +++ b/frontend/src/app/pages/Dashboard/canvas/DashboardEmptyState.tsx @@ -2,7 +2,7 @@ import React from 'react'; import Box from '@mui/material/Box'; import Typography from '@mui/material/Typography'; import { motion, AnimatePresence } from 'framer-motion'; -import { Search, Hammer, PenLine, GraduationCap, ArrowLeft, ArrowRight } from 'lucide-react'; +import { Search, Hammer, PenLine, GraduationCap, ArrowLeft } from 'lucide-react'; import type { LucideIcon } from 'lucide-react'; import type { ClaudeTokens } from '@/shared/styles/claudeTokens'; import { useDashboardActive } from '@/shared/hooks/useDashboardActive'; @@ -73,7 +73,6 @@ const DashboardEmptyState: React.FC<{ const canRun = useAppSelector((s) => hasFreeTrialActive(s) || hasModelConnected(s)); const [launching, setLaunching] = React.useState(false); const [expanded, setExpanded] = React.useState(null); - const [intent, setIntent] = React.useState(''); const currentCategory = STARTER_CATEGORIES.find((cat) => cat.id === expanded); const currentPrompts = currentCategory?.prompts ?? []; @@ -97,21 +96,6 @@ const DashboardEmptyState: React.FC<{ onLaunch(prompt, mode, model); }; - // Personalized path: wrap the user's own words into a "be my team" brief so one - // agent immediately produces real, tailored artifacts (plain language, no questions - // back, so a non-dev sees value in seconds). One session = one free run. - const launchIntent = () => { - const v = intent.trim(); - if (!v || launching || !onLaunch) return; - setLaunching(true); - const brief = - `Here's what I'm working on: ${v}. ` + - `Be my AI team and just get going, no questions back. Look into it, then make me ` + - `2-3 concrete things I can use right now (a quick research brief, a first draft, a simple plan), ` + - `and finish with what you'd tackle next. Keep it practical and plain.`; - onLaunch(brief, mode, model); - }; - return ( - {/* Personalized path: their own words put a tailored team to work. The generic - chips below stay for when they're not sure what they want yet. */} - - ) => setIntent(e.target.value)} - onKeyDown={(e: React.KeyboardEvent) => { if (e.key === 'Enter') launchIntent(); }} - placeholder="Tell me what you're working on, I'll put a team on it" - disabled={launching} - sx={{ - flex: 1, minWidth: 0, - border: 'none', outline: 'none', background: 'transparent', - color: c.text.primary, fontSize: '0.98rem', fontFamily: 'inherit', - py: 0.9, - '&::placeholder': { color: c.text.ghost }, - }} - /> - - - - {expanded === null ? ( Date: Sat, 13 Jun 2026 23:41:38 -0700 Subject: [PATCH 02/27] [eric] onboarding: empty state becomes 'What do you want done?' pointing at the chat bubble; chips one-tap launch (no prefill step), App Builder keeps its surface --- .../Dashboard/canvas/DashboardEmptyState.tsx | 23 +++++++++++-------- 1 file changed, 14 insertions(+), 9 deletions(-) diff --git a/frontend/src/app/pages/Dashboard/canvas/DashboardEmptyState.tsx b/frontend/src/app/pages/Dashboard/canvas/DashboardEmptyState.tsx index 7f2fee9d..efb79082 100644 --- a/frontend/src/app/pages/Dashboard/canvas/DashboardEmptyState.tsx +++ b/frontend/src/app/pages/Dashboard/canvas/DashboardEmptyState.tsx @@ -87,13 +87,18 @@ const DashboardEmptyState: React.FC<{ // context switch); the others use the default mode. const launch = (prompt: string) => { if (launching) return; - if (onStarter) { - onStarter(prompt, isAppBuilder ? 'view-builder' : undefined); + // App Builder opens its own surface, so prefill its composer (the user reviews, + // then builds). Everything else fires immediately: a pick is one tap to a running agent. + if (isAppBuilder) { + if (onStarter) onStarter(prompt, 'view-builder'); return; } - if (!onLaunch) return; - setLaunching(true); // empty state unmounts on first session, but guard a fast double-click - onLaunch(prompt, mode, model); + if (onLaunch) { + setLaunching(true); // empty state unmounts on first session, but guard a fast double-click + onLaunch(prompt, mode, model); + return; + } + if (onStarter) onStarter(prompt); }; return ( @@ -109,8 +114,8 @@ const DashboardEmptyState: React.FC<{ }} > - - No agents running + + What do you want done? - Click the + Tell the {/* Literal toolbar glyph; the shimmer's transparent color would hide it, so reset color here. */} - below to launch your first agent + below and I'll put a team on it {showChips && ( From b4f6a9e6e0019913db886a65714a53e004e763dd Mon Sep 17 00:00:00 2001 From: ciregenz Date: Sat, 13 Jun 2026 23:53:14 -0700 Subject: [PATCH 03/27] [eric] onboarding: agentic cursor auto-opens the chat on first run and asks 'what do you want done?' (static, no LLM); user types their own thing --- .../components/Onboarding/OnboardingRoot.tsx | 32 +++++++++++++++++- .../Onboarding/steps/step03_launchAgent.ts | 33 ++++++------------- 2 files changed, 41 insertions(+), 24 deletions(-) diff --git a/frontend/src/app/components/Onboarding/OnboardingRoot.tsx b/frontend/src/app/components/Onboarding/OnboardingRoot.tsx index 89d9fd03..d21a049d 100644 --- a/frontend/src/app/components/Onboarding/OnboardingRoot.tsx +++ b/frontend/src/app/components/Onboarding/OnboardingRoot.tsx @@ -18,7 +18,12 @@ import { import AgenticCursor, { type AgenticCursorHandle } from './ac/AgenticCursor'; import { onboardingDirector } from './OnboardingDirector'; import { STEPS } from './steps'; -import { hasAnyAgentCompleted } from './steps/skipPredicates'; +import { + hasAnyAgentCompleted, + hasAnyAgentLaunched, + hasFreeTrialActive, + hasModelConnected, +} from './steps/skipPredicates'; import OnboardingPanel from './OnboardingPanel'; import { onboardingBus } from './eventBus'; import { report } from './telemetry'; @@ -33,6 +38,15 @@ const OnboardingRoot: React.FC = () => { const progress = useAppSelector((s) => s.onboardingProgress); const settingsLoaded = useAppSelector((s) => s.settings.loaded); const firstAgentDone = useAppSelector(hasAnyAgentCompleted); + const autoOpenedRef = useRef(false); + // Ready to auto-open the chat: a way to run exists, nothing launched yet, and the + // first step isn't already done. + const firstRunReady = useAppSelector( + (s) => + (hasFreeTrialActive(s) || hasModelConnected(s)) && + !hasAnyAgentLaunched(s) && + !s.onboardingProgress.completedSteps.includes('launch_agent'), + ); // The one gentle nudge: open the quiet pill ONCE, but only AFTER the first agent // actually finishes, so it never pops mid-run. Respects a panel they've hidden or @@ -50,6 +64,22 @@ const OnboardingRoot: React.FC = () => { dispatch, ]); + // First-run assist: the agentic cursor opens the chat and asks "what do you want + // done?" on its own, so the user never hunts for the input. Fires once, only on the + // dashboard with the AC idle. Fail-safe: if it can't run, the empty state + chat + // still work by hand, so a missed auto-open never blocks anything. + useEffect(() => { + if (autoOpenedRef.current || !progress.initialized || !firstRunReady) return; + if (onboardingDirector.isRunning()) return; + if (!window.location.hash.includes('/dashboard/')) return; + autoOpenedRef.current = true; + const t = window.setTimeout(() => { + if (onboardingDirector.isRunning()) return; + onboardingDirector.startStep('launch_agent', { x: window.innerWidth - 80, y: 110 }); + }, 1400); // let the dashboard + cursor settle before it drives + return () => window.clearTimeout(t); + }, [progress.initialized, firstRunReady]); + useEffect(() => { if (progress.initialized) return; if (!settingsLoaded) return; diff --git a/frontend/src/app/components/Onboarding/steps/step03_launchAgent.ts b/frontend/src/app/components/Onboarding/steps/step03_launchAgent.ts index e6c74ba0..02c259d3 100644 --- a/frontend/src/app/components/Onboarding/steps/step03_launchAgent.ts +++ b/frontend/src/app/components/Onboarding/steps/step03_launchAgent.ts @@ -1,12 +1,6 @@ import type { OnboardingStep } from './types'; import { S } from '../selectors'; -import { hasAnyAgentLaunched, isYoutubeEnabled, hasModelConnected, hasFreeTrialActive } from './skipPredicates'; - -// Primary: YouTube summary (needs MCP from step 2). Fallback uses built-in web tools (no MCP). -const YOUTUBE_PROMPT = - 'What is this youtube video about: https://youtu.be/_NKj8KQMY-k?si=rEk4KO2bOpa5Vo0z. Do not use browser agents.'; -const FALLBACK_PROMPT = - 'Find the latest news about AI from the web and give me a short summary.'; +import { hasAnyAgentLaunched, hasModelConnected, hasFreeTrialActive } from './skipPredicates'; export const step03: OnboardingStep = { id: 'launch_agent', @@ -16,31 +10,24 @@ export const step03: OnboardingStep = { // instead, which restores today's flow exactly (no trial = no regression). index: 1, title: 'Launch your first Agent', - description: 'Click the chat bubble to fire up a new Agent in a dashboard.', + description: 'Tell the chat what you want done and a team gets to work.', videoSrc: './onboarding-videos/v2/03.mp4', videoDurationLabel: '0:24', skipIf: (s) => hasAnyAgentLaunched(s) || (!hasModelConnected(s) && !hasFreeTrialActive(s)), requiresDashboard: true, + // The cursor opens the chat FOR the user, then asks what they want. No canned + // prompt and no LLM here: it's a static move + simulated click + a hardcoded + // line; the user types their own thing and their team runs. ops: [ { kind: 'move_to', target: S.newAgentButton }, - { kind: 'popup', text: 'Tap the chat bubble to start a fresh chat.' }, - { - kind: 'wait_user', - condition: { kind: 'click_target', target: S.newAgentButton }, - }, - { - kind: 'type_into', - target: S.chatInput, - // YouTube prompt bans browser agents (MCP handles it); fallback uses web tools by design. - text: (state) => (isYoutubeEnabled(state) ? YOUTUBE_PROMPT : FALLBACK_PROMPT), - speedMs: 12, - }, - { kind: 'move_to', target: S.chatSendButton }, - { kind: 'click', target: S.chatSendButton, simulate: true }, + { kind: 'popup', text: 'Let me open a chat for you.' }, + { kind: 'click', target: S.newAgentButton, simulate: true }, + { kind: 'move_to', target: S.chatInput }, + { kind: 'popup', text: "What do you want done? Type it here and I'll put a team on it." }, { kind: 'wait_user', condition: { kind: 'event_bus', event: 'chat:message_sent' }, - timeoutMs: 30000, + timeoutMs: 180000, }, { kind: 'outro' }, ], From 1e7604961d47dcacd2e12b186a55f23a155b2866 Mon Sep 17 00:00:00 2001 From: ciregenz Date: Sun, 14 Jun 2026 00:12:32 -0700 Subject: [PATCH 04/27] [eric] onboarding: first-run welcome pops in and types a static self-intro, then the chips reveal; dropped the fragile cursor auto-open --- .../components/Onboarding/OnboardingRoot.tsx | 32 +----- .../Dashboard/canvas/DashboardEmptyState.tsx | 98 ++++++++++++------- 2 files changed, 61 insertions(+), 69 deletions(-) diff --git a/frontend/src/app/components/Onboarding/OnboardingRoot.tsx b/frontend/src/app/components/Onboarding/OnboardingRoot.tsx index d21a049d..89d9fd03 100644 --- a/frontend/src/app/components/Onboarding/OnboardingRoot.tsx +++ b/frontend/src/app/components/Onboarding/OnboardingRoot.tsx @@ -18,12 +18,7 @@ import { import AgenticCursor, { type AgenticCursorHandle } from './ac/AgenticCursor'; import { onboardingDirector } from './OnboardingDirector'; import { STEPS } from './steps'; -import { - hasAnyAgentCompleted, - hasAnyAgentLaunched, - hasFreeTrialActive, - hasModelConnected, -} from './steps/skipPredicates'; +import { hasAnyAgentCompleted } from './steps/skipPredicates'; import OnboardingPanel from './OnboardingPanel'; import { onboardingBus } from './eventBus'; import { report } from './telemetry'; @@ -38,15 +33,6 @@ const OnboardingRoot: React.FC = () => { const progress = useAppSelector((s) => s.onboardingProgress); const settingsLoaded = useAppSelector((s) => s.settings.loaded); const firstAgentDone = useAppSelector(hasAnyAgentCompleted); - const autoOpenedRef = useRef(false); - // Ready to auto-open the chat: a way to run exists, nothing launched yet, and the - // first step isn't already done. - const firstRunReady = useAppSelector( - (s) => - (hasFreeTrialActive(s) || hasModelConnected(s)) && - !hasAnyAgentLaunched(s) && - !s.onboardingProgress.completedSteps.includes('launch_agent'), - ); // The one gentle nudge: open the quiet pill ONCE, but only AFTER the first agent // actually finishes, so it never pops mid-run. Respects a panel they've hidden or @@ -64,22 +50,6 @@ const OnboardingRoot: React.FC = () => { dispatch, ]); - // First-run assist: the agentic cursor opens the chat and asks "what do you want - // done?" on its own, so the user never hunts for the input. Fires once, only on the - // dashboard with the AC idle. Fail-safe: if it can't run, the empty state + chat - // still work by hand, so a missed auto-open never blocks anything. - useEffect(() => { - if (autoOpenedRef.current || !progress.initialized || !firstRunReady) return; - if (onboardingDirector.isRunning()) return; - if (!window.location.hash.includes('/dashboard/')) return; - autoOpenedRef.current = true; - const t = window.setTimeout(() => { - if (onboardingDirector.isRunning()) return; - onboardingDirector.startStep('launch_agent', { x: window.innerWidth - 80, y: 110 }); - }, 1400); // let the dashboard + cursor settle before it drives - return () => window.clearTimeout(t); - }, [progress.initialized, firstRunReady]); - useEffect(() => { if (progress.initialized) return; if (!settingsLoaded) return; diff --git a/frontend/src/app/pages/Dashboard/canvas/DashboardEmptyState.tsx b/frontend/src/app/pages/Dashboard/canvas/DashboardEmptyState.tsx index efb79082..730b3549 100644 --- a/frontend/src/app/pages/Dashboard/canvas/DashboardEmptyState.tsx +++ b/frontend/src/app/pages/Dashboard/canvas/DashboardEmptyState.tsx @@ -5,13 +5,32 @@ import { motion, AnimatePresence } from 'framer-motion'; import { Search, Hammer, PenLine, GraduationCap, ArrowLeft } from 'lucide-react'; import type { LucideIcon } from 'lucide-react'; import type { ClaudeTokens } from '@/shared/styles/claudeTokens'; -import { useDashboardActive } from '@/shared/hooks/useDashboardActive'; import { useAppSelector } from '@/shared/hooks'; import { hasModelConnected, hasFreeTrialActive, } from '@/app/components/Onboarding/steps/skipPredicates'; -import ChatBubbleTeardrop from '../ChatBubbleTeardrop'; + +// The static self-intro the welcome types out (no LLM); the user answers in the chat. +const INTRO = "Hi, I'm your AI team. What do you want done?"; + +// One-shot typewriter for a fixed string. enabled=false shows it instantly (returning +// users); enabled=true reveals it char-by-char once. No infinite loop: it stops at the end. +function useTypewriter(text: string, enabled: boolean, speedMs = 34): { shown: string; done: boolean } { + const [shown, setShown] = React.useState(enabled ? '' : text); + React.useEffect(() => { + if (!enabled) { setShown(text); return; } + setShown(''); + let i = 0; + const id = window.setInterval(() => { + i += 1; + setShown(text.slice(0, i)); + if (i >= text.length) window.clearInterval(id); + }, speedMs); + return () => window.clearInterval(id); + }, [text, enabled, speedMs]); + return { shown, done: shown.length >= text.length }; +} // Two-level starters: pick a category, then its concrete prompts spawn. Every // prompt is one-click-runnable (no [placeholders]) and free-trial-safe, it @@ -65,12 +84,15 @@ const DashboardEmptyState: React.FC<{ // Optional mode opens it in a specific mode (Build -> 'view-builder'). onStarter?: (prompt: string, mode?: string) => void; }> = ({ c, onLaunch, onStarter }) => { - // The host hides Dashboard with visibility:hidden (not display:none), which keeps - // CSS animations ticking; gate on active so the shimmer only burns while watched. - const active = useDashboardActive(); const model = useAppSelector((s) => s.settings.data.default_model); const mode = useAppSelector((s) => s.settings.data.default_mode); const canRun = useAppSelector((s) => hasFreeTrialActive(s) || hasModelConnected(s)); + // Type the intro only for a fresh user (first agent not launched yet); returning + // users see it instantly so it isn't re-typed on every empty dashboard. + const introTyping = useAppSelector( + (s) => !(s.onboardingProgress?.completedSteps ?? []).includes('launch_agent'), + ); + const { shown: introShown, done: introDone } = useTypewriter(INTRO, introTyping); const [launching, setLaunching] = React.useState(false); const [expanded, setExpanded] = React.useState(null); const currentCategory = STARTER_CATEGORIES.find((cat) => cat.id === expanded); @@ -82,9 +104,6 @@ const DashboardEmptyState: React.FC<{ const isAppBuilder = currentCategory?.target === 'app-builder'; - // Click opens the composer with the prompt typed in (unsent); the user then sends. - // Build opens it in App Builder mode so it builds on the dashboard (no Apps-page - // context switch); the others use the default mode. const launch = (prompt: string) => { if (launching) return; // App Builder opens its own surface, so prefill its composer (the user reviews, @@ -113,47 +132,44 @@ const DashboardEmptyState: React.FC<{ pointerEvents: 'none', }} > - - - What do you want done? - - {`@keyframes welcome-caret { 0%,49% { opacity: 1 } 50%,100% { opacity: 0 } }`} + {/* The welcome pops in (scale + fade), then the intro types itself out. */} + - Tell the - {/* Literal toolbar glyph; the shimmer's transparent color would hide it, so reset color here. */} - - - - below and I'll put a team on it - + + {introShown} + {!introDone && ( + + ▌ + + )} + + - {showChips && ( - + {showChips && introDone ? ( + // Chips reveal only after the intro finishes typing, so it reads as one beat. + {expanded === null ? ( - or try one of these + pick one, or just tell me below {STARTER_CATEGORIES.map((cat) => ( @@ -233,6 +249,12 @@ const DashboardEmptyState: React.FC<{ )} + ) : ( + introDone && ( + + Connect a model in Settings to get started. + + ) )} ); From 665dec63e265b68e6e027bb8d3308eeba48789f0 Mon Sep 17 00:00:00 2001 From: ciregenz Date: Sun, 14 Jun 2026 00:56:48 -0700 Subject: [PATCH 05/27] [eric] onboarding: first-run proactive welcome chat (seeded greeting + in-chat chips, zero run) + cursor pop-nudge at Continue --- .../components/Onboarding/OnboardingPanel.tsx | 2 + .../components/Onboarding/OnboardingRoot.tsx | 17 +++ .../Onboarding/ac/AgenticCursor.tsx | 10 +- .../src/app/components/Onboarding/eventBus.ts | 3 +- .../app/components/Onboarding/selectors.ts | 2 + .../app/components/Onboarding/steps/index.ts | 7 +- .../Onboarding/steps/step00_welcomeNudge.ts | 18 +++ .../src/app/pages/AgentChat/AgentChat.tsx | 10 ++ .../pages/AgentChat/WelcomeQuickReplies.tsx | 120 +++++++++++++++++ .../Dashboard/canvas/DashboardEmptyState.tsx | 126 ++---------------- .../hooks/lifecycle/useWelcomeDraft.ts | 86 ++++++++++++ .../hooks/state/useDashboardController.ts | 12 ++ frontend/src/shared/starterCategories.ts | 53 ++++++++ frontend/src/shared/state/agentsSlice.ts | 18 ++- .../shared/state/onboardingProgressSlice.ts | 11 ++ 15 files changed, 374 insertions(+), 121 deletions(-) create mode 100644 frontend/src/app/components/Onboarding/steps/step00_welcomeNudge.ts create mode 100644 frontend/src/app/pages/AgentChat/WelcomeQuickReplies.tsx create mode 100644 frontend/src/app/pages/Dashboard/hooks/lifecycle/useWelcomeDraft.ts create mode 100644 frontend/src/shared/starterCategories.ts diff --git a/frontend/src/app/components/Onboarding/OnboardingPanel.tsx b/frontend/src/app/components/Onboarding/OnboardingPanel.tsx index f78773b9..45fadbac 100644 --- a/frontend/src/app/components/Onboarding/OnboardingPanel.tsx +++ b/frontend/src/app/components/Onboarding/OnboardingPanel.tsx @@ -16,6 +16,7 @@ import { clearJustCompleted } from '@/shared/state/onboardingProgressSlice'; import { STEPS, findStepById } from './steps'; import { useUnlockedStepIds } from './steps/stepUnlock'; import { STAGE_LABELS } from './steps/types'; +import { S } from './selectors'; import { onboardingDirector } from './OnboardingDirector'; import { report } from './telemetry'; import { cursorStore } from './ac/cursorStore'; @@ -166,6 +167,7 @@ const OnboardingPanel: React.FC = () => { style={{ pointerEvents: 'auto' }} > { report('panel_expanded', { from: 'pill' }); progress.setPanelMode('expanded'); diff --git a/frontend/src/app/components/Onboarding/OnboardingRoot.tsx b/frontend/src/app/components/Onboarding/OnboardingRoot.tsx index 89d9fd03..508c9293 100644 --- a/frontend/src/app/components/Onboarding/OnboardingRoot.tsx +++ b/frontend/src/app/components/Onboarding/OnboardingRoot.tsx @@ -50,6 +50,23 @@ const OnboardingRoot: React.FC = () => { dispatch, ]); + // First-run cursor nudge: a beat after the welcome chat pops, the cursor points at the + // top-right "Continue" pill. Armed by the once-ever 'welcome:shown' event (so it never + // re-fires across reloads), fail-safe (a missing AC / off-dashboard route just no-ops). + const nudgeFiredRef = useRef(false); + const nudgeTimerRef = useRef(null); + useEffect(() => { + const off = onboardingBus.on('welcome:shown', () => { + if (nudgeFiredRef.current || !window.location.hash.includes('/dashboard/')) return; + nudgeFiredRef.current = true; + nudgeTimerRef.current = window.setTimeout(() => { + if (!window.location.hash.includes('/dashboard/') || onboardingDirector.isRunning()) return; + onboardingDirector.startStep('welcome_nudge', { x: window.innerWidth - 120, y: 80 }); + }, 1000); + }); + return () => { off(); if (nudgeTimerRef.current) window.clearTimeout(nudgeTimerRef.current); }; + }, []); + useEffect(() => { if (progress.initialized) return; if (!settingsLoaded) return; diff --git a/frontend/src/app/components/Onboarding/ac/AgenticCursor.tsx b/frontend/src/app/components/Onboarding/ac/AgenticCursor.tsx index 212af63c..796097c3 100644 --- a/frontend/src/app/components/Onboarding/ac/AgenticCursor.tsx +++ b/frontend/src/app/components/Onboarding/ac/AgenticCursor.tsx @@ -95,12 +95,16 @@ const AgenticCursor = forwardRef((_props, ref) => { async fadeIn(from) { stopTrackingInternal(); writePos(from.x, from.y, true); - controls.set({ x: from.x, y: from.y, opacity: 0, scale: 0.5 }); + controls.set({ x: from.x, y: from.y, opacity: 0, scale: 0.3 }); setVisible(true); + // A little "pop!": scale overshoots past 1 then settles, while opacity snaps in. await controls.start({ opacity: 1, - scale: 1, - transition: { duration: 0.32, ease: 'easeOut' }, + scale: [0.3, 1.15, 1], + transition: { + opacity: { duration: 0.16, ease: 'easeOut' }, + scale: { duration: 0.36, ease: 'easeOut', times: [0, 0.62, 1] }, + }, }); }, async moveTo(x, y, transition) { diff --git a/frontend/src/app/components/Onboarding/eventBus.ts b/frontend/src/app/components/Onboarding/eventBus.ts index ae9a6fcb..f66bc458 100644 --- a/frontend/src/app/components/Onboarding/eventBus.ts +++ b/frontend/src/app/components/Onboarding/eventBus.ts @@ -14,7 +14,8 @@ export type OnboardingEvent = | 'element_selection:toggled' | 'agent:spawned' | 'agent:completed' - | 'agent:attached_to_browser'; + | 'agent:attached_to_browser' + | 'welcome:shown'; type Handler = (...args: unknown[]) => void; diff --git a/frontend/src/app/components/Onboarding/selectors.ts b/frontend/src/app/components/Onboarding/selectors.ts index e15972e5..1d9c9935 100644 --- a/frontend/src/app/components/Onboarding/selectors.ts +++ b/frontend/src/app/components/Onboarding/selectors.ts @@ -16,6 +16,8 @@ export const S = { newAgentButton: 'new-agent-button', browserButton: 'browser-button', canvasControls: 'canvas-controls', + /** The top-right onboarding pill ("Continue"); the first-run welcome nudge points here. */ + onboardingContinueButton: 'onboarding-continue-button', dashboardToolbarApps: 'dashboard-toolbar-apps', diff --git a/frontend/src/app/components/Onboarding/steps/index.ts b/frontend/src/app/components/Onboarding/steps/index.ts index 2c66a1e0..1fe0f152 100644 --- a/frontend/src/app/components/Onboarding/steps/index.ts +++ b/frontend/src/app/components/Onboarding/steps/index.ts @@ -7,6 +7,7 @@ import { step05 } from './step05_agentUseBrowser'; import { step06 } from './step06_agentControlAgents'; import { step07 } from './step07_installSkill'; import { step08 } from './step08_makeApp'; +import { welcomeNudgeStep } from './step00_welcomeNudge'; // Value-first order: launch an agent (step03) FIRST so a brand-new user sees // the product work on the free trial, then connect-your-own-model (step01). @@ -22,8 +23,12 @@ export const STEPS: OnboardingStep[] = [ step08, ]; +// Resolvable by the Director but kept OUT of STEPS, so they never appear in the roadmap, +// the panel count, or the unlock chain. The first-run welcome nudge lives here. +const HIDDEN_STEPS: OnboardingStep[] = [welcomeNudgeStep]; + export function findStepById(id: string): OnboardingStep | undefined { - return STEPS.find((s) => s.id === id); + return STEPS.find((s) => s.id === id) ?? HIDDEN_STEPS.find((s) => s.id === id); } export const STAGE_GROUPS: { stage: StepStage; steps: OnboardingStep[] }[] = [ diff --git a/frontend/src/app/components/Onboarding/steps/step00_welcomeNudge.ts b/frontend/src/app/components/Onboarding/steps/step00_welcomeNudge.ts new file mode 100644 index 00000000..a0591ad1 --- /dev/null +++ b/frontend/src/app/components/Onboarding/steps/step00_welcomeNudge.ts @@ -0,0 +1,18 @@ +import type { OnboardingStep } from './types'; +import { S } from '../selectors'; + +// Invisible first-run nudge (not part of the numbered tour): a beat after the welcome chat +// pops, the cursor points at the top-right "Continue" pill so the user knows the guided tour +// is there, then retreats. No click, no wait_user, so it's a one-shot gentle gesture. +export const welcomeNudgeStep: OnboardingStep = { + id: 'welcome_nudge', + stage: 'get_started', + index: 0, + title: 'Welcome', + description: '', + ops: [ + { kind: 'move_to', target: S.onboardingContinueButton }, + { kind: 'popup', text: 'Want a quick tour? Tap Continue any time.' }, + { kind: 'outro' }, + ], +}; diff --git a/frontend/src/app/pages/AgentChat/AgentChat.tsx b/frontend/src/app/pages/AgentChat/AgentChat.tsx index be0a2436..4ca802b0 100644 --- a/frontend/src/app/pages/AgentChat/AgentChat.tsx +++ b/frontend/src/app/pages/AgentChat/AgentChat.tsx @@ -45,6 +45,7 @@ import { store } from '@/shared/state/store'; import { fetchModes } from '@/shared/state/modesSlice'; import { createSessionWs, acquireSessionWs, releaseSessionWs } from '@/shared/ws/WebSocketManager'; import StreamingBubble from './bubbles/StreamingBubble'; +import WelcomeQuickReplies from './WelcomeQuickReplies'; import MessageBubble from './bubbles/MessageBubble'; import { estimateRenderedTextHeight, RECHECK_VISIBILITY_EVENT } from './bubbles/markdownMeasure'; import CompactionMarker from './bubbles/CompactionMarker'; @@ -1848,6 +1849,15 @@ const AgentChat: React.FC = ({ sessionId: sessionIdProp, onClose )} + {/* First-run welcome: quick-reply chips under the seeded greeting; vanish the + moment a user message exists (i.e. once they answer). */} + {session.is_welcome_draft && isDraft && !session.messages.some((m) => m.role === 'user') && ( + handleSend(p)} + onPickBuilder={(p) => chatInputRef.current?.setContent(p)} + /> + )} {showScrollButton && ( diff --git a/frontend/src/app/pages/AgentChat/WelcomeQuickReplies.tsx b/frontend/src/app/pages/AgentChat/WelcomeQuickReplies.tsx new file mode 100644 index 00000000..2b512b40 --- /dev/null +++ b/frontend/src/app/pages/AgentChat/WelcomeQuickReplies.tsx @@ -0,0 +1,120 @@ +import React from 'react'; +import Box from '@mui/material/Box'; +import Typography from '@mui/material/Typography'; +import { motion, AnimatePresence } from 'framer-motion'; +import { ArrowLeft } from 'lucide-react'; +import type { ClaudeTokens } from '@/shared/styles/claudeTokens'; +import { STARTER_CATEGORIES } from '@/shared/starterCategories'; + +// Quick-reply chips shown under the welcome greeting inside the first-run chat. Two-level: +// pick a category, then a concrete prompt. Research/Write/Learn -> onPick (runs the agent); +// Build -> onPickBuilder (opens App Builder). Pure UI; no run until the parent fires onPick. +const WelcomeQuickReplies: React.FC<{ + c: ClaudeTokens; + onPick: (prompt: string) => void; + onPickBuilder: (prompt: string) => void; +}> = ({ c, onPick, onPickBuilder }) => { + const [expanded, setExpanded] = React.useState(null); + const currentCategory = STARTER_CATEGORIES.find((cat) => cat.id === expanded); + const isAppBuilder = currentCategory?.target === 'app-builder'; + const currentPrompts = currentCategory?.prompts ?? []; + + const pick = (prompt: string) => { + if (isAppBuilder) onPickBuilder(prompt); + else onPick(prompt); + }; + + return ( + + + {expanded === null ? ( + + + pick one, or just type below + + + {STARTER_CATEGORIES.map((cat) => ( + setExpanded(cat.id)} + sx={{ + display: 'flex', alignItems: 'center', gap: 1, + px: 1.5, py: 1.05, + borderRadius: 2.2, + border: `1px solid ${c.border.medium}`, + background: c.bg.surface, + color: c.text.secondary, + fontSize: '0.9rem', fontWeight: 500, + cursor: 'pointer', fontFamily: 'inherit', + transition: 'background 150ms, border-color 150ms', + '&:hover': { background: c.bg.elevated, borderColor: c.border.strong }, + }} + > + + {cat.label} + + ))} + + + ) : ( + + setExpanded(null)} + sx={{ + display: 'inline-flex', alignItems: 'center', gap: 0.5, + alignSelf: 'flex-start', mb: 0.9, px: 0.6, py: 0.3, + border: 'none', background: 'transparent', + color: c.text.ghost, fontSize: '0.85rem', + cursor: 'pointer', fontFamily: 'inherit', + '&:hover': { color: c.text.secondary }, + }} + > + back + + + {currentPrompts.map((prompt) => ( + pick(prompt)} + sx={{ + textAlign: 'left', + px: 1.4, py: 0.95, + borderRadius: 1.8, + border: `1px solid ${c.border.medium}`, + background: c.bg.surface, + color: c.text.secondary, + fontSize: '0.88rem', + cursor: 'pointer', fontFamily: 'inherit', + transition: 'background 150ms, border-color 150ms', + '&:hover': { background: c.bg.elevated, borderColor: c.border.strong }, + }} + > + {prompt} + + ))} + + + )} + + + ); +}; + +export default WelcomeQuickReplies; diff --git a/frontend/src/app/pages/Dashboard/canvas/DashboardEmptyState.tsx b/frontend/src/app/pages/Dashboard/canvas/DashboardEmptyState.tsx index 730b3549..2a04fbd7 100644 --- a/frontend/src/app/pages/Dashboard/canvas/DashboardEmptyState.tsx +++ b/frontend/src/app/pages/Dashboard/canvas/DashboardEmptyState.tsx @@ -2,118 +2,42 @@ import React from 'react'; import Box from '@mui/material/Box'; import Typography from '@mui/material/Typography'; import { motion, AnimatePresence } from 'framer-motion'; -import { Search, Hammer, PenLine, GraduationCap, ArrowLeft } from 'lucide-react'; -import type { LucideIcon } from 'lucide-react'; +import { ArrowLeft } from 'lucide-react'; import type { ClaudeTokens } from '@/shared/styles/claudeTokens'; import { useAppSelector } from '@/shared/hooks'; import { hasModelConnected, hasFreeTrialActive, } from '@/app/components/Onboarding/steps/skipPredicates'; +import { STARTER_CATEGORIES } from '@/shared/starterCategories'; -// The static self-intro the welcome types out (no LLM); the user answers in the chat. -const INTRO = "Hi, I'm your AI team. What do you want done?"; - -// One-shot typewriter for a fixed string. enabled=false shows it instantly (returning -// users); enabled=true reveals it char-by-char once. No infinite loop: it stops at the end. -function useTypewriter(text: string, enabled: boolean, speedMs = 34): { shown: string; done: boolean } { - const [shown, setShown] = React.useState(enabled ? '' : text); - React.useEffect(() => { - if (!enabled) { setShown(text); return; } - setShown(''); - let i = 0; - const id = window.setInterval(() => { - i += 1; - setShown(text.slice(0, i)); - if (i >= text.length) window.clearInterval(id); - }, speedMs); - return () => window.clearInterval(id); - }, [text, enabled, speedMs]); - return { shown, done: shown.length >= text.length }; -} - -// Two-level starters: pick a category, then its concrete prompts spawn. Every -// prompt is one-click-runnable (no [placeholders]) and free-trial-safe, it -// touches the web or the App Builder sandbox, never the user's files. -// target 'app-builder' opens the App Builder (live preview) with the prompt -// auto-sent; the rest run as a normal agent on the dashboard. -type StarterCategory = { id: string; label: string; Icon: LucideIcon; prompts: string[]; target?: 'app-builder' }; -const STARTER_CATEGORIES: StarterCategory[] = [ - { - id: 'research', label: 'Research', Icon: Search, - prompts: [ - 'Find today\'s top news and summarize it for me', - 'Compare the 3 best standing desks and recommend one', - 'Plan a weekend trip to Tokyo with a day-by-day itinerary', - 'Find the strangest world record I could actually break', - ], - }, - { - id: 'build', label: 'Build', Icon: Hammer, target: 'app-builder', - prompts: [ - 'Build a focus timer that dings when the break starts', - 'Make a tip calculator that splits the bill', - 'Create a Snake game I can play right now', - 'Build a tiny Minecraft-style block world I can walk around in', - ], - }, - { - id: 'write', label: 'Write', Icon: PenLine, - prompts: [ - 'Write a friendly email introducing myself to a new client', - 'Turn my rough notes into a polished update', - 'Write a product description for a coffee mug', - 'Write my morning routine as an epic fantasy quest', - ], - }, - { - id: 'learn', label: 'Learn', Icon: GraduationCap, - prompts: [ - 'Explain how AI chatbots actually work, in plain English', - 'Teach me the basics of investing in 5 minutes', - 'Explain the stock market like I\'m five', - 'What would happen if the moon disappeared tomorrow?', - ], - }, -]; - +// Returning-user empty state (the first-run greeting now lives in the auto-popped welcome +// chat). Quiet: a one-line prompt + the shared starter chips for users who can run, or a +// connect-a-model hint for users who can't. Two-level: category -> concrete prompts. const DashboardEmptyState: React.FC<{ c: ClaudeTokens; onLaunch?: (prompt: string, mode: string, model: string) => void; - // Open the composer with the prompt typed in (translucent, unsent) on click. - // Optional mode opens it in a specific mode (Build -> 'view-builder'). onStarter?: (prompt: string, mode?: string) => void; }> = ({ c, onLaunch, onStarter }) => { const model = useAppSelector((s) => s.settings.data.default_model); const mode = useAppSelector((s) => s.settings.data.default_mode); const canRun = useAppSelector((s) => hasFreeTrialActive(s) || hasModelConnected(s)); - // Type the intro only for a fresh user (first agent not launched yet); returning - // users see it instantly so it isn't re-typed on every empty dashboard. - const introTyping = useAppSelector( - (s) => !(s.onboardingProgress?.completedSteps ?? []).includes('launch_agent'), - ); - const { shown: introShown, done: introDone } = useTypewriter(INTRO, introTyping); const [launching, setLaunching] = React.useState(false); const [expanded, setExpanded] = React.useState(null); const currentCategory = STARTER_CATEGORIES.find((cat) => cat.id === expanded); const currentPrompts = currentCategory?.prompts ?? []; - // Only offer chips once a run can actually succeed (free trial armed or a real - // model connected); otherwise fall back to the plain hint. const showChips = !!onLaunch && canRun; - const isAppBuilder = currentCategory?.target === 'app-builder'; const launch = (prompt: string) => { if (launching) return; - // App Builder opens its own surface, so prefill its composer (the user reviews, - // then builds). Everything else fires immediately: a pick is one tap to a running agent. if (isAppBuilder) { if (onStarter) onStarter(prompt, 'view-builder'); return; } if (onLaunch) { - setLaunching(true); // empty state unmounts on first session, but guard a fast double-click + setLaunching(true); onLaunch(prompt, mode, model); return; } @@ -132,31 +56,11 @@ const DashboardEmptyState: React.FC<{ pointerEvents: 'none', }} > - - {/* The welcome pops in (scale + fade), then the intro types itself out. */} - - - {introShown} - {!introDone && ( - - ▌ - - )} - - + + What do you want done? + - {showChips && introDone ? ( - // Chips reveal only after the intro finishes typing, so it reads as one beat. + {showChips ? ( {expanded === null ? ( @@ -165,7 +69,7 @@ const DashboardEmptyState: React.FC<{ initial={{ opacity: 0, y: 6 }} animate={{ opacity: 1, y: 0 }} exit={{ opacity: 0, y: -4 }} - transition={{ duration: 0.22 }} + transition={{ duration: 0.2 }} style={{ width: '100%', display: 'flex', flexDirection: 'column', alignItems: 'center' }} > @@ -250,11 +154,9 @@ const DashboardEmptyState: React.FC<{ ) : ( - introDone && ( - - Connect a model in Settings to get started. - - ) + + Connect a model in Settings to get started. + )} ); diff --git a/frontend/src/app/pages/Dashboard/hooks/lifecycle/useWelcomeDraft.ts b/frontend/src/app/pages/Dashboard/hooks/lifecycle/useWelcomeDraft.ts new file mode 100644 index 00000000..41db6bc6 --- /dev/null +++ b/frontend/src/app/pages/Dashboard/hooks/lifecycle/useWelcomeDraft.ts @@ -0,0 +1,86 @@ +import { useEffect, useRef, type RefObject } from 'react'; +import { useAppDispatch, useAppSelector } from '@/shared/hooks'; +import { createDraftSession, expandSession, type AgentMessage } from '@/shared/state/agentsSlice'; +import { placeCard, DEFAULT_CARD_W, EXPANDED_CARD_MIN_H } from '@/shared/state/dashboardLayoutSlice'; +import { markWelcomeShown } from '@/shared/state/onboardingProgressSlice'; +import { hasFreeTrialActive, hasModelConnected } from '@/app/components/Onboarding/steps/skipPredicates'; +import { onboardingBus } from '@/app/components/Onboarding/eventBus'; + +type SpawnOrigin = { x: number; y: number; type?: 'branch' }; + +// The proactive first-run greeting (static, no LLM). Seeded into the draft only; it never +// reaches the backend (launchAndSendFirstMessage.fulfilled swaps the draft for the server session). +const GREETING = "Hi, I'm your AI team. What do you want done?"; + +interface Args { + dashboardId: string; + isActive: boolean; + /** layoutInitialized && no sessions/views/browsers on the canvas. */ + canvasEmpty: boolean; + expandedSessionIds: string[]; + viewportRef: RefObject; + canvasStateRef: RefObject<{ panX: number; panY: number; zoom: number }>; + spawnOriginsRef: RefObject>; +} + +// Once ever, for a genuinely fresh user with a way to run, auto-open a welcome chat card on the +// empty dashboard: a seeded greeting + quick-reply chips, ZERO run consumed until they answer. +// Fail-safe: any throw bails and the dashboard/chat keep working by hand. +export function useWelcomeDraft({ + dashboardId, isActive, canvasEmpty, expandedSessionIds, + viewportRef, canvasStateRef, spawnOriginsRef, +}: Args): void { + const dispatch = useAppDispatch(); + const createdRef = useRef(false); + const eligible = useAppSelector( + (s) => + s.settings.loaded && + (hasFreeTrialActive(s) || hasModelConnected(s)) && + !s.onboardingProgress.welcomeShown && + !(s.onboardingProgress.completedSteps ?? []).includes('launch_agent'), + ); + const model = useAppSelector((s) => s.settings.data.default_model); + + useEffect(() => { + if (createdRef.current) return; + if (!isActive || !canvasEmpty || !eligible) return; + createdRef.current = true; + try { + const greeting: AgentMessage = { + id: 'welcome-greeting', + role: 'assistant', + content: GREETING, + timestamp: new Date().toISOString(), + branch_id: 'main', + parent_id: null, + }; + const action = dispatch( + createDraftSession({ welcome: true, seededMessages: [greeting], model, mode: 'agent', dashboardId, setActive: true }), + ); + const draftId = action.payload.draftId; + + // Center the card in the current viewport (canvas coords) so it pops in front of the user. + const vp = viewportRef.current; + const cs = canvasStateRef.current; + if (vp && cs) { + const vr = vp.getBoundingClientRect(); + const cx = (vr.width / 2 - cs.panX) / cs.zoom; + const cy = (vr.height / 2 - cs.panY) / cs.zoom; + if (spawnOriginsRef.current) spawnOriginsRef.current[draftId] = { x: cx, y: cy }; + dispatch(placeCard({ + sessionId: draftId, + x: cx - DEFAULT_CARD_W / 2, + y: cy - EXPANDED_CARD_MIN_H / 2, + width: DEFAULT_CARD_W, + height: EXPANDED_CARD_MIN_H, + expandedSessionIds, + })); + } + dispatch(expandSession(draftId)); + dispatch(markWelcomeShown()); + onboardingBus.emit('welcome:shown'); + } catch (err) { + console.error('[welcome-draft] create failed', err); + } + }, [isActive, canvasEmpty, eligible, model, dashboardId, expandedSessionIds, dispatch, viewportRef, canvasStateRef, spawnOriginsRef]); +} diff --git a/frontend/src/app/pages/Dashboard/hooks/state/useDashboardController.ts b/frontend/src/app/pages/Dashboard/hooks/state/useDashboardController.ts index a252b71b..79dcb457 100644 --- a/frontend/src/app/pages/Dashboard/hooks/state/useDashboardController.ts +++ b/frontend/src/app/pages/Dashboard/hooks/state/useDashboardController.ts @@ -15,6 +15,7 @@ import { useDashboardClipboard } from '../interaction/useDashboardClipboard'; import { useCardDrag } from '../interaction/useCardDrag'; import { useSubAgentLifecycle } from '../lifecycle/useSubAgentLifecycle'; import { useDashboardLifecycle } from '../lifecycle/useDashboardLifecycle'; +import { useWelcomeDraft } from '../lifecycle/useWelcomeDraft'; import { useDashboardThumbnail } from './useDashboardThumbnail'; import { useSiblingRestack } from '../lifecycle/useSiblingRestack'; import { useAgentSpawn } from '../lifecycle/useAgentSpawn'; @@ -129,6 +130,17 @@ export function useDashboardController(dashboardId: string, isActive: boolean) { restoredExpandedRef, }); + // First-run: auto-pop a welcome chat (seeded greeting + chips) on the empty dashboard. + useWelcomeDraft({ + dashboardId, + isActive, + canvasEmpty, + expandedSessionIds, + viewportRef: canvas.viewportRef, + canvasStateRef, + spawnOriginsRef, + }); + // ---- Auto-reveal / collapse / unreveal sub-agent cards ---- useSubAgentLifecycle({ isActive, diff --git a/frontend/src/shared/starterCategories.ts b/frontend/src/shared/starterCategories.ts new file mode 100644 index 00000000..5d6d84f5 --- /dev/null +++ b/frontend/src/shared/starterCategories.ts @@ -0,0 +1,53 @@ +import { Search, Hammer, PenLine, GraduationCap } from 'lucide-react'; +import type { LucideIcon } from 'lucide-react'; + +// Two-level starters shared by the empty-state and the first-run welcome chat: pick a +// category, then its concrete prompts. Every prompt is one-click-runnable (no [placeholders]) +// and free-trial-safe, it touches the web or the App Builder sandbox, never the user's files. +// target 'app-builder' opens the App Builder (live preview); the rest run as a normal agent. +export type StarterCategory = { + id: string; + label: string; + Icon: LucideIcon; + prompts: string[]; + target?: 'app-builder'; +}; + +export const STARTER_CATEGORIES: StarterCategory[] = [ + { + id: 'research', label: 'Research', Icon: Search, + prompts: [ + 'Find today\'s top news and summarize it for me', + 'Compare the 3 best standing desks and recommend one', + 'Plan a weekend trip to Tokyo with a day-by-day itinerary', + 'Find the strangest world record I could actually break', + ], + }, + { + id: 'build', label: 'Build', Icon: Hammer, target: 'app-builder', + prompts: [ + 'Build a focus timer that dings when the break starts', + 'Make a tip calculator that splits the bill', + 'Create a Snake game I can play right now', + 'Build a tiny Minecraft-style block world I can walk around in', + ], + }, + { + id: 'write', label: 'Write', Icon: PenLine, + prompts: [ + 'Write a friendly email introducing myself to a new client', + 'Turn my rough notes into a polished update', + 'Write a product description for a coffee mug', + 'Write my morning routine as an epic fantasy quest', + ], + }, + { + id: 'learn', label: 'Learn', Icon: GraduationCap, + prompts: [ + 'Explain how AI chatbots actually work, in plain English', + 'Teach me the basics of investing in 5 minutes', + 'Explain the stock market like I\'m five', + 'What would happen if the moon disappeared tomorrow?', + ], + }, +]; diff --git a/frontend/src/shared/state/agentsSlice.ts b/frontend/src/shared/state/agentsSlice.ts index d76912d9..b0f71e69 100644 --- a/frontend/src/shared/state/agentsSlice.ts +++ b/frontend/src/shared/state/agentsSlice.ts @@ -101,6 +101,9 @@ export interface AgentSession { connection_state?: 'live' | 'reconnecting'; /** Aux-LLM verb-phrase for the current turn; ThinkingBubble swaps in then back when turn ends. */ turn_label?: { label: string; turn_id: string } | null; + /** Frontend-only: this draft is the first-run welcome (seeded greeting + quick-reply chips). + * Dropped on the server swap in launchAndSendFirstMessage.fulfilled, so it never persists. */ + is_welcome_draft?: boolean; } export interface AgentConfig { @@ -508,8 +511,8 @@ const agentsSlice = createSlice({ initialState, reducers: { createDraftSession: { - reducer(state, action: PayloadAction<{ draftId: string; mode: string; setActive: boolean; targetDirectory?: string; model?: string; provider?: string; thinkingLevel?: 'off' | 'low' | 'medium' | 'high' | 'auto' }>) { - const { draftId, mode, setActive, targetDirectory, model, provider, thinkingLevel } = action.payload; + reducer(state, action: PayloadAction<{ draftId: string; mode: string; setActive: boolean; targetDirectory?: string; model?: string; provider?: string; thinkingLevel?: 'off' | 'low' | 'medium' | 'high' | 'auto'; seededMessages?: AgentMessage[]; welcome?: boolean; dashboardId?: string }>) { + const { draftId, mode, setActive, targetDirectory, model, provider, thinkingLevel, seededMessages, welcome, dashboardId } = action.payload; state.sessions[draftId] = { id: draftId, name: 'New chat', @@ -526,13 +529,17 @@ const agentsSlice = createSlice({ created_at: new Date().toISOString(), cost_usd: 0, tokens: { input: 0, output: 0 }, - messages: [], + // A seeded greeting is purely cosmetic: launchAndSendFirstMessage.fulfilled deletes this + // draft and swaps in the raw server session, so seeded messages never reach the backend. + messages: seededMessages ?? [], pending_approvals: [], branches: { main: { id: 'main', parent_branch_id: null, fork_point_message_id: null, created_at: new Date().toISOString() } }, active_branch_id: 'main', target_directory: targetDirectory || null, tool_group_meta: {}, thinking_level: thinkingLevel, + dashboard_id: dashboardId, + is_welcome_draft: welcome === true, }; if (setActive) { state.activeSessionId = draftId; @@ -541,7 +548,7 @@ const agentsSlice = createSlice({ } } }, - prepare(opts?: { mode?: string; setActive?: boolean; targetDirectory?: string; model?: string; provider?: string; thinkingLevel?: 'off' | 'low' | 'medium' | 'high' | 'auto' }) { + prepare(opts?: { mode?: string; setActive?: boolean; targetDirectory?: string; model?: string; provider?: string; thinkingLevel?: 'off' | 'low' | 'medium' | 'high' | 'auto'; seededMessages?: AgentMessage[]; welcome?: boolean; dashboardId?: string }) { return { payload: { draftId: `draft-${Date.now().toString(36)}`, @@ -551,6 +558,9 @@ const agentsSlice = createSlice({ model: opts?.model, provider: opts?.provider, thinkingLevel: opts?.thinkingLevel, + seededMessages: opts?.seededMessages, + welcome: opts?.welcome, + dashboardId: opts?.dashboardId, }, }; }, diff --git a/frontend/src/shared/state/onboardingProgressSlice.ts b/frontend/src/shared/state/onboardingProgressSlice.ts index 0e5b600f..14961e35 100644 --- a/frontend/src/shared/state/onboardingProgressSlice.ts +++ b/frontend/src/shared/state/onboardingProgressSlice.ts @@ -32,6 +32,8 @@ export interface OnboardingProgressState { disableSkipIf: boolean; /** True once we've gently auto-opened the panel after the first agent win (once, ever). */ revealedAfterWin?: boolean; + /** True once the first-run welcome chat has been created (once ever, survives reload). */ + welcomeShown?: boolean; } export function loadFromStorage(): OnboardingProgressState | null { @@ -53,6 +55,8 @@ export function loadFromStorage(): OnboardingProgressState | null { initialized: true, justCompletedStepId: null, disableSkipIf: Boolean((parsed as any).disableSkipIf), + revealedAfterWin: Boolean((parsed as any).revealedAfterWin), + welcomeShown: Boolean((parsed as any).welcomeShown), }; } catch { return null; @@ -101,6 +105,7 @@ const initialState: OnboardingProgressState = { justCompletedStepId: null, disableSkipIf: false, revealedAfterWin: false, + welcomeShown: false, }; const slice = createSlice({ @@ -129,6 +134,7 @@ const slice = createSlice({ state.initialized = true; state.disableSkipIf = Boolean(action.payload.disableSkipIf); state.revealedAfterWin = false; + state.welcomeShown = false; }, hydrate(state, action: PayloadAction) { Object.assign(state, action.payload, { running: false, initialized: true }); @@ -162,6 +168,9 @@ const slice = createSlice({ markRevealedAfterWin(state) { state.revealedAfterWin = true; }, + markWelcomeShown(state) { + state.welcomeShown = true; + }, unmarkStepCompleted(state, action: PayloadAction) { state.completedSteps = state.completedSteps.filter((id) => id !== action.payload); }, @@ -186,6 +195,7 @@ const slice = createSlice({ state.running = false; state.startedAt = Date.now(); state.revealedAfterWin = false; + state.welcomeShown = false; // Explicit restart: suppress skipIf so residual prior-tour data can't auto-mark. state.disableSkipIf = true; }, @@ -200,6 +210,7 @@ export const { markStepCompleted, clearJustCompleted, markRevealedAfterWin, + markWelcomeShown, unmarkStepCompleted, setRunning, recordMultiChoice, From b2fabec4a40142bf3d86cfea398000ebdf862568 Mon Sep 17 00:00:00 2001 From: ciregenz Date: Sun, 14 Jun 2026 01:06:49 -0700 Subject: [PATCH 06/27] [eric] onboarding: stream the welcome greeting + stagger-pop the chips (greeting moves into the component, no seeded message) --- .../src/app/pages/AgentChat/AgentChat.tsx | 18 +- .../pages/AgentChat/WelcomeQuickReplies.tsx | 218 ++++++++++-------- .../hooks/lifecycle/useWelcomeDraft.ts | 18 +- 3 files changed, 141 insertions(+), 113 deletions(-) diff --git a/frontend/src/app/pages/AgentChat/AgentChat.tsx b/frontend/src/app/pages/AgentChat/AgentChat.tsx index 4ca802b0..57f599bb 100644 --- a/frontend/src/app/pages/AgentChat/AgentChat.tsx +++ b/frontend/src/app/pages/AgentChat/AgentChat.tsx @@ -1520,6 +1520,15 @@ const AgentChat: React.FC = ({ sessionId: sessionIdProp, onClose }} > + {/* First-run welcome: the greeting streams in, the chips pop in; vanishes the + moment a user message exists (i.e. once they answer). Pure UI, no run. */} + {session.is_welcome_draft && isDraft && !session.messages.some((m) => m.role === 'user') && ( + handleSend(p)} + onPickBuilder={(p) => chatInputRef.current?.setContent(p)} + /> + )} {(session.mcp_suggestions && session.mcp_suggestions.length > 0) && ( = ({ sessionId: sessionIdProp, onClose )} - {/* First-run welcome: quick-reply chips under the seeded greeting; vanish the - moment a user message exists (i.e. once they answer). */} - {session.is_welcome_draft && isDraft && !session.messages.some((m) => m.role === 'user') && ( - handleSend(p)} - onPickBuilder={(p) => chatInputRef.current?.setContent(p)} - /> - )} {showScrollButton && ( diff --git a/frontend/src/app/pages/AgentChat/WelcomeQuickReplies.tsx b/frontend/src/app/pages/AgentChat/WelcomeQuickReplies.tsx index 2b512b40..0e3795f3 100644 --- a/frontend/src/app/pages/AgentChat/WelcomeQuickReplies.tsx +++ b/frontend/src/app/pages/AgentChat/WelcomeQuickReplies.tsx @@ -6,14 +6,33 @@ import { ArrowLeft } from 'lucide-react'; import type { ClaudeTokens } from '@/shared/styles/claudeTokens'; import { STARTER_CATEGORIES } from '@/shared/starterCategories'; -// Quick-reply chips shown under the welcome greeting inside the first-run chat. Two-level: -// pick a category, then a concrete prompt. Research/Write/Learn -> onPick (runs the agent); -// Build -> onPickBuilder (opens App Builder). Pure UI; no run until the parent fires onPick. +const GREETING = "Hi, I'm your AI team. What do you want done?"; + +// One-shot typewriter for a fixed string (no infinite loop; stops at the end). +function useTypewriter(text: string, speedMs = 26): { shown: string; done: boolean } { + const [shown, setShown] = React.useState(''); + React.useEffect(() => { + setShown(''); + let i = 0; + const id = window.setInterval(() => { + i += 1; + setShown(text.slice(0, i)); + if (i >= text.length) window.clearInterval(id); + }, speedMs); + return () => window.clearInterval(id); + }, [text, speedMs]); + return { shown, done: shown.length >= text.length }; +} + +// The first-run welcome: the greeting streams in like typing, then the quick-reply chips +// pop in (staggered). Two-level: category -> concrete prompts. Research/Write/Learn -> onPick +// (real run); Build -> onPickBuilder (App Builder). Pure UI; no run until the parent fires. const WelcomeQuickReplies: React.FC<{ c: ClaudeTokens; onPick: (prompt: string) => void; onPickBuilder: (prompt: string) => void; }> = ({ c, onPick, onPickBuilder }) => { + const { shown: greeting, done: greetingDone } = useTypewriter(GREETING); const [expanded, setExpanded] = React.useState(null); const currentCategory = STARTER_CATEGORIES.find((cat) => cat.id === expanded); const isAppBuilder = currentCategory?.target === 'app-builder'; @@ -25,94 +44,113 @@ const WelcomeQuickReplies: React.FC<{ }; return ( - - - {expanded === null ? ( - - - pick one, or just type below - - - {STARTER_CATEGORIES.map((cat) => ( - setExpanded(cat.id)} - sx={{ - display: 'flex', alignItems: 'center', gap: 1, - px: 1.5, py: 1.05, - borderRadius: 2.2, - border: `1px solid ${c.border.medium}`, - background: c.bg.surface, - color: c.text.secondary, - fontSize: '0.9rem', fontWeight: 500, - cursor: 'pointer', fontFamily: 'inherit', - transition: 'background 150ms, border-color 150ms', - '&:hover': { background: c.bg.elevated, borderColor: c.border.strong }, - }} - > - - {cat.label} - - ))} - - - ) : ( - - setExpanded(null)} - sx={{ - display: 'inline-flex', alignItems: 'center', gap: 0.5, - alignSelf: 'flex-start', mb: 0.9, px: 0.6, py: 0.3, - border: 'none', background: 'transparent', - color: c.text.ghost, fontSize: '0.85rem', - cursor: 'pointer', fontFamily: 'inherit', - '&:hover': { color: c.text.secondary }, - }} - > - back - - - {currentPrompts.map((prompt) => ( - pick(prompt)} - sx={{ - textAlign: 'left', - px: 1.4, py: 0.95, - borderRadius: 1.8, - border: `1px solid ${c.border.medium}`, - background: c.bg.surface, - color: c.text.secondary, - fontSize: '0.88rem', - cursor: 'pointer', fontFamily: 'inherit', - transition: 'background 150ms, border-color 150ms', - '&:hover': { background: c.bg.elevated, borderColor: c.border.strong }, - }} - > - {prompt} - - ))} - - + + {/* Greeting bubble, typed out like a real reply. */} + + {greeting} + {!greetingDone && ( + )} - + + + {/* Chips reveal only once the greeting finishes, each popping in with a stagger. */} + {greetingDone && ( + + + {expanded === null ? ( + + + + pick one, or just type below + + + + {STARTER_CATEGORIES.map((cat, i) => ( + setExpanded(cat.id)} + initial={{ opacity: 0, scale: 0.82 }} + animate={{ opacity: 1, scale: 1 }} + transition={{ type: 'spring', stiffness: 520, damping: 24, delay: 0.08 + i * 0.07 }} + style={{ + display: 'flex', alignItems: 'center', gap: 8, + padding: '10px 14px', + borderRadius: 13, + border: `1px solid ${c.border.medium}`, + background: c.bg.surface, + color: c.text.secondary, + fontSize: '0.9rem', fontWeight: 500, + cursor: 'pointer', fontFamily: 'inherit', + }} + > + + {cat.label} + + ))} + + + ) : ( + + setExpanded(null)} + sx={{ + display: 'inline-flex', alignItems: 'center', gap: 0.5, + alignSelf: 'flex-start', mb: 0.9, px: 0.6, py: 0.3, + border: 'none', background: 'transparent', + color: c.text.ghost, fontSize: '0.85rem', + cursor: 'pointer', fontFamily: 'inherit', + '&:hover': { color: c.text.secondary }, + }} + > + back + + + {currentPrompts.map((prompt, i) => ( + pick(prompt)} + initial={{ opacity: 0, scale: 0.9 }} + animate={{ opacity: 1, scale: 1 }} + transition={{ type: 'spring', stiffness: 520, damping: 26, delay: i * 0.05 }} + style={{ + textAlign: 'left', + padding: '9px 14px', + borderRadius: 11, + border: `1px solid ${c.border.medium}`, + background: c.bg.surface, + color: c.text.secondary, + fontSize: '0.88rem', + cursor: 'pointer', fontFamily: 'inherit', + }} + > + {prompt} + + ))} + + + )} + + + )} ); }; diff --git a/frontend/src/app/pages/Dashboard/hooks/lifecycle/useWelcomeDraft.ts b/frontend/src/app/pages/Dashboard/hooks/lifecycle/useWelcomeDraft.ts index 41db6bc6..3908e535 100644 --- a/frontend/src/app/pages/Dashboard/hooks/lifecycle/useWelcomeDraft.ts +++ b/frontend/src/app/pages/Dashboard/hooks/lifecycle/useWelcomeDraft.ts @@ -1,6 +1,6 @@ import { useEffect, useRef, type RefObject } from 'react'; import { useAppDispatch, useAppSelector } from '@/shared/hooks'; -import { createDraftSession, expandSession, type AgentMessage } from '@/shared/state/agentsSlice'; +import { createDraftSession, expandSession } from '@/shared/state/agentsSlice'; import { placeCard, DEFAULT_CARD_W, EXPANDED_CARD_MIN_H } from '@/shared/state/dashboardLayoutSlice'; import { markWelcomeShown } from '@/shared/state/onboardingProgressSlice'; import { hasFreeTrialActive, hasModelConnected } from '@/app/components/Onboarding/steps/skipPredicates'; @@ -8,10 +8,6 @@ import { onboardingBus } from '@/app/components/Onboarding/eventBus'; type SpawnOrigin = { x: number; y: number; type?: 'branch' }; -// The proactive first-run greeting (static, no LLM). Seeded into the draft only; it never -// reaches the backend (launchAndSendFirstMessage.fulfilled swaps the draft for the server session). -const GREETING = "Hi, I'm your AI team. What do you want done?"; - interface Args { dashboardId: string; isActive: boolean; @@ -46,16 +42,10 @@ export function useWelcomeDraft({ if (!isActive || !canvasEmpty || !eligible) return; createdRef.current = true; try { - const greeting: AgentMessage = { - id: 'welcome-greeting', - role: 'assistant', - content: GREETING, - timestamp: new Date().toISOString(), - branch_id: 'main', - parent_id: null, - }; + // No seeded message: the greeting + chips render (and animate) inside the welcome chat + // via WelcomeQuickReplies, so nothing here can ever reach the backend. const action = dispatch( - createDraftSession({ welcome: true, seededMessages: [greeting], model, mode: 'agent', dashboardId, setActive: true }), + createDraftSession({ welcome: true, model, mode: 'agent', dashboardId, setActive: true }), ); const draftId = action.payload.draftId; From e5f9f2df62754f9e37fbc0c6603d59a865384e7a Mon Sep 17 00:00:00 2001 From: ciregenz Date: Sun, 14 Jun 2026 01:12:00 -0700 Subject: [PATCH 07/27] [eric] onboarding: first-run welcome agent is exploratory (narrow down an open-ended ask before doing); concrete chips still just run --- frontend/src/shared/state/agentsSlice.ts | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/frontend/src/shared/state/agentsSlice.ts b/frontend/src/shared/state/agentsSlice.ts index b0f71e69..48020937 100644 --- a/frontend/src/shared/state/agentsSlice.ts +++ b/frontend/src/shared/state/agentsSlice.ts @@ -4,6 +4,14 @@ import { normalizeSessionName } from './sessionDisplay'; const AGENTS_API = `${API_BASE}/agents`; +// Appended (server-side) to the base agent prompt for the very first run, so the welcome +// agent opens like a sharp teammate: narrow down an open-ended ask before diving in. +const WELCOME_EXPLORATORY_PROMPT = + "This is the user's very first task with you. Be a warm, sharp teammate: if their request " + + "is open-ended or vague, ask 1-2 short clarifying questions to pin down exactly what they " + + "want and care about before you start, then do it. If it's already concrete, just do it. " + + 'Keep any questions brief and friendly, never a wall of text.'; + export interface AgentMessage { id: string; role: 'user' | 'assistant' | 'tool_call' | 'tool_result' | 'system' | 'thinking'; @@ -523,7 +531,8 @@ const agentsSlice = createSlice({ worktree_path: null, branch_name: null, sdk_session_id: null, - system_prompt: null, + // Welcome drafts launch the first agent in an exploratory "narrow-down-then-do" mode. + system_prompt: welcome === true ? WELCOME_EXPLORATORY_PROMPT : null, allowed_tools: [], max_turns: null, created_at: new Date().toISOString(), From 7eff2b1a88f29eb1bbaf2f54f392ae64eae3f4df Mon Sep 17 00:00:00 2001 From: ciregenz Date: Sun, 14 Jun 2026 01:15:49 -0700 Subject: [PATCH 08/27] [eric] onboarding: revamp starter chips to showcase the differentiators (App Builder apps, browser agent, MCPs, file/PDF harness, parallel agents) --- frontend/src/shared/starterCategories.ts | 52 +++++++++++++----------- 1 file changed, 29 insertions(+), 23 deletions(-) diff --git a/frontend/src/shared/starterCategories.ts b/frontend/src/shared/starterCategories.ts index 5d6d84f5..ae61c341 100644 --- a/frontend/src/shared/starterCategories.ts +++ b/frontend/src/shared/starterCategories.ts @@ -1,10 +1,12 @@ -import { Search, Hammer, PenLine, GraduationCap } from 'lucide-react'; +import { Search, Hammer, Globe, Plug } from 'lucide-react'; import type { LucideIcon } from 'lucide-react'; // Two-level starters shared by the empty-state and the first-run welcome chat: pick a -// category, then its concrete prompts. Every prompt is one-click-runnable (no [placeholders]) -// and free-trial-safe, it touches the web or the App Builder sandbox, never the user's files. -// target 'app-builder' opens the App Builder (live preview); the rest run as a normal agent. +// category, then a concrete prompt. These are chosen to SHOWCASE what makes OpenSwarm +// different from a plain chatbot, real apps (App Builder), the browser agent, your own +// tools (MCPs), the file harness (PDFs/exports), and parallel agents on the canvas. Every +// prompt is one-click-runnable (no [placeholders]) and reads plainly for a non-dev. +// target 'app-builder' opens the App Builder (live preview); the rest run as an agent. export type StarterCategory = { id: string; label: string; @@ -15,39 +17,43 @@ export type StarterCategory = { export const STARTER_CATEGORIES: StarterCategory[] = [ { + // Web research that ends in a real artifact + parallel agents on the canvas. id: 'research', label: 'Research', Icon: Search, prompts: [ - 'Find today\'s top news and summarize it for me', - 'Compare the 3 best standing desks and recommend one', - 'Plan a weekend trip to Tokyo with a day-by-day itinerary', - 'Find the strangest world record I could actually break', + 'Plan a 3-day Tokyo trip and turn it into a printable PDF itinerary', + 'Compare the 5 best robot vacuums and make me a one-page buying guide', + 'Spin up 3 agents to research 3 competitors at once and tell me who wins', + "Find the latest on a topic I'll name and write me a brief with real sources", ], }, { - id: 'build', label: 'Build', Icon: Hammer, target: 'app-builder', + // The App Builder is a full app (logic + data + live preview), not a toy snippet. + id: 'build', label: 'Build an app', Icon: Hammer, target: 'app-builder', prompts: [ - 'Build a focus timer that dings when the break starts', - 'Make a tip calculator that splits the bill', - 'Create a Snake game I can play right now', - 'Build a tiny Minecraft-style block world I can walk around in', + 'Build a working expense tracker app with live charts', + 'Make a Snake game I can actually play right now', + 'Build a habit tracker that remembers my streaks between visits', + 'Create a little 3D block world I can walk around in', ], }, { - id: 'write', label: 'Write', Icon: PenLine, + // The browser agent: OpenSwarm's most powerful tool, it actually drives the web. + id: 'browse', label: 'Use the web', Icon: Globe, prompts: [ - 'Write a friendly email introducing myself to a new client', - 'Turn my rough notes into a polished update', - 'Write a product description for a coffee mug', - 'Write my morning routine as an epic fantasy quest', + 'Send an agent to find the cheapest flights to Tokyo and show me the best options', + 'Have an agent pull a clean list of the top-rated coffee shops in a city', + 'Find 3 well-reviewed standing desks online and screenshot the best one', + 'Watch an agent sign me up for a free newsletter on a site', ], }, { - id: 'learn', label: 'Learn', Icon: GraduationCap, + // MCPs: plug your real tools in and let agents work across them. + id: 'connect', label: 'Connect your apps', Icon: Plug, prompts: [ - 'Explain how AI chatbots actually work, in plain English', - 'Teach me the basics of investing in 5 minutes', - 'Explain the stock market like I\'m five', - 'What would happen if the moon disappeared tomorrow?', + 'Summarize my Gmail inbox and flag what actually needs a reply', + 'Turn my Notion notes into a clear action plan', + 'Look at my calendar and lay out a realistic plan for my week', + 'Pull a sheet from my Google Drive and chart what matters', ], }, ]; From 294354d867374e12aa011b466d6793bf74c0e3b1 Mon Sep 17 00:00:00 2001 From: ciregenz Date: Sun, 14 Jun 2026 01:17:33 -0700 Subject: [PATCH 09/27] [eric] onboarding: Build chip says 'mini Minecraft' (clearer + more exciting than '3D block world') --- frontend/src/shared/starterCategories.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/src/shared/starterCategories.ts b/frontend/src/shared/starterCategories.ts index ae61c341..dad1de0d 100644 --- a/frontend/src/shared/starterCategories.ts +++ b/frontend/src/shared/starterCategories.ts @@ -33,7 +33,7 @@ export const STARTER_CATEGORIES: StarterCategory[] = [ 'Build a working expense tracker app with live charts', 'Make a Snake game I can actually play right now', 'Build a habit tracker that remembers my streaks between visits', - 'Create a little 3D block world I can walk around in', + 'Build a mini Minecraft I can walk around in', ], }, { From 24dd47b1a24176851ea9743029e7b98b0afd8422 Mon Sep 17 00:00:00 2001 From: ciregenz Date: Sun, 14 Jun 2026 01:23:32 -0700 Subject: [PATCH 10/27] [eric] onboarding: livelier Research chips (parallel-agent weekend plan, black-holes cheat sheet, strangest world record) over the flat ones --- frontend/src/shared/starterCategories.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/frontend/src/shared/starterCategories.ts b/frontend/src/shared/starterCategories.ts index dad1de0d..565c4bf8 100644 --- a/frontend/src/shared/starterCategories.ts +++ b/frontend/src/shared/starterCategories.ts @@ -21,9 +21,9 @@ export const STARTER_CATEGORIES: StarterCategory[] = [ id: 'research', label: 'Research', Icon: Search, prompts: [ 'Plan a 3-day Tokyo trip and turn it into a printable PDF itinerary', - 'Compare the 5 best robot vacuums and make me a one-page buying guide', - 'Spin up 3 agents to research 3 competitors at once and tell me who wins', - "Find the latest on a topic I'll name and write me a brief with real sources", + 'Send 3 agents to plan my weekend at once: where to eat, what to do, what to watch', + 'Break down how black holes really work into a printable one-page cheat sheet', + 'Find the strangest world record I could actually break and how to pull it off', ], }, { From d0848f868d9d7b50748c6e2dcbec695214577ab2 Mon Sep 17 00:00:00 2001 From: ciregenz Date: Sun, 14 Jun 2026 01:28:42 -0700 Subject: [PATCH 11/27] [eric] onboarding: Build chips lead with live-app wow only OpenSwarm gives instantly (Minecraft, beat-maker, live data dashboard, persistent tracker), drop Snake/expense-tracker --- frontend/src/shared/starterCategories.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/frontend/src/shared/starterCategories.ts b/frontend/src/shared/starterCategories.ts index 565c4bf8..6226e09f 100644 --- a/frontend/src/shared/starterCategories.ts +++ b/frontend/src/shared/starterCategories.ts @@ -30,10 +30,10 @@ export const STARTER_CATEGORIES: StarterCategory[] = [ // The App Builder is a full app (logic + data + live preview), not a toy snippet. id: 'build', label: 'Build an app', Icon: Hammer, target: 'app-builder', prompts: [ - 'Build a working expense tracker app with live charts', - 'Make a Snake game I can actually play right now', - 'Build a habit tracker that remembers my streaks between visits', 'Build a mini Minecraft I can walk around in', + 'Build a drum machine I can actually make beats on', + 'Build a live dashboard that pulls real weather and news', + 'Build a habit tracker that remembers my streaks between visits', ], }, { From 58b477462077a30a9c510e2ba985e9ad3658d428 Mon Sep 17 00:00:00 2001 From: ciregenz Date: Sun, 14 Jun 2026 01:33:44 -0700 Subject: [PATCH 12/27] [eric] onboarding: personal, computer-aware starter chips (resume->site, my-spreadsheet->dashboard, recap my work, Minecraft, slideshow) , the stuff a sandboxed chatbot can't touch --- frontend/src/shared/starterCategories.ts | 29 ++++++++++++------------ 1 file changed, 15 insertions(+), 14 deletions(-) diff --git a/frontend/src/shared/starterCategories.ts b/frontend/src/shared/starterCategories.ts index 6226e09f..4b3a1d92 100644 --- a/frontend/src/shared/starterCategories.ts +++ b/frontend/src/shared/starterCategories.ts @@ -2,10 +2,11 @@ import { Search, Hammer, Globe, Plug } from 'lucide-react'; import type { LucideIcon } from 'lucide-react'; // Two-level starters shared by the empty-state and the first-run welcome chat: pick a -// category, then a concrete prompt. These are chosen to SHOWCASE what makes OpenSwarm -// different from a plain chatbot, real apps (App Builder), the browser agent, your own -// tools (MCPs), the file harness (PDFs/exports), and parallel agents on the canvas. Every -// prompt is one-click-runnable (no [placeholders]) and reads plainly for a non-dev. +// category, then a concrete prompt. Chosen to SHOWCASE what only OpenSwarm can do, and to +// feel PERSONAL: the agents can see the user's own computer/files, drive the browser, plug +// into their apps (MCPs), build real apps, and run agents in parallel, none of which a plain +// chatbot can do out of the box. Many prompts deliberately touch the user's own stuff so it +// matters to them. One-click-runnable (no [placeholders]); reads plainly for a non-dev. // target 'app-builder' opens the App Builder (live preview); the rest run as an agent. export type StarterCategory = { id: string; @@ -17,32 +18,32 @@ export type StarterCategory = { export const STARTER_CATEGORIES: StarterCategory[] = [ { - // Web research that ends in a real artifact + parallel agents on the canvas. + // Deep web research that ends in a real artifact (PDF, slideshow) + the parallel canvas. id: 'research', label: 'Research', Icon: Search, prompts: [ 'Plan a 3-day Tokyo trip and turn it into a printable PDF itinerary', + 'Make a slideshow presentation on black holes', + "Look at what I've been working on lately and write me a quick recap", 'Send 3 agents to plan my weekend at once: where to eat, what to do, what to watch', - 'Break down how black holes really work into a printable one-page cheat sheet', - 'Find the strangest world record I could actually break and how to pull it off', ], }, { - // The App Builder is a full app (logic + data + live preview), not a toy snippet. + // Full live apps built from the user's OWN stuff, not toy snippets a chatbot just prints. id: 'build', label: 'Build an app', Icon: Hammer, target: 'app-builder', prompts: [ - 'Build a mini Minecraft I can walk around in', - 'Build a drum machine I can actually make beats on', - 'Build a live dashboard that pulls real weather and news', + 'Make me Minecraft I can play right now', + 'Build me a personal site from my resume', + 'Turn a spreadsheet on my computer into a live dashboard', 'Build a habit tracker that remembers my streaks between visits', ], }, { - // The browser agent: OpenSwarm's most powerful tool, it actually drives the web. + // The browser agent: OpenSwarm's most powerful tool, it actually drives the web for you. id: 'browse', label: 'Use the web', Icon: Globe, prompts: [ 'Send an agent to find the cheapest flights to Tokyo and show me the best options', - 'Have an agent pull a clean list of the top-rated coffee shops in a city', - 'Find 3 well-reviewed standing desks online and screenshot the best one', + 'Have an agent hunt down the best price on something I want to buy', + 'Find and screenshot the top-rated coffee shops in my city', 'Watch an agent sign me up for a free newsletter on a site', ], }, From ba850e5fdc094e9483566c2d1b1b7fc3b94697bf Mon Sep 17 00:00:00 2001 From: ciregenz Date: Sun, 14 Jun 2026 01:43:13 -0700 Subject: [PATCH 13/27] [eric] onboarding: slower sequential welcome reveal (title -> greeting -> chips) + cursor bursts in with radiating orange spark lines --- .../Onboarding/ac/AgenticCursor.tsx | 61 ++++++++++++++----- .../pages/AgentChat/WelcomeQuickReplies.tsx | 30 +++++---- 2 files changed, 63 insertions(+), 28 deletions(-) diff --git a/frontend/src/app/components/Onboarding/ac/AgenticCursor.tsx b/frontend/src/app/components/Onboarding/ac/AgenticCursor.tsx index 796097c3..85edbcf0 100644 --- a/frontend/src/app/components/Onboarding/ac/AgenticCursor.tsx +++ b/frontend/src/app/components/Onboarding/ac/AgenticCursor.tsx @@ -62,12 +62,34 @@ const IS_WIN = typeof navigator !== 'undefined' && navigator.userAgent.includes( // before the next step's instant write lands. const WIN_EASE_MS = 420; +// A little spark when the cursor pops into existence: short orange lines shoot out from the +// tip and fade. Re-keyed on each pop so it replays. One-shot per mount (no infinite loop). +const BURST_SPOKES = 8; +const CursorBurst: React.FC<{ color: string }> = ({ color }) => ( + <> + {Array.from({ length: BURST_SPOKES }).map((_, i) => ( +
+ +
+ ))} + +); + const AgenticCursor = forwardRef((_props, ref) => { const c = useClaudeTokens(); const controls = useAnimationControls(); const storePos = useCursorPosition(); const posRef = useRef({ x: 0, y: 0 }); const [visible, setVisible] = useState(false); + const [burstKey, setBurstKey] = useState(0); const [popup, setPopup] = useState(null); const [multiChoice, setMultiChoice] = useState(null); @@ -97,6 +119,7 @@ const AgenticCursor = forwardRef((_props, ref) => { writePos(from.x, from.y, true); controls.set({ x: from.x, y: from.y, opacity: 0, scale: 0.3 }); setVisible(true); + setBurstKey((k) => k + 1); // replay the orange spark on each pop // A little "pop!": scale overshoots past 1 then settles, while opacity snaps in. await controls.start({ opacity: 1, @@ -308,22 +331,28 @@ const AgenticCursor = forwardRef((_props, ref) => { }} > {visible && ( - - - + <> + {/* Orange spark behind the arrow, replays each pop via burstKey. */} +
+ +
+ + + + )} diff --git a/frontend/src/app/pages/AgentChat/WelcomeQuickReplies.tsx b/frontend/src/app/pages/AgentChat/WelcomeQuickReplies.tsx index 0e3795f3..d6a917b4 100644 --- a/frontend/src/app/pages/AgentChat/WelcomeQuickReplies.tsx +++ b/frontend/src/app/pages/AgentChat/WelcomeQuickReplies.tsx @@ -8,19 +8,23 @@ import { STARTER_CATEGORIES } from '@/shared/starterCategories'; const GREETING = "Hi, I'm your AI team. What do you want done?"; -// One-shot typewriter for a fixed string (no infinite loop; stops at the end). -function useTypewriter(text: string, speedMs = 26): { shown: string; done: boolean } { +// One-shot typewriter for a fixed string (no infinite loop; stops at the end). startDelayMs +// holds the start so the header title can stream first (sequential reveal). +function useTypewriter(text: string, speedMs = 45, startDelayMs = 0): { shown: string; done: boolean } { const [shown, setShown] = React.useState(''); React.useEffect(() => { setShown(''); - let i = 0; - const id = window.setInterval(() => { - i += 1; - setShown(text.slice(0, i)); - if (i >= text.length) window.clearInterval(id); - }, speedMs); - return () => window.clearInterval(id); - }, [text, speedMs]); + let interval: number | undefined; + const startTimer = window.setTimeout(() => { + let i = 0; + interval = window.setInterval(() => { + i += 1; + setShown(text.slice(0, i)); + if (i >= text.length) window.clearInterval(interval); + }, speedMs); + }, startDelayMs); + return () => { window.clearTimeout(startTimer); if (interval) window.clearInterval(interval); }; + }, [text, speedMs, startDelayMs]); return { shown, done: shown.length >= text.length }; } @@ -32,7 +36,9 @@ const WelcomeQuickReplies: React.FC<{ onPick: (prompt: string) => void; onPickBuilder: (prompt: string) => void; }> = ({ c, onPick, onPickBuilder }) => { - const { shown: greeting, done: greetingDone } = useTypewriter(GREETING); + // Slow + delayed so it reads as a sequence: card pops, the header title streams, THEN + // the greeting types, THEN the chips pop in. + const { shown: greeting, done: greetingDone } = useTypewriter(GREETING, 46, 650); const [expanded, setExpanded] = React.useState(null); const currentCategory = STARTER_CATEGORIES.find((cat) => cat.id === expanded); const isAppBuilder = currentCategory?.target === 'app-builder'; @@ -82,7 +88,7 @@ const WelcomeQuickReplies: React.FC<{ onClick={() => setExpanded(cat.id)} initial={{ opacity: 0, scale: 0.82 }} animate={{ opacity: 1, scale: 1 }} - transition={{ type: 'spring', stiffness: 520, damping: 24, delay: 0.08 + i * 0.07 }} + transition={{ type: 'spring', stiffness: 420, damping: 22, delay: 0.18 + i * 0.14 }} style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '10px 14px', From 8863d58c20277f474aec3d8d6a27313410183aa0 Mon Sep 17 00:00:00 2001 From: ciregenz Date: Sun, 14 Jun 2026 01:53:12 -0700 Subject: [PATCH 14/27] [eric] onboarding: cursor pops in then clicks New Agent to spawn the welcome chat (not auto), with delays; much slower title/greeting stream; manual click is the fail-safe --- .../components/Onboarding/OnboardingRoot.tsx | 41 +++++++++++-------- .../app/components/Onboarding/steps/index.ts | 4 +- .../Onboarding/steps/step00_welcomeNudge.ts | 16 ++++---- .../pages/AgentChat/WelcomeQuickReplies.tsx | 8 ++-- .../hooks/lifecycle/useAgentSpawn.ts | 12 +++++- .../hooks/lifecycle/useWelcomeDraft.ts | 34 +++++++-------- .../hooks/state/useDashboardController.ts | 8 ++-- 7 files changed, 70 insertions(+), 53 deletions(-) diff --git a/frontend/src/app/components/Onboarding/OnboardingRoot.tsx b/frontend/src/app/components/Onboarding/OnboardingRoot.tsx index 508c9293..30fd30b4 100644 --- a/frontend/src/app/components/Onboarding/OnboardingRoot.tsx +++ b/frontend/src/app/components/Onboarding/OnboardingRoot.tsx @@ -18,7 +18,7 @@ import { import AgenticCursor, { type AgenticCursorHandle } from './ac/AgenticCursor'; import { onboardingDirector } from './OnboardingDirector'; import { STEPS } from './steps'; -import { hasAnyAgentCompleted } from './steps/skipPredicates'; +import { hasAnyAgentCompleted, hasFreeTrialActive, hasModelConnected } from './steps/skipPredicates'; import OnboardingPanel from './OnboardingPanel'; import { onboardingBus } from './eventBus'; import { report } from './telemetry'; @@ -50,22 +50,31 @@ const OnboardingRoot: React.FC = () => { dispatch, ]); - // First-run cursor nudge: a beat after the welcome chat pops, the cursor points at the - // top-right "Continue" pill. Armed by the once-ever 'welcome:shown' event (so it never - // re-fires across reloads), fail-safe (a missing AC / off-dashboard route just no-ops). - const nudgeFiredRef = useRef(false); - const nudgeTimerRef = useRef(null); + // First run: the cursor pops into existence, pauses, then moves to and clicks the New Agent + // button (welcome_open step) which spawns the welcome chat. Fires once, only on the dashboard + // with a way to run and nothing launched yet. Fail-safe: if the cursor can't run, a manual + // New Agent click spawns the same welcome chat (handleNewAgent is welcome-aware). + const welcomeOpenReady = useAppSelector( + (s) => + s.settings.loaded && + (hasFreeTrialActive(s) || hasModelConnected(s)) && + !s.onboardingProgress.welcomeShown && + !(s.onboardingProgress.completedSteps ?? []).includes('launch_agent') && + Object.keys(s.agents?.sessions ?? {}).length === 0, + ); + const welcomeFiredRef = useRef(false); + const welcomeTimerRef = useRef(null); useEffect(() => { - const off = onboardingBus.on('welcome:shown', () => { - if (nudgeFiredRef.current || !window.location.hash.includes('/dashboard/')) return; - nudgeFiredRef.current = true; - nudgeTimerRef.current = window.setTimeout(() => { - if (!window.location.hash.includes('/dashboard/') || onboardingDirector.isRunning()) return; - onboardingDirector.startStep('welcome_nudge', { x: window.innerWidth - 120, y: 80 }); - }, 1000); - }); - return () => { off(); if (nudgeTimerRef.current) window.clearTimeout(nudgeTimerRef.current); }; - }, []); + if (welcomeFiredRef.current || !progress.initialized || !welcomeOpenReady) return; + if (!window.location.hash.includes('/dashboard/') || onboardingDirector.isRunning()) return; + welcomeFiredRef.current = true; + welcomeTimerRef.current = window.setTimeout(() => { + if (!window.location.hash.includes('/dashboard/') || onboardingDirector.isRunning()) return; + // Pop near center, then the step walks the cursor down to the New Agent button and clicks. + onboardingDirector.startStep('welcome_open', { x: window.innerWidth / 2, y: window.innerHeight / 2 }); + }, 600); + return () => { if (welcomeTimerRef.current) window.clearTimeout(welcomeTimerRef.current); }; + }, [progress.initialized, welcomeOpenReady]); useEffect(() => { if (progress.initialized) return; diff --git a/frontend/src/app/components/Onboarding/steps/index.ts b/frontend/src/app/components/Onboarding/steps/index.ts index 1fe0f152..4dad27b7 100644 --- a/frontend/src/app/components/Onboarding/steps/index.ts +++ b/frontend/src/app/components/Onboarding/steps/index.ts @@ -7,7 +7,7 @@ import { step05 } from './step05_agentUseBrowser'; import { step06 } from './step06_agentControlAgents'; import { step07 } from './step07_installSkill'; import { step08 } from './step08_makeApp'; -import { welcomeNudgeStep } from './step00_welcomeNudge'; +import { welcomeOpenStep } from './step00_welcomeNudge'; // Value-first order: launch an agent (step03) FIRST so a brand-new user sees // the product work on the free trial, then connect-your-own-model (step01). @@ -25,7 +25,7 @@ export const STEPS: OnboardingStep[] = [ // Resolvable by the Director but kept OUT of STEPS, so they never appear in the roadmap, // the panel count, or the unlock chain. The first-run welcome nudge lives here. -const HIDDEN_STEPS: OnboardingStep[] = [welcomeNudgeStep]; +const HIDDEN_STEPS: OnboardingStep[] = [welcomeOpenStep]; export function findStepById(id: string): OnboardingStep | undefined { return STEPS.find((s) => s.id === id) ?? HIDDEN_STEPS.find((s) => s.id === id); diff --git a/frontend/src/app/components/Onboarding/steps/step00_welcomeNudge.ts b/frontend/src/app/components/Onboarding/steps/step00_welcomeNudge.ts index a0591ad1..a8bba8e7 100644 --- a/frontend/src/app/components/Onboarding/steps/step00_welcomeNudge.ts +++ b/frontend/src/app/components/Onboarding/steps/step00_welcomeNudge.ts @@ -1,18 +1,20 @@ import type { OnboardingStep } from './types'; import { S } from '../selectors'; -// Invisible first-run nudge (not part of the numbered tour): a beat after the welcome chat -// pops, the cursor points at the top-right "Continue" pill so the user knows the guided tour -// is there, then retreats. No click, no wait_user, so it's a one-shot gentle gesture. -export const welcomeNudgeStep: OnboardingStep = { - id: 'welcome_nudge', +// First-run, invisible to the roadmap: the cursor pops into existence (handled by fadeIn, with +// the orange spark), pauses a beat, then moves to and clicks the New Agent button, which spawns +// the welcome chat. Static, no LLM. The delays give the pop and the move room to breathe. +export const welcomeOpenStep: OnboardingStep = { + id: 'welcome_open', stage: 'get_started', index: 0, title: 'Welcome', description: '', ops: [ - { kind: 'move_to', target: S.onboardingContinueButton }, - { kind: 'popup', text: 'Want a quick tour? Tap Continue any time.' }, + { kind: 'delay', ms: 750 }, // let the pop + spark settle + { kind: 'move_to', target: S.newAgentButton }, + { kind: 'delay', ms: 550 }, // pause, then click + { kind: 'click', target: S.newAgentButton, simulate: true }, // spawns the welcome chat { kind: 'outro' }, ], }; diff --git a/frontend/src/app/pages/AgentChat/WelcomeQuickReplies.tsx b/frontend/src/app/pages/AgentChat/WelcomeQuickReplies.tsx index d6a917b4..75b5cb52 100644 --- a/frontend/src/app/pages/AgentChat/WelcomeQuickReplies.tsx +++ b/frontend/src/app/pages/AgentChat/WelcomeQuickReplies.tsx @@ -36,9 +36,9 @@ const WelcomeQuickReplies: React.FC<{ onPick: (prompt: string) => void; onPickBuilder: (prompt: string) => void; }> = ({ c, onPick, onPickBuilder }) => { - // Slow + delayed so it reads as a sequence: card pops, the header title streams, THEN - // the greeting types, THEN the chips pop in. - const { shown: greeting, done: greetingDone } = useTypewriter(GREETING, 46, 650); + // Slow + delayed so it reads as a calm sequence: card pops, the header title streams, THEN + // the greeting types out unhurried, THEN the chips pop in. + const { shown: greeting, done: greetingDone } = useTypewriter(GREETING, 78, 850); const [expanded, setExpanded] = React.useState(null); const currentCategory = STARTER_CATEGORIES.find((cat) => cat.id === expanded); const isAppBuilder = currentCategory?.target === 'app-builder'; @@ -88,7 +88,7 @@ const WelcomeQuickReplies: React.FC<{ onClick={() => setExpanded(cat.id)} initial={{ opacity: 0, scale: 0.82 }} animate={{ opacity: 1, scale: 1 }} - transition={{ type: 'spring', stiffness: 420, damping: 22, delay: 0.18 + i * 0.14 }} + transition={{ type: 'spring', stiffness: 420, damping: 22, delay: 0.25 + i * 0.18 }} style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '10px 14px', diff --git a/frontend/src/app/pages/Dashboard/hooks/lifecycle/useAgentSpawn.ts b/frontend/src/app/pages/Dashboard/hooks/lifecycle/useAgentSpawn.ts index 7a8c3a18..7d67962a 100644 --- a/frontend/src/app/pages/Dashboard/hooks/lifecycle/useAgentSpawn.ts +++ b/frontend/src/app/pages/Dashboard/hooks/lifecycle/useAgentSpawn.ts @@ -38,6 +38,9 @@ interface UseAgentSpawnArgs { setToolbarOpen: Dispatch>; setAutoFocusSessionId: Dispatch>; setPendingSelectSessionId: Dispatch>; + /** First run only: clicking New Agent spawns the welcome chat instead of the composer. */ + welcomeEligible?: boolean; + onWelcomeNewAgent?: () => void; } export function useAgentSpawn({ @@ -54,6 +57,8 @@ export function useAgentSpawn({ setToolbarOpen, setAutoFocusSessionId, setPendingSelectSessionId, + welcomeEligible, + onWelcomeNewAgent, }: UseAgentSpawnArgs) { const dispatch = useAppDispatch(); @@ -100,8 +105,13 @@ export function useAgentSpawn({ ); const handleNewAgent = useCallback(() => { + // First run: spawn the welcome chat (cursor-clicked or hand-clicked) instead of the composer. + if (welcomeEligible && onWelcomeNewAgent) { + onWelcomeNewAgent(); + return; + } setToolbarOpen(true); - }, []); + }, [welcomeEligible, onWelcomeNewAgent, setToolbarOpen]); const handleToolbarCancel = useCallback(() => { setToolbarOpen(false); diff --git a/frontend/src/app/pages/Dashboard/hooks/lifecycle/useWelcomeDraft.ts b/frontend/src/app/pages/Dashboard/hooks/lifecycle/useWelcomeDraft.ts index 3908e535..1c783e54 100644 --- a/frontend/src/app/pages/Dashboard/hooks/lifecycle/useWelcomeDraft.ts +++ b/frontend/src/app/pages/Dashboard/hooks/lifecycle/useWelcomeDraft.ts @@ -1,16 +1,14 @@ -import { useEffect, useRef, type RefObject } from 'react'; +import { useCallback, type RefObject } from 'react'; import { useAppDispatch, useAppSelector } from '@/shared/hooks'; import { createDraftSession, expandSession } from '@/shared/state/agentsSlice'; import { placeCard, DEFAULT_CARD_W, EXPANDED_CARD_MIN_H } from '@/shared/state/dashboardLayoutSlice'; import { markWelcomeShown } from '@/shared/state/onboardingProgressSlice'; import { hasFreeTrialActive, hasModelConnected } from '@/app/components/Onboarding/steps/skipPredicates'; -import { onboardingBus } from '@/app/components/Onboarding/eventBus'; type SpawnOrigin = { x: number; y: number; type?: 'branch' }; interface Args { dashboardId: string; - isActive: boolean; /** layoutInitialized && no sessions/views/browsers on the canvas. */ canvasEmpty: boolean; expandedSessionIds: string[]; @@ -19,16 +17,14 @@ interface Args { spawnOriginsRef: RefObject>; } -// Once ever, for a genuinely fresh user with a way to run, auto-open a welcome chat card on the -// empty dashboard: a seeded greeting + quick-reply chips, ZERO run consumed until they answer. -// Fail-safe: any throw bails and the dashboard/chat keep working by hand. +// First-run welcome chat. NOT auto-created: the onboarding cursor clicks the New Agent button, +// which calls handleNewAgent -> createWelcomeDraft, so the chat is clicked into existence. The +// user clicking New Agent by hand spawns the same thing (fail-safe). Returns the gate + creator. export function useWelcomeDraft({ - dashboardId, isActive, canvasEmpty, expandedSessionIds, - viewportRef, canvasStateRef, spawnOriginsRef, -}: Args): void { + dashboardId, canvasEmpty, expandedSessionIds, viewportRef, canvasStateRef, spawnOriginsRef, +}: Args): { welcomeEligible: boolean; createWelcomeDraft: () => void } { const dispatch = useAppDispatch(); - const createdRef = useRef(false); - const eligible = useAppSelector( + const reduxEligible = useAppSelector( (s) => s.settings.loaded && (hasFreeTrialActive(s) || hasModelConnected(s)) && @@ -36,20 +32,17 @@ export function useWelcomeDraft({ !(s.onboardingProgress.completedSteps ?? []).includes('launch_agent'), ); const model = useAppSelector((s) => s.settings.data.default_model); + const welcomeEligible = reduxEligible && canvasEmpty; - useEffect(() => { - if (createdRef.current) return; - if (!isActive || !canvasEmpty || !eligible) return; - createdRef.current = true; + const createWelcomeDraft = useCallback(() => { try { - // No seeded message: the greeting + chips render (and animate) inside the welcome chat - // via WelcomeQuickReplies, so nothing here can ever reach the backend. + // No seeded message: the greeting + chips render (and animate) inside the welcome chat, + // so nothing here can ever reach the backend. const action = dispatch( createDraftSession({ welcome: true, model, mode: 'agent', dashboardId, setActive: true }), ); const draftId = action.payload.draftId; - // Center the card in the current viewport (canvas coords) so it pops in front of the user. const vp = viewportRef.current; const cs = canvasStateRef.current; if (vp && cs) { @@ -68,9 +61,10 @@ export function useWelcomeDraft({ } dispatch(expandSession(draftId)); dispatch(markWelcomeShown()); - onboardingBus.emit('welcome:shown'); } catch (err) { console.error('[welcome-draft] create failed', err); } - }, [isActive, canvasEmpty, eligible, model, dashboardId, expandedSessionIds, dispatch, viewportRef, canvasStateRef, spawnOriginsRef]); + }, [dispatch, model, dashboardId, expandedSessionIds, viewportRef, canvasStateRef, spawnOriginsRef]); + + return { welcomeEligible, createWelcomeDraft }; } diff --git a/frontend/src/app/pages/Dashboard/hooks/state/useDashboardController.ts b/frontend/src/app/pages/Dashboard/hooks/state/useDashboardController.ts index 79dcb457..4184dede 100644 --- a/frontend/src/app/pages/Dashboard/hooks/state/useDashboardController.ts +++ b/frontend/src/app/pages/Dashboard/hooks/state/useDashboardController.ts @@ -130,10 +130,10 @@ export function useDashboardController(dashboardId: string, isActive: boolean) { restoredExpandedRef, }); - // First-run: auto-pop a welcome chat (seeded greeting + chips) on the empty dashboard. - useWelcomeDraft({ + // First-run: the onboarding cursor clicks New Agent -> handleNewAgent -> createWelcomeDraft, + // spawning the welcome chat. A manual New Agent click does the same when eligible. + const { welcomeEligible, createWelcomeDraft } = useWelcomeDraft({ dashboardId, - isActive, canvasEmpty, expandedSessionIds, viewportRef: canvas.viewportRef, @@ -233,6 +233,8 @@ export function useDashboardController(dashboardId: string, isActive: boolean) { setToolbarOpen, setAutoFocusSessionId, setPendingSelectSessionId, + welcomeEligible, + onWelcomeNewAgent: createWelcomeDraft, }); const { From 89a1193f32f0208c0ff3828b5bd3b8924aedec86 Mon Sep 17 00:00:00 2001 From: ciregenz Date: Sun, 14 Jun 2026 01:59:49 -0700 Subject: [PATCH 15/27] [eric] onboarding: welcome_open cursor shows a quick readable popup then clicks New Agent promptly (snappier) --- .../app/components/Onboarding/steps/step00_welcomeNudge.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/frontend/src/app/components/Onboarding/steps/step00_welcomeNudge.ts b/frontend/src/app/components/Onboarding/steps/step00_welcomeNudge.ts index a8bba8e7..c126f813 100644 --- a/frontend/src/app/components/Onboarding/steps/step00_welcomeNudge.ts +++ b/frontend/src/app/components/Onboarding/steps/step00_welcomeNudge.ts @@ -11,9 +11,10 @@ export const welcomeOpenStep: OnboardingStep = { title: 'Welcome', description: '', ops: [ - { kind: 'delay', ms: 750 }, // let the pop + spark settle + { kind: 'delay', ms: 400 }, // brief beat after the pop + spark { kind: 'move_to', target: S.newAgentButton }, - { kind: 'delay', ms: 550 }, // pause, then click + { kind: 'popup', text: 'Let me open a chat for you.' }, + { kind: 'delay', ms: 650 }, // a moment to read, then click { kind: 'click', target: S.newAgentButton, simulate: true }, // spawns the welcome chat { kind: 'outro' }, ], From 82907b46015ec6c9ac9a45af601bfd69c29d99fb Mon Sep 17 00:00:00 2001 From: ciregenz Date: Sun, 14 Jun 2026 02:18:53 -0700 Subject: [PATCH 16/27] [eric] onboarding polish: exaggerated cursor pop + longer wait then prompt click; faster greeting, no caret; chips slide in smoothly (no instant pop); title 'First chat with OpenSwarm', no draft label --- .../Onboarding/ac/AgenticCursor.tsx | 8 +++--- .../Onboarding/steps/step00_welcomeNudge.ts | 4 +-- .../pages/AgentChat/WelcomeQuickReplies.tsx | 25 +++++++++++-------- .../app/pages/Dashboard/cards/AgentCard.tsx | 5 ++-- frontend/src/shared/state/agentsSlice.ts | 2 +- 5 files changed, 24 insertions(+), 20 deletions(-) diff --git a/frontend/src/app/components/Onboarding/ac/AgenticCursor.tsx b/frontend/src/app/components/Onboarding/ac/AgenticCursor.tsx index 85edbcf0..d7207a5b 100644 --- a/frontend/src/app/components/Onboarding/ac/AgenticCursor.tsx +++ b/frontend/src/app/components/Onboarding/ac/AgenticCursor.tsx @@ -120,13 +120,13 @@ const AgenticCursor = forwardRef((_props, ref) => { controls.set({ x: from.x, y: from.y, opacity: 0, scale: 0.3 }); setVisible(true); setBurstKey((k) => k + 1); // replay the orange spark on each pop - // A little "pop!": scale overshoots past 1 then settles, while opacity snaps in. + // An exaggerated "POP!": scale shoots way past 1 then settles back, opacity snaps in. await controls.start({ opacity: 1, - scale: [0.3, 1.15, 1], + scale: [0.1, 1.45, 0.92, 1], transition: { - opacity: { duration: 0.16, ease: 'easeOut' }, - scale: { duration: 0.36, ease: 'easeOut', times: [0, 0.62, 1] }, + opacity: { duration: 0.14, ease: 'easeOut' }, + scale: { duration: 0.5, ease: 'easeOut', times: [0, 0.55, 0.8, 1] }, }, }); }, diff --git a/frontend/src/app/components/Onboarding/steps/step00_welcomeNudge.ts b/frontend/src/app/components/Onboarding/steps/step00_welcomeNudge.ts index c126f813..f5b21c15 100644 --- a/frontend/src/app/components/Onboarding/steps/step00_welcomeNudge.ts +++ b/frontend/src/app/components/Onboarding/steps/step00_welcomeNudge.ts @@ -11,10 +11,10 @@ export const welcomeOpenStep: OnboardingStep = { title: 'Welcome', description: '', ops: [ - { kind: 'delay', ms: 400 }, // brief beat after the pop + spark + { kind: 'delay', ms: 900 }, // let the big POP land + breathe { kind: 'move_to', target: S.newAgentButton }, { kind: 'popup', text: 'Let me open a chat for you.' }, - { kind: 'delay', ms: 650 }, // a moment to read, then click + { kind: 'delay', ms: 350 }, // quick read, then click promptly { kind: 'click', target: S.newAgentButton, simulate: true }, // spawns the welcome chat { kind: 'outro' }, ], diff --git a/frontend/src/app/pages/AgentChat/WelcomeQuickReplies.tsx b/frontend/src/app/pages/AgentChat/WelcomeQuickReplies.tsx index 75b5cb52..e2d2c6af 100644 --- a/frontend/src/app/pages/AgentChat/WelcomeQuickReplies.tsx +++ b/frontend/src/app/pages/AgentChat/WelcomeQuickReplies.tsx @@ -36,9 +36,9 @@ const WelcomeQuickReplies: React.FC<{ onPick: (prompt: string) => void; onPickBuilder: (prompt: string) => void; }> = ({ c, onPick, onPickBuilder }) => { - // Slow + delayed so it reads as a calm sequence: card pops, the header title streams, THEN - // the greeting types out unhurried, THEN the chips pop in. - const { shown: greeting, done: greetingDone } = useTypewriter(GREETING, 78, 850); + // Sequence: card pops, the header title streams, THEN the greeting types out, THEN the chips + // slide in. Brisk but smooth. + const { shown: greeting, done: greetingDone } = useTypewriter(GREETING, 42, 450); const [expanded, setExpanded] = React.useState(null); const currentCategory = STARTER_CATEGORIES.find((cat) => cat.id === expanded); const isAppBuilder = currentCategory?.target === 'app-builder'; @@ -65,14 +65,17 @@ const WelcomeQuickReplies: React.FC<{ }} > {greeting} - {!greetingDone && ( - - )} - {/* Chips reveal only once the greeting finishes, each popping in with a stagger. */} + {/* The chips block slides up + fades in once the greeting finishes (the outer motion.div), + then each chip springs in staggered (inner). */} {greetingDone && ( - + {expanded === null ? ( @@ -86,9 +89,9 @@ const WelcomeQuickReplies: React.FC<{ setExpanded(cat.id)} - initial={{ opacity: 0, scale: 0.82 }} + initial={{ opacity: 0, scale: 0.85 }} animate={{ opacity: 1, scale: 1 }} - transition={{ type: 'spring', stiffness: 420, damping: 22, delay: 0.25 + i * 0.18 }} + transition={{ type: 'spring', stiffness: 480, damping: 24, delay: 0.12 + i * 0.08 }} style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '10px 14px', @@ -155,7 +158,7 @@ const WelcomeQuickReplies: React.FC<{ )} - + )} ); diff --git a/frontend/src/app/pages/Dashboard/cards/AgentCard.tsx b/frontend/src/app/pages/Dashboard/cards/AgentCard.tsx index 7fab5de5..adaaf2be 100644 --- a/frontend/src/app/pages/Dashboard/cards/AgentCard.tsx +++ b/frontend/src/app/pages/Dashboard/cards/AgentCard.tsx @@ -756,8 +756,9 @@ const AgentCard: React.FC = ({ )} - {/* Status speaks only when it needs the user; finished work sits quiet. */} - {session.status !== 'completed' && session.status !== 'stopped' && ( + {/* Status speaks only when it needs the user; finished work sits quiet. The welcome + chat hides its 'draft' label so the title reads clean. */} + {session.status !== 'completed' && session.status !== 'stopped' && !session.is_welcome_draft && ( {friendlyStatusLabel(session.status)} diff --git a/frontend/src/shared/state/agentsSlice.ts b/frontend/src/shared/state/agentsSlice.ts index 48020937..d64f52df 100644 --- a/frontend/src/shared/state/agentsSlice.ts +++ b/frontend/src/shared/state/agentsSlice.ts @@ -523,7 +523,7 @@ const agentsSlice = createSlice({ const { draftId, mode, setActive, targetDirectory, model, provider, thinkingLevel, seededMessages, welcome, dashboardId } = action.payload; state.sessions[draftId] = { id: draftId, - name: 'New chat', + name: welcome === true ? 'First chat with OpenSwarm' : 'New chat', status: 'draft', provider: provider || 'anthropic', model: model || 'sonnet', From e9c8774ad729d8b4d122f4c7d82421cd82341fae Mon Sep 17 00:00:00 2001 From: ciregenz Date: Sun, 14 Jun 2026 02:41:28 -0700 Subject: [PATCH 17/27] [eric] onboarding: welcome reads as a real chat + cursor opens it + model in header --- .../Onboarding/steps/step00_welcomeNudge.ts | 10 +-- .../src/app/pages/AgentChat/AgentChat.tsx | 11 +-- .../pages/AgentChat/WelcomeQuickReplies.tsx | 70 ++++++++----------- .../app/pages/Dashboard/cards/AgentCard.tsx | 6 ++ 4 files changed, 49 insertions(+), 48 deletions(-) diff --git a/frontend/src/app/components/Onboarding/steps/step00_welcomeNudge.ts b/frontend/src/app/components/Onboarding/steps/step00_welcomeNudge.ts index f5b21c15..c892e80b 100644 --- a/frontend/src/app/components/Onboarding/steps/step00_welcomeNudge.ts +++ b/frontend/src/app/components/Onboarding/steps/step00_welcomeNudge.ts @@ -11,11 +11,11 @@ export const welcomeOpenStep: OnboardingStep = { title: 'Welcome', description: '', ops: [ - { kind: 'delay', ms: 900 }, // let the big POP land + breathe - { kind: 'move_to', target: S.newAgentButton }, - { kind: 'popup', text: 'Let me open a chat for you.' }, - { kind: 'delay', ms: 350 }, // quick read, then click promptly - { kind: 'click', target: S.newAgentButton, simulate: true }, // spawns the welcome chat + { kind: 'delay', ms: 700 }, // let the big POP land + { kind: 'popup', text: 'Let me open up a chat for you.' }, // say it first + { kind: 'delay', ms: 900 }, // read, then go click it + { kind: 'move_to', target: S.newAgentButton }, // travel to the chat bubble + { kind: 'click', target: S.newAgentButton, simulate: true }, // click -> spawns the welcome chat { kind: 'outro' }, ], }; diff --git a/frontend/src/app/pages/AgentChat/AgentChat.tsx b/frontend/src/app/pages/AgentChat/AgentChat.tsx index 57f599bb..e7adf9e1 100644 --- a/frontend/src/app/pages/AgentChat/AgentChat.tsx +++ b/frontend/src/app/pages/AgentChat/AgentChat.tsx @@ -1358,14 +1358,17 @@ const AgentChat: React.FC = ({ sessionId: sessionIdProp, onClose )} - {!isDraft && ( + {(!isDraft || session.is_welcome_draft) && ( + // Welcome draft shows just the model so the header isn't bare; real runs add branch + cost. {resolveModelLabel(session.model)} - - {session.branch_name} - + {!isDraft && session.branch_name && ( + + {session.branch_name} + + )} {(() => { if (!(session.cost_usd > 0)) return null; // The SDK reports a per-call $ figure regardless of how diff --git a/frontend/src/app/pages/AgentChat/WelcomeQuickReplies.tsx b/frontend/src/app/pages/AgentChat/WelcomeQuickReplies.tsx index e2d2c6af..9f92c9c3 100644 --- a/frontend/src/app/pages/AgentChat/WelcomeQuickReplies.tsx +++ b/frontend/src/app/pages/AgentChat/WelcomeQuickReplies.tsx @@ -6,11 +6,17 @@ import { ArrowLeft } from 'lucide-react'; import type { ClaudeTokens } from '@/shared/styles/claudeTokens'; import { STARTER_CATEGORIES } from '@/shared/starterCategories'; -const GREETING = "Hi, I'm your AI team. What do you want done?"; +// Reads like a real chat welcome: a heading that types in, then a warm intro that leans on what +// only OpenSwarm can do (act right on your laptop), then the quick-reply chips. No em-dashes. +const HEADING = "Hi, I'm OpenSwarm, your personal AI team."; +const BODY = + "I can do just about anything right on your laptop, so bring me anything: a tough problem, " + + "a half-formed idea, something you need to write. We'll figure it out together. " + + 'Where do you want to start?'; // One-shot typewriter for a fixed string (no infinite loop; stops at the end). startDelayMs // holds the start so the header title can stream first (sequential reveal). -function useTypewriter(text: string, speedMs = 45, startDelayMs = 0): { shown: string; done: boolean } { +function useTypewriter(text: string, speedMs = 38, startDelayMs = 0): { shown: string; done: boolean } { const [shown, setShown] = React.useState(''); React.useEffect(() => { setShown(''); @@ -28,17 +34,15 @@ function useTypewriter(text: string, speedMs = 45, startDelayMs = 0): { shown: s return { shown, done: shown.length >= text.length }; } -// The first-run welcome: the greeting streams in like typing, then the quick-reply chips -// pop in (staggered). Two-level: category -> concrete prompts. Research/Write/Learn -> onPick -// (real run); Build -> onPickBuilder (App Builder). Pure UI; no run until the parent fires. +// First-run welcome. Two-level chips: category -> concrete prompts. Research/Write/Learn -> +// onPick (real run); Build -> onPickBuilder (App Builder). Pure UI; no run until the parent fires. const WelcomeQuickReplies: React.FC<{ c: ClaudeTokens; onPick: (prompt: string) => void; onPickBuilder: (prompt: string) => void; }> = ({ c, onPick, onPickBuilder }) => { - // Sequence: card pops, the header title streams, THEN the greeting types out, THEN the chips - // slide in. Brisk but smooth. - const { shown: greeting, done: greetingDone } = useTypewriter(GREETING, 42, 450); + // Sequence: card pops, header title streams, heading types, THEN body + chips slide in. + const { shown: heading, done: headingDone } = useTypewriter(HEADING, 38, 450); const [expanded, setExpanded] = React.useState(null); const currentCategory = STARTER_CATEGORIES.find((cat) => cat.id === expanded); const isAppBuilder = currentCategory?.target === 'app-builder'; @@ -50,48 +54,36 @@ const WelcomeQuickReplies: React.FC<{ }; return ( - - {/* Greeting bubble, typed out like a real reply. */} - - {greeting} - + + + {heading} + - {/* The chips block slides up + fades in once the greeting finishes (the outer motion.div), - then each chip springs in staggered (inner). */} - {greetingDone && ( + {/* Body + chips slide up + fade in once the heading finishes. */} + {headingDone && ( + + {BODY} + + {expanded === null ? ( - - - pick one, or just type below - - + + pick one, or just type below + {STARTER_CATEGORIES.map((cat, i) => ( setExpanded(cat.id)} - initial={{ opacity: 0, scale: 0.85 }} - animate={{ opacity: 1, scale: 1 }} - transition={{ type: 'spring', stiffness: 480, damping: 24, delay: 0.12 + i * 0.08 }} + initial={{ opacity: 0, scale: 0.86, y: 6 }} + animate={{ opacity: 1, scale: 1, y: 0 }} + transition={{ type: 'spring', stiffness: 360, damping: 24, delay: 0.25 + i * 0.13 }} style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '10px 14px', @@ -137,9 +129,9 @@ const WelcomeQuickReplies: React.FC<{ pick(prompt)} - initial={{ opacity: 0, scale: 0.9 }} + initial={{ opacity: 0, scale: 0.92 }} animate={{ opacity: 1, scale: 1 }} - transition={{ type: 'spring', stiffness: 520, damping: 26, delay: i * 0.05 }} + transition={{ type: 'spring', stiffness: 420, damping: 26, delay: i * 0.06 }} style={{ textAlign: 'left', padding: '9px 14px', diff --git a/frontend/src/app/pages/Dashboard/cards/AgentCard.tsx b/frontend/src/app/pages/Dashboard/cards/AgentCard.tsx index adaaf2be..0bb214c2 100644 --- a/frontend/src/app/pages/Dashboard/cards/AgentCard.tsx +++ b/frontend/src/app/pages/Dashboard/cards/AgentCard.tsx @@ -765,6 +765,12 @@ const AgentCard: React.FC = ({ )} + {/* Welcome chat has no status to show, so name the model instead, otherwise the header reads bare. */} + {session.is_welcome_draft && friendlyModelLabel && ( + + {friendlyModelLabel} + + )} {/* Calm, zero-click signal: the agent recalled or built up memory of this site, so the user feels it getting smarter on its own. */} From fbbc57c8105a110587f27028d9241d6aa90b0e5e Mon Sep 17 00:00:00 2001 From: ciregenz Date: Sun, 14 Jun 2026 03:20:38 -0700 Subject: [PATCH 18/27] [eric] onboarding: per-popup dwell override so the welcome cursor holds ~3s then clicks (not the tour's 6s floor) --- .../src/app/components/Onboarding/ac/acRuntime.ts | 12 +++++++++++- .../Onboarding/steps/step00_welcomeNudge.ts | 9 ++++----- .../src/app/components/Onboarding/steps/types.ts | 2 +- 3 files changed, 16 insertions(+), 7 deletions(-) diff --git a/frontend/src/app/components/Onboarding/ac/acRuntime.ts b/frontend/src/app/components/Onboarding/ac/acRuntime.ts index 960125c7..073a6c62 100644 --- a/frontend/src/app/components/Onboarding/ac/acRuntime.ts +++ b/frontend/src/app/components/Onboarding/ac/acRuntime.ts @@ -37,6 +37,8 @@ interface RunContext { findStep: (id: string) => OnboardingStep | undefined; highlightCleanup: { current: (() => void) | null }; popupShownAt: { current: number | null }; + // Per-popup dwell override; null falls back to MIN_POPUP_DWELL_MS. Lets a short one-liner (welcome nudge) move on fast instead of sitting the full 6s. + popupDwellMs: { current: number | null }; } // 6s = streaming typewriter cadence + ~3s post-stream read time; floor for popups that auto-transition without an explicit user action. @@ -61,8 +63,9 @@ function abortableSleep(ms: number, signal: AbortSignal): Promise { async function ensurePopupDwell(ctx: RunContext): Promise { const shownAt = ctx.popupShownAt.current; if (shownAt == null) return; + const dwell = ctx.popupDwellMs.current ?? MIN_POPUP_DWELL_MS; const elapsed = performance.now() - shownAt; - const remaining = MIN_POPUP_DWELL_MS - elapsed; + const remaining = dwell - elapsed; if (remaining > 0) await abortableSleep(remaining, ctx.signal); } @@ -89,6 +92,7 @@ export async function runStep(args: RunStepArgs): Promise { const highlightCleanup: { current: (() => void) | null } = { current: null }; const popupShownAt: { current: number | null } = { current: null }; + const popupDwellMs: { current: number | null } = { current: null }; const ctx: RunContext = { ac, store, @@ -100,6 +104,7 @@ export async function runStep(args: RunStepArgs): Promise { findStep, highlightCleanup, popupShownAt, + popupDwellMs, }; try { @@ -119,6 +124,7 @@ export async function runStep(args: RunStepArgs): Promise { report('dependency_walk', { step_id: step.id, dep_id: dep.stepId }); ac.showPopup('Quick setup before we continue.'); ctx.popupShownAt.current = performance.now(); + ctx.popupDwellMs.current = null; await sleep(700); // Non-silent dep-walk so each move_to has a label; telemetry stays per-step to avoid double-count. await runOps(depStep.ops, { ...ctx, silent: false, stepId: depStep.id }); @@ -276,6 +282,7 @@ async function runOp(op: ACOp, ctx: RunContext): Promise { await ensurePopupDwell(ctx); ac.hidePopup(); ctx.popupShownAt.current = null; + ctx.popupDwellMs.current = null; ac.stopTracking(); if (ctx.highlightCleanup.current) { ctx.highlightCleanup.current(); @@ -363,6 +370,7 @@ async function runOp(op: ACOp, ctx: RunContext): Promise { await ensurePopupDwell(ctx); ac.showPopup(op.text); ctx.popupShownAt.current = performance.now(); + ctx.popupDwellMs.current = op.dwellMs ?? null; return; } case 'multi_choice': { @@ -398,6 +406,7 @@ async function runOp(op: ACOp, ctx: RunContext): Promise { await ensurePopupDwell(ctx); ac.showPopup(op.popup); ctx.popupShownAt.current = performance.now(); + ctx.popupDwellMs.current = null; } await sleep(op.durationMs ?? 600); return; @@ -580,6 +589,7 @@ async function runOp(op: ACOp, ctx: RunContext): Promise { }); ac.showPopup("Didn't seem to go through. Try again?"); ctx.popupShownAt.current = performance.now(); + ctx.popupDwellMs.current = null; await waitForCondition( op.condition, signal, diff --git a/frontend/src/app/components/Onboarding/steps/step00_welcomeNudge.ts b/frontend/src/app/components/Onboarding/steps/step00_welcomeNudge.ts index c892e80b..282e97f1 100644 --- a/frontend/src/app/components/Onboarding/steps/step00_welcomeNudge.ts +++ b/frontend/src/app/components/Onboarding/steps/step00_welcomeNudge.ts @@ -11,11 +11,10 @@ export const welcomeOpenStep: OnboardingStep = { title: 'Welcome', description: '', ops: [ - { kind: 'delay', ms: 700 }, // let the big POP land - { kind: 'popup', text: 'Let me open up a chat for you.' }, // say it first - { kind: 'delay', ms: 900 }, // read, then go click it - { kind: 'move_to', target: S.newAgentButton }, // travel to the chat bubble - { kind: 'click', target: S.newAgentButton, simulate: true }, // click -> spawns the welcome chat + { kind: 'delay', ms: 700 }, // let the big POP land + { kind: 'popup', text: 'Let me open up a chat for you.', dwellMs: 3000 }, // say it, hold ~3s (not the tour's 6s floor) + { kind: 'move_to', target: S.newAgentButton }, // dwell elapses, then travel to the chat bubble + { kind: 'click', target: S.newAgentButton, simulate: true }, // click -> spawns the welcome chat { kind: 'outro' }, ], }; diff --git a/frontend/src/app/components/Onboarding/steps/types.ts b/frontend/src/app/components/Onboarding/steps/types.ts index 08bb085b..9d869719 100644 --- a/frontend/src/app/components/Onboarding/steps/types.ts +++ b/frontend/src/app/components/Onboarding/steps/types.ts @@ -14,7 +14,7 @@ export type ACMultiChoiceOption = { export type ACOp = | { kind: 'move_to'; target: Selector; offset?: { x: number; y: number } } - | { kind: 'popup'; text: string; cta?: string } + | { kind: 'popup'; text: string; cta?: string; dwellMs?: number } | { kind: 'multi_choice'; opId: string; question: string; options: ACMultiChoiceOption[] } | { kind: 'highlight_section'; target: Selector; popup?: string; durationMs?: number } | { From 80b40dc2b7e6ff183e807f00e72c977d1273e70a Mon Sep 17 00:00:00 2001 From: ciregenz Date: Sun, 14 Jun 2026 03:20:38 -0700 Subject: [PATCH 19/27] [eric] agents: forward draft dashboard_id into launch so the card survives send (fixes welcome chat quitting when you pick an option) --- frontend/src/app/pages/AgentChat/AgentChat.tsx | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/frontend/src/app/pages/AgentChat/AgentChat.tsx b/frontend/src/app/pages/AgentChat/AgentChat.tsx index e7adf9e1..ed0a7f4d 100644 --- a/frontend/src/app/pages/AgentChat/AgentChat.tsx +++ b/frontend/src/app/pages/AgentChat/AgentChat.tsx @@ -405,6 +405,10 @@ const AgentChat: React.FC = ({ sessionId: sessionIdProp, onClose const config: Record = { model, mode }; if (session?.system_prompt) config.system_prompt = session.system_prompt; if (session?.target_directory) config.target_directory = session.target_directory; + // Carry the draft's dashboard so the launched session stays ON this dashboard; without it the + // session lands dashboard_id=null, drops out of the reconcile filter, and its card vanishes + // the instant you send (looked like "the chat quit when I clicked an option"). + if (session?.dashboard_id) config.dashboard_id = session.dashboard_id; dispatch( launchAndSendFirstMessage({ draftId: id, config, prompt: msg.prompt, mode, model, images: msg.images, contextPaths: msg.contextPaths, forcedTools: msg.forcedTools, attachedSkills: msg.attachedSkills, selectedBrowserIds: msg.selectedBrowserIds, selectedAppIds: msg.selectedAppIds }) ).then((action) => { @@ -427,7 +431,7 @@ const AgentChat: React.FC = ({ sessionId: sessionIdProp, onClose } }); } - }, [id, isDraft, mode, model, session?.system_prompt, session?.target_directory, dispatch]); + }, [id, isDraft, mode, model, session?.system_prompt, session?.target_directory, session?.dashboard_id, dispatch]); statusRef.current = session?.status; From 5945dc91dc569964348754d36b0657b3064dbcab Mon Sep 17 00:00:00 2001 From: ciregenz Date: Sun, 14 Jun 2026 03:29:51 -0700 Subject: [PATCH 20/27] [eric] onboarding: welcome greeting is now a real streamed assistant bubble (rides the streaming slice + smooth-reveal), chips sit below it --- .../src/app/pages/AgentChat/AgentChat.tsx | 20 +- .../pages/AgentChat/WelcomeQuickReplies.tsx | 216 +++++++----------- .../app/pages/AgentChat/useWelcomeGreeting.ts | 66 ++++++ 3 files changed, 165 insertions(+), 137 deletions(-) create mode 100644 frontend/src/app/pages/AgentChat/useWelcomeGreeting.ts diff --git a/frontend/src/app/pages/AgentChat/AgentChat.tsx b/frontend/src/app/pages/AgentChat/AgentChat.tsx index ed0a7f4d..d581c616 100644 --- a/frontend/src/app/pages/AgentChat/AgentChat.tsx +++ b/frontend/src/app/pages/AgentChat/AgentChat.tsx @@ -46,6 +46,7 @@ import { fetchModes } from '@/shared/state/modesSlice'; import { createSessionWs, acquireSessionWs, releaseSessionWs } from '@/shared/ws/WebSocketManager'; import StreamingBubble from './bubbles/StreamingBubble'; import WelcomeQuickReplies from './WelcomeQuickReplies'; +import { useWelcomeGreeting } from './useWelcomeGreeting'; import MessageBubble from './bubbles/MessageBubble'; import { estimateRenderedTextHeight, RECHECK_VISIBILITY_EVENT } from './bubbles/markdownMeasure'; import CompactionMarker from './bubbles/CompactionMarker'; @@ -326,6 +327,7 @@ const AgentChat: React.FC = ({ sessionId: sessionIdProp, onClose const [dropTargetIdx, setDropTargetIdx] = useState(null); const isDraft = session?.status === 'draft'; + const { greetingDone: welcomeGreetingDone } = useWelcomeGreeting(session, isDraft); useEffect(() => { if (!id || isDraft) return; @@ -1527,15 +1529,6 @@ const AgentChat: React.FC = ({ sessionId: sessionIdProp, onClose }} > - {/* First-run welcome: the greeting streams in, the chips pop in; vanishes the - moment a user message exists (i.e. once they answer). Pure UI, no run. */} - {session.is_welcome_draft && isDraft && !session.messages.some((m) => m.role === 'user') && ( - handleSend(p)} - onPickBuilder={(p) => chatInputRef.current?.setContent(p)} - /> - )} {(session.mcp_suggestions && session.mcp_suggestions.length > 0) && ( = ({ sessionId: sessionIdProp, onClose /> )} + {/* First-run welcome chips: sit UNDER the streamed greeting, appear once it finishes, + vanish the moment the user answers. The greeting itself is a real assistant bubble. */} + {session.is_welcome_draft && isDraft && welcomeGreetingDone && !session.messages.some((m) => m.role === 'user') && ( + handleSend(p)} + onPickBuilder={(p) => chatInputRef.current?.setContent(p)} + /> + )} {(preSendActivityLabel || awaitingResponse || (session.status === 'running' && !streamingMessageId)) && ( { - setShown(''); - let interval: number | undefined; - const startTimer = window.setTimeout(() => { - let i = 0; - interval = window.setInterval(() => { - i += 1; - setShown(text.slice(0, i)); - if (i >= text.length) window.clearInterval(interval); - }, speedMs); - }, startDelayMs); - return () => { window.clearTimeout(startTimer); if (interval) window.clearInterval(interval); }; - }, [text, speedMs, startDelayMs]); - return { shown, done: shown.length >= text.length }; -} - -// First-run welcome. Two-level chips: category -> concrete prompts. Research/Write/Learn -> -// onPick (real run); Build -> onPickBuilder (App Builder). Pure UI; no run until the parent fires. +// Quick-reply chips that sit UNDER the streamed greeting bubble. Two levels: category -> +// concrete prompts. Research/Write/Learn -> onPick (real run); Build -> onPickBuilder (prefill). +// The greeting itself is a real streamed assistant message (see useWelcomeGreeting); this is just +// the follow-up affordance. Pure UI, no run until the parent fires. const WelcomeQuickReplies: React.FC<{ c: ClaudeTokens; onPick: (prompt: string) => void; onPickBuilder: (prompt: string) => void; }> = ({ c, onPick, onPickBuilder }) => { - // Sequence: card pops, header title streams, heading types, THEN body + chips slide in. - const { shown: heading, done: headingDone } = useTypewriter(HEADING, 38, 450); const [expanded, setExpanded] = React.useState(null); const currentCategory = STARTER_CATEGORIES.find((cat) => cat.id === expanded); const isAppBuilder = currentCategory?.target === 'app-builder'; @@ -54,105 +26,93 @@ const WelcomeQuickReplies: React.FC<{ }; return ( - - - {heading} - - - {/* Body + chips slide up + fade in once the heading finishes. */} - {headingDone && ( - - - {BODY} - - - - {expanded === null ? ( - - - pick one, or just type below - - - {STARTER_CATEGORIES.map((cat, i) => ( - setExpanded(cat.id)} - initial={{ opacity: 0, scale: 0.86, y: 6 }} - animate={{ opacity: 1, scale: 1, y: 0 }} - transition={{ type: 'spring', stiffness: 360, damping: 24, delay: 0.25 + i * 0.13 }} - style={{ - display: 'flex', alignItems: 'center', gap: 8, - padding: '10px 14px', - borderRadius: 13, - border: `1px solid ${c.border.medium}`, - background: c.bg.surface, - color: c.text.secondary, - fontSize: '0.9rem', fontWeight: 500, - cursor: 'pointer', fontFamily: 'inherit', - }} - > - - {cat.label} - - ))} - - - ) : ( - - setExpanded(null)} - sx={{ - display: 'inline-flex', alignItems: 'center', gap: 0.5, - alignSelf: 'flex-start', mb: 0.9, px: 0.6, py: 0.3, - border: 'none', background: 'transparent', - color: c.text.ghost, fontSize: '0.85rem', + + + {expanded === null ? ( + + + pick one, or just type below + + + {STARTER_CATEGORIES.map((cat, i) => ( + setExpanded(cat.id)} + initial={{ opacity: 0, scale: 0.9, y: 5 }} + animate={{ opacity: 1, scale: 1, y: 0 }} + transition={{ type: 'spring', stiffness: 380, damping: 24, delay: 0.08 + i * 0.07 }} + style={{ + display: 'flex', alignItems: 'center', gap: 8, + padding: '10px 14px', + borderRadius: 13, + border: `1px solid ${c.border.medium}`, + background: c.bg.surface, + color: c.text.secondary, + fontSize: '0.9rem', fontWeight: 500, cursor: 'pointer', fontFamily: 'inherit', - '&:hover': { color: c.text.secondary }, }} > - back - - - {currentPrompts.map((prompt, i) => ( - pick(prompt)} - initial={{ opacity: 0, scale: 0.92 }} - animate={{ opacity: 1, scale: 1 }} - transition={{ type: 'spring', stiffness: 420, damping: 26, delay: i * 0.06 }} - style={{ - textAlign: 'left', - padding: '9px 14px', - borderRadius: 11, - border: `1px solid ${c.border.medium}`, - background: c.bg.surface, - color: c.text.secondary, - fontSize: '0.88rem', - cursor: 'pointer', fontFamily: 'inherit', - }} - > - {prompt} - - ))} - - - )} - - - )} - + + {cat.label} + + ))} + + + ) : ( + + setExpanded(null)} + sx={{ + display: 'inline-flex', alignItems: 'center', gap: 0.5, + alignSelf: 'flex-start', mb: 0.9, px: 0.6, py: 0.3, + border: 'none', background: 'transparent', + color: c.text.ghost, fontSize: '0.85rem', + cursor: 'pointer', fontFamily: 'inherit', + '&:hover': { color: c.text.secondary }, + }} + > + back + + + {currentPrompts.map((prompt, i) => ( + pick(prompt)} + initial={{ opacity: 0, scale: 0.92 }} + animate={{ opacity: 1, scale: 1 }} + transition={{ type: 'spring', stiffness: 420, damping: 26, delay: i * 0.06 }} + style={{ + textAlign: 'left', + padding: '9px 14px', + borderRadius: 11, + border: `1px solid ${c.border.medium}`, + background: c.bg.surface, + color: c.text.secondary, + fontSize: '0.88rem', + cursor: 'pointer', fontFamily: 'inherit', + }} + > + {prompt} + + ))} + + + )} + + ); }; diff --git a/frontend/src/app/pages/AgentChat/useWelcomeGreeting.ts b/frontend/src/app/pages/AgentChat/useWelcomeGreeting.ts new file mode 100644 index 00000000..48b9959b --- /dev/null +++ b/frontend/src/app/pages/AgentChat/useWelcomeGreeting.ts @@ -0,0 +1,66 @@ +import { useEffect, useRef, useState } from 'react'; +import { useAppDispatch } from '@/shared/hooks'; +import { streamStart, streamDelta } from '@/shared/state/streamingSlice'; +import { addMessage, type AgentSession } from '@/shared/state/agentsSlice'; + +// The first thing a new user reads. Written as a normal assistant turn (prose, no headings) so it +// streams in exactly like a real reply. No em-dashes. +export const WELCOME_GREETING = + "Hi, I'm OpenSwarm, your personal AI team. I can do just about anything right on your laptop, " + + "so bring me anything: a tough problem, a half-formed idea, something you need to write. " + + "We'll figure it out together.\n\nWhere do you want to start?"; + +const GREETING_MSG_ID = 'welcome-greeting'; + +// Streams the first-run greeting in as a genuine assistant bubble: it rides the same streaming +// slice + smooth-reveal every real reply uses, then settles into a real message so the chips can +// follow. Pure UI, no LLM, no run: launchAndSendFirstMessage POSTs only the prompt, so this +// seeded message is dropped on the server swap and never reaches the backend. +export function useWelcomeGreeting( + session: AgentSession | undefined, + isDraft: boolean, +): { greetingDone: boolean } { + const dispatch = useAppDispatch(); + const [greetingDone, setGreetingDone] = useState(false); + const startedRef = useRef(false); + + const eligible = isDraft && !!session?.is_welcome_draft && (session?.messages?.length ?? 0) === 0; + const sessionId = session?.id; + const branchId = session?.active_branch_id || 'main'; + + useEffect(() => { + if (!eligible || !sessionId || startedRef.current) return; + startedRef.current = true; + + dispatch(streamStart({ sessionId, messageId: GREETING_MSG_ID, role: 'assistant' })); + + // Feed word-by-word at a real-reply cadence; useSmoothText trails it for the typed look. + const tokens = WELCOME_GREETING.split(/(\s+)/); + let i = 0; + const timer = window.setInterval(() => { + const chunk = (tokens[i] ?? '') + (tokens[i + 1] ?? ''); + i += 2; + if (chunk) dispatch(streamDelta({ sessionId, messageId: GREETING_MSG_ID, delta: chunk })); + if (i >= tokens.length) { + window.clearInterval(timer); + // Settle into a real message; addMessage's listener clears the matching stream entry. + dispatch(addMessage({ + sessionId, + message: { + id: GREETING_MSG_ID, + role: 'assistant', + content: WELCOME_GREETING, + timestamp: new Date().toISOString(), + branch_id: branchId, + parent_id: null, + }, + })); + setGreetingDone(true); + } + }, 50); + + return () => window.clearInterval(timer); + }, [eligible, sessionId, branchId, dispatch]); + + return { greetingDone }; +} From b20673d5523445beff3970a9832ecb9b474cf1c0 Mon Sep 17 00:00:00 2001 From: ciregenz Date: Sun, 14 Jun 2026 03:44:32 -0700 Subject: [PATCH 21/27] [eric] onboarding: pin the welcome chat to exact viewport center (placeCard grid-snap was shoving it off-center) --- .../pages/Dashboard/hooks/lifecycle/useWelcomeDraft.ts | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/frontend/src/app/pages/Dashboard/hooks/lifecycle/useWelcomeDraft.ts b/frontend/src/app/pages/Dashboard/hooks/lifecycle/useWelcomeDraft.ts index 1c783e54..e1b755f5 100644 --- a/frontend/src/app/pages/Dashboard/hooks/lifecycle/useWelcomeDraft.ts +++ b/frontend/src/app/pages/Dashboard/hooks/lifecycle/useWelcomeDraft.ts @@ -1,7 +1,7 @@ import { useCallback, type RefObject } from 'react'; import { useAppDispatch, useAppSelector } from '@/shared/hooks'; import { createDraftSession, expandSession } from '@/shared/state/agentsSlice'; -import { placeCard, DEFAULT_CARD_W, EXPANDED_CARD_MIN_H } from '@/shared/state/dashboardLayoutSlice'; +import { placeCard, setCardPosition, DEFAULT_CARD_W, EXPANDED_CARD_MIN_H } from '@/shared/state/dashboardLayoutSlice'; import { markWelcomeShown } from '@/shared/state/onboardingProgressSlice'; import { hasFreeTrialActive, hasModelConnected } from '@/app/components/Onboarding/steps/skipPredicates'; @@ -49,15 +49,19 @@ export function useWelcomeDraft({ const vr = vp.getBoundingClientRect(); const cx = (vr.width / 2 - cs.panX) / cs.zoom; const cy = (vr.height / 2 - cs.panY) / cs.zoom; + const x = cx - DEFAULT_CARD_W / 2; + const y = cy - EXPANDED_CARD_MIN_H / 2; if (spawnOriginsRef.current) spawnOriginsRef.current[draftId] = { x: cx, y: cy }; dispatch(placeCard({ sessionId: draftId, - x: cx - DEFAULT_CARD_W / 2, - y: cy - EXPANDED_CARD_MIN_H / 2, + x, y, width: DEFAULT_CARD_W, height: EXPANDED_CARD_MIN_H, expandedSessionIds, })); + // placeCard grid-snaps + dodges collisions; the welcome chat is the only thing on a + // fresh dashboard, so pin it to the EXACT viewport center instead of a grid cell. + dispatch(setCardPosition({ sessionId: draftId, x, y })); } dispatch(expandSession(draftId)); dispatch(markWelcomeShown()); From 8c7afa2bd83fc2d86bb4297d764bb75154b313ce Mon Sep 17 00:00:00 2001 From: ciregenz Date: Sun, 14 Jun 2026 03:49:40 -0700 Subject: [PATCH 22/27] [eric] onboarding: gate the tour like a game, hide the Finish setup pill until the first agent completes; drop the X/5 count --- .../app/components/Onboarding/OnboardingPanel.tsx | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/frontend/src/app/components/Onboarding/OnboardingPanel.tsx b/frontend/src/app/components/Onboarding/OnboardingPanel.tsx index 45fadbac..cf3f625a 100644 --- a/frontend/src/app/components/Onboarding/OnboardingPanel.tsx +++ b/frontend/src/app/components/Onboarding/OnboardingPanel.tsx @@ -10,8 +10,9 @@ import HelpOutlineIcon from '@mui/icons-material/HelpOutline'; import CheckCircleIcon from '@mui/icons-material/CheckCircle'; import CloseIcon from '@mui/icons-material/Close'; import { useClaudeTokens } from '@/shared/styles/ThemeContext'; -import { useAppDispatch } from '@/shared/hooks'; +import { useAppDispatch, useAppSelector } from '@/shared/hooks'; import { useOnboardingProgress } from './hooks/useOnboardingProgress'; +import { hasAnyAgentCompleted } from './steps/skipPredicates'; import { clearJustCompleted } from '@/shared/state/onboardingProgressSlice'; import { STEPS, findStepById } from './steps'; import { useUnlockedStepIds } from './steps/stepUnlock'; @@ -53,6 +54,7 @@ const OnboardingPanel: React.FC = () => { const c = useClaudeTokens(); const dispatch = useAppDispatch(); const progress = useOnboardingProgress(); + const firstAgentDone = useAppSelector(hasAnyAgentCompleted); const infoBtnRef = useRef(null); const [infoOpen, setInfoOpen] = useState(false); @@ -133,6 +135,10 @@ const OnboardingPanel: React.FC = () => { if (!currentStep && !justDoneStep) return null; if (progress.panelMode === 'hidden') return null; + // Gate it like a game: on first run the tour stays out of the way entirely. The pill only + // appears once the user has earned it (their first agent finishes), at which point the + // reveal-after-win effect flips the panel to 'expanded' with the next single nudge. + if (progress.panelMode === 'pill' && !firstAgentDone && !progress.revealedAfterWin) return null; // Slide panel off-screen while AC runs so it doesn't sit on top of top-right targets (Skills install, "+ New app", etc). const panelHidden = progress.running; @@ -195,9 +201,6 @@ const OnboardingPanel: React.FC = () => { Finish setup - - {done}/{total} - { > {STAGE_LABELS[stageOf]} - - {done}/{total} - Date: Sun, 14 Jun 2026 04:06:45 -0700 Subject: [PATCH 23/27] [eric] onboarding: cursor pop spark, fewer spokes (6) but thicker + glow + longer (0.62s) so each reads clearly --- .../src/app/components/Onboarding/ac/AgenticCursor.tsx | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/frontend/src/app/components/Onboarding/ac/AgenticCursor.tsx b/frontend/src/app/components/Onboarding/ac/AgenticCursor.tsx index d7207a5b..5fd89df6 100644 --- a/frontend/src/app/components/Onboarding/ac/AgenticCursor.tsx +++ b/frontend/src/app/components/Onboarding/ac/AgenticCursor.tsx @@ -64,7 +64,7 @@ const WIN_EASE_MS = 420; // A little spark when the cursor pops into existence: short orange lines shoot out from the // tip and fade. Re-keyed on each pop so it replays. One-shot per mount (no infinite loop). -const BURST_SPOKES = 8; +const BURST_SPOKES = 6; const CursorBurst: React.FC<{ color: string }> = ({ color }) => ( <> {Array.from({ length: BURST_SPOKES }).map((_, i) => ( @@ -73,10 +73,10 @@ const CursorBurst: React.FC<{ color: string }> = ({ color }) => ( style={{ position: 'absolute', left: 3, top: 3, transform: `rotate(${(i / BURST_SPOKES) * 360}deg)` }} > ))} From aff75261f1ce40bda02fce68f6db013ffe956bb3 Mon Sep 17 00:00:00 2001 From: ciregenz Date: Sun, 14 Jun 2026 04:14:41 -0700 Subject: [PATCH 24/27] [eric] onboarding: mark launch_agent done after the first chat (was stuck to-do); a draft no longer counts as a launched agent --- .../src/app/components/Onboarding/OnboardingRoot.tsx | 10 ++++++++++ .../app/components/Onboarding/steps/skipPredicates.ts | 5 ++++- 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/frontend/src/app/components/Onboarding/OnboardingRoot.tsx b/frontend/src/app/components/Onboarding/OnboardingRoot.tsx index 30fd30b4..3f207ece 100644 --- a/frontend/src/app/components/Onboarding/OnboardingRoot.tsx +++ b/frontend/src/app/components/Onboarding/OnboardingRoot.tsx @@ -50,6 +50,16 @@ const OnboardingRoot: React.FC = () => { dispatch, ]); + // The welcome chat IS the user launching their first agent, so once it finishes, mark + // launch_agent done. Without this the post-win nudge repeats "Launch your first Agent" they + // just did: the skipIf baseline-capture re-arms on every completedSteps change and tends to + // snapshot the real send as "pre-existing", which freezes the step as un-completable. + useEffect(() => { + if (!progress.initialized || !firstAgentDone) return; + if ((progress.completedSteps ?? []).includes('launch_agent')) return; + dispatch(markStepCompleted('launch_agent')); + }, [firstAgentDone, progress.initialized, progress.completedSteps, dispatch]); + // First run: the cursor pops into existence, pauses, then moves to and clicks the New Agent // button (welcome_open step) which spawns the welcome chat. Fires once, only on the dashboard // with a way to run and nothing launched yet. Fail-safe: if the cursor can't run, a manual diff --git a/frontend/src/app/components/Onboarding/steps/skipPredicates.ts b/frontend/src/app/components/Onboarding/steps/skipPredicates.ts index 902ac30d..7459ad38 100644 --- a/frontend/src/app/components/Onboarding/steps/skipPredicates.ts +++ b/frontend/src/app/components/Onboarding/steps/skipPredicates.ts @@ -59,7 +59,10 @@ export function isYoutubeEnabled(s: RootState): boolean { export function hasAnyAgentLaunched(s: RootState): boolean { const sessions = s.agents?.sessions ?? {}; - return Object.keys(sessions).length > 0; + // A draft is an unsent chat, not a launched agent. Counting drafts let the welcome draft + // pre-satisfy launch_agent at baseline-capture time, which froze the step as "pre-existing" + // so it never auto-completed, leaving "Launch your first Agent" stuck to-do after the chat. + return Object.values(sessions).some((x: any) => x?.status && x.status !== 'draft'); } /** True once any agent has actually FINISHED (not just started). Used to hold the From bd8e5d93cc59ff5416523d40a1925c1c2d67b8a9 Mon Sep 17 00:00:00 2001 From: ciregenz Date: Sun, 14 Jun 2026 04:35:22 -0700 Subject: [PATCH 25/27] [eric] onboarding: recovery popup auto-dismisses the moment its step completes, no stale 'Tap Show me' after a transient throw --- .../app/components/Onboarding/ac/acRuntime.ts | 26 +++++++++++++++++-- 1 file changed, 24 insertions(+), 2 deletions(-) diff --git a/frontend/src/app/components/Onboarding/ac/acRuntime.ts b/frontend/src/app/components/Onboarding/ac/acRuntime.ts index 073a6c62..92e9c955 100644 --- a/frontend/src/app/components/Onboarding/ac/acRuntime.ts +++ b/frontend/src/app/components/Onboarding/ac/acRuntime.ts @@ -60,6 +60,26 @@ function abortableSleep(ms: number, signal: AbortSignal): Promise { }); } +// Resolve after `ms`, OR early the moment `stepId` lands in completedSteps. Lets a stale recovery +// popup auto-dismiss when the step it was apologizing for actually completes (e.g. launch_agent +// auto-completing right after a transient throw), instead of sitting the full read-timeout. +function sleepOrStepComplete(store: Store, stepId: string, ms: number): Promise { + return new Promise((resolve) => { + let settled = false; + const isDone = () => (store.getState().onboardingProgress?.completedSteps ?? []).includes(stepId); + const finish = () => { + if (settled) return; + settled = true; + window.clearTimeout(timer); + unsub(); + resolve(); + }; + const timer = window.setTimeout(finish, ms); + const unsub = store.subscribe(() => { if (isDone()) finish(); }); + if (isDone()) finish(); + }); +} + async function ensurePopupDwell(ctx: RunContext): Promise { const shownAt = ctx.popupShownAt.current; if (shownAt == null) return; @@ -199,8 +219,10 @@ export async function runStep(args: RunStepArgs): Promise { "No worries, feel free to explore. Tap Show me whenever you're ready." + debugSuffix, ); - // 14s: ACPopup streams at ~30ms/char + ~210ms/punct, so a 240-char popup takes ~10s to finish streaming; needs time for streamer + read. - await new Promise((r) => window.setTimeout(r, 14000)); + // 14s read window (ACPopup streams ~30ms/char), but bail the instant the step completes + // so a transient throw on a step that then auto-completes doesn't leave a dead-end "Show me". + await sleepOrStepComplete(store, step.id, 14000); + ac.hidePopup(); } } catch { /* defensive; never let cleanup throw */ From c2c43be9a02d8e9c9cd24ebe46e3c6799a7c6523 Mon Sep 17 00:00:00 2001 From: ciregenz Date: Sun, 14 Jun 2026 04:44:47 -0700 Subject: [PATCH 26/27] [eric] frontend: normalize package-lock (npm dropped stale peer markers) --- frontend/package-lock.json | 19 +------------------ 1 file changed, 1 insertion(+), 18 deletions(-) diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 8e97f035..92d511d7 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -84,7 +84,6 @@ "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", @@ -1965,7 +1964,6 @@ "resolved": "https://registry.npmjs.org/@emotion/react/-/react-11.14.0.tgz", "integrity": "sha512-O000MLDBDdk/EohJPFUqvnp4qnHeYkVP5B0xEG0D/L7cOKP9kefu2DXn8dj74cQfsEzUqh+sr1RzFqiL1o+PpA==", "license": "MIT", - "peer": true, "dependencies": { "@babel/runtime": "^7.18.3", "@emotion/babel-plugin": "^11.13.5", @@ -2009,7 +2007,6 @@ "resolved": "https://registry.npmjs.org/@emotion/styled/-/styled-11.14.1.tgz", "integrity": "sha512-qEEJt42DuToa3gurlH4Qqc1kVpNq8wO8cJtDzU46TjlzWjDlsVyevtYCRijVq3SrHsROS+gVQ8Fnea108GnKzw==", "license": "MIT", - "peer": true, "dependencies": { "@babel/runtime": "^7.18.3", "@emotion/babel-plugin": "^11.13.5", @@ -2245,7 +2242,6 @@ "resolved": "https://registry.npmjs.org/@mui/material/-/material-7.3.10.tgz", "integrity": "sha512-cHvGOk2ZEfbQt3LnGe0ZKd/ETs9gsUpkW66DCO+GSjMZhpdKU4XsuIr7zJ/B/2XaN8ihxuzHfYAR4zPtCN4RYg==", "license": "MIT", - "peer": true, "dependencies": { "@babel/runtime": "^7.28.6", "@mui/core-downloads-tracker": "^7.3.10", @@ -3378,7 +3374,6 @@ "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.28.tgz", "integrity": "sha512-z9VXpC7MWrhfWipitjNdgCauoMLRdIILQsAEV+ZesIzBq/oUlxk0m3ApZuMFCXdnS4U7KrI+l3WRUEGQ8K1QKw==", "license": "MIT", - "peer": true, "dependencies": { "@types/prop-types": "*", "csstype": "^3.2.2" @@ -3775,7 +3770,6 @@ "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", "dev": true, "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -3815,7 +3809,6 @@ "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", @@ -4144,7 +4137,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.10.12", "caniuse-lite": "^1.0.30001782", @@ -8192,7 +8184,6 @@ "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -8249,7 +8240,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", @@ -8468,7 +8458,6 @@ "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", "license": "MIT", - "peer": true, "dependencies": { "loose-envify": "^1.1.0" }, @@ -8481,7 +8470,6 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", "license": "MIT", - "peer": true, "dependencies": { "loose-envify": "^1.1.0", "scheduler": "^0.23.2" @@ -8528,7 +8516,6 @@ "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz", "integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==", "license": "MIT", - "peer": true, "dependencies": { "@types/use-sync-external-store": "^0.0.6", "use-sync-external-store": "^1.4.0" @@ -8667,8 +8654,7 @@ "version": "5.0.1", "resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz", "integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/redux-thunk": { "version": "3.1.0", @@ -8980,7 +8966,6 @@ "integrity": "sha512-kgW13M54DUB7IsIRM5LvJkNlpH+WhMpooUcaWGFARkF1Tc82v9mIWkCbCYf+MBvpIUBSeSOTilpZjEPr2VYE6Q==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "chokidar": "^4.0.0", "immutable": "^5.1.5", @@ -10084,7 +10069,6 @@ "integrity": "sha512-wGN3qcrBQIFmQ/c0AiOAQBvrZ5lmY8vbbMv4Mxfgzqd/B6+9pXtLo73WuS1dSGXM5QYY3hZnIbvx+K1xxe6FyA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@types/eslint-scope": "^3.7.7", "@types/estree": "^1.0.8", @@ -10133,7 +10117,6 @@ "integrity": "sha512-pIDJHIEI9LR0yxHXQ+Qh95k2EvXpWzZ5l+d+jIo+RdSm9MiHfzazIxwwni/p7+x4eJZuvG1AJwgC4TNQ7NRgsg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@discoveryjs/json-ext": "^0.5.0", "@webpack-cli/configtest": "^2.1.1", From 78fbbebccd1d50df4f8f00b3531ba682b2448bd3 Mon Sep 17 00:00:00 2001 From: ciregenz Date: Sun, 14 Jun 2026 04:59:03 -0700 Subject: [PATCH 27/27] [eric] release: bump version to 1.2.84 (off-window crash fix + #81 rename + onboarding revamp) --- electron/package-lock.json | 4 ++-- electron/package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/electron/package-lock.json b/electron/package-lock.json index 96e0bad9..89267711 100644 --- a/electron/package-lock.json +++ b/electron/package-lock.json @@ -1,12 +1,12 @@ { "name": "openswarm", - "version": "1.2.77", + "version": "1.2.84", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "openswarm", - "version": "1.2.77", + "version": "1.2.84", "hasInstallScript": true, "dependencies": { "electron-updater": "6.8.3", diff --git a/electron/package.json b/electron/package.json index aa816113..af023c3f 100644 --- a/electron/package.json +++ b/electron/package.json @@ -1,6 +1,6 @@ { "name": "openswarm", - "version": "1.2.83", + "version": "1.2.84", "description": "OpenSwarm — AI Agent Orchestrator", "author": "openswarm-ai", "main": "main.js",