From eb21e6eb9b5acaa43ce19b1f1d19b5f4ec682d5b Mon Sep 17 00:00:00 2001 From: ciregenz Date: Wed, 12 Aug 2026 13:01:18 -0700 Subject: [PATCH] [eric] canvas: closed-card positions are capped at 50 instead of remembered forever; the reconnect pill confirms it healed instead of just vanishing --- .../components/Layout/ReconnectingPill.tsx | 61 ++++++++++++++----- .../src/shared/state/dashboardLayoutSlice.ts | 20 +++++- 2 files changed, 65 insertions(+), 16 deletions(-) diff --git a/frontend/src/app/components/Layout/ReconnectingPill.tsx b/frontend/src/app/components/Layout/ReconnectingPill.tsx index 00a90f79..8ae5cc2a 100644 --- a/frontend/src/app/components/Layout/ReconnectingPill.tsx +++ b/frontend/src/app/components/Layout/ReconnectingPill.tsx @@ -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 = () => { 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} > - - - Reconnecting to OpenSwarm… - - - click to reload - + {showProblem ? ( + <> + + + Reconnecting to OpenSwarm… + + + click to reload + + + ) : ( + <> + + + Reconnected + + + )} , diff --git a/frontend/src/shared/state/dashboardLayoutSlice.ts b/frontend/src/shared/state/dashboardLayoutSlice.ts index 7b38a35c..e15ebd5f 100644 --- a/frontend/src/shared/state/dashboardLayoutSlice.ts +++ b/frontend/src/shared/state/dashboardLayoutSlice.ts @@ -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, 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; @@ -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(