mirror of
https://github.com/openswarm-ai/openswarm.git
synced 2026-08-30 19:59:38 +02:00
[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:
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user