mirror of
https://github.com/openswarm-ai/openswarm.git
synced 2026-08-18 02:35:41 +02:00
[eric] perf: identity-stable selectors, tool rows stop subscribing to whole slices, thumbnails wait out interactions
This commit is contained in:
@@ -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());
|
||||
|
||||
Reference in New Issue
Block a user