[eric] ui: one popup handles all oversize files at once and the send fires itself after - 5 clicks becomes 1

This commit is contained in:
eric
2026-05-31 22:18:21 -07:00
parent bc1db576a0
commit ec6a923b46
4 changed files with 168 additions and 53 deletions
+16 -3
View File
@@ -91,13 +91,17 @@ const ChatInput = forwardRef<ChatInputHandle, Props>(({ 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<ChatInputHandle, Props>(({ 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<ChatInputHandle, Props>(({ 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<ChatInputHandle, Props>(({ 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}
@@ -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<number | null>(null);
const [oversizeQueue, setOversizeQueue] = useState<Array<{ path: string; name: string; tokens: number }>>([]);
const [summarizingPath, setSummarizingPath] = useState<string | null>(null);
const [summarizingAll, setSummarizingAll] = useState(false);
const [summarizeError, setSummarizeError] = useState<string | null>(null);
const [sendBlock, setSendBlock] = useState<SendBlock>(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<string, string> = { '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,
};
}
@@ -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<Props> = ({
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<Props> = ({
</Box>
</Modal>
{/* 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 && (
<Box
sx={{
position: 'absolute', left: 8, right: 8, bottom: 'calc(100% + 8px)',
display: 'flex', alignItems: 'center', gap: 1.5,
bgcolor: c.bg.surface, border: `1px solid ${c.border.medium}`,
boxShadow: c.shadow.md, borderRadius: '12px',
px: 2, py: 1.25,
whiteSpace: 'normal',
zIndex: 5,
}}
>
{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
? <><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(oversizeQueue[0].path) : summarizeAllOversize());
const onRemove = () => (n === 1 ? detachOversize(oversizeQueue[0].path) : detachAllOversize());
return (
<Box
sx={{
position: 'absolute', left: 8, right: 8, bottom: 'calc(100% + 8px)',
display: 'flex', alignItems: 'center', gap: 1.5,
bgcolor: c.bg.surface, border: `1px solid ${c.border.medium}`,
boxShadow: c.shadow.md, borderRadius: '12px',
px: 2, py: 1.25,
whiteSpace: 'normal',
zIndex: 5,
}}
>
<Box sx={{
color: c.text.primary, fontSize: '0.88rem', lineHeight: 1.45,
flex: '1 1 auto', minWidth: 0,
}}>
This file is too big to send. Shrink it down to a summary, or remove it?
{headline}
</Box>
) : null}
<Box sx={{ display: 'flex', gap: 0.75, flexShrink: 0 }}>
<Box
component="button"
disabled={summarizingPath === oversizeQueue[0]?.path}
onClick={() => 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 ? <ShrinkingLabel /> : 'Shrink it'}
</Box>
<Box
component="button"
onClick={() => 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
<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>
</Box>
</Box>
</Box>
)}
);
})()}
{/* Same panel-scoped approach for the error toast. Auto-dismiss kept via useEffect timer below. */}
{summarizeError && (
@@ -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<Props> = (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}