diff --git a/frontend/CLAUDE.md b/frontend/CLAUDE.md index dc19ee4a..352a983e 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). +- **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 diff --git a/frontend/src/app/pages/AgentChat/ChatInput/view/AttachmentChips.tsx b/frontend/src/app/pages/AgentChat/ChatInput/view/AttachmentChips.tsx index 695c9e0b..f0aae287 100644 --- a/frontend/src/app/pages/AgentChat/ChatInput/view/AttachmentChips.tsx +++ b/frontend/src/app/pages/AgentChat/ChatInput/view/AttachmentChips.tsx @@ -112,7 +112,7 @@ export const AttachmentChips: React.FC = ({ return ( = ({ sx: { fontFamily: c.font.mono, fontSize: '0.7rem', - maxWidth: 420, + maxWidth: 280, wordBreak: 'break-all', }, }, diff --git a/frontend/src/app/pages/AgentChat/ChatInput/view/SendBlockBanner.tsx b/frontend/src/app/pages/AgentChat/ChatInput/view/SendBlockBanner.tsx index ef82bf25..0fe04351 100644 --- a/frontend/src/app/pages/AgentChat/ChatInput/view/SendBlockBanner.tsx +++ b/frontend/src/app/pages/AgentChat/ChatInput/view/SendBlockBanner.tsx @@ -16,17 +16,18 @@ interface Props { } export const SendBlockBanner: React.FC = ({ 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 ( - - - This send would overflow the model's context window + + + That's a lot to send at once. Pick one: - - ~{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)}. - - + {sessionId && ( = ({ 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 )} {sendBlock.largestFile && ( @@ -56,21 +59,25 @@ export const SendBlockBanner: React.FC = ({ 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 )} { 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 = ({ 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 diff --git a/frontend/src/shared/state/agentsSlice.ts b/frontend/src/shared/state/agentsSlice.ts index 40b29305..5f104dcf 100644 --- a/frontend/src/shared/state/agentsSlice.ts +++ b/frontend/src/shared/state/agentsSlice.ts @@ -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.