Merge branch 'dry-review'

This commit is contained in:
Mohamed Boudra
2026-01-24 19:04:39 +07:00
14 changed files with 546 additions and 1043 deletions
-1
View File
@@ -347,7 +347,6 @@ export default function RootLayout() {
>
<Stack.Screen name="index" />
<Stack.Screen name="agents" />
<Stack.Screen name="orchestrator" />
<Stack.Screen name="agent/[id]" options={{ gestureEnabled: false }} />
<Stack.Screen name="agent/[serverId]/[agentId]" options={{ gestureEnabled: false }} />
<Stack.Screen name="settings" />
-83
View File
@@ -1,83 +0,0 @@
import { View } from "react-native";
import { useMemo, useRef, useState } from "react";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { useReanimatedKeyboardAnimation } from "react-native-keyboard-controller";
import ReanimatedAnimated, { useAnimatedStyle } from "react-native-reanimated";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { BackHeader } from "@/components/headers/back-header";
import { OrchestratorMessagesView } from "@/components/orchestrator-messages-view";
import { useSessionDirectory } from "@/hooks/use-session-directory";
import type { ScrollView } from "react-native";
import type { Artifact } from "@/components/artifact-drawer";
import type { MessageEntry } from "@/contexts/session-context";
export default function OrchestratorScreen() {
const { theme } = useUnistyles();
const insets = useSafeAreaInsets();
const sessionDirectory = useSessionDirectory();
const scrollViewRef = useRef<ScrollView>(null);
const [currentArtifact, setCurrentArtifact] = useState<Artifact | null>(null);
const aggregatedMessages = useMemo(() => {
const merged: MessageEntry[] = [];
sessionDirectory.forEach((session) => {
if (!session) {
return;
}
merged.push(...session.messages);
});
merged.sort((left, right) => left.timestamp - right.timestamp);
return merged;
}, [sessionDirectory]);
const streamingAssistantMessage = useMemo(() => {
for (const session of sessionDirectory.values()) {
if (session?.currentAssistantMessage) {
return session.currentAssistantMessage;
}
}
return "";
}, [sessionDirectory]);
// Keyboard animation
const { height: keyboardHeight } = useReanimatedKeyboardAnimation();
const animatedKeyboardStyle = useAnimatedStyle(() => {
"worklet";
const absoluteHeight = Math.abs(keyboardHeight.value);
const padding = Math.max(0, absoluteHeight - insets.bottom);
return {
paddingBottom: padding,
};
});
function handleArtifactClick(artifactId: string) {
// TODO: Implement artifact drawer
console.log("[Orchestrator] Artifact clicked:", artifactId);
}
return (
<View style={styles.container}>
{/* Header */}
<BackHeader title="Activity" />
{/* Content Area with Keyboard Animation */}
<ReanimatedAnimated.View style={[styles.content, animatedKeyboardStyle]}>
<OrchestratorMessagesView
ref={scrollViewRef}
messages={aggregatedMessages}
currentAssistantMessage={streamingAssistantMessage}
onArtifactClick={handleArtifactClick}
/>
</ReanimatedAnimated.View>
</View>
);
}
const styles = StyleSheet.create((theme) => ({
container: {
flex: 1,
backgroundColor: theme.colors.surface0,
},
content: {
flex: 1,
},
}));
@@ -34,7 +34,6 @@ import {
UserMessage,
ActivityLog,
ToolCall,
AgentThoughtMessage,
TodoListCard,
TurnCopyButton,
MessageOuterSpacingProvider,
@@ -49,7 +48,6 @@ 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";
@@ -274,9 +272,10 @@ export function AgentStreamView({
const isLastInSequence =
nextItem?.kind !== "tool_call" && nextItem?.kind !== "thought";
return (
<AgentThoughtMessage
message={item.text}
status={item.status}
<ToolCall
toolName="thinking"
args={item.text}
status={item.status === "ready" ? "completed" : "executing"}
isLastInSequence={isLastInSequence}
/>
);
@@ -533,8 +532,7 @@ export function AgentStreamView({
return (
<ToolCallSheetProvider>
<ThinkingSheetProvider>
<View style={stylesheet.container}>
<View style={stylesheet.container}>
<MessageOuterSpacingProvider disableOuterSpacing>
<FlatList
ref={flatListRef}
@@ -590,7 +588,6 @@ export function AgentStreamView({
</Animated.View>
)}
</View>
</ThinkingSheetProvider>
</ToolCallSheetProvider>
);
}
@@ -1,95 +0,0 @@
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,
},
}));
@@ -1,437 +0,0 @@
import { useState, useEffect, useCallback } from "react";
import {
Modal,
View,
Text,
Pressable,
ScrollView,
Alert,
KeyboardAvoidingView,
Platform,
} from "react-native";
import { SafeAreaView } from "react-native-safe-area-context";
import { MessageSquare, X, Plus, Trash2 } from "lucide-react-native";
import AsyncStorage from "@react-native-async-storage/async-storage";
import { StyleSheet } from "react-native-unistyles";
import type { DaemonClientV2 } from "@server/client/daemon-client-v2";
const STORAGE_KEY = "@paseo:voice-conversation-id";
interface Conversation {
id: string;
lastUpdated: string;
messageCount: number;
}
interface ConversationSelectorProps {
currentVoiceConversationId: string | null;
onSelectVoiceConversation: (voiceConversationId: string | null) => void;
client: DaemonClientV2 | null;
}
export function ConversationSelector({
currentVoiceConversationId,
onSelectVoiceConversation,
client,
}: ConversationSelectorProps) {
const [conversations, setConversations] = useState<Conversation[]>([]);
const [isOpen, setIsOpen] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const fetchConversations = useCallback(async () => {
setIsLoading(true);
if (!client) {
setIsLoading(false);
Alert.alert("Error", "Daemon unavailable.");
return;
}
try {
console.log("[ConversationSelector] Fetching conversations");
const response = await client.listVoiceConversations();
setConversations(response.conversations);
} catch (error) {
console.error(
"[ConversationSelector] Failed to list conversations:",
error
);
Alert.alert("Error", "Failed to load conversations.");
} finally {
setIsLoading(false);
}
}, [client]);
// Fetch conversations when modal opens
useEffect(() => {
if (isOpen) {
void fetchConversations();
}
}, [fetchConversations, isOpen]);
const handleDeleteConversation = useCallback(
(id: string) => {
Alert.alert(
"Delete Conversation",
"Are you sure you want to delete this conversation?",
[
{ text: "Cancel", style: "cancel" },
{
text: "Delete",
style: "destructive",
onPress: () => {
if (!client) {
Alert.alert("Error", "Daemon unavailable.");
return;
}
console.log("[ConversationSelector] Deleting conversation:", id);
void (async () => {
try {
const response = await client.deleteVoiceConversation(id);
if (response.success) {
await fetchConversations();
if (response.voiceConversationId === currentVoiceConversationId) {
await handleNewConversation();
}
} else {
Alert.alert(
"Error",
`Failed to delete conversation: ${response.error}`
);
}
} catch (error) {
console.error(
"[ConversationSelector] Failed to delete conversation:",
error
);
Alert.alert("Error", "Failed to delete conversation.");
}
})();
},
},
]
);
},
[currentVoiceConversationId, fetchConversations, handleNewConversation, client]
);
const handleClearAll = useCallback(() => {
Alert.alert(
"Clear All Conversations",
"Are you sure you want to delete all conversations?",
[
{ text: "Cancel", style: "cancel" },
{
text: "Clear All",
style: "destructive",
onPress: () => {
if (!client) {
Alert.alert("Error", "Daemon unavailable.");
return;
}
console.log("[ConversationSelector] Clearing all conversations");
void (async () => {
setIsLoading(true);
try {
for (const conv of conversations) {
await client.deleteVoiceConversation(conv.id);
}
setConversations([]);
await handleNewConversation();
setIsOpen(false);
} catch (error) {
console.error(
"[ConversationSelector] Failed to clear conversations:",
error
);
Alert.alert("Error", "Failed to clear conversations.");
} finally {
setIsLoading(false);
}
})();
},
},
]
);
}, [conversations, handleNewConversation, client]);
async function handleSelectConversation(id: string) {
try {
// Save to AsyncStorage for persistence
await AsyncStorage.setItem(STORAGE_KEY, id);
onSelectVoiceConversation(id);
setIsOpen(false);
} catch (error) {
console.error(
"[ConversationSelector] Failed to save conversation ID:",
error
);
}
}
async function handleNewConversation() {
try {
// Clear saved conversation ID
await AsyncStorage.removeItem(STORAGE_KEY);
onSelectVoiceConversation(null);
setIsOpen(false);
} catch (error) {
console.error(
"[ConversationSelector] Failed to clear conversation ID:",
error
);
}
}
function formatDate(dateString: string): string {
const date = new Date(dateString);
const now = new Date();
const diffMs = now.getTime() - date.getTime();
const diffMins = Math.floor(diffMs / 60000);
const diffHours = Math.floor(diffMs / 3600000);
const diffDays = Math.floor(diffMs / 86400000);
if (diffMins < 1) return "Just now";
if (diffMins < 60) return `${diffMins}m ago`;
if (diffHours < 24) return `${diffHours}h ago`;
if (diffDays < 7) return `${diffDays}d ago`;
return date.toLocaleDateString();
}
return (
<View>
<Pressable onPress={() => setIsOpen(true)} style={styles.triggerButton}>
<MessageSquare size={20} color="white" />
</Pressable>
<Modal
visible={isOpen}
animationType="slide"
transparent={true}
onRequestClose={() => setIsOpen(false)}
>
<View style={styles.modalOverlay}>
<Pressable
style={styles.modalBackdrop}
onPress={() => setIsOpen(false)}
/>
<View style={styles.modalContent}>
<View style={styles.modalInner}>
{/* Header */}
<View style={styles.header}>
<Text style={styles.headerTitle}>
Conversations{" "}
{conversations.length > 0 && `(${conversations.length})`}
</Text>
<Pressable onPress={() => setIsOpen(false)}>
<X size={24} color="white" />
</Pressable>
</View>
{/* New Conversation Button */}
<View style={styles.newButtonContainer}>
<Pressable
onPress={handleNewConversation}
style={styles.newButton}
>
<Plus size={20} color="white" />
<Text style={styles.newButtonText}>New Conversation</Text>
</Pressable>
</View>
{/* Conversations List */}
<ScrollView
style={styles.scrollView}
contentContainerStyle={styles.scrollContent}
keyboardShouldPersistTaps="handled"
keyboardDismissMode="on-drag"
>
{isLoading && (
<View style={styles.loadingContainer}>
<Text style={styles.loadingText}>Loading...</Text>
</View>
)}
{!isLoading && conversations.length === 0 && (
<View style={styles.emptyContainer}>
<Text style={styles.emptyText}>No saved conversations</Text>
</View>
)}
{!isLoading &&
conversations.map((conversation) => (
<View
key={conversation.id}
style={[
styles.conversationItem,
conversation.id === currentVoiceConversationId &&
styles.conversationItemActive,
]}
>
<Pressable
style={styles.conversationContent}
onPress={() =>
handleSelectConversation(conversation.id)
}
>
<Text style={styles.conversationTitle}>
{conversation.messageCount} messages
</Text>
<Text style={styles.conversationDate}>
{formatDate(conversation.lastUpdated)}
</Text>
</Pressable>
<Pressable
onPress={() =>
handleDeleteConversation(conversation.id)
}
style={styles.deleteButton}
hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}
>
<Trash2 size={20} color="#ef4444" />
</Pressable>
</View>
))}
{!isLoading && conversations.length > 0 && (
<View style={styles.clearAllContainer}>
<Pressable
onPress={handleClearAll}
style={styles.clearAllButton}
>
<Trash2 size={18} color="#ef4444" />
<Text style={styles.clearAllText}>Clear All</Text>
</Pressable>
</View>
)}
</ScrollView>
</View>
</View>
</View>
</Modal>
</View>
);
}
const styles = StyleSheet.create((theme) => ({
triggerButton: {
backgroundColor: theme.colors.surface2,
padding: theme.spacing[3],
borderRadius: theme.borderRadius.lg,
},
modalOverlay: {
flex: 1,
backgroundColor: "rgba(0,0,0,0.5)",
},
modalBackdrop: {
flex: 1,
},
modalContent: {
backgroundColor: theme.colors.surface2,
borderTopLeftRadius: theme.spacing[6],
borderTopRightRadius: theme.spacing[6],
height: "80%",
},
modalInner: {
flex: 1,
},
header: {
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
padding: theme.spacing[6],
borderBottomWidth: theme.borderWidth[1],
borderBottomColor: theme.colors.border,
},
headerTitle: {
color: theme.colors.foreground,
fontSize: theme.fontSize.lg,
fontWeight: theme.fontWeight.semibold,
},
newButtonContainer: {
padding: theme.spacing[4],
borderBottomWidth: theme.borderWidth[1],
borderBottomColor: theme.colors.border,
},
newButton: {
flexDirection: "row",
alignItems: "center",
justifyContent: "center",
gap: theme.spacing[2],
backgroundColor: theme.colors.surface2,
paddingVertical: theme.spacing[3],
borderRadius: theme.borderRadius.lg,
},
newButtonText: {
color: theme.colors.foreground,
fontWeight: theme.fontWeight.semibold,
},
scrollView: {
flex: 1,
},
scrollContent: {
paddingBottom: theme.spacing[4] * 5,
},
loadingContainer: {
padding: theme.spacing[8],
alignItems: "center",
},
loadingText: {
color: theme.colors.foregroundMuted,
},
emptyContainer: {
padding: theme.spacing[8],
alignItems: "center",
},
emptyText: {
color: theme.colors.foregroundMuted,
},
conversationItem: {
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
padding: theme.spacing[4],
borderBottomWidth: theme.borderWidth[1],
borderBottomColor: theme.colors.border,
backgroundColor: "transparent",
},
conversationItemActive: {
backgroundColor: theme.colors.surface2,
},
conversationContent: {
flex: 1,
},
conversationTitle: {
color: theme.colors.foreground,
fontWeight: theme.fontWeight.semibold,
fontSize: theme.fontSize.base,
},
conversationDate: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.sm,
marginTop: theme.spacing[1],
},
deleteButton: {
padding: theme.spacing[2],
},
clearAllContainer: {
padding: theme.spacing[4],
},
clearAllButton: {
flexDirection: "row",
alignItems: "center",
justifyContent: "center",
gap: theme.spacing[2],
backgroundColor: "rgba(127, 29, 29, 0.2)",
paddingVertical: theme.spacing[3],
borderRadius: theme.borderRadius.lg,
borderWidth: theme.borderWidth[1],
borderColor: theme.colors.palette.red[900],
},
clearAllText: {
color: theme.colors.destructive,
fontWeight: theme.fontWeight.semibold,
},
}));
@@ -1,60 +0,0 @@
import { useCallback } from "react";
import { Pressable } from "react-native";
import { router } from "expo-router";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { Settings, MessageSquare, Download, Plus } from "lucide-react-native";
import { ScreenHeader } from "./screen-header";
interface HomeHeaderProps {
onCreateAgent: () => void;
onImportAgent: () => void;
}
export function HomeHeader({ onCreateAgent, onImportAgent }: HomeHeaderProps) {
const { theme } = useUnistyles();
const handleCreatePress = useCallback(() => {
onCreateAgent();
}, [onCreateAgent]);
const handleImportPress = useCallback(() => {
onImportAgent();
}, [onImportAgent]);
return (
<ScreenHeader
left={
<Pressable
onPress={() => router.push("/settings")}
style={styles.iconButton}
>
<Settings size={20} color={theme.colors.foreground} />
</Pressable>
}
right={
<>
<Pressable
onPress={() => router.push("/orchestrator")}
style={styles.iconButton}
>
<MessageSquare size={20} color={theme.colors.foreground} />
</Pressable>
<Pressable onPress={handleImportPress} style={styles.iconButton}>
<Download size={20} color={theme.colors.foreground} />
</Pressable>
<Pressable onPress={handleCreatePress} style={styles.iconButton}>
<Plus size={20} color={theme.colors.foreground} />
</Pressable>
</>
}
/>
);
}
const styles = StyleSheet.create((theme) => ({
iconButton: {
padding: {
xs: theme.spacing[3],
md: theme.spacing[2],
},
borderRadius: theme.borderRadius.lg,
},
}));
+3 -50
View File
@@ -51,13 +51,11 @@ import {
} from "@/styles/markdown-styles";
import { Colors, Fonts } from "@/constants/theme";
import * as Clipboard from "expo-clipboard";
import type { TodoEntry, ThoughtStatus } from "@/types/stream";
import type { TodoEntry } from "@/types/stream";
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,
@@ -1051,13 +1049,6 @@ export const TodoListCard = memo(function TodoListCard({
);
});
interface AgentThoughtMessageProps {
message: string;
status?: ThoughtStatus;
isLastInSequence?: boolean;
disableOuterSpacing?: boolean;
}
interface ExpandableBadgeProps {
label: string;
secondaryLabel?: string;
@@ -1195,46 +1186,6 @@ const ExpandableBadge = memo(function ExpandableBadge({
);
});
export const AgentThoughtMessage = memo(function AgentThoughtMessage({
message,
status = "ready",
isLastInSequence = false,
disableOuterSpacing,
}: AgentThoughtMessageProps) {
const { openThinking } = useThinkingSheet();
const [isExpanded, setIsExpanded] = useState(false);
const isMobile =
UnistylesRuntime.breakpoint === "xs" ||
UnistylesRuntime.breakpoint === "sm";
const handleToggle = useCallback(() => {
if (isMobile) {
openThinking({ message, status });
return;
}
setIsExpanded((prev) => !prev);
}, [isMobile, openThinking, message, status]);
const renderDetails = useCallback(() => {
return <AgentThoughtContent message={message} />;
}, [message]);
return (
<ExpandableBadge
label="Thinking"
icon={status === "ready" ? Brain : undefined}
isExpanded={!isMobile && isExpanded}
onToggle={handleToggle}
renderDetails={isMobile ? () => null : renderDetails}
isLoading={status !== "ready"}
isLastInSequence={isLastInSequence}
style={isLastInSequence ? undefined : { marginBottom: theme.spacing[1] }}
disableOuterSpacing={disableOuterSpacing}
/>
);
});
interface ToolCallProps {
toolName: string;
args: any;
@@ -1252,6 +1203,7 @@ const toolKindIcons: Record<string, any> = {
read: Eye,
execute: SquareTerminal,
search: Search,
thinking: Brain,
};
const TOOL_CALL_LOG_TAG = "[ToolCall]";
const TOOL_CALL_COMMIT_THRESHOLD_MS = 16;
@@ -1259,6 +1211,7 @@ const TOOL_CALL_COMMIT_THRESHOLD_MS = 16;
// Derive tool kind from tool name for icon selection
function getToolKindFromName(toolName: string): string {
const lower = toolName.toLowerCase();
if (lower === "thinking") return "thinking";
if (lower === "read" || lower === "read_file" || lower.startsWith("read"))
return "read";
if (lower === "edit" || lower === "write" || lower === "apply_patch")
@@ -1,121 +0,0 @@
import { ScrollView } from "react-native";
import { StyleSheet } from "react-native-unistyles";
import { forwardRef } from "react";
import {
UserMessage,
AssistantMessage,
ActivityLog,
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 {
messages: MessageEntry[];
currentAssistantMessage: string;
onArtifactClick: (artifactId: string) => void;
}
export const OrchestratorMessagesView = forwardRef<ScrollView, OrchestratorMessagesViewProps>(
function OrchestratorMessagesView({ messages, currentAssistantMessage, onArtifactClick }, ref) {
return (
<ToolCallSheetProvider>
<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 === "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 === "tool_call") {
return (
<ToolCall
key={msg.id}
toolName={msg.toolName}
args={msg.args}
result={msg.result}
error={msg.error}
status={msg.status}
/>
);
}
return null;
})}
{/* Streaming assistant message */}
{currentAssistantMessage && (
<AssistantMessage
message={currentAssistantMessage}
timestamp={Date.now()}
/>
)}
</ScrollView>
</ThinkingSheetProvider>
</ToolCallSheetProvider>
);
}
);
const styles = StyleSheet.create((theme) => ({
scrollView: {
flex: 1,
},
scrollContent: {
paddingHorizontal: theme.spacing[4],
paddingTop: theme.spacing[4],
paddingBottom: theme.spacing[6],
},
}));
@@ -1,182 +0,0 @@
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],
},
}));
@@ -87,6 +87,14 @@ function summarizeToolCallDisplay(display: ToolCallDisplay): ToolCallDisplaySumm
},
};
}
case "thinking": {
const contentLength = display.content.length;
return {
displayType: display.type,
totalChars: contentLength,
detail: { contentLength },
};
}
default:
return assertNever(display);
}
@@ -227,6 +235,20 @@ export function ToolCallDetailsContent({
) : null}
</View>
);
} else if (display.type === "thinking") {
// Thinking: display the content as plain text
sections.push(
<View key="thinking" style={styles.section}>
<ScrollView
style={[styles.scrollArea, { maxHeight }]}
contentContainerStyle={styles.scrollContent}
nestedScrollEnabled
showsVerticalScrollIndicator={true}
>
<Text selectable style={styles.scrollText}>{display.content}</Text>
</ScrollView>
</View>
);
} else {
// Generic tool: show input/output as key-value pairs
if (display.input.length > 0) {
@@ -1168,6 +1168,7 @@ const TOOL_NAME_MAP: Record<string, string> = {
set_branch: "Set branch",
"mcp__agent-control__set_title": "Set title",
"mcp__agent-control__set_branch": "Set branch",
thinking: "Thinking",
};
const ToolCallDisplaySchema = z
@@ -1179,6 +1180,16 @@ const ToolCallDisplaySchema = z
.transform((data) => {
const normalizedToolName = TOOL_NAME_MAP[data.toolName] ?? data.toolName;
// Handle thinking - input is the thinking text content
if (data.toolName === "thinking") {
const content = typeof data.input === "string" ? data.input : "";
return {
type: "thinking" as const,
content,
toolName: normalizedToolName,
};
}
// Try each schema in order
const shellParsed = ShellToolCallSchema.safeParse({ input: data.input, result: data.result });
if (shellParsed.success) {