diff --git a/backend/apps/help/changelog.py b/backend/apps/help/changelog.py index 2f700896..2b058885 100644 --- a/backend/apps/help/changelog.py +++ b/backend/apps/help/changelog.py @@ -87,6 +87,7 @@ P_RELEASES: List[ReleaseNote] = [ "Brand-new apps stop dying at birth on busy machines. The first boot installs dependencies, which can take minutes; a fixed 60-second limit was killing exactly those boots.", "An agent that sent work to a browser can no longer hang forever when the finished result gets lost on the way back; it notices, recovers, and redoes the step.", "The app opens seconds faster on machines with a crowded system temp folder. File uploads moved into OpenSwarm's own folder, so startup no longer pays a toll that grew with years of temp-file clutter.", + "Clicking a chat in the sidebar or history now frames the whole card. The camera used to aim at the chat's collapsed footprint, so an opened chat could land with its bottom half off-screen and need a manual pan after every autofocus.", "Heavy sessions no longer vanish without a trace. When memory climbs past the safe line the app now sheds weight itself: preview thumbnails pause and refetchable caches drop, instead of growing until the operating system kills it mid-task.", ], ), diff --git a/frontend/src/app/pages/Dashboard/canvas/DashboardCardLayer.tsx b/frontend/src/app/pages/Dashboard/canvas/DashboardCardLayer.tsx index 80ced7f1..a834cdd8 100644 --- a/frontend/src/app/pages/Dashboard/canvas/DashboardCardLayer.tsx +++ b/frontend/src/app/pages/Dashboard/canvas/DashboardCardLayer.tsx @@ -7,6 +7,7 @@ import BrowserCard from '../cards/BrowserCard'; import DashboardWindowCards from './DashboardWindowCards'; import { EXPANDED_CARD_MIN_H, + renderedAgentCardHeight, DEFAULT_CARD_W, GRID_GAP, type CardPosition, @@ -102,9 +103,7 @@ const DashboardCardLayer: React.FC = ({ const srcCard = cards[glow.sourceId]; if (srcCard) { const srcH = measuredHeightsRef.current![glow.sourceId] - ?? (expandedSessionIds.includes(glow.sourceId) - ? Math.max(EXPANDED_CARD_MIN_H, srcCard.height) - : srcCard.height); + ?? renderedAgentCardHeight(srcCard.height, expandedSessionIds.includes(glow.sourceId)); origin = { x: srcCard.x + srcCard.width, y: srcCard.y + srcH / 2, @@ -120,9 +119,7 @@ const DashboardCardLayer: React.FC = ({ const srcCard = cards[glow.sourceId]; if (srcCard) { const srcH = measuredHeightsRef.current![glow.sourceId] - ?? (expandedSessionIds.includes(glow.sourceId) - ? Math.max(EXPANDED_CARD_MIN_H, srcCard.height) - : srcCard.height); + ?? renderedAgentCardHeight(srcCard.height, expandedSessionIds.includes(glow.sourceId)); exitTarget = { x: srcCard.x + srcCard.width, y: srcCard.y + srcH / 2, diff --git a/frontend/src/app/pages/Dashboard/geometry/getCardRect.test.ts b/frontend/src/app/pages/Dashboard/geometry/getCardRect.test.ts new file mode 100644 index 00000000..c92ebe1a --- /dev/null +++ b/frontend/src/app/pages/Dashboard/geometry/getCardRect.test.ts @@ -0,0 +1,52 @@ +// ENG-318: autofocus framed the STORED rect while an expanded chat renders >= EXPANDED_CARD_MIN_H +// tall plus a 64px title bubble above, so the camera landed with the card's bottom off-screen and +// the title beheaded. The correction existed as scattered Math.max copies; these pin the one shared +// helper and that the camera paths read the full framing envelope. +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import path from 'node:path'; +import { + EXPANDED_CARD_MIN_H, + EXPANDED_HEADER_H, + renderedAgentCardHeight, +} from '@/shared/state/dashboardLayoutSlice'; + +test('an expanded chat is never shorter than the overlay minimum', () => { + assert.equal(renderedAgentCardHeight(280, true), EXPANDED_CARD_MIN_H); + assert.equal(renderedAgentCardHeight(900, true), 900); +}); + +test('a collapsed chat keeps its stored height untouched', () => { + assert.equal(renderedAgentCardHeight(280, false), 280); + assert.equal(renderedAgentCardHeight(56, false), 56); +}); + +const here = path.join(process.cwd(), 'src/app/pages/Dashboard/geometry'); +const read = (rel: string): string => fs.readFileSync(path.join(here, rel), 'utf8'); + +test('getCardRect frames the rendered envelope, header included', () => { + const src = read('./getCardRect.ts'); + assert.ok(src.includes('renderedAgentCardHeight(card.height, true)'), 'expanded height must be the rendered one'); + assert.ok(src.includes('card.y - EXPANDED_HEADER_H'), 'the title bubble above the card must be inside the frame'); +}); + +test('the reveal paths frame through getCardRect, not raw store rects', () => { + // These two sites shipped the bug: history-resume and sidebar-focus flew to a 280px lie. + const actions = read('../hooks/lifecycle/useDashboardCardActions.ts'); + const resume = actions.slice(actions.indexOf('handleHistoryResume'), actions.indexOf('handleFitToView')); + assert.ok(resume.includes("getCardRect(sessionId, 'agent')"), 'history-resume must frame the envelope'); + assert.ok(!resume.includes('height: card.height'), 'the raw store rect is the bug'); + + const lifecycle = read('../hooks/lifecycle/useDashboardLifecycle.ts'); + const focusStart = lifecycle.indexOf('pendingFocusAgentId || !layoutInitialized'); + const focus = lifecycle.slice(focusStart, lifecycle.indexOf('pendingFocusBrowserId', focusStart)); + assert.ok(focus.includes("getCardRect(agentId, 'agent')"), 'sidebar-focus must frame the envelope'); + assert.ok(!focus.includes('height: card.height'), 'the raw store rect is the bug'); +}); + +test('the header constant has exactly one home', () => { + const actions = read('../hooks/lifecycle/useDashboardCardActions.ts'); + assert.ok(!actions.includes('EXPANDED_HEADER_H = 64'), 'local copies drift'); + assert.equal(EXPANDED_HEADER_H, 64); +}); diff --git a/frontend/src/app/pages/Dashboard/geometry/getCardRect.ts b/frontend/src/app/pages/Dashboard/geometry/getCardRect.ts index a9a5e9a1..fe4fa115 100644 --- a/frontend/src/app/pages/Dashboard/geometry/getCardRect.ts +++ b/frontend/src/app/pages/Dashboard/geometry/getCardRect.ts @@ -1,14 +1,20 @@ import { store } from '@/shared/state/store'; +import { EXPANDED_HEADER_H, renderedAgentCardHeight } from '@/shared/state/dashboardLayoutSlice'; import type { CardType } from '../hooks/state/useDashboardSelection'; -// Reads a card's rect straight from the live Redux store (collapsed height, which is what the zoom math wants). Module-level + store.getState() so the callback can stay stable across renders. +// Reads a card's RENDERED rect from the live Redux store. Module-level + store.getState() so the callback can stay stable across renders. export function getCardRect(id: string, type: CardType): { x: number; y: number; width: number; height: number } | undefined { const layoutState = store.getState().dashboardLayout; if (type === 'agent') { const card = layoutState.cards[id]; if (!card) return undefined; - return { x: card.x, y: card.y, width: card.width, height: card.height }; + const expanded = store.getState().agents.expandedSessionIds.includes(id); + if (!expanded) return { x: card.x, y: card.y, width: card.width, height: card.height }; + // Envelope, not stored rect: expanded chats render >= EXPANDED_CARD_MIN_H tall plus the title + // bubble above, and framing anything smaller is exactly the ENG-318 "autofocus missed" bug. + const height = renderedAgentCardHeight(card.height, true) + EXPANDED_HEADER_H; + return { x: card.x, y: card.y - EXPANDED_HEADER_H, width: card.width, height }; } else if (type === 'view') { const vc = layoutState.viewCards[id]; if (!vc) return undefined; diff --git a/frontend/src/app/pages/Dashboard/hooks/lifecycle/useDashboardCardActions.ts b/frontend/src/app/pages/Dashboard/hooks/lifecycle/useDashboardCardActions.ts index a9482142..6a794ac4 100644 --- a/frontend/src/app/pages/Dashboard/hooks/lifecycle/useDashboardCardActions.ts +++ b/frontend/src/app/pages/Dashboard/hooks/lifecycle/useDashboardCardActions.ts @@ -11,14 +11,14 @@ import { DEFAULT_VIEW_CARD_H, DEFAULT_BROWSER_CARD_W, DEFAULT_BROWSER_CARD_H, - EXPANDED_CARD_MIN_H, + EXPANDED_HEADER_H, + renderedAgentCardHeight, } from '@/shared/state/dashboardLayoutSlice'; import type { CardType, useDashboardSelection } from '../state/useDashboardSelection'; import type { CanvasActions } from '../interaction/useCanvasControls'; import { useSpawnPlacement } from './useSpawnPlacement'; // Title bubble + cost line, the strip an expanded card floats above itself. -const EXPANDED_HEADER_H = 64; type Selection = ReturnType; @@ -81,15 +81,15 @@ export function useDashboardCardActions({ dispatch(expandSession(sessionId)); setAutoFocusSessionId(sessionId); setTimeout(() => { - const card = store.getState().dashboardLayout.cards[sessionId]; - if (card) { - canvasActions.revealCards([{ x: card.x, y: card.y, width: card.width, height: card.height }]); + const rect = getCardRect(sessionId, 'agent'); + if (rect) { + canvasActions.revealCards([rect]); handleHighlightCard(sessionId); } }, 200); } }); - }, [dispatch, canvasActions, handleHighlightCard, setAutoFocusSessionId]); + }, [dispatch, canvasActions, handleHighlightCard, setAutoFocusSessionId, getCardRect]); // Context-aware fit: if a card is selected, zoom to it; otherwise fit all const handleFitToView = useCallback(() => { @@ -120,7 +120,7 @@ export function useDashboardCardActions({ ...Object.values(tidied).map((c) => { // An expanded card wears its title bubble ABOVE its rect, so the camera has to be told about that strip or Tidy frames the card and beheads it. const isExpanded = expandedSet.has(c.session_id); - const height = isExpanded ? Math.max(EXPANDED_CARD_MIN_H, c.height) : c.height; + const height = renderedAgentCardHeight(c.height, isExpanded); return isExpanded ? { x: c.x, y: c.y - EXPANDED_HEADER_H, width: c.width, height: height + EXPANDED_HEADER_H } : { x: c.x, y: c.y, width: c.width, height }; diff --git a/frontend/src/app/pages/Dashboard/hooks/lifecycle/useDashboardLifecycle.ts b/frontend/src/app/pages/Dashboard/hooks/lifecycle/useDashboardLifecycle.ts index 8fbff97c..02bdcd83 100644 --- a/frontend/src/app/pages/Dashboard/hooks/lifecycle/useDashboardLifecycle.ts +++ b/frontend/src/app/pages/Dashboard/hooks/lifecycle/useDashboardLifecycle.ts @@ -24,7 +24,7 @@ import { } from '@/shared/state/dashboardLayoutSlice'; import { fetchOutputs, type Output } from '@/shared/state/outputsSlice'; import { generateDashboardName } from '@/shared/state/dashboardsSlice'; -import { deservesCanvasCard } from '@/shared/state/isUserLaunchedSession'; +import { deservesCanvasCard, isPlumbingSession } from '@/shared/state/isUserLaunchedSession'; import { REVEAL_MIN_ZOOM } from '../../canvas/revealZoom'; import { fetchWorkflows, fetchAllRuns, fetchActiveRuns } from '@/shared/state/workflowsSlice'; import { fetchMissedRuns } from '@/shared/state/missedRunsSlice'; @@ -38,6 +38,7 @@ import { orphanViewCardKeys } from './orphanViewCardKeys'; import { clearPendingBrowserUrl, clearPendingFocusAgentId } from '@/shared/state/tempStateSlice'; import { API_BASE } from '@/shared/config'; import type { CanvasActions } from '../interaction/useCanvasControls'; +import { getCardRect } from '../../geometry/getCardRect'; // Module-level so the missed-runs review pops exactly once per app launch, not again on every dashboard switch. let missedRunsCheckedThisSession = false; @@ -203,9 +204,9 @@ export function useDashboardLifecycle({ dispatch(clearPendingFocusAgentId()); hasFittedRef.current = true; setTimeout(() => { - const card = store.getState().dashboardLayout.cards[agentId]; - if (card) { - canvasActions.revealCards([{ x: card.x, y: card.y, width: card.width, height: card.height }]); + const rect = getCardRect(agentId, 'agent'); + if (rect) { + canvasActions.revealCards([rect]); handleHighlightCard(agentId); } }, 350); @@ -328,10 +329,15 @@ export function useDashboardLifecycle({ const dashboardSessionIds = Object.values(sessions) .filter((s) => s.dashboard_id === dashboardId && deservesCanvasCard(s)) .map((s) => s.id); + // Revealed sub-agent cards are placed outside the deserving list, so reconcile needs told to + // keep them or an unrelated spawn despawns another agent's live subagents (ENG-304). + const keepIds = Object.values(sessions) + .filter((s) => s.dashboard_id === dashboardId && isPlumbingSession(s)) + .map((s) => s.id); const liveIds = dashboardSessionIds.sort().join(','); if (liveIds === prevSessionIdsRef.current) return; prevSessionIdsRef.current = liveIds; - dispatch(reconcileSessions({ sessionIds: dashboardSessionIds, expandedSessionIds })); + dispatch(reconcileSessions({ sessionIds: dashboardSessionIds, expandedSessionIds, keepIds })); }, [sessions, layoutInitialized, dispatch, dashboardId, expandedSessionIds]); // Prune orphan view cards whose underlying output was deleted (e.g. via the Views page). Without this, the layout entry persists in the minimap and contentBounds even though DashboardViewCard renders nothing. Gated on outputsRefetched (THIS open's fresh fetch), NOT the sticky global outputsLoaded: on a freshly-imported dashboard the global flag is already true from a prior dashboard, so the old gate pruned the just-imported app card against a stale apps list and the debounced save persisted the wipe. diff --git a/frontend/src/shared/state/dashboardLayoutSlice.ts b/frontend/src/shared/state/dashboardLayoutSlice.ts index 9be65157..4fcb6119 100644 --- a/frontend/src/shared/state/dashboardLayoutSlice.ts +++ b/frontend/src/shared/state/dashboardLayoutSlice.ts @@ -37,6 +37,14 @@ export const DEFAULT_MARKETPLACE_CARD_H = DEFAULT_BROWSER_CARD_H; export const WORKFLOWS_HUB_ID = 'workflows-hub'; export const WORKFLOWS_MONITOR_ID = 'workflows-monitor'; export const EXPANDED_CARD_MIN_H = 620; +// The floating title bubble rides this far ABOVE an expanded chat's rect; camera fits that ignore it behead the title. +export const EXPANDED_HEADER_H = 64; +// The stored rect is a chat's COLLAPSED geometry; expanded it renders at least EXPANDED_CARD_MIN_H +// tall, so any camera fit, reveal, tidy, or collision math that frames the stored height of an +// expanded chat frames a lie and leaves the card's bottom off-screen (ENG-318). +export function renderedAgentCardHeight(storedHeight: number, expanded: boolean): number { + return expanded ? Math.max(EXPANDED_CARD_MIN_H, storedHeight) : storedHeight; +} export const GRID_GAP = 24; // Gap between the Workflows window and the cards it spawns (run monitor, that monitor's browser). Keeps the hub -> monitor -> browser row evenly spaced. export const WORKFLOW_CARD_GAP = 140; @@ -401,7 +409,7 @@ function collectOccupiedRects( const rects: Rect[] = []; for (const c of Object.values(state.cards)) { if (exclude?.type === 'agent' && exclude.id === c.session_id) continue; - const h = expanded.has(c.session_id) ? Math.max(EXPANDED_CARD_MIN_H, c.height) : c.height; + const h = renderedAgentCardHeight(c.height, expanded.has(c.session_id)); rects.push({ x: c.x, y: c.y, w: c.width, h }); } for (const c of Object.values(state.viewCards)) { @@ -840,10 +848,14 @@ const dashboardLayoutSlice = createSlice({ reconcileSessions( state, - action: PayloadAction<{ sessionIds: string[]; expandedSessionIds: string[] }>, + action: PayloadAction<{ sessionIds: string[]; expandedSessionIds: string[]; keepIds?: string[] }>, ) { - const { sessionIds, expandedSessionIds } = action.payload; + const { sessionIds, expandedSessionIds, keepIds } = action.payload; const liveIds = new Set(sessionIds); + // Revealed sub-agent cards live OUTSIDE the deserving list, so without this exemption any + // unrelated spawn despawned another agent's live subagents (ENG-304). Keep is not create: + // these ids never earn a card here, they only stop losing one. + for (const id of keepIds ?? []) liveIds.add(id); for (const id of Object.keys(state.cards)) { if (!liveIds.has(id)) { @@ -911,9 +923,7 @@ const dashboardLayoutSlice = createSlice({ const sizeOf = (item: typeof allItems[number]): { w: number; h: number } => ({ w: item.storedW, - h: item.kind === 'agent' && expanded.has(item.id) - ? Math.max(EXPANDED_CARD_MIN_H, item.storedH) - : item.storedH, + h: renderedAgentCardHeight(item.storedH, item.kind === 'agent' && expanded.has(item.id)), }); const cols = tidyColumnCount(allItems.map(sizeOf)); const placedRects: Rect[] = [];