[eric] perf: identity-stable selectors, tool rows stop subscribing to whole slices, thumbnails wait out interactions

This commit is contained in:
ciregenz
2026-08-05 10:06:15 -07:00
parent 59b982bf37
commit efef227779
10 changed files with 109 additions and 80 deletions
@@ -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<string, string> = { 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' },
];
@@ -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<string | null>(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);
@@ -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<ToolCallBubbleProps> = React.memo(
const c = useClaudeTokens();
const dispatch = useAppDispatch();
const cards = useAppSelector((s) => s.dashboardLayout.cards);
const [expanded, setExpanded] = useState(false);
const bubbleRef = useRef<HTMLDivElement>(null);
@@ -121,73 +120,14 @@ const ToolCallBubble: React.FC<ToolCallBubbleProps> = 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(() => {
@@ -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();
}
}
@@ -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('');
@@ -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.
@@ -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<Date>(() => new Date());