diff --git a/backend/apps/onboarding/models.py b/backend/apps/onboarding/models.py index 5366ca55..d480bcf3 100644 --- a/backend/apps/onboarding/models.py +++ b/backend/apps/onboarding/models.py @@ -57,6 +57,8 @@ class PrepResponse(BaseModel): # A punchy <=10-word identity hook, read at a glance in the reveal's focal beat (the greeting is the # longer warm read for the chat; the headline is the scannable one-liner most people actually read). headline: str = "" + # 2-4 word identity titles tailored to this user; the Swarm Card leads with these over the static list. + epithets: List[str] = Field(default_factory=list) greeting: str = "" starters: List[PersonalizedStarter] = Field(default_factory=list) app_title: str = "" diff --git a/backend/apps/onboarding/prep.py b/backend/apps/onboarding/prep.py index a3cc32b6..64deb69b 100644 --- a/backend/apps/onboarding/prep.py +++ b/backend/apps/onboarding/prep.py @@ -30,7 +30,7 @@ P_SYSTEM = ( "You write first-run starter tasks for OpenSwarm, a desktop AI agent platform that can " "organize local files, browse the web in a real browser, build small apps, and run agents in parallel. " "Given facts about the user's machine and the apps they picked, respond with STRICT JSON only: " - '{"headline": string, "greeting": string, "starters": [{"title": string, "prompt": string, "reason": string}], "app_title": string, "app_prompt": string, "app_reason": string, "research_title": string, "research_prompt": string, "research_reason": string, "browser_title": string, "browser_prompt": string, "browser_reason": string, "automations": [{"title": string, "prompt": string, "cadence": "daily"|"weekday"|"weekly"}]}. ' + '{"headline": string, "greeting": string, "epithets": [string, string, string], "starters": [{"title": string, "prompt": string, "reason": string}], "app_title": string, "app_prompt": string, "app_reason": string, "research_title": string, "research_prompt": string, "research_reason": string, "browser_title": string, "browser_prompt": string, "browser_reason": string, "automations": [{"title": string, "prompt": string, "cadence": "daily"|"weekday"|"weekly"}]}. ' "First, silently infer a short, confident profile of this user: who they are and what they are working on. " "If usage_summary is present it is the STRONGEST signal (a distilled profile of who this person is and what " "they actually work on, read from their real AI conversations); weight it above everything else. Do NOT let a " @@ -126,6 +126,7 @@ P_SYSTEM = ( "defining trait, no filler, no full sentence, no period. It is read at a glance in big type, so it must NOT be " "a paragraph. Example shapes only (never copy, tailor to THEM): 'OpenSwarm founder who measures everything, " "vertical jump to agent latency' or 'Ships iOS apps, obsessed with the last 5% of polish'. Sharp, not wordy. " + "epithets: exactly 3 short identity titles for this person, 2-4 plain words each (like 'QUIET POWER USER' but THEIRS, drawn from their real work and interests in the profile), confident and warm, never punny, never generic. " "The greeting is one or two warm, punchy sentences that make this person feel INSTANTLY understood, the " "'wait, it actually gets me' hook. Lead with the single most specific true thing about them from the profile " "(their actual project BY NAME, their real craft, the obsession they keep returning to), then add ONE more " @@ -165,6 +166,7 @@ def parse_prep(text: str) -> Optional[PrepResponse]: starters = build_starters(data.get("starters") if isinstance(data.get("starters"), list) else []) automations = p_build_automations(data.get("automations") if isinstance(data.get("automations"), list) else []) headline = str(data.get("headline", "")).strip() + epithets = [strip_dashes(str(x)).strip() for x in (data.get("epithets") or []) if str(x).strip()][:3] greeting = str(data.get("greeting", "")).strip() app_title = str(data.get("app_title", "")).strip() app_prompt = str(data.get("app_prompt", "")).strip() @@ -213,6 +215,7 @@ def parse_prep(text: str) -> Optional[PrepResponse]: return None return PrepResponse( headline=strip_dashes(headline), + epithets=epithets, greeting=strip_dashes(greeting), starters=starters[:4], app_title=strip_dashes(app_title), diff --git a/frontend/src/app/components/OnboardingV3/OnboardingV3Root.tsx b/frontend/src/app/components/OnboardingV3/OnboardingV3Root.tsx index ae43cf39..c0cbc153 100644 --- a/frontend/src/app/components/OnboardingV3/OnboardingV3Root.tsx +++ b/frontend/src/app/components/OnboardingV3/OnboardingV3Root.tsx @@ -208,7 +208,7 @@ const OnboardingV3Root: React.FC = () => { )} {beat === 'apps' && setBeat('connect')} />} {beat === 'theme' && setBeat('card')} onBack={() => setBeat('apps')} />} - {beat === 'card' && { void leaveCard(name); }} onBack={() => setBeat('theme')} />} + {beat === 'card' && { void leaveCard(name); }} onBack={() => setBeat('theme')} />} diff --git a/frontend/src/app/components/OnboardingV3/beats/BeatCard.tsx b/frontend/src/app/components/OnboardingV3/beats/BeatCard.tsx index efe80ff9..2711ebc3 100644 --- a/frontend/src/app/components/OnboardingV3/beats/BeatCard.tsx +++ b/frontend/src/app/components/OnboardingV3/beats/BeatCard.tsx @@ -47,16 +47,24 @@ function readableInk(base: string, c: ClaudeTokens): string { const BeatCard: React.FC<{ c: ClaudeTokens; identity: ProviderIdentity[]; + // Prep-written identity titles for THIS user; the dice leads with these, statics are the floor. + personalizedEpithets?: string[]; onFinish: (name: string | null) => void; onBack: () => void; -}> = ({ c, identity, onFinish, onBack }) => { +}> = ({ c, identity, personalizedEpithets, onFinish, onBack }) => { const { accent, gradient } = useThemeAccent(); const [name, setName] = useState(() => nameFromIdentity(identity)); // finish() can legitimately wait up to PREP_WAIT_CAP_MS on prep; the button must say so, once. const [submitting, setSubmitting] = useState(false); - const seed = useMemo(() => Math.floor(Math.random() * EPITHETS.length), []); + const pool = useMemo(() => { + const personal = (personalizedEpithets ?? []).map((e) => e.toUpperCase()).filter(Boolean); + return personal.length > 0 ? [...personal, ...EPITHETS] : EPITHETS; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + // Personalized titles lead; only a pure-static pool starts on a random one. + const seed = useMemo(() => ((personalizedEpithets ?? []).length > 0 ? 0 : Math.floor(Math.random() * pool.length)), [pool]); const [roll, setRoll] = useState(0); - const epithet = EPITHETS[(seed + roll) % EPITHETS.length]; + const epithet = pool[(seed + roll) % pool.length]; const today = useMemo(() => new Date().toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' }), []); const [tilt, setTilt] = useState<{ rx: number; ry: number; mx: number; my: number } | null>(null); const [copied, setCopied] = useState(false); diff --git a/frontend/src/app/components/OnboardingV3/onboardingV3Api.ts b/frontend/src/app/components/OnboardingV3/onboardingV3Api.ts index 3df7bdcf..174b30bb 100644 --- a/frontend/src/app/components/OnboardingV3/onboardingV3Api.ts +++ b/frontend/src/app/components/OnboardingV3/onboardingV3Api.ts @@ -25,6 +25,7 @@ export interface ScanResult { export interface PrepResponse { headline: string; + epithets?: string[]; greeting: string; starters: PersonalizedStarter[]; app_title: string; diff --git a/frontend/src/app/components/OnboardingV3/useOnboardingV3Pipeline.ts b/frontend/src/app/components/OnboardingV3/useOnboardingV3Pipeline.ts index c6feec93..df7da7aa 100644 --- a/frontend/src/app/components/OnboardingV3/useOnboardingV3Pipeline.ts +++ b/frontend/src/app/components/OnboardingV3/useOnboardingV3Pipeline.ts @@ -118,5 +118,8 @@ export function useOnboardingV3Pipeline() { dispatch(setFlowActive(false)); }, [dispatch, accent, gradient, mode]); - return { identity, kickIdentity, kickScan, kickUsageRead, kickPrep, finish }; + // Read synchronously at card-beat time: prep has usually resolved by the last beat. + const getPrepEpithets = useCallback((): string[] => prepReadyRef.current?.epithets ?? [], []); + + return { identity, kickIdentity, kickScan, kickUsageRead, kickPrep, finish, getPrepEpithets }; } diff --git a/frontend/src/app/pages/Dashboard/cards/AgentCard.tsx b/frontend/src/app/pages/Dashboard/cards/AgentCard.tsx index e66e8383..2fc57fe7 100644 --- a/frontend/src/app/pages/Dashboard/cards/AgentCard.tsx +++ b/frontend/src/app/pages/Dashboard/cards/AgentCard.tsx @@ -906,6 +906,8 @@ const AgentCard: React.FC = ({ border: isFullscreen ? 'none' : isSelected ? '2px solid #3b82f6' : '1px solid rgba(255,255,255,0.08)', borderRadius: tiledStyle ? '12px' : '20px', boxShadow: '0 18px 48px rgba(0,0,0,0.4)', + // The hover header floats ABOVE the card; the root must not clip it (the chat body clips itself). + ...(tiledStyle ? {} : { overflow: 'visible' }), }), }} > @@ -1059,7 +1061,7 @@ const AgentCard: React.FC = ({ onPointerDown={(e) => e.stopPropagation()} sx={{ display: 'flex', alignItems: 'center', mr: 0.75, flexShrink: 0 }} > - handleRemove()} onMinimize={onMinimize} onTile={onTile} tiled={!!tileZone} fullscreen={isFullscreen} /> + handleRemove()} onMinimize={onMinimize} onTile={onTile} tiled={!!tileZone} /> = ({ onMinimize={handleMinimize} onTile={onTile} tiled={!!tileZone} - fullscreen={tileZone === 'fullscreen'} /> = ({ }} > e.stopPropagation()} sx={{ display: 'flex', alignItems: 'center', flexShrink: 0, mr: 0.25 }}> - handleRemove()} onMinimize={onMinimize} onTile={onTile} tiled={!!tileZone} fullscreen={tileZone === 'fullscreen'} /> + handleRemove()} onMinimize={onMinimize} onTile={onTile} tiled={!!tileZone} /> {!isMinimized && } = ({ }} > e.stopPropagation()} sx={{ display: 'flex', alignItems: 'center' }}> - handleRemove()} onMinimize={onMinimize} onTile={onTile} tiled={!!tileZone} fullscreen={tileZone === 'fullscreen'} /> + handleRemove()} onMinimize={onMinimize} onTile={onTile} tiled={!!tileZone} /> {isMinimized && ( diff --git a/frontend/src/app/pages/Dashboard/cards/WindowControls.tsx b/frontend/src/app/pages/Dashboard/cards/WindowControls.tsx index 515b4d1b..59fad7d8 100644 --- a/frontend/src/app/pages/Dashboard/cards/WindowControls.tsx +++ b/frontend/src/app/pages/Dashboard/cards/WindowControls.tsx @@ -1,6 +1,5 @@ import React, { useRef, useState } from 'react'; import Box from '@mui/material/Box'; -import FullscreenExitRoundedIcon from '@mui/icons-material/FullscreenExitRounded'; import { TILE_ZONES } from './tileZones'; interface WindowControlsProps { @@ -8,9 +7,6 @@ interface WindowControlsProps { onMinimize: () => void; onTile: (zone: string) => void; // a TILE_ZONES key, or 'restore' tiled?: boolean; - // Full size view: the native macOS lights sit right above this corner, so a second dot cluster - // reads as double chrome; collapse to ONE exit control and let the natives own window ops. - fullscreen?: boolean; // Green = direct fullscreen toggle, no Fill/Halves/Quarters submenu (for surfaces that only // support fullscreen, like the Workflows window, where half-tiling has nowhere to land). noTileMenu?: boolean; @@ -57,7 +53,7 @@ export const ARC_CHIP_SX: Record = { '.osw-pill-host:hover & .osw-window-lights > :nth-of-type(3)': { transform: 'translate(calc(-50% + 11px), calc(-50% + 5px)) scale(1)', opacity: 1, transitionDelay: '80ms' }, }; -function WindowControls({ onClose, onMinimize, onTile, tiled, fullscreen, noTileMenu }: WindowControlsProps): React.ReactElement { +function WindowControls({ onClose, onMinimize, onTile, tiled, noTileMenu }: WindowControlsProps): React.ReactElement { const [menuOpen, setMenuOpen] = useState(false); // Menu DOM (12 tiles + labels, ~30 nodes) mounts on first green-dot hover, not per card at boot. const [menuHot, setMenuHot] = useState(false); @@ -71,32 +67,6 @@ function WindowControls({ onClose, onMinimize, onTile, tiled, fullscreen, noTile const scheduleClose = (): void => { closeTimer.current = window.setTimeout(() => setMenuOpen(false), 180); }; const stop = (e: React.PointerEvent | React.MouseEvent): void => { e.stopPropagation(); }; - if (fullscreen) { - return ( - - { e.stopPropagation(); onTile('restore'); }} - onPointerDown={stop} - sx={{ - display: 'flex', alignItems: 'center', justifyContent: 'center', - width: 24, height: 24, p: 0, border: 'none', borderRadius: '7px', - // Neutral chip at rest so it reads on ANY ground (the fullscreen chat wash is dark, the - // browser chrome is light); hover goes explicit white-on-dark so it can never disappear. - background: 'rgba(128,128,128,0.28)', color: 'inherit', opacity: 0.9, cursor: 'pointer', - transition: 'opacity 120ms, background 120ms, color 120ms', - '&:hover': { opacity: 1, background: 'rgba(0,0,0,0.5)', color: '#fff' }, - }} - > - - - - ); - } - const btn = (color: string, symbol: string, onClick: () => void, label: string): React.ReactElement => ( { e.stopPropagation(); onClick(); }} onPointerDown={stop} sx={dotSx(color)}> diff --git a/frontend/src/app/pages/Dashboard/desktop/SpacesStrip.tsx b/frontend/src/app/pages/Dashboard/desktop/SpacesStrip.tsx index 89f4203c..1e0f743c 100644 --- a/frontend/src/app/pages/Dashboard/desktop/SpacesStrip.tsx +++ b/frontend/src/app/pages/Dashboard/desktop/SpacesStrip.tsx @@ -1,15 +1,19 @@ import React from 'react'; import Box from '@mui/material/Box'; +import Typography from '@mui/material/Typography'; import { Plus } from 'lucide-react'; import { useNavigate, useLocation } from 'react-router-dom'; import { useAppDispatch, useAppSelector } from '@/shared/hooks'; import { createDashboard } from '@/shared/state/dashboardsSlice'; -// macOS Spaces, one for one: rest the cursor on the very top edge and a translucent bar of -// dashboard "spaces" slides down (Mission Control's spaces row), click switches, + adds one. -// This replaces the sidebar as the dashboard switcher; tools live in the dock, search on Cmd+K. +// macOS Spaces, one for one: rest the cursor on the top edge and the spaces bar slides down, +// full-size thumbnail tiles with the name beneath, + at the right end to add a space. It stays +// up while the cursor is anywhere near the bar and only leaves once you move well below it +// (mouseleave flicker is exactly what Mission Control does not do). Replaces the sidebar. const HOT_ZONE_PX = 3; -const CLOSE_DELAY_MS = 280; +const TILE_W = 176; +const TILE_H = 104; +const DISMISS_BELOW_PX = 72; const SpacesStrip: React.FC = () => { const dispatch = useAppDispatch(); @@ -17,7 +21,7 @@ const SpacesStrip: React.FC = () => { const location = useLocation(); const dashboards = useAppSelector((s) => s.dashboards.items); const [open, setOpen] = React.useState(false); - const closeTimer = React.useRef(null); + const barRef = React.useRef(null); const activeId = React.useMemo(() => { const m = location.pathname.match(/\/dashboard\/([^/]+)/); @@ -29,32 +33,42 @@ const SpacesStrip: React.FC = () => { [dashboards], ); - const hold = (): void => { if (closeTimer.current) window.clearTimeout(closeTimer.current); }; - const reveal = (): void => { hold(); setOpen(true); }; - const scheduleClose = (): void => { hold(); closeTimer.current = window.setTimeout(() => setOpen(false), CLOSE_DELAY_MS); }; + // Mission Control dismissal: while open, watch the cursor globally and close only once it has + // moved a comfortable distance BELOW the bar; hovering within or near the bar never flickers. + React.useEffect(() => { + if (!open) return undefined; + const onMove = (e: MouseEvent): void => { + const barBottom = barRef.current?.getBoundingClientRect().bottom ?? 0; + if (e.clientY > barBottom + DISMISS_BELOW_PX) setOpen(false); + }; + window.addEventListener('mousemove', onMove); + return () => window.removeEventListener('mousemove', onMove); + }, [open]); const addSpace = (): void => { void dispatch(createDashboard('Untitled Dashboard')).then((result) => { - if (createDashboard.fulfilled.match(result)) navigate(`/dashboard/${(result.payload as { id: string }).id}`); + if (createDashboard.fulfilled.match(result)) { + navigate(`/dashboard/${(result.payload as { id: string }).id}`); + setOpen(false); + } }); }; return ( <> - + setOpen(true)} sx={{ position: 'fixed', top: 0, left: 0, right: 0, height: HOT_ZONE_PX, zIndex: 99998 }} /> @@ -66,16 +80,31 @@ const SpacesStrip: React.FC = () => { component="button" onClick={() => { navigate(`/dashboard/${d.id}`); setOpen(false); }} sx={{ - px: 2, py: 0.8, borderRadius: '9px', cursor: 'pointer', - border: active ? '2px solid rgba(255,255,255,0.85)' : '1px solid rgba(255,255,255,0.18)', - background: active ? 'rgba(255,255,255,0.16)' : 'rgba(255,255,255,0.07)', - color: 'rgba(255,255,255,0.92)', fontFamily: 'inherit', fontSize: '0.8125rem', fontWeight: active ? 600 : 500, - whiteSpace: 'nowrap', maxWidth: 200, overflow: 'hidden', textOverflow: 'ellipsis', - transition: 'background 130ms, border-color 130ms', - '&:hover': { background: 'rgba(255,255,255,0.18)' }, + display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 0.75, + p: 0, border: 'none', background: 'transparent', cursor: 'pointer', fontFamily: 'inherit', + '&:hover .osw-space-tile': { boxShadow: '0 0 0 3px rgba(255,255,255,0.65), 0 10px 26px rgba(0,0,0,0.4)' }, }} > - {d.name || 'Untitled'} + + {d.thumbnail ? ( + + ) : ( + + )} + + + {d.name || 'Untitled'} + ); })} @@ -85,14 +114,14 @@ const SpacesStrip: React.FC = () => { onClick={addSpace} sx={{ display: 'flex', alignItems: 'center', justifyContent: 'center', - width: 34, height: 34, borderRadius: '9px', cursor: 'pointer', - border: '1px dashed rgba(255,255,255,0.35)', background: 'transparent', - color: 'rgba(255,255,255,0.85)', - transition: 'background 130ms', - '&:hover': { background: 'rgba(255,255,255,0.14)' }, + width: 64, height: TILE_H, borderRadius: '10px', cursor: 'pointer', + border: '1.5px dashed rgba(255,255,255,0.35)', background: 'rgba(255,255,255,0.05)', + color: 'rgba(255,255,255,0.85)', flexShrink: 0, + transition: 'background 140ms, border-color 140ms', + '&:hover': { background: 'rgba(255,255,255,0.14)', borderColor: 'rgba(255,255,255,0.6)' }, }} > - + diff --git a/frontend/src/app/pages/Workflows/app/WorkflowsAppContent.tsx b/frontend/src/app/pages/Workflows/app/WorkflowsAppContent.tsx index e2790773..d23a8ae4 100644 --- a/frontend/src/app/pages/Workflows/app/WorkflowsAppContent.tsx +++ b/frontend/src/app/pages/Workflows/app/WorkflowsAppContent.tsx @@ -85,7 +85,6 @@ const WorkflowsAppContent: React.FC<{ header: CardHeader }> = ({ header }) => { onMinimize={() => dispatch(closeWorkflowsApp())} onTile={() => dispatch(toggleWorkflowsHubFullscreen())} tiled={isFullscreen} - fullscreen={isFullscreen} noTileMenu />