[aidan] chat: auto-compact inside send, surface as chat activity (#63)

This commit is contained in:
Aidan
2026-06-09 22:29:31 -07:00
committed by ciregenz
parent 0a704d4690
commit 5a26b26c34
3 changed files with 41 additions and 48 deletions
+16 -2
View File
@@ -201,6 +201,7 @@ const AgentChat: React.FC<AgentChatProps> = ({ sessionId: sessionIdProp, onClose
const [showScrollButton, setShowScrollButton] = useState(false);
const [showResumeBubble, setShowResumeBubble] = useState(false);
const [awaitingResponse, setAwaitingResponse] = useState(false);
const [preSendActivityLabel, setPreSendActivityLabel] = useState<string | null>(null);
const [activatingMcp, setActivatingMcp] = useState<string | null>(null);
const [activateError, setActivateError] = useState<string | null>(null);
const [mode, setMode] = useState('agent');
@@ -385,6 +386,18 @@ const AgentChat: React.FC<AgentChatProps> = ({ sessionId: sessionIdProp, onClose
const streamingMessageId = useAppSelector((s) => id ? s.streaming.bySession[id]?.id ?? null : null);
const hasStreaming = !!streamingMessageId;
useEffect(() => {
if (
streamingMessageId ||
session?.turn_label?.label ||
session?.status === 'completed' ||
session?.status === 'error' ||
session?.status === 'stopped'
) {
setPreSendActivityLabel(null);
}
}, [streamingMessageId, session?.turn_label?.label, session?.status]);
useEffect(() => {
if (reconcileTimer.current) {
clearTimeout(reconcileTimer.current);
@@ -1422,10 +1435,10 @@ const AgentChat: React.FC<AgentChatProps> = ({ sessionId: sessionIdProp, onClose
/>
</Box>
)}
{(awaitingResponse || (session.status === 'running' && !streamingMessageId)) && (
{(preSendActivityLabel || awaitingResponse || (session.status === 'running' && !streamingMessageId)) && (
<Box sx={{ overflowAnchor: 'none' }}>
<ThinkingBubble
label={session.turn_label?.label}
label={preSendActivityLabel || session.turn_label?.label}
seedKey={`${session.id}:${session.messages?.length ?? 0}`}
/>
</Box>
@@ -1781,6 +1794,7 @@ const AgentChat: React.FC<AgentChatProps> = ({ sessionId: sessionIdProp, onClose
autoFocus={autoFocus}
thinkingLevel={session?.thinking_level ?? 'auto'}
onThinkingLevelChange={handleThinkingLevelChange}
onActivityLabelChange={setPreSendActivityLabel}
/>
</Box>
</ClickAwayListener>
+22 -15
View File
@@ -41,9 +41,10 @@ interface Props {
queueLength?: number;
thinkingLevel?: 'off' | 'low' | 'medium' | 'high' | 'auto';
onThinkingLevelChange?: (level: 'off' | 'low' | 'medium' | 'high' | 'auto') => void;
onActivityLabelChange?: (label: string | null) => void;
}
const ChatInput = forwardRef<ChatInputHandle, Props>(({ onSend, disabled, mode, onModeChange, model, onModelChange, provider, onProviderChange, isRunning, onStop, autoRunMode, contextEstimate, embedded, autoFocus, sessionId, queueLength = 0, thinkingLevel = 'auto', onThinkingLevelChange }, ref) => {
const ChatInput = forwardRef<ChatInputHandle, Props>(({ onSend, disabled, mode, onModeChange, model, onModelChange, provider, onProviderChange, isRunning, onStop, autoRunMode, contextEstimate, embedded, autoFocus, sessionId, queueLength = 0, thinkingLevel = 'auto', onThinkingLevelChange, onActivityLabelChange }, ref) => {
const c = useClaudeTokens();
const editorRef = useRef<HTMLDivElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
@@ -73,6 +74,9 @@ const ChatInput = forwardRef<ChatInputHandle, Props>(({ onSend, disabled, mode,
);
const { allModelOptions, currentModelCtx, pdfSupported, imageSupported } = useChatInputModel(model);
const compactionInFlightRef = useRef(false);
useEffect(() => () => onActivityLabelChange?.(null), [onActivityLabelChange]);
useEffect(() => {
if (modesArr.length === 0) dispatch(fetchModes());
@@ -144,7 +148,8 @@ const ChatInput = forwardRef<ChatInputHandle, Props>(({ onSend, disabled, mode,
if (!trimmed) return;
const block = computeSendBlock({
trimmed, currentModelCtx,
trimmed,
currentModelCtx,
historyUsed: contextEstimate?.used ?? 0,
contextPaths, sessionFrameworkOverhead,
});
@@ -159,19 +164,21 @@ const ChatInput = forwardRef<ChatInputHandle, Props>(({ onSend, disabled, mode,
return;
}
// kind === 'compacting': history is the overflow source, which we CAN
// shrink. Auto-compact, capture the send intent, flash a status banner.
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; }
// shrink. Auto-compact invisibly, then continue this same send.
if (!sessionId || compactionInFlightRef.current) return;
compactionInFlightRef.current = true;
setSendBlock(null);
onActivityLabelChange?.('Compacting memory');
try {
const tok = (() => { try { return getAuthToken(); } catch { return ''; } })();
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
if (tok) headers['Authorization'] = `Bearer ${tok}`;
const resp = await fetch(`${API_BASE}/agents/sessions/${sessionId}/compact`, { method: 'POST', headers });
if (!resp.ok) throw new Error(`compact failed: ${resp.status}`);
} catch (err) {
console.error('[auto-compact] failed:', err);
}
setSendBlock(block);
setTimeout(() => setSendBlock(null), 2000);
return;
compactionInFlightRef.current = false;
}
// Fits now (e.g. user shortened a too-long message): clear any lingering block banner.
@@ -227,7 +234,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, sessionId, currentModelCtx, contextEstimate, sessionFrameworkOverhead, setSendBlock]);
}, [disabled, images, contextPaths, forcedTools, onSend, elementSelection, ownerId, summarizingPath, summarizingAll, oversizeQueue, pendingSendRef, sessionId, currentModelCtx, contextEstimate, sessionFrameworkOverhead, setSendBlock, onActivityLabelChange]);
const {
picker: editorPicker, setPicker,
@@ -9,46 +9,18 @@ interface Props {
c: ClaudeTokens;
}
/** Two states:
* - 'compacting': auto-compact already fired; pulsing-dot status so the send
* doesn't look frozen while we free up room.
* - 'too_long': this single message can't fit no matter what; a plain, friendly
* block telling the user to shorten it (no jargon, no spinner). */
/** Only hard-blocks render here. History compaction is shown as normal chat activity. */
export const SendBlockBanner: React.FC<Props> = ({ sendBlock, c }) => {
if (sendBlock.kind === 'too_long') {
return (
<Box sx={{
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}`,
}}>
<Typography sx={{ fontSize: '0.88rem', color: c.text.primary, lineHeight: 1.45 }}>
That message is too long to send. Try shortening it or splitting it into a few smaller ones.
</Typography>
</Box>
);
}
if (sendBlock.kind !== 'too_long') return null;
return (
<Box sx={{
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,
}}>
<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
That message is too long to send. Try shortening it or splitting it into a few smaller ones.
</Typography>
</Box>
);