diff --git a/frontend/src/app/components/DynamicIsland/CompactActionablePill.tsx b/frontend/src/app/components/DynamicIsland/CompactActionablePill.tsx index 688f52c2..97a84d82 100644 --- a/frontend/src/app/components/DynamicIsland/CompactActionablePill.tsx +++ b/frontend/src/app/components/DynamicIsland/CompactActionablePill.tsx @@ -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'; diff --git a/frontend/src/app/components/DynamicIsland/ExpandedCard.tsx b/frontend/src/app/components/DynamicIsland/ExpandedCard.tsx index 80e75dd7..bda2cfb0 100644 --- a/frontend/src/app/components/DynamicIsland/ExpandedCard.tsx +++ b/frontend/src/app/components/DynamicIsland/ExpandedCard.tsx @@ -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'; diff --git a/frontend/src/app/pages/AgentChat/AgentChat.tsx b/frontend/src/app/pages/AgentChat/AgentChat.tsx index b96dc85f..ad89fb26 100644 --- a/frontend/src/app/pages/AgentChat/AgentChat.tsx +++ b/frontend/src/app/pages/AgentChat/AgentChat.tsx @@ -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 = ({ 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({}); + 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 = ({ sessionId, onClose, embedded, aut + {session.pending_approvals.length > 1 ? ( - + ) : ( session.pending_approvals.map((req) => ( - + )) )} + + {showResumeBubble && session.status === 'stopped' && ( + + Resume + + )} + {isGlowing ? ( { e.stopPropagation(); onDismissGlow?.(); }} @@ -86,10 +109,8 @@ const AgentChat: React.FC = ({ sessionId, onClose, embedded, aut ) : ( - = ({ sessionId, onClose, embedded, aut contextEstimate={contextEstimate} sessionId={id} autoFocus={autoFocus} + initialContextPaths={initialContextPaths} /> )} diff --git a/frontend/src/app/pages/AgentChat/ApprovalBar.tsx b/frontend/src/app/pages/AgentChat/ApprovalBar.tsx deleted file mode 100644 index 86f7cf59..00000000 --- a/frontend/src/app/pages/AgentChat/ApprovalBar.tsx +++ /dev/null @@ -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'; diff --git a/frontend/src/app/pages/AgentChat/AttachedContextSection.tsx b/frontend/src/app/pages/AgentChat/AttachedContextSection.tsx deleted file mode 100644 index 8d0e72ca..00000000 --- a/frontend/src/app/pages/AgentChat/AttachedContextSection.tsx +++ /dev/null @@ -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: , - color: '#3b82f6', - label: `${elements.length} element${elements.length > 1 ? 's' : ''} selected`, - chips: elements.map((el) => ({ - label: el.label, - tooltip: el.selector, - icon: , - })), - }); - } - - 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: , - 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' - ? - : , - }; - }), - }); - } - - const skills = message.attached_skills; - if (skills && skills.length > 0) { - groups.push({ - key: 'skills', - icon: , - color: SKILL_COLOR, - label: `${skills.length} skill${skills.length > 1 ? 's' : ''}`, - chips: skills.map((s) => ({ - label: s.name, - icon: , - })), - }); - } - - const forcedTools = message.forced_tools; - if (forcedTools && forcedTools.length > 0) { - groups.push({ - key: 'tools', - icon: , - color: '#f59e0b', - label: `${forcedTools.length} action${forcedTools.length > 1 ? 's' : ''} requested`, - chips: forcedTools.map((t) => ({ - label: t, - icon: , - })), - }); - } - - return groups; -} - -const AttachedContextSection: React.FC<{ - elements: ParsedElement[]; - message: AgentMessage; - c: ReturnType; -}> = ({ elements, message, c }) => { - const [expanded, setExpanded] = useState(false); - const groups = useMemo(() => buildContextGroups(elements, message), [elements, message]); - - if (groups.length === 0) return null; - - return ( - - setExpanded(!expanded)} - sx={{ - display: 'inline-flex', - alignItems: 'center', - gap: 0.5, - cursor: 'pointer', - mb: 0.5, - '&:hover': { opacity: 0.8 }, - }} - > - {groups.map((g) => ( - - {g.icon} - - ))} - - {groups.map((g) => g.label).join(' · ')} - - - - - {groups.map((g) => ( - - - {g.label} - - - {g.chips.map((chip, i) => ( - - - - ))} - - - ))} - - - ); -}; - -export default AttachedContextSection; diff --git a/frontend/src/app/pages/AgentChat/ToolGroupBubble.tsx b/frontend/src/app/pages/AgentChat/ToolGroupBubble.tsx deleted file mode 100644 index aa37a05b..00000000 --- a/frontend/src/app/pages/AgentChat/ToolGroupBubble.tsx +++ /dev/null @@ -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 ( - - ); -}; - -const SkeletonPulse: React.FC<{ width: number; height: number; borderRadius?: number }> = ({ width, height, borderRadius = 4 }) => ( - -); - -interface Props { - group: ToolGroup; - isSessionRunning?: boolean; - meta?: ToolGroupMeta; - sessionId?: string; -} - -const ToolGroupBubble: React.FC = 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 ( - - - 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 ? ( - - ) : hasSvg ? ( - - ) : ( - - )} - - {!meta ? ( - - - - ) : ( - - {displayName} - - )} - - {deniedCount > 0 && ( - - {deniedCount} denied - - )} - {allDone && completedCount > 0 && ( - - - - {completedCount}/{group.callCount} - - - )} - {!allDone && pendingCount > 0 && ( - - {completedCount}/{group.callCount} - - )} - - - {expanded ? : } - - - - - - {group.pairs.map((pair) => ( - - ))} - - - - - ); -}); - -export default ToolGroupBubble; diff --git a/frontend/src/app/pages/AgentChat/composer/OpenSwarmComposer.tsx b/frontend/src/app/pages/AgentChat/composer/OpenSwarmComposer.tsx index 5c1e3b71..175cfb92 100644 --- a/frontend/src/app/pages/AgentChat/composer/OpenSwarmComposer.tsx +++ b/frontend/src/app/pages/AgentChat/composer/OpenSwarmComposer.tsx @@ -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 = ({ 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 = ({ const [selectedTemplate, setSelectedTemplate] = useState(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); diff --git a/frontend/src/app/pages/AgentChat/hooks/useAgentChat.ts b/frontend/src/app/pages/AgentChat/hooks/useAgentChat.ts index 675fae77..021109a3 100644 --- a/frontend/src/app/pages/AgentChat/hooks/useAgentChat.ts +++ b/frontend/src/app/pages/AgentChat/hooks/useAgentChat.ts @@ -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(null); - const chatInputRef = useRef(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 | null>(null); - const initialContextApplied = useRef(false); const messageQueueRef = useRef([]); const [queueLength, setQueueLength] = useState(0); const [editingMessageId, setEditingMessageId] = useState(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, }; diff --git a/frontend/src/app/pages/Dashboard/AgentCardCollapsed.tsx b/frontend/src/app/pages/Dashboard/AgentCardCollapsed.tsx index 8e0f1cb0..d1a6a74c 100644 --- a/frontend/src/app/pages/Dashboard/AgentCardCollapsed.tsx +++ b/frontend/src/app/pages/Dashboard/AgentCardCollapsed.tsx @@ -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';