mirror of
https://github.com/openswarm-ai/openswarm.git
synced 2026-09-11 20:27:44 +02:00
Merge branch 'eric/dev' of https://github.com/openswarm-ai/openswarm into aidan/ux-ui/app-builder-dashboard
This commit is contained in:
Generated
+2
-2
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
Generated
+1
-18
@@ -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",
|
||||
|
||||
@@ -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<HTMLButtonElement | null>(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' }}
|
||||
>
|
||||
<ButtonBase
|
||||
data-onboarding={S.onboardingContinueButton}
|
||||
onClick={() => {
|
||||
report('panel_expanded', { from: 'pill' });
|
||||
progress.setPanelMode('expanded');
|
||||
@@ -193,9 +201,6 @@ const OnboardingPanel: React.FC = () => {
|
||||
<Typography sx={{ fontSize: 14.5, fontWeight: 600, color: c.text.primary }}>
|
||||
Finish setup
|
||||
</Typography>
|
||||
<Typography sx={{ fontSize: 13.5, color: c.text.muted, fontWeight: 500 }}>
|
||||
{done}/{total}
|
||||
</Typography>
|
||||
<Box sx={{ flexGrow: 1, minWidth: 12 }} />
|
||||
<Typography
|
||||
sx={{
|
||||
@@ -253,9 +258,6 @@ const OnboardingPanel: React.FC = () => {
|
||||
>
|
||||
{STAGE_LABELS[stageOf]}
|
||||
</Typography>
|
||||
<Typography sx={{ fontSize: 11, color: c.text.muted }}>
|
||||
{done}/{total}
|
||||
</Typography>
|
||||
</Box>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.2 }}>
|
||||
<ButtonBase
|
||||
|
||||
@@ -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, hasFreeTrialActive, hasModelConnected } from './steps/skipPredicates';
|
||||
import OnboardingPanel from './OnboardingPanel';
|
||||
import { onboardingBus } from './eventBus';
|
||||
import { report } from './telemetry';
|
||||
@@ -32,24 +32,60 @@ 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,
|
||||
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<number | null>(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;
|
||||
|
||||
@@ -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) => (
|
||||
<div
|
||||
key={i}
|
||||
style={{ position: 'absolute', left: 3, top: 3, transform: `rotate(${(i / BURST_SPOKES) * 360}deg)` }}
|
||||
>
|
||||
<motion.div
|
||||
initial={{ y: -1, opacity: 1, scaleY: 0.4 }}
|
||||
animate={{ y: -23, opacity: 0, scaleY: 1 }}
|
||||
transition={{ duration: 0.62, ease: 'easeOut' }}
|
||||
style={{ position: 'absolute', left: -1.5, top: 0, width: 3, height: 12, borderRadius: 2, background: color, boxShadow: `0 0 6px ${color}` }}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
|
||||
const AgenticCursor = forwardRef<AgenticCursorHandle>((_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<PopupState | null>(null);
|
||||
const [multiChoice, setMultiChoice] = useState<MultiChoiceState | null>(null);
|
||||
|
||||
@@ -95,12 +117,17 @@ const AgenticCursor = forwardRef<AgenticCursorHandle>((_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<AgenticCursorHandle>((_props, ref) => {
|
||||
}}
|
||||
>
|
||||
{visible && (
|
||||
<motion.div
|
||||
animate={{
|
||||
scale: [1, 1.04, 1],
|
||||
}}
|
||||
transition={{
|
||||
duration: 1.8,
|
||||
repeat: Infinity,
|
||||
ease: 'easeInOut',
|
||||
}}
|
||||
style={{
|
||||
transform: 'translate(-2px, -2px)',
|
||||
filter: `drop-shadow(0 0 6px ${c.accent.primary}cc) drop-shadow(0 0 14px ${c.accent.primary}55)`,
|
||||
}}
|
||||
>
|
||||
<CursorArrow color={c.accent.primary} />
|
||||
</motion.div>
|
||||
<>
|
||||
{/* Orange spark behind the arrow, replays each pop via burstKey. */}
|
||||
<div key={burstKey} style={{ position: 'absolute', top: 0, left: 0 }}>
|
||||
<CursorBurst color={c.accent.primary} />
|
||||
</div>
|
||||
<motion.div
|
||||
animate={{
|
||||
scale: [1, 1.04, 1],
|
||||
}}
|
||||
transition={{
|
||||
duration: 1.8,
|
||||
repeat: Infinity,
|
||||
ease: 'easeInOut',
|
||||
}}
|
||||
style={{
|
||||
transform: 'translate(-2px, -2px)',
|
||||
filter: `drop-shadow(0 0 6px ${c.accent.primary}cc) drop-shadow(0 0 14px ${c.accent.primary}55)`,
|
||||
}}
|
||||
>
|
||||
<CursorArrow color={c.accent.primary} />
|
||||
</motion.div>
|
||||
</>
|
||||
)}
|
||||
</motion.div>
|
||||
|
||||
|
||||
@@ -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<void> {
|
||||
});
|
||||
}
|
||||
|
||||
// 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<RootState>, stepId: string, ms: number): Promise<void> {
|
||||
return new Promise<void>((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<void> {
|
||||
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<void> {
|
||||
|
||||
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<void> {
|
||||
findStep,
|
||||
highlightCleanup,
|
||||
popupShownAt,
|
||||
popupDwellMs,
|
||||
};
|
||||
|
||||
try {
|
||||
@@ -119,6 +144,7 @@ export async function runStep(args: RunStepArgs): Promise<void> {
|
||||
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<void> {
|
||||
"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<void>((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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
});
|
||||
ac.showPopup("Didn't seem to go through. Try again?");
|
||||
ctx.popupShownAt.current = performance.now();
|
||||
ctx.popupDwellMs.current = null;
|
||||
await waitForCondition(
|
||||
op.condition,
|
||||
signal,
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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',
|
||||
|
||||
|
||||
@@ -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[] }[] = [
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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' },
|
||||
],
|
||||
};
|
||||
@@ -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' },
|
||||
],
|
||||
|
||||
@@ -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 }
|
||||
| {
|
||||
|
||||
@@ -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<AgentChatProps> = ({ sessionId: sessionIdProp, onClose
|
||||
const [dropTargetIdx, setDropTargetIdx] = useState<number | null>(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<AgentChatProps> = ({ sessionId: sessionIdProp, onClose
|
||||
const config: Record<string, any> = { 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<AgentChatProps> = ({ 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<AgentChatProps> = ({ sessionId: sessionIdProp, onClose
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
{!isDraft && (
|
||||
{(!isDraft || session.is_welcome_draft) && (
|
||||
// Welcome draft shows just the model so the header isn't bare; real runs add branch + cost.
|
||||
<Box sx={{ display: 'flex', gap: 1.5, mt: 0.25, alignItems: 'center' }}>
|
||||
<Typography variant="caption" sx={{ color: c.text.tertiary }}>
|
||||
{resolveModelLabel(session.model)}
|
||||
</Typography>
|
||||
<Typography variant="caption" sx={{ color: c.text.tertiary }}>
|
||||
{session.branch_name}
|
||||
</Typography>
|
||||
{!isDraft && session.branch_name && (
|
||||
<Typography variant="caption" sx={{ color: c.text.tertiary }}>
|
||||
{session.branch_name}
|
||||
</Typography>
|
||||
)}
|
||||
{(() => {
|
||||
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<AgentChatProps> = ({ sessionId: sessionIdProp, onClose
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
{/* 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') && (
|
||||
<WelcomeQuickReplies
|
||||
c={c}
|
||||
onPick={(p) => handleSend(p)}
|
||||
onPickBuilder={(p) => chatInputRef.current?.setContent(p)}
|
||||
/>
|
||||
)}
|
||||
{(preSendActivityLabel || awaitingResponse || (session.status === 'running' && !streamingMessageId)) && (
|
||||
<Box sx={{ overflowAnchor: 'none' }}>
|
||||
<ThinkingBubble
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
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 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 }) => {
|
||||
const [expanded, setExpanded] = React.useState<string | null>(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 (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.45, ease: [0.22, 1, 0.36, 1] }}
|
||||
style={{ padding: '4px 18px 8px 18px', display: 'flex', flexDirection: 'column', alignItems: 'stretch' }}
|
||||
>
|
||||
<AnimatePresence mode="wait" initial={false}>
|
||||
{expanded === null ? (
|
||||
<motion.div key="categories" initial={false} style={{ display: 'flex', flexDirection: 'column' }}>
|
||||
<Typography sx={{ color: c.text.ghost, fontSize: '0.82rem', mb: 1.1 }}>
|
||||
pick one, or just type below
|
||||
</Typography>
|
||||
<Box sx={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 1 }}>
|
||||
{STARTER_CATEGORIES.map((cat, i) => (
|
||||
<motion.button
|
||||
key={cat.id}
|
||||
onClick={() => 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.Icon size={16} />
|
||||
{cat.label}
|
||||
</motion.button>
|
||||
))}
|
||||
</Box>
|
||||
</motion.div>
|
||||
) : (
|
||||
<motion.div
|
||||
key="specifics"
|
||||
initial={{ opacity: 0, y: 4 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -4 }}
|
||||
transition={{ duration: 0.18 }}
|
||||
style={{ display: 'flex', flexDirection: 'column' }}
|
||||
>
|
||||
<Box
|
||||
component="button"
|
||||
onClick={() => 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 },
|
||||
}}
|
||||
>
|
||||
<ArrowLeft size={14} /> back
|
||||
</Box>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.7 }}>
|
||||
{currentPrompts.map((prompt, i) => (
|
||||
<motion.button
|
||||
key={prompt}
|
||||
onClick={() => 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}
|
||||
</motion.button>
|
||||
))}
|
||||
</Box>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</motion.div>
|
||||
);
|
||||
};
|
||||
|
||||
export default WelcomeQuickReplies;
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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<string | null>(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',
|
||||
}}
|
||||
>
|
||||
<style>{`@keyframes empty-state-shimmer { 0% { background-position: 200% 0; } 100% { background-position: -200% 0; } }`}</style>
|
||||
<Typography sx={{ color: c.text.tertiary, fontSize: '1.25rem', mb: 1 }}>
|
||||
No agents running
|
||||
</Typography>
|
||||
<Typography
|
||||
sx={{
|
||||
fontSize: '1rem',
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: 0.7,
|
||||
background: `linear-gradient(90deg, ${c.text.ghost} 0%, ${c.text.ghost} 40%, ${c.text.primary} 50%, ${c.text.ghost} 60%, ${c.text.ghost} 100%)`,
|
||||
backgroundSize: '200% 100%',
|
||||
WebkitBackgroundClip: 'text',
|
||||
backgroundClip: 'text',
|
||||
WebkitTextFillColor: 'transparent',
|
||||
color: 'transparent',
|
||||
animation: active ? 'empty-state-shimmer 6s linear infinite' : 'none',
|
||||
}}
|
||||
>
|
||||
Click the
|
||||
{/* Literal toolbar glyph; the shimmer's transparent color would hide it, so reset color here. */}
|
||||
<Box component="span" sx={{ display: 'inline-flex', color: c.text.tertiary }}>
|
||||
<ChatBubbleTeardrop sx={{ fontSize: 15 }} />
|
||||
</Box>
|
||||
below to launch your first agent
|
||||
<Typography sx={{ color: c.text.secondary, fontSize: '1.3rem', fontWeight: 500, mb: 2.2, textAlign: 'center' }}>
|
||||
What do you want done?
|
||||
</Typography>
|
||||
|
||||
{showChips && (
|
||||
<Box sx={{ mt: 3, width: '100%', maxWidth: 560, pointerEvents: 'auto', display: 'flex', flexDirection: 'column', alignItems: 'center' }}>
|
||||
{/* 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. */}
|
||||
<Box
|
||||
sx={{
|
||||
width: '100%', maxWidth: 480, mb: 2.2,
|
||||
display: 'flex', alignItems: 'center', gap: 1,
|
||||
px: 1.6, py: 0.6,
|
||||
borderRadius: 3,
|
||||
border: `1px solid ${c.border.medium}`,
|
||||
background: c.bg.surface,
|
||||
transition: 'border-color 150ms',
|
||||
'&:focus-within': { borderColor: c.accent.primary },
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
component="input"
|
||||
value={intent}
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) => 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 },
|
||||
}}
|
||||
/>
|
||||
<Box
|
||||
component="button"
|
||||
onClick={launchIntent}
|
||||
disabled={launching || !intent.trim()}
|
||||
aria-label="Put a team on it"
|
||||
sx={{
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
width: 32, height: 32, flexShrink: 0,
|
||||
borderRadius: '50%', border: 'none',
|
||||
background: intent.trim() ? c.accent.primary : c.bg.elevated,
|
||||
color: intent.trim() ? '#fff' : c.text.ghost,
|
||||
cursor: intent.trim() && !launching ? 'pointer' : 'default',
|
||||
transition: 'background 150ms',
|
||||
}}
|
||||
>
|
||||
<ArrowRight size={18} />
|
||||
</Box>
|
||||
</Box>
|
||||
{showChips ? (
|
||||
<Box sx={{ width: '100%', maxWidth: 560, pointerEvents: 'auto', display: 'flex', flexDirection: 'column', alignItems: 'center' }}>
|
||||
<AnimatePresence mode="wait" initial={false}>
|
||||
{expanded === null ? (
|
||||
<motion.div
|
||||
key="categories"
|
||||
initial={{ opacity: 0, y: 4 }}
|
||||
initial={{ opacity: 0, y: 6 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -4 }}
|
||||
transition={{ duration: 0.18 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
style={{ width: '100%', display: 'flex', flexDirection: 'column', alignItems: 'center' }}
|
||||
>
|
||||
<Typography sx={{ color: c.text.ghost, fontSize: '0.9rem', mb: 1.4 }}>
|
||||
or try one of these
|
||||
pick one, or just tell me below
|
||||
</Typography>
|
||||
<Box sx={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 1.2, width: '100%', maxWidth: 460 }}>
|
||||
{STARTER_CATEGORIES.map((cat) => (
|
||||
@@ -291,6 +153,10 @@ const DashboardEmptyState: React.FC<{
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</Box>
|
||||
) : (
|
||||
<Typography sx={{ color: c.text.ghost, fontSize: '0.95rem' }}>
|
||||
Connect a model in Settings to get started.
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
|
||||
@@ -756,14 +756,21 @@ const AgentCard: React.FC<Props> = ({
|
||||
</Typography>
|
||||
)}
|
||||
</Typewriter>
|
||||
{/* 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 && (
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', flexShrink: 0 }}>
|
||||
<Typography sx={{ fontSize: '0.75rem', fontWeight: 500, color: c.text.tertiary, whiteSpace: 'nowrap' }}>
|
||||
{friendlyStatusLabel(session.status)}
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
{/* Welcome chat has no status to show, so name the model instead, otherwise the header reads bare. */}
|
||||
{session.is_welcome_draft && friendlyModelLabel && (
|
||||
<Typography sx={{ fontSize: '0.75rem', fontWeight: 500, color: c.text.tertiary, whiteSpace: 'nowrap', flexShrink: 0 }}>
|
||||
{friendlyModelLabel}
|
||||
</Typography>
|
||||
)}
|
||||
{/* Calm, zero-click signal: the agent recalled or built up memory of
|
||||
this site, so the user feels it getting smarter on its own. */}
|
||||
<Fade in={session.memory_recalled || session.memory_learned} timeout={{ enter: 200, exit: 220 }} unmountOnExit>
|
||||
|
||||
@@ -38,6 +38,9 @@ interface UseAgentSpawnArgs {
|
||||
setToolbarOpen: Dispatch<SetStateAction<boolean>>;
|
||||
setAutoFocusSessionId: Dispatch<SetStateAction<string | null>>;
|
||||
setPendingSelectSessionId: Dispatch<SetStateAction<string | null>>;
|
||||
/** 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);
|
||||
|
||||
@@ -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<HTMLDivElement | null>;
|
||||
canvasStateRef: RefObject<{ panX: number; panY: number; zoom: number }>;
|
||||
spawnOriginsRef: RefObject<Record<string, SpawnOrigin>>;
|
||||
}
|
||||
|
||||
// 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 };
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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',
|
||||
],
|
||||
},
|
||||
];
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
@@ -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<OnboardingProgressState>) {
|
||||
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<string>) {
|
||||
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,
|
||||
|
||||
Reference in New Issue
Block a user