mirror of
https://github.com/openswarm-ai/openswarm.git
synced 2026-09-26 19:44:51 +02:00
[eric] tiling: a tiled chat is an open chat, so a collapsed one can't paint the white card
This commit is contained in:
@@ -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];
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user