mirror of
https://github.com/openswarm-ai/openswarm.git
synced 2026-08-17 18:25:42 +02:00
[eric] onboarding: instant reveal + 3 tailored starters, drop auto-spawn
This commit is contained in:
@@ -11,7 +11,7 @@ from typing import List, Optional
|
||||
from typeguard import typechecked
|
||||
|
||||
from backend.apps.agents.core.aux_llm import aux_max_tokens_for, safe_resp_text
|
||||
from backend.apps.onboarding.models import PrepRequest, PrepResponse
|
||||
from backend.apps.onboarding.models import PrepRequest, PrepResponse, ScanResult
|
||||
from backend.apps.settings.models import AppSettings, PersonalizedAutomation, PersonalizedStarter
|
||||
|
||||
VALID_CADENCE = {"daily", "weekday", "weekly"}
|
||||
@@ -292,6 +292,47 @@ def parse_prep(text: str) -> Optional[PrepResponse]:
|
||||
)
|
||||
|
||||
|
||||
@typechecked
|
||||
def p_scan_grounded_starters(scan: ScanResult) -> List[PersonalizedStarter]:
|
||||
"""Build starters from the REAL scan (no LLM): each references something concrete on this machine, a
|
||||
screenshot pile, a content-heavy folder, the app they lean on, their code projects. Every one produces
|
||||
a keepable artifact and never modifies an existing file, so even the no-LLM path is genuinely tailored."""
|
||||
out: List[PersonalizedStarter] = []
|
||||
apps = scan.signal_apps[:3]
|
||||
# Screenshots -> a browsable gallery page (real deliverable from their real files).
|
||||
shots = next((f for f in scan.folders if f.screenshot_count > 2), None)
|
||||
if shots:
|
||||
out.append(PersonalizedStarter(
|
||||
title="Frame my screenshots",
|
||||
prompt=f"Find the screenshot images in my {shots.name} folder and build one browsable gallery web page showing them as a neat scrollable grid. Write only the new page; never move or delete the originals.",
|
||||
reason=f"{shots.screenshot_count} screenshots sitting in {shots.name}.",
|
||||
))
|
||||
# A content-heavy folder -> a searchable index page of what's in it.
|
||||
docs = next((f for f in scan.folders if f.name in ("Documents", "Downloads", "Desktop") and f.entry_count > 5 and f.top_extensions), None)
|
||||
if docs:
|
||||
ext = docs.top_extensions[0].lstrip(".") or "file"
|
||||
out.append(PersonalizedStarter(
|
||||
title=f"Index my {ext} files",
|
||||
prompt=f"Look through my {docs.name} folder and build one searchable index page listing my {ext} files with their names and dates so I can find things fast. Write only the new page; do not move or delete anything.",
|
||||
reason=f"{docs.entry_count} files in {docs.name}, lots of .{ext}.",
|
||||
))
|
||||
# Top signal app -> live web research for current tips (a real answer, not a plan).
|
||||
if apps:
|
||||
out.append(PersonalizedStarter(
|
||||
title=f"{apps[0]} tips",
|
||||
prompt=f"Search the web right now for the most useful current tips, shortcuts, and workflows for {apps[0]}, and give me a tight summary with dated sources.",
|
||||
reason=f"You lean on {apps[0]} a lot.",
|
||||
))
|
||||
# Code projects -> a plain-English recap of where each stands.
|
||||
if scan.git_repo_count > 0:
|
||||
out.append(PersonalizedStarter(
|
||||
title="Recap my projects",
|
||||
prompt="Look at the code projects on my computer, read each one's README and recent activity, and write me one short page summarizing what each project is and where it stands. Write only the summary; change nothing.",
|
||||
reason=f"{scan.git_repo_count} code projects on your machine.",
|
||||
))
|
||||
return out
|
||||
|
||||
|
||||
@typechecked
|
||||
def p_scan_grounded_fallback(request: PrepRequest) -> PrepResponse:
|
||||
"""When the aux call can't be made (a sole gemini/codex lane returns empty on 0.3.60, provider
|
||||
@@ -300,26 +341,22 @@ def p_scan_grounded_fallback(request: PrepRequest) -> PrepResponse:
|
||||
scan = request.scan
|
||||
if scan is None:
|
||||
return PrepResponse(greeting="", starters=list(FALLBACK_STARTERS))
|
||||
downloads = next((f for f in scan.folders if f.name == "Downloads" and f.entry_count > 0), None)
|
||||
apps = scan.signal_apps[:3]
|
||||
starters = p_scan_grounded_starters(scan)
|
||||
# Backfill from the generic list ONLY to reach four, and only when the machine was too sparse to
|
||||
# ground three real ones. On a normal Mac all four come from the scan, so 3-of-4 stays tailored.
|
||||
for s in FALLBACK_STARTERS:
|
||||
if len(starters) >= 4:
|
||||
break
|
||||
if all(s.title != existing.title for existing in starters):
|
||||
starters.append(s)
|
||||
downloads = next((f for f in scan.folders if f.name == "Downloads" and f.entry_count > 0), None)
|
||||
bits: List[str] = []
|
||||
if downloads:
|
||||
bits.append(f"{downloads.entry_count} files in Downloads")
|
||||
if apps:
|
||||
bits.append(", ".join(apps))
|
||||
greeting = f"I took a look around your Mac: {'; '.join(bits)}. Here is where I would start." if bits else ""
|
||||
starters: List[PersonalizedStarter] = []
|
||||
if downloads:
|
||||
starters.append(PersonalizedStarter(
|
||||
title="Audit Downloads",
|
||||
prompt=f"Scan my Downloads folder ({downloads.entry_count} files) and produce one report grouping files by type with cleanup suggestions. Do not move or delete anything; write only the report.",
|
||||
reason=f"Downloads has {downloads.entry_count} files worth sorting.",
|
||||
))
|
||||
for s in FALLBACK_STARTERS:
|
||||
if len(starters) >= 4:
|
||||
break
|
||||
if all(s.title != existing.title for existing in starters):
|
||||
starters.append(s)
|
||||
# Ground the research card on their top tool so the "looked into this" card still appears cross-provider.
|
||||
research_title = ""
|
||||
research_prompt = ""
|
||||
|
||||
@@ -1,56 +1,22 @@
|
||||
import { useCallback, useRef, useState } from 'react';
|
||||
import { useAppDispatch, useAppSelector } from '@/shared/hooks';
|
||||
import { updateSettingsPatch, type PersonalizedAutomation } from '@/shared/state/settingsSlice';
|
||||
import { createDraftSession, launchAndSendFirstMessage, type AgentConfig } from '@/shared/state/agentsSlice';
|
||||
import { createWorkflow } from '@/shared/state/workflowsSlice';
|
||||
import { hasModelConnected } from '@/app/components/Onboarding/steps/skipPredicates';
|
||||
import { getLastDashboardId } from '@/shared/lastDashboardId';
|
||||
import { setFlowActive, stageReveal, addPreppedJob } from '@/shared/state/onboardingV3Slice';
|
||||
import { useAppDispatch } from '@/shared/hooks';
|
||||
import { updateSettingsPatch } from '@/shared/state/settingsSlice';
|
||||
import { setFlowActive, stageReveal } from '@/shared/state/onboardingV3Slice';
|
||||
import { useThemeAccent, useThemeMode } from '@/shared/styles/ThemeContext';
|
||||
import {
|
||||
fetchIdentity, runPrep, runScan, summarizeScan,
|
||||
type PrepResponse, type ProviderIdentity, type ScanResult,
|
||||
} from './onboardingV3Api';
|
||||
import { summarizeUsage, type ProviderUsage, type UsageProvider } from '@/shared/providerUsage';
|
||||
import type { ModelOption } from '@/shared/state/modelsSlice';
|
||||
|
||||
// The auto-launched onboarding jobs must ride the CHEAP tier, not the user's premium default; running two Sonnet/Opus agents unprompted on first launch would burn real quota. Pick the lowest-intelligence (cheapest) model in the default's provider group, so a Claude user's demo runs on Haiku, a ChatGPT user's on mini.
|
||||
function pickCheapModel(byProvider: Record<string, ModelOption[]>, def: string): string {
|
||||
const groups = Object.values(byProvider);
|
||||
const all = groups.flat();
|
||||
if (!all.length) return def;
|
||||
const defGroup = groups.find((g) => g.some((m) => m.value === def));
|
||||
const pool = (defGroup && defGroup.length ? defGroup : all).filter((m) => Array.isArray(m.tiers));
|
||||
if (!pool.length) return def;
|
||||
return [...pool].sort((a, b) => (a.tiers![0]) - (b.tiers![0]))[0].value;
|
||||
}
|
||||
|
||||
// Turn an automation's cadence into a real schedule (9am local): daily = every day, weekday = Mon-Fri,
|
||||
// weekly = Mondays. The first run is always in the future, so the keep/discard toast can cancel it first.
|
||||
function cadenceToSchedule(cadence: string): Record<string, unknown> {
|
||||
const base = { enabled: true, repeat_every: 1, hour: 9, minute: 0, timezone: 'local', ends_at: null, max_runs: null, runs_count: 0 };
|
||||
if (cadence === 'daily') return { ...base, repeat_unit: 'day', on_days: [] };
|
||||
if (cadence === 'weekday') return { ...base, repeat_unit: 'week', on_days: [1, 2, 3, 4, 5] };
|
||||
return { ...base, repeat_unit: 'week', on_days: [1] };
|
||||
}
|
||||
|
||||
// How long finish() will wait for prep before staging the reveal. Prep is kicked early (theme beat) so it
|
||||
// has usually resolved; this only bites a user who outran it, and a brief wait for a COHERENT reveal beats
|
||||
// an instant one showing a previous run's stale greeting. Capped so it's never an open-ended spinner.
|
||||
const PREP_WAIT_CAP_MS = 5000;
|
||||
// Gap between auto-launched reveal jobs so four CLIs + a live app preview don't all boot in the same
|
||||
// frame on the user's first impression; also makes the cards populate one-by-one instead of at once.
|
||||
const JOB_STAGGER_MS = 2500;
|
||||
|
||||
// Used when prep's aux dropped the automations field, so the reveal always demonstrates automation.
|
||||
// A useful digest (not a cleanup chore, which the value bar bans).
|
||||
const SCHEDULE_FALLBACK: PersonalizedAutomation = {
|
||||
title: 'Weekly Roundup',
|
||||
prompt: 'Search the web for the most notable new tools, articles, and releases from this past week in technology and design, and write a short, skimmable roundup to a dated file at Documents/weekly_roundup_<date>.md. Do it in one pass with no questions.',
|
||||
cadence: 'weekly',
|
||||
};
|
||||
|
||||
// The curtain machinery: scan kicks off during the OAuth wait, prep during the theme beat, and the moment prep resolves the audit AND the app build launch as REAL background agents, so the curtain lifts on work already in motion. Every stage fails soft; the flow never blocks on any of it.
|
||||
// The curtain machinery: scan kicks off during the OAuth wait, prep during the theme beat. No agents or
|
||||
// apps auto-spawn anymore, the reveal lands INSTANTLY on a clean welcome chat whose greeting + starters
|
||||
// are personalized from prep, and the user picks what to run first. Every stage fails soft; nothing blocks.
|
||||
export function useOnboardingV3Pipeline() {
|
||||
const dispatch = useAppDispatch();
|
||||
const { accent, gradient } = useThemeAccent();
|
||||
@@ -60,24 +26,12 @@ export function useOnboardingV3Pipeline() {
|
||||
const scanRef = useRef<Promise<ScanResult | null> | null>(null);
|
||||
const prepRef = useRef<Promise<PrepResponse | null> | null>(null);
|
||||
// The resolved prep, readable SYNCHRONOUSLY at finish() time: lets the reveal seed with the real
|
||||
// jobs/greeting the instant they're ready (the common case, prep finishes during the beats) without
|
||||
// greeting/starters the instant they're ready (the common case, prep finishes during the beats) without
|
||||
// awaiting, so the curtain never blocks behind a spinner.
|
||||
const prepReadyRef = useRef<PrepResponse | null>(null);
|
||||
const scanResultRef = useRef<ScanResult | null>(null);
|
||||
const usageSummaryRef = useRef<string>('');
|
||||
const usageReadRef = useRef<Promise<void> | null>(null);
|
||||
const connected = useAppSelector((s) => hasModelConnected(s));
|
||||
const cheapModel = useAppSelector((s) => pickCheapModel(s.models.byProvider, s.settings.data.default_model));
|
||||
const launchCtxRef = useRef({ connected: false, model: 'sonnet' });
|
||||
launchCtxRef.current = { connected, model: cheapModel };
|
||||
const launchedRef = useRef(false);
|
||||
// Dev replay (osw_force_onboarding): show the whole flow WITHOUT spawning 4 real agents + a scheduled
|
||||
// workflow onto the tester's live dashboard. The flag is stripped in prod, so a real first-run (empty
|
||||
// machine) always gets its wow jobs; only a QA replay on an existing dashboard skips the clutter+lag.
|
||||
const isReplay = ((): boolean => {
|
||||
if (process.env.NODE_ENV === 'production') return false;
|
||||
try { return localStorage.getItem('osw_force_onboarding') === '1'; } catch { return false; }
|
||||
})();
|
||||
|
||||
const kickIdentity = useCallback(() => {
|
||||
fetchIdentity().then((ids) => { identityRef.current = ids; setIdentity(ids); }).catch(() => {});
|
||||
@@ -106,45 +60,6 @@ export function useOnboardingV3Pipeline() {
|
||||
: Promise.resolve(null);
|
||||
}, []);
|
||||
|
||||
// Fire one real background agent; the session exists in redux without a card until the reveal composes the canvas.
|
||||
const launchJob = useCallback((title: string, prompt: string, kind: 'app' | 'research' | 'browser', reason: string) => {
|
||||
const { model: liveModel } = launchCtxRef.current;
|
||||
const dashboardId = getLastDashboardId() ?? undefined;
|
||||
// All three run with full tools: the app build writes its own workspace, research + the browser task
|
||||
// save their findings. The browser task is kept safe by its PROMPT (public pages, never log in/buy).
|
||||
const config: AgentConfig = { name: title, model: liveModel, mode: 'agent', dashboard_id: dashboardId };
|
||||
const draftId = dispatch(createDraftSession({ mode: 'agent', model: liveModel, dashboardId: dashboardId ?? '', setActive: false })).payload.draftId;
|
||||
// Reveal cards open ENLARGED so the user sees the real work (not tiny collapsed stubs), and the
|
||||
// yellow minimize button then has something to collapse. The seeder stacks them at expanded height.
|
||||
void dispatch(launchAndSendFirstMessage({ draftId, config, prompt, mode: 'agent', model: liveModel, expand: true }))
|
||||
.then((action) => {
|
||||
if (launchAndSendFirstMessage.fulfilled.match(action)) {
|
||||
dispatch(addPreppedJob({ sessionId: action.payload.session.id, title, kind, reason }));
|
||||
}
|
||||
})
|
||||
.catch(() => {});
|
||||
}, [dispatch]);
|
||||
|
||||
// Set up ONE real scheduled task from the automations, so the reveal shows OpenSwarm having already
|
||||
// automated something for the user, not just run one-off jobs. It's a real workflow on a real schedule
|
||||
// (first run in the future); the keep/discard toast can delete it. Rides the cheap model like the jobs.
|
||||
const createScheduledJob = useCallback((auto: PersonalizedAutomation) => {
|
||||
const { model: liveModel } = launchCtxRef.current;
|
||||
const dashboardId = getLastDashboardId() ?? undefined;
|
||||
void dispatch(createWorkflow({
|
||||
title: auto.title,
|
||||
description: 'Set up for you during onboarding.',
|
||||
steps: [{ id: `step-${Date.now().toString(36)}`, text: auto.prompt, enabled: true }],
|
||||
schedule: cadenceToSchedule(auto.cadence) as never,
|
||||
dashboard_id: dashboardId,
|
||||
model: liveModel,
|
||||
})).unwrap()
|
||||
.then((wf) => {
|
||||
dispatch(addPreppedJob({ sessionId: '', workflowId: wf.id, title: auto.title, kind: 'schedule', reason: `runs ${auto.cadence} on its own` }));
|
||||
})
|
||||
.catch(() => {});
|
||||
}, [dispatch]);
|
||||
|
||||
const kickPrep = useCallback((pickedApps: string[]) => {
|
||||
if (prepRef.current) return;
|
||||
const scanPromise = scanRef.current ?? Promise.resolve(null);
|
||||
@@ -152,30 +67,9 @@ export function useOnboardingV3Pipeline() {
|
||||
prepRef.current = Promise.all([scanPromise, usagePromise])
|
||||
.then(([scan]) => runPrep(scan, pickedApps, identityRef.current, usageSummaryRef.current))
|
||||
.catch(() => null);
|
||||
// Launch the prepped work MID-FLOW (theme/card beats cover the latency): audit + app build, gated to a real connected model so the fragile free trial never carries it.
|
||||
void prepRef.current.then((prep) => {
|
||||
prepReadyRef.current = prep;
|
||||
if (launchedRef.current || !prep || !prep.greeting || !launchCtxRef.current.connected) return;
|
||||
launchedRef.current = true;
|
||||
// QA replay: run the flow, but never seed real jobs/schedules onto the tester's working canvas.
|
||||
if (isReplay) return;
|
||||
// The four auto-run showcase jobs, one per capability: build an app, dig the web, drive a real
|
||||
// browser, and set up a scheduled task. STAGGERED, not all-at-once: firing four CLIs + a live app
|
||||
// preview in the same instant spiked the render on the user's very first impression. Spacing them
|
||||
// ~2.5s apart spreads the load AND reads better, cards populate one-by-one ("watch it work") instead
|
||||
// of lurching in together. Each still fires independently (own draft, own async launch, own errors);
|
||||
// the app goes first so its build (the slowest) gets a head start.
|
||||
const staggered: Array<() => void> = [];
|
||||
if (prep.app_title && prep.app_prompt) staggered.push(() => launchJob(prep.app_title!, prep.app_prompt!, 'app', prep.app_reason ?? ''));
|
||||
if (prep.research_title && prep.research_prompt) staggered.push(() => launchJob(prep.research_title!, prep.research_prompt!, 'research', prep.research_reason ?? ''));
|
||||
if (prep.browser_title && prep.browser_prompt) staggered.push(() => launchJob(prep.browser_title!, prep.browser_prompt!, 'browser', prep.browser_reason ?? ''));
|
||||
// The scheduled task is a first-class part of the reveal (the "it automates for me" capability), so
|
||||
// guarantee one: use the model's automation when it emitted one (it sometimes drops the last JSON
|
||||
// field), else fall back to a safe, universally-useful weekly roundup.
|
||||
staggered.push(() => createScheduledJob(prep.automations[0] ?? SCHEDULE_FALLBACK));
|
||||
staggered.forEach((fire, i) => { if (i === 0) fire(); else window.setTimeout(fire, i * JOB_STAGGER_MS); });
|
||||
});
|
||||
}, [launchJob, createScheduledJob, isReplay]);
|
||||
// Cache the resolved prep so finish() can stage the reveal the instant it's ready, no await.
|
||||
void prepRef.current.then((prep) => { prepReadyRef.current = prep; });
|
||||
}, []);
|
||||
|
||||
const finish = useCallback(async (outcome: 'done' | 'skipped') => {
|
||||
if (outcome === 'skipped') {
|
||||
@@ -185,9 +79,9 @@ export function useOnboardingV3Pipeline() {
|
||||
}
|
||||
dispatch(updateSettingsPatch({ onboarding_v3: 'done', accent_color: accent, accent_gradient: gradient, theme: mode }));
|
||||
// Wait for prep (kicked early during the beats, usually already resolved) so the welcome greeting +
|
||||
// starters are from the SAME prep as the launched jobs. A previous run's greeting persists in settings,
|
||||
// so staging the reveal before this patch landed showed a stale, mismatched greeting; persist FIRST,
|
||||
// then stage. Capped so a user who outran prep waits a beat, never an open-ended spinner.
|
||||
// starters are coherent. A previous run's greeting persists in settings, so staging the reveal before
|
||||
// this patch landed showed a stale greeting; persist FIRST, then stage. Capped so a user who outran
|
||||
// prep waits a beat, never an open-ended spinner.
|
||||
let prep = prepReadyRef.current;
|
||||
if (!prep && prepRef.current) {
|
||||
prep = (await Promise.race([
|
||||
|
||||
@@ -1609,7 +1609,8 @@ const AgentChat: React.FC<AgentChatProps> = ({ sessionId: sessionIdProp, onClose
|
||||
'&:hover': { scrollbarColor: `${c.border.medium} transparent` },
|
||||
}}
|
||||
>
|
||||
<Box>
|
||||
{/* The welcome greeting is the FIRST thing and it's the agent talking, no user message above it, so give it real air under the header instead of sitting flush at the top. */}
|
||||
<Box sx={{ pt: session.is_welcome_draft ? 4 : 0 }}>
|
||||
{session.context_overflow && (() => {
|
||||
const reason = session.context_overflow.reason;
|
||||
const isAuth = reason === 'openswarm_pro_auth_expired' || reason === 'anthropic_auth_invalid' || reason === 'auth_error';
|
||||
|
||||
@@ -2,12 +2,35 @@ 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 { ArrowLeft, Sparkles } from 'lucide-react';
|
||||
import { useAppSelector } from '@/shared/hooks';
|
||||
import type { ClaudeTokens } from '@/shared/styles/claudeTokens';
|
||||
import { STARTER_CATEGORIES } from '@/shared/starterCategories';
|
||||
import AutomationChips from '@/app/components/nudges/AutomationChips';
|
||||
|
||||
// The always-present 4th option for anyone who doesn't see themselves in the 3 tailored starters: a warm,
|
||||
// no-personalization-needed "just start talking" that asks OpenSwarm to show what it can do on their machine.
|
||||
const ANCHOR_PROMPT = "I'm new to OpenSwarm. Show me a few concrete things you could do for me right now on my computer, then pick the single most useful one and actually do it as a quick demo.";
|
||||
|
||||
const AnchorButton: React.FC<{ c: ClaudeTokens; onPick: (p: string) => void; delay?: number }> = ({ c, onPick, delay = 0 }) => (
|
||||
<motion.button
|
||||
onClick={() => onPick(ANCHOR_PROMPT)}
|
||||
initial={{ opacity: 0, scale: 0.92 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
transition={{ type: 'spring', stiffness: 420, damping: 26, delay }}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', gap: 8, textAlign: 'left', width: '100%',
|
||||
padding: '10px 14px', borderRadius: 11,
|
||||
border: `1px solid ${c.border.medium}`, background: c.bg.surface,
|
||||
color: c.text.secondary, fontSize: '0.88rem', fontWeight: 500,
|
||||
cursor: 'pointer', fontFamily: 'inherit',
|
||||
}}
|
||||
>
|
||||
<Sparkles size={15} color={c.accent.primary} style={{ flexShrink: 0 }} />
|
||||
Not sure? Show me what you can do
|
||||
</motion.button>
|
||||
);
|
||||
|
||||
// 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;
|
||||
@@ -15,11 +38,12 @@ const WelcomeQuickReplies: React.FC<{
|
||||
onPickBuilder: (prompt: string) => void;
|
||||
}> = ({ c, onPick, onPickBuilder }) => {
|
||||
const [expanded, setExpanded] = React.useState<string | null>(null);
|
||||
// Onboarding v3's prep wrote starters about THIS user's machine and apps; they lead, generic categories demote to "More ideas".
|
||||
const personalized = useAppSelector((s) => s.settings.data.personalized_starters ?? []);
|
||||
// Onboarding v3's prep wrote starters about THIS user's machine and apps; they lead, generic categories demote to "More ideas". Show the top 3 tailored + the always-present anchor = 4 options (prep returns 4, so we keep the strongest three and let the anchor be the fourth).
|
||||
const personalized = useAppSelector((s) => (s.settings.data.personalized_starters ?? []).slice(0, 3));
|
||||
const [showCategories, setShowCategories] = React.useState(personalized.length === 0);
|
||||
const currentCategory = STARTER_CATEGORIES.find((cat) => cat.id === expanded);
|
||||
const isAppBuilder = currentCategory?.target === 'app-builder';
|
||||
const isSchedule = currentCategory?.target === 'schedule';
|
||||
const currentPrompts = currentCategory?.prompts ?? [];
|
||||
|
||||
const pick = (prompt: string) => {
|
||||
@@ -59,6 +83,7 @@ const WelcomeQuickReplies: React.FC<{
|
||||
{s.title}
|
||||
</motion.button>
|
||||
))}
|
||||
<AnchorButton c={c} onPick={onPick} delay={0.08 + personalized.length * 0.06} />
|
||||
</Box>
|
||||
<Typography sx={{ color: c.text.ghost, fontSize: '0.78rem', mt: 1.4, mb: 0.6 }}>
|
||||
worth putting on a schedule
|
||||
@@ -123,6 +148,11 @@ const WelcomeQuickReplies: React.FC<{
|
||||
</motion.button>
|
||||
))}
|
||||
</Box>
|
||||
{personalized.length === 0 && (
|
||||
<Box sx={{ mt: 1 }}>
|
||||
<AnchorButton c={c} onPick={onPick} delay={0.08 + STARTER_CATEGORIES.length * 0.07} />
|
||||
</Box>
|
||||
)}
|
||||
</motion.div>
|
||||
) : (
|
||||
<motion.div
|
||||
@@ -147,6 +177,14 @@ const WelcomeQuickReplies: React.FC<{
|
||||
>
|
||||
<ArrowLeft size={14} /> back
|
||||
</Box>
|
||||
{isSchedule ? (
|
||||
<>
|
||||
<Typography sx={{ color: c.text.ghost, fontSize: '0.82rem', mb: 1.1 }}>
|
||||
one click puts it on a schedule
|
||||
</Typography>
|
||||
<AutomationChips c={c} />
|
||||
</>
|
||||
) : (
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.7 }}>
|
||||
{currentPrompts.map((prompt, i) => (
|
||||
<motion.button
|
||||
@@ -170,6 +208,7 @@ const WelcomeQuickReplies: React.FC<{
|
||||
</motion.button>
|
||||
))}
|
||||
</Box>
|
||||
)}
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
@@ -2,7 +2,6 @@ import { useEffect, useRef, useState, type MutableRefObject } from 'react';
|
||||
import { report } from '@/shared/serviceClient';
|
||||
import { store } from '@/shared/state/store';
|
||||
import { useAppDispatch, useAppSelector } from '@/shared/hooks';
|
||||
import { revealAppSpot } from './useOnboardingRevealSeed';
|
||||
import {
|
||||
fetchSessions,
|
||||
fetchHistory,
|
||||
@@ -338,28 +337,14 @@ export function useDashboardLifecycle({
|
||||
if (sess.dashboard_id !== dashboardId) continue;
|
||||
autoOpenedOutputsRef.current.add(output.id);
|
||||
if (viewCards[output.id]) continue;
|
||||
// The onboarding reveal's app is born at its composed arc-end spot (right of the note), not in
|
||||
// the parent's column, so no post-hoc move ever races layout persistence. One-shot per reveal.
|
||||
const v3 = store.getState().onboardingV3;
|
||||
const revealSpot = (v3.revealAnchor && v3.prepped.some((j) => j.kind === 'app' && j.sessionId === sid))
|
||||
? revealAppSpot(v3.revealAnchor)
|
||||
: null;
|
||||
if (revealSpot) {
|
||||
// Reveal: born as a light "click to open" card so the curtain lifts instantly, not behind a live Vite boot.
|
||||
dispatch(addViewCard({ outputId: output.id, expandedSessionIds, x: revealSpot.x, y: revealSpot.y, previewDeferred: true }));
|
||||
} else {
|
||||
dispatch(addViewCard({ outputId: output.id, expandedSessionIds, parentSessionId: sid }));
|
||||
}
|
||||
dispatch(addViewCard({ outputId: output.id, expandedSessionIds, parentSessionId: sid }));
|
||||
const outputId = output.id;
|
||||
setTimeout(() => {
|
||||
const vc = store.getState().dashboardLayout.viewCards[outputId];
|
||||
if (!vc) return;
|
||||
const rects = [{ x: vc.x, y: vc.y, width: vc.width, height: vc.height }];
|
||||
// Reveal spot: frame just the app (the note edges into frame on its left); otherwise include the parent chat.
|
||||
if (!revealSpot) {
|
||||
const ac = store.getState().dashboardLayout.cards[sid];
|
||||
if (ac) rects.push({ x: ac.x, y: ac.y, width: ac.width, height: ac.height });
|
||||
}
|
||||
const ac = store.getState().dashboardLayout.cards[sid];
|
||||
if (ac) rects.push({ x: ac.x, y: ac.y, width: ac.width, height: ac.height });
|
||||
canvasActions.revealCards(rects);
|
||||
handleHighlightCard(outputId);
|
||||
}, 200);
|
||||
|
||||
@@ -1,88 +1,27 @@
|
||||
import { useCallback, useEffect, useRef, type RefObject } from 'react';
|
||||
import { useEffect, useRef, type RefObject } from 'react';
|
||||
import { useAppDispatch, useAppSelector } from '@/shared/hooks';
|
||||
import { store } from '@/shared/state/store';
|
||||
import {
|
||||
placeCard, openWorkflowsApp, setWorkflowsHubPosition, setWorkflowsHubSize,
|
||||
clearPendingFocusWorkflowsHub, setViewCardPosition,
|
||||
DEFAULT_CARD_W, EXPANDED_CARD_MIN_H,
|
||||
} from '@/shared/state/dashboardLayoutSlice';
|
||||
import { clearReveal, setRevealAnchor } from '@/shared/state/onboardingV3Slice';
|
||||
import { DEFAULT_CARD_W, EXPANDED_CARD_MIN_H } from '@/shared/state/dashboardLayoutSlice';
|
||||
import { clearReveal } from '@/shared/state/onboardingV3Slice';
|
||||
|
||||
interface Args {
|
||||
isActive: boolean;
|
||||
dashboardId: string;
|
||||
expandedSessionIds: string[];
|
||||
viewportRef: RefObject<HTMLDivElement | null>;
|
||||
canvasStateRef: RefObject<{ panX: number; panY: number; zoom: number }>;
|
||||
createWelcomeDraft: () => void;
|
||||
fitToCards: (rects: Array<{ x: number; y: number; width: number; height: number }>, maxZoom?: number, animate?: boolean, minZoom?: number, centered?: boolean) => void;
|
||||
}
|
||||
|
||||
const GAP = 48;
|
||||
// The scheduled task opens the FULL Workflows app (rich detail view), sized to span the 2-column agent
|
||||
// grid below it so the default 1280x800 hub doesn't dominate the reveal.
|
||||
const REVEAL_WORKFLOW_W = 2 * DEFAULT_CARD_W + GAP;
|
||||
const REVEAL_WORKFLOW_H = 560;
|
||||
|
||||
/** Where the reveal's app view card is born: right of the welcome chat, top-aligned. The "here's what I did" legend is the fixed RevealHero panel, not a canvas note, so the app sits right next to the chat. */
|
||||
export function revealAppSpot(anchor: { cx: number; cy: number }): { x: number; y: number } {
|
||||
return { x: anchor.cx + DEFAULT_CARD_W / 2 + GAP, y: anchor.cy - EXPANDED_CARD_MIN_H / 2 };
|
||||
}
|
||||
|
||||
// The reveal: onboarding v3 finished behind the curtain and the prepped work (a personal dashboard app, a live web-research dig, a read-only file tidy-up, and one recurring task) has been running since mid-flow. Compose: welcome chat center, job grid + Workflows app to its left, app view card born to its right (via revealAnchor + the lifecycle auto-add). The camera lands on JUST the chat + app slot at readable zoom; the rest peeks in from the left for the user to click into. The keep/discard toast owns the jobs' fate afterward.
|
||||
export function useOnboardingRevealSeed({ isActive, dashboardId, expandedSessionIds, viewportRef, canvasStateRef, createWelcomeDraft, fitToCards }: Args): void {
|
||||
// The reveal: onboarding v3 finished behind the curtain and the flow lands INSTANTLY on a single clean
|
||||
// welcome chat (no auto-spawned agents or app preview, that lag is gone). Its greeting + starters are
|
||||
// personalized from prep; the user picks what to run first. We just seed the chat and frame the camera on
|
||||
// it at readable zoom.
|
||||
export function useOnboardingRevealSeed({ isActive, dashboardId, viewportRef, canvasStateRef, createWelcomeDraft, fitToCards }: Args): void {
|
||||
const dispatch = useAppDispatch();
|
||||
const revealPending = useAppSelector((s) => s.onboardingV3.revealPending);
|
||||
const prepped = useAppSelector((s) => s.onboardingV3.prepped);
|
||||
const settingsLoaded = useAppSelector((s) => s.settings.loaded);
|
||||
const seededRef = useRef(false);
|
||||
// Jobs launch async at prep-resolve, so some land in `prepped` a beat AFTER the curtain lifts. The
|
||||
// anchor + placed-set let us keep dropping those cards in the same left stack instead of losing them.
|
||||
const anchorRef = useRef<{ cx: number; cy: number } | null>(null);
|
||||
const placedRef = useRef<Set<string>>(new Set());
|
||||
// Only the agent-card jobs flow into the 2-column grid; the schedule gets its own wide slot below, so
|
||||
// count agent cards separately or the schedule would leave a hole in the grid rhythm.
|
||||
const agentCountRef = useRef(0);
|
||||
|
||||
const placeJobs = useCallback(() => {
|
||||
const a = anchorRef.current;
|
||||
if (!a) return;
|
||||
// Grid geometry, shared by placement here and the schedule's below-grid slot.
|
||||
const gridLeftX = a.cx - DEFAULT_CARD_W / 2 - 2 * GAP - 2 * DEFAULT_CARD_W;
|
||||
const gridTopY = a.cy - EXPANDED_CARD_MIN_H / 2;
|
||||
prepped.forEach((job) => {
|
||||
// Every job shows its AGENT card (its live transcript), including the app builder, so the reveal
|
||||
// makes it obvious the agent is BUILDING the app, not just a "building..." box. Its finished app
|
||||
// view card appears beside it when it renders (birth-position path in useDashboardLifecycle).
|
||||
const key = job.workflowId || job.sessionId;
|
||||
if (placedRef.current.has(key)) return;
|
||||
if (job.kind === 'schedule' && job.workflowId) {
|
||||
// The scheduled task opens the FULL Workflows app (its rich detail view: schedule + steps), not
|
||||
// the compact run-monitor card, so the reveal shows off the real automation GUI. It sits in a wide
|
||||
// slot below the agent grid. Opening the app normally snaps the camera to the hub (a fitToCards on
|
||||
// pendingFocusWorkflowsHub); clear that flag so the reveal's own framing wins.
|
||||
const sy = gridTopY + 2 * (EXPANDED_CARD_MIN_H + 24);
|
||||
dispatch(openWorkflowsApp({ workflowId: job.workflowId, expandedSessionIds }));
|
||||
dispatch(clearPendingFocusWorkflowsHub());
|
||||
dispatch(setWorkflowsHubSize({ width: REVEAL_WORKFLOW_W, height: REVEAL_WORKFLOW_H }));
|
||||
dispatch(setWorkflowsHubPosition({ x: gridLeftX, y: sy }));
|
||||
} else {
|
||||
// 2-column grid to the LEFT of the welcome chat. A single tall column (enlarged cards stacked)
|
||||
// forced the camera so far out the cards went unreadable; wide-and-short frames at a legible zoom.
|
||||
const gi = agentCountRef.current;
|
||||
agentCountRef.current += 1;
|
||||
const col = gi % 2;
|
||||
const row = Math.floor(gi / 2);
|
||||
const x = gridLeftX + col * (DEFAULT_CARD_W + GAP);
|
||||
const y = gridTopY + row * (EXPANDED_CARD_MIN_H + 24);
|
||||
dispatch(placeCard({ sessionId: job.sessionId, x, y, width: DEFAULT_CARD_W, height: EXPANDED_CARD_MIN_H, expandedSessionIds, exact: true }));
|
||||
}
|
||||
placedRef.current.add(key);
|
||||
});
|
||||
}, [prepped, dispatch, expandedSessionIds]);
|
||||
|
||||
// One-time: fix the anchor, seed the welcome chat + the "head start" note, place jobs present so far,
|
||||
// then frame the camera on the whole cluster so the curtain lifts onto a composed, readable scene.
|
||||
useEffect(() => {
|
||||
if (!revealPending || seededRef.current || !isActive || !settingsLoaded) return;
|
||||
seededRef.current = true;
|
||||
@@ -93,38 +32,16 @@ export function useOnboardingRevealSeed({ isActive, dashboardId, expandedSession
|
||||
const vr = vp.getBoundingClientRect();
|
||||
const cx = (vr.width / 2 - cs.panX) / cs.zoom;
|
||||
const cy = (vr.height / 2 - cs.panY) / cs.zoom;
|
||||
anchorRef.current = { cx, cy };
|
||||
dispatch(setRevealAnchor({ cx, cy }));
|
||||
const app = prepped.find((j) => j.kind === 'app');
|
||||
// The "here's what I did" legend is the fixed RevealHero panel (top-center, unmissable, live
|
||||
// status), not a canvas note, so there is no wall-of-text sticky to miss here anymore.
|
||||
placeJobs();
|
||||
// The app agent often creates its output BEFORE the curtain lifts (it gets a head start at
|
||||
// connect), so its view card was auto-added with no anchor to stage against. Move it to the
|
||||
// arc-end spot now; the birth-position path in useDashboardLifecycle covers late arrivals.
|
||||
if (app?.sessionId) {
|
||||
const now = store.getState();
|
||||
const out = Object.values(now.outputs.items).find((o) => o.session_id === app.sessionId);
|
||||
if (out && now.dashboardLayout.viewCards[out.id]) {
|
||||
const spot = revealAppSpot({ cx, cy });
|
||||
dispatch(setViewCardPosition({ outputId: out.id, x: spot.x, y: spot.y }));
|
||||
}
|
||||
}
|
||||
createWelcomeDraft();
|
||||
// Land READABLE: frame only the welcome chat + the app slot beside it. Fitting the whole
|
||||
// cluster (grid + Workflows row, ~2000x1850 canvas) forced ~25% zoom: text unreadable AND
|
||||
// every card on-screen at once (nothing can suspend). The job grid edges into the left of
|
||||
// frame as the discovery cue, and the app view card is born inside the frame so its arrival
|
||||
// never yanks the camera; one click on any card still focuses it (fitToCards at 1.15).
|
||||
const frameLeft = cx - DEFAULT_CARD_W / 2;
|
||||
const frameRight = revealAppSpot({ cx, cy }).x + DEFAULT_CARD_W;
|
||||
// Reserve headroom at the top so the chat header clears the macOS traffic lights + the
|
||||
// floating dashboard title pill + the RevealHero panel, instead of landing under them.
|
||||
// Frame the welcome chat, centered. Reserve headroom up top so the chat header clears the macOS
|
||||
// traffic lights + the floating dashboard title pill instead of landing under them.
|
||||
const TOP_CHROME_PAD = 130;
|
||||
fitToCards(
|
||||
[{ x: frameLeft, y: cy - EXPANDED_CARD_MIN_H / 2 - TOP_CHROME_PAD, width: frameRight - frameLeft, height: EXPANDED_CARD_MIN_H + TOP_CHROME_PAD }],
|
||||
[{ x: cx - DEFAULT_CARD_W / 2, y: cy - EXPANDED_CARD_MIN_H / 2 - TOP_CHROME_PAD, width: DEFAULT_CARD_W, height: EXPANDED_CARD_MIN_H + TOP_CHROME_PAD }],
|
||||
0.9,
|
||||
true,
|
||||
undefined,
|
||||
true,
|
||||
);
|
||||
} else {
|
||||
createWelcomeDraft();
|
||||
@@ -132,10 +49,5 @@ export function useOnboardingRevealSeed({ isActive, dashboardId, expandedSession
|
||||
} finally {
|
||||
dispatch(clearReveal());
|
||||
}
|
||||
}, [revealPending, isActive, settingsLoaded, prepped, dashboardId, expandedSessionIds, viewportRef, canvasStateRef, createWelcomeDraft, fitToCards, dispatch, placeJobs]);
|
||||
|
||||
// Jobs that launched after the curtain lifted: drop their cards in as they arrive.
|
||||
useEffect(() => {
|
||||
if (seededRef.current) placeJobs();
|
||||
}, [prepped, placeJobs]);
|
||||
}, [revealPending, isActive, settingsLoaded, dashboardId, viewportRef, canvasStateRef, createWelcomeDraft, fitToCards, dispatch]);
|
||||
}
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import { Search, Hammer, Globe, Plug } from 'lucide-react';
|
||||
import { Search, Hammer, Globe, CalendarClock } 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. All run as a normal agent; the 'build' category (target 'app-builder') just prefills the composer in the welcome chat instead of auto-sending, since the agent builds the app in-place (it calls CreateApp and the live card drops on the canvas).
|
||||
// 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, build real apps, and run recurring routines on a schedule, 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. Labels name the END RESULT, not the mechanism. The 'build' category (target 'app-builder') prefills the composer since the agent builds the app in-place (CreateApp drops a live card); the 'schedule' category (target 'schedule') hands off to the automation chips, which turn a pick into a real scheduled workflow.
|
||||
export type StarterCategory = {
|
||||
id: string;
|
||||
label: string;
|
||||
Icon: LucideIcon;
|
||||
prompts: string[];
|
||||
target?: 'app-builder';
|
||||
target?: 'app-builder' | 'schedule';
|
||||
};
|
||||
|
||||
export const STARTER_CATEGORIES: StarterCategory[] = [
|
||||
@@ -42,13 +42,10 @@ export const STARTER_CATEGORIES: StarterCategory[] = [
|
||||
],
|
||||
},
|
||||
{
|
||||
// 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',
|
||||
],
|
||||
// Recurring routines on a real schedule: the pick hands off to AutomationChips (target 'schedule'),
|
||||
// which shows THIS user's tailored automations and turns a click into a scheduled workflow. Label
|
||||
// names the payoff (a brief waiting for you), not the machinery. No inline prompts: the chips own them.
|
||||
id: 'schedule', label: 'Daily brief', Icon: CalendarClock, target: 'schedule',
|
||||
prompts: [],
|
||||
},
|
||||
];
|
||||
|
||||
Reference in New Issue
Block a user