From 665dec63e265b68e6e027bb8d3308eeba48789f0 Mon Sep 17 00:00:00 2001 From: ciregenz Date: Sun, 14 Jun 2026 00:56:48 -0700 Subject: [PATCH] [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,