diff --git a/frontend/src/app/pages/AgentChat/StreamingBubble.tsx b/frontend/src/app/pages/AgentChat/StreamingBubble.tsx index e02dceca..d28cd733 100644 --- a/frontend/src/app/pages/AgentChat/StreamingBubble.tsx +++ b/frontend/src/app/pages/AgentChat/StreamingBubble.tsx @@ -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 = ({ 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 diff --git a/frontend/src/app/pages/Customization/Customization.tsx b/frontend/src/app/pages/Customization/Customization.tsx index e388e690..6956c287 100644 --- a/frontend/src/app/pages/Customization/Customization.tsx +++ b/frontend/src/app/pages/Customization/Customization.tsx @@ -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', }} > { 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', }} > diff --git a/frontend/src/app/pages/Skills/Skills.tsx b/frontend/src/app/pages/Skills/Skills.tsx index 5e6a3373..b18d65c5 100644 --- a/frontend/src/app/pages/Skills/Skills.tsx +++ b/frontend/src/app/pages/Skills/Skills.tsx @@ -362,7 +362,7 @@ const Skills: React.FC = () => { {/* Search input (toggled) */} - + { ({filteredLocal.length}) - + {filteredLocal.map((sk) => ( { ({group.length}) - + {group.map((sk) => ( = ({ )} - + @@ -438,7 +438,7 @@ const ToolSection: React.FC = ({ onCategoryPermissionChange(catTools.map((t) => t.name), v)} /> - + {catTools.map((bt) => { const toolPolicy = builtinPermissions[bt.name] || 'always_allow'; @@ -1131,7 +1131,7 @@ const Tools: React.FC = () => { Built-in Action Sets - + {/* Core Tools */} @@ -1183,7 +1183,7 @@ const Tools: React.FC = () => { )} - + {outputs.map((out) => { @@ -1265,7 +1265,7 @@ const Tools: React.FC = () => { )} - + @@ -1299,7 +1299,7 @@ const Tools: React.FC = () => { 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 } }}> - + {browserDelegationTools.map((bt) => { const toolPolicy = builtinPermissions[bt.name] || 'always_allow'; @@ -1347,7 +1347,7 @@ const Tools: React.FC = () => { 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 } }}> - + {browserActionTools.map((bt) => { const toolPolicy = builtinPermissions[bt.name] || 'always_allow'; @@ -1388,7 +1388,7 @@ const Tools: React.FC = () => { Custom Action Sets - + {loading ? ( {[0, 1, 2, 3].map((i) => ( @@ -1538,7 +1538,7 @@ const Tools: React.FC = () => { handleGroupPermissionChange(tool.id, allNames, v)} /> - + {(data.read?.length || 0) > 0 && ( @@ -1770,7 +1770,7 @@ const Tools: React.FC = () => { - + @@ -2077,7 +2077,7 @@ const Tools: React.FC = () => { - + {srv.description} diff --git a/frontend/src/app/pages/Views/ViewCard.tsx b/frontend/src/app/pages/Views/ViewCard.tsx index 7f03b71e..fe43c6e6 100644 --- a/frontend/src/app/pages/Views/ViewCard.tsx +++ b/frontend/src/app/pages/Views/ViewCard.tsx @@ -29,10 +29,19 @@ const ViewCard: React.FC = ({ 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 = ({ 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 = ({ 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); diff --git a/frontend/src/app/pages/Views/Views.tsx b/frontend/src/app/pages/Views/Views.tsx index d80563c3..b7ceb4b4 100644 --- a/frontend/src/app/pages/Views/Views.tsx +++ b/frontend/src/app/pages/Views/Views.tsx @@ -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 ; + return ( + Loading editor...}> + + + ); } return ( diff --git a/frontend/src/shared/state/dashboardLayoutSlice.ts b/frontend/src/shared/state/dashboardLayoutSlice.ts index 8a10c262..33ea67ae 100644 --- a/frontend/src/shared/state/dashboardLayoutSlice.ts +++ b/frontend/src/shared/state/dashboardLayoutSlice.ts @@ -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]; diff --git a/frontend/src/shared/useTypewriter.ts b/frontend/src/shared/useTypewriter.ts deleted file mode 100644 index 9e5edf82..00000000 --- a/frontend/src/shared/useTypewriter.ts +++ /dev/null @@ -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(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); -}