mirror of
https://github.com/openswarm-ai/openswarm.git
synced 2026-09-29 04:54:49 +02:00
[eric] ui: onboarding skip-to-sidebar + global count; sans fallback; memoize hot selectors
This commit is contained in:
@@ -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(
|
||||
|
||||
@@ -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)',
|
||||
|
||||
Reference in New Issue
Block a user