From ec6a923b46e5cbc47445c706737294ae0668187b Mon Sep 17 00:00:00 2001 From: eric Date: Sun, 31 May 2026 22:18:21 -0700 Subject: [PATCH] [eric] ui: one popup handles all oversize files at once and the send fires itself after - 5 clicks becomes 1 --- .../src/app/pages/AgentChat/ChatInput.tsx | 19 ++- .../ChatInput/hooks/useContextFiles.ts | 79 +++++++++++- .../ChatInput/view/ChatInputOverlays.tsx | 117 ++++++++++-------- .../ChatInput/view/ChatInputView.tsx | 6 + 4 files changed, 168 insertions(+), 53 deletions(-) diff --git a/frontend/src/app/pages/AgentChat/ChatInput.tsx b/frontend/src/app/pages/AgentChat/ChatInput.tsx index a9e60f10..f23b9374 100644 --- a/frontend/src/app/pages/AgentChat/ChatInput.tsx +++ b/frontend/src/app/pages/AgentChat/ChatInput.tsx @@ -91,13 +91,17 @@ const ChatInput = forwardRef(({ onSend, disabled, mode, copiedPathIdx, setCopiedPathIdx, oversizeQueue, summarizingPath, + summarizingAll, summarizeError, setSummarizeError, sendBlock, setSendBlock, uploadAndAttachFiles, detachOversize, + detachAllOversize, summarizeOversize, + summarizeAllOversize, pendingPayloadEstimate, pendingKinds, + pendingSendRef, } = useContextFiles(currentModelCtx, model, contextEstimate, sessionFrameworkOverhead); useImperativeHandle(ref, () => ({ @@ -124,8 +128,14 @@ const ChatInput = forwardRef(({ onSend, disabled, mode, const handleSend = useCallback(async () => { const editor = editorRef.current; if (!editor || disabled) return; - if (summarizingPath) return; - if (oversizeQueue.length > 0) return; + if (summarizingPath || summarizingAll) return; + // If files are flagged too big, popup will appear above the input. Capture + // the user's intent to send so once they pick Shrink all / Remove all and + // the queue drains, the send fires automatically (zero extra clicks). + if (oversizeQueue.length > 0) { + pendingSendRef.current = () => { handleSend(); }; + return; + } const serialized = editor.tagName === 'TEXTAREA' ? (editor as unknown as HTMLTextAreaElement).value : serializeEditorContent(editor, attachedSkillsRef.current); @@ -189,7 +199,7 @@ const ChatInput = forwardRef(({ onSend, disabled, mode, setAttachedSkills({}); setHasContent(false); elementSelection?.clearOwnerElements(ownerId); - }, [disabled, images, contextPaths, forcedTools, onSend, elementSelection, ownerId]); + }, [disabled, images, contextPaths, forcedTools, onSend, elementSelection, ownerId, summarizingPath, summarizingAll, oversizeQueue, pendingSendRef]); const { picker: editorPicker, setPicker, @@ -281,8 +291,11 @@ const ChatInput = forwardRef(({ onSend, disabled, mode, lightboxSrc={lightboxSrc} oversizeQueue={oversizeQueue} summarizingPath={summarizingPath} + summarizingAll={summarizingAll} summarizeOversize={summarizeOversize} + summarizeAllOversize={summarizeAllOversize} detachOversize={detachOversize} + detachAllOversize={detachAllOversize} currentModelCtx={currentModelCtx} summarizeError={summarizeError} setSummarizeError={setSummarizeError} diff --git a/frontend/src/app/pages/AgentChat/ChatInput/hooks/useContextFiles.ts b/frontend/src/app/pages/AgentChat/ChatInput/hooks/useContextFiles.ts index c5ac32f3..ff4561ee 100644 --- a/frontend/src/app/pages/AgentChat/ChatInput/hooks/useContextFiles.ts +++ b/frontend/src/app/pages/AgentChat/ChatInput/hooks/useContextFiles.ts @@ -1,4 +1,4 @@ -import { useState, useCallback, useEffect, useMemo } from 'react'; +import { useState, useCallback, useEffect, useMemo, useRef } from 'react'; import { ContextPath } from '@/app/components/editor/DirectoryBrowser'; import { API_BASE, getAuthToken } from '@/shared/config'; import { ForcedToolGroup } from '../types'; @@ -27,8 +27,12 @@ export function useContextFiles( const [copiedPathIdx, setCopiedPathIdx] = useState(null); const [oversizeQueue, setOversizeQueue] = useState>([]); const [summarizingPath, setSummarizingPath] = useState(null); + const [summarizingAll, setSummarizingAll] = useState(false); const [summarizeError, setSummarizeError] = useState(null); const [sendBlock, setSendBlock] = useState(null); + // Set when user clicked Send but oversize popup intercepted. Once the queue drains, + // we trigger the send automatically so the user doesn't have to click Send a second time. + const pendingSendRef = useRef<(() => void) | null>(null); const uploadAndAttachFiles = useCallback(async (files: File[]) => { if (files.length === 0) return; @@ -79,6 +83,17 @@ export function useContextFiles( setOversizeQueue((q) => q.filter((o) => o.path !== path)); }, []); + // Single-click batch: remove EVERY oversize file. The auto-retry effect below + // notices the queue went empty and fires the pending send (if any), so going + // from 5 too-big files to a sent message is 1 click instead of 6. + const detachAllOversize = useCallback(() => { + setOversizeQueue((q) => { + const paths = new Set(q.map((o) => o.path)); + setContextPaths((prev) => prev.filter((cp) => !paths.has(cp.path))); + return []; + }); + }, []); + const summarizeOversize = useCallback(async (path: string) => { if (summarizingPath) return; // another summarize is in flight; ignore setSummarizingPath(path); @@ -112,6 +127,64 @@ export function useContextFiles( } }, [currentModelCtx, model, summarizingPath]); + // Single-click batch: shrink EVERY oversize file in parallel. Server-side each + // call already chunks-and-merges via asyncio.gather, so N files at once is bounded + // by the slowest one's chunk count, not N x single-file time. Errors from any + // one file land in summarizeError; others continue. + const summarizeAllOversize = useCallback(async () => { + if (summarizingAll) return; + const snapshot = oversizeQueue.slice(); + if (snapshot.length === 0) return; + setSummarizingAll(true); + try { + const tok = (() => { try { return getAuthToken(); } catch { return ''; } })(); + const headers: Record = { 'Content-Type': 'application/json' }; + if (tok) headers['Authorization'] = `Bearer ${tok}`; + const target = Math.min(8_000, Math.max(1_000, Math.floor(currentModelCtx * 0.05))); + const results = await Promise.allSettled(snapshot.map(async (item) => { + const resp = await fetch(`${API_BASE}/settings/summarize-file`, { + method: 'POST', headers, + body: JSON.stringify({ path: item.path, target_tokens: target, primary_model: model }), + }); + if (!resp.ok) { + let detail = `summarize failed (${resp.status})`; + try { const j = await resp.json(); if (j?.detail) detail = String(j.detail); } catch {} + throw new Error(detail); + } + const data = await resp.json(); + return { oldPath: item.path, newPath: data.path as string, newTokens: (data.tokens as number) || 0 }; + })); + const succeeded = results.filter((r) => r.status === 'fulfilled') as PromiseFulfilledResult<{ oldPath: string; newPath: string; newTokens: number }>[]; + const failed = results.filter((r) => r.status === 'rejected') as PromiseRejectedResult[]; + if (succeeded.length > 0) { + setContextPaths((prev) => prev.map((cp) => { + const hit = succeeded.find((r) => r.value.oldPath === cp.path); + return hit ? { ...cp, path: hit.value.newPath, tokens: hit.value.newTokens, kind: 'text' as const, media_type: 'text/plain' } : cp; + })); + const okPaths = new Set(succeeded.map((r) => r.value.oldPath)); + setOversizeQueue((q) => q.filter((o) => !okPaths.has(o.path))); + } + if (failed.length > 0) { + failed.forEach((f) => { if (f.reason instanceof Error) console.error('[summarize-all] failed:', f.reason.message); }); + setSummarizeError(failed.length === snapshot.length + ? 'Could not shrink the files. Try removing some, or pick a model with a bigger window in Settings.' + : `Could not shrink ${failed.length} of ${snapshot.length} files. Remove or retry the ones still flagged.`); + } + } finally { + setSummarizingAll(false); + } + }, [oversizeQueue, summarizingAll, currentModelCtx, model]); + + // 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. + useEffect(() => { + if (oversizeQueue.length === 0 && !summarizingAll && !summarizingPath && pendingSendRef.current) { + const send = pendingSendRef.current; + pendingSendRef.current = null; + send(); + } + }, [oversizeQueue.length, summarizingAll, summarizingPath]); + const pendingPayloadEstimate = useMemo(() => { const history = Math.max(0, contextEstimate?.used ?? 0); const filesSum = contextPaths.reduce((acc, cp) => acc + (cp.tokens || 0), 0); @@ -133,12 +206,16 @@ export function useContextFiles( copiedPathIdx, setCopiedPathIdx, oversizeQueue, summarizingPath, + summarizingAll, summarizeError, setSummarizeError, sendBlock, setSendBlock, uploadAndAttachFiles, detachOversize, + detachAllOversize, summarizeOversize, + summarizeAllOversize, pendingPayloadEstimate, pendingKinds, + pendingSendRef, }; } diff --git a/frontend/src/app/pages/AgentChat/ChatInput/view/ChatInputOverlays.tsx b/frontend/src/app/pages/AgentChat/ChatInput/view/ChatInputOverlays.tsx index b7a747af..b7a0eaba 100644 --- a/frontend/src/app/pages/AgentChat/ChatInput/view/ChatInputOverlays.tsx +++ b/frontend/src/app/pages/AgentChat/ChatInput/view/ChatInputOverlays.tsx @@ -28,16 +28,20 @@ interface Props { setLightboxSrc: (src: string | null) => void; oversizeQueue: Array<{ path: string; name: string; tokens: number }>; summarizingPath: string | null; + summarizingAll: boolean; summarizeOversize: (path: string) => void; + summarizeAllOversize: () => void; detachOversize: (path: string) => void; + detachAllOversize: () => void; currentModelCtx: number; summarizeError: string | null; setSummarizeError: (v: string | null) => void; } export const ChatInputOverlays: React.FC = ({ - c, lightboxSrc, setLightboxSrc, oversizeQueue, summarizingPath, summarizeOversize, - detachOversize, currentModelCtx, summarizeError, setSummarizeError, + c, lightboxSrc, setLightboxSrc, oversizeQueue, summarizingPath, summarizingAll, + summarizeOversize, summarizeAllOversize, detachOversize, detachAllOversize, + currentModelCtx, summarizeError, setSummarizeError, }) => { // Auto-dismiss the error after 6s, matching the Snackbar behavior we replaced. React.useEffect(() => { @@ -89,60 +93,75 @@ export const ChatInputOverlays: React.FC = ({ - {/* Scoped to the chat-input container (parent Box is the panel). position:absolute + bottom-anchored so it sits ABOVE the input instead of overlapping it, and shrinks to fit the chat panel width regardless of how the canvas is laid out. */} - {oversizeQueue.length > 0 && ( - - {oversizeQueue[0] ? ( + {/* 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 + ? <>{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(oversizeQueue[0].path) : summarizeAllOversize()); + const onRemove = () => (n === 1 ? detachOversize(oversizeQueue[0].path) : detachAllOversize()); + return ( + - This file is too big to send. Shrink it down to a summary, or remove it? + {headline} - ) : null} - - oversizeQueue[0] && summarizeOversize(oversizeQueue[0].path)} - 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 }, - }} - > - {summarizingPath === oversizeQueue[0]?.path ? : 'Shrink it'} - - oversizeQueue[0] && detachOversize(oversizeQueue[0].path)} - 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', - '&:hover': { bgcolor: c.bg.secondary, color: c.text.primary }, - }} - > - Remove + + + {shrinking ? : shrinkLabel} + + + {removeLabel} + - - )} + ); + })()} {/* Same panel-scoped approach for the error toast. Auto-dismiss kept via useEffect timer below. */} {summarizeError && ( diff --git a/frontend/src/app/pages/AgentChat/ChatInput/view/ChatInputView.tsx b/frontend/src/app/pages/AgentChat/ChatInput/view/ChatInputView.tsx index e7f6dcc6..7458f729 100644 --- a/frontend/src/app/pages/AgentChat/ChatInput/view/ChatInputView.tsx +++ b/frontend/src/app/pages/AgentChat/ChatInput/view/ChatInputView.tsx @@ -90,8 +90,11 @@ interface Props { lightboxSrc: string | null; oversizeQueue: Array<{ path: string; name: string; tokens: number }>; summarizingPath: string | null; + summarizingAll: boolean; summarizeOversize: (path: string) => void; + summarizeAllOversize: () => void; detachOversize: (path: string) => void; + detachAllOversize: () => void; currentModelCtx: number; summarizeError: string | null; setSummarizeError: (v: string | null) => void; @@ -258,8 +261,11 @@ export const ChatInputView: React.FC = (p) => { setLightboxSrc={p.setLightboxSrc} oversizeQueue={p.oversizeQueue} summarizingPath={p.summarizingPath} + summarizingAll={p.summarizingAll} summarizeOversize={p.summarizeOversize} + summarizeAllOversize={p.summarizeAllOversize} detachOversize={p.detachOversize} + detachAllOversize={p.detachAllOversize} currentModelCtx={p.currentModelCtx} summarizeError={p.summarizeError} setSummarizeError={p.setSummarizeError}