[eric] ui: onboarding skip-to-sidebar + global count; sans fallback; memoize hot selectors

This commit is contained in:
ciregenz
2026-05-24 11:03:30 -07:00
parent 6e2d4e94aa
commit edec6e0f9c
7 changed files with 171 additions and 32 deletions
@@ -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 = () => {
</Box>
<OnboardingSidebarEntry />
<Box
sx={{
px: 1,
@@ -67,15 +67,8 @@ const OnboardingPanel: 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]}
</Typography>
<Typography sx={{ fontSize: 11, color: c.text.muted }}>
{stageDone}/{stageSteps.length}
{done}/{total}
</Typography>
</Box>
<IconButton
size="small"
onClick={() => {
report('panel_minimized', { from: 'expanded' });
progress.setPanelMode('pill');
}}
sx={{ color: c.text.tertiary, p: 0.4 }}
aria-label="Minimize"
>
<RemoveIcon sx={{ fontSize: 16 }} />
</IconButton>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.2 }}>
<ButtonBase
onClick={() => {
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
</ButtonBase>
<IconButton
size="small"
onClick={() => {
report('panel_minimized', { from: 'expanded' });
progress.setPanelMode('pill');
}}
sx={{ color: c.text.tertiary, p: 0.4 }}
aria-label="Minimize"
>
<RemoveIcon sx={{ fontSize: 16 }} />
</IconButton>
</Box>
</Box>
<Box
sx={{
@@ -269,14 +283,10 @@ const OnboardingPanel: React.FC = () => {
}}
>
<motion.div
key={`stage-progress-${stageOf}`}
key="tour-progress"
initial={false}
animate={{
width: `${
stageSteps.length > 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={{
@@ -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;
@@ -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 (
<AnimatePresence>
{show && (
<motion.div
key="onboarding-docked"
initial={{ opacity: 0, scale: 0.85, y: 6 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.9, y: 6 }}
transition={{ type: 'spring', stiffness: 380, damping: 30 }}
>
<Box sx={{ px: 1, pt: 1 }}>
<ListItemButton
onMouseEnter={() => 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',
}}
>
<ListItemIcon
sx={{ minWidth: 32, position: 'relative', display: 'inline-flex' }}
>
<CircularProgress
variant="determinate"
value={100}
size={20}
thickness={4}
sx={{ color: `${c.text.tertiary}33` }}
/>
<CircularProgress
variant="determinate"
value={pct}
size={20}
thickness={4}
sx={{ color: c.accent.primary, position: 'absolute', left: 0 }}
/>
</ListItemIcon>
<ListItemText
primary="Finish setup"
sx={{
'& .MuiListItemText-primary': {
color: c.text.primary,
fontSize: '0.82rem',
fontWeight: 500,
},
}}
/>
{hovered ? (
<IconButton
size="small"
aria-label="Dismiss setup"
onClick={(e) => {
e.stopPropagation();
report('panel_dismissed', { from: 'sidebar' });
progress.setPanelMode('hidden');
}}
sx={{
p: 0.2,
color: c.text.tertiary,
'&:hover': { color: c.text.primary },
}}
>
<CloseIcon sx={{ fontSize: 15 }} />
</IconButton>
) : (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.3 }}>
<Typography
sx={{ fontSize: '0.72rem', color: c.text.muted, fontWeight: 500 }}
>
{done}/{total}
</Typography>
<ChevronRightIcon sx={{ fontSize: 16, color: c.text.tertiary }} />
</Box>
)}
</ListItemButton>
</Box>
</motion.div>
)}
</AnimatePresence>
);
};
export default OnboardingSidebarEntry;
@@ -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<Props> = 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.
@@ -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(
+2 -2
View File
@@ -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)',