From 32bc3077ad73a97f080b1d62186fc64cd7a56ec1 Mon Sep 17 00:00:00 2001 From: eric Date: Sun, 31 May 2026 23:06:28 -0700 Subject: [PATCH] [eric] ux: oversize files auto-shrink and over-context sends auto-compact, no click needed, popups are now status not prompts --- .../src/app/pages/AgentChat/ChatInput.tsx | 27 +++++- .../ChatInput/hooks/useContextFiles.ts | 18 +++- .../ChatInput/view/ChatInputOverlays.tsx | 78 ++++----------- .../ChatInput/view/ChatInputView.tsx | 4 - .../ChatInput/view/SendBlockBanner.tsx | 96 ++++--------------- 5 files changed, 77 insertions(+), 146 deletions(-) diff --git a/frontend/src/app/pages/AgentChat/ChatInput.tsx b/frontend/src/app/pages/AgentChat/ChatInput.tsx index f23b9374..f9f77918 100644 --- a/frontend/src/app/pages/AgentChat/ChatInput.tsx +++ b/frontend/src/app/pages/AgentChat/ChatInput.tsx @@ -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(({ 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 = { '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(({ 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, diff --git a/frontend/src/app/pages/AgentChat/ChatInput/hooks/useContextFiles.ts b/frontend/src/app/pages/AgentChat/ChatInput/hooks/useContextFiles.ts index ff4561ee..b3e322f0 100644 --- a/frontend/src/app/pages/AgentChat/ChatInput/hooks/useContextFiles.ts +++ b/frontend/src/app/pages/AgentChat/ChatInput/hooks/useContextFiles.ts @@ -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; diff --git a/frontend/src/app/pages/AgentChat/ChatInput/view/ChatInputOverlays.tsx b/frontend/src/app/pages/AgentChat/ChatInput/view/ChatInputOverlays.tsx index 13227408..8d0a7898 100644 --- a/frontend/src/app/pages/AgentChat/ChatInput/view/ChatInputOverlays.tsx +++ b/frontend/src/app/pages/AgentChat/ChatInput/view/ChatInputOverlays.tsx @@ -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 = ({ 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()); + const label = n === 1 + ? <>Shrinking {firstName} to fit + : <>Shrinking {firstName} and {n - 1} other{n > 2 ? 's' : ''} to fit; return ( = ({ zIndex: 5, }} > - + + - {headline} - - - - {shrinking ? : shrinkLabel} - - - {removeLabel} - + {label} @@ -258,10 +220,6 @@ export const ChatInputOverlays: React.FC = ({ 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. */} diff --git a/frontend/src/app/pages/AgentChat/ChatInput/view/ChatInputView.tsx b/frontend/src/app/pages/AgentChat/ChatInput/view/ChatInputView.tsx index 7458f729..92a685be 100644 --- a/frontend/src/app/pages/AgentChat/ChatInput/view/ChatInputView.tsx +++ b/frontend/src/app/pages/AgentChat/ChatInput/view/ChatInputView.tsx @@ -177,10 +177,6 @@ export const ChatInputView: React.FC = (p) => { )} diff --git a/frontend/src/app/pages/AgentChat/ChatInput/view/SendBlockBanner.tsx b/frontend/src/app/pages/AgentChat/ChatInput/view/SendBlockBanner.tsx index 0fe04351..0666c653 100644 --- a/frontend/src/app/pages/AgentChat/ChatInput/view/SendBlockBanner.tsx +++ b/frontend/src/app/pages/AgentChat/ChatInput/view/SendBlockBanner.tsx @@ -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; c: ClaudeTokens; - sessionId?: string; - setSendBlock: (v: SendBlock) => void; - setContextPaths: React.Dispatch>; - setModelAnchor: (el: HTMLElement | null) => void; } -export const SendBlockBanner: React.FC = ({ 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 = ({ sendBlock, c }) => { return ( - - That's a lot to send at once. Pick one: + + + Making room for your message - - {sessionId && ( - { - try { - const tok = (() => { try { return getAuthToken(); } catch { return ''; } })(); - const headers: Record = { '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 - - )} - {sendBlock.largestFile && ( - { - 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 - - )} - { 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 - - 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 - - ); };