Merge branch 'eric/dev' of https://github.com/openswarm-ai/openswarm into aidan/ux-ui/app-builder-dashboard

This commit is contained in:
abccodes
2026-06-14 06:02:02 -07:00
25 changed files with 632 additions and 256 deletions
+2 -2
View File
@@ -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 -1
View File
@@ -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",
+1 -18
View File
@@ -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 }
| {
+24 -5
View File
@@ -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 {
+60
View File
@@ -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',
],
},
];
+25 -6
View File
@@ -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,