[Haik]: Agentic refactor 2. Thread and Messages

This commit is contained in:
haikdc
2026-03-30 14:18:44 -07:00
parent 3066f8647c
commit 46c100dec7
18 changed files with 497 additions and 1186 deletions
+5 -108
View File
@@ -1,22 +1,13 @@
import React, { useCallback } from 'react';
import React from 'react';
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 KeyboardArrowDownIcon from '@mui/icons-material/KeyboardArrowDown';
import { AssistantRuntimeProvider, useAui, Tools } from '@assistant-ui/react';
import { AgentMessage, editMessage, switchBranch, duplicateSession, setActiveSession } from '@/shared/state/agentsSlice';
import MessageBubble from './MessageBubble';
import MessageActionBar from './MessageActionBar';
import ToolCallBubble from './ToolCallBubble';
import ToolGroupBubble, { isToolGroup, isToolPair } from './ToolGroupBubble';
import ApprovalBar, { BatchApprovalBar } from './ApprovalBar';
import ChatInput from './ChatInput';
import ChatHeader from './ChatHeader';
import MessageQueue from './MessageQueue';
import StreamingSection from './StreamingSection';
import OpenSwarmThread from './thread/OpenSwarmThread';
import { useAgentChat } from './hooks/useAgentChat';
import { useMessageRendering } from './hooks/useMessageRendering';
import { useOpenSwarmRuntime } from './runtime/useOpenSwarmRuntime';
import { toolkit } from './toolkit';
import { ContextPath } from '@/app/components/DirectoryBrowser';
@@ -49,33 +40,7 @@ const AgentChat: React.FC<AgentChatProps> = ({ sessionId, onClose, embedded, aut
const runtime = useOpenSwarmRuntime(id);
const aui = useAui({ tools: Tools({ toolkit }) });
const { activeBranchMessages, renderItems, lastAssistantIdsInTurn, getSiblingBranches, contextEstimate } = useMessageRendering(session, model, id, isDraft);
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 = { used: 0, limit: 200_000 };
if (!session) {
return (
@@ -84,81 +49,13 @@ const AgentChat: React.FC<AgentChatProps> = ({ sessionId, onClose, embedded, aut
</Box>
);
}
const sessionRunning = session.status === 'running' || session.status === 'waiting_approval';
return (
<AssistantRuntimeProvider runtime={runtime} aui={aui}>
<Box sx={{ display: 'flex', height: '100%' }}>
<Box sx={{ display: 'flex', flexDirection: 'column', flex: 1, minWidth: 0, overflow: 'hidden' }}>
{!embedded && <ChatHeader session={session} isDraft={isDraft} id={id} onClose={onClose} />}
<Box sx={{ flex: 1, minHeight: 0, position: 'relative' }}>
<Box
ref={scrollContainerRef}
onScroll={handleScroll}
sx={{
height: '100%', overflow: 'auto', px: 2, py: 1,
'&::-webkit-scrollbar': { width: 6 },
'&::-webkit-scrollbar-track': { background: 'transparent' },
'&::-webkit-scrollbar-thumb': {
background: c.border.medium, borderRadius: 3,
'&:hover': { background: c.border.strong },
},
scrollbarWidth: 'thin', scrollbarColor: `${c.border.medium} transparent`,
}}
>
{renderItems.map((item) => {
if (isToolGroup(item)) {
const groupMeta = session.tool_group_meta?.[item.id];
return <ToolGroupBubble key={item.id} group={item} isSessionRunning={sessionRunning} meta={groupMeta} sessionId={session.id} />;
}
if (isToolPair(item)) {
const isPending = item.result === null && sessionRunning;
return <ToolCallBubble key={item.id} call={item.call} result={item.result} isPending={isPending} sessionId={session.id} />;
}
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} sx={{ '&:hover .msg-actions': { opacity: 1 } }}>
<MessageBubble message={msg} editing={isEditing} onSaveEdit={handleSaveEdit} onCancelEdit={handleCancelEdit} />
{!isEditing && (msg.role === 'user' || (msg.role === 'assistant' && lastAssistantIdsInTurn.has(msg.id))) && (
<MessageActionBar
role={msg.role as 'user' | 'assistant'}
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,
onPrevious: () => { const b = siblings[Math.max(0, currentBranchIdx - 1)]; if (b && id) dispatch(switchBranch({ sessionId: id, branchId: b })); },
onNext: () => { const b = siblings[Math.min(siblings.length - 1, currentBranchIdx + 1)]; if (b && id) dispatch(switchBranch({ sessionId: id, branchId: b })); },
} : undefined}
/>
)}
</Box>
);
})}
<StreamingSection session={session} awaitingResponse={awaitingResponse} showResumeBubble={showResumeBubble} handleResume={handleResume} />
</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 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} />
@@ -1,128 +0,0 @@
import React from 'react';
import Box from '@mui/material/Box';
import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
import { useClaudeTokens } from '@/shared/styles/ThemeContext';
const streamingCursorKeyframes = `
@keyframes blink-cursor {
0%, 100% { opacity: 1; }
50% { opacity: 0; }
}
`;
const StreamingCursor: React.FC = () => {
const c = useClaudeTokens();
return (
<>
<style>{streamingCursorKeyframes}</style>
<span
style={{
display: 'inline-block',
width: 2,
height: '1em',
background: c.accent.primary,
marginLeft: 2,
verticalAlign: 'text-bottom',
animation: 'blink-cursor 0.8s step-end infinite',
}}
/>
</>
);
};
interface Props {
rawText: string;
isStreaming?: boolean;
}
const AssistantBubbleContent: React.FC<Props> = ({ rawText, isStreaming }) => {
const c = useClaudeTokens();
return (
<Box
sx={{
color: c.text.secondary,
fontSize: '0.875rem',
lineHeight: 1.7,
overflowWrap: 'anywhere',
wordBreak: 'break-word',
'& p': { m: 0, mb: 1, '&:last-child': { mb: 0 } },
'& pre': {
bgcolor: c.bg.secondary,
borderRadius: 1.5,
p: 1.5,
overflow: 'auto',
fontSize: '0.8rem',
fontFamily: c.font.mono,
border: `1px solid ${c.border.subtle}`,
'&::-webkit-scrollbar': { height: 5, width: 5 },
'&::-webkit-scrollbar-track': { background: 'transparent' },
'&::-webkit-scrollbar-thumb': {
background: c.border.medium,
borderRadius: 3,
'&:hover': { background: c.border.strong },
},
scrollbarWidth: 'thin',
scrollbarColor: `${c.border.medium} transparent`,
},
'& code': {
bgcolor: c.bg.secondary,
px: 0.5,
py: 0.25,
borderRadius: 0.5,
fontSize: '0.8rem',
fontFamily: c.font.mono,
},
'& pre code': { bgcolor: 'transparent', p: 0 },
'& table': {
width: '100%',
borderCollapse: 'collapse',
my: 1.5,
fontSize: '0.82rem',
border: `1px solid ${c.border.subtle}`,
borderRadius: 1,
overflow: 'hidden',
},
'& thead': {
bgcolor: c.bg.secondary,
},
'& th': {
textAlign: 'left',
fontWeight: 600,
color: c.text.primary,
px: 1.5,
py: 0.75,
borderBottom: `1.5px solid ${c.border.medium}`,
whiteSpace: 'nowrap',
},
'& td': {
px: 1.5,
py: 0.6,
borderBottom: `0.5px solid ${c.border.subtle}`,
verticalAlign: 'top',
},
'& tr:last-child td': {
borderBottom: 'none',
},
'& tbody tr:hover': {
bgcolor: `${c.bg.secondary}80`,
},
'& ul, & ol': { pl: 2.5, mb: 1 },
'& li': { mb: 0.25 },
'& a': { color: c.accent.primary },
}}
>
<ReactMarkdown
remarkPlugins={[remarkGfm]}
components={{
a: ({ children, ...props }) => (
<a {...props} style={{ cursor: 'pointer' }}>{children}</a>
),
}}
>{rawText}</ReactMarkdown>
{isStreaming && <StreamingCursor />}
</Box>
);
};
export default AssistantBubbleContent;
@@ -13,7 +13,12 @@ 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';
import { ParsedElement } from './messageBubbleUtils';
interface ParsedElement {
label: string;
selector: string;
isSemantic?: boolean;
}
interface ContextGroup {
key: string;
@@ -1,60 +0,0 @@
import React from 'react';
import Box from '@mui/material/Box';
import Typography from '@mui/material/Typography';
import IconButton from '@mui/material/IconButton';
import ChevronLeftIcon from '@mui/icons-material/ChevronLeft';
import ChevronRightIcon from '@mui/icons-material/ChevronRight';
import { useClaudeTokens } from '@/shared/styles/ThemeContext';
interface Props {
currentIndex: number;
totalBranches: number;
onPrevious: () => void;
onNext: () => void;
}
const BranchNavigator: React.FC<Props> = ({ currentIndex, totalBranches, onPrevious, onNext }) => {
const c = useClaudeTokens();
if (totalBranches <= 1) return null;
return (
<Box
sx={{
display: 'flex',
justifyContent: 'flex-end',
mt: -0.25,
mb: 0.5,
}}
>
<Box
sx={{
display: 'flex',
alignItems: 'center',
gap: 0.25,
}}
>
<IconButton
size="small"
onClick={onPrevious}
disabled={currentIndex === 0}
sx={{ color: c.text.tertiary, p: 0.25, '&.Mui-disabled': { color: c.border.medium } }}
>
<ChevronLeftIcon sx={{ fontSize: 16 }} />
</IconButton>
<Typography sx={{ color: c.text.tertiary, fontSize: '0.7rem', minWidth: 28, textAlign: 'center', userSelect: 'none' }}>
{currentIndex + 1} / {totalBranches}
</Typography>
<IconButton
size="small"
onClick={onNext}
disabled={currentIndex === totalBranches - 1}
sx={{ color: c.text.tertiary, p: 0.25, '&.Mui-disabled': { color: c.border.medium } }}
>
<ChevronRightIcon sx={{ fontSize: 16 }} />
</IconButton>
</Box>
</Box>
);
};
export default BranchNavigator;
@@ -1,153 +0,0 @@
import React, { useState } from 'react';
import Box from '@mui/material/Box';
import IconButton from '@mui/material/IconButton';
import Typography from '@mui/material/Typography';
import Tooltip from '@mui/material/Tooltip';
import ContentCopyIcon from '@mui/icons-material/ContentCopy';
import CheckIcon from '@mui/icons-material/Check';
import EditIcon from '@mui/icons-material/Edit';
import BookmarkBorderIcon from '@mui/icons-material/BookmarkBorder';
import ReplayIcon from '@mui/icons-material/Replay';
import CallSplitIcon from '@mui/icons-material/CallSplit';
import ChevronLeftIcon from '@mui/icons-material/ChevronLeft';
import ChevronRightIcon from '@mui/icons-material/ChevronRight';
import { useClaudeTokens } from '@/shared/styles/ThemeContext';
interface BranchNavProps {
currentIndex: number;
totalBranches: number;
onPrevious: () => void;
onNext: () => void;
}
interface Props {
role: 'user' | 'assistant';
onCopy: () => void;
onEdit?: () => void;
onRegenerate?: () => void;
onBranch?: () => void;
branchNav?: BranchNavProps;
}
const btnSx = (c: ReturnType<typeof useClaudeTokens>) => ({
color: c.text.tertiary,
p: 0.4,
'&:hover': { color: c.text.secondary, bgcolor: 'transparent' },
'&.Mui-disabled': { color: c.border.medium },
});
const MessageActionBar: React.FC<Props> = ({
role,
onCopy,
onEdit,
onRegenerate,
onBranch,
branchNav,
}) => {
const c = useClaudeTokens();
const [copied, setCopied] = useState(false);
const handleCopy = () => {
onCopy();
setCopied(true);
setTimeout(() => setCopied(false), 1500);
};
const isUser = role === 'user';
return (
<Box
className="msg-actions"
sx={{
display: 'flex',
alignItems: 'center',
justifyContent: isUser ? 'flex-end' : 'flex-start',
gap: 0,
opacity: 0,
transition: 'opacity 0.15s',
mt: -0.25,
mb: 0.25,
minHeight: 28,
}}
>
{isUser ? (
<>
<Tooltip title="Coming soon" arrow>
<span>
<IconButton size="small" disabled sx={btnSx(c)}>
<BookmarkBorderIcon sx={{ fontSize: 16 }} />
</IconButton>
</span>
</Tooltip>
<Tooltip title={copied ? 'Copied!' : 'Copy'} arrow>
<IconButton size="small" onClick={handleCopy} sx={btnSx(c)}>
{copied ? <CheckIcon sx={{ fontSize: 16 }} /> : <ContentCopyIcon sx={{ fontSize: 16 }} />}
</IconButton>
</Tooltip>
{onEdit && (
<Tooltip title="Edit" arrow>
<IconButton size="small" onClick={onEdit} sx={btnSx(c)}>
<EditIcon sx={{ fontSize: 16 }} />
</IconButton>
</Tooltip>
)}
{branchNav && branchNav.totalBranches > 1 && (
<Box sx={{ display: 'inline-flex', alignItems: 'center', ml: 0.25 }}>
<IconButton
size="small"
onClick={branchNav.onPrevious}
disabled={branchNav.currentIndex === 0}
sx={btnSx(c)}
>
<ChevronLeftIcon sx={{ fontSize: 16 }} />
</IconButton>
<Typography
sx={{
color: c.text.tertiary,
fontSize: '0.7rem',
minWidth: 28,
textAlign: 'center',
userSelect: 'none',
}}
>
{branchNav.currentIndex + 1} / {branchNav.totalBranches}
</Typography>
<IconButton
size="small"
onClick={branchNav.onNext}
disabled={branchNav.currentIndex === branchNav.totalBranches - 1}
sx={btnSx(c)}
>
<ChevronRightIcon sx={{ fontSize: 16 }} />
</IconButton>
</Box>
)}
</>
) : (
<>
<Tooltip title={copied ? 'Copied!' : 'Copy'} arrow>
<IconButton size="small" onClick={handleCopy} sx={btnSx(c)}>
{copied ? <CheckIcon sx={{ fontSize: 16 }} /> : <ContentCopyIcon sx={{ fontSize: 16 }} />}
</IconButton>
</Tooltip>
{onRegenerate && (
<Tooltip title="Regenerate" arrow>
<IconButton size="small" onClick={onRegenerate} sx={btnSx(c)}>
<ReplayIcon sx={{ fontSize: 16 }} />
</IconButton>
</Tooltip>
)}
{onBranch && (
<Tooltip title="Branch chat" arrow>
<IconButton size="small" onClick={onBranch} sx={btnSx(c)}>
<CallSplitIcon sx={{ fontSize: 16 }} />
</IconButton>
</Tooltip>
)}
</>
)}
</Box>
);
};
export default MessageActionBar;
@@ -1,99 +0,0 @@
import React from 'react';
import Box from '@mui/material/Box';
import Typography from '@mui/material/Typography';
import { useClaudeTokens } from '@/shared/styles/ThemeContext';
import { parseElementContext, MessageBubbleProps } from './messageBubbleUtils';
import UserBubbleContent from './UserBubbleContent';
import AssistantBubbleContent from './AssistantBubbleContent';
import ViewBubble from './ViewBubble';
const MessageBubble: React.FC<MessageBubbleProps> = React.memo(({ message, editing = false, onSaveEdit, onCancelEdit, isStreaming }) => {
const c = useClaudeTokens();
const { role, content } = message;
if (role === 'system') {
return (
<Box sx={{ display: 'flex', justifyContent: 'center', my: 1 }}>
<Typography sx={{ color: c.text.ghost, fontSize: '0.8rem', fontStyle: 'italic' }}>
{typeof content === 'string' ? content : JSON.stringify(content)}
</Typography>
</Box>
);
}
if (role === 'tool_call') {
const toolData = typeof content === 'object' ? content : {};
const toolInput = toolData.input || {};
if (toolData.tool === 'RenderOutput') {
return <ViewBubble toolInput={toolInput} isStreaming={isStreaming} />;
}
return null;
}
if (role === 'tool_result') {
let parsedContent: any = null;
try { parsedContent = typeof content === 'string' ? JSON.parse(content) : content; } catch {}
if (parsedContent?.output_id && parsedContent?.frontend_code) {
return (
<ViewBubble
toolInput={{ output_id: parsedContent.output_id, input_data: parsedContent.input_data || {} }}
toolResult={parsedContent}
/>
);
}
return null;
}
const isUser = role === 'user';
const rawText = typeof content === 'string' ? content : JSON.stringify(content);
const { userMessage: displayText, elements: selectedElements } = isUser
? parseElementContext(rawText)
: { userMessage: rawText, elements: [] };
const truncatedContent = typeof content === 'string'
? content.slice(0, 200)
: JSON.stringify(content).slice(0, 200);
return (
<Box
data-select-type="message"
data-select-id={message.id}
data-select-meta={JSON.stringify({ role, content: truncatedContent })}
sx={{
display: 'flex',
justifyContent: isUser ? 'flex-end' : 'flex-start',
my: 0.75,
}}
>
<Box
sx={{
maxWidth: '85%',
minWidth: 0,
bgcolor: isUser ? c.user.bubble : c.bg.surface,
border: isUser ? 'none' : `1px solid ${c.border.subtle}`,
borderRadius: isUser ? '16px 16px 4px 16px' : '16px 16px 16px 4px',
px: 2,
py: 1.25,
boxShadow: isUser ? 'none' : c.shadow.sm,
overflow: 'hidden',
}}
>
{isUser ? (
<UserBubbleContent
message={message}
displayText={displayText}
rawText={rawText}
selectedElements={selectedElements}
editing={editing}
onSaveEdit={onSaveEdit}
onCancelEdit={onCancelEdit}
/>
) : (
<AssistantBubbleContent rawText={rawText} isStreaming={isStreaming} />
)}
</Box>
</Box>
);
});
export default MessageBubble;
@@ -1,108 +0,0 @@
import React, { useState } from 'react';
import Box from '@mui/material/Box';
import IconButton from '@mui/material/IconButton';
import Modal from '@mui/material/Modal';
import CloseIcon from '@mui/icons-material/Close';
import { useClaudeTokens } from '@/shared/styles/ThemeContext';
const ImageLightbox: React.FC<{
open: boolean;
src: string;
onClose: () => void;
c: ReturnType<typeof useClaudeTokens>;
}> = ({ open, src, onClose, c }) => (
<Modal open={open} onClose={onClose} sx={{ display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<Box
onClick={onClose}
sx={{
position: 'relative',
outline: 'none',
maxWidth: '90vw',
maxHeight: '90vh',
}}
>
<IconButton
onClick={onClose}
sx={{
position: 'absolute',
top: -16,
right: -16,
bgcolor: c.bg.surface,
border: `1px solid ${c.border.medium}`,
color: c.text.secondary,
width: 32,
height: 32,
zIndex: 1,
'&:hover': { bgcolor: c.bg.secondary },
boxShadow: c.shadow.md,
}}
>
<CloseIcon sx={{ fontSize: 16 }} />
</IconButton>
<img
src={src}
alt=""
onClick={(e) => e.stopPropagation()}
style={{
maxWidth: '90vw',
maxHeight: '90vh',
borderRadius: 8,
boxShadow: '0 8px 32px rgba(0,0,0,0.4)',
display: 'block',
}}
/>
</Box>
</Modal>
);
interface Props {
images: Array<{ data: string; media_type: string }>;
c: ReturnType<typeof useClaudeTokens>;
}
const MessageImageThumbnails: React.FC<Props> = ({ images, c }) => {
const [lightboxSrc, setLightboxSrc] = useState<string | null>(null);
if (images.length === 0) return null;
return (
<>
<Box sx={{ display: 'flex', gap: 0.75, mb: 1, flexWrap: 'wrap' }}>
{images.map((img, idx) => {
const src = `data:${img.media_type};base64,${img.data}`;
return (
<Box
key={idx}
onClick={() => setLightboxSrc(src)}
sx={{
width: 64,
height: 64,
flexShrink: 0,
borderRadius: '8px',
overflow: 'hidden',
border: `1px solid ${c.border.subtle}`,
cursor: 'pointer',
transition: 'opacity 0.15s, transform 0.15s',
'&:hover': { opacity: 0.85, transform: 'scale(1.04)' },
}}
>
<img
src={src}
alt=""
style={{ width: '100%', height: '100%', objectFit: 'cover', display: 'block' }}
/>
</Box>
);
})}
</Box>
<ImageLightbox
open={!!lightboxSrc}
src={lightboxSrc || ''}
onClose={() => setLightboxSrc(null)}
c={c}
/>
</>
);
};
export default MessageImageThumbnails;
@@ -1,68 +0,0 @@
import React from 'react';
import Box from '@mui/material/Box';
import Typography from '@mui/material/Typography';
import PlayArrowIcon from '@mui/icons-material/PlayArrow';
import ToolCallBubble from './ToolCallBubble';
import MessageBubble from './MessageBubble';
import ThinkingBubble from './ThinkingBubble';
import { useClaudeTokens } from '@/shared/styles/ThemeContext';
interface StreamingSectionProps {
session: any;
awaitingResponse: boolean;
showResumeBubble: boolean;
handleResume: () => void;
}
const StreamingSection: React.FC<StreamingSectionProps> = ({ session, awaitingResponse, showResumeBubble, handleResume }) => {
const c = useClaudeTokens();
return (
<>
{session.streamingMessage && (
session.streamingMessage.role === 'tool_call' ? (
<ToolCallBubble
key={`streaming-${session.streamingMessage.id}`}
isStreaming
isPending
sessionId={session.id}
call={{
id: session.streamingMessage.id, role: 'tool_call',
content: { tool: session.streamingMessage.tool_name || '', input: session.streamingMessage.content },
timestamp: new Date().toISOString(), branch_id: session.active_branch_id || 'main', parent_id: null,
}}
/>
) : (
<MessageBubble
key={`streaming-${session.streamingMessage.id}`}
isStreaming
message={{
id: session.streamingMessage.id, role: session.streamingMessage.role,
content: session.streamingMessage.content, timestamp: new Date().toISOString(),
branch_id: session.active_branch_id || 'main', parent_id: null,
}}
/>
)
)}
{(awaitingResponse || (session.status === 'running' && !session.streamingMessage)) && <ThinkingBubble />}
{showResumeBubble && session.status === 'stopped' && (
<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>
)}
</>
);
};
export default StreamingSection;
@@ -1,55 +0,0 @@
import React from 'react';
import Box from '@mui/material/Box';
import { useClaudeTokens } from '@/shared/styles/ThemeContext';
export const CONTEXT_WINDOWS: Record<string, number> = {
sonnet: 200_000,
opus: 200_000,
haiku: 200_000,
};
const thinkingDotsKeyframes = `
@keyframes thinking-bounce {
0%, 80%, 100% { transform: scale(0); opacity: 0.4; }
40% { transform: scale(1); opacity: 1; }
}
`;
const ThinkingBubble: React.FC = () => {
const c = useClaudeTokens();
return (
<Box sx={{ display: 'flex', justifyContent: 'flex-start', my: 0.75 }}>
<style>{thinkingDotsKeyframes}</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',
gap: '4px',
minHeight: 36,
}}
>
{[0, 1, 2].map((i) => (
<Box
key={i}
sx={{
width: 7,
height: 7,
borderRadius: '50%',
bgcolor: c.text.tertiary,
animation: 'thinking-bounce 1.4s infinite ease-in-out both',
animationDelay: `${i * 0.16}s`,
}}
/>
))}
</Box>
</Box>
);
};
export default ThinkingBubble;
@@ -1,152 +0,0 @@
import React, { useState, useEffect } from 'react';
import Box from '@mui/material/Box';
import Typography from '@mui/material/Typography';
import TextField from '@mui/material/TextField';
import Button from '@mui/material/Button';
import Chip from '@mui/material/Chip';
import PsychologyOutlinedIcon from '@mui/icons-material/PsychologyOutlined';
import { AgentMessage } from '@/shared/state/agentsSlice';
import { useClaudeTokens } from '@/shared/styles/ThemeContext';
import { SKILL_COLOR } from '@/app/components/richEditorUtils';
import { ParsedElement, SKILL_PILL_RE } from './messageBubbleUtils';
import AttachedContextSection from './AttachedContextSection';
import MessageImageThumbnails from './MessageImageThumbnails';
function renderUserTextWithPills(text: string, c: ReturnType<typeof useClaudeTokens>): React.ReactNode[] {
const parts: React.ReactNode[] = [];
let lastIndex = 0;
let match: RegExpExecArray | null;
const re = new RegExp(SKILL_PILL_RE.source, 'g');
while ((match = re.exec(text)) !== null) {
if (match.index > lastIndex) {
parts.push(text.slice(lastIndex, match.index));
}
const skillName = match[1];
parts.push(
<Chip
key={`skill-${match.index}`}
icon={<PsychologyOutlinedIcon sx={{ fontSize: 12 }} />}
label={skillName}
size="small"
sx={{
bgcolor: `${SKILL_COLOR}18`,
color: SKILL_COLOR,
fontSize: '0.72rem',
fontFamily: c.font.mono,
height: 20,
mx: 0.25,
verticalAlign: 'baseline',
'& .MuiChip-icon': { color: SKILL_COLOR },
}}
/>,
);
lastIndex = re.lastIndex;
}
if (lastIndex < text.length) {
parts.push(text.slice(lastIndex));
}
return parts;
}
interface Props {
message: AgentMessage;
displayText: string;
rawText: string;
selectedElements: ParsedElement[];
editing: boolean;
onSaveEdit?: (messageId: string, newContent: string) => void;
onCancelEdit?: () => void;
}
const UserBubbleContent: React.FC<Props> = ({
message, displayText, rawText, selectedElements, editing, onSaveEdit, onCancelEdit,
}) => {
const c = useClaudeTokens();
const [editText, setEditText] = useState('');
useEffect(() => {
if (editing) setEditText(rawText);
}, [editing, rawText]);
const handleCancelEdit = () => {
setEditText('');
onCancelEdit?.();
};
const handleSaveEdit = () => {
const trimmed = editText.trim();
if (trimmed && trimmed !== rawText && onSaveEdit) {
onSaveEdit(message.id, trimmed);
}
setEditText('');
onCancelEdit?.();
};
if (editing) {
return (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1, minWidth: 240 }}>
<TextField
multiline
fullWidth
value={editText}
onChange={(e) => setEditText(e.target.value)}
variant="outlined"
size="small"
autoFocus
onKeyDown={(e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
handleSaveEdit();
}
if (e.key === 'Escape') handleCancelEdit();
}}
sx={{
'& .MuiOutlinedInput-root': {
color: c.text.primary,
fontSize: '0.875rem',
'& fieldset': { borderColor: c.border.strong },
'&:hover fieldset': { borderColor: c.text.tertiary },
'&.Mui-focused fieldset': { borderColor: c.accent.primary },
},
}}
/>
<Box sx={{ display: 'flex', gap: 1, justifyContent: 'flex-end' }}>
<Button
size="small"
onClick={handleCancelEdit}
sx={{ color: c.text.muted, fontSize: '0.75rem' }}
>
Cancel
</Button>
<Button
size="small"
variant="contained"
onClick={handleSaveEdit}
disabled={!editText.trim() || editText.trim() === rawText}
sx={{
bgcolor: c.accent.primary,
fontSize: '0.75rem',
'&:hover': { bgcolor: c.accent.hover },
}}
>
Save & Submit
</Button>
</Box>
</Box>
);
}
return (
<Box>
{message.images && message.images.length > 0 && (
<MessageImageThumbnails images={message.images} c={c} />
)}
<Typography sx={{ color: c.text.primary, fontSize: '0.875rem', lineHeight: 1.6, overflowWrap: 'anywhere', wordBreak: 'break-word' }}>
{renderUserTextWithPills(displayText, c)}
</Typography>
<AttachedContextSection elements={selectedElements} message={message} c={c} />
</Box>
);
};
export default UserBubbleContent;
@@ -1,196 +0,0 @@
import { useMemo, useCallback, useEffect, useRef } from 'react';
import { useAppDispatch } from '@/shared/hooks';
import { type AgentMessage, generateGroupMeta } from '@/shared/state/agentsSlice';
import type { ToolPair } from '../ToolCallBubble';
import { type RenderItem, type ToolGroup, isToolGroup, isToolPair } from '../ToolGroupBubble';
import { CONTEXT_WINDOWS } from '../ThinkingBubble';
function stringifyContent(content: any): string {
if (content == null) return '';
return typeof content === 'string' ? content : JSON.stringify(content);
}
export function useMessageRendering(session: any, model: string, id: string | undefined, isDraft: boolean) {
const dispatch = useAppDispatch();
const activeBranchMessages: AgentMessage[] = 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: AgentMessage) => 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: AgentMessage[] = [];
for (let i = 0; i < segments.length; i++) {
const seg = segments[i];
const nextForkMsgId = seg.upToMessageId;
if (nextForkMsgId) {
const forkIdx = session.messages.findIndex((m: AgentMessage) => m.id === nextForkMsgId);
result.push(...session.messages.slice(0, forkIdx).filter((m: AgentMessage) => m.branch_id === seg.branchId));
} else if (i < segments.length - 1) {
const nextFork = segments[i + 1].upToMessageId;
const forkIdx = nextFork ? session.messages.findIndex((m: AgentMessage) => m.id === nextFork) : session.messages.length;
result.push(...session.messages.slice(0, forkIdx).filter((m: AgentMessage) => m.branch_id === seg.branchId));
} else {
result.push(...session.messages.filter((m: AgentMessage) => m.branch_id === seg.branchId));
}
}
const leafMsgs = session.messages.filter((m: AgentMessage) => m.branch_id === branchId);
if (!result.some((m: AgentMessage) => m.branch_id === branchId)) result.push(...leafMsgs);
return result;
}, [session?.messages, session?.active_branch_id, session?.branches]);
const renderItems: RenderItem[] = useMemo(() => {
const isOutputCall = (m: AgentMessage) =>
m.role === 'tool_call' && typeof m.content === 'object' && m.content.tool === 'RenderOutput';
const isOutputResult = (m: AgentMessage) => {
if (m.role !== 'tool_result') return false;
try {
const parsed = typeof m.content === 'string' ? JSON.parse(m.content) : m.content;
return !!(parsed?.output_id && parsed?.frontend_code);
} catch { return false; }
};
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: AgentMessage[] = [];
while (i < activeBranchMessages.length && (activeBranchMessages[i].role === 'tool_call' || activeBranchMessages[i].role === 'tool_result')) {
group.push(activeBranchMessages[i]);
i++;
}
const regular: AgentMessage[] = [];
const outputItems: AgentMessage[] = [];
for (const m of group) {
if (isOutputCall(m) || isOutputResult(m)) { outputItems.push(m); continue; }
regular.push(m);
}
const calls = regular.filter((m) => m.role === 'tool_call');
const results = regular.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);
}
items.push(...outputItems);
} else {
if (!msg.hidden) items.push(msg);
i++;
}
}
return items;
}, [activeBranchMessages]);
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(new Set<string>());
const groupMetaRefinedRef = useRef(new Set<string>());
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 : '';
return { tool, input_summary: (typeof input === 'string' ? input : JSON.stringify(input)).slice(0, 120) };
});
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 : '';
return { tool, input_summary: (typeof input === 'string' ? input : JSON.stringify(input)).slice(0, 120) };
});
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: any) => b.fork_point_message_id === messageId).map((b: any) => b.id);
if (directForks.length > 0) {
const originalMsg = session.messages.find((m: AgentMessage) => m.id === messageId);
return [originalMsg?.branch_id || 'main', ...directForks];
}
const msg = session.messages.find((m: AgentMessage) => 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: AgentMessage) => 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: any) => b.fork_point_message_id === forkPointId).map((b: any) => b.id);
return [msgBranch.parent_branch_id || 'main', ...siblingBranches];
}, [session?.branches, session?.messages]);
const contextEstimate = useMemo(() => {
const limit = CONTEXT_WINDOWS[model] || 200_000;
let chars = (session?.system_prompt || '').length;
for (const msg of activeBranchMessages) chars += stringifyContent(msg.content).length;
if (session?.streamingMessage) chars += (session.streamingMessage.content || '').length;
return { used: Math.round(chars / 4), limit };
}, [activeBranchMessages, session?.system_prompt, session?.streamingMessage?.content, model]);
return { activeBranchMessages, renderItems, lastAssistantIdsInTurn, getSiblingBranches, contextEstimate };
}
@@ -1,54 +0,0 @@
import { AgentMessage } from '@/shared/state/agentsSlice';
export const ELEMENT_SEPARATOR = '\n\n---\nSelected UI Elements:\n';
export interface ParsedElement {
label: string;
selector: string;
isSemantic?: boolean;
}
export function parseElementContext(text: string): { userMessage: string; elements: ParsedElement[] } {
const sepIdx = text.indexOf(ELEMENT_SEPARATOR);
if (sepIdx === -1) return { userMessage: text, elements: [] };
const userMessage = text.slice(0, sepIdx);
const elementSection = text.slice(sepIdx + ELEMENT_SEPARATOR.length);
const elements: ParsedElement[] = [];
const blocks = elementSection.split(/\n(?=\d+\.\s)/).filter(Boolean);
for (const block of blocks) {
const semanticMatch = block.match(/\d+\.\s+\[([^\]]+)\]\s*(.*)/);
if (semanticMatch) {
const typeLabel = semanticMatch[1];
const rest = semanticMatch[2].trim();
elements.push({
label: `${typeLabel}: ${rest.split('\n')[0]}`,
selector: typeLabel,
isSemantic: true,
});
continue;
}
const labelMatch = block.match(/`([^`]+)`\s+\((\w+)\)/);
const selectorMatch = block.match(/Selector:\s*(.+)/);
if (labelMatch) {
elements.push({
label: labelMatch[1],
selector: selectorMatch?.[1]?.trim() ?? labelMatch[1],
});
}
}
return { userMessage, elements };
}
export const SKILL_PILL_RE = /\{\{skill:([^}]+)\}\}/g;
export interface MessageBubbleProps {
message: AgentMessage;
editing?: boolean;
onSaveEdit?: (messageId: string, newContent: string) => void;
onCancelEdit?: () => void;
isStreaming?: boolean;
}
@@ -0,0 +1,40 @@
import { type FC } from 'react';
import { MessagePrimitive, ErrorPrimitive } from '@assistant-ui/react';
import { MarkdownText } from '@/components/assistant-ui/markdown-text';
import { ToolFallback } from '@/components/assistant-ui/tool-fallback';
import { AssistantActionBar } from './MessageActions';
import { BranchPicker } from './BranchPicker';
export const AssistantMessage: FC = () => {
return (
<MessagePrimitive.Root
className="aui-assistant-message-root relative mx-auto w-full max-w-(--thread-max-width) py-3"
data-role="assistant"
>
<div className="aui-assistant-message-content wrap-break-word px-2 text-foreground leading-relaxed">
<MessagePrimitive.Parts>
{({ part }) => {
if (part.type === 'text') return <MarkdownText />;
if (part.type === 'tool-call')
return part.toolUI ?? <ToolFallback {...part} />;
return null;
}}
</MessagePrimitive.Parts>
<MessageError />
</div>
<div className="aui-assistant-message-footer mt-1 ml-2 flex min-h-6 items-center">
<BranchPicker />
<AssistantActionBar />
</div>
</MessagePrimitive.Root>
);
};
const MessageError: FC = () => (
<MessagePrimitive.Error>
<ErrorPrimitive.Root className="aui-message-error-root mt-2 rounded-md border border-destructive bg-destructive/10 p-3 text-destructive text-sm dark:bg-destructive/5 dark:text-red-200">
<ErrorPrimitive.Message className="aui-message-error-message line-clamp-2" />
</ErrorPrimitive.Root>
</MessagePrimitive.Error>
);
@@ -0,0 +1,33 @@
import { type FC } from 'react';
import { BranchPickerPrimitive } from '@assistant-ui/react';
import { ChevronLeftIcon, ChevronRightIcon } from 'lucide-react';
import { TooltipIconButton } from '@/components/assistant-ui/tooltip-icon-button';
import { cn } from '@/lib/utils';
export const BranchPicker: FC<BranchPickerPrimitive.Root.Props> = ({
className,
...rest
}) => (
<BranchPickerPrimitive.Root
hideWhenSingleBranch
className={cn(
'aui-branch-picker-root mr-2 -ml-2 inline-flex items-center text-muted-foreground text-xs',
className,
)}
{...rest}
>
<BranchPickerPrimitive.Previous asChild>
<TooltipIconButton tooltip="Previous">
<ChevronLeftIcon />
</TooltipIconButton>
</BranchPickerPrimitive.Previous>
<span className="aui-branch-picker-state font-medium">
<BranchPickerPrimitive.Number /> / <BranchPickerPrimitive.Count />
</span>
<BranchPickerPrimitive.Next asChild>
<TooltipIconButton tooltip="Next">
<ChevronRightIcon />
</TooltipIconButton>
</BranchPickerPrimitive.Next>
</BranchPickerPrimitive.Root>
);
@@ -0,0 +1,101 @@
import { type FC, useCallback } from 'react';
import {
ActionBarPrimitive,
AuiIf,
useAui,
} from '@assistant-ui/react';
import {
CheckIcon,
CopyIcon,
GitBranchIcon,
PencilIcon,
RefreshCwIcon,
} from 'lucide-react';
import { TooltipIconButton } from '@/components/assistant-ui/tooltip-icon-button';
import { useAppDispatch, useAppSelector } from '@/shared/hooks';
import {
duplicateSession,
setActiveSession,
} from '@/shared/state/agentsSlice';
import { useSessionId, useBranchChatCallback } from './OpenSwarmThread';
export const UserActionBar: FC = () => (
<ActionBarPrimitive.Root
hideWhenRunning
autohide="not-last"
className="aui-user-action-bar-root flex flex-col items-end"
>
<ActionBarPrimitive.Edit asChild>
<TooltipIconButton tooltip="Edit" className="aui-user-action-edit p-4">
<PencilIcon />
</TooltipIconButton>
</ActionBarPrimitive.Edit>
</ActionBarPrimitive.Root>
);
export const AssistantActionBar: FC = () => (
<ActionBarPrimitive.Root
hideWhenRunning
autohide="not-last"
className="aui-assistant-action-bar-root -ml-1 flex gap-1 text-muted-foreground"
>
<ActionBarPrimitive.Copy asChild>
<TooltipIconButton tooltip="Copy">
<AuiIf condition={(s) => s.message.isCopied}>
<CheckIcon />
</AuiIf>
<AuiIf condition={(s) => !s.message.isCopied}>
<CopyIcon />
</AuiIf>
</TooltipIconButton>
</ActionBarPrimitive.Copy>
<ActionBarPrimitive.Reload asChild>
<TooltipIconButton tooltip="Regenerate">
<RefreshCwIcon />
</TooltipIconButton>
</ActionBarPrimitive.Reload>
<BranchChatButton />
</ActionBarPrimitive.Root>
);
const BranchChatButton: FC = () => {
const dispatch = useAppDispatch();
const sessionId = useSessionId();
const onBranchChat = useBranchChatCallback();
const aui = useAui();
const dashboardId = useAppSelector((state) => {
if (!sessionId) return undefined;
return state.agents.sessions[sessionId]?.dashboard_id;
});
const handleBranchChat = useCallback(async () => {
if (!sessionId) return;
let messageId: string | undefined;
try {
messageId = aui.message().getState().id;
} catch {
return;
}
if (!messageId) return;
const action = await dispatch(
duplicateSession({
sessionId,
dashboardId,
upToMessageId: messageId,
}),
);
if (duplicateSession.fulfilled.match(action)) {
if (onBranchChat) onBranchChat(action.payload.id);
else dispatch(setActiveSession(action.payload.id));
}
}, [sessionId, dashboardId, dispatch, aui, onBranchChat]);
return (
<TooltipIconButton tooltip="Branch chat" onClick={handleBranchChat}>
<GitBranchIcon />
</TooltipIconButton>
);
};
@@ -1,7 +1,108 @@
import React from 'react';
import { createContext, useContext, type FC, type ReactNode } from 'react';
import {
ThreadPrimitive,
MessagePrimitive,
ComposerPrimitive,
AuiIf,
} from '@assistant-ui/react';
import { ArrowDownIcon } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { TooltipProvider } from '@/components/ui/tooltip';
import { TooltipIconButton } from '@/components/assistant-ui/tooltip-icon-button';
import { UserMessage } from './UserMessage';
import { AssistantMessage } from './AssistantMessage';
const OpenSwarmThread: React.FC = () => {
return <div>Thread placeholder</div>;
const SessionIdContext = createContext<string | undefined>(undefined);
export const useSessionId = () => useContext(SessionIdContext);
const BranchChatContext = createContext<
((newSessionId: string) => void) | undefined
>(undefined);
export const useBranchChatCallback = () => useContext(BranchChatContext);
export interface OpenSwarmThreadProps {
sessionId?: string;
onBranchChat?: (newSessionId: string) => void;
children?: ReactNode;
}
const OpenSwarmThread: FC<OpenSwarmThreadProps> = ({
sessionId,
onBranchChat,
children,
}) => {
return (
<TooltipProvider>
<SessionIdContext.Provider value={sessionId}>
<BranchChatContext.Provider value={onBranchChat}>
<ThreadPrimitive.Root
className="aui-root aui-thread-root flex h-full flex-col bg-background"
style={{
['--thread-max-width' as string]: '44rem',
}}
>
<ThreadPrimitive.Viewport className="aui-thread-viewport relative flex flex-1 flex-col overflow-y-auto scroll-smooth px-4 pt-4">
<AuiIf condition={(s) => s.thread.isEmpty}>
<ThreadWelcome />
</AuiIf>
<ThreadPrimitive.Messages
components={{
UserMessage,
AssistantMessage,
EditComposer,
}}
/>
<ThreadPrimitive.ViewportFooter className="aui-thread-viewport-footer sticky bottom-0 mx-auto mt-auto flex w-full max-w-(--thread-max-width) flex-col items-center overflow-visible pb-4">
<ThreadScrollToBottom />
{children}
</ThreadPrimitive.ViewportFooter>
</ThreadPrimitive.Viewport>
</ThreadPrimitive.Root>
</BranchChatContext.Provider>
</SessionIdContext.Provider>
</TooltipProvider>
);
};
const ThreadWelcome: FC = () => (
<div className="mx-auto my-auto flex w-full max-w-(--thread-max-width) grow flex-col items-center justify-center">
<p className="text-muted-foreground text-lg">How can I help you today?</p>
</div>
);
const ThreadScrollToBottom: FC = () => (
<ThreadPrimitive.ScrollToBottom asChild>
<TooltipIconButton
tooltip="Scroll to bottom"
variant="outline"
className="aui-thread-scroll-to-bottom absolute -top-12 z-10 self-center rounded-full p-4 disabled:invisible dark:border-border dark:bg-background dark:hover:bg-accent"
>
<ArrowDownIcon />
</TooltipIconButton>
</ThreadPrimitive.ScrollToBottom>
);
const EditComposer: FC = () => (
<MessagePrimitive.Root className="aui-edit-composer-wrapper mx-auto flex w-full max-w-(--thread-max-width) flex-col px-2 py-3">
<ComposerPrimitive.Root className="aui-edit-composer-root ml-auto flex w-full max-w-[85%] flex-col rounded-2xl bg-muted">
<ComposerPrimitive.Input
className="aui-edit-composer-input min-h-14 w-full resize-none bg-transparent p-4 text-foreground text-sm outline-none"
autoFocus
/>
<div className="aui-edit-composer-footer mx-3 mb-3 flex items-center gap-2 self-end">
<ComposerPrimitive.Cancel asChild>
<Button variant="ghost" size="sm">
Cancel
</Button>
</ComposerPrimitive.Cancel>
<ComposerPrimitive.Send asChild>
<Button size="sm">Update</Button>
</ComposerPrimitive.Send>
</div>
</ComposerPrimitive.Root>
</MessagePrimitive.Root>
);
export default OpenSwarmThread;
@@ -0,0 +1,207 @@
import { type FC } from 'react';
import {
MessagePrimitive,
useAui,
useMessagePartText,
} from '@assistant-ui/react';
import { UserMessageAttachments } from '@/components/assistant-ui/attachment';
import { useAppSelector } from '@/shared/hooks';
import type { AgentMessage } from '@/shared/state/agentsSlice';
import { useSessionId } from './OpenSwarmThread';
import { UserActionBar } from './MessageActions';
import { BranchPicker } from './BranchPicker';
const ELEMENT_SEPARATOR = '\n\n---\nSelected UI Elements:\n';
const SKILL_PILL_RE = /\{\{skill:([^}]+)\}\}/g;
interface ParsedElement {
label: string;
selector: string;
}
function parseElementContext(text: string): {
userMessage: string;
elements: ParsedElement[];
} {
const sepIdx = text.indexOf(ELEMENT_SEPARATOR);
if (sepIdx === -1) return { userMessage: text, elements: [] };
const userMessage = text.slice(0, sepIdx);
const elementSection = text.slice(sepIdx + ELEMENT_SEPARATOR.length);
const elements: ParsedElement[] = [];
const blocks = elementSection.split(/\n(?=\d+\.\s)/).filter(Boolean);
for (const block of blocks) {
const semanticMatch = block.match(/\d+\.\s+\[([^\]]+)\]\s*(.*)/);
if (semanticMatch) {
elements.push({
label: `${semanticMatch[1]}: ${semanticMatch[2].trim().split('\n')[0]}`,
selector: semanticMatch[1],
});
continue;
}
const labelMatch = block.match(/`([^`]+)`\s+\((\w+)\)/);
const selectorMatch = block.match(/Selector:\s*(.+)/);
if (labelMatch) {
elements.push({
label: labelMatch[1],
selector: selectorMatch?.[1]?.trim() ?? labelMatch[1],
});
}
}
return { userMessage, elements };
}
function renderTextWithSkillPills(text: string): React.ReactNode[] {
const parts: React.ReactNode[] = [];
let lastIndex = 0;
let match: RegExpExecArray | null;
const re = new RegExp(SKILL_PILL_RE.source, 'g');
while ((match = re.exec(text)) !== null) {
if (match.index > lastIndex) parts.push(text.slice(lastIndex, match.index));
parts.push(
<span
key={`skill-${match.index}`}
className="inline-flex items-center gap-0.5 rounded-full bg-violet-500/10 text-violet-600 text-xs font-mono px-1.5 py-0.5 mx-0.5 align-baseline"
>
{match[1]}
</span>,
);
lastIndex = re.lastIndex;
}
if (lastIndex < text.length) parts.push(text.slice(lastIndex));
return parts;
}
function useOriginalMessage(): AgentMessage | undefined {
const sessionId = useSessionId();
const aui = useAui();
let messageId: string | undefined;
try {
messageId = aui.message().getState().id;
} catch {
return undefined;
}
return useAppSelector((state) => {
if (!sessionId || !messageId) return undefined;
return state.agents.sessions[sessionId]?.messages.find(
(m) => m.id === messageId,
);
});
}
const UserTextContent: FC = () => {
const { text } = useMessagePartText();
const { userMessage, elements } = parseElementContext(text);
return (
<>
<p className="text-sm leading-relaxed break-words whitespace-pre-wrap">
{renderTextWithSkillPills(userMessage)}
</p>
{elements.length > 0 && (
<div className="mt-1.5 flex flex-wrap gap-1">
{elements.map((el, i) => (
<span
key={i}
className="inline-flex items-center rounded-full bg-blue-500/10 text-blue-600 text-xs px-2 py-0.5"
title={el.selector}
>
{el.label}
</span>
))}
</div>
)}
</>
);
};
const ContextPills: FC = () => {
const msg = useOriginalMessage();
if (!msg) return null;
const contextPaths = msg.context_paths;
const attachedSkills = msg.attached_skills;
const forcedTools = msg.forced_tools;
const hasContext =
(contextPaths && contextPaths.length > 0) ||
(attachedSkills && attachedSkills.length > 0) ||
(forcedTools && forcedTools.length > 0);
if (!hasContext) return null;
return (
<div className="mt-1.5 pt-1.5 border-t border-border/50 flex flex-wrap gap-1">
{contextPaths?.map((cp, i) => (
<span
key={`path-${i}`}
className="inline-flex items-center gap-1 rounded-full bg-emerald-500/10 text-emerald-600 text-xs font-mono px-2 py-0.5"
title={cp.path}
>
{cp.type === 'directory' ? '📁' : '📄'}
{cp.path.split('/').filter(Boolean).pop()}
</span>
))}
{attachedSkills?.map((skill, i) => (
<span
key={`skill-${i}`}
className="inline-flex items-center gap-1 rounded-full bg-violet-500/10 text-violet-600 text-xs font-mono px-2 py-0.5"
>
🧠 {skill.name}
</span>
))}
{forcedTools?.map((tool, i) => (
<span
key={`tool-${i}`}
className="inline-flex items-center gap-1 rounded-full bg-amber-500/10 text-amber-600 text-xs font-mono px-2 py-0.5"
>
🔧 {tool}
</span>
))}
</div>
);
};
const ImageThumbnails: FC = () => {
const msg = useOriginalMessage();
if (!msg?.images?.length) return null;
return (
<div className="flex gap-1.5 mb-1.5 flex-wrap">
{msg.images.map((img, i) => (
<img
key={i}
src={`data:${img.media_type};base64,${img.data}`}
alt=""
className="size-16 rounded-lg object-cover border border-border/50 cursor-pointer hover:opacity-80 transition-opacity"
/>
))}
</div>
);
};
export const UserMessage: FC = () => {
return (
<MessagePrimitive.Root
className="aui-user-message-root mx-auto grid w-full max-w-(--thread-max-width) auto-rows-auto grid-cols-[minmax(72px,1fr)_auto] content-start gap-y-2 px-2 py-3 [&:where(>*)]:col-start-2"
data-role="user"
>
<UserMessageAttachments />
<div className="aui-user-message-content-wrapper relative col-start-2 min-w-0">
<div className="aui-user-message-content peer rounded-2xl bg-muted px-4 py-2.5 text-foreground empty:hidden">
<ImageThumbnails />
<MessagePrimitive.Parts components={{ Text: UserTextContent }} />
<ContextPills />
</div>
<div className="aui-user-action-bar-wrapper absolute top-1/2 left-0 -translate-x-full -translate-y-1/2 pr-2 peer-empty:hidden">
<UserActionBar />
</div>
</div>
<BranchPicker className="aui-user-branch-picker col-span-full col-start-1 row-start-3 -mr-1 justify-end" />
</MessagePrimitive.Root>
);
};
File diff suppressed because one or more lines are too long