[eric] ui: send-block banner is plain not red, compact button actually invalidates the stale estimate, chip tooltips show filename not temp path

This commit is contained in:
eric
2026-05-31 19:07:28 -07:00
parent a6dd596405
commit 5d282b67ef
4 changed files with 42 additions and 24 deletions
+2
View File
@@ -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).
- **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.
## Pitfalls
@@ -112,7 +112,7 @@ export const AttachmentChips: React.FC<Props> = ({
return (
<Tooltip
key={`${cp.path}-${idx}`}
title={copiedPathIdx === idx ? 'Copied!' : cp.path}
title={copiedPathIdx === idx ? 'Copied!' : (cp.path.split('/').pop() || cp.path)}
arrow
placement="top"
slotProps={{
@@ -120,7 +120,7 @@ export const AttachmentChips: React.FC<Props> = ({
sx: {
fontFamily: c.font.mono,
fontSize: '0.7rem',
maxWidth: 420,
maxWidth: 280,
wordBreak: 'break-all',
},
},
@@ -16,17 +16,18 @@ interface Props {
}
export const SendBlockBanner: React.FC<Props> = ({ sendBlock, c, sessionId, setSendBlock, setContextPaths, setModelAnchor }) => {
const fmt = (n: number) => n >= 1000 ? `${(n / 1000).toFixed(1)}K` : String(n);
const over = sendBlock.estimate - sendBlock.window;
const fmt = (n: number) => n >= 1000 ? `${(n / 1000).toFixed(0)}K` : String(n);
return (
<Box sx={{ mx: 1.5, mt: 1, mb: 0.5, p: 1.25, borderRadius: '10px', border: `1px solid ${c.status.error}`, bgcolor: `${c.status.error}10` }}>
<Typography sx={{ fontSize: '0.78rem', fontWeight: 600, color: c.status.error, mb: 0.5 }}>
This send would overflow the model's context window
<Box sx={{
mx: 1.5, mt: 1, mb: 0.5, px: 2, py: 1.5,
borderRadius: '12px',
bgcolor: c.bg.surface,
border: `1px solid ${c.border.medium}`,
}}>
<Typography sx={{ fontSize: '0.9rem', color: c.text.primary, lineHeight: 1.5, mb: 1 }}>
That's a lot to send at once. Pick one:
</Typography>
<Typography sx={{ fontSize: '0.72rem', color: c.text.secondary, mb: 0.75, fontVariantNumeric: 'tabular-nums' }}>
~{fmt(sendBlock.estimate)} of {fmt(sendBlock.window)} tokens ({over > 0 ? `${fmt(over)} over` : 'at cap'}). History {fmt(sendBlock.history)} · Files {fmt(sendBlock.files)} · Tools/MCPs {fmt(sendBlock.framework)} · This message {fmt(sendBlock.prompt)}.
</Typography>
<Box sx={{ display: 'flex', gap: 0.5, flexWrap: 'wrap' }}>
<Box sx={{ display: 'flex', gap: 0.75, flexWrap: 'wrap' }}>
{sessionId && (
<Box
component="button"
@@ -40,11 +41,13 @@ export const SendBlockBanner: React.FC<Props> = ({ sendBlock, c, sessionId, setS
} catch (err) { console.error(err); }
}}
sx={{
background: c.accent.primary, color: '#fff', border: 'none', borderRadius: '6px',
px: 1, py: 0.5, fontSize: '0.72rem', cursor: 'pointer', '&:hover': { opacity: 0.9 },
bgcolor: c.accent.primary, color: '#fff', border: 'none', borderRadius: '6px',
px: 1.5, py: 0.7, fontSize: '0.82rem', fontWeight: 500, cursor: 'pointer',
transition: 'background 0.15s ease',
'&:hover': { bgcolor: c.accent.hover },
}}
>
Compact memory
Shrink history
</Box>
)}
{sendBlock.largestFile && (
@@ -56,21 +59,25 @@ export const SendBlockBanner: React.FC<Props> = ({ sendBlock, c, sessionId, setS
setSendBlock(null);
}}
sx={{
background: 'transparent', color: c.text.primary, border: `1px solid ${c.border.subtle}`,
borderRadius: '6px', px: 1, py: 0.5, fontSize: '0.72rem', cursor: 'pointer',
'&:hover': { background: c.bg.secondary },
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',
transition: 'background 0.15s ease, color 0.15s ease',
'&:hover': { bgcolor: c.bg.secondary, color: c.text.primary },
}}
>
Detach largest file (~{fmt(sendBlock.largestFile.tokens)})
Remove biggest file
</Box>
)}
<Box
component="button"
onClick={(e) => { setModelAnchor(e.currentTarget as HTMLElement); setSendBlock(null); }}
sx={{
background: 'transparent', color: c.text.primary, border: `1px solid ${c.border.subtle}`,
borderRadius: '6px', px: 1, py: 0.5, fontSize: '0.72rem', cursor: 'pointer',
'&:hover': { background: c.bg.secondary },
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',
transition: 'background 0.15s ease, color 0.15s ease',
'&:hover': { bgcolor: c.bg.secondary, color: c.text.primary },
}}
>
Switch model
@@ -79,9 +86,10 @@ export const SendBlockBanner: React.FC<Props> = ({ sendBlock, c, sessionId, setS
component="button"
onClick={() => setSendBlock(null)}
sx={{
background: 'transparent', color: c.text.muted, border: 'none',
borderRadius: '6px', px: 1, py: 0.5, fontSize: '0.72rem', cursor: 'pointer',
'&:hover': { background: c.bg.secondary },
bgcolor: 'transparent', color: c.text.muted, border: 'none',
borderRadius: '6px', px: 1.5, py: 0.7, fontSize: '0.82rem', cursor: 'pointer',
transition: 'color 0.15s ease',
'&:hover': { color: c.text.secondary },
}}
>
Dismiss
+8
View File
@@ -744,6 +744,14 @@ const agentsSlice = createSlice({
const session = state.sessions[action.payload.sessionId];
if (!session) return;
session.compacted_through_msg_id = action.payload.throughMsgId;
// The next preflight check looks at tokens.input to estimate "history used".
// After a compaction the real number is unknown until the next turn round-trips,
// but the OLD number is definitely wrong (it counts messages we just dropped).
// Zero it so preflight falls back to the char/4 estimate of remaining content,
// which is closer than holding the stale pre-compact value.
if (action.payload.throughMsgId) {
session.tokens = { input: 0, output: session.tokens?.output ?? 0 };
}
},
// Aux-LLM turn label; pill renderer prefers this over the static "Thinking..." verb.