From efef22777901eba36c8448d62530a46675e2bacd Mon Sep 17 00:00:00 2001 From: ciregenz Date: Wed, 5 Aug 2026 10:06:15 -0700 Subject: [PATCH] [eric] perf: identity-stable selectors, tool rows stop subscribing to whole slices, thumbnails wait out interactions --- .../app/components/nudges/AutomationChips.tsx | 4 +- .../components/nudges/ScheduleOfferToast.tsx | 6 +- .../AgentChat/tool-bubbles/ToolCallBubble.tsx | 72 ++---------------- .../AgentChat/tool-bubbles/revealSubAgent.ts | 73 +++++++++++++++++++ .../Dashboard/canvas/DashboardEmptyState.tsx | 6 +- .../hooks/state/useDashboardThumbnail.ts | 24 +++--- .../AddCustomConnectorDialog.tsx | 0 .../{ => dialogs}/CommunityInstallConfirm.tsx | 0 .../{ => dialogs}/UploadSkillDialog.tsx | 0 .../app/pages/Workflows/ScheduleCalendar.tsx | 4 +- 10 files changed, 109 insertions(+), 80 deletions(-) create mode 100644 frontend/src/app/pages/AgentChat/tool-bubbles/revealSubAgent.ts rename frontend/src/app/pages/Directory/{ => dialogs}/AddCustomConnectorDialog.tsx (100%) rename frontend/src/app/pages/Directory/{ => dialogs}/CommunityInstallConfirm.tsx (100%) rename frontend/src/app/pages/Directory/{ => dialogs}/UploadSkillDialog.tsx (100%) diff --git a/frontend/src/app/components/nudges/AutomationChips.tsx b/frontend/src/app/components/nudges/AutomationChips.tsx index dff1780c..e59c5e87 100644 --- a/frontend/src/app/components/nudges/AutomationChips.tsx +++ b/frontend/src/app/components/nudges/AutomationChips.tsx @@ -17,13 +17,15 @@ function scheduleFor(cadence: string) { return { ...base, repeat_unit: 'week' as const, on_days: [1] }; } +// Identity-stable fallback: `?? []` inline minted a fresh array per store tick and re-rendered this on every streamed character. +const EMPTY_AUTOMATIONS: PersonalizedAutomation[] = []; const CADENCE_LABEL: Record = { daily: 'daily at 9am', weekday: 'weekdays at 9am', weekly: 'Mondays at 9am' }; // Prep proposed routines worth automating for THIS user; each chip is one click to a real scheduled workflow. Falls back to a generic morning brief when prep gave none. One-shot per install (localStorage), so a returning user is never re-nagged. const AutomationChips: React.FC<{ c: ClaudeTokens }> = ({ c }) => { const dispatch = useAppDispatch(); const model = useAppSelector((s) => s.settings.data.default_model); - const proposed = useAppSelector((s) => s.settings.data.personalized_automations ?? []); + const proposed = useAppSelector((s) => s.settings.data.personalized_automations) ?? EMPTY_AUTOMATIONS; const items: PersonalizedAutomation[] = proposed.length > 0 ? proposed.slice(0, 3) : [ { title: 'Morning brief', prompt: "Put together my morning brief: today's date, my location's weather, and top tech + world headlines. Keep it under 300 words and save it as a dated note on my dashboard.", cadence: 'daily' }, ]; diff --git a/frontend/src/app/components/nudges/ScheduleOfferToast.tsx b/frontend/src/app/components/nudges/ScheduleOfferToast.tsx index 6b1b7cb8..ae5e0304 100644 --- a/frontend/src/app/components/nudges/ScheduleOfferToast.tsx +++ b/frontend/src/app/components/nudges/ScheduleOfferToast.tsx @@ -8,6 +8,10 @@ import { AnimatePresence, motion } from 'framer-motion'; import { useClaudeTokens } from '@/shared/styles/ThemeContext'; import { useAppDispatch, useAppSelector } from '@/shared/hooks'; import { createWorkflow } from '@/shared/state/workflowsSlice'; +import type { PersonalizedStarter } from '@/shared/state/settingsSlice'; + +// Identity-stable fallback so an absent settings field can't re-render this per store tick. +const EMPTY_STARTERS: PersonalizedStarter[] = []; const OFFER_DONE_KEY = 'openswarm.schedule-offer.v1'; @@ -31,7 +35,7 @@ const ScheduleOfferToast: React.FC<{ dashboardId: string }> = ({ dashboardId }) const dispatch = useAppDispatch(); const [resolved, setResolved] = useState(offerAlreadyResolved); const [confirmText, setConfirmText] = useState(null); - const starters = useAppSelector((s) => s.settings.data.personalized_starters ?? []); + const starters = useAppSelector((s) => s.settings.data.personalized_starters) ?? EMPTY_STARTERS; const sessions = useAppSelector((s) => s.agents.sessions); const model = useAppSelector((s) => s.settings.data.default_model); diff --git a/frontend/src/app/pages/AgentChat/tool-bubbles/ToolCallBubble.tsx b/frontend/src/app/pages/AgentChat/tool-bubbles/ToolCallBubble.tsx index a19ae9cc..2f53f7a6 100644 --- a/frontend/src/app/pages/AgentChat/tool-bubbles/ToolCallBubble.tsx +++ b/frontend/src/app/pages/AgentChat/tool-bubbles/ToolCallBubble.tsx @@ -1,8 +1,8 @@ import React, { useState, useCallback, useMemo, useRef } from 'react'; -import { AgentMessage, expandSession, collapseSession, fetchSession } from '@/shared/state/agentsSlice'; -import { useAppDispatch, useAppSelector } from '@/shared/hooks'; -import { placeCard, removeCard, setGlowingAgentCard, clearGlowingAgentCard, DEFAULT_CARD_W, DEFAULT_CARD_H, EXPANDED_CARD_MIN_H, GRID_GAP } from '@/shared/state/dashboardLayoutSlice'; +import { AgentMessage } from '@/shared/state/agentsSlice'; +import { useAppDispatch } from '@/shared/hooks'; import { useClaudeTokens } from '@/shared/styles/ThemeContext'; +import { revealSubAgent } from './revealSubAgent'; import { ensureToolCallKeyframes } from '../parsing/toolBubbleChrome'; import { getToolData, @@ -61,7 +61,6 @@ const ToolCallBubble: React.FC = React.memo( const c = useClaudeTokens(); const dispatch = useAppDispatch(); - const cards = useAppSelector((s) => s.dashboardLayout.cards); const [expanded, setExpanded] = useState(false); const bubbleRef = useRef(null); @@ -121,73 +120,14 @@ const ToolCallBubble: React.FC = React.memo( const revealTargetSessionId = invokedSessionId || createAgentSessionId; - const sessions = useAppSelector((s) => s.agents.sessions); - const expandedSessionIds = useAppSelector((s) => s.agents.expandedSessionIds); - const handleRevealAgent = useCallback( (e: React.MouseEvent) => { e.stopPropagation(); if (!revealTargetSessionId || !sessionId) return; - - if (cards[revealTargetSessionId]) { - dispatch(collapseSession(revealTargetSessionId)); - dispatch(removeCard(revealTargetSessionId)); - setTimeout(() => { - dispatch(clearGlowingAgentCard(revealTargetSessionId)); - }, 500); - return; - } - - let sourceYRatio: number | undefined; - if (bubbleRef.current) { - const bubbleEl = bubbleRef.current; - const cardEl = bubbleEl.closest('[data-select-type="agent-card"]') as HTMLElement | null; - if (cardEl) { - const cardRect = cardEl.getBoundingClientRect(); - const bubbleRect = bubbleEl.getBoundingClientRect(); - const bubbleCenterY = bubbleRect.top + bubbleRect.height / 2; - const ratio = (bubbleCenterY - cardRect.top) / cardRect.height; - sourceYRatio = Math.max(0, Math.min(1, ratio)); - } - } - - const doPlace = () => { - const parentCard = cards[sessionId]; - const targetX = parentCard - ? parentCard.x + parentCard.width + GRID_GAP * 12 - : 40; - let targetY = parentCard ? parentCard.y : 100; - if (parentCard) { - const columnCards = Object.values(cards).filter( - (c) => Math.abs(c.x - targetX) < 50 && c.session_id !== revealTargetSessionId, - ); - if (columnCards.length > 0) { - const lowestBottom = Math.max( - ...columnCards.map((c) => c.y + Math.max(EXPANDED_CARD_MIN_H, c.height)), - ); - targetY = lowestBottom + GRID_GAP; - } - } - dispatch(placeCard({ - sessionId: revealTargetSessionId, - x: targetX, - y: targetY, - width: DEFAULT_CARD_W, - height: DEFAULT_CARD_H, - expandedSessionIds, - })); - dispatch(expandSession(revealTargetSessionId)); - const label = isCreateAgent ? 'Create Agent' : isInvokeAgent ? 'Invoke Agent' : 'Agent'; - dispatch(setGlowingAgentCard({ sessionId: revealTargetSessionId, sourceId: sessionId, sourceYRatio, label })); - }; - - if (!sessions[revealTargetSessionId]) { - dispatch(fetchSession(revealTargetSessionId)).then(doPlace); - } else { - doPlace(); - } + const label = isCreateAgent ? 'Create Agent' : isInvokeAgent ? 'Invoke Agent' : 'Agent'; + revealSubAgent(dispatch, sessionId, revealTargetSessionId, bubbleRef.current, label); }, - [revealTargetSessionId, sessionId, cards, sessions, dispatch], + [revealTargetSessionId, sessionId, dispatch, isCreateAgent, isInvokeAgent], ); const toggle = useCallback(() => { diff --git a/frontend/src/app/pages/AgentChat/tool-bubbles/revealSubAgent.ts b/frontend/src/app/pages/AgentChat/tool-bubbles/revealSubAgent.ts new file mode 100644 index 00000000..9fad1658 --- /dev/null +++ b/frontend/src/app/pages/AgentChat/tool-bubbles/revealSubAgent.ts @@ -0,0 +1,73 @@ +import { expandSession, collapseSession, fetchSession } from '@/shared/state/agentsSlice'; +import { placeCard, removeCard, setGlowingAgentCard, clearGlowingAgentCard, DEFAULT_CARD_W, DEFAULT_CARD_H, EXPANDED_CARD_MIN_H, GRID_GAP } from '@/shared/state/dashboardLayoutSlice'; +import { store } from '@/shared/state/store'; +import type { AppDispatch } from '@/shared/state/store'; + +// The reveal-sub-agent click, reading the store lazily on purpose: subscribing every tool row to +// whole sessions/cards re-rendered the entire transcript per streamed character (ENG-156). +export function revealSubAgent( + dispatch: AppDispatch, + sessionId: string, + targetSessionId: string, + bubbleEl: HTMLElement | null, + label: string, +): void { + const cards = store.getState().dashboardLayout.cards; + + if (cards[targetSessionId]) { + dispatch(collapseSession(targetSessionId)); + dispatch(removeCard(targetSessionId)); + setTimeout(() => { + dispatch(clearGlowingAgentCard(targetSessionId)); + }, 500); + return; + } + + let sourceYRatio: number | undefined; + if (bubbleEl) { + const cardEl = bubbleEl.closest('[data-select-type="agent-card"]') as HTMLElement | null; + if (cardEl) { + const cardRect = cardEl.getBoundingClientRect(); + const bubbleRect = bubbleEl.getBoundingClientRect(); + const bubbleCenterY = bubbleRect.top + bubbleRect.height / 2; + const ratio = (bubbleCenterY - cardRect.top) / cardRect.height; + sourceYRatio = Math.max(0, Math.min(1, ratio)); + } + } + + const doPlace = (): void => { + const cardsNow = store.getState().dashboardLayout.cards; + const parentCard = cardsNow[sessionId]; + const targetX = parentCard + ? parentCard.x + parentCard.width + GRID_GAP * 12 + : 40; + let targetY = parentCard ? parentCard.y : 100; + if (parentCard) { + const columnCards = Object.values(cardsNow).filter( + (c) => Math.abs(c.x - targetX) < 50 && c.session_id !== targetSessionId, + ); + if (columnCards.length > 0) { + const lowestBottom = Math.max( + ...columnCards.map((c) => c.y + Math.max(EXPANDED_CARD_MIN_H, c.height)), + ); + targetY = lowestBottom + GRID_GAP; + } + } + dispatch(placeCard({ + sessionId: targetSessionId, + x: targetX, + y: targetY, + width: DEFAULT_CARD_W, + height: DEFAULT_CARD_H, + expandedSessionIds: store.getState().agents.expandedSessionIds, + })); + dispatch(expandSession(targetSessionId)); + dispatch(setGlowingAgentCard({ sessionId: targetSessionId, sourceId: sessionId, sourceYRatio, label })); + }; + + if (!store.getState().agents.sessions[targetSessionId]) { + void dispatch(fetchSession(targetSessionId)).then(doPlace); + } else { + doPlace(); + } +} diff --git a/frontend/src/app/pages/Dashboard/canvas/DashboardEmptyState.tsx b/frontend/src/app/pages/Dashboard/canvas/DashboardEmptyState.tsx index b44b8a8e..b104a1b2 100644 --- a/frontend/src/app/pages/Dashboard/canvas/DashboardEmptyState.tsx +++ b/frontend/src/app/pages/Dashboard/canvas/DashboardEmptyState.tsx @@ -11,6 +11,10 @@ import { hasFreeTrialActive, } from '@/app/components/Onboarding/steps/skipPredicates'; import { HERO_CATEGORIES, heroMenuFor, type HeroCategoryId } from './heroMenu'; +import type { PersonalizedStarter } from '@/shared/state/settingsSlice'; + +// Identity-stable fallback so an absent settings field can't re-render this per store tick. +const EMPTY_STARTERS: PersonalizedStarter[] = []; // Empty canvas, styled after ChatGPT / Claude / Manus: a short question, a centered composer as the // HERO, then a two-level menu: 4 GENERAL things OpenSwarm can do, each drilling into 4 SPECIFIC @@ -70,7 +74,7 @@ const DashboardEmptyState: React.FC<{ const mode = useAppSelector((s) => s.settings.data.default_mode); const canRun = useAppSelector((s) => hasFreeTrialActive(s) || hasModelConnected(s)); const settingsKnown = useAppSelector((s) => s.settings.loaded); - const personalized = useAppSelector((s) => s.settings.data.personalized_starters ?? []); + const personalized = useAppSelector((s) => s.settings.data.personalized_starters) ?? EMPTY_STARTERS; const personalizedMenu = useAppSelector((s) => s.settings.data.personalized_menu ?? null); const userName = useAppSelector((s) => s.settings.data.user_name ?? null); const [text, setText] = React.useState(''); diff --git a/frontend/src/app/pages/Dashboard/hooks/state/useDashboardThumbnail.ts b/frontend/src/app/pages/Dashboard/hooks/state/useDashboardThumbnail.ts index 9f4cd5aa..e1aacaeb 100644 --- a/frontend/src/app/pages/Dashboard/hooks/state/useDashboardThumbnail.ts +++ b/frontend/src/app/pages/Dashboard/hooks/state/useDashboardThumbnail.ts @@ -3,6 +3,7 @@ import { store } from '@/shared/state/store'; import { useAppSelector } from '@/shared/hooks'; import { updateDashboardThumbnail } from '@/shared/state/dashboardsSlice'; import { anyWebviewLoading } from '@/shared/browserRegistry'; +import { isCanvasInteractionActive } from '@/shared/canvasInteractionState'; import { isAnyBrowserBusy } from '@/shared/browserCommandHandler'; import { captureDashboardThumbnail } from '../../geometry/captureDashboardThumbnail'; @@ -67,8 +68,8 @@ export function useDashboardThumbnail({ } return; } - // Capturing the dashboard composites live webview pixels; doing it while a browser webview is mid-navigation OR an agent is actively driving it (its GPU surface recycling) crashes the renderer (SharedImage 'non-existent mailbox' -> V8 ToLocalChecked). Wait for it to go quiet; after a few tries, skip this round and keep the old preview rather than risk the crash. - if (anyWebviewLoading() || isAnyBrowserBusy()) { + // Capturing the dashboard composites live webview pixels; doing it while a browser webview is mid-navigation OR an agent is actively driving it (its GPU surface recycling) crashes the renderer (SharedImage 'non-existent mailbox' -> V8 ToLocalChecked). Wait for it to go quiet; after a few tries, skip this round and keep the old preview rather than risk the crash. Mid-gesture captures also land inside the user's interaction frames (ENG-156's presentation delay), so those wait too. + if (anyWebviewLoading() || isAnyBrowserBusy() || isCanvasInteractionActive()) { if (captureRetriesRef.current < 6) { captureRetriesRef.current += 1; if (captureTimerRef.current) clearTimeout(captureTimerRef.current); @@ -83,14 +84,17 @@ export function useDashboardThumbnail({ browserCards: layoutState.browserCards, }; const capturingId = dashboardId; - captureDashboardThumbnail(viewportEl, contentEl, allCards) - .then((thumbnail) => { - if (!thumbnail) return; - if (sig === lastSavedSignatureRef.current) return; - store.dispatch(updateDashboardThumbnail({ id: capturingId, thumbnail, signature: sig })); - lastSavedSignatureRef.current = sig; - }) - .catch(() => {}); + // Idle-scheduled so the capturePage IPC + dataURL decode never ride the frames right after a click. + requestIdleCallback(() => { + captureDashboardThumbnail(viewportEl, contentEl, allCards) + .then((thumbnail) => { + if (!thumbnail) return; + if (sig === lastSavedSignatureRef.current) return; + store.dispatch(updateDashboardThumbnail({ id: capturingId, thumbnail, signature: sig })); + lastSavedSignatureRef.current = sig; + }) + .catch(() => {}); + }, { timeout: 3000 }); }, [dashboardId, viewportRef, contentRef]); // While visible, (re)snapshot a beat after the card set changes. If it already matches the saved shot (or was reverted back to it), cancel any pending capture instead of committing stale pixels. diff --git a/frontend/src/app/pages/Directory/AddCustomConnectorDialog.tsx b/frontend/src/app/pages/Directory/dialogs/AddCustomConnectorDialog.tsx similarity index 100% rename from frontend/src/app/pages/Directory/AddCustomConnectorDialog.tsx rename to frontend/src/app/pages/Directory/dialogs/AddCustomConnectorDialog.tsx diff --git a/frontend/src/app/pages/Directory/CommunityInstallConfirm.tsx b/frontend/src/app/pages/Directory/dialogs/CommunityInstallConfirm.tsx similarity index 100% rename from frontend/src/app/pages/Directory/CommunityInstallConfirm.tsx rename to frontend/src/app/pages/Directory/dialogs/CommunityInstallConfirm.tsx diff --git a/frontend/src/app/pages/Directory/UploadSkillDialog.tsx b/frontend/src/app/pages/Directory/dialogs/UploadSkillDialog.tsx similarity index 100% rename from frontend/src/app/pages/Directory/UploadSkillDialog.tsx rename to frontend/src/app/pages/Directory/dialogs/UploadSkillDialog.tsx diff --git a/frontend/src/app/pages/Workflows/ScheduleCalendar.tsx b/frontend/src/app/pages/Workflows/ScheduleCalendar.tsx index 0df0796f..88fa09d0 100644 --- a/frontend/src/app/pages/Workflows/ScheduleCalendar.tsx +++ b/frontend/src/app/pages/Workflows/ScheduleCalendar.tsx @@ -41,7 +41,9 @@ type ListRow = export default function ScheduleCalendar({ view, density, onSelectWorkflow, refDate }: Props) { const c = useClaudeTokens(); const dispatch = useAppDispatch(); - const workflows = useAppSelector((s) => Object.values(s.workflows.items)); + const workflowItems = useAppSelector((s) => s.workflows.items); + // Object.values inside the selector returned a fresh array per store notification; derive once per items identity. + const workflows = useMemo(() => Object.values(workflowItems), [workflowItems]); const allPaused = useAppSelector((s) => s.workflows.paused); // Live clock for the "now" line; a snapshot would drift and refDate may be a navigated week, so it can't double as the current moment. const [now, setNow] = useState(() => new Date());