diff --git a/frontend/src/app/pages/Dashboard/cards/AgentCard.tsx b/frontend/src/app/pages/Dashboard/cards/AgentCard.tsx index 9e7aabd9..0e9f9989 100644 --- a/frontend/src/app/pages/Dashboard/cards/AgentCard.tsx +++ b/frontend/src/app/pages/Dashboard/cards/AgentCard.tsx @@ -32,7 +32,6 @@ import { clearGlowingAgentCard, removeCard, recordClosedCard, - clearTiledCard, } from '@/shared/state/dashboardLayoutSlice'; import WindowControls, { ARC_CHIP_SX } from './WindowControls'; import { useTiledCard } from './useTiledCard'; @@ -301,13 +300,23 @@ const GLOW_FADE_MS = 2500; const SNAP_THRESHOLD = 60; const AgentCard: React.FC = ({ - session, expanded, cardX, cardY, cardWidth, cardHeight, getCanvasState, spawnFrom, exitTarget, + session, expanded: expandedInStore, cardX, cardY, cardWidth, cardHeight, getCanvasState, spawnFrom, exitTarget, isSelected = false, isHighlighted = false, multiDragDelta, onCardSelect, onDragStart, onDragMove, onDragEnd, onBranch, onMeasuredHeight, snapColumn, autoFocusInput, cardZOrder = 0, onDoubleClick, onBringToFront, shakeDirection, }) => { const c = useClaudeTokens(); const dispatch = useAppDispatch(); + const commitPosition = useCallback((x: number, y: number) => { + dispatch(setCardPosition({ sessionId: session.id, x, y })); + }, [dispatch, session.id]); + const tiling = useCardTiling({ cardId: session.id, getCanvasState, commitPosition }); + const tileZone = tiling.zone; + const isTiled = !!tileZone; + const isFullscreen = tileZone === 'fullscreen'; + // A tiled chat IS an open chat. One flag drives the window's SIZE and its SKIN together, so a tile + // can never wear the collapsed card's light surface (that was "fullscreen turns white"). + const expanded = expandedInStore || isTiled; const isDashboardActive = useDashboardActive(); const hasApiKey = !!useAppSelector((s) => s.settings.data.anthropic_api_key); const expandedSessionIds = useAppSelector((s) => s.agents.expandedSessionIds); @@ -446,11 +455,6 @@ const AgentCard: React.FC = ({ const justDraggedRef = useRef(false); const lastPointerRef = useRef<{ clientX: number; clientY: number }>({ clientX: 0, clientY: 0 }); - const commitPosition = useCallback((x: number, y: number) => { - dispatch(setCardPosition({ sessionId: session.id, x, y })); - }, [dispatch, session.id]); - const tiling = useCardTiling({ cardId: session.id, getCanvasState, commitPosition }); - const tileZone = tiling.zone; const handleDragPointerDown = useCallback((e: React.PointerEvent) => { if (e.button !== 0) return; e.preventDefault(); @@ -659,21 +663,13 @@ const AgentCard: React.FC = ({ } }; - const isFullscreen = tileZone === 'fullscreen'; - const isTiled = !!tileZone; - // A collapsed chat can never stay tiled: collapsing while fullscreen left a white full-window shell - // (the header collapse control still fires in full size view). Seal the state instead of the path. - useEffect(() => { - if (tileZone && !expanded) dispatch(clearTiledCard(session.id)); - }, [tileZone, expanded, dispatch, session.id]); const onMinimize = (): void => { dispatch(collapseSession(session.id)); }; const onTile = (zone: string): void => { // A collapsed chat has nothing to fill a zone with, so tiling one opens it first. - if (zone !== 'restore' && !expanded) dispatch(expandSession(session.id)); + if (zone !== 'restore') dispatch(expandSession(session.id)); tiling.applyZone(zone); }; - const lastMessage = session.messages[session.messages.length - 1]; // Subscribe to this card's own streaming entry so per-character mutations don't churn other cards. const streamingMessage = useStreamingMessage(session.id); @@ -1014,7 +1010,7 @@ const AgentCard: React.FC = ({ handleRemove()} onMinimize={() => dispatch(expandSession(session.id))} - onTile={(zone: string) => { dispatch(expandSession(session.id)); onTile(zone); }} + onTile={onTile} tiled={false} /> diff --git a/frontend/src/app/pages/Dashboard/cards/useCardTiling.ts b/frontend/src/app/pages/Dashboard/cards/useCardTiling.ts index 5ca609ed..705d3158 100644 --- a/frontend/src/app/pages/Dashboard/cards/useCardTiling.ts +++ b/frontend/src/app/pages/Dashboard/cards/useCardTiling.ts @@ -14,6 +14,8 @@ import { zoneRect } from '../canvas/tiledGeometry'; // 6. Minimizing keeps the zone (the rail puts you back in it); only 'fullscreen' is dropped, since // a parked card must not keep hiding the whole shell. // 7. Closing a card drops its zone (the reducers own that; an orphan entry poisons every reader). +// 8. A chat's zone is bound to its OPEN state: collapsing a chat untiles it in the SAME dispatch +// (dashboardLayoutSlice extraReducers), and a tiled chat renders as an open one either way. export interface CardFrame { x: number; diff --git a/frontend/src/shared/state/dashboardLayoutSlice.ts b/frontend/src/shared/state/dashboardLayoutSlice.ts index 0b11a7bb..b835e99b 100644 --- a/frontend/src/shared/state/dashboardLayoutSlice.ts +++ b/frontend/src/shared/state/dashboardLayoutSlice.ts @@ -1,5 +1,6 @@ import { createSlice, createAsyncThunk, PayloadAction, createAction } from '@reduxjs/toolkit'; -import { launchAndSendFirstMessage, resumeSession } from './agentsSlice'; +import { launchAndSendFirstMessage, resumeSession, collapseSession, collapseAllSessions, setExpandedSessionIds } from './agentsSlice'; +import { untileClosedChats } from './untileClosedChats'; import { API_BASE } from '@/shared/config'; import { getLastDashboardId } from '@/shared/lastDashboardId'; @@ -1759,6 +1760,17 @@ const dashboardLayoutSlice = createSlice({ // Fail-open for RENDERING only; saveArmed stays false so this client can never persist the empty layout it booted with over the server's real one (the wipe that hit 2026-07-20). state.initialized = true; }) + // Rule 8 of the tiling set: a chat's zone belongs to its OPEN state, so every action that closes + // chats untiles them here, in the same dispatch. See untileClosedChats. + .addCase(collapseSession, (state, action) => { + untileClosedChats(state.tiledCards, [action.payload], []); + }) + .addCase(collapseAllSessions, (state) => { + untileClosedChats(state.tiledCards, Object.keys(state.cards), []); + }) + .addCase(setExpandedSessionIds, (state, action) => { + untileClosedChats(state.tiledCards, Object.keys(state.cards), action.payload); + }) .addCase(fetchSessionRejectedAction, (state, action) => { // 404/410 means permanent; strip the card. Other failure modes leave it (next fetch may succeed). const payload = action.payload; @@ -1779,6 +1791,13 @@ const dashboardLayoutSlice = createSlice({ delete state.cards[draftId]; state.cards[session.id] = { ...card, session_id: session.id, zOrder: state.nextZOrder++ }; } + // The zone rides the re-key too: left behind, a tiled draft pops out of its tile AND strands an + // entry no reader can ever clear (a stranded 'fullscreen' hides the whole shell until reload). + const draftZone = state.tiledCards[draftId]; + if (draftZone) { + delete state.tiledCards[draftId]; + state.tiledCards[session.id] = draftZone; + } // Carry an optimistic browser tether from the draft id to the real session id, in place (no flicker, no stale draft endpoint). for (const entry of Object.values(state.glowingBrowserCards)) { if (entry.sourceId === draftId) entry.sourceId = session.id; diff --git a/frontend/src/shared/state/untileClosedChats.test.ts b/frontend/src/shared/state/untileClosedChats.test.ts new file mode 100644 index 00000000..6d208f1f --- /dev/null +++ b/frontend/src/shared/state/untileClosedChats.test.ts @@ -0,0 +1,28 @@ +// Run: node --test frontend/src/shared/state/untileClosedChats.test.ts +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { untileClosedChats } from './untileClosedChats.ts'; + +test('collapsing one chat drops that chat tile and nothing else', () => { + const tiled: Record = { a: 'left', b: 'fullscreen' }; + untileClosedChats(tiled, ['a'], []); + assert.deepEqual(tiled, { b: 'fullscreen' }); +}); + +test('restoring a saved expansion list untiles every chat missing from it', () => { + const tiled: Record = { a: 'left', b: 'fullscreen', c: 'right' }; + untileClosedChats(tiled, ['a', 'b', 'c'], ['b']); + assert.deepEqual(tiled, { b: 'fullscreen' }); +}); + +test('non-chat tiles (browsers, apps, windows) are never touched', () => { + const tiled: Record = { chat: 'left', 'browser-1': 'right' }; + untileClosedChats(tiled, ['chat'], []); + assert.deepEqual(tiled, { 'browser-1': 'right' }); +}); + +test('an open chat keeps its tile', () => { + const tiled: Record = { a: 'fullscreen' }; + untileClosedChats(tiled, ['a'], ['a']); + assert.deepEqual(tiled, { a: 'fullscreen' }); +}); diff --git a/frontend/src/shared/state/untileClosedChats.ts b/frontend/src/shared/state/untileClosedChats.ts new file mode 100644 index 00000000..8c93fc75 --- /dev/null +++ b/frontend/src/shared/state/untileClosedChats.ts @@ -0,0 +1,14 @@ +// A tiled chat IS an open chat, so the two states can never be allowed to drift apart. Anything that +// closes chats drops their zones in the SAME reducer: an after-the-fact effect leaves a window where +// the card is tile-sized but wearing the collapsed skin, and on a busy machine that window has been +// measured at 17 seconds (the "fullscreen turns white" bug). +export function untileClosedChats( + tiledCards: Record, + chatCardIds: string[], + openChatIds: readonly string[], +): void { + const open = new Set(openChatIds); + for (const id of chatCardIds) { + if (!open.has(id)) delete tiledCards[id]; + } +}