Skip to content

Commit be0caf7

Browse files
committed
feat: improve agent message tool rendering
1 parent 16d9eb1 commit be0caf7

11 files changed

Lines changed: 383 additions & 111 deletions

File tree

backend/Cargo.lock

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

features/agent/components/message-list/animated-collapse.tsx

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,8 @@ export function AnimatedCollapse({
2121
}: AnimatedCollapseProps) {
2222
const [mounted, setMounted] = useState(expanded);
2323
const [contentHeight, setContentHeight] = useState(0);
24-
const height = useSharedValue(expanded ? 0 : 0);
24+
const [initialRender, setInitialRender] = useState(expanded);
25+
const height = useSharedValue(0);
2526
const opacity = useSharedValue(expanded ? 1 : 0);
2627

2728
const targetHeight = useMemo(() => {
@@ -39,6 +40,13 @@ export function AnimatedCollapse({
3940
}, [expanded]);
4041

4142
useEffect(() => {
43+
if (initialRender && targetHeight > 0) {
44+
height.value = targetHeight;
45+
opacity.value = 1;
46+
setInitialRender(false);
47+
return;
48+
}
49+
if (initialRender) return;
4250
if (!mounted) return;
4351

4452
if (expanded) {
@@ -73,7 +81,7 @@ export function AnimatedCollapse({
7381
if (!mounted) return null;
7482

7583
return (
76-
<Animated.View style={[styles.container, style]}>
84+
<Animated.View style={[styles.container, initialRender ? undefined : style]}>
7785
<View onLayout={handleLayout} style={styles.content}>
7886
{children}
7987
</View>

features/agent/components/message-list/assistant-message.tsx

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,11 +22,13 @@ import { ToolCallGroup } from "./tool-call";
2222
interface AssistantMessageProps {
2323
message: ChatMessage;
2424
isDark: boolean;
25+
toolCallsOverride?: ChatMessage["toolCalls"];
2526
}
2627

2728
export const AssistantMessage = memo(function AssistantMessage({
2829
message,
2930
isDark,
31+
toolCallsOverride,
3032
}: AssistantMessageProps) {
3133
const colorScheme = useColorScheme() ?? "light";
3234
const colors = Colors[colorScheme];
@@ -37,9 +39,10 @@ export const AssistantMessage = memo(function AssistantMessage({
3739
message.isStreaming,
3840
);
3941

42+
const toolCalls = toolCallsOverride ?? message.toolCalls;
4043
const hasThinking = !!message.thinking;
4144
const hasText = !!message.text;
42-
const hasToolCalls = !!(message.toolCalls && message.toolCalls.length > 0);
45+
const hasToolCalls = !!(toolCalls && toolCalls.length > 0);
4346
const hasError = !!message.errorMessage;
4447
const isStreaming = !!message.isStreaming;
4548
const isThinkingOnly = hasThinking && !hasText && !hasToolCalls && isStreaming;
@@ -70,7 +73,7 @@ export const AssistantMessage = memo(function AssistantMessage({
7073
{hasText && <View style={styles.textBlock}>{elements}</View>}
7174

7275
{hasToolCalls && (
73-
<ToolCallGroup toolCalls={message.toolCalls!} isDark={isDark} />
76+
<ToolCallGroup toolCalls={toolCalls!} isDark={isDark} />
7477
)}
7578

7679
{hasError && (

features/agent/components/message-list/code-preview.tsx

Lines changed: 51 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@ interface CodePreviewProps {
88
maxHeight?: number;
99
startLine?: number;
1010
language?: string;
11+
diffLanguage?: string;
12+
showLineNumbers?: boolean;
1113
}
1214

1315
type TokenKind =
@@ -160,12 +162,25 @@ function tokenizeGeneric(line: string): Segment[] {
160162
return [{ text: line || " ", kind: "plain" }];
161163
}
162164

163-
function tokenizeDiff(line: string): Segment[] {
165+
function tintSegments(segments: Segment[], kind: TokenKind): Segment[] {
166+
return segments.map((segment) =>
167+
segment.kind === "plain" || segment.kind === "punctuation" ? { ...segment, kind } : segment,
168+
);
169+
}
170+
171+
function tokenizeDiff(line: string, diffLanguage?: string): Segment[] {
164172
if (line.startsWith("@@") || line.startsWith("diff ") || line.startsWith("index ")) {
165173
return [{ text: line || " ", kind: "diffMeta" }];
166174
}
167-
if (line.startsWith("+")) return [{ text: line || " ", kind: "diffAdd" }];
168-
if (line.startsWith("-")) return [{ text: line || " ", kind: "diffRemove" }];
175+
if (line.startsWith("+")) {
176+
return [{ text: "+", kind: "diffAdd" }, ...tintSegments(tokenizeLine(line.slice(1), diffLanguage), "diffAdd")];
177+
}
178+
if (line.startsWith("-")) {
179+
return [{ text: "-", kind: "diffRemove" }, ...tintSegments(tokenizeLine(line.slice(1), diffLanguage), "diffRemove")];
180+
}
181+
if (line.startsWith(" ")) {
182+
return [{ text: " ", kind: "plain" }, ...tokenizeLine(line.slice(1), diffLanguage)];
183+
}
169184
return tokenizeGeneric(line);
170185
}
171186

@@ -252,6 +267,21 @@ function tokenizeBash(line: string): Segment[] {
252267
);
253268
}
254269

270+
function tokenizeYaml(line: string): Segment[] {
271+
return tokenizeWithPattern(
272+
line,
273+
/(#.*$|"(?:[^"\\]|\\.)*"|'(?:[^'\\]|\\.)*'|\b\d+(?:\.\d+)?\b|\btrue\b|\bfalse\b|\bnull\b|\b[A-Za-z0-9_.-]+(?=\s*:)|[{}\[\],:-])/g,
274+
(value) => {
275+
if (value.startsWith("#")) return "comment";
276+
if (value.startsWith('"') || value.startsWith("'")) return "string";
277+
if (/^\d/.test(value)) return "number";
278+
if (/^(true|false|null)$/.test(value)) return "keyword";
279+
if (/^[A-Za-z0-9_.-]+$/.test(value)) return "property";
280+
return "punctuation";
281+
},
282+
);
283+
}
284+
255285
function tokenizeMarkup(line: string): Segment[] {
256286
return tokenizeWithPattern(
257287
line,
@@ -268,7 +298,7 @@ function tokenizeMarkup(line: string): Segment[] {
268298
);
269299
}
270300

271-
function tokenizeLine(line: string, language?: string): Segment[] {
301+
function tokenizeLine(line: string, language?: string, diffLanguage?: string): Segment[] {
272302
const lang = normalizeLanguage(language);
273303

274304
if (["ts", "tsx", "js", "jsx", "typescript", "javascript"].includes(lang)) {
@@ -277,8 +307,9 @@ function tokenizeLine(line: string, language?: string): Segment[] {
277307
if (lang === "json") return tokenizeJson(line);
278308
if (["py", "python"].includes(lang)) return tokenizePython(line);
279309
if (["bash", "sh", "shell", "zsh"].includes(lang)) return tokenizeBash(line);
310+
if (["yaml", "yml"].includes(lang)) return tokenizeYaml(line);
280311
if (["html", "htm", "xml", "xhtml", "svg"].includes(lang)) return tokenizeMarkup(line);
281-
if (["diff", "patch"].includes(lang)) return tokenizeDiff(line);
312+
if (["diff", "patch"].includes(lang)) return tokenizeDiff(line, diffLanguage);
282313
return tokenizeGeneric(line);
283314
}
284315

@@ -288,13 +319,15 @@ export const CodePreview = memo(function CodePreview({
288319
maxHeight,
289320
startLine = 1,
290321
language,
322+
diffLanguage,
323+
showLineNumbers = true,
291324
}: CodePreviewProps) {
292325
const colors = isDark ? Colors.dark : Colors.light;
293326
const tokenColors = useMemo(() => createTokenColors(isDark), [isDark]);
294327
const lines = useMemo(() => code.split("\n"), [code]);
295328

296329
return (
297-
<View style={[styles.container, { backgroundColor: colors.surfaceRaised, borderColor: colors.border }]}>
330+
<View style={[styles.container, { backgroundColor: colors.surfaceRaised, borderColor: colors.border }]}>
298331
<ScrollView
299332
style={maxHeight ? { maxHeight } : undefined}
300333
nestedScrollEnabled
@@ -303,15 +336,17 @@ export const CodePreview = memo(function CodePreview({
303336
<ScrollView horizontal showsHorizontalScrollIndicator={false}>
304337
<View>
305338
{lines.map((line, i) => {
306-
const segments = tokenizeLine(line, language);
339+
const segments = tokenizeLine(line, language, diffLanguage);
307340
return (
308341
<View key={i} style={styles.row}>
309-
<View style={[styles.lineNoCol, { borderRightColor: colors.border }]}>
310-
<Text style={[styles.lineNo, { color: colors.textTertiary }]}>
311-
{startLine + i}
312-
</Text>
313-
</View>
314-
<Text style={[styles.lineText, { color: tokenColors.plain }]}>
342+
{showLineNumbers ? (
343+
<View style={[styles.lineNoCol, { borderRightColor: colors.border }]}>
344+
<Text style={[styles.lineNo, { color: colors.textTertiary }]}>
345+
{startLine + i}
346+
</Text>
347+
</View>
348+
) : null}
349+
<Text style={[styles.lineText, !showLineNumbers && styles.lineTextNoGutter, { color: tokenColors.plain }]}>
315350
{segments.length ? segments.map((segment, idx) => (
316351
<Text key={`${i}-${idx}`} style={{ color: tokenColors[segment.kind] }}>
317352
{segment.text || (idx === 0 ? " " : "")}
@@ -357,4 +392,7 @@ const styles = StyleSheet.create({
357392
paddingHorizontal: 8,
358393
paddingVertical: 1,
359394
},
395+
lineTextNoGutter: {
396+
paddingLeft: 10,
397+
},
360398
});

features/agent/components/message-list/index.tsx

Lines changed: 77 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -10,12 +10,12 @@ import {
1010
type NativeScrollEvent,
1111
type NativeSyntheticEvent,
1212
} from "react-native";
13-
import Animated, { FadeIn, FadeOut } from "react-native-reanimated";
13+
import Animated, { FadeIn, FadeOut, LinearTransition } from "react-native-reanimated";
1414
import { ArrowDown } from "lucide-react-native";
1515
import { useAgentSession } from "@pi-ui/client";
1616
import { Colors, Fonts } from "@/constants/theme";
1717
import { useColorScheme } from "@/hooks/use-color-scheme";
18-
import type { ChatMessage } from "../../types";
18+
import type { ChatMessage, ToolCallInfo } from "../../types";
1919
import { UserMessage } from "./user-message";
2020
import { AssistantMessage } from "./assistant-message";
2121
import { SystemMessage } from "./system-message";
@@ -24,6 +24,58 @@ interface MessageListProps {
2424
sessionId: string;
2525
}
2626

27+
interface VisibleMessageItem {
28+
key: string;
29+
message: ChatMessage;
30+
toolCalls?: ToolCallInfo[];
31+
turnDurationMs?: number;
32+
}
33+
34+
function mergeConsecutiveToolCalls(
35+
messages: ChatMessage[],
36+
turnDurations: Map<string, number>,
37+
): VisibleMessageItem[] {
38+
const visible: VisibleMessageItem[] = [];
39+
let anchor: VisibleMessageItem | null = null;
40+
41+
for (let index = 0; index < messages.length; index++) {
42+
const msg = messages[index]!;
43+
const hasText =
44+
msg.text.length > 0 ||
45+
(!!msg.errorMessage && msg.errorMessage.length > 0) ||
46+
(!!msg.thinking && msg.thinking.length > 0);
47+
const toolCalls = msg.toolCalls?.length ? msg.toolCalls : undefined;
48+
const turnDurationMs = turnDurations.get(msg.id);
49+
50+
if (msg.role === "user" || msg.role === "system") {
51+
anchor = null;
52+
visible.push({ key: msg.id, message: msg, turnDurationMs });
53+
continue;
54+
}
55+
56+
if (hasText || !anchor || msg.isStreaming) {
57+
const item: VisibleMessageItem = {
58+
key: msg.id,
59+
message: msg,
60+
toolCalls,
61+
turnDurationMs,
62+
};
63+
anchor = msg.isStreaming ? null : item;
64+
visible.push(item);
65+
continue;
66+
}
67+
68+
if (toolCalls?.length && anchor) {
69+
anchor.toolCalls = anchor.toolCalls?.length
70+
? [...anchor.toolCalls, ...toolCalls]
71+
: [...toolCalls];
72+
anchor.turnDurationMs = anchor.turnDurationMs ?? turnDurationMs;
73+
}
74+
}
75+
76+
return visible;
77+
}
78+
2779
const SCROLL_THRESHOLD = 200;
2880
const INITIAL_RENDER_COUNT = 8;
2981
const RENDER_BATCH_COUNT = 4;
@@ -43,7 +95,6 @@ export const MessageList = memo(function MessageList({
4395
const messages = session.messages as ChatMessage[];
4496
const isStreaming = session.isStreaming;
4597

46-
const reversed = useMemo(() => [...messages].reverse(), [messages]);
4798
const prevMessageCountRef = useRef(messages.length);
4899

49100
const turnDurations = useMemo(() => {
@@ -61,6 +112,12 @@ export const MessageList = memo(function MessageList({
61112
return map;
62113
}, [messages]);
63114

115+
const visibleItems = useMemo(
116+
() => mergeConsecutiveToolCalls(messages, turnDurations),
117+
[messages, turnDurations],
118+
);
119+
const reversed = useMemo(() => [...visibleItems].reverse(), [visibleItems]);
120+
64121
useEffect(() => {
65122
if (!autoFollow) return;
66123
const countChanged = messages.length !== prevMessageCountRef.current;
@@ -102,17 +159,18 @@ export const MessageList = memo(function MessageList({
102159
}, [session]);
103160

104161
const renderItem = useCallback(
105-
({ item }: ListRenderItemInfo<ChatMessage>) => (
162+
({ item }: ListRenderItemInfo<VisibleMessageItem>) => (
106163
<MessageItem
107-
message={item}
164+
message={item.message}
165+
toolCalls={item.toolCalls}
108166
isDark={isDark}
109-
turnDurationMs={turnDurations.get(item.id)}
167+
turnDurationMs={item.turnDurationMs}
110168
/>
111169
),
112-
[isDark, turnDurations],
170+
[isDark],
113171
);
114172

115-
const keyExtractor = useCallback((item: ChatMessage) => item.id, []);
173+
const keyExtractor = useCallback((item: VisibleMessageItem) => item.key, []);
116174

117175
const listFooter = (
118176
<View style={styles.historyLoaderWrap}>
@@ -138,7 +196,7 @@ export const MessageList = memo(function MessageList({
138196
<View style={styles.root}>
139197
<FlatList
140198
ref={listRef}
141-
data={reversed}
199+
data={reversed as VisibleMessageItem[]}
142200
renderItem={renderItem}
143201
keyExtractor={keyExtractor}
144202
inverted
@@ -212,10 +270,12 @@ const TurnDivider = memo(function TurnDivider({
212270

213271
const MessageItem = memo(function MessageItem({
214272
message,
273+
toolCalls,
215274
isDark,
216275
turnDurationMs,
217276
}: {
218277
message: ChatMessage;
278+
toolCalls?: ToolCallInfo[];
219279
isDark: boolean;
220280
turnDurationMs?: number;
221281
}) {
@@ -224,7 +284,7 @@ const MessageItem = memo(function MessageItem({
224284
case "user":
225285
return <UserMessage message={message} isDark={isDark} />;
226286
case "assistant":
227-
return <AssistantMessage message={message} isDark={isDark} />;
287+
return <AssistantMessage message={message} toolCallsOverride={toolCalls} isDark={isDark} />;
228288
case "system":
229289
return <SystemMessage message={message} isDark={isDark} />;
230290
default:
@@ -233,12 +293,17 @@ const MessageItem = memo(function MessageItem({
233293
})();
234294

235295
return (
236-
<View style={styles.itemWrap}>
296+
<Animated.View
297+
layout={LinearTransition.duration(180)}
298+
entering={FadeIn.duration(160)}
299+
exiting={FadeOut.duration(140)}
300+
style={styles.itemWrap}
301+
>
237302
{content}
238303
{typeof turnDurationMs === "number" && turnDurationMs > 0 && (
239304
<TurnDivider durationMs={turnDurationMs} isDark={isDark} />
240305
)}
241-
</View>
306+
</Animated.View>
242307
);
243308
});
244309

0 commit comments

Comments
 (0)