From edec6e0f9c6b336934f88c396d56f3a16d5455f3 Mon Sep 17 00:00:00 2001 From: ciregenz Date: Sun, 24 May 2026 11:03:30 -0700 Subject: [PATCH] [eric] ui: onboarding skip-to-sidebar + global count; sans fallback; memoize hot selectors --- .../src/app/components/Layout/AppShell.tsx | 3 + .../components/Onboarding/OnboardingPanel.tsx | 62 +++++---- .../Onboarding/OnboardingProgressSlice.ts | 2 +- .../Onboarding/OnboardingSidebarEntry.tsx | 122 ++++++++++++++++++ .../src/app/pages/AgentChat/MessageBubble.tsx | 3 +- .../src/shared/state/subscriptionsSlice.ts | 7 +- frontend/src/shared/styles/claudeTokens.ts | 4 +- 7 files changed, 171 insertions(+), 32 deletions(-) create mode 100644 frontend/src/app/components/Onboarding/OnboardingSidebarEntry.tsx diff --git a/frontend/src/app/components/Layout/AppShell.tsx b/frontend/src/app/components/Layout/AppShell.tsx index 1fa4994e..117e58fc 100644 --- a/frontend/src/app/components/Layout/AppShell.tsx +++ b/frontend/src/app/components/Layout/AppShell.tsx @@ -33,6 +33,7 @@ import CircularProgress from '@mui/material/CircularProgress'; // Settings modal lazy-loaded so its 2.3K LOC + Stripe/OAuth helpers don't ship on first paint. const Settings = React.lazy(() => import('@/app/pages/Settings/Settings')); import DynamicIsland from '@/app/components/DynamicIsland'; +import OnboardingSidebarEntry from '@/app/components/Onboarding/OnboardingSidebarEntry'; import Dashboard from '@/app/pages/Dashboard/Dashboard'; import DashboardHost from '@/app/components/Layout/DashboardHost'; import { useLastDashboardId } from '@/shared/hooks/useLastDashboardId'; @@ -986,6 +987,8 @@ const AppShell: React.FC = () => { + + { return STEPS.find((s) => !progress.completedSteps.includes(s.id)) ?? null; }, [progress.currentStepId, progress.completedSteps]); - // Stage-relative (panel) vs overall (pill). + // Stage name labels the panel; the count + bar stay global so progress never resets between stages. const stageOf = currentStep?.stage ?? 'get_started'; - const stageSteps = useMemo( - () => STEPS.filter((s) => s.stage === stageOf), - [stageOf], - ); - const stageDone = stageSteps.filter((s) => - progress.completedSteps.includes(s.id), - ).length; const total = STEPS.length; const done = progress.completedSteps.length; @@ -243,20 +236,41 @@ const OnboardingPanel: React.FC = () => { {STAGE_LABELS[stageOf]} - {stageDone}/{stageSteps.length} + {done}/{total} - { - report('panel_minimized', { from: 'expanded' }); - progress.setPanelMode('pill'); - }} - sx={{ color: c.text.tertiary, p: 0.4 }} - aria-label="Minimize" - > - - + + { + report('panel_skipped', { from: 'expanded' }); + progress.setPanelMode('docked'); + }} + sx={{ + fontSize: 11.5, + fontWeight: 500, + color: c.text.muted, + px: 0.7, + py: 0.3, + borderRadius: `${c.radius.sm}px`, + transition: 'color 0.15s, background 0.15s', + '&:hover': { color: c.text.primary, bgcolor: `${c.text.tertiary}0A` }, + }} + aria-label="Skip setup and tuck it into the sidebar" + > + Skip + + { + report('panel_minimized', { from: 'expanded' }); + progress.setPanelMode('pill'); + }} + sx={{ color: c.text.tertiary, p: 0.4 }} + aria-label="Minimize" + > + + + { }} > 0 - ? (stageDone / stageSteps.length) * 100 - : 0 - }%`, + width: `${total > 0 ? (done / total) * 100 : 0}%`, }} transition={{ duration: 0.6, ease: [0.2, 0.8, 0.2, 1] }} style={{ diff --git a/frontend/src/app/components/Onboarding/OnboardingProgressSlice.ts b/frontend/src/app/components/Onboarding/OnboardingProgressSlice.ts index 3a0ed094..7631f42f 100644 --- a/frontend/src/app/components/Onboarding/OnboardingProgressSlice.ts +++ b/frontend/src/app/components/Onboarding/OnboardingProgressSlice.ts @@ -5,7 +5,7 @@ import { createSlice, PayloadAction } from '@reduxjs/toolkit'; const STORAGE_KEY = 'openswarm.onboarding.v2'; const SCHEMA_VERSION = 2 as const; -export type PanelMode = 'pill' | 'expanded' | 'roadmap' | 'hidden'; +export type PanelMode = 'pill' | 'expanded' | 'roadmap' | 'docked' | 'hidden'; export interface PerStepState { lastViewedAt: number; diff --git a/frontend/src/app/components/Onboarding/OnboardingSidebarEntry.tsx b/frontend/src/app/components/Onboarding/OnboardingSidebarEntry.tsx new file mode 100644 index 00000000..c8292c70 --- /dev/null +++ b/frontend/src/app/components/Onboarding/OnboardingSidebarEntry.tsx @@ -0,0 +1,122 @@ +// Docked onboarding home: a compact row at the bottom of the sidebar that springs the tour back open. + +import React, { useState } from 'react'; +import { motion, AnimatePresence } from 'framer-motion'; +import { + Box, + ListItemButton, + ListItemIcon, + ListItemText, + IconButton, + Typography, + CircularProgress, +} from '@mui/material'; +import ChevronRightIcon from '@mui/icons-material/ChevronRight'; +import CloseIcon from '@mui/icons-material/Close'; +import { useClaudeTokens } from '@/shared/styles/ThemeContext'; +import { useOnboardingProgress } from './hooks/useOnboardingProgress'; +import { STEPS } from './steps'; +import { report } from './telemetry'; + +const OnboardingSidebarEntry: React.FC = () => { + const c = useClaudeTokens(); + const progress = useOnboardingProgress(); + const [hovered, setHovered] = useState(false); + + const total = STEPS.length; + const done = progress.completedSteps.length; + const show = + progress.initialized && progress.panelMode === 'docked' && done < total; + const pct = total > 0 ? (done / total) * 100 : 0; + + return ( + + {show && ( + + + setHovered(true)} + onMouseLeave={() => setHovered(false)} + onClick={() => { + report('panel_reopened', { from: 'sidebar' }); + progress.setPanelMode('expanded'); + }} + sx={{ + borderRadius: 1.5, + py: 0.6, + px: 1.25, + bgcolor: `${c.accent.primary}0C`, + '&:hover': { bgcolor: `${c.accent.primary}1A` }, + transition: 'background-color 0.15s', + }} + > + + + + + + {hovered ? ( + { + e.stopPropagation(); + report('panel_dismissed', { from: 'sidebar' }); + progress.setPanelMode('hidden'); + }} + sx={{ + p: 0.2, + color: c.text.tertiary, + '&:hover': { color: c.text.primary }, + }} + > + + + ) : ( + + + {done}/{total} + + + + )} + + + + )} + + ); +}; + +export default OnboardingSidebarEntry; diff --git a/frontend/src/app/pages/AgentChat/MessageBubble.tsx b/frontend/src/app/pages/AgentChat/MessageBubble.tsx index 94d27575..c4b610c2 100644 --- a/frontend/src/app/pages/AgentChat/MessageBubble.tsx +++ b/frontend/src/app/pages/AgentChat/MessageBubble.tsx @@ -20,6 +20,7 @@ import ReactMarkdown from 'react-markdown'; import remarkGfm from 'remark-gfm'; import { AgentMessage } from '@/shared/state/agentsSlice'; import { openSettingsModal } from '@/shared/state/settingsSlice'; +import { shallowEqual } from 'react-redux'; import { useAppDispatch, useAppSelector } from '@/shared/hooks'; import { useClaudeTokens } from '@/shared/styles/ThemeContext'; import { SKILL_COLOR } from '@/app/components/richEditorUtils'; @@ -909,7 +910,7 @@ const MessageBubble: React.FC = React.memo(({ message, editing = false, o activeMcpCount: s.active_mcps?.length ?? 0, messagesCount: s.messages?.length ?? 0, } as OverflowContext; - }); + }, shallowEqual); const openswarmError = !isUser ? parseOpenSwarmError(rawText, overflowCtx) : null; // (message.id, kind) keys so cap card analytics fire once, not on edits. diff --git a/frontend/src/shared/state/subscriptionsSlice.ts b/frontend/src/shared/state/subscriptionsSlice.ts index 918cc132..c61e5a71 100644 --- a/frontend/src/shared/state/subscriptionsSlice.ts +++ b/frontend/src/shared/state/subscriptionsSlice.ts @@ -61,14 +61,17 @@ const subscriptionsSlice = createSlice({ export const { setSubscriptionStatus } = subscriptionsSlice.actions; +// Stable empty ref so the selector doesn't hand back a fresh [] each call (forces needless rerenders). +const EMPTY_CONNECTIONS: SubscriptionConnection[] = []; + /** Unwraps the polymorphic `providers` shape (modern object vs legacy array). */ export function selectSubscriptionConnections( state: WithSubscriptions, ): SubscriptionConnection[] { const providers = state.subscriptions.status?.providers; - if (!providers) return []; + if (!providers) return EMPTY_CONNECTIONS; if (Array.isArray(providers)) return providers; - return providers.connections ?? []; + return providers.connections ?? EMPTY_CONNECTIONS; } export function isProviderConnected( diff --git a/frontend/src/shared/styles/claudeTokens.ts b/frontend/src/shared/styles/claudeTokens.ts index 21daff46..a1a6f5f7 100644 --- a/frontend/src/shared/styles/claudeTokens.ts +++ b/frontend/src/shared/styles/claudeTokens.ts @@ -56,7 +56,7 @@ export const lightTokens: ClaudeTokens = { }, user: { bubble: '#DDD9CE' }, font: { - sans: '"Anthropic Sans", ui-serif, Georgia, Cambria, "Times New Roman", Times, serif', + sans: '"Anthropic Sans", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif', mono: 'ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace', }, transition: 'all 150ms cubic-bezier(0.165, 0.85, 0.45, 1)', @@ -107,7 +107,7 @@ export const darkTokens: ClaudeTokens = { }, user: { bubble: '#393937' }, font: { - sans: '"Anthropic Sans", ui-serif, Georgia, Cambria, "Times New Roman", Times, serif', + sans: '"Anthropic Sans", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif', mono: 'ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace', }, transition: 'all 150ms cubic-bezier(0.165, 0.85, 0.45, 1)',