mirror of
https://github.com/openswarm-ai/openswarm.git
synced 2026-09-30 05:24:50 +02:00
[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:
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user