[eric] canvas: closed-card positions are capped at 50 instead of remembered forever; the reconnect pill confirms it healed instead of just vanishing

This commit is contained in:
ciregenz
2026-08-12 13:01:18 -07:00
parent df32505aca
commit eb21e6eb9b
2 changed files with 65 additions and 16 deletions
@@ -1,21 +1,32 @@
import React, { useEffect, useState } from 'react';
import React, { useEffect, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
import Box from '@mui/material/Box';
import Grow from '@mui/material/Grow';
import Typography from '@mui/material/Typography';
import CircularProgress from '@mui/material/CircularProgress';
import CheckRoundedIcon from '@mui/icons-material/CheckRounded';
import { useClaudeTokens } from '@/shared/styles/ThemeContext';
import { backendReachable, onBackendReachability } from '@/shared/backendConnection';
// Honest "the local backend went away" state, so an unreachable backend never reads as a silent
// forever-spinner (ENG-242). The interceptor's background probe self-heals; this only tells the
// user what is happening while it does, and lets them force a reload if they are impatient.
//
// It also CLOSES the story. The pill used to just vanish when the backend came back, which leaves
// the one question the user actually has unanswered: did it work, or did the notice give up? A brief
// "Reconnected" is the whole feature, deliberately not a permanent status bar: a surface that is
// always there becomes furniture nobody reads, and the interesting states are rare by design.
const RECONNECTED_HOLD_MS = 2600;
const ReconnectingPill: React.FC = () => {
const c = useClaudeTokens();
const [reachable, setReachable] = useState(true);
// Only show after a short grace so a normal ~4s backend respawn heals invisibly; the pill is
// for the case that actually worried the user, a backend that stays gone.
const [showable, setShowable] = useState(false);
const [justHealed, setJustHealed] = useState(false);
// Only confirm a recovery the user was actually told about; a heal nobody saw needs no receipt.
const wasVisibleRef = useRef(false);
useEffect(() => {
setReachable(backendReachable());
@@ -28,7 +39,18 @@ const ReconnectingPill: React.FC = () => {
return () => clearTimeout(t);
}, [reachable]);
const show = !reachable && showable;
const showProblem = !reachable && showable;
useEffect(() => {
if (showProblem) { wasVisibleRef.current = true; return undefined; }
if (!reachable || !wasVisibleRef.current) return undefined;
wasVisibleRef.current = false;
setJustHealed(true);
const t = setTimeout(() => setJustHealed(false), RECONNECTED_HOLD_MS);
return () => clearTimeout(t);
}, [showProblem, reachable]);
const show = showProblem || justHealed;
// Portal to body: `position: fixed` resolves against a transformed ancestor, not the viewport, and
// AppShell sits inside one, so the pill rendered visibly off-centre (measured on a screenshot).
@@ -39,31 +61,42 @@ const ReconnectingPill: React.FC = () => {
<Box sx={{ position: 'fixed', bottom: 16, left: 0, right: 0, zIndex: 1400, display: 'flex', justifyContent: 'center', pointerEvents: 'none' }}>
<Grow in={show} unmountOnExit>
<Box
onClick={() => window.location.reload()}
role="button"
aria-label="Reconnecting to OpenSwarm; click to reload"
onClick={showProblem ? () => window.location.reload() : undefined}
role={showProblem ? 'button' : 'status'}
aria-label={showProblem ? 'Reconnecting to OpenSwarm; click to reload' : 'Reconnected to OpenSwarm'}
sx={{
pointerEvents: 'auto',
pointerEvents: showProblem ? 'auto' : 'none',
display: 'flex',
alignItems: 'center',
gap: 1,
px: 1.75,
py: 1,
borderRadius: 999,
cursor: 'pointer',
cursor: showProblem ? 'pointer' : 'default',
WebkitAppRegion: 'no-drag',
background: c.bg.elevated,
border: `1px solid ${c.border.strong}`,
boxShadow: c.shadow.lg,
} as object}
>
<CircularProgress size={14} sx={{ color: c.text.secondary }} />
<Typography sx={{ fontSize: '0.8125rem', color: c.text.primary, fontWeight: 500 }}>
Reconnecting to OpenSwarm
</Typography>
<Typography sx={{ fontSize: '0.75rem', color: c.text.tertiary }}>
click to reload
</Typography>
{showProblem ? (
<>
<CircularProgress size={14} sx={{ color: c.text.secondary }} />
<Typography sx={{ fontSize: '0.8125rem', color: c.text.primary, fontWeight: 500 }}>
Reconnecting to OpenSwarm
</Typography>
<Typography sx={{ fontSize: '0.75rem', color: c.text.tertiary }}>
click to reload
</Typography>
</>
) : (
<>
<CheckRoundedIcon sx={{ fontSize: 16, color: c.status.success }} />
<Typography sx={{ fontSize: '0.8125rem', color: c.text.primary, fontWeight: 500 }}>
Reconnected
</Typography>
</>
)}
</Box>
</Grow>
</Box>,
@@ -131,6 +131,22 @@ export type ClosedCard =
export type ClosedCardKind = ClosedCard['kind'];
const RECENTLY_CLOSED_CAP = 25;
// Reopening a chat should drop it back where it was, so we remember closed cards' geometry. Nothing
// pruned it and it is persisted, so it grew forever and rode every layout save to disk: measured 555
// entries from 250 closes (the reconcile path records one too, so a close can bill twice). A position
// from hundreds of cards ago has no value, nobody reopens that, so keep the recent ones and drop the
// rest. Insertion order on a string-keyed object is the age order we need.
const CLOSED_POSITIONS_CAP = 50;
function rememberClosedPosition(
map: Record<string, CardPosition>, id: string, pos: CardPosition,
): void {
// Re-closing the same card must refresh its age, not keep the stale slot.
delete map[id];
map[id] = pos;
const ids = Object.keys(map);
for (let i = 0; i < ids.length - CLOSED_POSITIONS_CAP; i++) delete map[ids[i]];
}
export interface DashboardLayoutState {
cards: Record<string, CardPosition>;
@@ -799,7 +815,7 @@ const dashboardLayoutSlice = createSlice({
for (const id of Object.keys(state.cards)) {
if (!liveIds.has(id)) {
state.closedCardPositions[id] = { ...state.cards[id] };
rememberClosedPosition(state.closedCardPositions, id, { ...state.cards[id] });
delete state.cards[id];
// A dead card must never keep owning a tile: an orphaned 'fullscreen' entry hides ALL chrome until reload.
delete state.tiledCards[id];
@@ -1779,7 +1795,7 @@ const dashboardLayoutSlice = createSlice({
state,
action: PayloadAction<{ sessionId: string; position: CardPosition }>
) {
state.closedCardPositions[action.payload.sessionId] = action.payload.position;
rememberClosedPosition(state.closedCardPositions, action.payload.sessionId, action.payload.position);
},
replaceDraftId(