wip: tool call structure investigation and e2e tests

- Add debug logging for tool call payloads in agent-stream-view
- Add e2e tests to verify tool call structure for Claude and Codex agents
- Include sidebar toggle and horizontal scroll improvements from previous work

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Mohamed Boudra
2026-01-07 10:30:29 +07:00
co-authored by Claude Opus 4.5
parent 351735a71b
commit 4f3b2317ab
11 changed files with 398 additions and 80 deletions
+38 -5
View File
@@ -15,11 +15,15 @@ import { useState, useEffect, type ReactNode, useMemo } from "react";
import { Platform } from "react-native"; import { Platform } from "react-native";
import { SlidingSidebar } from "@/components/sliding-sidebar"; import { SlidingSidebar } from "@/components/sliding-sidebar";
import { useSidebarStore } from "@/stores/sidebar-store"; import { useSidebarStore } from "@/stores/sidebar-store";
import { runOnJS, interpolate, Extrapolation } from "react-native-reanimated"; import { runOnJS, interpolate, Extrapolation, useSharedValue } from "react-native-reanimated";
import { import {
SidebarAnimationProvider, SidebarAnimationProvider,
useSidebarAnimation, useSidebarAnimation,
} from "@/contexts/sidebar-animation-context"; } from "@/contexts/sidebar-animation-context";
import {
HorizontalScrollProvider,
useHorizontalScrollOptional,
} from "@/contexts/horizontal-scroll-context";
function QueryProvider({ children }: { children: ReactNode }) { function QueryProvider({ children }: { children: ReactNode }) {
const [queryClient] = useState( const [queryClient] = useState(
@@ -48,6 +52,7 @@ interface AppContainerProps {
function AppContainer({ children, selectedAgentId }: AppContainerProps) { function AppContainer({ children, selectedAgentId }: AppContainerProps) {
const { theme } = useUnistyles(); const { theme } = useUnistyles();
const { isOpen, open, toggle } = useSidebarStore(); const { isOpen, open, toggle } = useSidebarStore();
const horizontalScroll = useHorizontalScrollOptional();
// Cmd+B to toggle sidebar (web only) // Cmd+B to toggle sidebar (web only)
useEffect(() => { useEffect(() => {
@@ -72,15 +77,41 @@ function AppContainer({ children, selectedAgentId }: AppContainerProps) {
const isMobile = const isMobile =
UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm"; UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
// Track initial touch position for manual activation
const touchStartX = useSharedValue(0);
// Open gesture: swipe right from anywhere to open sidebar (interactive drag) // Open gesture: swipe right from anywhere to open sidebar (interactive drag)
// If any horizontal scroll is scrolled right, let the scroll view handle the gesture first
const openGesture = useMemo( const openGesture = useMemo(
() => () =>
Gesture.Pan() Gesture.Pan()
.enabled(isMobile && !isOpen) .enabled(isMobile && !isOpen)
// Only activate after 15px horizontal movement to the right .manualActivation(true)
.activeOffsetX(15)
// Fail if 10px vertical movement happens first (allow vertical scroll) // Fail if 10px vertical movement happens first (allow vertical scroll)
.failOffsetY([-10, 10]) .failOffsetY([-10, 10])
.onTouchesDown((event) => {
const touch = event.changedTouches[0];
if (touch) {
touchStartX.value = touch.absoluteX;
}
})
.onTouchesMove((event, stateManager) => {
const touch = event.changedTouches[0];
if (!touch || event.numberOfTouches !== 1) return;
const deltaX = touch.absoluteX - touchStartX.value;
// If horizontal scroll is scrolled right, fail so ScrollView handles it
if (horizontalScroll?.isAnyScrolledRight.value) {
stateManager.fail();
return;
}
// Activate after 15px rightward movement
if (deltaX > 15) {
stateManager.activate();
}
})
.onStart(() => { .onStart(() => {
isGesturing.value = true; isGesturing.value = true;
}) })
@@ -109,7 +140,7 @@ function AppContainer({ children, selectedAgentId }: AppContainerProps) {
.onFinalize(() => { .onFinalize(() => {
isGesturing.value = false; isGesturing.value = false;
}), }),
[isMobile, isOpen, windowWidth, translateX, backdropOpacity, animateToOpen, animateToClose, open, isGesturing] [isMobile, isOpen, windowWidth, translateX, backdropOpacity, animateToOpen, animateToClose, open, isGesturing, horizontalScroll?.isAnyScrolledRight, touchStartX]
); );
const content = ( const content = (
@@ -221,7 +252,8 @@ export default function RootLayout() {
<MultiDaemonSessionHost /> <MultiDaemonSessionHost />
<ProvidersWrapper> <ProvidersWrapper>
<SidebarAnimationProvider> <SidebarAnimationProvider>
<AppWithSidebar> <HorizontalScrollProvider>
<AppWithSidebar>
<Stack <Stack
screenOptions={{ screenOptions={{
headerShown: false, headerShown: false,
@@ -242,6 +274,7 @@ export default function RootLayout() {
<Stack.Screen name="file-explorer" /> <Stack.Screen name="file-explorer" />
</Stack> </Stack>
</AppWithSidebar> </AppWithSidebar>
</HorizontalScrollProvider>
</SidebarAnimationProvider> </SidebarAnimationProvider>
</ProvidersWrapper> </ProvidersWrapper>
</DaemonConnectionsProvider> </DaemonConnectionsProvider>
@@ -33,7 +33,7 @@ import {
Users, Users,
ChevronRight, ChevronRight,
PlusIcon, PlusIcon,
PanelRightOpen, PanelRight,
} from "lucide-react-native"; } from "lucide-react-native";
import { MenuHeader } from "@/components/headers/menu-header"; import { MenuHeader } from "@/components/headers/menu-header";
import { BackHeader } from "@/components/headers/back-header"; import { BackHeader } from "@/components/headers/back-header";
@@ -679,18 +679,29 @@ function AgentScreenContent({
rightContent={ rightContent={
<View style={styles.headerRightContent}> <View style={styles.headerRightContent}>
<Pressable onPress={toggleExplorer} style={styles.menuButton}> <Pressable onPress={toggleExplorer} style={styles.menuButton}>
<PanelRightOpen {isMobile ? (
size={20} <Folder
color={ size={16}
isExplorerOpen color={
? theme.colors.foreground isExplorerOpen
: theme.colors.mutedForeground ? theme.colors.foreground
} : theme.colors.mutedForeground
/> }
/>
) : (
<PanelRight
size={16}
color={
isExplorerOpen
? theme.colors.foreground
: theme.colors.mutedForeground
}
/>
)}
</Pressable> </Pressable>
<View ref={menuButtonRef} collapsable={false}> <View ref={menuButtonRef} collapsable={false}>
<Pressable onPress={handleOpenMenu} style={styles.menuButton}> <Pressable onPress={handleOpenMenu} style={styles.menuButton}>
<MoreVertical size={20} color={theme.colors.mutedForeground} /> <MoreVertical size={16} color={theme.colors.mutedForeground} />
</Pressable> </Pressable>
</View> </View>
</View> </View>
+2 -3
View File
@@ -1,5 +1,4 @@
import { View, Text, Pressable, Modal, RefreshControl, type ListRenderItem } from "react-native"; import { View, Text, Pressable, Modal, RefreshControl, FlatList, type ListRenderItem } from "react-native";
import { FlatList } from "react-native-gesture-handler";
import { useCallback, useState, type ReactElement } from "react"; import { useCallback, useState, type ReactElement } from "react";
import { router, usePathname } from "expo-router"; import { router, usePathname } from "expo-router";
import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { StyleSheet, useUnistyles } from "react-native-unistyles";
@@ -222,7 +221,7 @@ const styles = StyleSheet.create((theme) => ({
}, },
listContent: { listContent: {
paddingHorizontal: theme.spacing[4], paddingHorizontal: theme.spacing[4],
paddingTop: theme.spacing[4], paddingTop: theme.spacing[2],
paddingBottom: theme.spacing[4], paddingBottom: theme.spacing[4],
}, },
agentItem: { agentItem: {
@@ -283,6 +283,8 @@ export function AgentStreamView({
case "tool_call": { case "tool_call": {
const { payload } = item; const { payload } = item;
console.log("[TOOL_CALL_DEBUG]", JSON.stringify(payload, null, 2));
if (payload.source === "agent") { if (payload.source === "agent") {
const data = payload.data; const data = payload.data;
const toolLabel = data.displayName ?? `${data.server}/${data.tool}`; const toolLabel = data.displayName ?? `${data.server}/${data.tool}`;
@@ -16,6 +16,7 @@ import {
type ViewMode, type ViewMode,
} from "@/stores/explorer-sidebar-store"; } from "@/stores/explorer-sidebar-store";
import { useExplorerSidebarAnimation } from "@/contexts/explorer-sidebar-animation-context"; import { useExplorerSidebarAnimation } from "@/contexts/explorer-sidebar-animation-context";
import { HEADER_INNER_HEIGHT } from "@/constants/layout";
import { GitDiffPane } from "./git-diff-pane"; import { GitDiffPane } from "./git-diff-pane";
import { FileExplorerPane } from "./file-explorer-pane"; import { FileExplorerPane } from "./file-explorer-pane";
@@ -187,16 +188,14 @@ export function ExplorerSidebar({ serverId, agentId }: ExplorerSidebarProps) {
return ( return (
<Animated.View style={[styles.desktopSidebar, resizeAnimatedStyle]}> <Animated.View style={[styles.desktopSidebar, resizeAnimatedStyle]}>
{/* Resize handle on left edge */} {/* Resize handle - absolutely positioned over left border */}
<GestureDetector gesture={resizeGesture}> <GestureDetector gesture={resizeGesture}>
<View <View
style={[ style={[
styles.resizeHandle, styles.resizeHandle,
Platform.OS === "web" && ({ cursor: "col-resize" } as any), Platform.OS === "web" && ({ cursor: "col-resize" } as any),
]} ]}
> />
<View style={styles.resizeHandleInner} />
</View>
</GestureDetector> </GestureDetector>
<SidebarContent <SidebarContent
@@ -239,7 +238,7 @@ function SidebarContent({
return ( return (
<View style={styles.sidebarContent} pointerEvents="auto"> <View style={styles.sidebarContent} pointerEvents="auto">
{/* Header with tabs and close button */} {/* Header with tabs and close button */}
<View style={styles.header}> <View style={styles.header} testID="explorer-header">
<View style={styles.tabsContainer}> <View style={styles.tabsContainer}>
<Pressable <Pressable
style={[styles.tab, activeTab === "changes" && styles.tabActive]} style={[styles.tab, activeTab === "changes" && styles.tabActive]}
@@ -297,7 +296,7 @@ function SidebarContent({
</View> </View>
{/* Content based on active tab */} {/* Content based on active tab */}
<View style={styles.contentArea}> <View style={styles.contentArea} testID="explorer-content-area">
{activeTab === "changes" ? ( {activeTab === "changes" ? (
<GitDiffPane serverId={serverId} agentId={agentId} /> <GitDiffPane serverId={serverId} agentId={agentId} />
) : ( ) : (
@@ -355,22 +354,18 @@ const styles = StyleSheet.create((theme) => ({
overflow: "hidden", overflow: "hidden",
}, },
desktopSidebar: { desktopSidebar: {
flexDirection: "row", position: "relative",
borderLeftWidth: 1, borderLeftWidth: 1,
borderLeftColor: theme.colors.border, borderLeftColor: theme.colors.border,
backgroundColor: theme.colors.background, backgroundColor: theme.colors.background,
}, },
resizeHandle: { resizeHandle: {
width: 8, position: "absolute",
alignItems: "center", left: -5,
justifyContent: "center", top: 0,
}, bottom: 0,
resizeHandleInner: { width: 10,
width: 2, zIndex: 10,
height: 32,
backgroundColor: theme.colors.border,
borderRadius: 1,
opacity: 0.5,
}, },
sidebarContent: { sidebarContent: {
flex: 1, flex: 1,
@@ -378,11 +373,11 @@ const styles = StyleSheet.create((theme) => ({
overflow: "hidden", overflow: "hidden",
}, },
header: { header: {
height: HEADER_INNER_HEIGHT,
flexDirection: "row", flexDirection: "row",
alignItems: "center", alignItems: "center",
justifyContent: "space-between", justifyContent: "space-between",
paddingHorizontal: theme.spacing[2], paddingHorizontal: theme.spacing[2],
paddingVertical: theme.spacing[1],
borderBottomWidth: 1, borderBottomWidth: 1,
borderBottomColor: theme.colors.border, borderBottomColor: theme.colors.border,
}, },
+66 -29
View File
@@ -1,5 +1,13 @@
import { useState, useCallback } from "react"; import { useState, useCallback, useEffect, useId } from "react";
import { View, Text, ActivityIndicator, Pressable, RefreshControl } from "react-native"; import {
View,
Text,
ActivityIndicator,
Pressable,
RefreshControl,
type NativeSyntheticEvent,
type NativeScrollEvent,
} from "react-native";
import { ScrollView } from "react-native-gesture-handler"; import { ScrollView } from "react-native-gesture-handler";
import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { ChevronRight } from "lucide-react-native"; import { ChevronRight } from "lucide-react-native";
@@ -10,6 +18,7 @@ import {
type DiffLine, type DiffLine,
type HighlightToken, type HighlightToken,
} from "@/hooks/use-highlighted-diff-query"; } from "@/hooks/use-highlighted-diff-query";
import { useHorizontalScrollOptional } from "@/contexts/horizontal-scroll-context";
type HighlightStyle = NonNullable<HighlightToken["style"]>; type HighlightStyle = NonNullable<HighlightToken["style"]>;
@@ -70,6 +79,7 @@ function HighlightedText({ tokens, lineType }: HighlightedTextProps) {
interface DiffFileSectionProps { interface DiffFileSectionProps {
file: ParsedDiffFile; file: ParsedDiffFile;
defaultExpanded?: boolean; defaultExpanded?: boolean;
testID?: string;
} }
function DiffLineView({ line }: { line: DiffLine }) { function DiffLineView({ line }: { line: DiffLine }) {
@@ -106,16 +116,37 @@ function DiffLineView({ line }: { line: DiffLine }) {
); );
} }
function DiffFileSection({ file, defaultExpanded = true }: DiffFileSectionProps) { function DiffFileSection({ file, defaultExpanded = true, testID }: DiffFileSectionProps) {
const { theme } = useUnistyles(); const { theme } = useUnistyles();
const [isExpanded, setIsExpanded] = useState(defaultExpanded); const [isExpanded, setIsExpanded] = useState(defaultExpanded);
const horizontalScroll = useHorizontalScrollOptional();
const scrollId = useId();
const toggleExpanded = useCallback(() => { const toggleExpanded = useCallback(() => {
setIsExpanded((prev) => !prev); setIsExpanded((prev) => !prev);
}, []); }, []);
// Register/unregister scroll offset tracking
useEffect(() => {
if (!horizontalScroll || !isExpanded) return;
// Start at 0 (not scrolled)
horizontalScroll.registerScrollOffset(scrollId, 0);
return () => {
horizontalScroll.unregisterScrollOffset(scrollId);
};
}, [horizontalScroll, isExpanded, scrollId]);
const handleScroll = useCallback(
(event: NativeSyntheticEvent<NativeScrollEvent>) => {
if (!horizontalScroll) return;
const offsetX = event.nativeEvent.contentOffset.x;
horizontalScroll.registerScrollOffset(scrollId, offsetX);
},
[horizontalScroll, scrollId]
);
return ( return (
<View style={styles.fileSection}> <View style={styles.fileSection} testID={testID}>
<Pressable <Pressable
style={({ pressed }) => [ style={({ pressed }) => [
styles.fileHeader, styles.fileHeader,
@@ -154,8 +185,11 @@ function DiffFileSection({ file, defaultExpanded = true }: DiffFileSectionProps)
horizontal horizontal
nestedScrollEnabled nestedScrollEnabled
showsHorizontalScrollIndicator showsHorizontalScrollIndicator
bounces={false}
style={styles.diffContent} style={styles.diffContent}
contentContainerStyle={styles.diffContentInner} contentContainerStyle={styles.diffContentInner}
onScroll={handleScroll}
scrollEventThrottle={16}
> >
<View style={styles.linesContainer}> <View style={styles.linesContainer}>
{file.hunks.map((hunk, hunkIndex) => {file.hunks.map((hunk, hunkIndex) =>
@@ -200,7 +234,7 @@ export function GitDiffPane({ serverId, agentId }: GitDiffPaneProps) {
return ( return (
<ScrollView <ScrollView
style={styles.scrollView} style={styles.scrollView}
contentContainerStyle={styles.contentContainer} testID="git-diff-scroll"
refreshControl={ refreshControl={
<RefreshControl <RefreshControl
refreshing={isFetching && !isLoading} refreshing={isFetching && !isLoading}
@@ -210,24 +244,26 @@ export function GitDiffPane({ serverId, agentId }: GitDiffPaneProps) {
/> />
} }
> >
{isLoading ? ( <View style={styles.contentContainer} testID="git-diff-content">
<View style={styles.loadingContainer}> {isLoading ? (
<ActivityIndicator size="large" /> <View style={styles.loadingContainer}>
<Text style={styles.loadingText}>Loading changes...</Text> <ActivityIndicator size="large" />
</View> <Text style={styles.loadingText}>Loading changes...</Text>
) : isError ? ( </View>
<View style={styles.errorContainer}> ) : isError ? (
<Text style={styles.errorText}>{errorMessage ?? "Failed to load changes"}</Text> <View style={styles.errorContainer}>
</View> <Text style={styles.errorText}>{errorMessage ?? "Failed to load changes"}</Text>
) : !hasChanges ? ( </View>
<View style={styles.emptyContainer}> ) : !hasChanges ? (
<Text style={styles.emptyText}>No changes</Text> <View style={styles.emptyContainer}>
</View> <Text style={styles.emptyText}>No changes</Text>
) : ( </View>
files.map((file, fileIndex) => ( ) : (
<DiffFileSection key={fileIndex} file={file} /> files.map((file, fileIndex) => (
)) <DiffFileSection key={fileIndex} file={file} testID={`diff-file-${fileIndex}`} />
)} ))
)}
</View>
</ScrollView> </ScrollView>
); );
} }
@@ -238,7 +274,7 @@ const styles = StyleSheet.create((theme) => ({
}, },
contentContainer: { contentContainer: {
paddingHorizontal: theme.spacing[2], paddingHorizontal: theme.spacing[2],
paddingTop: theme.spacing[2], paddingTop: theme.spacing[3],
paddingBottom: theme.spacing[8], paddingBottom: theme.spacing[8],
}, },
loadingContainer: { loadingContainer: {
@@ -346,7 +382,7 @@ const styles = StyleSheet.create((theme) => ({
diffContent: { diffContent: {
borderTopWidth: theme.borderWidth[1], borderTopWidth: theme.borderWidth[1],
borderTopColor: theme.colors.border, borderTopColor: theme.colors.border,
backgroundColor: "#0d1117", // GitHub dark background backgroundColor: "#0d1117",
}, },
diffContentInner: { diffContentInner: {
flexDirection: "column", flexDirection: "column",
@@ -354,6 +390,7 @@ const styles = StyleSheet.create((theme) => ({
linesContainer: { linesContainer: {
alignSelf: "flex-start", alignSelf: "flex-start",
minWidth: "100%", minWidth: "100%",
backgroundColor: "#0d1117",
}, },
diffLineContainer: { diffLineContainer: {
paddingHorizontal: theme.spacing[3], paddingHorizontal: theme.spacing[3],
@@ -365,25 +402,25 @@ const styles = StyleSheet.create((theme) => ({
color: theme.colors.foreground, color: theme.colors.foreground,
}, },
addLineContainer: { addLineContainer: {
backgroundColor: "rgba(46, 160, 67, 0.1)", // GitHub green with transparency backgroundColor: "rgba(46, 160, 67, 0.15)", // GitHub green
}, },
addLineText: { addLineText: {
color: "#c9d1d9", // Same text color as all code color: "#c9d1d9", // Same text color as all code
}, },
removeLineContainer: { removeLineContainer: {
backgroundColor: "rgba(248, 81, 73, 0.1)", // GitHub red with transparency backgroundColor: "rgba(248, 81, 73, 0.1)", // GitHub red
}, },
removeLineText: { removeLineText: {
color: "#c9d1d9", // Same text color as all code color: "#c9d1d9", // Same text color as all code
}, },
headerLineContainer: { headerLineContainer: {
backgroundColor: "#161b22", // GitHub dark header backgroundColor: theme.colors.muted,
}, },
headerLineText: { headerLineText: {
color: theme.colors.mutedForeground, color: theme.colors.mutedForeground,
}, },
contextLineContainer: { contextLineContainer: {
backgroundColor: "#0d1117", // GitHub dark background backgroundColor: "#0d1117",
}, },
contextLineText: { contextLineText: {
color: theme.colors.mutedForeground, color: theme.colors.mutedForeground,
@@ -1,7 +1,7 @@
import type { ReactNode } from "react"; import type { ReactNode } from "react";
import { Pressable, Text } from "react-native"; import { Pressable, Text } from "react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles";
import { Menu } from "lucide-react-native"; import { Menu, PanelLeft } from "lucide-react-native";
import { ScreenHeader } from "./screen-header"; import { ScreenHeader } from "./screen-header";
import { useSidebarStore } from "@/stores/sidebar-store"; import { useSidebarStore } from "@/stores/sidebar-store";
@@ -12,14 +12,21 @@ interface MenuHeaderProps {
export function MenuHeader({ title, rightContent }: MenuHeaderProps) { export function MenuHeader({ title, rightContent }: MenuHeaderProps) {
const { theme } = useUnistyles(); const { theme } = useUnistyles();
const { toggle } = useSidebarStore(); const { isOpen, toggle } = useSidebarStore();
const isMobile =
UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
const MenuIcon = isMobile ? Menu : PanelLeft;
const menuIconColor = !isMobile && isOpen
? theme.colors.foreground
: theme.colors.mutedForeground;
return ( return (
<ScreenHeader <ScreenHeader
left={ left={
<> <>
<Pressable onPress={toggle} style={styles.menuButton}> <Pressable onPress={toggle} style={styles.menuButton}>
<Menu size={20} color={theme.colors.mutedForeground} /> <MenuIcon size={16} color={menuIconColor} />
</Pressable> </Pressable>
{title && ( {title && (
<Text style={styles.title} numberOfLines={1}> <Text style={styles.title} numberOfLines={1}>
@@ -1,7 +1,8 @@
import type { ReactNode } from "react"; import type { ReactNode } from "react";
import { View, type StyleProp, type ViewStyle } from "react-native"; import { View, type StyleProp, type ViewStyle } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context"; import { useSafeAreaInsets } from "react-native-safe-area-context";
import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles"; import { StyleSheet, UnistylesRuntime } from "react-native-unistyles";
import { HEADER_INNER_HEIGHT } from "@/constants/layout";
interface ScreenHeaderProps { interface ScreenHeaderProps {
left?: ReactNode; left?: ReactNode;
@@ -16,8 +17,9 @@ interface ScreenHeaderProps {
*/ */
export function ScreenHeader({ left, right, leftStyle, rightStyle }: ScreenHeaderProps) { export function ScreenHeader({ left, right, leftStyle, rightStyle }: ScreenHeaderProps) {
const insets = useSafeAreaInsets(); const insets = useSafeAreaInsets();
const { theme } = useUnistyles(); const isMobile = UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
const topPadding = UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm" ? 8 : 4; // Only add extra padding on mobile for better touch targets; on desktop, only use safe area insets
const topPadding = isMobile ? 8 : 0;
return ( return (
<View style={styles.header}> <View style={styles.header}>
@@ -37,17 +39,11 @@ const styles = StyleSheet.create((theme) => ({
}, },
inner: {}, inner: {},
row: { row: {
height: HEADER_INNER_HEIGHT,
flexDirection: "row", flexDirection: "row",
alignItems: "center", alignItems: "center",
justifyContent: "space-between", justifyContent: "space-between",
paddingHorizontal: { paddingHorizontal: theme.spacing[2],
xs: theme.spacing[2],
md: theme.spacing[1],
},
paddingBottom: {
xs: theme.spacing[2],
md: theme.spacing[1],
},
borderBottomWidth: theme.borderWidth[1], borderBottomWidth: theme.borderWidth[1],
borderBottomColor: theme.colors.border, borderBottomColor: theme.colors.border,
}, },
+5
View File
@@ -1 +1,6 @@
export const FOOTER_HEIGHT = 75; export const FOOTER_HEIGHT = 75;
// Shared header inner height (excluding safe area insets and border)
// Used by both agent header (ScreenHeader) and explorer sidebar header
// This ensures both headers have the same visual height
export const HEADER_INNER_HEIGHT = 48;
@@ -0,0 +1,68 @@
import { createContext, useContext, useCallback, useRef, type ReactNode } from "react";
import { useSharedValue, type SharedValue } from "react-native-reanimated";
interface HorizontalScrollContextValue {
// Shared value indicating if any registered horizontal scroll is scrolled (offset > 0)
isAnyScrolledRight: SharedValue<boolean>;
// Register a scroll view's offset - returns an unregister function
registerScrollOffset: (id: string, offset: number) => void;
unregisterScrollOffset: (id: string) => void;
}
const HorizontalScrollContext = createContext<HorizontalScrollContextValue | null>(null);
export function HorizontalScrollProvider({ children }: { children: ReactNode }) {
const isAnyScrolledRight = useSharedValue(false);
const scrollOffsetsRef = useRef<Map<string, number>>(new Map());
const updateIsAnyScrolled = useCallback(() => {
let anyScrolled = false;
for (const offset of scrollOffsetsRef.current.values()) {
if (offset > 1) {
anyScrolled = true;
break;
}
}
isAnyScrolledRight.value = anyScrolled;
}, [isAnyScrolledRight]);
const registerScrollOffset = useCallback(
(id: string, offset: number) => {
scrollOffsetsRef.current.set(id, offset);
updateIsAnyScrolled();
},
[updateIsAnyScrolled]
);
const unregisterScrollOffset = useCallback(
(id: string) => {
scrollOffsetsRef.current.delete(id);
updateIsAnyScrolled();
},
[updateIsAnyScrolled]
);
return (
<HorizontalScrollContext.Provider
value={{
isAnyScrolledRight,
registerScrollOffset,
unregisterScrollOffset,
}}
>
{children}
</HorizontalScrollContext.Provider>
);
}
export function useHorizontalScroll() {
const context = useContext(HorizontalScrollContext);
if (!context) {
throw new Error("useHorizontalScroll must be used within HorizontalScrollProvider");
}
return context;
}
export function useHorizontalScrollOptional() {
return useContext(HorizontalScrollContext);
}
@@ -3115,4 +3115,169 @@ describe("daemon E2E", () => {
180000 // 3 minute timeout 180000 // 3 minute timeout
); );
}); });
describe("tool call structure", () => {
test(
"Claude agent tool calls have expected structure",
async () => {
const cwd = tmpCwd();
// Create Claude agent with bypass permissions
const agent = await ctx.client.createAgent({
provider: "claude",
cwd,
title: "Tool Structure Test - Claude",
modeId: "bypassPermissions",
});
expect(agent.provider).toBe("claude");
ctx.client.clearMessageQueue();
// Prompt that triggers a Read tool call
await ctx.client.sendMessage(
agent.id,
"Read the file /etc/hosts and tell me how many lines it has. Be brief."
);
const finalState = await ctx.client.waitForAgentIdle(agent.id, 120000);
expect(finalState.status).toBe("idle");
// Extract tool_call timeline items
const queue = ctx.client.getMessageQueue();
const toolCalls: AgentTimelineItem[] = [];
for (const m of queue) {
if (
m.type === "agent_stream" &&
m.payload.agentId === agent.id &&
m.payload.event.type === "timeline" &&
m.payload.event.item.type === "tool_call"
) {
toolCalls.push(m.payload.event.item);
}
}
expect(toolCalls.length).toBeGreaterThan(0);
// Log and verify structure for each tool call
for (const tc of toolCalls) {
expect(tc.type).toBe("tool_call");
// Current structure has: server, tool, displayName, kind
expect(typeof tc.server).toBe("string");
expect(typeof tc.tool).toBe("string");
console.log(
"[CLAUDE TOOL_CALL]",
JSON.stringify({
server: tc.server,
tool: tc.tool,
displayName: tc.displayName,
kind: tc.kind,
callId: tc.callId,
status: tc.status,
hasInput: tc.input !== undefined,
hasOutput: tc.output !== undefined,
})
);
}
// Find a Read tool call specifically
const readCall = toolCalls.find(
(tc) =>
tc.server === "Read" ||
tc.tool === "Read" ||
tc.displayName === "Read"
);
expect(readCall).toBeDefined();
expect(readCall?.input).toBeDefined();
// Cleanup
await ctx.client.deleteAgent(agent.id);
rmSync(cwd, { recursive: true, force: true });
},
180000
);
test(
"Codex agent tool calls have expected structure",
async () => {
const cwd = tmpCwd();
// Create Codex agent with full access
const agent = await ctx.client.createAgent({
provider: "codex",
cwd,
title: "Tool Structure Test - Codex",
modeId: "full-access",
});
expect(agent.provider).toBe("codex");
ctx.client.clearMessageQueue();
// Prompt that triggers a shell command
await ctx.client.sendMessage(
agent.id,
"Run `echo hello` and tell me what it outputs. Be brief."
);
const finalState = await ctx.client.waitForAgentIdle(agent.id, 120000);
expect(finalState.status).toBe("idle");
// Extract tool_call timeline items
const queue = ctx.client.getMessageQueue();
const toolCalls: AgentTimelineItem[] = [];
for (const m of queue) {
if (
m.type === "agent_stream" &&
m.payload.agentId === agent.id &&
m.payload.event.type === "timeline" &&
m.payload.event.item.type === "tool_call"
) {
toolCalls.push(m.payload.event.item);
}
}
expect(toolCalls.length).toBeGreaterThan(0);
// Log and verify structure for each tool call
for (const tc of toolCalls) {
expect(tc.type).toBe("tool_call");
// Current structure has: server, tool, displayName, kind
expect(typeof tc.server).toBe("string");
expect(typeof tc.tool).toBe("string");
console.log(
"[CODEX TOOL_CALL]",
JSON.stringify({
server: tc.server,
tool: tc.tool,
displayName: tc.displayName,
kind: tc.kind,
callId: tc.callId,
status: tc.status,
hasInput: tc.input !== undefined,
hasOutput: tc.output !== undefined,
})
);
}
// Find a shell/execute tool call
const shellCall = toolCalls.find(
(tc) =>
tc.kind === "execute" ||
tc.server?.includes("shell") ||
tc.displayName?.includes("echo")
);
expect(shellCall).toBeDefined();
// Cleanup
await ctx.client.deleteAgent(agent.id);
rmSync(cwd, { recursive: true, force: true });
},
180000
);
});
}); });