Files
openswarm/frontend/src/app/pages/AgentChat/AgentChat.tsx
T

2307 lines
107 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import React, { useEffect, useRef, useMemo, useState, useCallback } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import Box from '@mui/material/Box';
import Typography from '@mui/material/Typography';
import IconButton from '@mui/material/IconButton';
import Tooltip from '@mui/material/Tooltip';
import TextField from '@mui/material/TextField';
import ClickAwayListener from '@mui/material/ClickAwayListener';
import Fade from '@mui/material/Fade';
import SwapHorizRoundedIcon from '@mui/icons-material/SwapHorizRounded';
import CloseIcon from '@mui/icons-material/Close';
import KeyboardArrowDownIcon from '@mui/icons-material/KeyboardArrowDown';
import KeyboardArrowUpIcon from '@mui/icons-material/KeyboardArrowUp';
import PlayArrowIcon from '@mui/icons-material/PlayArrow';
import EditOutlinedIcon from '@mui/icons-material/EditOutlined';
import DeleteOutlineIcon from '@mui/icons-material/DeleteOutline';
import CheckIcon from '@mui/icons-material/Check';
import DragIndicatorIcon from '@mui/icons-material/DragIndicator';
import RestartAltIcon from '@mui/icons-material/RestartAlt';
import { useAppDispatch, useAppSelector } from '@/shared/hooks';
import { friendlyStatusLabel } from '@/shared/statusLabel';
import { openSettingsModal, dismissMcpSuggestion } from '@/shared/state/settingsSlice';
import { API_BASE, getAuthToken } from '@/shared/config';
import {
sendMessage as sendMessageThunk,
launchAndSendFirstMessage,
generateTitle,
generateGroupMeta,
stopAgent,
handleApproval,
editMessage,
switchBranch,
duplicateSession,
setActiveSession,
updateSessionModel,
clearContextOverflow,
updateSessionMode,
updateSessionThinkingLevel,
updateThinkingLevel,
fetchSession,
AgentMessage,
clearSessionMessages,
clearMcpSuggestions,
} from '@/shared/state/agentsSlice';
import { displayChatTitle, isLegacyAutoName } from '@/shared/state/sessionDisplay';
import { Typewriter } from '@/app/components/feedback/Animated';
import { store } from '@/shared/state/store';
import { fetchModes } from '@/shared/state/modesSlice';
import { createSessionWs, acquireSessionWs, releaseSessionWs } from '@/shared/ws/WebSocketManager';
import StreamingBubble from './bubbles/StreamingBubble';
import WelcomeQuickReplies from './WelcomeQuickReplies';
import { useWelcomeGreeting } from './useWelcomeGreeting';
import { THINKING_LABELS } from './thinkingLabels';
import MessageBubble from './bubbles/MessageBubble';
import { estimateRenderedTextHeight, RECHECK_VISIBILITY_EVENT } from './bubbles/markdownMeasure';
import CompactionMarker from './bubbles/CompactionMarker';
import MessageActionBar from './shell/MessageActionBar';
import ToolCallBubble, { ToolPair } from './tool-bubbles/ToolCallBubble';
import ToolGroupBubble, { RenderItem, ToolGroup, isToolGroup, isToolPair } from './tool-bubbles/ToolGroupBubble';
import ApprovalBar, { BatchApprovalBar } from './shell/ApprovalBar';
import ForceStopAgentBar from './ForceStopAgentBar';
import ChatInput, { ChatInputHandle } from './ChatInput';
import ContextDrawer from './shell/ContextDrawer';
import { ErrorSlime } from '@/app/components/feedback/ErrorSlime';
import { ContextPath } from '@/app/components/editor/DirectoryBrowser';
import { setGlowingBrowserCards, fadeGlowingBrowserCards, clearGlowingBrowserCards, removeCard } from '@/shared/state/dashboardLayoutSlice';
import { setCardSidecar, commitDraft, updateWorkflowCard } from '@/shared/state/workflowsSlice';
import { useClaudeTokens } from '@/shared/styles/ThemeContext';
import { parseMcpToolName, getMcpInputSummary } from '@/shared/mcpToolMeta';
const CONTEXT_WINDOWS: Record<string, number> = {
'opus-4-8': 1_000_000,
'opus-4-7': 1_000_000,
opus: 1_000_000,
sonnet: 1_000_000,
haiku: 200_000,
};
// Only a fallback for never-rendered items; real heights are measured once on
// screen.
const RENDER_ITEM_ESTIMATED_HEIGHT = 140;
// Conservative estimate for an unmeasured tool row: tool groups/pairs render
// collapsed (~40-50px) far more often than expanded. Leaning low keeps scrollHeight
// (and the scrollbar thumb) from jumping when a tool row measures shorter.
const COLLAPSED_TOOL_ROW_HEIGHT = 44;
// How many screens of real content to keep mounted on EACH side of the viewport.
// Beyond it, items unmount and are replaced by a measured-height spacer, so
// render/memory stays bounded no matter how long the transcript is.
const WINDOW_BUFFER_SCREENS_PER_SIDE = 3;
// Below this item count the transcript renders WHOLE, no spacers, no windowing.
// Virtualization only earns its keep on huge chats; on a normal chat the
// spacer-height recompute just fights the scroll position (the "jumps up and
// down" glitch), so we skip it entirely until a chat is genuinely long.
const WINDOW_MIN_ITEMS = 60;
// Floor on the mounted item count so a single very tall item can't strand us with
// an effectively empty window.
const MIN_WINDOW_BUFFER_ITEMS = 6;
// Bootstrap count for the initial bottom-anchored slice (on open and on
// scroll-to-bottom): enough rows to cover the viewport + buffer using the same
// row-height estimate the solver uses, floored. It's only a seed — the pixel
// solver (computeDesiredWindow) refines the window to exact from measured heights
// on the next frame, so this never needs to be precise.
function initialSeedItems(viewportHeight: number): number {
const fillPx = (1 + WINDOW_BUFFER_SCREENS_PER_SIDE) * Math.max(1, viewportHeight);
return Math.max(MIN_WINDOW_BUFFER_ITEMS, Math.ceil(fillPx / RENDER_ITEM_ESTIMATED_HEIGHT));
}
// Pure window solver: given the current scroll position and a per-index height
// accessor (measured where known, estimated otherwise), return the [start, end)
// slice of render items that should be mounted. The buffer is measured in PIXELS
// (N screens of real content on each side of the viewport), not item count, so a
// few very tall messages can't blow the mounted set up to the whole transcript.
// A huge viewport naturally yields start=0/end=total (mount all).
function computeDesiredWindow(
scrollTop: number,
clientHeight: number,
total: number,
heightOf: (index: number) => number,
bufferPx: number,
): { start: number; end: number } {
if (total <= 0) return { start: 0, end: 0 };
const keepTop = scrollTop - bufferPx;
const keepBottom = scrollTop + clientHeight + bufferPx;
let offset = 0;
let start = -1;
let end = total;
for (let i = 0; i < total; i++) {
const h = heightOf(i);
const itemTop = offset;
const itemBottom = offset + h;
if (start === -1 && itemBottom > keepTop) start = i;
if (itemTop < keepBottom) {
end = i + 1;
} else {
// Everything past here starts below the keep band.
break;
}
offset += h;
}
if (start === -1) start = Math.max(0, total - 1);
end = Math.min(total, Math.max(end, start + 1));
// Always keep at least a small floor of items mounted around the viewport so a
// single under-measured item can't strand us with an empty window.
if (end - start < MIN_WINDOW_BUFFER_ITEMS) {
start = Math.max(0, Math.min(start, end - MIN_WINDOW_BUFFER_ITEMS));
}
return { start: Math.max(0, start), end };
}
function stringifyContent(content: any): string {
if (content == null) return '';
if (typeof content === 'string') return content;
return JSON.stringify(content);
}
// Content-aware height estimate for a render item that has never been measured.
// Tool rows and tiny system/thinking rows keep the flat fallback; message bubbles
// scale with their FULL text length (messages render in full once on-screen, so
// the estimate matches both the rendered bubble and MessageBubble's placeholder
// fallback).
function estimateItemHeight(item: RenderItem, viewportWidth: number): number {
if (isToolGroup(item) || isToolPair(item)) return COLLAPSED_TOOL_ROW_HEIGHT;
const msg = item as AgentMessage;
if (msg.role === 'thinking' || msg.role === 'system') return 60;
return estimateRenderedTextHeight(stringifyContent(msg.content), viewportWidth);
}
const thinkingShimmerKeyframes = `
@keyframes thinking-shimmer {
0% { background-position: 200% 0; }
100% { background-position: -200% 0; }
}
`;
// Pick a label deterministically per session-turn so the pill has variety
// without flickering between renders. Shared list with MessageBubble.
function streamingLabelFor(seedKey: string | undefined): string {
if (!seedKey) return THINKING_LABELS[0].live;
let h = 0;
for (let i = 0; i < seedKey.length; i++) {
h = ((h << 5) - h + seedKey.charCodeAt(i)) | 0;
}
return THINKING_LABELS[Math.abs(h) % THINKING_LABELS.length].live;
}
const ThinkingBubble: React.FC<{ label?: string | null; seedKey?: string }> = ({ label, seedKey }) => {
const c = useClaudeTokens();
const shimmerBase = c.text.tertiary;
const shimmerHighlight = c.text.primary;
// Aux-LLM label wins; otherwise pick a quirky verb keyed off seedKey
// so different sessions / turns show different verbs without flicker.
const display = label ? `${label}…` : `${streamingLabelFor(seedKey)}…`;
return (
<Box sx={{ display: 'flex', justifyContent: 'flex-start', my: 0.75 }}>
<style>{thinkingShimmerKeyframes}</style>
<Box
sx={{
bgcolor: c.bg.surface,
border: `1px solid ${c.border.subtle}`,
borderRadius: '16px 16px 16px 4px',
px: 2,
py: 1.5,
boxShadow: c.shadow.sm,
display: 'flex',
alignItems: 'center',
minHeight: 36,
}}
>
<Box
component="span"
sx={{
fontSize: '0.85rem',
fontWeight: 500,
background: `linear-gradient(90deg, ${shimmerBase} 0%, ${shimmerBase} 40%, ${shimmerHighlight} 50%, ${shimmerBase} 60%, ${shimmerBase} 100%)`,
backgroundSize: '200% 100%',
WebkitBackgroundClip: 'text',
backgroundClip: 'text',
WebkitTextFillColor: 'transparent',
color: 'transparent',
animation: 'thinking-shimmer 2s linear infinite',
transition: 'opacity 0.25s',
}}
>
{display}
</Box>
</Box>
</Box>
);
};
interface QueuedMessage {
prompt: string;
images?: Array<{ data: string; media_type: string }>;
contextPaths?: Array<{ path: string; type: 'file' | 'directory' }>;
forcedTools?: string[];
attachedSkills?: Array<{ id: string; name: string; content: string }>;
selectedBrowserIds?: string[];
selectedAppIds?: string[];
}
interface AgentChatProps {
sessionId?: string;
onClose?: () => void;
embedded?: boolean;
autoFocus?: boolean;
isGlowing?: boolean;
onDismissGlow?: () => void;
initialContextPaths?: ContextPath[];
onBranch?: (newSessionId: string) => void;
// Set when this chat is the workflow build/edit agent: the out-of-tokens card
// then warns that switching models here also changes the workflow's run model.
workflowEditId?: string;
}
const AgentChat: React.FC<AgentChatProps> = ({ sessionId: sessionIdProp, onClose, embedded, autoFocus, initialContextPaths, onBranch, workflowEditId }) => {
const c = useClaudeTokens();
const STATUS_STYLES: Record<string, { color: string; bg: string }> = {
running: { color: c.status.success, bg: c.status.successBg },
waiting_approval: { color: c.status.warning, bg: c.status.warningBg },
completed: { color: c.text.tertiary, bg: c.bg.secondary },
error: { color: c.status.error, bg: c.status.errorBg },
stopped: { color: c.text.tertiary, bg: c.bg.secondary },
};
const { id: routeId } = useParams<{ id: string }>();
const id = sessionIdProp || routeId;
// A card linked as a workflow sidecar (Test Agent, or a watched run) swaps
// its composer for a Force Stop button: continuing the chat is meaningless,
// but killing the run is the common need. Once a Test Agent finishes, the
// button flips to a green "close" (see workflow_test_state + ForceStopAgentBar).
const linkedWorkflowId = useAppSelector((s) => {
const found = Object.values(s.workflows.openCards).find(
(cd) => cd.sidecarSessionId === id && (cd.sidecarKind === 'testing' || cd.sidecarKind === 'watching'),
);
return found?.workflowId ?? null;
});
const isStoppableSidecar = !!linkedWorkflowId;
// A live workflow run being watched owns pause/resume from its workflow
// card, so the chat's own "Resume Agent Response" bubble is redundant and
// would go stale against the card's Resume. Suppress it for any workflow-run
// sidecar, not just the fragile exact "watching" value. Test-run sidecars
// keep their chat-level resume behavior.
const isWorkflowRunSidecar = useAppSelector((s) => {
if (!id) return false;
for (const cd of Object.values(s.workflows.openCards)) {
if (cd.sidecarSessionId !== id || cd.sidecarKind === 'testing') continue;
if (cd.runId) {
const run = (s.workflows.runs[cd.workflowId] || []).find((r) => r.id === cd.runId);
if (!run || run.session_id === id) return true;
}
if (cd.sidecarKind === 'watching' || cd.sidecarKind === 'viewing-completed' || cd.sidecarKind === 'viewing-error') return true;
}
return Object.values(s.workflows.runs).some((runs) =>
runs.some((r) => r.session_id === id && r.status === 'running'),
);
});
const testState = useAppSelector((s) => (id ? s.agents.sessions[id]?.workflow_test_state : null) ?? null);
const navigate = useNavigate();
const dispatch = useAppDispatch();
const session = useAppSelector((state) => (id ? state.agents.sessions[id] : undefined));
const modesMap = useAppSelector((state) => state.modes.items);
const modelsByProvider = useAppSelector((state) => state.models.byProvider);
const connectionMode = useAppSelector((state) => state.settings.data.connection_mode);
// Stored value → curated picker label, with a tidy fallback for unknowns.
const resolveModelLabel = useCallback((value: string | null | undefined): string => {
if (!value) return '';
for (const models of Object.values(modelsByProvider)) {
for (const m of models as any[]) {
if (m.value === value) return m.label;
}
}
let s = String(value);
if (s.startsWith('or:')) s = s.slice(3);
if (s.includes('/')) s = s.split('/').pop() || s;
return s;
}, [modelsByProvider]);
// Used by the "too many connected apps for Haiku" warning rendered above
// ChatInput. Each connected MCP adds a meaningful chunk of tool-schema
// tokens to every request; Haiku 4.5's 200K window can't hold 5+ of them.
const toolItems = useAppSelector((state) => state.tools.items);
const scrollContainerRef = useRef<HTMLDivElement>(null);
const lastVisibleItemRef = useRef<HTMLDivElement>(null);
const chatInputRef = useRef<ChatInputHandle>(null);
const isAtBottomRef = useRef(true);
const pendingInitialBottomScrollRef = useRef(false);
const initialBottomScrollSettledRef = useRef(false);
const renderItemsLengthRef = useRef(0);
const renderItemsRef = useRef<RenderItem[]>([]);
const itemHeightsRef = useRef<Map<string, number>>(new Map());
const estimateCacheRef = useRef<Map<string, number>>(new Map());
const viewportWidthRef = useRef(0);
const windowStartRef = useRef(0);
const windowEndRef = useRef(0);
const windowScrollRafRef = useRef<number | null>(null);
const [viewportHeight, setViewportHeight] = useState(0);
const [viewportWidth, setViewportWidth] = useState(0);
const [scrollRoot, setScrollRoot] = useState<HTMLDivElement | null>(null);
const [windowStart, setWindowStart] = useState(0);
const [windowEnd, setWindowEnd] = useState(0);
const [heightVersion, setHeightVersion] = useState(0);
const [showScrollButton, setShowScrollButton] = useState(false);
const [showResumeBubble, setShowResumeBubble] = useState(false);
useEffect(() => {
if (isWorkflowRunSidecar) setShowResumeBubble(false);
}, [isWorkflowRunSidecar]);
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);
// Holds the last non-empty suggestions so the docked banner's exit fade renders
// them instead of going blank the instant the array is cleared.
const mcpSnapshotRef = useRef<Array<{ id: string; title: string; description: string; reason?: string }>>([]);
const [mode, setMode] = useState('agent');
const [model, setModel] = useState('sonnet');
// Workflow build chat only: brief "this model now runs the workflow" notice
// when the user switches models, so the run-model change isn't silent.
const [workflowModelNotice, setWorkflowModelNotice] = useState<string | null>(null);
const workflowModelNoticeTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const wsRef = useRef<ReturnType<typeof createSessionWs> | null>(null);
// Current status for the WS-cleanup closure (effect deps can't include it).
const statusRef = useRef<string | undefined>(undefined);
const initialContextApplied = useRef(false);
const messageQueueRef = useRef<QueuedMessage[]>([]);
const [queueLength, setQueueLength] = useState(0);
const [queueExpanded, setQueueExpanded] = useState(false);
const [editingQueueIdx, setEditingQueueIdx] = useState<number | null>(null);
const [editingQueueText, setEditingQueueText] = useState('');
const [dragIdx, setDragIdx] = useState<number | null>(null);
const [dropTargetIdx, setDropTargetIdx] = useState<number | null>(null);
const isDraft = session?.status === 'draft';
const { greetingDone: welcomeGreetingDone } = useWelcomeGreeting(session, isDraft);
useEffect(() => {
if (!id || isDraft) return;
let cancelled = false;
let ws: ReturnType<typeof createSessionWs> | null = null;
// Order matters: hydrate the persisted message list from REST FIRST,
// THEN connect the WS. The WS resume protocol replays buffered
// events starting at last_seq=0, which includes every stream_*
// event for messages that finished before the disconnect. The
// replay-skip guard in WebSocketManager._messageAlreadyComplete
// checks `session.messages` to decide whether to drop deltas , so
// if we connect first, the slice is empty when the replay arrives,
// the guard returns false, and the user sees the chat type itself
// out again. Awaiting fetchSession before connect makes the slice
// authoritative before any replay event lands.
(async () => {
// The await exists so the slice isn't EMPTY at replay time. A warm store
// (remount after a hop) already satisfies that, so connect immediately and
// let the fetch reconcile in the background; awaiting serialized a slow
// round trip in front of the live stream on every reopen.
const warm = !!store.getState().agents.sessions[id]?.messages?.length;
if (warm) {
dispatch(fetchSession(id));
} else {
try {
await dispatch(fetchSession(id));
} catch {
// Even if the REST hydrate fails, still connect , the WS resume
// protocol can hydrate from buffered events as a fallback.
}
}
if (cancelled) return;
// acquireSessionWs reuses a still-open socket kept alive from the last hop,
// so an active agent's stream resumes with no reconnect handshake. connect()
// is a no-op when the reused socket is already open.
ws = acquireSessionWs(id);
ws.connect();
wsRef.current = ws;
})();
return () => {
cancelled = true;
if (ws) {
const st = statusRef.current;
const active = st === 'running' || st === 'waiting_approval';
releaseSessionWs(id, ws, active);
}
wsRef.current = null;
};
}, [id, isDraft, dispatch]);
useEffect(() => {
if (initialContextApplied.current || !initialContextPaths?.length) return;
const timer = setTimeout(() => {
chatInputRef.current?.setContent('', initialContextPaths);
initialContextApplied.current = true;
}, 50);
return () => clearTimeout(timer);
}, [initialContextPaths]);
useEffect(() => {
if (session) setMode(session.mode);
}, [session?.mode]);
useEffect(() => {
if (session) setModel(session.model);
}, [session?.model]);
useEffect(() => {
if (Object.keys(modesMap).length === 0) dispatch(fetchModes());
}, [dispatch, modesMap]);
const dispatchMessage = useCallback((msg: QueuedMessage) => {
if (!id) return;
setShowResumeBubble(false);
setAwaitingResponse(true);
if (isDraft) {
const config: Record<string, any> = { model, mode };
if (session?.system_prompt) config.system_prompt = session.system_prompt;
if (session?.target_directory) config.target_directory = session.target_directory;
// Carry the draft's dashboard so the launched session stays ON this dashboard; without it the
// session lands dashboard_id=null, drops out of the reconcile filter, and its card vanishes
// the instant you send (looked like "the chat quit when I clicked an option").
if (session?.dashboard_id) config.dashboard_id = session.dashboard_id;
dispatch(
launchAndSendFirstMessage({ draftId: id, config, prompt: msg.prompt, mode, model, images: msg.images, contextPaths: msg.contextPaths, forcedTools: msg.forcedTools, attachedSkills: msg.attachedSkills, selectedBrowserIds: msg.selectedBrowserIds, selectedAppIds: msg.selectedAppIds })
).then((action) => {
if (launchAndSendFirstMessage.fulfilled.match(action)) {
const realId = action.payload.session.id;
dispatch(generateTitle({ sessionId: realId, prompt: msg.prompt }));
if (msg.selectedBrowserIds?.length) {
dispatch(setGlowingBrowserCards({ browserIds: msg.selectedBrowserIds, sessionId: realId, label: 'Use Browser' }));
}
}
});
} else {
if (msg.selectedBrowserIds?.length) {
dispatch(setGlowingBrowserCards({ browserIds: msg.selectedBrowserIds, sessionId: id, label: 'Use Browser' }));
}
dispatch(sendMessageThunk({ sessionId: id, prompt: msg.prompt, mode, model, images: msg.images, contextPaths: msg.contextPaths, forcedTools: msg.forcedTools, attachedSkills: msg.attachedSkills, selectedBrowserIds: msg.selectedBrowserIds, selectedAppIds: msg.selectedAppIds }))
.then((action) => {
if (sendMessageThunk.rejected.match(action)) {
setAwaitingResponse(false);
}
});
}
}, [id, isDraft, mode, model, session?.system_prompt, session?.target_directory, session?.dashboard_id, dispatch]);
statusRef.current = session?.status;
const agentBusy = awaitingResponse || (!isDraft && (session?.status === 'running' || session?.status === 'waiting_approval'));
const prevStatusRef = useRef(session?.status);
useEffect(() => {
const prev = prevStatusRef.current;
const curr = session?.status;
prevStatusRef.current = curr;
let didDispatchQueued = false;
const wasActive = prev === 'running' || prev === 'waiting_approval';
const isTerminal = curr === 'completed' || curr === 'stopped' || curr === 'error';
if (wasActive && isTerminal) {
if (id) {
dispatch(fadeGlowingBrowserCards(id));
setTimeout(() => dispatch(clearGlowingBrowserCards(id)), 2800);
}
const nextQueued = messageQueueRef.current.shift();
if (nextQueued) {
setQueueLength(messageQueueRef.current.length);
dispatchMessage(nextQueued);
didDispatchQueued = true;
} else {
if (curr === 'stopped') {
setShowResumeBubble(!isWorkflowRunSidecar);
}
}
const currentMode = modesMap[mode];
if (currentMode?.default_next_mode && modesMap[currentMode.default_next_mode]) {
setMode(currentMode.default_next_mode);
if (id && !isDraft) {
dispatch(updateSessionMode({ sessionId: id, mode: currentMode.default_next_mode as any }));
}
}
}
if (curr === 'running') {
setShowResumeBubble(false);
}
if (curr !== 'draft' && !didDispatchQueued) {
setAwaitingResponse(false);
}
}, [session?.status, mode, modesMap, id, isDraft, dispatch, dispatchMessage, isWorkflowRunSidecar]);
// Idle reconcile: if the session has been 'running' for 5s with no
// WebSocket activity (no new messages, no streaming updates), do a
// single GET to fetch the real status from the backend. Catches the
// case where the completion WebSocket event was dropped (network blip,
// sleep/wake, SDK subprocess dying). Resets on every activity signal
// so it never fires during normal streaming.
const reconcileTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const messageCount = session?.messages?.length ?? 0;
// Subscribe only to the streaming MESSAGE ID (stable across the 30Hz
// delta updates), never to the content. The actual streaming text
// renders inside the leaf <StreamingBubble> below, which subscribes to
// the content itself. This keeps AgentChat's render and useEffects
// dormant during streaming; only the bubble updates per delta.
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);
reconcileTimer.current = null;
}
if (!id || session?.status !== 'running') return;
reconcileTimer.current = setTimeout(() => {
reconcileTimer.current = null;
dispatch(fetchSession(id));
}, 5000);
return () => {
if (reconcileTimer.current) {
clearTimeout(reconcileTimer.current);
reconcileTimer.current = null;
}
};
}, [id, session?.status, messageCount, hasStreaming, dispatch]);
const SCROLL_THRESHOLD = 50;
// Reserved pixel height for a render item: the measured height once we have one,
// otherwise a content-aware estimate (cached per id). The spacer math and the
// window solver both go through this so unmounted spacers, freshly-mounted
// placeholders, and the real rendered bubble all reserve the same space.
const reservedHeightForItem = useCallback((item: RenderItem | undefined): number => {
if (!item) return RENDER_ITEM_ESTIMATED_HEIGHT;
const measured = itemHeightsRef.current.get(item.id);
if (measured != null) return measured;
const cached = estimateCacheRef.current.get(item.id);
if (cached != null) return cached;
const est = estimateItemHeight(item, viewportWidthRef.current);
estimateCacheRef.current.set(item.id, est);
return est;
}, []);
// Measured-or-estimated pixel height of render item at `index`, for the window
// solver (reads the renderItems ref so it is valid inside rAF callbacks).
const heightOf = useCallback((index: number): number => {
return reservedHeightForItem(renderItemsRef.current[index]);
}, [reservedHeightForItem]);
// Solve the mounted window from the live scroll position and push it to state
// when it changes. Scroll position itself is preserved by the container's
// overflow-anchor plus the measured-height spacers, so we never touch
// scrollTop here. Following (pinned to bottom) always keeps the newest item.
const applyWindowFromScroll = useCallback(() => {
const el = scrollContainerRef.current;
if (!el) return;
if (!initialBottomScrollSettledRef.current) return;
const total = renderItemsLengthRef.current;
// Below the windowing threshold the whole transcript is mounted; recomputing
// a window here would only churn the spacers and shift scroll. Leave it alone.
if (total < WINDOW_MIN_ITEMS) return;
const clientHeight = Math.max(1, el.clientHeight);
const tightPx = WINDOW_BUFFER_SCREENS_PER_SIDE * clientHeight;
// Mount with the tight buffer, but keep already-mounted items until
// they drift a full extra screen past it. Without this, an item sitting
// right on the buffer edge flip-flops mounted/unmounted forever: mounting it
// shifts content above the viewport, overflow-anchor nudges scrollTop a few px,
// that re-runs the solver, which now excludes it, and round it goes.
const loosePx = tightPx + clientHeight;
const tight = computeDesiredWindow(el.scrollTop, clientHeight, total, heightOf, tightPx);
const loose = computeDesiredWindow(el.scrollTop, clientHeight, total, heightOf, loosePx);
const curStart = windowStartRef.current;
const curEnd = windowEndRef.current;
// Must-mount the tight band; keep current edges only while still inside loose.
let start = Math.max(loose.start, Math.min(curStart, tight.start));
let end = Math.min(loose.end, Math.max(curEnd, tight.end));
if (isAtBottomRef.current) end = total;
start = Math.max(0, Math.min(start, Math.max(0, end - 1)));
if (start === curStart && end === curEnd) return;
windowStartRef.current = start;
windowEndRef.current = end;
setWindowStart(start);
setWindowEnd(end);
}, [heightOf]);
const scheduleWindowRecompute = useCallback(() => {
if (windowScrollRafRef.current != null) return;
windowScrollRafRef.current = requestAnimationFrame(() => {
windowScrollRafRef.current = null;
applyWindowFromScroll();
});
}, [applyWindowFromScroll]);
useEffect(() => {
const el = scrollContainerRef.current;
if (!el) return;
setScrollRoot(el);
const updateViewport = () => {
setViewportHeight(el.clientHeight);
setViewportWidth(el.clientWidth);
// Width drives the char-per-line estimate; drop cached estimates so they
// recompute at the new width (measured heights are unaffected and kept).
estimateCacheRef.current.clear();
// Resize changes the budgets and how many items fit; re-solve the window
// off the current scroll position WITHOUT resetting it (only session /
// branch changes reset). overflow-anchor holds the visible content.
scheduleWindowRecompute();
};
updateViewport();
const observer = new ResizeObserver(updateViewport);
observer.observe(el);
return () => {
observer.disconnect();
if (windowScrollRafRef.current != null) {
cancelAnimationFrame(windowScrollRafRef.current);
windowScrollRafRef.current = null;
}
setScrollRoot(null);
};
}, [id, session?.id, scheduleWindowRecompute]);
React.useLayoutEffect(() => {
const seed = initialSeedItems(viewportHeight);
const total = renderItemsLengthRef.current;
const end = total;
const start = Math.max(0, end - seed);
windowStartRef.current = start;
windowEndRef.current = end;
setWindowStart(start);
setWindowEnd(end);
itemHeightsRef.current.clear();
estimateCacheRef.current.clear();
if (initialPinRafRef.current != null) {
cancelAnimationFrame(initialPinRafRef.current);
initialPinRafRef.current = null;
}
pendingInitialBottomScrollRef.current = true;
initialBottomScrollSettledRef.current = false;
isAtBottomRef.current = true;
setShowScrollButton(false);
}, [id, session?.active_branch_id]);
const handleScroll = useCallback(() => {
const el = scrollContainerRef.current;
if (!el) return;
// Measure against the real content bottom, not the locked-height pad below it.
const atBottom = el.scrollHeight - el.scrollTop - el.clientHeight < SCROLL_THRESHOLD;
isAtBottomRef.current = atBottom;
setShowScrollButton(!atBottom);
// Slide the mounted window to follow the viewport (loads newer/older items
// and unloads ones that drifted past the buffer on either side).
scheduleWindowRecompute();
}, [scheduleWindowRecompute]);
// Prevent scroll from leaking into the dashboard canvas when at boundaries
useEffect(() => {
const el = scrollContainerRef.current;
if (!el) return;
const onWheel = (e: WheelEvent) => {
// Pinch-to-zoom (ctrl/meta + wheel) must reach the canvas viewport so
// the dashboard zooms when the cursor is over an agent's chat panel.
// Without this early-out the unconditional stopPropagation below kills
// ctrl+wheel and the canvas listener never fires.
if (e.ctrlKey || e.metaKey) return;
// Horizontal-dominant gestures must also reach the canvas so a sideways
// swipe pans the dashboard (chat has no horizontal scroll to absorb).
if (Math.abs(e.deltaX) > Math.abs(e.deltaY)) return;
const atTop = el.scrollTop <= 0;
const atBottom = el.scrollTop + el.clientHeight >= el.scrollHeight - 1;
const scrollingDown = e.deltaY > 0;
const scrollingUp = e.deltaY < 0;
if ((scrollingUp && atTop) || (scrollingDown && atBottom)) {
e.preventDefault();
}
e.stopPropagation();
};
el.addEventListener('wheel', onWheel, { passive: false });
return () => el.removeEventListener('wheel', onWheel);
}, []);
const scrollToBottomRafRef = useRef<number | null>(null);
const scrollToBottom = useCallback(() => {
const el = scrollContainerRef.current;
if (!el) return;
isAtBottomRef.current = true;
setShowScrollButton(false);
// When scrolled far up the newest items are unmounted behind the bottom
// spacer (estimated height). Jump the window to the bottom slice so they
// actually mount, then pin across several frames: a single scrollTop=scrollHeight
// lands short because the spacer collapses and the freshly-mounted items
// replace their estimates with real measured heights, changing scrollHeight.
const total = renderItemsLengthRef.current;
const start = Math.max(0, total - initialSeedItems(el.clientHeight));
windowStartRef.current = start;
windowEndRef.current = total;
setWindowStart(start);
setWindowEnd(total);
if (scrollToBottomRafRef.current != null) cancelAnimationFrame(scrollToBottomRafRef.current);
let frame = 0;
const FRAMES = 16; // ~260ms, enough for the window + oversized blocks to settle
const pin = () => {
const c = scrollContainerRef.current;
if (!c) { scrollToBottomRafRef.current = null; return; }
c.scrollTop = c.scrollHeight;
lastScrollHeightRef.current = c.scrollHeight;
isAtBottomRef.current = true;
if (++frame < FRAMES) {
scrollToBottomRafRef.current = requestAnimationFrame(pin);
} else {
scrollToBottomRafRef.current = null;
// Jump has settled: re-evaluate oversized message / block visibility
// synchronously so nothing now in view is stuck as a placeholder.
c.dispatchEvent(new CustomEvent(RECHECK_VISIBILITY_EVENT));
}
};
pin();
}, []);
const scrollRafRef = useRef<number | null>(null);
const pinRafRef = useRef<number | null>(null);
const initialPinRafRef = useRef<number | null>(null);
const lastScrollHeightRef = useRef<number>(0);
// Shared scroll-stick routine. Used both by the structural-events
// useEffect below (new message lands / stream starts/ends) and by
// StreamingBubble's onStreamGrew callback (per-delta growth). RAF +
// height-grew gate ensures we only set scrollTop when needed.
const stickToBottomIfNeeded = useCallback(() => {
if (!isAtBottomRef.current) return;
if (scrollRafRef.current != null) return;
scrollRafRef.current = requestAnimationFrame(() => {
scrollRafRef.current = null;
if (!isAtBottomRef.current) return;
const el = scrollContainerRef.current;
if (!el) return;
const newHeight = el.scrollHeight;
if (newHeight === lastScrollHeightRef.current) return;
lastScrollHeightRef.current = newHeight;
el.scrollTop = newHeight;
});
}, []);
useEffect(() => {
stickToBottomIfNeeded();
// Structural triggers only: a new message lands or a stream
// starts/ends. Streaming content updates trigger this via
// <StreamingBubble onStreamGrew={stickToBottomIfNeeded} /> instead
// so AgentChat stays dormant during the 30Hz delta storm.
}, [session?.messages.length, streamingMessageId, stickToBottomIfNeeded]);
// Stream-end re-stick. When a stream finishes, the live bubble (smooth-revealed
// text) is replaced by the committed bubble rendering FULL markdown with
// contentVisibility placeholders; as those resolve, Chromium's overflow-anchor
// re-anchors to an EARLIER element (the user message), yanking the view up to
// "the top of the user input". A single deferred scroll loses the race because
// that anchor shift fires an onScroll that flips isAtBottomRef false before we
// run. Fix: snapshot the "was following" intent the moment streaming stops
// (captured continuously during the stream, before any completion re-render),
// then pin to bottom across a short multi-frame window that OVERRIDES the
// layout-induced flip. A genuine user scroll-away (wheel/touch) during that
// window aborts the pin, honoring "unless the user scrolls up".
const prevStreamingIdRef = useRef<string | null>(null);
const wasFollowingRef = useRef(true);
const pinAbortRef = useRef(false);
// Keep the follow-intent fresh while streaming so it's accurate at the instant
// the stream ends (handleScroll updates isAtBottomRef on every real scroll).
if (streamingMessageId) wasFollowingRef.current = isAtBottomRef.current;
useEffect(() => {
const prev = prevStreamingIdRef.current;
prevStreamingIdRef.current = streamingMessageId;
if (!(prev && !streamingMessageId)) return;
if (!wasFollowingRef.current) return; // user had scrolled up; leave them be
pinAbortRef.current = false;
const el = scrollContainerRef.current;
if (!el) return;
// Abort the pin only on a deliberate scroll-away gesture, not the
// layout-induced onScroll the commit itself triggers.
const onUserScrollAway = (e: Event) => {
if ((e as WheelEvent).deltaY != null && (e as WheelEvent).deltaY < 0) pinAbortRef.current = true; // wheel up
else if (e.type === 'touchmove') pinAbortRef.current = true;
};
el.addEventListener('wheel', onUserScrollAway, { passive: true });
el.addEventListener('touchmove', onUserScrollAway, { passive: true });
let frame = 0;
const FRAMES = 18; // ~300ms at 60fps, long enough for async highlight/layout
const pin = () => {
if (pinAbortRef.current) { cleanup(); return; }
const c = scrollContainerRef.current;
if (c) { c.scrollTop = c.scrollHeight; lastScrollHeightRef.current = c.scrollHeight; isAtBottomRef.current = true; }
if (++frame < FRAMES) { pinRafRef.current = requestAnimationFrame(pin); }
else cleanup();
};
const cleanup = () => {
el.removeEventListener('wheel', onUserScrollAway);
el.removeEventListener('touchmove', onUserScrollAway);
if (pinRafRef.current != null) { cancelAnimationFrame(pinRafRef.current); pinRafRef.current = null; }
};
pinRafRef.current = requestAnimationFrame(pin);
return cleanup;
}, [streamingMessageId]);
// A tool's live pill is already on screen when it commits, so re-running the
// mount reveal on the committed bubble flashes the exact same row. Remember the
// id that just stopped streaming for a beat and let that one bubble skip its
// entrance, so the hand-off is seamless. 500ms is slack for the commit render
// to land after the stream clears (they don't always arrive on the same frame).
const [justStreamedId, setJustStreamedId] = useState<string | null>(null);
const justStreamPrevRef = useRef<string | null>(null);
useEffect(() => {
const prev = justStreamPrevRef.current;
justStreamPrevRef.current = streamingMessageId;
if (prev && !streamingMessageId) {
setJustStreamedId(prev);
const t = setTimeout(() => setJustStreamedId(null), 500);
return () => clearTimeout(t);
}
}, [streamingMessageId]);
useEffect(() => () => {
if (scrollRafRef.current != null) {
cancelAnimationFrame(scrollRafRef.current);
scrollRafRef.current = null;
}
if (pinRafRef.current != null) {
cancelAnimationFrame(pinRafRef.current);
pinRafRef.current = null;
}
if (initialPinRafRef.current != null) {
cancelAnimationFrame(initialPinRafRef.current);
initialPinRafRef.current = null;
}
if (scrollToBottomRafRef.current != null) {
cancelAnimationFrame(scrollToBottomRafRef.current);
scrollToBottomRafRef.current = null;
}
}, []);
// useCallback so ChatInput's memo equality holds across AgentChat
// re-renders driven by unrelated session state. Captures agentBusy
// through the dependency so a stale "busy" closure doesn't ever route
// a message past the queue.
const handleSend = useCallback(
(
prompt: string,
images?: Array<{ data: string; media_type: string }>,
contextPaths?: Array<{ path: string; type: 'file' | 'directory' }>,
forcedTools?: string[],
attachedSkills?: Array<{ id: string; name: string; content: string }>,
selectedBrowserIds?: string[],
selectedAppIds?: string[],
) => {
if (!id) return;
scrollToBottom();
const msg: QueuedMessage = { prompt, images, contextPaths, forcedTools, attachedSkills, selectedBrowserIds, selectedAppIds };
if (agentBusy) {
messageQueueRef.current.push(msg);
setQueueLength(messageQueueRef.current.length);
return;
}
dispatchMessage(msg);
},
[id, scrollToBottom, agentBusy, dispatchMessage],
);
const handleModeChange = useCallback((newMode: string) => {
setMode(newMode);
if (id && !isDraft) dispatch(updateSessionMode({ sessionId: id, mode: newMode }));
}, [id, isDraft, dispatch]);
const handleModelChange = useCallback((newModel: string) => {
if (workflowEditId && newModel !== model) {
setWorkflowModelNotice(resolveModelLabel(newModel));
if (workflowModelNoticeTimer.current) clearTimeout(workflowModelNoticeTimer.current);
workflowModelNoticeTimer.current = setTimeout(() => setWorkflowModelNotice(null), 5000);
}
setModel(newModel);
if (id && !isDraft) dispatch(updateSessionModel({ sessionId: id, model: newModel }));
}, [id, isDraft, dispatch, workflowEditId, model, resolveModelLabel]);
useEffect(() => () => { if (workflowModelNoticeTimer.current) clearTimeout(workflowModelNoticeTimer.current); }, []);
const handleThinkingLevelChange = useCallback((level: 'off' | 'low' | 'medium' | 'high' | 'auto') => {
if (!id) return;
dispatch(updateSessionThinkingLevel({ sessionId: id, level }));
if (!isDraft) dispatch(updateThinkingLevel({ sessionId: id, level }));
}, [id, isDraft, dispatch]);
const handleApprove = (requestId: string, updatedInput?: Record<string, any>, trustPattern?: boolean) => {
dispatch(handleApproval({ requestId, behavior: 'allow', updatedInput, trustPattern }));
};
const handleDeny = (requestId: string, message?: string) => {
dispatch(handleApproval({ requestId, behavior: 'deny', message }));
};
const handleStop = useCallback(() => {
if (!id) return;
dispatch(stopAgent({ sessionId: id }));
}, [id, dispatch]);
// Finished Test Agent card: drop the tether + remove this card, and either
// commit the workflow draft (Save, same as the edit card's "save now") or
// leave the draft untouched so the user keeps editing.
const onTestContinueEditing = useCallback(() => {
if (linkedWorkflowId) dispatch(setCardSidecar({ workflowId: linkedWorkflowId, sessionId: null, kind: null }));
if (id) dispatch(removeCard(id));
}, [linkedWorkflowId, id, dispatch]);
const onTestSaveWorkflow = useCallback(async () => {
if (linkedWorkflowId) {
try {
await dispatch(commitDraft(linkedWorkflowId)).unwrap();
} catch {
return;
}
dispatch(updateWorkflowCard({ workflowId: linkedWorkflowId, patch: { view: 'saved' } }));
dispatch(setCardSidecar({ workflowId: linkedWorkflowId, sessionId: null, kind: null }));
}
if (id) dispatch(removeCard(id));
}, [linkedWorkflowId, id, dispatch]);
const handleResume = useCallback(() => {
if (!id) return;
setShowResumeBubble(false);
dispatch(sendMessageThunk({
sessionId: id,
prompt: "Continue where you left off. Start you're response EXACTLY with 'Sorry, let me pick up where I left off",
mode,
model,
hidden: true,
}));
}, [id, mode, model, dispatch]);
const [editingMessageId, setEditingMessageId] = useState<string | null>(null);
const handleSaveEdit = useCallback(
(messageId: string, newContent: string) => {
if (!id) return;
dispatch(editMessage({ sessionId: id, messageId, content: newContent }));
setEditingMessageId(null);
},
[id, dispatch]
);
const handleCancelEdit = useCallback(() => {
setEditingMessageId(null);
}, []);
const activeBranchMessages = useMemo(() => {
if (!session) return [];
const branchId = session.active_branch_id || 'main';
const branch = session.branches?.[branchId];
if (!branch || !branch.fork_point_message_id) {
return session.messages.filter((m) => m.branch_id === 'main' || m.branch_id === branchId);
}
const segments: Array<{ branchId: string; upToMessageId?: string }> = [];
let cur = branch;
let curId = branchId;
while (cur && cur.fork_point_message_id) {
segments.unshift({ branchId: curId, upToMessageId: cur.fork_point_message_id });
curId = cur.parent_branch_id || 'main';
cur = session.branches?.[curId];
}
segments.unshift({ branchId: curId });
const result: typeof session.messages = [];
for (let i = 0; i < segments.length; i++) {
const seg = segments[i];
const nextForkMsgId = seg.upToMessageId;
if (nextForkMsgId) {
const forkIdx = session.messages.findIndex((m) => m.id === nextForkMsgId);
const pre = session.messages
.slice(0, forkIdx)
.filter((m) => m.branch_id === seg.branchId);
result.push(...pre);
} else if (i < segments.length - 1) {
const nextFork = segments[i + 1].upToMessageId;
const forkIdx = nextFork
? session.messages.findIndex((m) => m.id === nextFork)
: session.messages.length;
result.push(
...session.messages.slice(0, forkIdx).filter((m) => m.branch_id === seg.branchId)
);
} else {
result.push(...session.messages.filter((m) => m.branch_id === seg.branchId));
}
}
const leafMsgs = session.messages.filter((m) => m.branch_id === branchId);
if (!result.some((m) => m.branch_id === branchId)) {
result.push(...leafMsgs);
}
return result;
}, [session?.messages, session?.active_branch_id, session?.branches]);
const handleRegenerate = useCallback(
(assistantMsg: AgentMessage) => {
if (!id) return;
const idx = activeBranchMessages.findIndex((m) => m.id === assistantMsg.id);
for (let i = idx - 1; i >= 0; i--) {
if (activeBranchMessages[i].role === 'user') {
const userMsg = activeBranchMessages[i];
const content = typeof userMsg.content === 'string' ? userMsg.content : JSON.stringify(userMsg.content);
dispatch(editMessage({ sessionId: id, messageId: userMsg.id, content }));
break;
}
}
},
[id, activeBranchMessages, dispatch]
);
const handleBranchChat = useCallback(async (upToMessageId: string) => {
if (!id) return;
const dashId = session?.dashboard_id;
const action = await dispatch(duplicateSession({ sessionId: id, dashboardId: dashId, upToMessageId }));
if (duplicateSession.fulfilled.match(action)) {
if (onBranch) {
onBranch(action.payload.id);
} else {
dispatch(setActiveSession(action.payload.id));
}
}
}, [id, dispatch, onBranch, session?.dashboard_id]);
const contextEstimate = useMemo(() => {
// Prefer the live API-reported input token count once we have one
// (session.tokens.input includes the full request: messages + system +
// tool defs + cached prefix). That number is authoritative because
// Anthropic counts it against the context window. Before the first
// turn completes, fall back to a char/4 estimate of visible message
// content as a rough pre-send hint.
let limit = 0;
for (const ms of Object.values(modelsByProvider)) {
const hit = ms.find((m) => m.value === model);
if (hit?.context_window) { limit = hit.context_window; break; }
}
if (!limit) limit = (session?.context_window) || CONTEXT_WINDOWS[model] || 200_000;
const liveInput = session?.tokens?.input ?? 0;
if (liveInput > 0) {
return { used: liveInput, limit };
}
let totalChars = 0;
if (session?.system_prompt) totalChars += session.system_prompt.length;
for (const msg of activeBranchMessages) {
totalChars += stringifyContent(msg.content).length;
}
const used = Math.round(totalChars / 4);
return { used, limit };
// Streaming content's contribution to the context estimate is no
// longer included here: we'd have to subscribe to the streaming
// text and re-run this sum on every painted character, defeating
// the whole point of isolating AgentChat from delta updates. The
// header gauge will catch up when stream_end commits the message.
}, [activeBranchMessages, session?.system_prompt, session?.tokens?.input, session?.context_window, streamingMessageId, model, modelsByProvider]);
const sessionRunning = session?.status === 'running' || session?.status === 'waiting_approval';
const renderItems: RenderItem[] = useMemo(() => {
const items: RenderItem[] = [];
let i = 0;
while (i < activeBranchMessages.length) {
const msg = activeBranchMessages[i];
if (msg.role === 'tool_call' || msg.role === 'tool_result') {
const group: typeof activeBranchMessages = [];
while (
i < activeBranchMessages.length &&
(activeBranchMessages[i].role === 'tool_call' ||
activeBranchMessages[i].role === 'tool_result')
) {
group.push(activeBranchMessages[i]);
i++;
}
const calls = group.filter((m) => m.role === 'tool_call');
const results = group.filter((m) => m.role === 'tool_result');
const pairs: ToolPair[] = calls.map((call, idx) => ({
type: 'tool_pair' as const,
id: `pair-${call.id}`,
call,
result: results[idx] || null,
}));
const mcpServers = new Set(
calls.map((m) => {
const tool = typeof m.content === 'object' ? m.content.tool || '' : '';
const match = tool.match(/^mcp__([^_]+(?:-[^_]+)*)__/);
return match ? match[1] : '';
}).filter(Boolean)
);
const allSameMcp = mcpServers.size === 1 && pairs.length > 0;
if (allSameMcp) {
const mcpServer = [...mcpServers][0];
const toolNames = new Set(
calls.map((m) => (typeof m.content === 'object' ? m.content.tool : ''))
);
const label =
toolNames.size === 1 ? calls[0].content?.tool || 'Tool calls' : `${calls.length} tool calls`;
items.push({
type: 'tool_group',
id: `group-${group[0].id}`,
pairs,
label,
callCount: calls.length,
mcpServer,
} satisfies ToolGroup);
} else if (pairs.length <= 2) {
items.push(...pairs);
} else if (pairs.length > 0) {
const toolNames = new Set(
calls.map((m) => (typeof m.content === 'object' ? m.content.tool : ''))
);
const label =
toolNames.size === 1 ? calls[0].content?.tool || 'Tool calls' : `${calls.length} tool calls`;
items.push({
type: 'tool_group',
id: `group-${group[0].id}`,
pairs,
label,
callCount: calls.length,
} satisfies ToolGroup);
}
} else {
if (!msg.hidden) {
items.push(msg);
}
i++;
}
}
return items;
}, [activeBranchMessages]);
React.useLayoutEffect(() => {
const total = renderItems.length;
renderItemsLengthRef.current = total;
renderItemsRef.current = renderItems;
const seed = initialSeedItems(viewportHeight);
let start = windowStartRef.current;
let end = windowEndRef.current;
if (isAtBottomRef.current || end === 0) {
// Following the live tail: keep the newest item mounted and unload the
// oldest beyond a bounded recent slice so memory stays flat as the
// transcript grows. The pixel solver refines this seed on the next scroll.
end = total;
start = Math.max(0, end - seed);
} else {
// Scrolled up: just keep the existing window valid against the new length.
end = Math.min(end, total);
start = Math.min(start, Math.max(0, end - 1));
}
if (start !== windowStartRef.current) { windowStartRef.current = start; setWindowStart(start); }
if (end !== windowEndRef.current) { windowEndRef.current = end; setWindowEnd(end); }
}, [id, renderItems, viewportHeight]);
const total = renderItems.length;
// Small chats render whole (no windowing): forces the full slice so both spacer
// loops sum to 0, which removes the recompute-driven scroll jump entirely.
const windowingActive = total >= WINDOW_MIN_ITEMS;
const safeWindowEnd = !windowingActive ? total : (windowEnd > 0 ? Math.min(windowEnd, total) : total);
const safeWindowStart = !windowingActive ? 0 : Math.min(Math.max(0, windowStart), Math.max(0, safeWindowEnd - 1));
const visibleStartIndex = safeWindowStart;
const visibleRenderItems = useMemo(
() => renderItems.slice(safeWindowStart, safeWindowEnd),
[renderItems, safeWindowStart, safeWindowEnd]
);
const renderedVisibleItems = useMemo(
() => visibleRenderItems.filter((item) => !streamingMessageId || item.id !== streamingMessageId),
[streamingMessageId, visibleRenderItems]
);
// Keep the ref the height estimator reads in sync with the live viewport width.
viewportWidthRef.current = viewportWidth;
// Measure mounted item heights so the spacers that stand in for unmounted
// items keep the scrollbar geometry stable (no jump when unloading above).
React.useLayoutEffect(() => {
const el = scrollContainerRef.current;
if (!el) return;
let changed = false;
el.querySelectorAll<HTMLElement>('[data-window-item-id]').forEach((node) => {
const itemId = node.dataset.windowItemId;
if (!itemId) return;
const h = node.offsetHeight;
if (h <= 0) return;
const prev = itemHeightsRef.current.get(itemId);
if (prev === undefined || Math.abs(prev - h) > 1) {
itemHeightsRef.current.set(itemId, h);
changed = true;
}
});
// Guarded so this converges: once heights stop moving, no more version bumps.
if (changed) setHeightVersion((v) => v + 1);
});
// Spacers reserve the cumulative height of the unmounted items above/below the
// window. heightVersion gates recompute off the ref-held measurements; we index
// the render-scope renderItems directly so id->height stays correct on the
// frame the transcript changes.
const topSpacerHeight = useMemo(() => {
let h = 0;
for (let i = 0; i < safeWindowStart; i++) h += reservedHeightForItem(renderItems[i]);
return h;
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [renderItems, safeWindowStart, heightVersion, reservedHeightForItem]);
const bottomSpacerHeight = useMemo(() => {
let h = 0;
for (let i = safeWindowEnd; i < total; i++) h += reservedHeightForItem(renderItems[i]);
return h;
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [renderItems, safeWindowEnd, total, heightVersion, reservedHeightForItem]);
React.useLayoutEffect(() => {
if (pendingInitialBottomScrollRef.current) {
const el = scrollContainerRef.current;
if (!el || visibleRenderItems.length === 0) return;
let frame = 0;
const FRAMES = 8;
const pin = () => {
const c = scrollContainerRef.current;
if (!c) return;
lastVisibleItemRef.current?.scrollIntoView({ block: 'end' });
c.scrollTop = Math.max(0, Math.min(c.scrollTop, c.scrollHeight - c.clientHeight));
lastScrollHeightRef.current = c.scrollHeight;
isAtBottomRef.current = true;
setShowScrollButton(false);
if (++frame < FRAMES) {
initialPinRafRef.current = requestAnimationFrame(pin);
} else {
initialPinRafRef.current = null;
initialBottomScrollSettledRef.current = true;
// The open slice is sized by item COUNT; now that we're settled and
// measured, trim it down to the pixel-based band so tall messages high
// in the slice unload instead of sitting fully rendered off-screen.
scheduleWindowRecompute();
// Re-evaluate visibility now the open jump has settled, so an oversized
// newest message isn't left stuck as a placeholder.
c.dispatchEvent(new CustomEvent(RECHECK_VISIBILITY_EVENT));
}
};
if (initialPinRafRef.current != null) {
cancelAnimationFrame(initialPinRafRef.current);
initialPinRafRef.current = null;
}
pendingInitialBottomScrollRef.current = false;
pin();
}
}, [id, session?.active_branch_id, renderItems.length, renderedVisibleItems.length, visibleStartIndex]);
const lastAssistantIdsInTurn = useMemo(() => {
const ids = new Set<string>();
let lastAssistantId: string | null = null;
for (const item of renderItems) {
if (!isToolGroup(item) && !isToolPair(item)) {
const msg = item as AgentMessage;
if (msg.role === 'assistant') {
lastAssistantId = msg.id;
} else if (msg.role === 'user') {
if (lastAssistantId) ids.add(lastAssistantId);
lastAssistantId = null;
}
}
}
if (lastAssistantId) ids.add(lastAssistantId);
return ids;
}, [renderItems]);
const groupMetaRequestedRef = useRef<Set<string>>(new Set());
const groupMetaRefinedRef = useRef<Set<string>>(new Set());
useEffect(() => {
if (!id || isDraft) return;
const toolGroups = renderItems.filter(isToolGroup) as ToolGroup[];
const meta = session?.tool_group_meta ?? {};
for (const group of toolGroups) {
const allDone = group.pairs.every((p) => p.result !== null);
if (!groupMetaRequestedRef.current.has(group.id) && !meta[group.id]) {
groupMetaRequestedRef.current.add(group.id);
const toolCalls = group.pairs.map((p) => {
const c = p.call.content;
const tool = typeof c === 'object' ? c.tool || '' : '';
const input = typeof c === 'object' ? c.input : '';
const mcp = parseMcpToolName(tool);
const friendly = mcp.isMcp ? getMcpInputSummary(input, mcp.action, mcp.serverSlug) : '';
const summary = friendly || (typeof input === 'string' ? input.slice(0, 120) : JSON.stringify(input).slice(0, 120));
return { tool, input_summary: summary };
});
dispatch(generateGroupMeta({ sessionId: id, groupId: group.id, toolCalls }));
}
if (allDone && meta[group.id] && !meta[group.id].is_refined && !groupMetaRefinedRef.current.has(group.id)) {
groupMetaRefinedRef.current.add(group.id);
const toolCalls = group.pairs.map((p) => {
const c = p.call.content;
const tool = typeof c === 'object' ? c.tool || '' : '';
const input = typeof c === 'object' ? c.input : '';
const mcp = parseMcpToolName(tool);
const friendly = mcp.isMcp ? getMcpInputSummary(input, mcp.action, mcp.serverSlug) : '';
const summary = friendly || (typeof input === 'string' ? input.slice(0, 120) : JSON.stringify(input).slice(0, 120));
return { tool, input_summary: summary };
});
const resultsSummary = group.pairs
.filter((p) => p.result)
.map((p) => {
const rc = p.result!.content;
const text = typeof rc === 'string' ? rc : typeof rc === 'object' && rc?.text ? rc.text : JSON.stringify(rc);
return text.slice(0, 150);
});
dispatch(generateGroupMeta({ sessionId: id, groupId: group.id, toolCalls, resultsSummary, isRefinement: true }));
}
}
}, [renderItems, id, isDraft, session?.tool_group_meta, dispatch]);
const getSiblingBranches = useCallback(
(messageId: string): string[] => {
if (!session?.branches) return [];
const directForks = Object.values(session.branches)
.filter((b) => b.fork_point_message_id === messageId)
.map((b) => b.id);
if (directForks.length > 0) {
const originalMsg = session.messages.find((m) => m.id === messageId);
const parentBranchId = originalMsg?.branch_id || 'main';
return [parentBranchId, ...directForks];
}
const msg = session.messages.find((m) => m.id === messageId);
if (!msg || msg.role !== 'user') return [];
const msgBranch = session.branches[msg.branch_id];
if (!msgBranch?.fork_point_message_id) return [];
const branchUserMsgs = session.messages.filter(
(m) => m.branch_id === msg.branch_id && m.role === 'user'
);
if (branchUserMsgs.length === 0 || branchUserMsgs[0].id !== messageId) return [];
const forkPointId = msgBranch.fork_point_message_id;
const siblingBranches = Object.values(session.branches)
.filter((b) => b.fork_point_message_id === forkPointId)
.map((b) => b.id);
const parentBranchId = msgBranch.parent_branch_id || 'main';
return [parentBranchId, ...siblingBranches];
},
[session?.branches, session?.messages]
);
if (!session) {
return (
<Box sx={{ display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', height: '100%', gap: 2 }}>
<Typography sx={{ color: c.text.tertiary, fontSize: '1rem' }}>
Session not found
</Typography>
</Box>
);
}
const branchNavLocked = agentBusy || hasStreaming;
const statusStyle = STATUS_STYLES[session.status] || { color: c.text.tertiary, bg: c.bg.secondary };
return (
<Box sx={{ display: 'flex', height: '100%' }}>
<ContextDrawer />
<Box sx={{ display: 'flex', flexDirection: 'column', flex: 1, minWidth: 0, overflow: 'hidden' }}>
{!embedded && (
<Box
sx={{
display: 'flex',
alignItems: 'center',
gap: 1.5,
px: 2,
py: 1.5,
// No seam: the header is just a band of typography inside the chat
// panel; transparent bg + air carry it, no hairline. (An earlier
// bg.surface here read lighter than the body and pulled focus.)
bgcolor: 'transparent',
}}
>
<Box sx={{ flex: 1, minWidth: 0 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Typewriter
value={displayChatTitle(session)}
enabled={!!session.name && !isLegacyAutoName(session.name)}
>
{(t) => <Typography noWrap sx={{ color: c.text.primary, fontWeight: 600 }}>{t}</Typography>}
</Typewriter>
{!isDraft && statusStyle && session.status !== 'completed' && session.status !== 'stopped' && (
// Status speaks only when it needs the user; finished work sits quiet.
<Box sx={{ display: 'flex', alignItems: 'center', flexShrink: 0 }}>
<Typography sx={{ fontSize: '0.75rem', fontWeight: 500, color: c.text.tertiary, whiteSpace: 'nowrap' }}>
{friendlyStatusLabel(session.status)}
</Typography>
</Box>
)}
</Box>
{(!isDraft || session.is_welcome_draft) && (
// Welcome draft shows just the model so the header isn't bare; real runs add branch + cost.
<Box sx={{ display: 'flex', gap: 1.5, mt: 0.25, alignItems: 'center' }}>
<Typography variant="caption" sx={{ color: c.text.tertiary }}>
{resolveModelLabel(session.model)}
</Typography>
{!isDraft && session.branch_name && (
<Typography variant="caption" sx={{ color: c.text.tertiary }}>
{session.branch_name}
</Typography>
)}
{(() => {
if (!(session.cost_usd > 0)) return null;
// The SDK reports a per-call $ figure regardless of how
// the request was routed. For requests that went through
// a subscription path, that figure is misleading , the
// user pays flat-rate. Show "subscription" instead in
// those cases. Show $ only when the call was actually
// metered (Anthropic API key, OpenAI API key, etc.).
//
// Model-id signals (these are short_name values from the
// BUILTIN_MODELS registry):
// - `*-api` → pinned Anthropic API key (METERED)
// - `*-cc` → pinned Claude Pro/Max via 9Router (sub)
// - plain sonnet/opus/haiku + openswarm-pro mode → Pro proxy (sub)
// - plain sonnet/opus/haiku + own_key mode → API key (METERED)
// - gpt-5.4* / gpt-5.3* → ChatGPT Plus/Pro via 9Router (sub)
// - gemini-* → Gemini Advanced via 9Router (sub)
const m = (session.model || '').toLowerCase();
const isApiRoute = m.endsWith('-api');
if (isApiRoute) {
return (
<Typography variant="caption" sx={{ color: c.accent.primary }}>
${session.cost_usd.toFixed(4)}
</Typography>
);
}
const isCcRoute = m.endsWith('-cc');
const isPlainAnthropic = m === 'sonnet' || m === 'opus' || m === 'haiku';
const isProRoute = isPlainAnthropic && connectionMode === 'openswarm-pro';
const isOwnKeyAnthropic = isPlainAnthropic && connectionMode !== 'openswarm-pro';
const isOpenAISub = m.startsWith('gpt-5') || m.startsWith('gpt-4') || m.startsWith('o1') || m.startsWith('o3') || m.startsWith('o4');
const isGeminiSub = m.startsWith('gemini-');
const isSubscriptionRouted = isCcRoute || isProRoute || isOpenAISub || isGeminiSub;
if (isSubscriptionRouted) {
return (
<Typography
variant="caption"
sx={{ color: c.text.tertiary }}
title="Routed through subscription, flat-rate, per-call cost not metered"
>
subscription
</Typography>
);
}
// own-key Anthropic OR anything else → real $ figure.
void isOwnKeyAnthropic;
return (
<Typography variant="caption" sx={{ color: c.accent.primary }}>
${session.cost_usd.toFixed(4)}
</Typography>
);
})()}
{(() => {
const mcpCount = session.active_mcps?.length ?? 0;
if (mcpCount === 0) return null;
return (
<Typography
variant="caption"
sx={{ display: 'inline-flex', alignItems: 'center', gap: 0.5, fontVariantNumeric: 'tabular-nums' }}
title={`${mcpCount} tool${mcpCount === 1 ? '' : 's'} connected.`}
>
<Box component="span" sx={{ color: c.text.tertiary }}>
{mcpCount} tool{mcpCount === 1 ? '' : 's'}
</Box>
</Typography>
);
})()}
</Box>
)}
</Box>
{!isDraft && id && (
<Tooltip title="Reset history">
<IconButton
size="small"
onClick={async () => {
const sid = id;
// Reset local UI state first; clearSessionMessages only touches Redux session.messages, so showResumeBubble/awaitingResponse/the queue otherwise survive and a "thinking" or "Resume agent response" bubble lingers on a now-empty chat.
setShowResumeBubble(false);
setAwaitingResponse(false);
messageQueueRef.current = [];
setQueueLength(0);
setQueueExpanded(false);
setEditingQueueIdx(null);
setEditingQueueText('');
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/${sid}/clear`, { method: 'POST', headers });
} catch { /* surfaced via context_status */ }
dispatch(clearSessionMessages(sid));
}}
sx={{ color: c.text.tertiary, '&:hover': { color: c.text.primary } }}
>
<RestartAltIcon fontSize="small" />
</IconButton>
</Tooltip>
)}
{onClose && (
<IconButton onClick={onClose} size="small" sx={{ color: c.text.tertiary, '&:hover': { color: c.text.primary } }}>
<CloseIcon fontSize="small" />
</IconButton>
)}
</Box>
)}
<Box sx={{ flex: 1, minHeight: 0, position: 'relative' }}>
<Box
ref={scrollContainerRef}
onScroll={handleScroll}
sx={{
height: '100%',
overflow: 'auto',
scrollbarGutter: 'stable',
// Top-aligned natural flow: messages start at the top and grow down
// (standard chat). The earlier flex-column + mt:auto bottom-anchor
// clustered short chats at the bottom under a big void, reading broken.
px: 2,
py: 1,
// Smoothness bundle (perf-only , no behavior change):
// 1. overflow-anchor: auto , Chromium's native scroll
// anchoring keeps the viewport pinned to the user's
// visible content as siblings above/below resize.
// Eliminates the "transcript snaps back" feel during
// streaming and parallel tool fan-outs. Runs on the
// compositor thread, free.
// 2. contain: layout , tells the browser layout shifts
// inside this scroll container don't affect siblings
// outside it. Prevents reflow from cascading up to
// the dashboard layout when bubbles grow.
// 3. overscroll-behavior: contain , keeps over-scroll
// gestures from leaking up to the dashboard pan/zoom
// when the user hits the chat top/bottom.
overflowAnchor: 'auto',
contain: 'layout',
overscrollBehavior: 'contain',
// Hidden until the user is in the chat: the thumb is transparent at
// rest and fades in on hover, so a resizing thumb never draws the eye.
'&::-webkit-scrollbar': { width: 6 },
'&::-webkit-scrollbar-track': { background: 'transparent' },
'&::-webkit-scrollbar-thumb': {
background: 'transparent',
borderRadius: 3,
minHeight: 48,
transition: 'background 0.2s',
},
'&:hover::-webkit-scrollbar-thumb': { background: c.border.medium },
'&:hover::-webkit-scrollbar-thumb:hover': { background: c.border.strong },
scrollbarWidth: 'thin',
scrollbarColor: 'transparent transparent',
'&:hover': { scrollbarColor: `${c.border.medium} transparent` },
}}
>
<Box>
{session.context_overflow && (() => {
const reason = session.context_overflow.reason;
const isAuth = reason === 'openswarm_pro_auth_expired' || reason === 'anthropic_auth_invalid' || reason === 'auth_error';
const isOutOfTokens = reason === 'out_of_tokens';
const title = isOutOfTokens ? 'Out of tokens' : isAuth ? 'Sign-in required' : 'Context full';
const primaryLabel = isOutOfTokens ? 'Got it' : isAuth ? 'Open Settings' : 'Start a fresh chat';
// In the workflow build chat, switching models here also sets the
// workflow's scheduled run model, so spell that consequence out.
const message = isOutOfTokens && workflowEditId
? `${session.context_overflow.message} Whichever model you switch to here becomes the model this workflow runs on.`
: session.context_overflow.message;
const onPrimary = () => {
if (isOutOfTokens) {
if (id) dispatch(clearContextOverflow({ sessionId: id }));
} else if (isAuth) {
dispatch(openSettingsModal('models'));
} else {
const did = session?.dashboard_id;
window.location.hash = did ? `#/dashboard/${did}` : '#/';
}
};
return (
<Box sx={{
mt: 1,
mb: 1.5,
p: 1.5,
borderRadius: 1.5,
border: `1px solid ${c.border.strong}`,
bgcolor: c.bg.secondary,
}}>
<Typography variant="body2" sx={{ color: c.text.primary, fontWeight: 500, mb: 0.5 }}>
{title}
</Typography>
<Typography variant="caption" sx={{ color: c.text.secondary, display: 'block', mb: 1.25 }}>
{message}
</Typography>
<Box sx={{ display: 'flex', gap: 1 }}>
<Typography
component="button"
variant="caption"
onClick={onPrimary}
sx={{
cursor: 'pointer',
border: `1px solid ${c.border.medium}`,
borderRadius: 1,
px: 1.25,
py: 0.5,
bgcolor: 'transparent',
color: c.text.primary,
'&:hover': { bgcolor: c.bg.elevated },
}}
>
{primaryLabel}
</Typography>
</Box>
</Box>
);
})()}
{/* Stand-in for items unmounted ABOVE the window. Its measured
height keeps the scrollbar geometry and scroll position stable
while overflow-anchor pins the visible content. */}
{topSpacerHeight > 0 && (
<Box aria-hidden data-window-spacer="top" sx={{ height: topSpacerHeight, flexShrink: 0, overflowAnchor: 'none' }} />
)}
{renderedVisibleItems.map((item, itemIdx) => {
const isLastVisibleItem = itemIdx === renderedVisibleItems.length - 1;
const isCompactionAnchor = !!session.compacted_through_msg_id && item.id === session.compacted_through_msg_id;
const compactionChip = isCompactionAnchor ? (
<CompactionMarker
key={`compaction-${item.id}`}
collapsedCount={
Math.max(0, renderItems.findIndex((it) => it.id === session.compacted_through_msg_id) + 1)
}
/>
) : null;
if (isToolGroup(item)) {
const groupMeta = session.tool_group_meta?.[item.id];
return (
<Box key={item.id} data-window-item-id={item.id} ref={isLastVisibleItem ? lastVisibleItemRef : undefined}>
<ToolGroupBubble group={item} isSessionRunning={sessionRunning} meta={groupMeta} sessionId={session.id} />
{compactionChip}
</Box>
);
}
if (isToolPair(item)) {
const isPending = item.result === null && sessionRunning;
return (
<Box key={item.id} data-window-item-id={item.id} ref={isLastVisibleItem ? lastVisibleItemRef : undefined}>
<ToolCallBubble call={item.call} result={item.result} isPending={isPending} sessionId={session.id} suppressReveal={item.call.id === justStreamedId} />
{compactionChip}
</Box>
);
}
const msg = item;
const isEditing = editingMessageId === msg.id;
const siblings = getSiblingBranches(msg.id);
const hasBranches = siblings.length > 0;
const currentBranchIdx = hasBranches
? siblings.indexOf(session.active_branch_id || 'main')
: 0;
const rawText = typeof msg.content === 'string' ? msg.content : JSON.stringify(msg.content);
return (
<Box key={msg.id} data-window-item-id={msg.id} ref={isLastVisibleItem ? lastVisibleItemRef : undefined}>
<Box
sx={{
'&:hover .msg-actions': { opacity: 1 },
// Cheap virtualization: tells the browser to skip
// paint + layout for bubbles outside the scroll
// viewport. `contain-intrinsic-size: auto N` reserves
// a placeholder height so the scrollbar doesn't jump,
// and `auto` lets the browser remember the actual
// height after first render. Works alongside the
// container's overflow-anchor. Chrome 85+ (Electron
// covers this).
contentVisibility: 'auto',
containIntrinsicSize: 'auto 120px',
}}
>
<MessageBubble
message={msg}
editing={isEditing}
onSaveEdit={handleSaveEdit}
onCancelEdit={handleCancelEdit}
viewportHeight={viewportHeight}
viewportWidth={viewportWidth}
scrollRoot={scrollRoot}
/>
{!isEditing && (msg.role === 'user' || (msg.role === 'assistant' && lastAssistantIdsInTurn.has(msg.id))) && (
<MessageActionBar
role={msg.role as 'user' | 'assistant'}
sessionId={session.id}
messageId={msg.id}
onCopy={() => navigator.clipboard.writeText(rawText)}
onEdit={msg.role === 'user' ? () => setEditingMessageId(msg.id) : undefined}
onRegenerate={msg.role === 'assistant' ? () => handleRegenerate(msg) : undefined}
onBranch={msg.role === 'assistant' ? () => handleBranchChat(msg.id) : undefined}
branchNav={
hasBranches
? {
currentIndex: Math.max(0, currentBranchIdx),
totalBranches: siblings.length,
disabled: branchNavLocked,
onPrevious: () => {
if (branchNavLocked) return;
const prevBranch = siblings[Math.max(0, currentBranchIdx - 1)];
if (prevBranch && id) dispatch(switchBranch({ sessionId: id, branchId: prevBranch }));
},
onNext: () => {
if (branchNavLocked) return;
const nextBranch = siblings[Math.min(siblings.length - 1, currentBranchIdx + 1)];
if (nextBranch && id) dispatch(switchBranch({ sessionId: id, branchId: nextBranch }));
},
}
: undefined
}
/>
)}
{compactionChip}
</Box>
</Box>
);
})}
{/* Stand-in for items unmounted BELOW the window (newer items not yet
scrolled into view). Zero while following the live tail. */}
{bottomSpacerHeight > 0 && (
<Box aria-hidden data-window-spacer="bottom" sx={{ height: bottomSpacerHeight, flexShrink: 0, overflowAnchor: 'none' }} />
)}
{/* overflow-anchor: none on the two elements that grow every frame
(live stream + thinking dots) keeps Chromium's scroll anchoring
from fighting our jam-to-bottom for the scroll position. The
committed messages above keep the default anchor, so resizing a
tool row while the user has scrolled up still holds their view. */}
{id && (
<Box sx={{ overflowAnchor: 'none' }}>
<StreamingBubble
sessionId={id}
activeBranchId={session.active_branch_id || 'main'}
turnLabel={session.turn_label?.label}
onStreamGrew={stickToBottomIfNeeded}
/>
</Box>
)}
{/* First-run welcome chips: sit UNDER the streamed greeting, appear once it finishes,
vanish the moment the user answers. The greeting itself is a real assistant bubble. */}
{session.is_welcome_draft && isDraft && welcomeGreetingDone && !session.messages.some((m) => m.role === 'user') && (
<WelcomeQuickReplies
c={c}
onPick={(p) => handleSend(p)}
onPickBuilder={(p) => chatInputRef.current?.setContent(p)}
/>
)}
{(preSendActivityLabel || awaitingResponse || (session.status === 'running' && !streamingMessageId)) && (
<Box sx={{ overflowAnchor: 'none' }}>
<ThinkingBubble
label={preSendActivityLabel || session.turn_label?.label}
seedKey={`${session.id}:${session.messages?.length ?? 0}`}
/>
</Box>
)}
{showResumeBubble && session.status === 'stopped' && !isWorkflowRunSidecar && (
<Box sx={{ display: 'flex', justifyContent: 'flex-start', my: 0.75 }}>
<Box
onClick={handleResume}
sx={{
display: 'inline-flex',
alignItems: 'center',
gap: 0.5,
px: 1.5,
py: 0.75,
borderRadius: '12px',
cursor: 'pointer',
bgcolor: `${c.accent.primary}10`,
border: `1px solid ${c.accent.primary}30`,
transition: 'all 0.15s',
'&:hover': {
bgcolor: `${c.accent.primary}1a`,
border: `1px solid ${c.accent.primary}50`,
},
}}
>
<PlayArrowIcon sx={{ fontSize: 14, color: c.accent.primary }} />
<Typography sx={{ fontSize: '0.78rem', fontWeight: 500, color: c.accent.primary }}>
Resume Agent Response
</Typography>
</Box>
</Box>
)}
</Box>
</Box>
{showScrollButton && (
<Tooltip title="Scroll to bottom">
<IconButton
onClick={scrollToBottom}
sx={{
position: 'absolute',
bottom: 12,
left: '50%',
transform: 'translateX(-50%)',
bgcolor: c.bg.surface,
border: `1px solid ${c.border.medium}`,
color: c.accent.primary,
width: 36,
height: 36,
'&:hover': { bgcolor: c.bg.secondary },
boxShadow: c.shadow.md,
zIndex: 1,
}}
>
<KeyboardArrowDownIcon fontSize="small" />
</IconButton>
</Tooltip>
)}
</Box>
{session.pending_approvals.length > 1 ? (
<BatchApprovalBar requests={session.pending_approvals} onApprove={handleApprove} onDeny={handleDeny} />
) : (
session.pending_approvals.map((req) => (
<ApprovalBar key={req.id} request={req} onApprove={handleApprove} onDeny={handleDeny} />
))
)}
<ClickAwayListener onClickAway={() => { if (queueExpanded) { setQueueExpanded(false); setEditingQueueIdx(null); } }}>
<Box>
{queueLength > 0 && (
<Box sx={{ ml: 3, mr: 1.5 }}>
<Box
onClick={() => { setQueueExpanded((v) => !v); setEditingQueueIdx(null); }}
sx={{
display: 'inline-flex',
alignItems: 'center',
gap: 0.5,
px: 1.25,
py: 0.25,
borderRadius: '8px 8px 0 0',
bgcolor: c.bg.surface,
border: `1px solid ${c.border.subtle}`,
borderBottom: 'none',
cursor: 'pointer',
userSelect: 'none',
'&:hover': { bgcolor: c.bg.secondary },
transition: 'background 0.12s',
}}
>
{queueExpanded
? <KeyboardArrowDownIcon sx={{ fontSize: 12, color: c.text.tertiary }} />
: <KeyboardArrowUpIcon sx={{ fontSize: 12, color: c.text.tertiary }} />
}
<Typography sx={{ fontSize: '0.68rem', fontWeight: 600, color: c.text.muted, letterSpacing: 0.2 }}>
{queueLength} queued
</Typography>
<Tooltip title="Clear all">
<IconButton
size="small"
onClick={(e) => { e.stopPropagation(); messageQueueRef.current = []; setQueueLength(0); setQueueExpanded(false); setEditingQueueIdx(null); }}
sx={{ p: 0.15, color: c.text.tertiary, '&:hover': { color: c.status.error } }}
>
<CloseIcon sx={{ fontSize: 10 }} />
</IconButton>
</Tooltip>
</Box>
{queueExpanded && (
<Box
sx={{
bgcolor: c.bg.surface,
border: `1px solid ${c.border.subtle}`,
borderBottom: 'none',
borderRadius: '0 8px 0 0',
maxHeight: 240,
overflowY: 'auto',
'&::-webkit-scrollbar': { width: 4 },
'&::-webkit-scrollbar-thumb': { background: c.border.medium, borderRadius: 2 },
}}
>
{messageQueueRef.current.map((msg, idx) => (
<Box
key={idx}
draggable={editingQueueIdx !== idx}
onDragStart={(e) => {
setDragIdx(idx);
e.dataTransfer.effectAllowed = 'move';
}}
onDragOver={(e) => {
e.preventDefault();
e.dataTransfer.dropEffect = 'move';
if (dragIdx !== null && dragIdx !== idx) setDropTargetIdx(idx);
}}
onDragLeave={() => { if (dropTargetIdx === idx) setDropTargetIdx(null); }}
onDrop={(e) => {
e.preventDefault();
if (dragIdx !== null && dragIdx !== idx) {
const q = messageQueueRef.current;
const [item] = q.splice(dragIdx, 1);
q.splice(idx, 0, item);
setQueueLength(q.length);
}
setDragIdx(null);
setDropTargetIdx(null);
}}
onDragEnd={() => { setDragIdx(null); setDropTargetIdx(null); }}
sx={{
display: 'flex',
alignItems: 'flex-start',
gap: 0.75,
px: 1.5,
py: 1,
borderBottom: idx < queueLength - 1 ? `1px solid ${c.border.subtle}` : 'none',
'&:hover': { bgcolor: c.bg.secondary },
transition: 'background 0.1s, opacity 0.15s',
...(dragIdx === idx ? { opacity: 0.35 } : {}),
...(dropTargetIdx === idx && dragIdx !== null && dragIdx !== idx
? { borderTop: `2px solid ${c.accent.primary}` }
: {}),
}}
>
<Box
sx={{
cursor: editingQueueIdx === idx ? 'default' : 'grab',
display: 'flex',
alignItems: 'center',
mt: 0.3,
color: c.text.ghost,
'&:hover': { color: c.text.tertiary },
'&:active': { cursor: 'grabbing' },
}}
>
<DragIndicatorIcon sx={{ fontSize: 14 }} />
</Box>
{editingQueueIdx === idx ? (
<Box sx={{ flex: 1, display: 'flex', gap: 0.5, alignItems: 'flex-start' }}>
<TextField
multiline
fullWidth
size="small"
value={editingQueueText}
onChange={(e) => setEditingQueueText(e.target.value)}
autoFocus
onKeyDown={(e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
const trimmed = editingQueueText.trim();
if (trimmed) {
messageQueueRef.current[idx] = { ...messageQueueRef.current[idx], prompt: trimmed };
setQueueLength(messageQueueRef.current.length);
}
setEditingQueueIdx(null);
}
if (e.key === 'Escape') setEditingQueueIdx(null);
}}
sx={{
'& .MuiOutlinedInput-root': {
fontSize: '0.78rem',
color: c.text.primary,
'& fieldset': { borderColor: c.border.medium },
'&.Mui-focused fieldset': { borderColor: c.accent.primary },
},
}}
/>
<IconButton
size="small"
onClick={() => {
const trimmed = editingQueueText.trim();
if (trimmed) {
messageQueueRef.current[idx] = { ...messageQueueRef.current[idx], prompt: trimmed };
setQueueLength(messageQueueRef.current.length);
}
setEditingQueueIdx(null);
}}
sx={{ p: 0.25, color: c.accent.primary, mt: 0.25 }}
>
<CheckIcon sx={{ fontSize: 14 }} />
</IconButton>
</Box>
) : (
<Typography
sx={{
flex: 1,
fontSize: '0.78rem',
color: c.text.secondary,
lineHeight: 1.5,
overflow: 'hidden',
display: '-webkit-box',
WebkitLineClamp: 3,
WebkitBoxOrient: 'vertical',
wordBreak: 'break-word',
}}
>
{msg.prompt}
</Typography>
)}
{editingQueueIdx !== idx && (
<Box sx={{ display: 'flex', gap: 0.25, flexShrink: 0, mt: 0.15 }}>
<Tooltip title="Edit">
<IconButton
size="small"
onClick={() => { setEditingQueueIdx(idx); setEditingQueueText(msg.prompt); }}
sx={{ p: 0.25, color: c.text.tertiary, '&:hover': { color: c.text.primary } }}
>
<EditOutlinedIcon sx={{ fontSize: 13 }} />
</IconButton>
</Tooltip>
<Tooltip title="Remove">
<IconButton
size="small"
onClick={() => {
messageQueueRef.current.splice(idx, 1);
setQueueLength(messageQueueRef.current.length);
if (messageQueueRef.current.length === 0) setQueueExpanded(false);
}}
sx={{ p: 0.25, color: c.text.tertiary, '&:hover': { color: c.status.error } }}
>
<DeleteOutlineIcon sx={{ fontSize: 13 }} />
</IconButton>
</Tooltip>
</Box>
)}
</Box>
))}
</Box>
)}
</Box>
)}
{(() => {
// Proactive Haiku-overflow warning. Each connected MCP adds
// a sizeable tools-schema chunk to every Claude request;
// Haiku 4.5's window is 5x smaller than Sonnet/Opus, so 5+
// simultaneously-enabled MCPs reliably push a one-line
// message past the limit. We surface this BEFORE the user
// sends so they don't waste a turn on "Prompt is too long".
const isHaiku = (model || '').toLowerCase().startsWith('haiku');
const enabledMcpCount = Object.values(toolItems).filter(
(t) => t.enabled && t.mcp_config && Object.keys(t.mcp_config).length > 0,
).length;
if (!isHaiku || enabledMcpCount < 5) return null;
return (
<Box
sx={{
mx: 2,
mb: 1,
p: 1.5,
borderRadius: `${c.radius.lg}px`,
border: `1px solid ${c.status.warning}40`,
bgcolor: `${c.status.warning}10`,
display: 'flex',
alignItems: 'flex-start',
gap: 1.2,
}}
>
<Box sx={{ flexShrink: 0, mt: 0.2 }}>
<ErrorSlime size={20} />
</Box>
<Box sx={{ flex: 1, minWidth: 0 }}>
<Typography sx={{ fontSize: '0.86rem', fontWeight: 600, color: c.text.primary, mb: 0.4 }}>
Haiku may run out of room with {enabledMcpCount} apps connected
</Typography>
<Typography sx={{ fontSize: '0.78rem', color: c.text.secondary, lineHeight: 1.45 }}>
Haiku is the fastest Claude model but holds the least at once.
Each connected app adds instructions Claude has to read first.
If your message fails with “Prompt is too long,” turn off a few
apps (Microsoft 365 is the heaviest) or switch to Sonnet/Opus,
both have 5× more room.
</Typography>
</Box>
</Box>
);
})()}
{(() => {
const list = session.mcp_suggestions ?? [];
if (list.length) mcpSnapshotRef.current = list;
const display = mcpSnapshotRef.current;
return (
<Fade in={list.length > 0} timeout={{ enter: 200, exit: 220 }} unmountOnExit>
<Box sx={{
mx: 2,
mb: 1,
p: 1.5,
borderRadius: 1.5,
border: `1px solid ${c.border.medium}`,
bgcolor: c.bg.secondary,
position: 'relative',
}}>
<Box
role="button"
aria-label="Dismiss integration suggestion"
onClick={() => {
if (!id) return;
dispatch(clearMcpSuggestions({ sessionId: id }));
dispatch(dismissMcpSuggestion(display.map((s) => s.id)));
}}
sx={{
position: 'absolute',
top: 6,
right: 8,
width: 20,
height: 20,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: '1rem',
lineHeight: 1,
color: c.text.muted,
cursor: 'pointer',
borderRadius: 0.75,
'&:hover': { color: c.text.primary, bgcolor: c.bg.elevated },
}}
>
×
</Box>
<Typography variant="body2" sx={{ color: c.text.primary, fontWeight: 500, mb: 0.5, pr: 3 }}>
Looks like this might need an integration
</Typography>
<Typography variant="caption" sx={{ color: c.text.secondary, display: 'block', mb: 1 }}>
Activating one of these will let the agent answer in a single round-trip.
</Typography>
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1 }}>
{display.map((s) => (
<Box key={s.id} sx={{ flexBasis: '100%', display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 1 }}>
<Box sx={{ flex: 1, minWidth: 0 }}>
<Typography variant="caption" sx={{ color: c.text.primary, fontWeight: 500 }}>
{s.title}
</Typography>
{s.reason && (
<Typography variant="caption" sx={{ display: 'block', color: c.text.tertiary }}>
{s.reason}
</Typography>
)}
</Box>
<Typography
component="button"
variant="caption"
disabled={activatingMcp === s.id}
onClick={async () => {
if (activatingMcp) return;
setActivateError(null);
setActivatingMcp(s.id);
try {
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
const tok = (() => { try { return getAuthToken(); } catch { return ''; } })();
if (tok) headers['Authorization'] = `Bearer ${tok}`;
const r = await fetch(`${API_BASE}/mcp-meta/activate`, {
method: 'POST',
headers,
body: JSON.stringify({
server_name: s.id.toLowerCase().replace(/\s+/g, '-'),
reason: s.reason || 'preflight suggestion',
parent_session_id: session.id,
}),
});
const body = await r.json().catch(() => ({} as any));
if (!r.ok) {
setActivateError(`Activation failed (${r.status})`);
} else if (body?.status === 'unknown_server') {
// Not yet connected; jump straight to Actions
// so the user can finish OAuth. Nothing here
// can do it on their behalf.
navigate('/actions');
} else if (id) {
// Activation succeeded; clear the banner so the user
// gets visual confirmation the click did something.
dispatch(clearMcpSuggestions({ sessionId: id }));
}
} catch (e: any) {
setActivateError(e?.message || 'Activation failed');
} finally {
setActivatingMcp(null);
}
}}
sx={{
cursor: activatingMcp === s.id ? 'wait' : 'pointer',
border: `1px solid ${c.border.medium}`,
borderRadius: 1,
px: 1.25,
py: 0.5,
bgcolor: 'transparent',
color: c.text.primary,
opacity: activatingMcp === s.id ? 0.5 : 1,
'&:hover': { bgcolor: activatingMcp ? 'transparent' : c.bg.elevated },
flexShrink: 0,
}}
>
{activatingMcp === s.id ? 'Activating…' : 'Activate'}
</Typography>
</Box>
))}
</Box>
{activateError && (
<Typography variant="caption" sx={{ display: 'block', mt: 0.75, color: c.status.error }}>
{activateError}
</Typography>
)}
</Box>
</Fade>
);
})()}
{isStoppableSidecar ? (
<ForceStopAgentBar onStop={handleStop} onSaveWorkflow={onTestSaveWorkflow} onContinueEditing={onTestContinueEditing} testState={testState} />
) : (
<Box sx={{ position: 'relative' }}>
<WorkflowModelNotice c={c} label={workflowModelNotice} />
<ChatInput
ref={chatInputRef}
onSend={handleSend}
disabled={false}
mode={mode}
onModeChange={handleModeChange}
model={model}
onModelChange={handleModelChange}
isRunning={agentBusy}
onStop={handleStop}
queueLength={queueLength}
contextEstimate={contextEstimate}
sessionId={id}
autoFocus={autoFocus}
thinkingLevel={session?.thinking_level ?? 'auto'}
onThinkingLevelChange={handleThinkingLevelChange}
onActivityLabelChange={setPreSendActivityLabel}
/>
</Box>
)}
</Box>
</ClickAwayListener>
</Box>
</Box>
);
};
// Brief toast above the build chat's composer confirming a model switch also
// changes the model the scheduled workflow will run on. Holds the last label in
// a ref so the exit fade renders content instead of blanking mid-animation.
function WorkflowModelNotice({ c, label }: { c: ReturnType<typeof useClaudeTokens>; label: string | null }) {
const last = React.useRef<string | null>(null);
if (label) last.current = label;
const display = last.current;
if (!display) return null;
return (
<Fade in={!!label} timeout={{ enter: 200, exit: 220 }} unmountOnExit>
<Box sx={{
position: 'absolute', left: 8, right: 8, bottom: 'calc(100% + 8px)',
display: 'flex', alignItems: 'center', gap: 1,
bgcolor: c.bg.surface, border: `1px solid ${c.border.medium}`,
boxShadow: c.shadow.md, borderRadius: '12px',
px: 1.75, py: 1, zIndex: 6,
}}>
<SwapHorizRoundedIcon sx={{ fontSize: 17, color: c.accent.primary, flexShrink: 0 }} />
<Box sx={{ fontSize: '0.83rem', color: c.text.primary, lineHeight: 1.4 }}>
This workflow will run on <b>{display}</b> after you save.
</Box>
</Box>
</Fade>
);
}
export default AgentChat;