mirror of
https://github.com/openswarm-ai/openswarm.git
synced 2026-09-12 04:37:44 +02:00
[eric] frontend perf: instant collapses, lazy app editor, stale-id reap
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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 }}>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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];
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user