[eric] tiling: a tiled chat is an open chat, so a collapsed one can't paint the white card

This commit is contained in:
ciregenz
2026-07-31 00:00:51 -07:00
parent b5e205160d
commit eae4f067d9
5 changed files with 77 additions and 18 deletions
@@ -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<Props> = ({
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<Props> = ({
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<Props> = ({
}
};
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<Props> = ({
<WindowControls
onClose={() => handleRemove()}
onMinimize={() => dispatch(expandSession(session.id))}
onTile={(zone: string) => { dispatch(expandSession(session.id)); onTile(zone); }}
onTile={onTile}
tiled={false}
/>
</Box>
@@ -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;
@@ -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;
@@ -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<string, string> = { 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<string, string> = { 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<string, string> = { chat: 'left', 'browser-1': 'right' };
untileClosedChats(tiled, ['chat'], []);
assert.deepEqual(tiled, { 'browser-1': 'right' });
});
test('an open chat keeps its tile', () => {
const tiled: Record<string, string> = { a: 'fullscreen' };
untileClosedChats(tiled, ['a'], ['a']);
assert.deepEqual(tiled, { a: 'fullscreen' });
});
@@ -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<string, string>,
chatCardIds: string[],
openChatIds: readonly string[],
): void {
const open = new Set(openChatIds);
for (const id of chatCardIds) {
if (!open.has(id)) delete tiledCards[id];
}
}