[eric] perf: a send no longer re-renders the whole board; four card callbacks keep one identity, cards select their own slice, the selection provider memoises its value, the header lists a projection with memoised rows, the per-bubble upgrade modal mounts on demand, bubbles get a bucketed viewport height (ENG-467, ENG-487)

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
ciregenz
2026-09-07 12:17:30 -07:00
co-authored by Claude Fable 5.1
parent 8b4191ef95
commit e6caedb392
12 changed files with 130 additions and 85 deletions
@@ -136,28 +136,29 @@ export const ElementSelectionProvider: React.FC<{ children: React.ReactNode }> =
});
}, []);
// One value identity per state change: the inline literal was a new object on every provider render, and every
// browser card reads this context, so all of them re-rendered on each render of the app shell (a send, for one).
const value = useMemo(() => ({
selectMode,
toggleSelectMode,
setSelectMode,
excludeSelectId,
setExcludeSelectId,
activeOwnerId,
setActiveOwnerId,
selectedElements,
addSelectedElement,
updateSelectedElement,
removeSelectedElement,
clearSelectedElements,
elementsByOwner,
addElementForOwner,
removeOwnerElement,
clearOwnerElements,
iframeRef,
}), [selectMode, toggleSelectMode, excludeSelectId, activeOwnerId, selectedElements, addSelectedElement, updateSelectedElement, removeSelectedElement, clearSelectedElements, elementsByOwner, addElementForOwner, removeOwnerElement, clearOwnerElements]);
return (
<ElementSelectionContext.Provider
value={{
selectMode,
toggleSelectMode,
setSelectMode,
excludeSelectId,
setExcludeSelectId,
activeOwnerId,
setActiveOwnerId,
selectedElements,
addSelectedElement,
updateSelectedElement,
removeSelectedElement,
clearSelectedElements,
elementsByOwner,
addElementForOwner,
removeOwnerElement,
clearOwnerElements,
iframeRef,
}}
>
<ElementSelectionContext.Provider value={value}>
{children}
</ElementSelectionContext.Provider>
);
@@ -397,6 +397,9 @@ const AgentChat: React.FC<AgentChatProps> = ({ sessionId: sessionIdProp, onClose
const windowEndRef = useRef(0);
const windowScrollRafRef = useRef<number | null>(null);
const [viewportHeight, setViewportHeight] = useState(0);
// Bubbles read the height only for the oversized-message threshold, so hand them an 80 px bucket: the scroller's
// clientHeight moves a few px as the composer and working slot change, and the raw value re-rendered every bubble per message.
const bubbleViewportHeight = Math.round(viewportHeight / 80) * 80;
const [viewportWidth, setViewportWidth] = useState(0);
const [scrollRoot, setScrollRoot] = useState<HTMLDivElement | null>(null);
const [windowStart, setWindowStart] = useState(0);
@@ -2077,7 +2080,7 @@ const AgentChat: React.FC<AgentChatProps> = ({ sessionId: sessionIdProp, onClose
editing={isEditing}
onSaveEdit={handleSaveEdit}
onCancelEdit={handleCancelEdit}
viewportHeight={viewportHeight}
viewportHeight={bubbleViewportHeight}
viewportWidth={viewportWidth}
scrollRoot={scrollRoot}
/>
@@ -1365,15 +1365,18 @@ const ChatMessageBubble: React.FC<Props> = ({ message, editing = false, onSaveEd
Waiting for the current step to finish. Press Stop to send it now.
</Typography>
)}
<PlanPickerModal
open={pickerOpen}
onClose={() => setPickerOpen(false)}
title="Upgrade your plan"
subtitle="Pick a plan to keep going. Cancel anytime from Stripe."
source="upgrade_cta"
defaultPlan="pro_plus"
onSubscribed={() => setPickerOpen(false)}
/>
{/* Mounted only while open: every bubble carried a closed modal, so a 40-message transcript ran 40 modal renders per streamed message. */}
{pickerOpen && (
<PlanPickerModal
open
onClose={() => setPickerOpen(false)}
title="Upgrade your plan"
subtitle="Pick a plan to keep going. Cancel anytime from Stripe."
source="upgrade_cta"
defaultPlan="pro_plus"
onSubscribed={() => setPickerOpen(false)}
/>
)}
</Box>
);
};
@@ -1,10 +1,11 @@
import React, { useEffect, type RefObject } from 'react';
import React, { useEffect, type RefObject, useMemo } from 'react';
import Box from '@mui/material/Box';
import { useAppDispatch, useAppSelector } from '@/shared/hooks';
import { addViewCard, addBrowserTab, clearTiledCard, toggleMinimizeCard, selectFullscreenCardId, selectViewportCoveringCardId } from '@/shared/state/dashboardLayoutSlice';
import { store } from '@/shared/state/store';
import { buildDockEntries } from '../desktop/dockEntries';
import DashboardHeader from './DashboardHeader';
import type { HeaderSession } from './DashboardHeader';
import TetherLayerHost from './TetherLayerHost';
import { useLiveMultiDrag } from '../hooks/interaction/useLiveMultiDrag';
import DashboardCardLayer from './DashboardCardLayer';
@@ -346,6 +347,14 @@ const DashboardCanvas: React.FC<DashboardCanvasProps> = ({
canvas.actions.syncTransform();
});
// The header lists id, name, status and model; keyed on those values so a streamed message (a new sessions map) re-lists nothing.
const headerSig = Object.values(sessions).map((s) => `${s.id}|${s.name}|${s.status}|${s.model}`).join('\n');
const headerSessions = useMemo<Record<string, HeaderSession>>(() => {
const out: Record<string, HeaderSession> = {};
for (const s of Object.values(sessions)) out[s.id] = { id: s.id, name: s.name, status: s.status, model: s.model };
return out;
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [headerSig]);
return (
<>
<Box sx={{ position: 'relative', height: '100%', overflow: 'hidden' }}>
@@ -377,7 +386,7 @@ const DashboardCanvas: React.FC<DashboardCanvasProps> = ({
<Box sx={{ display: 'flex', alignItems: 'center', pointerEvents: headerRevealed ? 'auto' : 'none' }}>
<DashboardHeader
dashboardName={dashboardName}
sessions={sessions}
sessions={headerSessions}
cards={cards}
viewCards={viewCards}
browserCards={browserCards}
@@ -1,4 +1,5 @@
import React, { useState, useRef, useEffect, useCallback } from 'react';
import { useStableCallback } from '@/shared/hooks/useStableCallback';
import Box from '@mui/material/Box';
import Typography from '@mui/material/Typography';
import SmartToyOutlinedIcon from '@mui/icons-material/SmartToyOutlined';
@@ -21,9 +22,12 @@ import type { Output } from '@/shared/state/outputsSlice';
import type { CanvasActions } from '../hooks/interaction/useCanvasControls';
import { friendlyStatusLabel } from '@/shared/statusLabel';
export type HeaderSession = Pick<AgentSession, 'id' | 'name' | 'status' | 'model'>;
interface DashboardHeaderProps {
dashboardName: string | undefined;
sessions: Record<string, AgentSession>;
// Only the fields the list reads: the whole sessions map re-rendered every row on every streamed message.
sessions: Record<string, HeaderSession>;
cards: Record<string, CardPosition>;
viewCards: Record<string, ViewCardPosition>;
browserCards: Record<string, BrowserCardPosition>;
@@ -119,6 +123,7 @@ const DashboardHeader: React.FC<DashboardHeaderProps> = ({
},
[canvasActions, onHighlightCard],
);
const focusStable = useStableCallback(handleFocus);
const toggle = useCallback(() => {
if (hasItems) setExpanded((v) => !v);
@@ -272,29 +277,7 @@ const DashboardHeader: React.FC<DashboardHeaderProps> = ({
{agentItems.length > 0 && (
<CategoryGroup icon={<SmartToyOutlinedIcon />} label="Agents" count={agentItems.length} c={c}>
{agentItems.map((item) => (
<ItemRow key={item.id} onClick={() => handleFocus(item.id, item.card)} c={c}>
<Box
sx={{
width: 7,
height: 7,
borderRadius: '50%',
bgcolor: STATUS_DOT[item.status] || c.text.tertiary,
flexShrink: 0,
mt: '1px',
}}
/>
<Typography
noWrap
sx={{ fontSize: '0.8125rem', color: c.text.primary, flex: 1, minWidth: 0 }}
>
{item.name}
</Typography>
<Typography
sx={{ fontSize: '0.6875rem', color: c.text.ghost, flexShrink: 0 }}
>
{friendlyStatusLabel(item.status)}
</Typography>
</ItemRow>
<AgentRow key={item.id} id={item.id} name={item.name} status={item.status} x={item.card.x} y={item.card.y} width={item.card.width} height={item.card.height} onFocus={focusStable} c={c} />
))}
</CategoryGroup>
)}
@@ -379,6 +362,21 @@ const CategoryGroup: React.FC<{
</Box>
);
// One row per agent on primitives, memoised: the list rebuilt every row's closure on every header render, and the header
// renders whenever a session's name or status moves, so a 60-card board paid 60 row renders per event.
const AgentRow = React.memo(({ id, name, status, x, y, width, height, onFocus, c }: {
id: string; name: string; status: string; x: number; y: number; width: number; height: number;
onFocus: (cardId: string, card: { x: number; y: number; width: number; height: number }) => void;
c: ReturnType<typeof useClaudeTokens>;
}) => (
<ItemRow onClick={() => onFocus(id, { x, y, width, height })} c={c}>
<Box sx={{ width: 7, height: 7, borderRadius: '50%', bgcolor: STATUS_DOT[status] || c.text.tertiary, flexShrink: 0, mt: '1px' }} />
<Typography noWrap sx={{ fontSize: '0.8125rem', color: c.text.primary, flex: 1, minWidth: 0 }}>{name}</Typography>
<Typography sx={{ fontSize: '0.6875rem', color: c.text.ghost, flexShrink: 0 }}>{friendlyStatusLabel(status)}</Typography>
</ItemRow>
));
AgentRow.displayName = 'AgentRow';
const ItemRow: React.FC<{
onClick: () => void;
c: ReturnType<typeof useClaudeTokens>;
@@ -0,0 +1,23 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync, existsSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
// ENG-487 / ENG-467: on a send the selection object is rebuilt and the welcome flag flips, and four callbacks handed to every
// memoized AgentCard changed identity with them, so the whole board re-rendered (4,800 fibers, 380 ms at 4x). Each of the four
// is wrapped in useStableCallback now; the profiler (commit_profile.mjs) is the liveness proof, this pins the wiring.
const here = fileURLToPath(new URL('.', import.meta.url));
const src = here.replace(/([\\/])\.test-build([\\/])/, '$1src$2');
const files = {
'hooks/interaction/useDashboardInteractions.ts': ['handleCardSelect'],
'hooks/interaction/useCardDrag.ts': ['handleCardDragStart', 'handleCardDragEnd'],
'hooks/lifecycle/useAgentSpawn.ts': ['handleBranchFromCard'],
};
for (const [rel, names] of Object.entries(files)) {
test(`${rel}: ${names.join(', ')} keep one identity across renders`, () => {
const path = src + rel;
assert.ok(existsSync(path), `could not locate ${rel} from ${here}`);
const text = readFileSync(path, 'utf8');
for (const n of names) assert.match(text, new RegExp(`const ${n} = useStableCallback\\(${n}Impl\\);`), n);
});
}
@@ -307,31 +307,18 @@ const AgentCard: React.FC<Props> = ({
const expanded = expandedInStore || isTiled;
const isDashboardActive = useDashboardActive();
const hasApiKey = !!useAppSelector((s) => s.settings.data.anthropic_api_key);
const expandedSessionIds = useAppSelector((s) => s.agents.expandedSessionIds);
const workflowSuggestion = useMemo(() => findWorkflowSuggestion(session), [session]);
// Suppress the convert-suggestion glow when this chat is already entangled with a workflow. Two cases: (a) The session is one of a workflow's runner sessions, OR (b) The session is the source the workflow was originally derived from. Either way a fresh convert would just clone the workflow, which is confusing identity collapse.
const workflowRunsMap = useAppSelector((s) => s.workflows.runs);
const workflowItems = useAppSelector((s) => s.workflows.items);
const linkedWorkflowSidecarId = useAppSelector((s) => {
const entry = Object.values(s.workflows.openCards).find((card) => card.sidecarSessionId === session.id);
return entry?.workflowId ?? null;
});
const sourceWorkflow = useMemo(() => {
for (const wf of Object.values(workflowItems || {})) {
if (wf.source_session_id === session.id) return wf;
}
return null;
}, [workflowItems, session.id]);
const isWorkflowRunnerSession = useMemo(() => {
// A Test Agent (spawned to validate a workflow draft) isn't a chat to convert; it carries workflow_test_state.
if (session.workflow_test_state) return true;
for (const arr of Object.values(workflowRunsMap || {})) {
for (const r of arr || []) {
if (r.session_id === session.id) return true;
}
}
return Boolean(sourceWorkflow);
}, [workflowRunsMap, sourceWorkflow, session.id, session.workflow_test_state]);
// Per-card answers, not the whole workflow maps: subscribing every card to `workflows.items` and `workflows.runs`
// re-rendered the entire board on any run update. The object is a stable reference until that workflow changes.
const sourceWorkflow = useAppSelector((s) => Object.values(s.workflows.items || {}).find((wf) => wf.source_session_id === session.id) ?? null);
const hasWorkflowRun = useAppSelector((s) => Object.values(s.workflows.runs || {}).some((arr) => (arr || []).some((r) => r.session_id === session.id)));
// A Test Agent (spawned to validate a workflow draft) isn't a chat to convert; it carries workflow_test_state.
const isWorkflowRunnerSession = Boolean(session.workflow_test_state) || hasWorkflowRun || Boolean(sourceWorkflow);
const hasUserPrompt = useMemo(
() => session.messages.length > 0
? session.messages.some((m) => m.role === 'user' && !m.hidden)
@@ -375,13 +362,14 @@ const AgentCard: React.FC<Props> = ({
return;
}
if (scheduleWorkflowCount <= baselineScheduleCountRef.current) return;
for (const wf of Object.values(workflowItems || {})) {
// The count is the trigger; the list is read when it fires, so the map is not a subscription.
for (const wf of Object.values(store.getState().workflows.items || {})) {
if (wf.source_session_id !== session.id) continue;
if (autoOpenedWorkflowIdsRef.current.has(wf.id)) continue;
autoOpenedWorkflowIdsRef.current.add(wf.id);
dispatch(openWorkflowsApp({ workflowId: wf.id }));
}
}, [scheduleWorkflowCount, workflowItems, session.id, dispatch]);
}, [scheduleWorkflowCount, session.id, dispatch]);
const cardBoxRef = useRef<HTMLDivElement>(null);
// Ref so ResizeObserver sees latest value without re-attaching when active flips.
@@ -792,7 +780,8 @@ const AgentCard: React.FC<Props> = ({
onContextMenu={(e: React.MouseEvent) => { if (isNativeMenuTarget(e)) return; if ((e.target as HTMLElement).closest?.('[data-chat-transcript]')) return; openCardContextMenu(e, {
rename: { value: displayChatTitle(session), onCommit: (name) => dispatch(renameSession({ sessionId: session.id, name })) },
items: agentCardMenuRows({
session, dispatch, expanded, tileZone, expandedSessionIds,
// Read at click time: subscribing every card to the expanded list re-rendered the whole board on each expand (a send expands the new chat).
session, dispatch, expanded, tileZone, expandedSessionIds: store.getState().agents.expandedSessionIds,
card: { x: cardX, y: cardY, width: cardWidth, height: cardHeight },
onTile, onClose: () => handleRemove(),
}),
@@ -1073,8 +1073,8 @@ const BrowserCard: React.FC<Props> = ({
const accentColor = c.accent.primary;
const glowingBrowserCards = useAppSelector((s) => s.dashboardLayout.glowingBrowserCards);
const browserGlow = glowingBrowserCards[browserId];
// Only this card's entry: the whole map re-rendered every browser card on every agent action anywhere.
const browserGlow = useAppSelector((s) => s.dashboardLayout.glowingBrowserCards[browserId]);
// Drop the glow the moment the agent's done (fading) so it eases off via the 0.4s box-shadow transition, instead of holding full until the entry clears. The tether arrow already keyed off `fading`; the card never did.
const showGlow = !!browserGlow && !browserGlow.fading;
@@ -1,4 +1,5 @@
import { useCallback, useEffect, useRef, useState, type RefObject } from 'react';
import { useStableCallback } from '@/shared/hooks/useStableCallback';
import { report } from '@/shared/serviceClient';
import { useAppDispatch } from '@/shared/hooks';
import { moveCards } from '@/shared/state/dashboardLayoutSlice';
@@ -86,7 +87,7 @@ export function useCardDrag({
edgePanFrameRef.current = requestAnimationFrame(tickEdgePan);
}, [viewportRef, canvasActions]);
const handleCardDragStart = useCallback((id: string, type: CardType) => {
const handleCardDragStartImpl = useCallback((id: string, type: CardType) => {
activeDragCardRef.current = id;
// Multi only when there is actually company: a lone selected card on this path made every drag after the first pay a setState per frame.
if (selection.isSelected(id) && selection.selectedArray().length > 1) {
@@ -98,6 +99,7 @@ export function useCardDrag({
isMultiDragRef.current = false;
}
}, [selection]);
const handleCardDragStart = useStableCallback(handleCardDragStartImpl);
const handleCardDragMove = useCallback((dx: number, dy: number, mouseX?: number, mouseY?: number) => {
if (mouseX !== undefined && mouseY !== undefined) {
@@ -143,7 +145,7 @@ export function useCardDrag({
}
}, [stopEdgePan, canvasActions]);
const handleCardDragEnd = useCallback((dx: number, dy: number, didDrag: boolean) => {
const handleCardDragEndImpl = useCallback((dx: number, dy: number, didDrag: boolean) => {
if (didDrag) report('dashboard', 'card_dragged');
if (isMultiDragRef.current && didDrag) {
const items = selection.selectedArray()
@@ -154,6 +156,7 @@ export function useCardDrag({
}
clearDrag();
}, [selection, dispatch, clearDrag]);
const handleCardDragEnd = useStableCallback(handleCardDragEndImpl);
// Backstop: a pointercancel or a lost pointer capture never reaches the card's onDragEnd, which would otherwise strand the drag with the rAF above panning forever. A normal release runs the card's commit first, since React delegates to the root container and this fires as the event bubbles on past it.
useEffect(() => {
@@ -1,4 +1,5 @@
import React, { useCallback, useEffect, useRef, type Dispatch, type SetStateAction } from 'react';
import { useStableCallback } from '@/shared/hooks/useStableCallback';
import { report } from '@/shared/serviceClient';
import { useAppDispatch } from '@/shared/hooks';
import { store } from '@/shared/state/store';
@@ -76,7 +77,7 @@ export function useDashboardInteractions({
// Delay single-click collapse so double-click can override
const clickTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const handleCardSelect = useCallback((id: string, type: CardType, shiftKey: boolean, originTarget?: EventTarget | null) => {
const handleCardSelectImpl = useCallback((id: string, type: CardType, shiftKey: boolean, originTarget?: EventTarget | null) => {
report('dashboard', 'card_clicked', { card_type: type, shift: shiftKey });
if (shiftKey) {
selection.selectCard(id, type, true);
@@ -136,6 +137,7 @@ export function useDashboardInteractions({
};
setTimeout(() => tryFit(0), 100);
}, [selection, getCardRect, canvas.actions, dispatch, expandedSessionIds]);
const handleCardSelect = useStableCallback(handleCardSelectImpl);
const handleBringToFront = useCallback((id: string, type: CardType) => {
// Deferred past the pointerdown's paint: this fires on EVERY card press and the z-restack was
@@ -1,4 +1,5 @@
import { useCallback, type Dispatch, type RefObject, type SetStateAction } from 'react';
import { useStableCallback } from '@/shared/hooks/useStableCallback';
import { report } from '@/shared/serviceClient';
import { store } from '@/shared/state/store';
import { useAppDispatch } from '@/shared/hooks';
@@ -72,7 +73,7 @@ export function useAgentSpawn({
const dispatch = useAppDispatch();
const getSpawnPlacement = useSpawnPlacement({ selection, viewportRef, canvasStateRef, expandedSessionIds });
const handleBranchFromCard = useCallback(
const handleBranchFromCardImpl = useCallback(
(sourceSessionId: string, newSessionId: string) => {
const sourceCard = cards[sourceSessionId];
if (!sourceCard) return;
@@ -122,6 +123,7 @@ export function useAgentSpawn({
}
setToolbarOpen(true);
}, [welcomeEligible, onWelcomeNewAgent, setToolbarOpen]);
const handleBranchFromCard = useStableCallback(handleBranchFromCardImpl);
const handleToolbarCancel = useCallback(() => {
setToolbarOpen(false);
@@ -0,0 +1,12 @@
import { useCallback, useRef } from 'react';
// A callback whose IDENTITY never changes while its body always sees the latest closure. For a
// handler handed to every memoized card on the board: a plain useCallback whose deps include the
// selection re-created itself on every send, and 150 AgentCards re-rendered their whole chrome for
// it (a 380 ms commit on a 4x-throttled machine, the ENG-467 send freeze). Only for event handlers:
// the latest body is read at CALL time, never at render time.
export function useStableCallback<A extends unknown[], R>(fn: (...args: A) => R): (...args: A) => R {
const latest = useRef(fn);
latest.current = fn;
return useCallback((...args: A) => latest.current(...args), []);
}