Match thinking badge behavior to tool badge

This commit is contained in:
Mohamed Boudra
2026-01-21 15:13:34 +07:00
parent ddbcc272f1
commit 42fb56890b
5 changed files with 431 additions and 278 deletions

View File

@@ -49,6 +49,7 @@ import type { DaemonClientV2 } from "@server/client/daemon-client-v2";
import { parseToolCallDisplay } from "@/utils/tool-call-parsers";
import { ToolCallDetailsContent } from "./tool-call-details";
import { ToolCallSheetProvider } from "./tool-call-sheet";
import { ThinkingSheetProvider } from "./thinking-sheet";
import { createMarkdownStyles } from "@/styles/markdown-styles";
import { MAX_CONTENT_WIDTH } from "@/constants/layout";
import { isPerfLoggingEnabled, measurePayload, perfLog } from "@/utils/perf";
@@ -532,62 +533,64 @@ export function AgentStreamView({
return (
<ToolCallSheetProvider>
<View style={stylesheet.container}>
<MessageOuterSpacingProvider disableOuterSpacing>
<FlatList
ref={flatListRef}
data={flatListData}
renderItem={renderStreamItem}
keyExtractor={(item) => item.id}
contentContainerStyle={{
paddingVertical: 0,
flexGrow: 1,
}}
style={stylesheet.list}
onScroll={handleScroll}
scrollEventThrottle={16}
ListEmptyComponent={
<View style={[stylesheet.emptyState, stylesheet.contentWrapper]}>
<Text style={stylesheet.emptyStateText}>
Start chatting with this agent...
</Text>
</View>
}
ListHeaderComponent={listHeaderComponent}
extraData={flatListExtraData}
maintainVisibleContentPosition={
// Disable when streaming and user is at bottom - we handle auto-scroll ourselves
agent.status === "running" && isNearBottom
? undefined
: { minIndexForVisible: 0, autoscrollToTopThreshold: 40 }
}
initialNumToRender={12}
windowSize={10}
inverted
/>
</MessageOuterSpacingProvider>
<ThinkingSheetProvider>
<View style={stylesheet.container}>
<MessageOuterSpacingProvider disableOuterSpacing>
<FlatList
ref={flatListRef}
data={flatListData}
renderItem={renderStreamItem}
keyExtractor={(item) => item.id}
contentContainerStyle={{
paddingVertical: 0,
flexGrow: 1,
}}
style={stylesheet.list}
onScroll={handleScroll}
scrollEventThrottle={16}
ListEmptyComponent={
<View style={[stylesheet.emptyState, stylesheet.contentWrapper]}>
<Text style={stylesheet.emptyStateText}>
Start chatting with this agent...
</Text>
</View>
}
ListHeaderComponent={listHeaderComponent}
extraData={flatListExtraData}
maintainVisibleContentPosition={
// Disable when streaming and user is at bottom - we handle auto-scroll ourselves
agent.status === "running" && isNearBottom
? undefined
: { minIndexForVisible: 0, autoscrollToTopThreshold: 40 }
}
initialNumToRender={12}
windowSize={10}
inverted
/>
</MessageOuterSpacingProvider>
{/* Scroll to bottom button */}
{!isNearBottom && (
<Animated.View
style={stylesheet.scrollToBottomContainer}
entering={scrollIndicatorFadeIn}
exiting={scrollIndicatorFadeOut}
>
<View style={stylesheet.scrollToBottomInner}>
<Pressable
style={stylesheet.scrollToBottomButton}
onPress={scrollToBottom}
>
<ChevronDown
size={24}
color={stylesheet.scrollToBottomIcon.color}
/>
</Pressable>
</View>
</Animated.View>
)}
</View>
{/* Scroll to bottom button */}
{!isNearBottom && (
<Animated.View
style={stylesheet.scrollToBottomContainer}
entering={scrollIndicatorFadeIn}
exiting={scrollIndicatorFadeOut}
>
<View style={stylesheet.scrollToBottomInner}>
<Pressable
style={stylesheet.scrollToBottomButton}
onPress={scrollToBottom}
>
<ChevronDown
size={24}
color={stylesheet.scrollToBottomIcon.color}
/>
</Pressable>
</View>
</Animated.View>
)}
</View>
</ThinkingSheetProvider>
</ToolCallSheetProvider>
);
}

View File

@@ -0,0 +1,95 @@
import { memo, useMemo } from "react";
import { Text, View } from "react-native";
import Markdown from "react-native-markdown-display";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { createCompactMarkdownStyles } from "@/styles/markdown-styles";
export const AgentThoughtContent = memo(function AgentThoughtContent({
message,
}: {
message: string;
}) {
const { theme } = useUnistyles();
const markdownContent = useMemo(() => message?.trim() ?? "", [message]);
const markdownStyles = useMemo(
() => createCompactMarkdownStyles(theme),
[theme]
);
const markdownRules = useMemo(() => {
return {
text: (node: any, _children: any[], _parent: any, styles: any, inheritedStyles: any = {}) => (
<Text key={node.key} style={[inheritedStyles, styles.text]}>
{node.content}
</Text>
),
textgroup: (node: any, children: any[], _parent: any, styles: any, inheritedStyles: any = {}) => (
<Text key={node.key} style={[inheritedStyles, styles.textgroup]}>
{children}
</Text>
),
code_block: (node: any, _children: any[], _parent: any, styles: any, inheritedStyles: any = {}) => (
<Text key={node.key} style={[inheritedStyles, styles.code_block]}>
{node.content}
</Text>
),
fence: (node: any, _children: any[], _parent: any, styles: any, inheritedStyles: any = {}) => (
<Text key={node.key} style={[inheritedStyles, styles.fence]}>
{node.content}
</Text>
),
code_inline: (node: any, _children: any[], _parent: any, styles: any, inheritedStyles: any = {}) => (
<Text key={node.key} style={[inheritedStyles, styles.code_inline]}>
{node.content}
</Text>
),
bullet_list: (node: any, children: any[], _parent: any, styles: any) => (
<View key={node.key} style={styles.bullet_list}>
{children}
</View>
),
ordered_list: (node: any, children: any[], _parent: any, styles: any) => (
<View key={node.key} style={styles.ordered_list}>
{children}
</View>
),
list_item: (node: any, children: any[], parent: any, styles: any) => {
const isOrdered = parent?.type === "ordered_list";
const index = parent?.children?.indexOf(node) ?? 0;
const bullet = isOrdered ? `${index + 1}.` : "•";
const iconStyle = isOrdered ? styles.ordered_list_icon : styles.bullet_list_icon;
const contentStyle = isOrdered
? styles.ordered_list_content
: styles.bullet_list_content;
return (
<View key={node.key} style={styles.list_item}>
<Text style={iconStyle}>{bullet}</Text>
<View style={[contentStyle, { flex: 1, flexShrink: 1, minWidth: 0 }]}>
{children}
</View>
</View>
);
},
};
}, []);
if (!markdownContent) {
return <Text style={styles.emptyText}>No captured thinking</Text>;
}
return (
<Markdown style={markdownStyles} rules={markdownRules}>
{markdownContent}
</Markdown>
);
});
const styles = StyleSheet.create((theme) => ({
emptyText: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.xs,
fontStyle: "italic" as const,
},
}));

View File

@@ -48,7 +48,6 @@ import {
import { baseColors, theme } from "@/styles/theme";
import {
createMarkdownStyles,
createCompactMarkdownStyles,
} from "@/styles/markdown-styles";
import { Colors, Fonts } from "@/constants/theme";
import * as Clipboard from "expo-clipboard";
@@ -57,6 +56,8 @@ import { extractPrincipalParam } from "@/utils/tool-call-parsers";
import { getNowMs, isPerfLoggingEnabled, perfLog } from "@/utils/perf";
import { resolveToolCallPreview } from "./tool-call-preview";
import { useToolCallSheet } from "./tool-call-sheet";
import { useThinkingSheet } from "./thinking-sheet";
import { AgentThoughtContent } from "./agent-thought-content";
import {
ToolCallDetailsContent,
useToolCallDetails,
@@ -1194,172 +1195,41 @@ const ExpandableBadge = memo(function ExpandableBadge({
);
});
const agentThoughtStylesheet = StyleSheet.create((theme) => ({
emptyText: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.xs,
fontStyle: "italic" as const,
},
}));
export const AgentThoughtMessage = memo(function AgentThoughtMessage({
message,
status = "ready",
isLastInSequence = false,
disableOuterSpacing,
}: AgentThoughtMessageProps) {
const { theme } = useUnistyles();
const { openThinking } = useThinkingSheet();
const [isExpanded, setIsExpanded] = useState(false);
const markdownContent = useMemo(() => message?.trim() ?? "", [message]);
const markdownStyles = useMemo(
() => createCompactMarkdownStyles(theme),
[theme]
);
const toggle = useCallback(() => {
const isMobile =
UnistylesRuntime.breakpoint === "xs" ||
UnistylesRuntime.breakpoint === "sm";
const handleToggle = useCallback(() => {
if (isMobile) {
openThinking({ message, status });
return;
}
setIsExpanded((prev) => !prev);
}, []);
const markdownRules = useMemo(() => {
return {
text: (
node: any,
_children: ReactNode[],
_parent: any,
styles: any,
inheritedStyles: any = {}
) => (
<Text key={node.key} style={[inheritedStyles, styles.text]}>
{node.content}
</Text>
),
textgroup: (
node: any,
children: ReactNode[],
_parent: any,
styles: any,
inheritedStyles: any = {}
) => (
<Text
key={node.key}
style={[inheritedStyles, styles.textgroup]}
>
{children}
</Text>
),
code_block: (
node: any,
_children: ReactNode[],
_parent: any,
styles: any,
inheritedStyles: any = {}
) => (
<Text
key={node.key}
style={[inheritedStyles, styles.code_block]}
>
{node.content}
</Text>
),
fence: (
node: any,
_children: ReactNode[],
_parent: any,
styles: any,
inheritedStyles: any = {}
) => (
<Text key={node.key} style={[inheritedStyles, styles.fence]}>
{node.content}
</Text>
),
code_inline: (
node: any,
_children: ReactNode[],
_parent: any,
styles: any,
inheritedStyles: any = {}
) => (
<Text
key={node.key}
style={[inheritedStyles, styles.code_inline]}
>
{node.content}
</Text>
),
bullet_list: (
node: any,
children: ReactNode[],
_parent: any,
styles: any
) => (
<View key={node.key} style={styles.bullet_list}>
{children}
</View>
),
ordered_list: (
node: any,
children: ReactNode[],
_parent: any,
styles: any
) => (
<View key={node.key} style={styles.ordered_list}>
{children}
</View>
),
list_item: (
node: any,
children: ReactNode[],
parent: any,
styles: any
) => {
const isOrdered = parent?.type === "ordered_list";
const index = parent?.children?.indexOf(node) ?? 0;
const bullet = isOrdered ? `${index + 1}.` : "•";
const iconStyle = isOrdered
? styles.ordered_list_icon
: styles.bullet_list_icon;
const contentStyle = isOrdered
? styles.ordered_list_content
: styles.bullet_list_content;
return (
<View key={node.key} style={styles.list_item}>
<Text style={iconStyle}>{bullet}</Text>
<View
style={[contentStyle, { flex: 1, flexShrink: 1, minWidth: 0 }]}
>
{children}
</View>
</View>
);
},
};
}, []);
}, [isMobile, openThinking, message, status]);
const renderDetails = useCallback(() => {
if (!markdownContent) {
return (
<Text style={agentThoughtStylesheet.emptyText}>
No captured thinking
</Text>
);
}
return (
<Markdown style={markdownStyles} rules={markdownRules}>
{markdownContent}
</Markdown>
);
}, [markdownContent, markdownRules, markdownStyles]);
return <AgentThoughtContent message={message} />;
}, [message]);
return (
<ExpandableBadge
label="Thinking"
icon={status === "ready" ? Brain : undefined}
isExpanded={isExpanded}
onToggle={toggle}
renderDetails={renderDetails}
isExpanded={!isMobile && isExpanded}
onToggle={handleToggle}
renderDetails={isMobile ? () => null : renderDetails}
isLoading={status !== "ready"}
isLastInSequence={isLastInSequence}
style={isLastInSequence ? undefined : { marginBottom: theme.spacing[1] }}
disableOuterSpacing={disableOuterSpacing}
/>
);

View File

@@ -8,6 +8,7 @@ import {
ToolCall,
} from "@/components/message";
import { ToolCallSheetProvider } from "@/components/tool-call-sheet";
import { ThinkingSheetProvider } from "@/components/thinking-sheet";
import type { MessageEntry } from "@/contexts/session-context";
interface OrchestratorMessagesViewProps {
@@ -21,86 +22,88 @@ export const OrchestratorMessagesView = forwardRef<ScrollView, OrchestratorMessa
return (
<ToolCallSheetProvider>
<ScrollView
ref={ref}
style={styles.scrollView}
contentContainerStyle={styles.scrollContent}
keyboardShouldPersistTaps="handled"
keyboardDismissMode="on-drag"
>
{messages.map((msg) => {
if (msg.type === "user") {
return (
<UserMessage
key={msg.id}
message={msg.message}
timestamp={msg.timestamp}
/>
);
}
<ThinkingSheetProvider>
<ScrollView
ref={ref}
style={styles.scrollView}
contentContainerStyle={styles.scrollContent}
keyboardShouldPersistTaps="handled"
keyboardDismissMode="on-drag"
>
{messages.map((msg) => {
if (msg.type === "user") {
return (
<UserMessage
key={msg.id}
message={msg.message}
timestamp={msg.timestamp}
/>
);
}
if (msg.type === "assistant") {
return (
<AssistantMessage
key={msg.id}
message={msg.message}
timestamp={msg.timestamp}
/>
);
}
if (msg.type === "assistant") {
return (
<AssistantMessage
key={msg.id}
message={msg.message}
timestamp={msg.timestamp}
/>
);
}
if (msg.type === "activity") {
return (
<ActivityLog
key={msg.id}
type={msg.activityType}
message={msg.message}
timestamp={msg.timestamp}
metadata={msg.metadata}
onArtifactClick={onArtifactClick}
/>
);
}
if (msg.type === "activity") {
return (
<ActivityLog
key={msg.id}
type={msg.activityType}
message={msg.message}
timestamp={msg.timestamp}
metadata={msg.metadata}
onArtifactClick={onArtifactClick}
/>
);
}
if (msg.type === "artifact") {
return (
<ActivityLog
key={msg.id}
type="artifact"
message=""
timestamp={msg.timestamp}
artifactId={msg.artifactId}
artifactType={msg.artifactType}
title={msg.title}
onArtifactClick={onArtifactClick}
/>
);
}
if (msg.type === "artifact") {
return (
<ActivityLog
key={msg.id}
type="artifact"
message=""
timestamp={msg.timestamp}
artifactId={msg.artifactId}
artifactType={msg.artifactType}
title={msg.title}
onArtifactClick={onArtifactClick}
/>
);
}
if (msg.type === "tool_call") {
return (
<ToolCall
key={msg.id}
toolName={msg.toolName}
args={msg.args}
result={msg.result}
error={msg.error}
status={msg.status}
/>
);
}
if (msg.type === "tool_call") {
return (
<ToolCall
key={msg.id}
toolName={msg.toolName}
args={msg.args}
result={msg.result}
error={msg.error}
status={msg.status}
/>
);
}
return null;
})}
return null;
})}
{/* Streaming assistant message */}
{currentAssistantMessage && (
<AssistantMessage
message={currentAssistantMessage}
timestamp={Date.now()}
/>
)}
</ScrollView>
{/* Streaming assistant message */}
{currentAssistantMessage && (
<AssistantMessage
message={currentAssistantMessage}
timestamp={Date.now()}
/>
)}
</ScrollView>
</ThinkingSheetProvider>
</ToolCallSheetProvider>
);
}

View File

@@ -0,0 +1,182 @@
import React, {
createContext,
useCallback,
useContext,
useMemo,
useRef,
ReactNode,
} from "react";
import { View, Text, Pressable } from "react-native";
import { StyleSheet } from "react-native-unistyles";
import {
BottomSheetModal,
BottomSheetScrollView,
BottomSheetBackdrop,
} from "@gorhom/bottom-sheet";
import { Brain, X } from "lucide-react-native";
import type { ThoughtStatus } from "@/types/stream";
import { AgentThoughtContent } from "./agent-thought-content";
export interface ThinkingSheetData {
message: string;
status?: ThoughtStatus;
}
interface ThinkingSheetContextValue {
openThinking: (data: ThinkingSheetData) => void;
closeThinking: () => void;
}
const ThinkingSheetContext = createContext<ThinkingSheetContextValue | null>(null);
export function useThinkingSheet(): ThinkingSheetContextValue {
const context = useContext(ThinkingSheetContext);
if (!context) {
throw new Error("useThinkingSheet must be used within a ThinkingSheetProvider");
}
return context;
}
export function ThinkingSheetProvider({ children }: { children: ReactNode }) {
const bottomSheetRef = useRef<BottomSheetModal>(null);
const [sheetData, setSheetData] = React.useState<ThinkingSheetData | null>(null);
const snapPoints = useMemo(() => ["60%", "95%"], []);
const openThinking = useCallback((data: ThinkingSheetData) => {
setSheetData(data);
bottomSheetRef.current?.present();
}, []);
const closeThinking = useCallback(() => {
bottomSheetRef.current?.dismiss();
}, []);
const handleSheetChange = useCallback((index: number) => {
if (index === -1) {
setSheetData(null);
}
}, []);
const renderBackdrop = useCallback(
(props: React.ComponentProps<typeof BottomSheetBackdrop>) => (
<BottomSheetBackdrop
{...props}
disappearsOnIndex={-1}
appearsOnIndex={0}
opacity={0.5}
/>
),
[]
);
const contextValue = useMemo(
() => ({ openThinking, closeThinking }),
[openThinking, closeThinking]
);
return (
<ThinkingSheetContext.Provider value={contextValue}>
{children}
<BottomSheetModal
ref={bottomSheetRef}
snapPoints={snapPoints}
index={0}
enableDynamicSizing={false}
onChange={handleSheetChange}
backdropComponent={renderBackdrop}
enablePanDownToClose
backgroundStyle={styles.sheetBackground}
handleIndicatorStyle={styles.handleIndicator}
>
{sheetData && (
<ThinkingSheetContent data={sheetData} onClose={closeThinking} />
)}
</BottomSheetModal>
</ThinkingSheetContext.Provider>
);
}
function ThinkingSheetContent({
data,
onClose,
}: {
data: ThinkingSheetData;
onClose: () => void;
}) {
const { message } = data;
return (
<View style={styles.container}>
<View style={styles.header}>
<View style={styles.headerLeft}>
<Brain size={20} color={styles.headerIcon.color} />
<Text style={styles.headerTitle} numberOfLines={1}>
Thinking
</Text>
</View>
<Pressable onPress={onClose} style={styles.closeButton}>
<X size={20} color={styles.closeIcon.color} />
</Pressable>
</View>
<BottomSheetScrollView
style={styles.content}
contentContainerStyle={styles.contentContainer}
>
<AgentThoughtContent message={message} />
</BottomSheetScrollView>
</View>
);
}
const styles = StyleSheet.create((theme) => ({
sheetBackground: {
backgroundColor: theme.colors.surface2,
},
handleIndicator: {
backgroundColor: theme.colors.palette.zinc[600],
},
container: {
flex: 1,
backgroundColor: theme.colors.surface2,
},
header: {
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
paddingHorizontal: theme.spacing[4],
paddingVertical: theme.spacing[3],
borderBottomWidth: theme.borderWidth[1],
borderBottomColor: theme.colors.border,
},
headerLeft: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
flex: 1,
},
headerIcon: {
color: theme.colors.foreground,
},
headerTitle: {
fontSize: theme.fontSize.lg,
fontWeight: theme.fontWeight.semibold,
color: theme.colors.foreground,
flex: 1,
},
closeButton: {
padding: theme.spacing[2],
},
closeIcon: {
color: theme.colors.foregroundMuted,
},
content: {
flex: 1,
backgroundColor: theme.colors.surface2,
},
contentContainer: {
paddingHorizontal: theme.spacing[4],
paddingTop: theme.spacing[4],
paddingBottom: theme.spacing[8],
},
}));