mirror of
https://github.com/openswarm-ai/openswarm.git
synced 2026-09-13 13:17:40 +02:00
[Haik]: Agentic refactor 2. Thread and Messages
This commit is contained in:
@@ -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
Reference in New Issue
Block a user