From 7ed8d8d27a6c3dcf2ebe1955e0e8159761e64148 Mon Sep 17 00:00:00 2001 From: eric Date: Sun, 31 May 2026 22:51:02 -0700 Subject: [PATCH] [eric] ui: popups and chip fade in/out instead of snap-cutting and shrink gets a calm 'may take a minute' hint after 10s only --- frontend/CLAUDE.md | 2 + frontend/src/app/Main.tsx | 44 +-- .../ChatInput/view/ChatInputOverlays.tsx | 269 +++++++++++------- 3 files changed, 198 insertions(+), 117 deletions(-) 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)} /> ); };