From cc619725b21d03bf9e722b6b9c39be4bec25ed8d Mon Sep 17 00:00:00 2001 From: ciregenz Date: Thu, 6 Aug 2026 09:57:40 -0700 Subject: [PATCH] [eric] canvas: focus bumps write a zOrders override map, card dicts keep identity and clicks stop arming layout PUTs --- .../src/app/pages/AgentChat/AgentChat.tsx | 6 +- .../app/pages/Dashboard/cards/AgentCard.tsx | 3 +- .../app/pages/Dashboard/cards/BrowserCard.tsx | 6 +- .../Dashboard/cards/CanvasWindowCard.tsx | 4 +- .../Dashboard/cards/DashboardViewCard.tsx | 6 +- .../src/shared/state/dashboardLayoutSlice.ts | 82 ++++++++----------- 6 files changed, 52 insertions(+), 55 deletions(-) diff --git a/frontend/src/app/pages/AgentChat/AgentChat.tsx b/frontend/src/app/pages/AgentChat/AgentChat.tsx index 1d4da977..2eeab67a 100644 --- a/frontend/src/app/pages/AgentChat/AgentChat.tsx +++ b/frontend/src/app/pages/AgentChat/AgentChat.tsx @@ -262,11 +262,13 @@ const AgentChat: React.FC = ({ sessionId: sessionIdProp, onClose Object.values(st.dashboardLayout.browserCards).some((bc) => bc.docked_to === (sessionIdProp || routeId)) || Object.values(st.dashboardLayout.viewCards).some((vc) => vc.docked_to === (sessionIdProp || routeId))); // The docked surface's aspect ratio, so the inline slot hugs the browser's shape instead of reserving a fixed letterbox band (primitive selectors so no fresh-object rerenders). Highest z wins, mirroring BrowserCard's dock-owner election, so a dead rival's stale dock never feeds dims or shots. - const pickTopDocked = (st: { dashboardLayout: { browserCards: Record } }) => { + const pickTopDocked = (st: { dashboardLayout: { zOrders: Record; browserCards: Record } }) => { let best: { browser_id: string; width: number; height: number; zOrder: number } | null = null; + let bestZ = -1; + const zOf = (b: { browser_id: string; zOrder: number }): number => st.dashboardLayout.zOrders[b.browser_id] ?? b.zOrder ?? 0; for (const b of Object.values(st.dashboardLayout.browserCards)) { if (b.docked_to !== (sessionIdProp || routeId)) continue; - if (!best || (b.zOrder || 0) > (best.zOrder || 0)) best = b; + if (!best || zOf(b) > bestZ) { best = b; bestZ = zOf(b); } } return best; }; diff --git a/frontend/src/app/pages/Dashboard/cards/AgentCard.tsx b/frontend/src/app/pages/Dashboard/cards/AgentCard.tsx index 24b01862..dba317f7 100644 --- a/frontend/src/app/pages/Dashboard/cards/AgentCard.tsx +++ b/frontend/src/app/pages/Dashboard/cards/AgentCard.tsx @@ -1483,6 +1483,7 @@ const MemoAgentCard = React.memo(AgentCard); const AgentCardOuter: React.FC = (props) => { const session = useAppSelector((s) => s.agents.sessions[props.sessionId]); const cardEntry = useAppSelector((s) => s.dashboardLayout.cards[props.sessionId]); + const zOverride = useAppSelector((s) => s.dashboardLayout.zOrders[props.sessionId]); if (!session || !cardEntry) return null; return ( = (props) => { cardY={cardEntry.y} cardWidth={cardEntry.width} cardHeight={cardEntry.height} - cardZOrder={cardEntry.zOrder ?? 0} + cardZOrder={zOverride ?? cardEntry.zOrder ?? 0} /> ); }; diff --git a/frontend/src/app/pages/Dashboard/cards/BrowserCard.tsx b/frontend/src/app/pages/Dashboard/cards/BrowserCard.tsx index 247affe4..97d0d6a2 100644 --- a/frontend/src/app/pages/Dashboard/cards/BrowserCard.tsx +++ b/frontend/src/app/pages/Dashboard/cards/BrowserCard.tsx @@ -323,7 +323,9 @@ const BrowserCard: React.FC = ({ }; // dockParentCard x/y/w/h are re-measure triggers: the slot's client rect moves with the chat card. }, [dockedTo, dockParentExpanded, dockParentTiled, dockParentCard?.x, dockParentCard?.y, dockParentCard?.width, dockParentCard?.height, getCanvasState, dockParentCard]); - const dockParentZ = dockParentCard?.zOrder ?? 0; + const dockParentZOverride = useAppSelector((state) => (dockedTo ? state.dashboardLayout.zOrders[dockedTo] : undefined)); + const dockParentZ = dockParentZOverride ?? dockParentCard?.zOrder ?? 0; + const zOverride = useAppSelector((state) => state.dashboardLayout.zOrders[browserId]); // The slot's frozen-shot backdrop and the live overlay must never BOTH paint (the clamped overlay leaves margins where a misaligned copy of the page peeks through), so the card stamps its live state onto the slot and the slot's CSS hides the shot while live. const overlayLiveRef = useRef(false); @@ -1221,7 +1223,7 @@ const BrowserCard: React.FC = ({ overflow: 'hidden', display: 'flex', flexDirection: 'column', - zIndex: isTiled ? 999990 : (isDragging || isResizing) ? 999999 : dockActive ? (dockParentTiled ? 999991 : dockParentZ + 1) : cardZOrder, + zIndex: isTiled ? 999990 : (isDragging || isResizing) ? 999999 : dockActive ? (dockParentTiled ? 999991 : dockParentZ + 1) : (zOverride ?? cardZOrder), // The inline slot scrolls with the transcript; a webview can't be clipped by the scroller, so the mini fades out when its slot is mostly out of view instead of floating over unrelated messages. opacity: (dockActive && !dockVisible) || dockPending ? 0 : 1, transition: noTransition ? 'none' : 'box-shadow 0.4s ease, border 0.3s ease, opacity 0.14s ease', diff --git a/frontend/src/app/pages/Dashboard/cards/CanvasWindowCard.tsx b/frontend/src/app/pages/Dashboard/cards/CanvasWindowCard.tsx index 7cb7de00..20e32341 100644 --- a/frontend/src/app/pages/Dashboard/cards/CanvasWindowCard.tsx +++ b/frontend/src/app/pages/Dashboard/cards/CanvasWindowCard.tsx @@ -1,5 +1,6 @@ import React, { useCallback, useRef, useState } from 'react'; import { useClaudeTokens } from '@/shared/styles/ThemeContext'; +import { useAppSelector } from '@/shared/hooks'; import { useTiledCard } from './useTiledCard'; import { useCardTiling } from './useCardTiling'; import { useCanvasWindowResize } from './useCanvasWindowResize'; @@ -71,6 +72,7 @@ const CanvasWindowCard: React.FC = ({ children, }) => { const c = useClaudeTokens(); + const zOverride = useAppSelector((state) => state.dashboardLayout.zOrders[cardId]); const tiling = useCardTiling({ cardId, getCanvasState, commitPosition: onCommitPosition }); // ---- Drag (title bar is the handle) ---- @@ -219,7 +221,7 @@ const CanvasWindowCard: React.FC = ({ overflow: 'hidden', display: 'flex', flexDirection: 'column', - zIndex: tiledSize ? 999990 : (isDragging || isResizing) ? 999999 : cardZOrder, + zIndex: tiledSize ? 999990 : (isDragging || isResizing) ? 999999 : (zOverride ?? cardZOrder), transition: noTransition ? 'none' : 'box-shadow 0.3s ease, border-color 0.2s ease', }} > diff --git a/frontend/src/app/pages/Dashboard/cards/DashboardViewCard.tsx b/frontend/src/app/pages/Dashboard/cards/DashboardViewCard.tsx index 12c3d662..5470935b 100644 --- a/frontend/src/app/pages/Dashboard/cards/DashboardViewCard.tsx +++ b/frontend/src/app/pages/Dashboard/cards/DashboardViewCard.tsx @@ -207,7 +207,9 @@ const DashboardViewCard: React.FC = ({ timers.forEach((tm) => window.clearTimeout(tm)); }; }, [dockedTo, dockParentExpanded, dockParentTiled, dockParentCard?.x, dockParentCard?.y, dockParentCard?.width, dockParentCard?.height, getCanvasState, dockParentCard]); - const dockParentZ = dockParentCard?.zOrder ?? 0; + const dockParentZOverride = useAppSelector((state) => (dockedTo ? state.dashboardLayout.zOrders[dockedTo] : undefined)); + const dockParentZ = dockParentZOverride ?? dockParentCard?.zOrder ?? 0; + const zOverride = useAppSelector((state) => state.dashboardLayout.zOrders[cardKey]); // Keep the live preview mounted only when the user can actually see/use this app card. Always live // when it's being interacted with, driven by an agent, tiled, or selected; otherwise gated on being @@ -691,7 +693,7 @@ const DashboardViewCard: React.FC = ({ overflow: 'hidden', display: 'flex', flexDirection: 'column', - zIndex: isTiled ? 999990 : (isDragging || isResizing) ? 999999 : dockActive ? (dockParentTiled ? 999991 : dockParentZ + 1) : cardZOrder, + zIndex: isTiled ? 999990 : (isDragging || isResizing) ? 999999 : dockActive ? (dockParentTiled ? 999991 : dockParentZ + 1) : (zOverride ?? cardZOrder), transition: noTransition ? 'none' : 'box-shadow 0.4s ease, border 0.3s ease', '&:hover .resize-handle': { opacity: 1 }, ...(isHighlighted && { diff --git a/frontend/src/shared/state/dashboardLayoutSlice.ts b/frontend/src/shared/state/dashboardLayoutSlice.ts index 442bdcef..bb5d4df1 100644 --- a/frontend/src/shared/state/dashboardLayoutSlice.ts +++ b/frontend/src/shared/state/dashboardLayoutSlice.ts @@ -186,6 +186,8 @@ export interface DashboardLayoutState { pendingFocusMarketplaceCard: boolean; /** Transient deep-link: which marketplace view to land on; the card consumes and clears it. */ marketplaceRequestedTab: string | null; + /** Focus-order overrides, keyed by card id (singletons use their literal ids). bringToFront writes ONLY here, so a click never churns card-object identities; effective z = zOrders[id] ?? the base stamped at creation. */ + zOrders: Record; /** Creation-order ledger, oldest first, across all content card types; the trash's no-selection press pops the newest. Persisted with the layout (zOrder can't stand in, it re-bumps on every focus). */ creationOrder: string[]; } @@ -234,6 +236,7 @@ const initialState: DashboardLayoutState = { pendingFocusMarketplaceCard: false, marketplaceRequestedTab: null, creationOrder: [], + zOrders: {}, }; interface LayoutPayload { @@ -244,6 +247,8 @@ interface LayoutPayload { workflowsHub: WorkflowsHubPosition | null; expandedSessionIds: string[]; creationOrder: string[]; + // Optional because savers omit it: the save thunk reads the live map from state itself. + zOrders?: Record; } function ledgerAdd(ledger: string[], id: string): void { @@ -296,6 +301,7 @@ export const fetchLayout = createAsyncThunk( workflowsHub: (layout.workflows_hub ?? null) as WorkflowsHubPosition | null, expandedSessionIds: (layout.expanded_session_ids ?? []) as string[], creationOrder: (layout.creation_order ?? []) as string[], + zOrders: (layout.z_orders ?? {}) as Record, } satisfies LayoutPayload; }, ); @@ -308,8 +314,14 @@ export const saveLayout = createAsyncThunk( 'dashboardLayout/save', async (payload: SaveLayoutPayload, { getState }) => { // Never persist a layout this client never successfully loaded; a failed boot fetch otherwise saves the pristine empty store over the server's real layout (the wipe class). - const armed = (getState() as { dashboardLayout: { saveArmed: boolean } }).dashboardLayout.saveArmed; - if (!armed) return payload; + const dl = (getState() as { dashboardLayout: { saveArmed: boolean; zOrders: Record } }).dashboardLayout; + if (!dl.saveArmed) return payload; + // Prune focus overrides to ids that still exist, so removed cards can't grow the map forever. + const liveZ: Record = {}; + for (const [zid, z] of Object.entries(dl.zOrders)) { + if (payload.cards[zid] || payload.viewCards[zid] || payload.browserCards[zid] || payload.workflowCards[zid] + || zid === 'settings' || zid === 'marketplace' || zid === 'workflows-hub' || zid === 'workflows-monitor') liveZ[zid] = z; + } await fetch(`${DASHBOARDS_API}/${payload.dashboardId}`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, @@ -322,6 +334,7 @@ export const saveLayout = createAsyncThunk( workflows_hub: payload.workflowsHub, expanded_session_ids: payload.expandedSessionIds, creation_order: payload.creationOrder, + z_orders: liveZ, }, }), }); @@ -727,50 +740,23 @@ const dashboardLayoutSlice = createSlice({ action: PayloadAction<{ id: string; type: CardType }>, ) { const { id, type } = action.payload; - // Compute the current top zOrder across ALL card types so we can short-circuit when the target is already on top. Without this guard, every click on a card (which fires onPointerDownCapture + onClick + onDoubleClick) bumps zOrder and triggers a Redux mutation. That mutation cascades into a re-render that unmounts inputs mid-keystroke, causing the workflow card's title / description / step textareas to lose focus on every click. - let maxZ = 0; - let currentZ = 0; - const tally = (z: number | undefined) => { if (typeof z === 'number' && z > maxZ) maxZ = z; }; - for (const c of Object.values(state.cards)) tally(c.zOrder); - for (const c of Object.values(state.viewCards)) tally(c.zOrder); - for (const c of Object.values(state.browserCards)) tally(c.zOrder); - for (const c of Object.values(state.workflowCards)) tally(c.zOrder); - if (state.workflowsHub) tally(state.workflowsHub.zOrder); - if (state.workflowsMonitorCard) tally(state.workflowsMonitorCard.zOrder); - if (state.settingsCard) tally(state.settingsCard.zOrder); - if (state.marketplaceCard) tally(state.marketplaceCard.zOrder); - if (type === 'agent') currentZ = state.cards[id]?.zOrder ?? 0; - else if (type === 'view') currentZ = state.viewCards[id]?.zOrder ?? 0; - else if (type === 'workflow') currentZ = state.workflowCards[id]?.zOrder ?? 0; - else if (type === 'workflows-hub') currentZ = state.workflowsHub?.zOrder ?? 0; - else if (type === 'workflows-monitor') currentZ = state.workflowsMonitorCard?.zOrder ?? 0; - else if (type === 'settings') currentZ = state.settingsCard?.zOrder ?? 0; - else if (type === 'marketplace') currentZ = state.marketplaceCard?.zOrder ?? 0; - else currentZ = state.browserCards[id]?.zOrder ?? 0; - if (currentZ >= maxZ) return; // Already on top: no-op. - - const z = state.nextZOrder++; - if (type === 'agent') { - const card = state.cards[id]; - if (card) card.zOrder = z; - } else if (type === 'view') { - const card = state.viewCards[id]; - if (card) card.zOrder = z; - } else if (type === 'workflow') { - const card = state.workflowCards[id]; - if (card) card.zOrder = z; - } else if (type === 'workflows-hub') { - if (state.workflowsHub) state.workflowsHub.zOrder = z; - } else if (type === 'workflows-monitor') { - if (state.workflowsMonitorCard) state.workflowsMonitorCard.zOrder = z; - } else if (type === 'settings') { - if (state.settingsCard) state.settingsCard.zOrder = z; - } else if (type === 'marketplace') { - if (state.marketplaceCard) state.marketplaceCard.zOrder = z; - } else { - const card = state.browserCards[id]; - if (card) card.zOrder = z; - } + // Focus writes ONLY the override map: the old form mutated the card object, which replaced its + // dict, re-rendered the controller + tethers + dock + minimap, and armed a layout PUT + + // thumbnail capture on every press (CANVAS_LAG_NOTES item 33). The nextZOrder-1 holder is + // always the top card, so the already-on-top guard (workflow textarea focus-loss) is one compare. + let base = 0; + if (type === 'agent') base = state.cards[id]?.zOrder ?? 0; + else if (type === 'view') base = state.viewCards[id]?.zOrder ?? 0; + else if (type === 'workflow') base = state.workflowCards[id]?.zOrder ?? 0; + else if (type === 'workflows-hub') base = state.workflowsHub?.zOrder ?? 0; + else if (type === 'workflows-monitor') base = state.workflowsMonitorCard?.zOrder ?? 0; + else if (type === 'settings') base = state.settingsCard?.zOrder ?? 0; + else if (type === 'marketplace') base = state.marketplaceCard?.zOrder ?? 0; + else base = state.browserCards[id]?.zOrder ?? 0; + const effective = state.zOrders[id] ?? base; + // Zero is the legacy every-card tie (backend-synced cards arrive without z); never short-circuit on it. + if (effective > 0 && effective === state.nextZOrder - 1) return; + state.zOrders[id] = state.nextZOrder++; }, removeCard(state, action: PayloadAction) { @@ -1911,11 +1897,13 @@ const dashboardLayoutSlice = createSlice({ if (!w.zOrder) w.zOrder = 0; if (w.zOrder > maxZ) maxZ = w.zOrder; } + state.zOrders = { ...state.zOrders, ...(action.payload.zOrders ?? {}) }; + for (const z of Object.values(state.zOrders)) { if (z > maxZ) maxZ = z; } state.nextZOrder = maxZ + 1; // Ledger rebuild: persisted order filtered to live ids, then unledgered survivors by zOrder (legacy layouts, drift). Keep-alive browsers homed on OTHER dashboards stay out: the trash must never delete a card the user can't see. const zOf = (id: string): number => - state.cards[id]?.zOrder ?? state.viewCards[id]?.zOrder ?? state.browserCards[id]?.zOrder ?? state.workflowCards[id]?.zOrder ?? 0; + state.zOrders[id] ?? state.cards[id]?.zOrder ?? state.viewCards[id]?.zOrder ?? state.browserCards[id]?.zOrder ?? state.workflowCards[id]?.zOrder ?? 0; const live = new Set([ ...Object.keys(state.cards), ...Object.keys(state.viewCards),