Skip to content

Commit 2a73bfd

Browse files
priscilawebdevwedamija
authored andcommitted
feat(conversations): Collapse tool calls in message bubbles (#109176)
Show at most 3 tool calls in assistant message bubbles, with a toggle to expand the rest. When hidden tool calls include errors, display a failed count in red next to the toggle. Tool call logic is extracted into MessageToolCalls to keep MessagesPanel focused on layout. The table (toolTags.tsx) is unchanged and keeps its existing row-height-based collapse behaviour.
1 parent f4555e9 commit 2a73bfd

File tree

2 files changed

+129
-55
lines changed

2 files changed

+129
-55
lines changed
Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
import {useState} from 'react';
2+
import styled from '@emotion/styled';
3+
4+
import {Tag} from '@sentry/scraps/badge';
5+
import {Button} from '@sentry/scraps/button';
6+
import {Flex} from '@sentry/scraps/layout';
7+
import {Text} from '@sentry/scraps/text';
8+
9+
import {IconFire} from 'sentry/icons';
10+
import {t} from 'sentry/locale';
11+
import type {AITraceSpanNode} from 'sentry/views/insights/pages/agents/utils/types';
12+
import type {ToolCall} from 'sentry/views/insights/pages/conversations/utils/conversationMessages';
13+
14+
const COLLAPSE_COUNT = 3;
15+
16+
interface CollapsibleTagListProps {
17+
items: React.ReactNode[];
18+
failedCount?: number;
19+
}
20+
21+
function CollapsibleTagList({items, failedCount = 0}: CollapsibleTagListProps) {
22+
const [isExpanded, setIsExpanded] = useState(false);
23+
24+
const visibleItems = isExpanded ? items : items.slice(0, COLLAPSE_COUNT);
25+
const hiddenCount = items.length - COLLAPSE_COUNT;
26+
27+
return (
28+
<Flex align="center" gap="xs" wrap="wrap" flexGrow={1}>
29+
{visibleItems}
30+
{!isExpanded && hiddenCount > 0 && (
31+
<Button priority="link" size="xs" onClick={() => setIsExpanded(true)}>
32+
{t('+%s more', hiddenCount)}
33+
{failedCount > 0 && (
34+
<Text as="span" variant="danger">
35+
{'\u00A0'}
36+
{t('(%s failed)', failedCount)}
37+
</Text>
38+
)}
39+
</Button>
40+
)}
41+
{isExpanded && items.length > COLLAPSE_COUNT && (
42+
<Button priority="link" size="xs" onClick={() => setIsExpanded(false)}>
43+
{t('Show less')}
44+
</Button>
45+
)}
46+
</Flex>
47+
);
48+
}
49+
50+
interface MessageToolCallsProps {
51+
nodeMap: Map<string, AITraceSpanNode>;
52+
onSelectNode: (node: AITraceSpanNode) => void;
53+
selectedNodeId: string | null;
54+
toolCalls: ToolCall[];
55+
}
56+
57+
/**
58+
* Note: the table uses a different collapse mechanism (toolTags.tsx) based on row
59+
* height via ResizeObserver. This component is intentionally separate and uses a
60+
* fixed count-based collapse specific to the message bubble context.
61+
*/
62+
export function MessageToolCalls({
63+
toolCalls,
64+
selectedNodeId,
65+
nodeMap,
66+
onSelectNode,
67+
}: MessageToolCallsProps) {
68+
const failedCount = toolCalls
69+
.slice(COLLAPSE_COUNT)
70+
.filter(tool => tool.hasError).length;
71+
72+
const items = toolCalls.map(tool => {
73+
const toolNode = nodeMap.get(tool.nodeId);
74+
const isToolSelected = tool.nodeId === selectedNodeId;
75+
return (
76+
<ClickableTag
77+
key={tool.nodeId}
78+
variant={tool.hasError ? 'danger' : 'info'}
79+
icon={tool.hasError ? <IconFire /> : undefined}
80+
hasError={tool.hasError}
81+
isSelected={isToolSelected}
82+
onClick={e => {
83+
e.stopPropagation();
84+
if (toolNode) {
85+
onSelectNode(toolNode);
86+
}
87+
}}
88+
>
89+
{tool.name}
90+
</ClickableTag>
91+
);
92+
});
93+
94+
return (
95+
<Footer direction="row" align="center" gap="xs" wrap="wrap" padding="xs sm">
96+
<Text size="xs" style={{opacity: 0.7}}>
97+
{t('Tools called:')}
98+
</Text>
99+
<CollapsibleTagList items={items} failedCount={failedCount} />
100+
</Footer>
101+
);
102+
}
103+
104+
const Footer = styled(Flex)`
105+
border-top: 1px solid ${p => p.theme.tokens.border.primary};
106+
`;
107+
108+
const ClickableTag = styled(Tag)<{hasError?: boolean; isSelected?: boolean}>`
109+
cursor: pointer;
110+
&:hover {
111+
opacity: 0.8;
112+
}
113+
${p =>
114+
p.isSelected &&
115+
`
116+
outline: 2px solid ${p.hasError ? p.theme.tokens.content.danger : p.theme.tokens.focus.default};
117+
outline-offset: -2px;
118+
`}
119+
`;

static/app/views/insights/pages/conversations/components/messagesPanel.tsx

Lines changed: 10 additions & 55 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,17 @@
11
import {useCallback, useMemo, useState} from 'react';
22
import styled from '@emotion/styled';
33

4-
import {Tag} from '@sentry/scraps/badge';
54
import {Container, Flex, Stack} from '@sentry/scraps/layout';
65
import {Text} from '@sentry/scraps/text';
76

87
import ClippedBox from 'sentry/components/clippedBox';
98
import EmptyMessage from 'sentry/components/emptyMessage';
10-
import {IconFire, IconUser} from 'sentry/icons';
9+
import {IconUser} from 'sentry/icons';
1110
import {IconBot} from 'sentry/icons/iconBot';
1211
import {t} from 'sentry/locale';
1312
import {MarkedText} from 'sentry/utils/marked/markedText';
1413
import type {AITraceSpanNode} from 'sentry/views/insights/pages/agents/utils/types';
14+
import {MessageToolCalls} from 'sentry/views/insights/pages/conversations/components/messageToolCalls';
1515
import type {ConversationMessage} from 'sentry/views/insights/pages/conversations/utils/conversationMessages';
1616
import {extractMessagesFromNodes} from 'sentry/views/insights/pages/conversations/utils/conversationMessages';
1717
import {TraceDrawerComponents} from 'sentry/views/performance/newTraceDetails/traceDrawer/details/styles';
@@ -108,42 +108,14 @@ export function MessagesPanel({nodes, selectedNodeId, onSelectNode}: MessagesPan
108108
</MessageText>
109109
</Container>
110110
</StyledClippedBox>
111-
{message.role === 'assistant' &&
112-
message.toolCalls &&
113-
message.toolCalls.length > 0 && (
114-
<ToolCallsFooter
115-
direction="row"
116-
align="center"
117-
gap="xs"
118-
wrap="wrap"
119-
padding="xs sm"
120-
>
121-
<Text size="xs" style={{opacity: 0.7}}>
122-
{t('Tools called:')}
123-
</Text>
124-
{message.toolCalls.map(tool => {
125-
const toolNode = nodeMap.get(tool.nodeId);
126-
const isToolSelected = tool.nodeId === selectedNodeId;
127-
return (
128-
<ClickableTag
129-
key={tool.nodeId}
130-
variant={tool.hasError ? 'danger' : 'info'}
131-
icon={tool.hasError ? <IconFire /> : undefined}
132-
hasError={tool.hasError}
133-
isSelected={isToolSelected}
134-
onClick={e => {
135-
e.stopPropagation();
136-
if (toolNode) {
137-
onSelectNode(toolNode);
138-
}
139-
}}
140-
>
141-
{tool.name}
142-
</ClickableTag>
143-
);
144-
})}
145-
</ToolCallsFooter>
146-
)}
111+
{isAssistant && message.toolCalls && message.toolCalls.length > 0 && (
112+
<MessageToolCalls
113+
toolCalls={message.toolCalls}
114+
selectedNodeId={selectedNodeId}
115+
nodeMap={nodeMap}
116+
onSelectNode={onSelectNode}
117+
/>
118+
)}
147119
</MessageBubble>
148120
);
149121
})}
@@ -225,20 +197,3 @@ const MessageBubble = styled('div')<{
225197
const StyledClippedBox = styled(ClippedBox)`
226198
padding: 0;
227199
`;
228-
229-
const ToolCallsFooter = styled(Flex)`
230-
border-top: 1px solid ${p => p.theme.tokens.border.primary};
231-
`;
232-
233-
const ClickableTag = styled(Tag)<{hasError?: boolean; isSelected?: boolean}>`
234-
cursor: pointer;
235-
&:hover {
236-
opacity: 0.8;
237-
}
238-
${p =>
239-
p.isSelected &&
240-
`
241-
outline: 2px solid ${p.hasError ? p.theme.tokens.content.danger : p.theme.tokens.focus.default};
242-
outline-offset: -2px;
243-
`}
244-
`;

0 commit comments

Comments
 (0)