[eric] frontend perf: instant collapses, lazy app editor, stale-id reap

This commit is contained in:
ciregenz
2026-05-15 21:43:55 -07:00
parent 9078326268
commit 51a4b5dcaa
9 changed files with 95 additions and 193 deletions
@@ -1,6 +1,5 @@
import React, { useEffect, useRef } from 'react';
import { useStreamingMessage } from '@/shared/state/streamingSlice';
import { useTypewriter } from '@/shared/useTypewriter';
import MessageBubble from './MessageBubble';
import ToolCallBubble from './ToolCallBubble';
@@ -26,11 +25,21 @@ interface Props {
// local and cheap.
const StreamingBubble: React.FC<Props> = ({ sessionId, activeBranchId, turnLabel, onStreamGrew }) => {
const streamingMessage = useStreamingMessage(sessionId);
// Typewriter pacing: smooths bursty upstream output into a steady
// character-by-character reveal with tiny pauses after punctuation.
// The full text always lives in Redux (so resume/replay still works);
// this only controls how fast it APPEARS to the user.
const typedContent = useTypewriter(streamingMessage?.content ?? '');
// Render the raw streaming content as it arrives. We tried a
// client-side typewriter (word + char chunking, punctuation pauses)
// but it introduced two real problems:
// 1. The pacing was slower than Claude's actual emit rate, so the
// visible response lagged behind real arrival by 2-3x.
// 2. On stream_end, useStreamingMessage clears and the streamed
// partial vanished, then the final message rendered all at
// once via MessageBubble. That's the "no streaming, just a
// huge block" experience.
// The other isolation work (streamingSlice, RAF WS batching,
// StreamingBubble as a leaf) is what actually makes streaming feel
// smooth: the React tree above this component stays dormant, the
// browser renders one growing text node per frame, and the user
// sees tokens arrive at the model's natural pace.
const typedContent = streamingMessage?.content ?? '';
// Fire onStreamGrew once per render (i.e. per delta) on a RAF so the
// host can scroll if it wants to. RAF coalesces multiple deltas in
// the same frame into one host call. The ref-callback keeps the
@@ -64,11 +64,11 @@ const Customization: React.FC = () => {
border: `1px solid ${c.border.subtle}`,
borderRadius: 2.5,
boxShadow: c.shadow.sm,
'&:hover': {
borderColor: c.accent.primary,
boxShadow: `0 0 0 1px ${c.accent.primary}22`,
},
transition: 'border-color 0.2s, box-shadow 0.2s',
willChange: 'transform',
// Removed the box-shadow hover animation; border-color
// alone reads as the affordance and is layout-free.
'&:hover': { borderColor: c.accent.primary },
transition: 'border-color 0.2s',
}}
>
<CardActionArea
+9 -2
View File
@@ -279,8 +279,15 @@ const Modes: React.FC = () => {
border: `1px solid ${c.border.subtle}`,
borderRadius: 2,
boxShadow: c.shadow.sm,
'&:hover': { borderColor: mode.color, boxShadow: c.shadow.md },
transition: 'border-color 0.2s, box-shadow 0.2s',
// Promote each card to its own compositor layer so a
// hover-cross between cards in the grid only re-paints
// that one card's layer, not the whole grid.
willChange: 'transform',
// Animate ONLY border-color on hover (cheap). Removing
// the box-shadow animation kills the per-frame CPU
// paint that fired on every hover-cross.
'&:hover': { borderColor: mode.color },
transition: 'border-color 0.2s',
}}
>
<CardContent sx={{ pb: 1 }}>
+3 -3
View File
@@ -362,7 +362,7 @@ const Skills: React.FC = () => {
</Box>
{/* Search input (toggled) */}
<Collapse in={searchFilter !== ''}>
<Collapse in={searchFilter !== ''} timeout={0} unmountOnExit>
<Box sx={{ px: 1.5, pb: 1 }}>
<TextField
placeholder="Filter skills..."
@@ -415,7 +415,7 @@ const Skills: React.FC = () => {
</Typography>
<Typography sx={{ fontSize: '0.68rem', color: c.text.ghost, ml: 0.5 }}>({filteredLocal.length})</Typography>
</Box>
<Collapse in={!collapsedCats['__local']}>
<Collapse in={!collapsedCats['__local']} timeout={0} unmountOnExit>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.25, mt: 0.25 }}>
{filteredLocal.map((sk) => (
<SidebarRow
@@ -459,7 +459,7 @@ const Skills: React.FC = () => {
</Typography>
<Typography sx={{ fontSize: '0.68rem', color: c.text.ghost, ml: 0.5 }}>({group.length})</Typography>
</Box>
<Collapse in={!isCollapsed}>
<Collapse in={!isCollapsed} timeout={0} unmountOnExit>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.25, mt: 0.25 }}>
{group.map((sk) => (
<SidebarRow
+11 -11
View File
@@ -409,7 +409,7 @@ const ToolSection: React.FC<ToolSectionProps> = ({
)}
</Box>
</CardContent>
<Collapse in={open && enabled}>
<Collapse in={open && enabled} timeout={0} unmountOnExit>
<Box sx={{ px: 2, pb: 2, pt: 0, borderTop: `1px solid ${c.border.subtle}` }}>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mt: 1.5, mb: 1 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
@@ -438,7 +438,7 @@ const ToolSection: React.FC<ToolSectionProps> = ({
</Box>
<PermToggle value={catPolicy === 'mixed' ? 'ask' : catPolicy} onChange={(v) => onCategoryPermissionChange(catTools.map((t) => t.name), v)} />
</Box>
<Collapse in={isOpen}>
<Collapse in={isOpen} timeout={0} unmountOnExit>
<Box sx={{ px: 1, pb: 1 }}>
{catTools.map((bt) => {
const toolPolicy = builtinPermissions[bt.name] || 'always_allow';
@@ -1131,7 +1131,7 @@ const Tools: React.FC = () => {
<Typography sx={{ color: c.text.muted, fontWeight: 600, fontSize: '0.8rem', textTransform: 'uppercase', letterSpacing: '0.05em' }}>Built-in Action Sets</Typography>
<Chip label={coreTools.length + deferredTools.length + outputs.length + browserTools.length} size="small" sx={{ bgcolor: c.bg.secondary, color: c.text.muted, fontSize: '0.7rem', height: 18, minWidth: 24, '& .MuiChip-label': { px: 0.8 } }} />
</Box>
<Collapse in={builtinSectionOpen}>
<Collapse in={builtinSectionOpen} timeout={0} unmountOnExit>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1.5, pl: 1 }}>
{/* Core Tools */}
@@ -1183,7 +1183,7 @@ const Tools: React.FC = () => {
)}
</Box>
</CardContent>
<Collapse in={viewsSectionOpen && viewsSectionEnabled}>
<Collapse in={viewsSectionOpen && viewsSectionEnabled} timeout={0} unmountOnExit>
<Box sx={{ px: 2, pb: 2, pt: 0, borderTop: `1px solid ${c.border.subtle}` }}>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.5, mt: 1.5 }}>
{outputs.map((out) => {
@@ -1265,7 +1265,7 @@ const Tools: React.FC = () => {
)}
</Box>
</CardContent>
<Collapse in={browserSectionOpen && browserSectionEnabled}>
<Collapse in={browserSectionOpen && browserSectionEnabled} timeout={0} unmountOnExit>
<Box sx={{ px: 2, pb: 2, pt: 0, borderTop: `1px solid ${c.border.subtle}` }}>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mt: 1.5, mb: 1 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
@@ -1299,7 +1299,7 @@ const Tools: React.FC = () => {
<Tooltip title="Always deny"><IconButton size="small" onClick={() => handleBuiltinCategoryPermissionChange(browserDelegationTools.map((t) => t.name), 'deny')} sx={{ p: 0.4, borderRadius: 1, bgcolor: groupPolicy === 'deny' ? `${c.status.error}20` : 'transparent', color: groupPolicy === 'deny' ? c.status.error : c.text.ghost, '&:hover': { bgcolor: `${c.status.error}15`, color: c.status.error } }}><BlockIcon sx={{ fontSize: 16 }} /></IconButton></Tooltip>
</Box>
</Box>
<Collapse in={isOpen}>
<Collapse in={isOpen} timeout={0} unmountOnExit>
<Box sx={{ px: 1, pb: 1 }}>
{browserDelegationTools.map((bt) => {
const toolPolicy = builtinPermissions[bt.name] || 'always_allow';
@@ -1347,7 +1347,7 @@ const Tools: React.FC = () => {
<Tooltip title="Always deny"><IconButton size="small" onClick={() => handleBuiltinCategoryPermissionChange(browserActionTools.map((t) => t.name), 'deny')} sx={{ p: 0.4, borderRadius: 1, bgcolor: groupPolicy === 'deny' ? `${c.status.error}20` : 'transparent', color: groupPolicy === 'deny' ? c.status.error : c.text.ghost, '&:hover': { bgcolor: `${c.status.error}15`, color: c.status.error } }}><BlockIcon sx={{ fontSize: 16 }} /></IconButton></Tooltip>
</Box>
</Box>
<Collapse in={isOpen}>
<Collapse in={isOpen} timeout={0} unmountOnExit>
<Box sx={{ px: 1, pb: 1 }}>
{browserActionTools.map((bt) => {
const toolPolicy = builtinPermissions[bt.name] || 'always_allow';
@@ -1388,7 +1388,7 @@ const Tools: React.FC = () => {
<Typography sx={{ color: c.text.muted, fontWeight: 600, fontSize: '0.8rem', textTransform: 'uppercase', letterSpacing: '0.05em' }}>Custom Action Sets</Typography>
<Chip label={tools.length + uninstalledIntegrations.length} size="small" sx={{ bgcolor: c.bg.secondary, color: c.text.muted, fontSize: '0.7rem', height: 18, minWidth: 24, '& .MuiChip-label': { px: 0.8 } }} />
</Box>
<Collapse in={customSectionOpen}>
<Collapse in={customSectionOpen} timeout={0} unmountOnExit>
{loading ? (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1.5, pl: 1, mt: 1 }}>
{[0, 1, 2, 3].map((i) => (
@@ -1538,7 +1538,7 @@ const Tools: React.FC = () => {
<PermToggle value={svcPolicy === 'mixed' ? 'ask' : svcPolicy} onChange={(v) => handleGroupPermissionChange(tool.id, allNames, v)} />
</Box>
</Box>
<Collapse in={isOpen}>
<Collapse in={isOpen} timeout={0} unmountOnExit>
<Box sx={{ px: 1, pb: 1 }}>
{(data.read?.length || 0) > 0 && (
<Box sx={{ mt: 0.5 }}>
@@ -1770,7 +1770,7 @@ const Tools: React.FC = () => {
</Box>
</CardContent>
<Collapse in={isExpanded && !isDisabled}>
<Collapse in={isExpanded && !isDisabled} timeout={0} unmountOnExit>
<Box sx={{ px: 2, pb: 2, pt: 0, borderTop: `1px solid ${c.border.subtle}` }}>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mt: 1.5, mb: 1 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
@@ -2077,7 +2077,7 @@ const Tools: React.FC = () => {
<KeyboardArrowDownIcon sx={{ fontSize: 16, color: c.text.ghost, transition: 'transform 0.2s', transform: isExpanded ? 'rotate(180deg)' : 'rotate(0deg)', flexShrink: 0 }} />
</Box>
<Collapse in={isExpanded}>
<Collapse in={isExpanded} timeout={0} unmountOnExit>
<Box sx={{ ml: 4.5, mr: 1.5, mb: 1, px: 2, py: 1.5, bgcolor: c.bg.elevated, borderRadius: 1.5, borderLeft: '2px solid rgba(174,86,48,0.12)' }}>
<Typography sx={{ color: c.text.secondary, fontSize: '0.85rem', mb: 1.5, lineHeight: 1.5 }}>
{srv.description}
+18 -3
View File
@@ -29,10 +29,19 @@ const ViewCard: React.FC<Props> = ({ output, onClick, onDelete, onRun }) => {
border: `1px solid ${c.border.subtle}`,
bgcolor: c.bg.surface,
overflow: 'hidden',
transition: 'all 0.2s ease',
// Promote each card to its own compositor layer so a hover-
// cross between cards in the grid only re-paints that one
// card's layer, not the entire grid. Same fix we landed on
// the dashboard AgentCard.
willChange: 'transform',
// Animate ONLY transform on hover (composited on the GPU).
// Previously this also animated box-shadow + border-color via
// `transition: all`, which forces per-frame CPU paint for the
// shadow blur on every card the user hovers across. Border
// color is layout-free and ~free to paint, so we keep that.
transition: 'transform 0.15s ease, border-color 0.15s ease',
'&:hover': {
borderColor: c.border.strong,
boxShadow: c.shadow.md,
transform: 'translateY(-2px)',
},
'&:hover .card-actions': { opacity: 1 },
@@ -56,6 +65,8 @@ const ViewCard: React.FC<Props> = ({ output, onClick, onDelete, onRun }) => {
component="img"
src={output.thumbnail}
alt={`${output.name} preview`}
loading="lazy"
decoding="async"
sx={{
width: '100%',
height: '100%',
@@ -149,4 +160,8 @@ const ViewCard: React.FC<Props> = ({ output, onClick, onDelete, onRun }) => {
);
};
export default ViewCard;
// Memoize so re-renders of the parent (Views.tsx) don't re-render every
// card. The callback props are inline arrow functions from the parent so
// they change every render, but the equality check below treats them as
// stable when `output` identity is unchanged.
export default React.memo(ViewCard, (prev, next) => prev.output === next.output);
+10 -3
View File
@@ -1,4 +1,4 @@
import React, { useEffect, useState, useMemo } from 'react';
import React, { useEffect, useState, useMemo, lazy, Suspense } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import Box from '@mui/material/Box';
import Typography from '@mui/material/Typography';
@@ -9,8 +9,11 @@ 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';
// ViewEditor pulls in CodeMirror (~600KB minified) and 1600+ lines of
// form scaffolding. Landing on /apps to browse the grid shouldn't pay
// that cost; lazy so the chunk only loads when the user opens an editor.
const ViewEditor = lazy(() => import('./ViewEditor'));
const Views: React.FC = () => {
const c = useClaudeTokens();
@@ -66,7 +69,11 @@ const Views: React.FC = () => {
};
if (editorOpen) {
return <ViewEditor key={editingOutput?.id ?? 'new'} output={editingOutput} onClose={handleEditorClose} />;
return (
<Suspense fallback={<Box sx={{ p: 4, color: c.text.muted }}>Loading editor...</Box>}>
<ViewEditor key={editingOutput?.id ?? 'new'} output={editingOutput} onClose={handleEditorClose} />
</Suspense>
);
}
return (
@@ -1,7 +1,17 @@
import { createSlice, createAsyncThunk, PayloadAction } from '@reduxjs/toolkit';
import { createSlice, createAsyncThunk, PayloadAction, createAction } from '@reduxjs/toolkit';
import { launchAndSendFirstMessage } from './agentsSlice';
import { API_BASE } from '@/shared/config';
// Cross-slice listener: when agentsSlice's fetchSession thunk rejects
// with a 404/410, the session is gone server-side. We strip the card
// from layout here so AgentChat doesn't keep re-mounting + re-fetching
// the same dead id in a loop (the visible "404 spam" in dev logs).
// Matching the rejected-thunk action type literally avoids a circular
// import on the thunk's reject metadata.
const fetchSessionRejectedAction = createAction<
{ sessionId?: string; status?: number } | undefined
>('agents/fetchSession/rejected');
const DASHBOARDS_API = `${API_BASE}/dashboards`;
export const DEFAULT_CARD_W = 480;
@@ -940,6 +950,19 @@ const dashboardLayoutSlice = createSlice({
state.loading = false;
state.initialized = true;
})
.addCase(fetchSessionRejectedAction, (state, action) => {
// 404/410 means the session is permanently gone from the
// backend; remove its card so AgentChat doesn't keep remounting
// and re-fetching it in a loop. Same id, same dead path. Other
// failure modes (network blip, 500) leave the card in place
// because the next fetch may succeed.
const payload = action.payload;
if (!payload?.sessionId) return;
if (payload.status !== 404 && payload.status !== 410) return;
const id = payload.sessionId;
if (state.cards[id]) delete state.cards[id];
if (state.closedCardPositions[id]) delete state.closedCardPositions[id];
})
.addCase(launchAndSendFirstMessage.fulfilled, (state, action) => {
const { draftId, session } = action.payload;
const card = state.cards[draftId];
-159
View File
@@ -1,159 +0,0 @@
import { useEffect, useRef, useState } from 'react';
// RPG-style typewriter hook. Given a growing `fullText` (server keeps
// appending), reveals it character-by-character at a steady cadence
// with tiny pauses after sentence-ending punctuation. Different from a
// pure "as data arrives" stream: smooths bursty upstream cadence into
// a calm, predictable rhythm that reads like Phoenix Wright / Animal
// Crossing dialogue rather than a Twitch raid spam.
//
// Why this lives here instead of inside StreamingBubble: both the
// assistant message bubble AND the tool-call bubble want the same
// typing rhythm, so the loop logic is shared.
//
// The hook keeps a ref-based `displayedLen` counter and only triggers
// React re-renders when that counter advances (one re-render per
// RAF tick at most). The leaf component reads the substring fresh on
// each render. Caller's parent does NOT re-render between ticks
// because nothing leaks out of this hook.
interface TypewriterOptions {
// Steady-state characters per second. RPG dialogue typically runs
// 25-60 cps; 70 reads as "fast confident character." If the
// upstream sends faster than this, we don't drop chars, we lag
// a bit and catch up between bursts.
cps?: number;
// ms to pause after `.`, `!`, `?`, `:` (sentence-ending punctuation).
sentencePauseMs?: number;
// ms to pause after `\n\n` (paragraph break).
paragraphPauseMs?: number;
// ms after `,` `;`. Smaller; barely perceptible but adds rhythm.
commaPauseMs?: number;
// When the gap between displayed and full exceeds this, accelerate
// catch-up so a long lag doesn't feel "stuck." 200 chars behind
// means the model emitted a big burst (paragraph, tool result);
// we'll catch up in roughly 2 seconds at 2x rate instead of 6s.
catchupThresholdChars?: number;
catchupMultiplier?: number;
}
const SENTENCE_PUNCT = new Set(['.', '!', '?', ':']);
const COMMA_PUNCT = new Set([',', ';']);
export function useTypewriter(fullText: string, options: TypewriterOptions = {}): string {
const {
cps = 65,
sentencePauseMs = 90,
paragraphPauseMs = 180,
commaPauseMs = 35,
catchupThresholdChars = 200,
catchupMultiplier = 2.0,
} = options;
// Bumping `tick` triggers a re-render so the caller reads the new
// substring; we never put the substring itself in state because that
// would allocate a new string on every paint.
const [, setTick] = useState(0);
const displayedLenRef = useRef(0);
const rafRef = useRef<number | null>(null);
const lastPaintAtRef = useRef(0);
const pauseUntilRef = useRef(0);
// Reset when fullText resets to empty (stream cleared / new turn).
// We do NOT reset when fullText simply grows; that's the steady-
// state case the loop handles.
useEffect(() => {
if (fullText.length === 0 && displayedLenRef.current > 0) {
displayedLenRef.current = 0;
lastPaintAtRef.current = 0;
pauseUntilRef.current = 0;
setTick((t) => (t + 1) & 0xffff);
}
}, [fullText]);
useEffect(() => {
// Nothing to type? Stop the loop.
if (displayedLenRef.current >= fullText.length) {
if (rafRef.current != null) {
cancelAnimationFrame(rafRef.current);
rafRef.current = null;
}
return;
}
const step = (now: number) => {
rafRef.current = null;
// Honor punctuation pause: if we're inside one, don't advance,
// but DO reschedule so we wake up after the pause expires.
if (now < pauseUntilRef.current) {
rafRef.current = requestAnimationFrame(step);
return;
}
const last = lastPaintAtRef.current || now;
const dt = Math.max(1, now - last);
lastPaintAtRef.current = now;
// Catch-up: if we're far behind, paint faster.
const lag = fullText.length - displayedLenRef.current;
const effectiveCps = lag > catchupThresholdChars ? cps * catchupMultiplier : cps;
// How many chars should we have painted given the elapsed ms?
// Round so 16ms * 65cps = ~1.04 chars rounds to 1, not 0.
const charsToAdd = Math.max(1, Math.round((dt * effectiveCps) / 1000));
let next = Math.min(displayedLenRef.current + charsToAdd, fullText.length);
// Punctuation pause check: if a punctuation char is in the chunk
// we're about to add, stop AT the punctuation (include it), then
// arm the pause. Picks the FIRST punctuation in the chunk so a
// burst-paint doesn't skip over multiple sentence boundaries.
const chunk = fullText.slice(displayedLenRef.current, next);
let pauseMs = 0;
for (let i = 0; i < chunk.length; i++) {
const ch = chunk[i];
if (SENTENCE_PUNCT.has(ch)) {
next = displayedLenRef.current + i + 1;
pauseMs = sentencePauseMs;
// Check for paragraph break (.\n\n pattern) to extend pause.
if (fullText[next] === '\n' && fullText[next + 1] === '\n') {
pauseMs = paragraphPauseMs;
}
break;
}
if (COMMA_PUNCT.has(ch)) {
next = displayedLenRef.current + i + 1;
pauseMs = commaPauseMs;
break;
}
}
if (next !== displayedLenRef.current) {
displayedLenRef.current = next;
setTick((t) => (t + 1) & 0xffff);
}
if (pauseMs > 0) {
pauseUntilRef.current = now + pauseMs;
}
if (displayedLenRef.current < fullText.length) {
rafRef.current = requestAnimationFrame(step);
}
};
if (rafRef.current == null) {
rafRef.current = requestAnimationFrame(step);
}
return () => {
if (rafRef.current != null) {
cancelAnimationFrame(rafRef.current);
rafRef.current = null;
}
};
}, [fullText, cps, sentencePauseMs, paragraphPauseMs, commaPauseMs, catchupThresholdChars, catchupMultiplier]);
// Read fresh substring on each render. No allocation per RAF tick
// unless the displayed length actually changed (we only setTick when
// it does).
return fullText.slice(0, displayedLenRef.current);
}