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)',