[Haik]: agentic refactor 7. Integration, SkillBuilderChat & Cleanup

This commit is contained in:
haikdc
2026-03-30 18:56:12 -07:00
parent 4675697bfa
commit 0b16e6efe3
9 changed files with 58 additions and 462 deletions
@@ -7,7 +7,7 @@ import CloseIcon from '@mui/icons-material/Close';
import CheckIcon from '@mui/icons-material/Check';
import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
import { motion } from 'framer-motion';
import { parseMcpToolName, useMcpToolMeta, getToolIcon } from '@/app/pages/AgentChat/ApprovalBar';
import { parseMcpToolName, useMcpToolMeta, getToolIcon } from '@/app/pages/AgentChat/toolkit/approval-tools';
import { SPRING_BOUNCE } from './islandTypes';
import type { ClaudeTokens } from './islandTypes';
import type { ApprovalRequest } from '@/shared/state/agentsSlice';
@@ -4,7 +4,7 @@ import Typography from '@mui/material/Typography';
import IconButton from '@mui/material/IconButton';
import CloseIcon from '@mui/icons-material/Close';
import { motion } from 'framer-motion';
import ApprovalBar, { BatchApprovalBar } from '@/app/pages/AgentChat/ApprovalBar';
import { ApprovalRouter as ApprovalBar, BatchApprovalWrapper as BatchApprovalBar } from '@/app/pages/AgentChat/toolkit/approval-tools';
import { AgentStatusRow } from './AgentStatusRow';
import { CompletedAgentsList } from './CompletedAgentsList';
import type { ClaudeTokens, SessionApprovalGroup, TrackedAgent } from './islandTypes';
+41 -19
View File
@@ -1,16 +1,16 @@
import React from 'react';
import React, { useCallback, useRef } from 'react';
import Box from '@mui/material/Box';
import Typography from '@mui/material/Typography';
import { AssistantRuntimeProvider, useAui, Tools } from '@assistant-ui/react';
import ApprovalBar, { BatchApprovalBar } from './ApprovalBar';
import ChatInput from './ChatInput';
import { ApprovalRouter, BatchApprovalWrapper } from './toolkit/approval-tools';
import ChatHeader from './ChatHeader';
import MessageQueue from './MessageQueue';
import OpenSwarmThread from './thread/OpenSwarmThread';
import OpenSwarmComposer from './composer/OpenSwarmComposer';
import { useAgentChat } from './hooks/useAgentChat';
import { useOpenSwarmRuntime } from './runtime/useOpenSwarmRuntime';
import { useOpenSwarmRuntime, type ComposerExtras, type DispatchableMessage } from './runtime/useOpenSwarmRuntime';
import { toolkit } from './toolkit';
import { ContextPath } from '@/app/components/DirectoryBrowser';
import type { ContextPath } from '@/app/components/DirectoryBrowser';
import { useClaudeTokens } from '@/shared/styles/ThemeContext';
interface AgentChatProps {
@@ -27,17 +27,22 @@ interface AgentChatProps {
const AgentChat: React.FC<AgentChatProps> = ({ sessionId, onClose, embedded, autoFocus, isGlowing, onDismissGlow, initialContextPaths, onBranch }) => {
const c = useClaudeTokens();
const {
id, session, isDraft, dispatch, mode, model,
scrollContainerRef, chatInputRef, messageQueueRef,
showScrollButton, showResumeBubble, awaitingResponse, editingMessageId,
id, session, isDraft, mode, model,
messageQueueRef, showResumeBubble,
queueLength, setQueueLength, agentBusy,
handleScroll, scrollToBottom, handleSend,
handleModeChange, handleModelChange,
handleSend, handleModeChange, handleModelChange,
handleApprove, handleDeny, handleStop, handleResume,
handleSaveEdit, handleCancelEdit, setEditingMessageId,
} = useAgentChat({ sessionId, initialContextPaths });
} = useAgentChat({ sessionId });
const runtime = useOpenSwarmRuntime(id);
const composerExtrasRef = useRef<ComposerExtras>({});
const dispatchForRuntime = useCallback((msg: DispatchableMessage) => {
handleSend(msg.prompt, msg.images, msg.contextPaths, msg.forcedTools, msg.attachedSkills, msg.selectedBrowserIds);
}, [handleSend]);
const runtime = useOpenSwarmRuntime(id, {
composerExtrasRef,
dispatchMessage: dispatchForRuntime,
});
const aui = useAui({ tools: Tools({ toolkit }) });
const contextEstimate = { used: 0, limit: 200_000 };
@@ -57,13 +62,31 @@ const AgentChat: React.FC<AgentChatProps> = ({ sessionId, onClose, embedded, aut
<Box sx={{ flex: 1, minHeight: 0 }}>
<OpenSwarmThread sessionId={id} onBranchChat={onBranch} />
</Box>
{session.pending_approvals.length > 1 ? (
<BatchApprovalBar requests={session.pending_approvals} onApprove={handleApprove} onDeny={handleDeny} />
<BatchApprovalWrapper requests={session.pending_approvals} onApprove={handleApprove} onDeny={handleDeny} />
) : (
session.pending_approvals.map((req) => (
<ApprovalBar key={req.id} request={req} onApprove={handleApprove} onDeny={handleDeny} />
<ApprovalRouter key={req.id} request={req} onApprove={handleApprove} onDeny={handleDeny} />
))
)}
{showResumeBubble && session.status === 'stopped' && (
<Box
onClick={handleResume}
sx={{
mx: 1.5, mb: 1.5, py: 1.25, display: 'flex', alignItems: 'center', justifyContent: 'center',
borderRadius: 2.5, cursor: 'pointer', fontWeight: 600, fontSize: '0.85rem',
color: c.accent.primary, border: `1.5px solid ${c.accent.primary}`,
background: `${c.accent.primary}08`,
transition: 'background 0.15s, box-shadow 0.15s',
'&:hover': { background: `${c.accent.primary}14` },
}}
>
Resume
</Box>
)}
{isGlowing ? (
<Box
onClick={(e) => { e.stopPropagation(); onDismissGlow?.(); }}
@@ -86,10 +109,8 @@ const AgentChat: React.FC<AgentChatProps> = ({ sessionId, onClose, embedded, aut
</Box>
) : (
<MessageQueue messageQueueRef={messageQueueRef} queueLength={queueLength} setQueueLength={setQueueLength}>
<ChatInput
ref={chatInputRef}
onSend={handleSend}
disabled={false}
<OpenSwarmComposer
composerExtrasRef={composerExtrasRef}
mode={mode}
onModeChange={handleModeChange}
model={model}
@@ -100,6 +121,7 @@ const AgentChat: React.FC<AgentChatProps> = ({ sessionId, onClose, embedded, aut
contextEstimate={contextEstimate}
sessionId={id}
autoFocus={autoFocus}
initialContextPaths={initialContextPaths}
/>
</MessageQueue>
)}
@@ -1,10 +0,0 @@
/**
* Re-export stub — logic moved to toolkit/approval-tools.tsx.
* Kept for backward compatibility with AgentChat, DynamicIsland, and Dashboard imports.
*/
export { ApprovalRouter as default } from './toolkit/approval-tools';
export { ToolQuestion as QuestionForm } from './toolkit/approval-tools';
export type { ToolQuestionProps as QuestionFormProps } from './toolkit/approval-tools';
export { BatchApprovalWrapper as BatchApprovalBar } from './toolkit/approval-tools';
export { parseMcpToolName, useMcpToolMeta, getToolIcon } from './toolkit/approval-tools';
export type { ParsedTool } from './toolkit/approval-tools';
@@ -1,183 +0,0 @@
import React, { useState, useMemo } from 'react';
import Box from '@mui/material/Box';
import Typography from '@mui/material/Typography';
import Chip from '@mui/material/Chip';
import Tooltip from '@mui/material/Tooltip';
import Collapse from '@mui/material/Collapse';
import AdsClickIcon from '@mui/icons-material/AdsClick';
import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
import FolderOutlinedIcon from '@mui/icons-material/FolderOutlined';
import InsertDriveFileOutlinedIcon from '@mui/icons-material/InsertDriveFileOutlined';
import PsychologyOutlinedIcon from '@mui/icons-material/PsychologyOutlined';
import BuildOutlinedIcon from '@mui/icons-material/BuildOutlined';
import { AgentMessage } from '@/shared/state/agentsSlice';
import { useClaudeTokens } from '@/shared/styles/ThemeContext';
import { SKILL_COLOR } from '@/app/components/richEditorUtils';
interface ParsedElement {
label: string;
selector: string;
isSemantic?: boolean;
}
interface ContextGroup {
key: string;
icon: React.ReactNode;
color: string;
label: string;
chips: Array<{ label: string; tooltip?: string; icon: React.ReactNode }>;
}
function buildContextGroups(
elements: ParsedElement[],
message: AgentMessage,
): ContextGroup[] {
const groups: ContextGroup[] = [];
if (elements.length > 0) {
groups.push({
key: 'elements',
icon: <AdsClickIcon sx={{ fontSize: 13 }} />,
color: '#3b82f6',
label: `${elements.length} element${elements.length > 1 ? 's' : ''} selected`,
chips: elements.map((el) => ({
label: el.label,
tooltip: el.selector,
icon: <AdsClickIcon sx={{ fontSize: 12 }} />,
})),
});
}
const contextPaths = message.context_paths;
if (contextPaths && contextPaths.length > 0) {
const files = contextPaths.filter((cp) => cp.type === 'file');
const dirs = contextPaths.filter((cp) => cp.type === 'directory');
const allPaths = [...dirs, ...files];
const label = [
dirs.length > 0 ? `${dirs.length} folder${dirs.length > 1 ? 's' : ''}` : '',
files.length > 0 ? `${files.length} file${files.length > 1 ? 's' : ''}` : '',
].filter(Boolean).join(', ') + ' attached';
groups.push({
key: 'paths',
icon: <FolderOutlinedIcon sx={{ fontSize: 13 }} />,
color: '#10b981',
label,
chips: allPaths.map((cp) => {
const name = cp.path.split('/').filter(Boolean).pop() || cp.path;
return {
label: name,
tooltip: cp.path,
icon: cp.type === 'directory'
? <FolderOutlinedIcon sx={{ fontSize: 12 }} />
: <InsertDriveFileOutlinedIcon sx={{ fontSize: 12 }} />,
};
}),
});
}
const skills = message.attached_skills;
if (skills && skills.length > 0) {
groups.push({
key: 'skills',
icon: <PsychologyOutlinedIcon sx={{ fontSize: 13 }} />,
color: SKILL_COLOR,
label: `${skills.length} skill${skills.length > 1 ? 's' : ''}`,
chips: skills.map((s) => ({
label: s.name,
icon: <PsychologyOutlinedIcon sx={{ fontSize: 12 }} />,
})),
});
}
const forcedTools = message.forced_tools;
if (forcedTools && forcedTools.length > 0) {
groups.push({
key: 'tools',
icon: <BuildOutlinedIcon sx={{ fontSize: 13 }} />,
color: '#f59e0b',
label: `${forcedTools.length} action${forcedTools.length > 1 ? 's' : ''} requested`,
chips: forcedTools.map((t) => ({
label: t,
icon: <BuildOutlinedIcon sx={{ fontSize: 12 }} />,
})),
});
}
return groups;
}
const AttachedContextSection: React.FC<{
elements: ParsedElement[];
message: AgentMessage;
c: ReturnType<typeof useClaudeTokens>;
}> = ({ elements, message, c }) => {
const [expanded, setExpanded] = useState(false);
const groups = useMemo(() => buildContextGroups(elements, message), [elements, message]);
if (groups.length === 0) return null;
return (
<Box sx={{ mt: 1, pt: 0.75, borderTop: `1px solid ${c.border.subtle}` }}>
<Box
onClick={() => setExpanded(!expanded)}
sx={{
display: 'inline-flex',
alignItems: 'center',
gap: 0.5,
cursor: 'pointer',
mb: 0.5,
'&:hover': { opacity: 0.8 },
}}
>
{groups.map((g) => (
<Box key={g.key} sx={{ color: g.color, display: 'inline-flex', alignItems: 'center' }}>
{g.icon}
</Box>
))}
<Typography sx={{ fontSize: '0.7rem', fontWeight: 600, color: c.text.muted }}>
{groups.map((g) => g.label).join(' · ')}
</Typography>
<ExpandMoreIcon
sx={{
fontSize: 14,
color: c.text.tertiary,
transform: expanded ? 'rotate(180deg)' : 'rotate(0deg)',
transition: '0.15s',
}}
/>
</Box>
<Collapse in={expanded}>
{groups.map((g) => (
<Box key={g.key} sx={{ mt: 0.5 }}>
<Typography sx={{ fontSize: '0.62rem', fontWeight: 600, color: g.color, textTransform: 'uppercase', letterSpacing: 0.5, mb: 0.25 }}>
{g.label}
</Typography>
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.5 }}>
{g.chips.map((chip, i) => (
<Tooltip key={i} title={chip.tooltip || chip.label} arrow placement="top"
slotProps={{ tooltip: { sx: { fontFamily: c.font.mono, fontSize: '0.68rem', maxWidth: 400 } } }}
>
<Chip
icon={chip.icon as React.ReactElement}
label={chip.label}
size="small"
sx={{
bgcolor: `${g.color}18`,
color: g.color,
fontSize: '0.68rem',
fontFamily: c.font.mono,
height: 22,
'& .MuiChip-icon': { color: g.color },
}}
/>
</Tooltip>
))}
</Box>
</Box>
))}
</Collapse>
</Box>
);
};
export default AttachedContextSection;
@@ -1,200 +0,0 @@
import React, { useMemo, useState } from 'react';
import Box from '@mui/material/Box';
import Typography from '@mui/material/Typography';
import Collapse from '@mui/material/Collapse';
import IconButton from '@mui/material/IconButton';
import Chip from '@mui/material/Chip';
import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
import ExpandLessIcon from '@mui/icons-material/ExpandLess';
import TerminalIcon from '@mui/icons-material/Terminal';
import CheckCircleOutlineIcon from '@mui/icons-material/CheckCircleOutline';
import { AgentMessage, ToolGroupMeta } from '@/shared/state/agentsSlice';
import { useClaudeTokens } from '@/shared/styles/ThemeContext';
import { sanitizeSvgString } from '@/shared/sanitizeSvg';
import ToolCallBubble, { ToolPair } from './ToolCallBubble';
export interface ToolGroup {
type: 'tool_group';
id: string;
pairs: ToolPair[];
label: string;
callCount: number;
mcpServer?: string;
}
export type RenderItem = AgentMessage | ToolGroup | ToolPair;
export function isToolGroup(item: RenderItem): item is ToolGroup {
return (item as ToolGroup).type === 'tool_group';
}
export function isToolPair(item: RenderItem): item is ToolPair {
return (item as ToolPair).type === 'tool_pair';
}
const GeneratedSvgIcon: React.FC<{ svg: string; size?: number; color: string }> = ({ svg, size = 16, color }) => {
const sanitized = useMemo(() => sanitizeSvgString(svg), [svg]);
if (!sanitized) return null;
return (
<svg
width={size}
height={size}
viewBox="0 0 24 24"
fill="none"
style={{ flexShrink: 0, color }}
dangerouslySetInnerHTML={{ __html: sanitized }}
/>
);
};
const SkeletonPulse: React.FC<{ width: number; height: number; borderRadius?: number }> = ({ width, height, borderRadius = 4 }) => (
<Box
sx={{
width,
height,
borderRadius: `${borderRadius}px`,
bgcolor: 'currentColor',
opacity: 0.1,
animation: 'pulse 1.5s ease-in-out infinite',
'@keyframes pulse': {
'0%, 100%': { opacity: 0.1 },
'50%': { opacity: 0.2 },
},
}}
/>
);
interface Props {
group: ToolGroup;
isSessionRunning?: boolean;
meta?: ToolGroupMeta;
sessionId?: string;
}
const ToolGroupBubble: React.FC<Props> = React.memo(({ group, isSessionRunning = false, meta, sessionId }) => {
const c = useClaudeTokens();
const isMcp = !!group.mcpServer;
const [expanded, setExpanded] = useState(isMcp);
const completedCount = group.pairs.filter((p) => p.result !== null).length;
const pendingCount = group.pairs.filter((p) => p.result === null).length;
const deniedCount = group.pairs.filter(
(p) => typeof p.call.content === 'object' && p.call.content.approved === false
).length;
const allDone = pendingCount === 0 || !isSessionRunning;
const displayName = meta?.name || group.label;
const hasSvg = !!meta?.svg;
const toolNames = group.pairs.map((p) => {
const c2 = typeof p.call.content === 'object' ? p.call.content : {};
return c2.tool || 'unknown';
});
return (
<Box
data-select-type="tool-group"
data-select-id={group.id}
data-select-meta={JSON.stringify({ label: displayName, callCount: group.callCount, tools: toolNames })}
sx={{ maxWidth: '85%', my: 0.5 }}
>
<Box
sx={{
bgcolor: c.bg.elevated,
border: `1px solid ${c.border.subtle}`,
borderRadius: 2,
overflow: 'hidden',
}}
>
<Box
onClick={() => setExpanded(!expanded)}
sx={{
display: 'flex',
alignItems: 'center',
gap: 0.75,
px: 1.5,
py: 0.7,
cursor: 'pointer',
'&:hover': { bgcolor: 'rgba(0,0,0,0.02)' },
}}
>
{!meta ? (
<SkeletonPulse width={15} height={15} borderRadius={8} />
) : hasSvg ? (
<GeneratedSvgIcon svg={meta.svg} size={15} color={c.accent.primary} />
) : (
<TerminalIcon sx={{ fontSize: 15, color: c.accent.primary, flexShrink: 0 }} />
)}
{!meta ? (
<Box sx={{ flex: 1, display: 'flex', alignItems: 'center' }}>
<SkeletonPulse width={100} height={12} />
</Box>
) : (
<Typography
sx={{
color: c.accent.primary,
fontSize: '0.8rem',
fontWeight: 600,
flex: 1,
}}
>
{displayName}
</Typography>
)}
{deniedCount > 0 && (
<Typography sx={{ color: c.status.error, fontSize: '0.68rem' }}>
{deniedCount} denied
</Typography>
)}
{allDone && completedCount > 0 && (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.3 }}>
<CheckCircleOutlineIcon sx={{ fontSize: 12, color: c.status.success }} />
<Typography sx={{ color: c.status.success, fontSize: '0.68rem' }}>
{completedCount}/{group.callCount}
</Typography>
</Box>
)}
{!allDone && pendingCount > 0 && (
<Typography sx={{ color: c.text.tertiary, fontSize: '0.68rem', fontFamily: c.font.mono }}>
{completedCount}/{group.callCount}
</Typography>
)}
<Chip
label={`×${group.callCount}`}
size="small"
sx={{
height: 18,
fontSize: '0.7rem',
fontWeight: 600,
bgcolor: c.bg.secondary,
color: c.text.muted,
'& .MuiChip-label': { px: 0.75 },
}}
/>
<IconButton size="small" sx={{ color: c.text.tertiary, p: 0.15 }}>
{expanded ? <ExpandLessIcon sx={{ fontSize: 16 }} /> : <ExpandMoreIcon sx={{ fontSize: 16 }} />}
</IconButton>
</Box>
<Collapse in={expanded}>
<Box sx={{ borderTop: `0.5px solid ${c.border.medium}` }}>
{group.pairs.map((pair) => (
<ToolCallBubble
key={pair.id}
call={pair.call}
result={pair.result}
isPending={pair.result === null && isSessionRunning}
mcpCompact
sessionId={sessionId}
/>
))}
</Box>
</Collapse>
</Box>
</Box>
);
});
export default ToolGroupBubble;
@@ -5,6 +5,7 @@ import type { Unstable_MentionItem } from '@assistant-ui/core';
import { useAppSelector } from '@/shared/hooks';
import type { PromptTemplate } from '@/shared/state/templatesSlice';
import type { ComposerExtras } from '../runtime/useOpenSwarmRuntime';
import type { ContextPath } from '@/app/components/DirectoryBrowser';
import { useOpenSwarmMentionAdapter, type MentionItemMetadata } from './OpenSwarmMentionAdapter';
import { useComposerAttachments } from './useComposerAttachments';
import { MentionSelectOverride, MentionPopover, ComposerAttachmentChips } from './ComposerParts';
@@ -23,11 +24,13 @@ export interface OpenSwarmComposerProps {
queueLength?: number;
contextEstimate?: { used: number; limit: number };
autoFocus?: boolean;
initialContextPaths?: ContextPath[];
}
const OpenSwarmComposer: FC<OpenSwarmComposerProps> = ({
composerExtrasRef, mode, onModeChange, model, onModelChange,
isRunning, onStop, sessionId, queueLength, contextEstimate, autoFocus,
initialContextPaths,
}) => {
const aui = useAui();
const mentionAdapter = useOpenSwarmMentionAdapter();
@@ -36,9 +39,16 @@ const OpenSwarmComposer: FC<OpenSwarmComposerProps> = ({
const [selectedTemplate, setSelectedTemplate] = useState<PromptTemplate | null>(null);
const [hasContent, setHasContent] = useState(false);
const initialContextApplied = useRef(false);
const templates = useAppSelector((s) => s.templates.items);
const skills = useAppSelector((s) => s.skills.items);
useEffect(() => {
if (initialContextApplied.current || !initialContextPaths?.length) return;
att.setContextPaths(initialContextPaths);
initialContextApplied.current = true;
}, [initialContextPaths, att]);
const syncExtras = useCallback(() => {
const allForcedTools = att.forcedTools.flatMap((ft) => ft.tools);
const skillList = Object.values(att.attachedSkills);
@@ -1,4 +1,4 @@
import { useEffect, useLayoutEffect, useRef, useState, useCallback } from 'react';
import { useEffect, useRef, useState, useCallback } from 'react';
import { useParams } from 'react-router-dom';
import { useAppDispatch, useAppSelector } from '@/shared/hooks';
import {
@@ -14,8 +14,6 @@ import {
} from '@/shared/state/agentsSlice';
import { fetchModes } from '@/shared/state/modesSlice';
import { createSessionWs } from '@/shared/ws/WebSocketManager';
import type { ChatInputHandle } from '../ChatInput';
import type { ContextPath } from '@/app/components/DirectoryBrowser';
import { setGlowingBrowserCards, fadeGlowingBrowserCards, clearGlowingBrowserCards } from '@/shared/state/dashboardLayoutSlice';
export interface QueuedMessage {
@@ -29,25 +27,19 @@ export interface QueuedMessage {
interface UseAgentChatParams {
sessionId?: string;
initialContextPaths?: ContextPath[];
}
export function useAgentChat({ sessionId: sessionIdProp, initialContextPaths }: UseAgentChatParams) {
export function useAgentChat({ sessionId: sessionIdProp }: UseAgentChatParams) {
const { id: routeId } = useParams<{ id: string }>();
const id = sessionIdProp || routeId;
const dispatch = useAppDispatch();
const session = useAppSelector((state) => (id ? state.agents.sessions[id] : undefined));
const modesMap = useAppSelector((state) => state.modes.items);
const scrollContainerRef = useRef<HTMLDivElement>(null);
const chatInputRef = useRef<ChatInputHandle>(null);
const isAtBottomRef = useRef(true);
const [showScrollButton, setShowScrollButton] = useState(false);
const [showResumeBubble, setShowResumeBubble] = useState(false);
const [awaitingResponse, setAwaitingResponse] = useState(false);
const [mode, setMode] = useState('agent');
const [model, setModel] = useState('sonnet');
const wsRef = useRef<ReturnType<typeof createSessionWs> | null>(null);
const initialContextApplied = useRef(false);
const messageQueueRef = useRef<QueuedMessage[]>([]);
const [queueLength, setQueueLength] = useState(0);
const [editingMessageId, setEditingMessageId] = useState<string | null>(null);
@@ -63,15 +55,6 @@ export function useAgentChat({ sessionId: sessionIdProp, initialContextPaths }:
return () => { ws.disconnect(); 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]);
@@ -137,30 +120,6 @@ export function useAgentChat({ sessionId: sessionIdProp, initialContextPaths }:
if (curr !== 'draft' && !didDispatchQueued) setAwaitingResponse(false);
}, [session?.status, mode, modesMap, id, isDraft, dispatch, dispatchMessage]);
const SCROLL_THRESHOLD = 50;
const handleScroll = useCallback(() => {
const el = scrollContainerRef.current;
if (!el) return;
const atBottom = el.scrollHeight - el.scrollTop - el.clientHeight < SCROLL_THRESHOLD;
isAtBottomRef.current = atBottom;
setShowScrollButton(!atBottom);
}, []);
const scrollToBottom = useCallback(() => {
const el = scrollContainerRef.current;
if (!el) return;
el.scrollTop = el.scrollHeight;
isAtBottomRef.current = true;
setShowScrollButton(false);
}, []);
useLayoutEffect(() => {
if (isAtBottomRef.current) {
const el = scrollContainerRef.current;
if (el) el.scrollTop = el.scrollHeight;
}
}, [session?.messages.length, session?.streamingMessage?.content]);
const handleSend = (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[]) => {
if (!id) return;
const msg: QueuedMessage = { prompt, images, contextPaths, forcedTools, attachedSkills, selectedBrowserIds };
@@ -211,11 +170,9 @@ export function useAgentChat({ sessionId: sessionIdProp, initialContextPaths }:
return {
id, session, isDraft, dispatch, mode, model,
scrollContainerRef, chatInputRef, messageQueueRef,
showScrollButton, showResumeBubble, awaitingResponse, editingMessageId,
messageQueueRef, showResumeBubble, awaitingResponse, editingMessageId,
queueLength, setQueueLength, agentBusy,
handleScroll, scrollToBottom, handleSend,
handleModeChange, handleModelChange,
handleSend, handleModeChange, handleModelChange,
handleApprove, handleDeny, handleStop, handleResume,
handleSaveEdit, handleCancelEdit, setEditingMessageId,
};
@@ -11,7 +11,7 @@ import CloseIcon from '@mui/icons-material/Close';
import TerminalIcon from '@mui/icons-material/Terminal';
import { AgentSession, handleApproval } from '@/shared/state/agentsSlice';
import { useAppDispatch } from '@/shared/hooks';
import { QuestionForm } from '@/app/pages/AgentChat/ApprovalBar';
import { ToolQuestion as QuestionForm } from '@/app/pages/AgentChat/toolkit/approval-tools';
import { parseMcpToolName } from '@/app/pages/AgentChat/ToolCallBubble';
import GoogleServiceIcon from '@/app/components/GoogleServiceIcon';
import { summarizeToolInput, getToolDisplayName } from './agentCardUtils';