diff --git a/frontend/CLAUDE.md b/frontend/CLAUDE.md
index 352a983e..634d57f8 100644
--- a/frontend/CLAUDE.md
+++ b/frontend/CLAUDE.md
@@ -50,6 +50,8 @@ This app ships to non-developers. Anything a user sees has to read like a person
- **Friendly without being cute.** Conversational, not chirpy. "Want me to shrink it down to a summary?" not "Whoops! That file is huge!".
- **Minimalist by default. Less is more.** One short message, one subtle animation, one verb. Do NOT add rotating progress messages, multi-line status text, percentage counters, or step-by-step explainers unless the user explicitly needs them. A pulsing dot + "Shrinking" beats a 4-message carousel + spinner + progress bar every time. The user knows what they clicked; we just need to confirm we're alive.
- **Animations are subtle.** Pulse, fade, soft scale (≤1.0× to 0.6×). No bounce, no flashing, no harsh blinking, no rotating spinners with multiple emoji. Easing: `ease-in-out`. Duration: 1-1.5s for ambient states (loading), 150-250ms for state changes (hover, mode flip).
+- **Transient popups MUST fade in/out, not snap-cut.** Any element that appears or disappears in response to user action (oversize popup, error toast, recovery chip, send-block banner) must use MUI `Fade` with `timeout={{ enter: 200, exit: 220 }}` and `unmountOnExit`. Pattern: hold a `lastSnapshot` ref so the exit animation renders the same content it had a moment ago instead of going blank mid-fade. Snap-cuts feel anxious; 200ms fades feel calm.
+- **Long waits get an honest hint AFTER 10s, not upfront.** If an operation usually finishes in 2s but occasionally takes 60s, don't lie by always showing "this may take a minute". Mount a delayed hint that fades in only after 10s of waiting — silent for fast cases, reassuring for slow ones. See `SlowHint` in `ChatInput/view/ChatInputOverlays.tsx`.
- **Don't expose absolute filesystem paths to users.** Tooltips, file chips, and labels should show only the file's basename (e.g. `llama2.pdf`), never the temp-dir path (`/var/folders/s7/.../self-swarm-uploads/llama2.pdf`). Users don't care where their file landed in temp, and a 200-char tooltip dangling over the chat input is ugly. If the user genuinely needs the path, expose it via a "copy path" action, not a hover tooltip.
- **State changes from one button must invalidate downstream estimates.** If a button claims to shrink/clear/reset something the next user action depends on, you have to invalidate the cached estimate too. Example: clicking "Compact memory" calls `/compact` server-side, but the renderer's `tokens.input` was a snapshot from the previous round-trip — leaving it stale makes the next send re-fire the same "over context window" banner, looking like the button did nothing. Always pair an action with the redux update that its UX promise implies.
diff --git a/frontend/src/app/Main.tsx b/frontend/src/app/Main.tsx
index c81a91bf..d7fa1be6 100644
--- a/frontend/src/app/Main.tsx
+++ b/frontend/src/app/Main.tsx
@@ -3,6 +3,7 @@ import { Provider } from 'react-redux';
import { HashRouter, Routes, Route } from 'react-router-dom';
import { ThemeProvider as MuiThemeProvider, createTheme, CssBaseline } from '@mui/material';
import Box from '@mui/material/Box';
+import Fade from '@mui/material/Fade';
import Snackbar from '@mui/material/Snackbar';
import Alert from '@mui/material/Alert';
import { store } from '../shared/state/store';
@@ -371,15 +372,16 @@ const DefaultModelGuard: React.FC<{ children: React.ReactNode }> = ({ children }
/** Surfaces a brief recovery chip if the crash-watchdog relaunched us last cycle.
* Mac-only path (watchdog only runs on darwin); main.js returns null elsewhere.
- * Auto-hides after 8s. No interaction required from the user; sessions are server-side
- * so reattachment is automatic via the WS dashboard subscription that's already wired. */
+ * Fade in over 250ms, hold for 8s, fade out over 300ms. No interaction required;
+ * sessions are server-side so reattachment is automatic. */
const CrashRecoveryChip: React.FC = () => {
const [show, setShow] = React.useState(false);
+ const [mounted, setMounted] = React.useState(false);
React.useEffect(() => {
const api = (window as any).openswarm as OpenSwarmAPI | undefined;
if (!api?.getCrashRecoveryInfo) return;
api.getCrashRecoveryInfo().then((info) => {
- if (info) setShow(true);
+ if (info) { setMounted(true); setShow(true); }
}).catch(() => {});
}, []);
React.useEffect(() => {
@@ -387,25 +389,27 @@ const CrashRecoveryChip: React.FC = () => {
const t = setTimeout(() => setShow(false), 8000);
return () => clearTimeout(t);
}, [show]);
- if (!show) return null;
+ if (!mounted) return null;
return (
-
-
-
- We had a hiccup and brought you back. Your sessions are still here.
+
+
+
+
+ We had a hiccup and brought you back. Your sessions are still here.
+
-
+
);
};
diff --git a/frontend/src/app/pages/AgentChat/ChatInput/view/ChatInputOverlays.tsx b/frontend/src/app/pages/AgentChat/ChatInput/view/ChatInputOverlays.tsx
index b7a0eaba..13227408 100644
--- a/frontend/src/app/pages/AgentChat/ChatInput/view/ChatInputOverlays.tsx
+++ b/frontend/src/app/pages/AgentChat/ChatInput/view/ChatInputOverlays.tsx
@@ -1,5 +1,6 @@
import React from 'react';
import Box from '@mui/material/Box';
+import Fade from '@mui/material/Fade';
import IconButton from '@mui/material/IconButton';
import Modal from '@mui/material/Modal';
import CloseIcon from '@mui/icons-material/Close';
@@ -22,6 +23,162 @@ function ShrinkingLabel() {
);
}
+interface OversizePopupProps {
+ c: ClaudeTokens;
+ oversizeQueue: Array<{ path: string; name: string; tokens: number }>;
+ summarizingAll: boolean;
+ summarizingPath: string | null;
+ summarizeOversize: (path: string) => void;
+ summarizeAllOversize: () => void;
+ detachOversize: (path: string) => void;
+ detachAllOversize: () => void;
+}
+
+/** Wrapper that delays unmount through MUI Fade so the popup eases out instead
+ * of snap-disappearing. Important because the auto-retry-send fires once the
+ * queue drains; without the fade-out the user sees "popup vanishes" → blank ms
+ * → "their message appears", which feels jumpy. With the fade it's a calm
+ * handoff. Visibility tracked via local `open` so we can decouple it from the
+ * queue-length React render. */
+const OversizePopup: React.FC = ({
+ c, oversizeQueue, summarizingAll, summarizingPath,
+ summarizeOversize, summarizeAllOversize, detachOversize, detachAllOversize,
+}) => {
+ const queued = oversizeQueue.length > 0;
+ // Remember the last non-empty snapshot so the fade-out renders the same content
+ // it had a moment ago, instead of going blank during the transition.
+ const lastSnapshot = React.useRef(oversizeQueue);
+ if (queued) lastSnapshot.current = oversizeQueue;
+ const snap = lastSnapshot.current;
+ const n = snap.length;
+ if (n === 0) return null;
+ const firstName = snap[0].name;
+ const headline = n === 1
+ ? <>{firstName} is too big to send.>
+ : <>{n} files are too big to send: {firstName}{n > 1 ? <> and {n - 1} other{n > 2 ? 's' : ''}> : null}.>;
+ const shrinkLabel = n === 1 ? 'Shrink it' : `Shrink all ${n}`;
+ const removeLabel = n === 1 ? 'Remove' : `Remove all ${n}`;
+ const shrinking = summarizingAll || !!summarizingPath;
+ const onShrink = () => (n === 1 ? summarizeOversize(snap[0].path) : summarizeAllOversize());
+ const onRemove = () => (n === 1 ? detachOversize(snap[0].path) : detachAllOversize());
+ return (
+
+
+
+
+ {headline}
+
+
+
+ {shrinking ? : shrinkLabel}
+
+
+ {removeLabel}
+
+
+
+
+
+
+ );
+};
+
+/** Fade-wrapped error toast. Last-non-null snapshot keeps the message visible
+ * through the exit animation instead of going blank during fade-out. */
+const ErrorToast: React.FC<{ c: ClaudeTokens; message: string | null; onClose: () => void }> = ({ c, message, onClose }) => {
+ const lastMessage = React.useRef(null);
+ if (message) lastMessage.current = message;
+ const display = lastMessage.current;
+ if (!display) return null;
+ return (
+
+
+
+ {display}
+
+
+
+
+
+
+ );
+};
+
+/** Honest "this is taking a sec" hint that fades in only AFTER 10s of waiting.
+ * Silent on fast operations (most cases) so we don't lie about every shrink
+ * being slow; visible only when the user has actually been waiting long enough
+ * to start wondering if it's frozen. */
+function SlowHint({ active, color }: { active: boolean; color: string }) {
+ const [show, setShow] = React.useState(false);
+ React.useEffect(() => {
+ if (!active) { setShow(false); return; }
+ const t = setTimeout(() => setShow(true), 10000);
+ return () => clearTimeout(t);
+ }, [active]);
+ return (
+
+
+ This may take up to a minute. Sit tight.
+
+
+ );
+}
+
interface Props {
c: ClaudeTokens;
lightboxSrc: string | null;
@@ -93,104 +250,22 @@ export const ChatInputOverlays: React.FC = ({
- {/* Single popup handles ALL over-size files. One click → all shrunk in parallel or all removed.
- Auto-retry in useContextFiles fires the user's pending send after the queue drains, so going
- from 5 too-big files to a sent message is 1 click instead of 6 (Shrink+Remove pairs * 5 + Send). */}
- {oversizeQueue.length > 0 && (() => {
- const n = oversizeQueue.length;
- const firstName = oversizeQueue[0].name;
- const headline = n === 1
- ? <>{firstName} is too big to send.>
- : <>{n} files are too big to send: {firstName}{n > 1 ? <> and {n - 1} other{n > 2 ? 's' : ''}> : null}.>;
- const shrinkLabel = n === 1 ? 'Shrink it' : `Shrink all ${n}`;
- const removeLabel = n === 1 ? 'Remove' : `Remove all ${n}`;
- const shrinking = summarizingAll || !!summarizingPath;
- const onShrink = () => (n === 1 ? summarizeOversize(oversizeQueue[0].path) : summarizeAllOversize());
- const onRemove = () => (n === 1 ? detachOversize(oversizeQueue[0].path) : detachAllOversize());
- return (
-
-
- {headline}
-
-
-
- {shrinking ? : shrinkLabel}
-
-
- {removeLabel}
-
-
-
- );
- })()}
+ {/* Single popup handles ALL over-size files. Fade controls enter/exit so the
+ handoff to auto-retry-send feels smooth, not snap-cut. Internal SlowHint
+ only fades in after 10s of waiting so we're honest without being noisy. */}
+
- {/* Same panel-scoped approach for the error toast. Auto-dismiss kept via useEffect timer below. */}
- {summarizeError && (
-
-
- {summarizeError}
-
- setSummarizeError(null)}
- size="small"
- sx={{ color: c.text.secondary, flexShrink: 0, '&:hover': { color: c.text.primary } }}
- >
-
-
-
- )}
+ {/* Error toast also fades. 220ms exit keeps it from snap-disappearing on close. */}
+ setSummarizeError(null)} />
>
);
};