mirror of
https://github.com/openswarm-ai/openswarm.git
synced 2026-09-09 19:27:45 +02:00
[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
This commit is contained in:
@@ -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.
|
||||
|
||||
|
||||
+24
-20
@@ -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 (
|
||||
<Box sx={{
|
||||
position: 'fixed', top: 16, right: 16, zIndex: 1500,
|
||||
display: 'flex', alignItems: 'center', gap: 1,
|
||||
bgcolor: 'background.paper',
|
||||
border: '1px solid', borderColor: 'divider',
|
||||
boxShadow: 3, borderRadius: '10px',
|
||||
px: 1.75, py: 1, fontSize: '0.85rem',
|
||||
maxWidth: 360,
|
||||
}}>
|
||||
<Box component="span" sx={{
|
||||
width: 8, height: 8, borderRadius: '50%',
|
||||
bgcolor: 'success.main',
|
||||
}} />
|
||||
<Box component="span">
|
||||
We had a hiccup and brought you back. Your sessions are still here.
|
||||
<Fade in={show} timeout={{ enter: 250, exit: 300 }} unmountOnExit>
|
||||
<Box sx={{
|
||||
position: 'fixed', top: 16, right: 16, zIndex: 1500,
|
||||
display: 'flex', alignItems: 'center', gap: 1,
|
||||
bgcolor: 'background.paper',
|
||||
border: '1px solid', borderColor: 'divider',
|
||||
boxShadow: 3, borderRadius: '10px',
|
||||
px: 1.75, py: 1, fontSize: '0.85rem',
|
||||
maxWidth: 360,
|
||||
}}>
|
||||
<Box component="span" sx={{
|
||||
width: 8, height: 8, borderRadius: '50%',
|
||||
bgcolor: 'success.main',
|
||||
}} />
|
||||
<Box component="span">
|
||||
We had a hiccup and brought you back. Your sessions are still here.
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
</Fade>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -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<OversizePopupProps> = ({
|
||||
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
|
||||
? <><strong>{firstName}</strong> is too big to send.</>
|
||||
: <>{n} files are too big to send: <strong>{firstName}</strong>{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 (
|
||||
<Fade in={queued} timeout={{ enter: 200, exit: 220 }} unmountOnExit>
|
||||
<Box
|
||||
sx={{
|
||||
position: 'absolute', left: 8, right: 8, bottom: 'calc(100% + 8px)',
|
||||
bgcolor: c.bg.surface, border: `1px solid ${c.border.medium}`,
|
||||
boxShadow: c.shadow.md, borderRadius: '12px',
|
||||
px: 2, py: 1.25,
|
||||
whiteSpace: 'normal',
|
||||
zIndex: 5,
|
||||
}}
|
||||
>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5 }}>
|
||||
<Box sx={{
|
||||
color: c.text.primary, fontSize: '0.88rem', lineHeight: 1.45,
|
||||
flex: '1 1 auto', minWidth: 0,
|
||||
}}>
|
||||
{headline}
|
||||
</Box>
|
||||
<Box sx={{ display: 'flex', gap: 0.75, flexShrink: 0 }}>
|
||||
<Box
|
||||
component="button"
|
||||
disabled={shrinking}
|
||||
onClick={onShrink}
|
||||
sx={{
|
||||
bgcolor: c.accent.primary, color: '#fff',
|
||||
border: 'none', borderRadius: '6px',
|
||||
px: 1.5, py: 0.7, fontSize: '0.82rem', fontWeight: 500, cursor: 'pointer',
|
||||
whiteSpace: 'nowrap',
|
||||
transition: 'background 0.15s ease, opacity 0.15s ease',
|
||||
'&:hover': { bgcolor: c.accent.hover },
|
||||
'&:disabled': { opacity: 0.85, cursor: 'wait', bgcolor: c.accent.primary },
|
||||
}}
|
||||
>
|
||||
{shrinking ? <ShrinkingLabel /> : shrinkLabel}
|
||||
</Box>
|
||||
<Box
|
||||
component="button"
|
||||
disabled={shrinking}
|
||||
onClick={onRemove}
|
||||
sx={{
|
||||
bgcolor: 'transparent', color: c.text.secondary,
|
||||
border: `1px solid ${c.border.medium}`, borderRadius: '6px',
|
||||
px: 1.5, py: 0.7, fontSize: '0.82rem', cursor: 'pointer',
|
||||
whiteSpace: 'nowrap',
|
||||
transition: 'background 0.15s ease, color 0.15s ease',
|
||||
'&:hover': { bgcolor: c.bg.secondary, color: c.text.primary },
|
||||
'&:disabled': { opacity: 0.5, cursor: 'not-allowed' },
|
||||
}}
|
||||
>
|
||||
{removeLabel}
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
<SlowHint active={shrinking} color={c.text.secondary} />
|
||||
</Box>
|
||||
</Fade>
|
||||
);
|
||||
};
|
||||
|
||||
/** 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<string | null>(null);
|
||||
if (message) lastMessage.current = message;
|
||||
const display = lastMessage.current;
|
||||
if (!display) return null;
|
||||
return (
|
||||
<Fade in={!!message} timeout={{ enter: 200, exit: 220 }} unmountOnExit>
|
||||
<Box
|
||||
sx={{
|
||||
position: 'absolute', left: 8, right: 8, bottom: 'calc(100% + 8px)',
|
||||
display: 'flex', alignItems: 'center', gap: 1.5,
|
||||
bgcolor: c.bg.surface, border: `1px solid ${c.border.medium}`,
|
||||
boxShadow: c.shadow.md, borderRadius: '12px',
|
||||
px: 2, py: 1.25,
|
||||
whiteSpace: 'normal',
|
||||
zIndex: 6,
|
||||
}}
|
||||
>
|
||||
<Box sx={{
|
||||
color: c.text.primary, fontSize: '0.88rem', lineHeight: 1.45,
|
||||
flex: '1 1 auto', minWidth: 0,
|
||||
}}>
|
||||
{display}
|
||||
</Box>
|
||||
<IconButton
|
||||
onClick={onClose}
|
||||
size="small"
|
||||
sx={{ color: c.text.secondary, flexShrink: 0, '&:hover': { color: c.text.primary } }}
|
||||
>
|
||||
<CloseIcon sx={{ fontSize: 18 }} />
|
||||
</IconButton>
|
||||
</Box>
|
||||
</Fade>
|
||||
);
|
||||
};
|
||||
|
||||
/** 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 (
|
||||
<Fade in={show} timeout={250}>
|
||||
<Box sx={{ color, fontSize: '0.75rem', mt: 0.5, lineHeight: 1.3, opacity: 0.7 }}>
|
||||
This may take up to a minute. Sit tight.
|
||||
</Box>
|
||||
</Fade>
|
||||
);
|
||||
}
|
||||
|
||||
interface Props {
|
||||
c: ClaudeTokens;
|
||||
lightboxSrc: string | null;
|
||||
@@ -93,104 +250,22 @@ export const ChatInputOverlays: React.FC<Props> = ({
|
||||
</Box>
|
||||
</Modal>
|
||||
|
||||
{/* 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
|
||||
? <><strong>{firstName}</strong> is too big to send.</>
|
||||
: <>{n} files are too big to send: <strong>{firstName}</strong>{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 (
|
||||
<Box
|
||||
sx={{
|
||||
position: 'absolute', left: 8, right: 8, bottom: 'calc(100% + 8px)',
|
||||
display: 'flex', alignItems: 'center', gap: 1.5,
|
||||
bgcolor: c.bg.surface, border: `1px solid ${c.border.medium}`,
|
||||
boxShadow: c.shadow.md, borderRadius: '12px',
|
||||
px: 2, py: 1.25,
|
||||
whiteSpace: 'normal',
|
||||
zIndex: 5,
|
||||
}}
|
||||
>
|
||||
<Box sx={{
|
||||
color: c.text.primary, fontSize: '0.88rem', lineHeight: 1.45,
|
||||
flex: '1 1 auto', minWidth: 0,
|
||||
}}>
|
||||
{headline}
|
||||
</Box>
|
||||
<Box sx={{ display: 'flex', gap: 0.75, flexShrink: 0 }}>
|
||||
<Box
|
||||
component="button"
|
||||
disabled={shrinking}
|
||||
onClick={onShrink}
|
||||
sx={{
|
||||
bgcolor: c.accent.primary, color: '#fff',
|
||||
border: 'none', borderRadius: '6px',
|
||||
px: 1.5, py: 0.7, fontSize: '0.82rem', fontWeight: 500, cursor: 'pointer',
|
||||
whiteSpace: 'nowrap',
|
||||
transition: 'background 0.15s ease, opacity 0.15s ease',
|
||||
'&:hover': { bgcolor: c.accent.hover },
|
||||
'&:disabled': { opacity: 0.85, cursor: 'wait', bgcolor: c.accent.primary },
|
||||
}}
|
||||
>
|
||||
{shrinking ? <ShrinkingLabel /> : shrinkLabel}
|
||||
</Box>
|
||||
<Box
|
||||
component="button"
|
||||
disabled={shrinking}
|
||||
onClick={onRemove}
|
||||
sx={{
|
||||
bgcolor: 'transparent', color: c.text.secondary,
|
||||
border: `1px solid ${c.border.medium}`, borderRadius: '6px',
|
||||
px: 1.5, py: 0.7, fontSize: '0.82rem', cursor: 'pointer',
|
||||
whiteSpace: 'nowrap',
|
||||
transition: 'background 0.15s ease, color 0.15s ease',
|
||||
'&:hover': { bgcolor: c.bg.secondary, color: c.text.primary },
|
||||
'&:disabled': { opacity: 0.5, cursor: 'not-allowed' },
|
||||
}}
|
||||
>
|
||||
{removeLabel}
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
})()}
|
||||
{/* 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. */}
|
||||
<OversizePopup
|
||||
c={c}
|
||||
oversizeQueue={oversizeQueue}
|
||||
summarizingAll={summarizingAll}
|
||||
summarizingPath={summarizingPath}
|
||||
summarizeOversize={summarizeOversize}
|
||||
summarizeAllOversize={summarizeAllOversize}
|
||||
detachOversize={detachOversize}
|
||||
detachAllOversize={detachAllOversize}
|
||||
/>
|
||||
|
||||
{/* Same panel-scoped approach for the error toast. Auto-dismiss kept via useEffect timer below. */}
|
||||
{summarizeError && (
|
||||
<Box
|
||||
sx={{
|
||||
position: 'absolute', left: 8, right: 8, bottom: 'calc(100% + 8px)',
|
||||
display: 'flex', alignItems: 'center', gap: 1.5,
|
||||
bgcolor: c.bg.surface, border: `1px solid ${c.border.medium}`,
|
||||
boxShadow: c.shadow.md, borderRadius: '12px',
|
||||
px: 2, py: 1.25,
|
||||
whiteSpace: 'normal',
|
||||
zIndex: 6,
|
||||
}}
|
||||
>
|
||||
<Box sx={{
|
||||
color: c.text.primary, fontSize: '0.88rem', lineHeight: 1.45,
|
||||
flex: '1 1 auto', minWidth: 0,
|
||||
}}>
|
||||
{summarizeError}
|
||||
</Box>
|
||||
<IconButton
|
||||
onClick={() => setSummarizeError(null)}
|
||||
size="small"
|
||||
sx={{ color: c.text.secondary, flexShrink: 0, '&:hover': { color: c.text.primary } }}
|
||||
>
|
||||
<CloseIcon sx={{ fontSize: 18 }} />
|
||||
</IconButton>
|
||||
</Box>
|
||||
)}
|
||||
{/* Error toast also fades. 220ms exit keeps it from snap-disappearing on close. */}
|
||||
<ErrorToast c={c} message={summarizeError} onClose={() => setSummarizeError(null)} />
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user