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(