[eric] ux: oversize files auto-shrink and over-context sends auto-compact, no click needed, popups are now status not prompts

This commit is contained in:
eric
2026-05-31 23:06:28 -07:00
parent 7ed8d8d27a
commit 32bc3077ad
5 changed files with 77 additions and 146 deletions
+25 -2
View File
@@ -9,6 +9,7 @@ import { useClaudeTokens } from '@/shared/styles/ThemeContext';
import { useChatInputModel } from './ChatInput/hooks/useChatInputModel';
import { useDraftLoad, deleteDraft, loadDraft } from './ChatInput/hooks/draftStore';
import { handleSlashCommand } from './ChatInput/hooks/slashCommands';
import { API_BASE, getAuthToken } from '@/shared/config';
import { materializeImages, appendSelectedElements, computeSendBlock } from './ChatInput/sendHelpers';
import { useImageAttachments } from './ChatInput/hooks/useImageAttachments';
import { useContextFiles } from './ChatInput/hooks/useContextFiles';
@@ -147,7 +148,29 @@ const ChatInput = forwardRef<ChatInputHandle, Props>(({ onSend, disabled, mode,
historyUsed: contextEstimate?.used ?? 0,
contextPaths, sessionFrameworkOverhead,
});
if (block) { setSendBlock(block); return; }
if (block) {
// Auto-compact instead of prompting. Conversation history is the only
// overflow source we can shrink without losing user content — files were
// already auto-shrunk above, the prompt itself is the message the user
// just wrote, MCPs are framework. So if we're over, hit /compact, capture
// the send intent, and let the next-message effect fire it after the
// server-side compaction acks. User did nothing; problem solved silently.
if (sessionId) {
pendingSendRef.current = () => { handleSend(); };
try {
const tok = (() => { try { return getAuthToken(); } catch { return ''; } })();
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
if (tok) headers['Authorization'] = `Bearer ${tok}`;
await fetch(`${API_BASE}/agents/sessions/${sessionId}/compact`, { method: 'POST', headers });
} catch (err) { console.error('[auto-compact] failed:', err); pendingSendRef.current = null; }
}
// Briefly flash the banner as a status (not a prompt) so the user sees
// something happened. Auto-clear after 2s; in 99% of cases the auto-retry
// send has already fired by then.
setSendBlock(block);
setTimeout(() => setSendBlock(null), 2000);
return;
}
onboardingBus.emit('chat:message_sent');
if (window.location.hash.includes('/apps/')) {
@@ -199,7 +222,7 @@ const ChatInput = forwardRef<ChatInputHandle, Props>(({ onSend, disabled, mode,
setAttachedSkills({});
setHasContent(false);
elementSelection?.clearOwnerElements(ownerId);
}, [disabled, images, contextPaths, forcedTools, onSend, elementSelection, ownerId, summarizingPath, summarizingAll, oversizeQueue, pendingSendRef]);
}, [disabled, images, contextPaths, forcedTools, onSend, elementSelection, ownerId, summarizingPath, summarizingAll, oversizeQueue, pendingSendRef, sessionId, currentModelCtx, contextEstimate, sessionFrameworkOverhead, setSendBlock]);
const {
picker: editorPicker, setPicker,
@@ -175,8 +175,24 @@ export function useContextFiles(
}
}, [oversizeQueue, summarizingAll, currentModelCtx, model]);
// Auto-shrink: as soon as a file lands oversize, fire the shrink. No "this file is
// too big, what do you want to do?" prompt — there's no real choice, we KNOW the only
// reasonable answer is "shrink it". The popup becomes a status indicator ("Shrinking
// X") not a question, and disappears the moment shrinking finishes. If the user wanted
// the original unshrunk file they'd not have attached something bigger than the model's
// window in the first place; we still expose detach-on-chip if they change their mind.
const lastAutoShrinkSig = useRef('');
useEffect(() => {
if (oversizeQueue.length === 0) return;
if (summarizingAll || summarizingPath) return;
const sig = oversizeQueue.map((o) => o.path).sort().join('|');
if (sig === lastAutoShrinkSig.current) return;
lastAutoShrinkSig.current = sig;
summarizeAllOversize();
}, [oversizeQueue, summarizingAll, summarizingPath, summarizeAllOversize]);
// Auto-retry: when the queue drains AND the user had a pending send, fire it.
// Zero extra clicks once they pick Shrink all / Remove all.
// Zero extra clicks; user types "hi" with attached files, the shrink happens, send fires.
useEffect(() => {
if (oversizeQueue.length === 0 && !summarizingAll && !summarizingPath && pendingSendRef.current) {
const send = pendingSendRef.current;
@@ -28,39 +28,25 @@ interface OversizePopupProps {
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. */
/** Status indicator (NOT a prompt). When files land oversize they auto-shrink
* via useContextFiles' useEffect — no click required. This box just tells the
* user what's happening so the chat doesn't look frozen during the shrink. */
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());
const label = n === 1
? <>Shrinking <strong>{firstName}</strong> to fit</>
: <>Shrinking <strong>{firstName}</strong> and {n - 1} other{n > 2 ? 's' : ''} to fit</>;
return (
<Fade in={queued} timeout={{ enter: 200, exit: 220 }} unmountOnExit>
<Box
@@ -73,46 +59,22 @@ const OversizePopup: React.FC<OversizePopupProps> = ({
zIndex: 5,
}}
>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Box component="span" sx={{
display: 'inline-block', width: 6, height: 6, borderRadius: '50%',
bgcolor: c.accent.primary,
animation: shrinking ? 'osw-pulse 1.2s ease-in-out infinite' : 'none',
'@keyframes osw-pulse': {
'0%, 100%': { opacity: 0.4 },
'50%': { opacity: 1 },
},
flexShrink: 0,
}} />
<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>
{label}
</Box>
</Box>
<SlowHint active={shrinking} color={c.text.secondary} />
@@ -258,10 +220,6 @@ export const ChatInputOverlays: React.FC<Props> = ({
oversizeQueue={oversizeQueue}
summarizingAll={summarizingAll}
summarizingPath={summarizingPath}
summarizeOversize={summarizeOversize}
summarizeAllOversize={summarizeAllOversize}
detachOversize={detachOversize}
detachAllOversize={detachAllOversize}
/>
{/* Error toast also fades. 220ms exit keeps it from snap-disappearing on close. */}
@@ -177,10 +177,6 @@ export const ChatInputView: React.FC<Props> = (p) => {
<SendBlockBanner
sendBlock={p.sendBlock}
c={c}
sessionId={p.sessionId}
setSendBlock={p.setSendBlock}
setContextPaths={p.setContextPaths}
setModelAnchor={p.setModelAnchor}
/>
)}
@@ -1,100 +1,38 @@
import React from 'react';
import Box from '@mui/material/Box';
import Typography from '@mui/material/Typography';
import { ContextPath } from '@/app/components/editor/DirectoryBrowser';
import { API_BASE, getAuthToken } from '@/shared/config';
import { ClaudeTokens } from '@/shared/styles/claudeTokens';
import { SendBlock } from '../hooks/useContextFiles';
interface Props {
sendBlock: NonNullable<SendBlock>;
c: ClaudeTokens;
sessionId?: string;
setSendBlock: (v: SendBlock) => void;
setContextPaths: React.Dispatch<React.SetStateAction<ContextPath[]>>;
setModelAnchor: (el: HTMLElement | null) => void;
}
export const SendBlockBanner: React.FC<Props> = ({ sendBlock, c, sessionId, setSendBlock, setContextPaths, setModelAnchor }) => {
const fmt = (n: number) => n >= 1000 ? `${(n / 1000).toFixed(0)}K` : String(n);
/** Status indicator, not a prompt. Auto-compact already fired from handleSend; this
* just lets the user know we're freeing up space so the send doesn't look frozen. */
export const SendBlockBanner: React.FC<Props> = ({ sendBlock, c }) => {
return (
<Box sx={{
mx: 1.5, mt: 1, mb: 0.5, px: 2, py: 1.5,
mx: 1.5, mt: 1, mb: 0.5, px: 2, py: 1.25,
borderRadius: '12px',
bgcolor: c.bg.surface,
border: `1px solid ${c.border.medium}`,
display: 'flex', alignItems: 'center', gap: 1,
}}>
<Typography sx={{ fontSize: '0.9rem', color: c.text.primary, lineHeight: 1.5, mb: 1 }}>
That's a lot to send at once. Pick one:
<Box component="span" sx={{
display: 'inline-block', width: 6, height: 6, borderRadius: '50%',
bgcolor: c.accent.primary,
animation: 'osw-pulse 1.2s ease-in-out infinite',
'@keyframes osw-pulse': {
'0%, 100%': { opacity: 0.4 },
'50%': { opacity: 1 },
},
flexShrink: 0,
}} />
<Typography sx={{ fontSize: '0.88rem', color: c.text.primary, lineHeight: 1.45 }}>
Making room for your message
</Typography>
<Box sx={{ display: 'flex', gap: 0.75, flexWrap: 'wrap' }}>
{sessionId && (
<Box
component="button"
onClick={async () => {
try {
const tok = (() => { try { return getAuthToken(); } catch { return ''; } })();
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
if (tok) headers['Authorization'] = `Bearer ${tok}`;
await fetch(`${API_BASE}/agents/sessions/${sessionId}/compact`, { method: 'POST', headers });
setSendBlock(null);
} catch (err) { console.error(err); }
}}
sx={{
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 },
}}
>
Shrink history
</Box>
)}
{sendBlock.largestFile && (
<Box
component="button"
onClick={() => {
const p = sendBlock.largestFile!.path;
setContextPaths((prev) => prev.filter((cp) => cp.path !== p));
setSendBlock(null);
}}
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',
transition: 'background 0.15s ease, color 0.15s ease',
'&:hover': { bgcolor: c.bg.secondary, color: c.text.primary },
}}
>
Remove biggest file
</Box>
)}
<Box
component="button"
onClick={(e) => { setModelAnchor(e.currentTarget as HTMLElement); setSendBlock(null); }}
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',
transition: 'background 0.15s ease, color 0.15s ease',
'&:hover': { bgcolor: c.bg.secondary, color: c.text.primary },
}}
>
Switch model
</Box>
<Box
component="button"
onClick={() => setSendBlock(null)}
sx={{
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
</Box>
</Box>
</Box>
);
};