[eric] perceived-speed pass — skeleton placeholders replace spinners on Tools/Modes/Views/DiffViewer/DashboardSelection, dashboard rename

updates instantly (rollback if server rejects), dashboard cards no longer shake neighbors during streaming, plus shared motion/loading
  primitives for future consistency. Toolbar model/mode/thinking pick now writes through to the global default so it sticks across reopens.
This commit is contained in:
ciregenz
2026-05-04 01:00:43 -07:00
parent b0515ac3c5
commit 4ba7ed583e
17 changed files with 448 additions and 21 deletions
+115
View File
@@ -0,0 +1,115 @@
import React, { useEffect, useRef, useState } from 'react';
import Box from '@mui/material/Box';
import { DURATION_MS, EASE } from '@/shared/styles/motionTokens';
import { useReducedMotion } from '@/shared/hooks/useReducedMotion';
/**
* Smooth visual transitions for status pills + counters that currently snap.
*
* <CrossFadeOnChange value={x}>{(v) => <span>{v}</span>}</CrossFadeOnChange>
* Old value fades to 30% while new value fades in. Cancels on rapid changes.
*
* <TweeningNumber value={1234} format={(n) => `$${n.toFixed(4)}`} />
* RAF-tweens from previous to new value. Caps duration on big jumps.
*/
interface CrossFadeProps<T> {
value: T;
children: (currentValue: T) => React.ReactNode;
/** Defaults to DURATION_MS.quick (140ms). */
durationMs?: number;
}
export function CrossFadeOnChange<T>({ value, children, durationMs }: CrossFadeProps<T>) {
const reduced = useReducedMotion();
const dur = reduced ? 0 : (durationMs ?? DURATION_MS.quick);
const [displayed, setDisplayed] = useState(value);
const [opacity, setOpacity] = useState(1);
useEffect(() => {
if (Object.is(displayed, value)) return;
if (dur === 0) {
setDisplayed(value);
return;
}
// Fade old to ~0, then swap and fade new in.
setOpacity(0);
const t = setTimeout(() => {
setDisplayed(value);
setOpacity(1);
}, dur / 2);
return () => clearTimeout(t);
}, [value, dur, displayed]);
return (
<Box
component="span"
sx={{
display: 'inline-block',
opacity,
transition: `opacity ${dur / 2}ms ${EASE.out}`,
}}
>
{children(displayed)}
</Box>
);
}
interface TweeningNumberProps {
value: number;
/** How to render the tweened number. Default: `n.toString()`. */
format?: (n: number) => string;
/** Cap on tween duration regardless of delta. Default 500ms. */
maxDurationMs?: number;
}
export const TweeningNumber: React.FC<TweeningNumberProps> = ({
value,
format = (n) => String(Math.round(n)),
maxDurationMs = 500,
}) => {
const reduced = useReducedMotion();
const [displayed, setDisplayed] = useState(value);
const startedAtRef = useRef<number | null>(null);
const fromRef = useRef<number>(value);
const toRef = useRef<number>(value);
const rafRef = useRef<number | null>(null);
useEffect(() => {
if (reduced) {
setDisplayed(value);
return;
}
if (Object.is(toRef.current, value)) return;
fromRef.current = displayed;
toRef.current = value;
startedAtRef.current = performance.now();
// Duration scales with delta but caps. ~1ms per unit, capped.
const delta = Math.abs(value - fromRef.current);
const dur = Math.min(maxDurationMs, Math.max(120, delta * 1.2));
if (rafRef.current != null) cancelAnimationFrame(rafRef.current);
const step = (now: number) => {
const t = Math.min(1, (now - (startedAtRef.current as number)) / dur);
// ease-out cubic
const eased = 1 - Math.pow(1 - t, 3);
const current = fromRef.current + (toRef.current - fromRef.current) * eased;
setDisplayed(current);
if (t < 1) {
rafRef.current = requestAnimationFrame(step);
} else {
rafRef.current = null;
}
};
rafRef.current = requestAnimationFrame(step);
return () => {
if (rafRef.current != null) cancelAnimationFrame(rafRef.current);
};
}, [value, reduced, maxDurationMs]); // eslint-disable-line react-hooks/exhaustive-deps
return <>{format(displayed)}</>;
};
@@ -334,8 +334,9 @@ const AppShell: React.FC = () => {
const handleDashboardRenameSubmit = (id: string) => {
const trimmed = renameValue.trim();
if (trimmed && trimmed !== dashboardItems[id]?.name) {
dispatch(renameDashboard({ id, name: trimmed }));
const previousName = dashboardItems[id]?.name;
if (trimmed && trimmed !== previousName) {
dispatch(renameDashboard({ id, name: trimmed, previousName }));
}
setRenamingDashboardId(null);
};
+132
View File
@@ -0,0 +1,132 @@
import React, { useEffect, useState } from 'react';
import Box from '@mui/material/Box';
import Typography from '@mui/material/Typography';
import CircularProgress from '@mui/material/CircularProgress';
import { useClaudeTokens } from '@/shared/styles/ThemeContext';
import { DURATION_MS, EASE, pulseKeyframes } from '@/shared/styles/motionTokens';
import { useReducedMotion } from '@/shared/hooks/useReducedMotion';
/**
* Unified loading primitives. Three components, one aesthetic.
*
* <Skeleton variant="card|line|circle" width height />
* For full-component / full-page loads. Replaces decorative spinners.
*
* <InlineSpinner size />
* For inline button states + OAuth waits. Spinner = "I'm doing it now".
*
* <EmptyState icon title hint />
* For "nothing here yet" empty lists. Replaces ad-hoc "Loading..." text.
*
* `delayMs` (Skeleton + EmptyState): don't show until N ms have elapsed.
* Prevents the flash-of-skeleton on fast loads (<100ms common case).
*/
interface SkeletonProps {
variant?: 'card' | 'line' | 'circle' | 'custom';
width?: number | string;
height?: number | string;
/** Default 100ms; pass 0 to render immediately */
delayMs?: number;
}
export const Skeleton: React.FC<SkeletonProps> = ({
variant = 'line',
width,
height,
delayMs = 100,
}) => {
const c = useClaudeTokens();
const reduced = useReducedMotion();
const [show, setShow] = useState(delayMs === 0);
useEffect(() => {
if (delayMs === 0) return;
const t = setTimeout(() => setShow(true), delayMs);
return () => clearTimeout(t);
}, [delayMs]);
if (!show) return null;
const dimensions: React.CSSProperties = {
width: width ?? (variant === 'card' ? '100%' : variant === 'circle' ? 24 : '60%'),
height: height ?? (variant === 'card' ? 80 : variant === 'circle' ? 24 : 12),
};
const radius = variant === 'circle'
? '50%'
: variant === 'card'
? 8
: 4;
return (
<Box
sx={{
...dimensions,
borderRadius: `${typeof radius === 'number' ? `${radius}px` : radius}`,
bgcolor: c.border.subtle,
opacity: 0.5,
animation: reduced ? 'none' : `openswarmPulse ${DURATION_MS.ambient}ms ${EASE.pulse} infinite`,
...pulseKeyframes,
}}
/>
);
};
interface InlineSpinnerProps {
/** 14 / 16 / 18; defaults to 16 */
size?: 14 | 16 | 18 | 20;
color?: string;
}
export const InlineSpinner: React.FC<InlineSpinnerProps> = ({ size = 16, color }) => {
const c = useClaudeTokens();
return <CircularProgress size={size} sx={{ color: color ?? c.text.tertiary }} />;
};
interface EmptyStateProps {
icon?: React.ReactNode;
title: string;
hint?: string;
/** Show after N ms — keeps "Loading..." flash off fast paths */
delayMs?: number;
}
export const EmptyState: React.FC<EmptyStateProps> = ({ icon, title, hint, delayMs = 100 }) => {
const c = useClaudeTokens();
const [show, setShow] = useState(delayMs === 0);
useEffect(() => {
if (delayMs === 0) return;
const t = setTimeout(() => setShow(true), delayMs);
return () => clearTimeout(t);
}, [delayMs]);
if (!show) return null;
return (
<Box
sx={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
gap: 1.5,
py: 6,
px: 3,
color: c.text.tertiary,
textAlign: 'center',
}}
>
{icon && <Box sx={{ opacity: 0.5, fontSize: 32 }}>{icon}</Box>}
<Typography sx={{ fontSize: '0.9rem', fontWeight: 500, color: c.text.muted }}>
{title}
</Typography>
{hint && (
<Typography sx={{ fontSize: '0.75rem', color: c.text.ghost, maxWidth: 320 }}>
{hint}
</Typography>
)}
</Box>
);
};
@@ -7,6 +7,7 @@ import RefreshIcon from '@mui/icons-material/Refresh';
import DifferenceIcon from '@mui/icons-material/Difference';
import { useClaudeTokens } from '@/shared/styles/ThemeContext';
import { API_BASE } from '@/shared/config';
import { Skeleton } from '@/app/components/Loading';
const AGENTS_API = `${API_BASE}/agents`;
@@ -100,7 +101,11 @@ const DiffViewer: React.FC<Props> = ({ sessionId }) => {
}}
>
{loading ? (
<Typography sx={{ color: c.text.ghost, fontSize: '0.8rem' }}>Loading...</Typography>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.5 }}>
{[0, 1, 2, 3, 4, 5, 6].map((i) => (
<Skeleton key={i} variant="line" width={`${60 + (i * 7) % 30}%`} height={10} />
))}
</Box>
) : diff ? (
<pre
style={{
@@ -611,6 +611,8 @@ const AgentCard: React.FC<Props> = ({
}}
sx={{
position: 'relative',
// contain: streaming chat updates inside don't reflow the dashboard.
contain: 'layout style',
width: localResize ? activeW : Math.max(cardWidth, MIN_W),
height: localResize ? activeH : (expanded ? Math.max(EXPANDED_OVERLAY_H, cardHeight) : 'auto'),
bgcolor: c.bg.surface,
@@ -626,6 +626,8 @@ const BrowserCard: React.FC<Props> = ({
}}
sx={{
position: 'absolute',
// contain: webview repaints don't shake neighbor cards.
contain: 'layout style',
left: displayX,
top: displayY,
width: displayW,
@@ -19,6 +19,8 @@ import { useElementSelection } from '@/app/components/ElementSelectionContext';
import { useClaudeTokens } from '@/shared/styles/ThemeContext';
import { useAppDispatch, useAppSelector } from '@/shared/hooks';
import { searchHistory, clearHistorySearch } from '@/shared/state/agentsSlice';
import { updateSettings, AppSettings } from '@/shared/state/settingsSlice';
import { store } from '@/shared/state/store';
import type { ClaudeTokens } from '@/shared/styles/claudeTokens';
import type { Output } from '@/shared/state/outputsSlice';
@@ -127,6 +129,32 @@ const DashboardToolbar = React.forwardRef<HTMLDivElement, Props>(
}
prevInputOpen.current = inputOpen;
}, [inputOpen, settingsLoaded, defaultMode, defaultModel, defaultThinkingLevel]);
// Picking a model/mode/thinking-level in the toolbar writes through to
// the global default. Without this, the reopen-reset effect above
// would snap back to the old default the next time the user opens the
// toolbar, ignoring what they last picked.
const promoteToDefault = useCallback(<K extends keyof AppSettings>(key: K, value: AppSettings[K]) => {
const current = store.getState().settings;
if (!current.loaded) return;
if (current.data[key] === value) return;
dispatch(updateSettings({ ...current.data, [key]: value }));
}, [dispatch]);
const handleModeChange = useCallback((newMode: string) => {
setMode(newMode);
promoteToDefault('default_mode', newMode);
}, [promoteToDefault]);
const handleModelChange = useCallback((newModel: string) => {
setModel(newModel);
promoteToDefault('default_model', newModel);
}, [promoteToDefault]);
const handleThinkingLevelChange = useCallback((level: 'off' | 'low' | 'medium' | 'high' | 'auto') => {
setThinkingLevel(level);
promoteToDefault('default_thinking_level', level);
}, [promoteToDefault]);
const [viewPickerOpen, setViewPickerOpen] = useState(false);
const [viewSearch, setViewSearch] = useState('');
const [historyOpen, setHistoryOpen] = useState(false);
@@ -384,14 +412,14 @@ const DashboardToolbar = React.forwardRef<HTMLDivElement, Props>(
<ChatInput
onSend={handleSend}
mode={mode}
onModeChange={setMode}
onModeChange={handleModeChange}
model={model}
onModelChange={setModel}
onModelChange={handleModelChange}
embedded
autoFocus
sessionId={TOOLBAR_OWNER_ID}
thinkingLevel={thinkingLevel}
onThinkingLevelChange={setThinkingLevel}
onThinkingLevelChange={handleThinkingLevelChange}
/>
</div>
) : historyOpen ? (
@@ -307,6 +307,8 @@ const DashboardViewCard: React.FC<Props> = ({
}}
sx={{
position: 'absolute',
// contain: iframe app repaints don't shake the rest of the dashboard.
contain: 'layout style',
left: displayX,
top: displayY,
width: displayW,
@@ -267,6 +267,8 @@ const NoteCard: React.FC<Props> = ({
top: displayY,
width: displayW,
height: displayH,
// contain: reflow inside this note doesn't shake the dashboard.
contain: 'layout style',
borderRadius: `${c.radius.md}px`,
bgcolor: palette.bg,
border: isHighlighted
@@ -13,6 +13,7 @@ import ListItemText from '@mui/material/ListItemText';
import AddIcon from '@mui/icons-material/Add';
import DashboardIcon from '@mui/icons-material/Dashboard';
import DeleteOutlineIcon from '@mui/icons-material/DeleteOutline';
import { Skeleton } from '@/app/components/Loading';
import ContentCopyIcon from '@mui/icons-material/ContentCopy';
import EditIcon from '@mui/icons-material/Edit';
import MoreVertIcon from '@mui/icons-material/MoreVert';
@@ -107,8 +108,9 @@ const DashboardSelection: React.FC = () => {
const handleRenameSubmit = (id: string) => {
const trimmed = renameValue.trim();
if (trimmed && trimmed !== items[id]?.name) {
dispatch(renameDashboard({ id, name: trimmed }));
const previousName = items[id]?.name;
if (trimmed && trimmed !== previousName) {
dispatch(renameDashboard({ id, name: trimmed, previousName }));
}
setRenamingId(null);
};
@@ -174,9 +176,11 @@ const DashboardSelection: React.FC = () => {
</Box>
{loading ? (
<Typography sx={{ color: c.text.muted, textAlign: 'center', py: 8 }}>
Loading...
</Typography>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1.5, py: 4 }}>
{[0, 1, 2].map((i) => (
<Skeleton key={i} variant="card" height={64} />
))}
</Box>
) : dashboards.length === 0 ? (
<Box sx={{ textAlign: 'center', py: 10, color: c.text.muted }}>
<Typography sx={{ fontSize: '1.1rem', mb: 1 }}>
+5 -2
View File
@@ -14,6 +14,7 @@ import IconButton from '@mui/material/IconButton';
import Chip from '@mui/material/Chip';
import CircularProgress from '@mui/material/CircularProgress';
import Tooltip from '@mui/material/Tooltip';
import { Skeleton } from '@/app/components/Loading';
import FormControl from '@mui/material/FormControl';
import InputLabel from '@mui/material/InputLabel';
import Select from '@mui/material/Select';
@@ -242,8 +243,10 @@ const Modes: React.FC = () => {
</Box>
{loading ? (
<Box sx={{ display: 'flex', justifyContent: 'center', mt: 8 }}>
<CircularProgress sx={{ color: c.accent.primary }} />
<Box sx={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(280px, 1fr))', gap: 2, mt: 1 }}>
{[0, 1, 2, 3, 4, 5].map((i) => (
<Skeleton key={i} variant="card" height={120} />
))}
</Box>
) : modes.length === 0 ? (
<Box
+10 -3
View File
@@ -87,6 +87,7 @@ import {
updateOutput,
Output,
} from '@/shared/state/outputsSlice';
import { Skeleton } from '@/app/components/Loading';
import { useClaudeTokens } from '@/shared/styles/ThemeContext';
import { API_BASE } from '@/shared/config';
@@ -1384,7 +1385,11 @@ const Tools: React.FC = () => {
</Box>
<Collapse in={customSectionOpen}>
{loading ? (
<Box sx={{ display: 'flex', justifyContent: 'center', mt: 6 }}><CircularProgress sx={{ color: c.accent.primary }} size={28} /></Box>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1.5, pl: 1, mt: 1 }}>
{[0, 1, 2, 3].map((i) => (
<Skeleton key={i} variant="card" height={72} />
))}
</Box>
) : (tools.length === 0 && uninstalledIntegrations.length === 0) ? (
<Box sx={{ display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', py: 6, color: c.text.ghost, gap: 1.5 }}>
<BuildIcon sx={{ fontSize: 40, opacity: 0.3 }} />
@@ -1923,8 +1928,10 @@ const Tools: React.FC = () => {
</Box>
{regLoading && regServers.length === 0 ? (
<Box sx={{ display: 'flex', justifyContent: 'center', alignItems: 'center', flex: 1 }}>
<CircularProgress sx={{ color: c.accent.primary }} size={28} />
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1.25, flex: 1, py: 1 }}>
{[0, 1, 2, 3, 4, 5].map((i) => (
<Skeleton key={i} variant="card" height={56} />
))}
</Box>
) : regServers.length === 0 ? (
<Box sx={{ display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', flex: 1, color: c.text.ghost, gap: 1.5 }}>
+6 -3
View File
@@ -8,6 +8,7 @@ import { useAppDispatch, useAppSelector } from '@/shared/hooks';
import { fetchOutputs, deleteOutput, Output } from '@/shared/state/outputsSlice';
import { useClaudeTokens } from '@/shared/styles/ThemeContext';
import ViewCard from './ViewCard';
import { Skeleton } from '@/app/components/Loading';
import ViewEditor from './ViewEditor';
import ViewRunDialog from './ViewRunDialog';
@@ -115,9 +116,11 @@ const Views: React.FC = () => {
{/* Card grid */}
{loading ? (
<Typography sx={{ color: c.text.muted, textAlign: 'center', py: 8 }}>
Loading...
</Typography>
<Box sx={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(260px, 1fr))', gap: 2, py: 2 }}>
{[0, 1, 2, 3, 4, 5].map((i) => (
<Skeleton key={i} variant="card" height={140} />
))}
</Box>
) : outputs.length === 0 ? (
<Box
sx={{
@@ -0,0 +1,50 @@
import { useSyncExternalStore } from 'react';
const QUERY = '(prefers-reduced-motion: reduce)';
function subscribe(callback: () => void): () => void {
if (typeof window === 'undefined' || !window.matchMedia) return () => {};
const mql = window.matchMedia(QUERY);
// Modern + legacy event names both supported.
mql.addEventListener('change', callback);
return () => mql.removeEventListener('change', callback);
}
function getSnapshot(): boolean {
if (typeof window === 'undefined' || !window.matchMedia) return false;
return window.matchMedia(QUERY).matches;
}
function getServerSnapshot(): boolean {
return false;
}
/**
* True when the OS-level "Reduce motion" preference is on.
* Mac: System Settings → Accessibility → Display → Reduce Motion.
* Windows: Settings → Ease of Access → Display → Show animations.
*
* Reactive — flips immediately if the user toggles the OS setting
* mid-session (rare but supported).
*/
export function useReducedMotion(): boolean {
return useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);
}
/**
* Convenience: returns 0 when reduced-motion is on, otherwise the supplied
* duration. Use inline at animation sites:
*
* const dur = useMotionDuration(DURATION_MS.quick);
* <Fade timeout={dur}>...</Fade>
*
* For animations that convey causality (modal open, drawer slide), prefer a
* tiny non-zero floor so the user still perceives the transition:
*
* const dur = useMotionDuration(DURATION_MS.standard, { floor: 40 });
*/
export function useMotionDuration(ms: number, opts: { floor?: number } = {}): number {
const reduced = useReducedMotion();
if (!reduced) return ms;
return opts.floor ?? 0;
}
+18 -1
View File
@@ -42,12 +42,13 @@ export const createDashboard = createAsyncThunk(
export const renameDashboard = createAsyncThunk(
'dashboards/rename',
async ({ id, name }: { id: string; name: string }) => {
async ({ id, name }: { id: string; name: string; previousName?: string }) => {
const res = await fetch(`${DASHBOARDS_API}/${id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name }),
});
if (!res.ok) throw new Error(`rename failed: ${res.status}`);
return (await res.json()) as Dashboard;
},
);
@@ -116,6 +117,16 @@ const dashboardsSlice = createSlice({
.addCase(createDashboard.fulfilled, (state, action) => {
state.items[action.payload.id] = action.payload;
})
// Optimistic: update name immediately on dispatch so the sidebar
// entry / picker label swaps with no perceptible lag. Server confirms
// on .fulfilled (rare correction); .rejected rolls back to previousName.
.addCase(renameDashboard.pending, (state, action) => {
const { id, name } = action.meta.arg;
if (state.items[id]) {
state.items[id].name = name;
state.items[id].auto_named = false;
}
})
.addCase(renameDashboard.fulfilled, (state, action) => {
const d = action.payload;
if (state.items[d.id]) {
@@ -127,6 +138,12 @@ const dashboardsSlice = createSlice({
};
}
})
.addCase(renameDashboard.rejected, (state, action) => {
const { id, previousName } = action.meta.arg;
if (state.items[id] && previousName !== undefined) {
state.items[id].name = previousName;
}
})
.addCase(deleteDashboard.fulfilled, (state, action) => {
delete state.items[action.payload];
})
@@ -0,0 +1,54 @@
// Single source of truth for animation timing + easing across the app.
// Mixing one-off durations / curves makes the chrome feel like several
// different products glued together; tokenizing makes everything land
// the same way.
//
// Pair with `useReducedMotion()` to respect OS-level "Reduce motion".
export const DURATION_MS = {
/** 60ms — hover state changes, subtle press feedback */
instant: 60,
/** 140ms — rows fading in, popovers, tooltip open, status pill swaps */
quick: 140,
/** 220ms — modal open, page transitions, banners */
standard: 220,
/** 400ms — drawer slide, big layout shifts */
slow: 400,
/** 1500ms — skeleton pulse + ambient breathing indicators */
ambient: 1500,
} as const;
export const EASE = {
/** Linear's signature curve. Snappy out, gentle settle. Good default for "thing appears". */
out: 'cubic-bezier(0.16, 1, 0.3, 1)',
/** MUI / Material default. Symmetric — for things that move both directions. */
inOut: 'cubic-bezier(0.4, 0, 0.2, 1)',
/** Subtle bounce at the end. Use sparingly for delight moments. */
spring: 'cubic-bezier(0.34, 1.56, 0.64, 1)',
/** Gentle breathing curve for ambient pulses. */
pulse: 'cubic-bezier(0.4, 0, 0.6, 1)',
} as const;
/** Framer-motion uses array-form easing. Same curves as EASE above. */
export const FRAMER_EASE = {
out: [0.16, 1, 0.3, 1] as [number, number, number, number],
inOut: [0.4, 0, 0.2, 1] as [number, number, number, number],
spring: [0.34, 1.56, 0.64, 1] as [number, number, number, number],
pulse: [0.4, 0, 0.6, 1] as [number, number, number, number],
};
/** Module-scoped fadeIn keyframe. Imported once instead of redefined inline at each callsite. */
export const fadeInKeyframes = {
'@keyframes openswarmFadeIn': {
from: { opacity: 0 },
to: { opacity: 1 },
},
};
/** Skeleton + indicator pulse keyframe. Imported once. */
export const pulseKeyframes = {
'@keyframes openswarmPulse': {
'0%, 100%': { opacity: 0.5 },
'50%': { opacity: 0.25 },
},
};
File diff suppressed because one or more lines are too long