perf: optimize agent stream view rendering

- Separate streaming item from FlatList data array to prevent full
  re-diffs on every streaming chunk
- Memoize renderStreamItem with useCallback
- Memoize AgentThoughtMessage component with React.memo

The main fix is rendering the streaming message in ListHeaderComponent
instead of prepending it to the data array, which was causing the
entire list to re-diff on every character streamed.
This commit is contained in:
Mohamed Boudra
2026-01-09 18:00:34 +07:00
parent 1a2498e9e7
commit b90c17a919
2 changed files with 102 additions and 103 deletions
+100 -101
View File
@@ -240,96 +240,99 @@ export function AgentStreamView({
setIsNearBottom(true); setIsNearBottom(true);
} }
function renderStreamItem({ item }: ListRenderItemInfo<StreamItem>) { const renderStreamItem = useCallback(
let content: React.ReactNode = null; ({ item }: ListRenderItemInfo<StreamItem>) => {
let content: React.ReactNode = null;
switch (item.kind) { switch (item.kind) {
case "user_message": case "user_message":
content = (
<UserMessage
message={item.text}
timestamp={item.timestamp.getTime()}
/>
);
break;
case "assistant_message":
content = (
<AssistantMessage
message={item.text}
timestamp={item.timestamp.getTime()}
onInlinePathPress={handleInlinePathPress}
/>
);
break;
case "thought":
content = <AgentThoughtMessage message={item.text} status={item.status} />;
break;
case "tool_call": {
const { payload } = item;
if (payload.source === "agent") {
const data = payload.data;
content = ( content = (
<ToolCall <UserMessage
toolName={data.name} message={item.text}
args={data.input} timestamp={item.timestamp.getTime()}
result={data.result}
error={data.error}
status={data.status as "executing" | "completed" | "failed"}
parsedEditEntries={data.parsedEdits}
parsedReadEntries={data.parsedReads}
parsedCommandDetails={data.parsedCommand ?? null}
cwd={agent.cwd}
/> />
); );
} else { break;
const data = payload.data;
case "assistant_message":
content = ( content = (
<ToolCall <AssistantMessage
toolName={data.toolName} message={item.text}
args={data.arguments} timestamp={item.timestamp.getTime()}
result={data.result} onInlinePathPress={handleInlinePathPress}
status={data.status}
/> />
); );
break;
case "thought":
content = <AgentThoughtMessage message={item.text} status={item.status} />;
break;
case "tool_call": {
const { payload } = item;
if (payload.source === "agent") {
const data = payload.data;
content = (
<ToolCall
toolName={data.name}
args={data.input}
result={data.result}
error={data.error}
status={data.status as "executing" | "completed" | "failed"}
parsedEditEntries={data.parsedEdits}
parsedReadEntries={data.parsedReads}
parsedCommandDetails={data.parsedCommand ?? null}
cwd={agent.cwd}
/>
);
} else {
const data = payload.data;
content = (
<ToolCall
toolName={data.toolName}
args={data.arguments}
result={data.result}
status={data.status}
/>
);
}
break;
} }
break;
case "activity_log":
content = (
<ActivityLog
type={item.activityType}
message={item.message}
timestamp={item.timestamp.getTime()}
metadata={item.metadata}
/>
);
break;
case "todo_list":
content = (
<TodoListCard
provider={item.provider}
timestamp={item.timestamp.getTime()}
items={item.items}
/>
);
break;
default:
content = null;
} }
case "activity_log": if (!content) {
content = ( return null;
<ActivityLog }
type={item.activityType}
message={item.message}
timestamp={item.timestamp.getTime()}
metadata={item.metadata}
/>
);
break;
case "todo_list": return <View style={stylesheet.streamItemWrapper}>{content}</View>;
content = ( },
<TodoListCard [handleInlinePathPress, agent.cwd]
provider={item.provider} );
timestamp={item.timestamp.getTime()}
items={item.items}
/>
);
break;
default:
content = null;
}
if (!content) {
return null;
}
return <View style={stylesheet.streamItemWrapper}>{content}</View>;
}
const pendingPermissionItems = useMemo( const pendingPermissionItems = useMemo(
() => () =>
@@ -339,30 +342,20 @@ export function AgentStreamView({
[pendingPermissions, agentId] [pendingPermissions, agentId]
); );
const streamingItem = useMemo<StreamItem | null>(() => {
if (!streamingBuffer) {
return null;
}
return {
kind: "assistant_message",
id: streamingBuffer.id,
text: streamingBuffer.text,
timestamp: streamingBuffer.timestamp,
};
}, [streamingBuffer]);
const streamingItemId = streamingItem?.id ?? null;
const showWorkingIndicator = agent.status === "running"; const showWorkingIndicator = agent.status === "running";
const listHeaderComponent = useMemo(() => { const listHeaderComponent = useMemo(() => {
if (pendingPermissionItems.length === 0 && !showWorkingIndicator) { const hasPermissions = pendingPermissionItems.length > 0;
const hasStreaming = !!streamingBuffer;
if (!hasPermissions && !showWorkingIndicator && !hasStreaming) {
return null; return null;
} }
return ( return (
<View style={stylesheet.contentWrapper}> <View style={stylesheet.contentWrapper}>
<View style={stylesheet.listHeaderContent}> <View style={stylesheet.listHeaderContent}>
{pendingPermissionItems.length > 0 ? ( {hasPermissions ? (
<View style={stylesheet.permissionsContainer}> <View style={stylesheet.permissionsContainer}>
{pendingPermissionItems.map((permission) => ( {pendingPermissionItems.map((permission) => (
<PermissionRequestCard key={permission.key} permission={permission} ws={wsOrInert} /> <PermissionRequestCard key={permission.key} permission={permission} ws={wsOrInert} />
@@ -375,18 +368,24 @@ export function AgentStreamView({
<WorkingIndicator /> <WorkingIndicator />
</View> </View>
) : null} ) : null}
{hasStreaming ? (
<View style={stylesheet.streamItemWrapper}>
<AssistantMessage
message={streamingBuffer.text}
timestamp={streamingBuffer.timestamp.getTime()}
onInlinePathPress={handleInlinePathPress}
/>
</View>
) : null}
</View> </View>
</View> </View>
); );
}, [pendingPermissionItems, showWorkingIndicator, wsOrInert]); }, [pendingPermissionItems, showWorkingIndicator, wsOrInert, streamingBuffer, handleInlinePathPress]);
const flatListData = useMemo(() => { const flatListData = useMemo(() => {
const reversed = [...streamItems].reverse(); return [...streamItems].reverse();
if (streamingItem) { }, [streamItems]);
reversed.unshift(streamingItem);
}
return reversed;
}, [streamItems, streamingItem]);
const flatListExtraData = useMemo( const flatListExtraData = useMemo(
() => ({ () => ({
+2 -2
View File
@@ -963,7 +963,7 @@ const agentThoughtStylesheet = StyleSheet.create((theme) => ({
}, },
})); }));
export function AgentThoughtMessage({ message, status = "ready" }: AgentThoughtMessageProps) { export const AgentThoughtMessage = memo(function AgentThoughtMessage({ message, status = "ready" }: AgentThoughtMessageProps) {
const { theme } = useUnistyles(); const { theme } = useUnistyles();
const [isExpanded, setIsExpanded] = useState(false); const [isExpanded, setIsExpanded] = useState(false);
const markdownContent = useMemo(() => message?.trim() ?? "", [message]); const markdownContent = useMemo(() => message?.trim() ?? "", [message]);
@@ -1102,7 +1102,7 @@ export function AgentThoughtMessage({ message, status = "ready" }: AgentThoughtM
isLoading={status !== "ready"} isLoading={status !== "ready"}
/> />
); );
} });
interface ToolCallProps { interface ToolCallProps {
toolName: string; toolName: string;