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", 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", diff --git a/frontend/src/app/components/Onboarding/OnboardingPanel.tsx b/frontend/src/app/components/Onboarding/OnboardingPanel.tsx index f78773b9..cf3f625a 100644 --- a/frontend/src/app/components/Onboarding/OnboardingPanel.tsx +++ b/frontend/src/app/components/Onboarding/OnboardingPanel.tsx @@ -10,12 +10,14 @@ 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'; import { STAGE_LABELS } from './steps/types'; +import { S } from './selectors'; import { onboardingDirector } from './OnboardingDirector'; import { report } from './telemetry'; import { cursorStore } from './ac/cursorStore'; @@ -52,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); @@ -132,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; @@ -166,6 +173,7 @@ const OnboardingPanel: React.FC = () => { style={{ pointerEvents: 'auto' }} > { report('panel_expanded', { from: 'pill' }); progress.setPanelMode('expanded'); @@ -193,9 +201,6 @@ const OnboardingPanel: React.FC = () => { Finish setup - - {done}/{total} - { > {STAGE_LABELS[stageOf]} - - {done}/{total} - { 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, 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 + // 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(() => { + 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; 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..5fd89df6 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 = 6; +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); @@ -95,12 +117,17 @@ 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); + setBurstKey((k) => k + 1); // replay the orange spark on each pop + // An exaggerated "POP!": scale shoots way past 1 then settles back, opacity snaps in. await controls.start({ opacity: 1, - scale: 1, - transition: { duration: 0.32, ease: 'easeOut' }, + scale: [0.1, 1.45, 0.92, 1], + transition: { + opacity: { duration: 0.14, ease: 'easeOut' }, + scale: { duration: 0.5, ease: 'easeOut', times: [0, 0.55, 0.8, 1] }, + }, }); }, async moveTo(x, y, transition) { @@ -304,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/components/Onboarding/ac/acRuntime.ts b/frontend/src/app/components/Onboarding/ac/acRuntime.ts index 960125c7..92e9c955 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. @@ -58,11 +60,32 @@ 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; + 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 +112,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 +124,7 @@ export async function runStep(args: RunStepArgs): Promise { findStep, highlightCleanup, popupShownAt, + popupDwellMs, }; try { @@ -119,6 +144,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 }); @@ -193,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 */ @@ -276,6 +304,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 +392,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 +428,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 +611,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/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..4dad27b7 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 { 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). @@ -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[] = [welcomeOpenStep]; + 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/skipPredicates.ts b/frontend/src/app/components/Onboarding/steps/skipPredicates.ts index b4907e4a..7459ad38 100644 --- a/frontend/src/app/components/Onboarding/steps/skipPredicates.ts +++ b/frontend/src/app/components/Onboarding/steps/skipPredicates.ts @@ -59,7 +59,17 @@ 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 + * 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 { 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..282e97f1 --- /dev/null +++ b/frontend/src/app/components/Onboarding/steps/step00_welcomeNudge.ts @@ -0,0 +1,20 @@ +import type { OnboardingStep } from './types'; +import { S } from '../selectors'; + +// 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: '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/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' }, ], 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 } | { diff --git a/frontend/src/app/pages/AgentChat/AgentChat.tsx b/frontend/src/app/pages/AgentChat/AgentChat.tsx index be0a2436..d581c616 100644 --- a/frontend/src/app/pages/AgentChat/AgentChat.tsx +++ b/frontend/src/app/pages/AgentChat/AgentChat.tsx @@ -45,6 +45,8 @@ 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 { useWelcomeGreeting } from './useWelcomeGreeting'; import MessageBubble from './bubbles/MessageBubble'; import { estimateRenderedTextHeight, RECHECK_VISIBILITY_EVENT } from './bubbles/markdownMeasure'; import CompactionMarker from './bubbles/CompactionMarker'; @@ -325,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; @@ -404,6 +407,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) => { @@ -426,7 +433,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; @@ -1357,14 +1364,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 @@ -1812,6 +1822,15 @@ const AgentChat: React.FC = ({ 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)) && ( +// 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 }) => { + 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, 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', + }} + > + + {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} + + ))} + + + )} + + + ); +}; + +export default WelcomeQuickReplies; 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 }; +} diff --git a/frontend/src/app/pages/Dashboard/canvas/DashboardEmptyState.tsx b/frontend/src/app/pages/Dashboard/canvas/DashboardEmptyState.tsx index 6ed7c5ba..2a04fbd7 100644 --- a/frontend/src/app/pages/Dashboard/canvas/DashboardEmptyState.tsx +++ b/frontend/src/app/pages/Dashboard/canvas/DashboardEmptyState.tsx @@ -2,114 +2,46 @@ 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 type { LucideIcon } from 'lucide-react'; +import { ArrowLeft } 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'; - -// 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?', - ], - }, -]; +import { STARTER_CATEGORIES } from '@/shared/starterCategories'; +// 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 }) => { - // 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)); 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 ?? []; - // 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'; - // 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; - if (onStarter) { - onStarter(prompt, isAppBuilder ? 'view-builder' : undefined); + 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); - }; - - // 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); + if (onLaunch) { + setLaunching(true); + onLaunch(prompt, mode, model); + return; + } + if (onStarter) onStarter(prompt); }; return ( @@ -124,94 +56,24 @@ const DashboardEmptyState: React.FC<{ pointerEvents: 'none', }} > - - - No agents running - - - Click the - {/* Literal toolbar glyph; the shimmer's transparent color would hide it, so reset color here. */} - - - - below to launch your first agent + + What do you want done? - {showChips && ( - - {/* 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 }, - }} - /> - - - - + {showChips ? ( + {expanded === null ? ( - or try one of these + pick one, or just tell me below {STARTER_CATEGORIES.map((cat) => ( @@ -291,6 +153,10 @@ const DashboardEmptyState: React.FC<{ )} + ) : ( + + Connect a model in Settings to get started. + )} ); diff --git a/frontend/src/app/pages/Dashboard/cards/AgentCard.tsx b/frontend/src/app/pages/Dashboard/cards/AgentCard.tsx index 7fab5de5..0bb214c2 100644 --- a/frontend/src/app/pages/Dashboard/cards/AgentCard.tsx +++ b/frontend/src/app/pages/Dashboard/cards/AgentCard.tsx @@ -756,14 +756,21 @@ 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)} )} + {/* 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. */} diff --git a/frontend/src/app/pages/Dashboard/hooks/lifecycle/useAgentSpawn.ts b/frontend/src/app/pages/Dashboard/hooks/lifecycle/useAgentSpawn.ts index e1ff4cbc..e26fdfa0 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 new file mode 100644 index 00000000..e1b755f5 --- /dev/null +++ b/frontend/src/app/pages/Dashboard/hooks/lifecycle/useWelcomeDraft.ts @@ -0,0 +1,74 @@ +import { useCallback, type RefObject } from 'react'; +import { useAppDispatch, useAppSelector } from '@/shared/hooks'; +import { createDraftSession, expandSession } from '@/shared/state/agentsSlice'; +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'; + +type SpawnOrigin = { x: number; y: number; type?: 'branch' }; + +interface Args { + dashboardId: string; + /** layoutInitialized && no sessions/views/browsers on the canvas. */ + canvasEmpty: boolean; + expandedSessionIds: string[]; + viewportRef: RefObject; + canvasStateRef: RefObject<{ panX: number; panY: number; zoom: number }>; + spawnOriginsRef: RefObject>; +} + +// 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, canvasEmpty, expandedSessionIds, viewportRef, canvasStateRef, spawnOriginsRef, +}: Args): { welcomeEligible: boolean; createWelcomeDraft: () => void } { + const dispatch = useAppDispatch(); + const reduxEligible = 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); + const welcomeEligible = reduxEligible && canvasEmpty; + + const createWelcomeDraft = useCallback(() => { + try { + // 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; + + 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; + 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, 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()); + } catch (err) { + console.error('[welcome-draft] create failed', err); + } + }, [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 8b9326d6..fc425a19 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: 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, + canvasEmpty, + expandedSessionIds, + viewportRef: canvas.viewportRef, + canvasStateRef, + spawnOriginsRef, + }); + // ---- Auto-reveal / collapse / unreveal sub-agent cards ---- useSubAgentLifecycle({ isActive, @@ -221,6 +233,8 @@ export function useDashboardController(dashboardId: string, isActive: boolean) { setToolbarOpen, setAutoFocusSessionId, setPendingSelectSessionId, + welcomeEligible, + onWelcomeNewAgent: createWelcomeDraft, }); const { diff --git a/frontend/src/shared/starterCategories.ts b/frontend/src/shared/starterCategories.ts new file mode 100644 index 00000000..4b3a1d92 --- /dev/null +++ b/frontend/src/shared/starterCategories.ts @@ -0,0 +1,60 @@ +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. 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; + label: string; + Icon: LucideIcon; + prompts: string[]; + target?: 'app-builder'; +}; + +export const STARTER_CATEGORIES: StarterCategory[] = [ + { + // 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', + ], + }, + { + // 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: [ + '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 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 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', + ], + }, + { + // MCPs: plug your real tools in and let agents work across them. + id: 'connect', label: 'Connect your apps', Icon: Plug, + prompts: [ + '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', + ], + }, +]; diff --git a/frontend/src/shared/state/agentsSlice.ts b/frontend/src/shared/state/agentsSlice.ts index d76912d9..d64f52df 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'; @@ -101,6 +109,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,11 +519,11 @@ 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', + name: welcome === true ? 'First chat with OpenSwarm' : 'New chat', status: 'draft', provider: provider || 'anthropic', model: model || 'sonnet', @@ -520,19 +531,24 @@ 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(), 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 +557,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 +567,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,