Fix reasoning timeline UI and type errors

This commit is contained in:
Mohamed Boudra
2025-11-23 14:12:53 +01:00
parent 95bacf8fc7
commit 47a99f5668
23 changed files with 2569 additions and 1237 deletions
+29 -1
View File
@@ -4,6 +4,33 @@ import type { PropsWithChildren } from "react";
// Ensure Unistyles runs before Expo Router statically renders each page.
import "../styles/unistyles";
const webEcosystemStyles = /* css */ `
html {
touch-action: auto;
}
body {
overflow: auto;
overscroll-behavior: contain;
-webkit-user-select: text;
user-select: text;
}
body * {
-webkit-user-select: text;
user-select: text;
}
`;
function WebRespectfulStyleReset() {
return (
<style
id="paseo-web-ecosystem"
dangerouslySetInnerHTML={{ __html: webEcosystemStyles }}
/>
);
}
export default function Root({ children }: PropsWithChildren) {
return (
<html lang="en">
@@ -12,10 +39,11 @@ export default function Root({ children }: PropsWithChildren) {
<meta httpEquiv="X-UA-Compatible" content="IE=edge" />
<meta
name="viewport"
content="width=device-width, initial-scale=1, shrink-to-fit=no"
content="width=device-width, initial-scale=1, shrink-to-fit=no, user-scalable=yes, minimum-scale=1, maximum-scale=5"
/>
{/* Reset scroll styles so React Native Web views behave like native. */}
<ScrollViewStyleReset />
<WebRespectfulStyleReset />
</head>
<body>{children}</body>
</html>
+104 -39
View File
@@ -34,7 +34,7 @@ interface AgentInputAreaProps {
agentId: string;
}
const MIN_INPUT_HEIGHT = 32;
const MIN_INPUT_HEIGHT = 50;
const MAX_INPUT_HEIGHT = 160;
const MAX_INPUT_WIDTH = 960;
const BASE_VERTICAL_PADDING = (FOOTER_HEIGHT - MIN_INPUT_HEIGHT) / 2;
@@ -87,6 +87,8 @@ export function AgentInputArea({ agentId }: AgentInputAreaProps) {
const recordingIntervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
const textInputRef = useRef<TextInput | (TextInput & { getNativeRef?: () => unknown }) | null>(null);
const inputHeightRef = useRef(MIN_INPUT_HEIGHT);
const baselineInputHeightRef = useRef<number | null>(null);
const overlayTransition = useSharedValue(0);
const { pickImages } = useImageAttachmentPicker();
@@ -102,6 +104,9 @@ export function AgentInputArea({ agentId }: AgentInputAreaProps) {
}
console.log(`[AgentInput][InputHeight] ${label}`, payload);
};
useEffect(() => {
inputHeightRef.current = inputHeight;
}, [inputHeight]);
async function handleSendMessage() {
if (!userInput.trim() || !ws.isConnected) return;
@@ -298,6 +303,32 @@ export function AgentInputArea({ agentId }: AgentInputAreaProps) {
return null;
}
function focusWebTextInput(): void {
if (!IS_WEB) {
return;
}
const node = textInputRef.current;
if (!node) {
return;
}
const target: unknown =
typeof (node as { focus?: () => void }).focus === "function"
? node
: typeof (node as { getNativeRef?: () => unknown }).getNativeRef === "function"
? (node as { getNativeRef?: () => unknown }).getNativeRef?.()
: null;
if (target && typeof (target as { focus?: () => void }).focus === "function") {
const exec = () => (target as { focus: () => void }).focus();
if (typeof requestAnimationFrame === "function") {
requestAnimationFrame(exec);
} else {
setTimeout(exec, 0);
}
}
}
function measureWebInputHeight(source: string): boolean {
if (!IS_WEB) {
return false;
@@ -311,37 +342,59 @@ export function AgentInputArea({ agentId }: AgentInputAreaProps) {
element.style.height = "auto";
const measuredHeight = element.scrollHeight;
element.style.height = previousHeight ?? "";
const bounded = Math.min(
MAX_INPUT_HEIGHT,
Math.max(MIN_INPUT_HEIGHT, measuredHeight),
);
debugInputHeight(source, {
measuredHeight,
bounded,
const bounded = applyMeasuredHeight(measuredHeight, source, {
scrollHeight: element.scrollHeight,
inlineHeight: previousHeight,
});
setBoundedInputHeight(bounded);
element.style.height = `${bounded}px`;
element.style.minHeight = `${MIN_INPUT_HEIGHT}px`;
element.style.maxHeight = `${MAX_INPUT_HEIGHT}px`;
element.style.overflowY = bounded >= MAX_INPUT_HEIGHT ? "auto" : "hidden";
return true;
}
function applyMeasuredHeight(
measuredHeight: number,
source: string,
details: Record<string, unknown> = {},
): number {
const existingBaseline = baselineInputHeightRef.current;
if (existingBaseline === null) {
baselineInputHeightRef.current = measuredHeight;
}
const baseline = baselineInputHeightRef.current ?? measuredHeight;
const normalizedHeight = measuredHeight - baseline + MIN_INPUT_HEIGHT;
const bounded = Math.min(
MAX_INPUT_HEIGHT,
Math.max(MIN_INPUT_HEIGHT, normalizedHeight),
);
debugInputHeight(source, {
measuredHeight,
normalizedHeight,
bounded,
baseline,
...details,
});
setBoundedInputHeight(bounded);
return bounded;
}
function setBoundedInputHeight(nextHeight: number) {
const boundedHeight = Math.min(
MAX_INPUT_HEIGHT,
Math.max(MIN_INPUT_HEIGHT, nextHeight),
);
setInputHeight((currentHeight) => {
if (Math.abs(currentHeight - boundedHeight) < 1) {
return currentHeight;
}
debugInputHeight("set-state", {
nextHeight,
boundedHeight,
previousHeight: currentHeight,
});
return boundedHeight;
if (Math.abs(inputHeightRef.current - boundedHeight) < 1) {
return;
}
debugInputHeight("set-state", {
nextHeight,
boundedHeight,
previousHeight: inputHeightRef.current,
});
setInputHeight(boundedHeight);
}
function handleContentSizeChange(
@@ -351,11 +404,9 @@ export function AgentInputArea({ agentId }: AgentInputAreaProps) {
return;
}
debugInputHeight("native-measure", {
height: event.nativeEvent.contentSize.height,
applyMeasuredHeight(event.nativeEvent.contentSize.height, "native-measure", {
width: event.nativeEvent.contentSize.width,
});
setBoundedInputHeight(event.nativeEvent.contentSize.height);
}
const agent = agents.get(agentId);
@@ -387,6 +438,13 @@ export function AgentInputArea({ agentId }: AgentInputAreaProps) {
return;
}
event.preventDefault();
if (isAgentRunning) {
if (!hasSendableContent || !ws.isConnected) {
return;
}
handleQueueCurrentInput();
return;
}
if (!shouldShowSendButton || isProcessing || !ws.isConnected) {
return;
}
@@ -399,6 +457,10 @@ export function AgentInputArea({ agentId }: AgentInputAreaProps) {
}
}, [isAgentRunning, ws.isConnected]);
useEffect(() => {
focusWebTextInput();
}, [agentId]);
// Hydrate draft only when switching agents
useEffect(() => {
const draft = draftInputs.get(agentId);
@@ -522,9 +584,9 @@ export function AgentInputArea({ agentId }: AgentInputAreaProps) {
onPress={handleRealtimePress}
disabled={!ws.isConnected && !isRealtimeMode}
style={[
styles.realtimeButton,
isRealtimeMode && styles.realtimeButtonActive,
(!ws.isConnected && !isRealtimeMode) && styles.buttonDisabled,
styles.realtimeButton as any,
(isRealtimeMode ? styles.realtimeButtonActive : undefined) as any,
(!ws.isConnected && !isRealtimeMode ? styles.buttonDisabled : undefined) as any,
]}
>
{isRealtimeMode ? (
@@ -620,8 +682,8 @@ export function AgentInputArea({ agentId }: AgentInputAreaProps) {
onPress={handlePickImage}
disabled={!ws.isConnected}
style={[
styles.attachButton,
!ws.isConnected && styles.buttonDisabled,
styles.attachButton as any,
(!ws.isConnected ? styles.buttonDisabled : undefined) as any,
]}
>
<Paperclip size={20} color={theme.colors.foreground} />
@@ -639,7 +701,10 @@ export function AgentInputArea({ agentId }: AgentInputAreaProps) {
disabled={!ws.isConnected}
accessibilityLabel="Queue message while agent is running"
accessibilityRole="button"
style={[styles.queueButton, !ws.isConnected && styles.buttonDisabled]}
style={[
styles.queueButton as any,
(!ws.isConnected ? styles.buttonDisabled : undefined) as any,
]}
>
<ArrowUp size={20} color="white" />
</Pressable>
@@ -651,8 +716,8 @@ export function AgentInputArea({ agentId }: AgentInputAreaProps) {
accessibilityLabel={isCancellingAgent ? "Canceling agent" : "Stop agent"}
accessibilityRole="button"
style={[
styles.cancelButton,
(!ws.isConnected || isCancellingAgent) && styles.buttonDisabled,
styles.cancelButton as any,
(!ws.isConnected || isCancellingAgent ? styles.buttonDisabled : undefined) as any,
]}
>
{isCancellingAgent ? (
@@ -666,10 +731,10 @@ export function AgentInputArea({ agentId }: AgentInputAreaProps) {
<Pressable
onPress={handleSendMessage}
disabled={!ws.isConnected || isProcessing}
style={[
styles.sendButton,
(!ws.isConnected || isProcessing) && styles.buttonDisabled,
]}
style={[
styles.sendButton as any,
(!ws.isConnected || isProcessing ? styles.buttonDisabled : undefined) as any,
]}
>
<ArrowUp size={20} color="white" />
</Pressable>
@@ -679,9 +744,9 @@ export function AgentInputArea({ agentId }: AgentInputAreaProps) {
onPress={handleVoicePress}
disabled={!ws.isConnected || isRealtimeMode}
style={[
styles.voiceButton,
(!ws.isConnected || isRealtimeMode) && styles.buttonDisabled,
isRecording && styles.voiceButtonRecording,
styles.voiceButton as any,
(!ws.isConnected || isRealtimeMode ? styles.buttonDisabled : undefined) as any,
(isRecording ? styles.voiceButtonRecording : undefined) as any,
]}
>
{isRecording ? (
@@ -715,7 +780,7 @@ export function AgentInputArea({ agentId }: AgentInputAreaProps) {
);
}
const styles = StyleSheet.create((theme) => ({
const styles = StyleSheet.create(((theme: any) => ({
container: {
flexDirection: "column",
},
@@ -900,4 +965,4 @@ const styles = StyleSheet.create((theme) => ({
queueSendButton: {
backgroundColor: theme.colors.palette.blue[600],
},
}));
})) as any) as Record<string, any>;
+12 -1
View File
@@ -18,9 +18,20 @@ export function AgentList({ agents }: AgentListProps) {
const [actionAgent, setActionAgent] = useState<Agent | null>(null);
const isActionSheetVisible = actionAgent !== null;
// Sort agents by the persisted timestamp of the last user message, falling back to last activity
// Sort agents by status so running agents stay at the top, then fall back to the
// persisted timestamp of the last user message, and finally their last activity
const agentArray = useMemo(() => {
return Array.from(agents.values()).sort((a, b) => {
const aIsRunning = a.status === "running";
const bIsRunning = b.status === "running";
if (aIsRunning && !bIsRunning) {
return -1;
}
if (!aIsRunning && bIsRunning) {
return 1;
}
const sortA = (a.lastUserMessageAt ?? a.lastActivityAt).getTime();
const sortB = (b.lastUserMessageAt ?? b.lastActivityAt).getTime();
return sortB - sortA;
@@ -14,7 +14,6 @@ import {
import Markdown from "react-native-markdown-display";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { BottomSheetModal } from "@gorhom/bottom-sheet";
import Animated, { FadeIn, FadeOut, cancelAnimation, useAnimatedStyle, useSharedValue, withDelay, withRepeat, withSequence, withTiming } from "react-native-reanimated";
import { ChevronDown } from "lucide-react-native";
import { useRouter } from "expo-router";
@@ -27,10 +26,9 @@ import {
TodoListCard,
type InlinePathTarget,
} from "./message";
import { ToolCallBottomSheet } from "./tool-call-bottom-sheet";
import { DiffViewer } from "./diff-viewer";
import type { StreamItem } from "@/types/stream";
import type { SelectedToolCall, PendingPermission } from "@/types/shared";
import type { PendingPermission } from "@/types/shared";
import type { AgentPermissionResponse } from "@server/server/agent/agent-sdk-types";
import type { Agent } from "@/contexts/session-context";
import { useSession } from "@/contexts/session-context";
@@ -57,10 +55,7 @@ export function AgentStreamView({
onPermissionResponse,
}: AgentStreamViewProps) {
const flatListRef = useRef<FlatList<StreamItem>>(null);
const bottomSheetRef = useRef<BottomSheetModal | null>(null);
const insets = useSafeAreaInsets();
const [selectedToolCall, setSelectedToolCall] =
useState<SelectedToolCall | null>(null);
const [isNearBottom, setIsNearBottom] = useState(true);
const hasScrolledInitially = useRef(false);
const hasAutoScrolledOnce = useRef(false);
@@ -81,18 +76,6 @@ export function AgentStreamView({
isNearBottomRef.current = true;
}, [agentId]);
function handleOpenToolCallDetails(toolCall: SelectedToolCall) {
setSelectedToolCall(toolCall);
// Delay present to next frame to ensure component is mounted
setTimeout(() => {
bottomSheetRef.current?.present();
}, 0);
}
function handleBottomSheetDismiss() {
setSelectedToolCall(null);
}
const handleInlinePathPress = useCallback(
(target: InlinePathTarget) => {
if (!target.path) {
@@ -244,7 +227,7 @@ export function AgentStreamView({
break;
case "thought":
content = <AgentThoughtMessage message={item.text} />;
content = <AgentThoughtMessage message={item.text} status={item.status} />;
break;
case "tool_call": {
@@ -264,7 +247,6 @@ export function AgentStreamView({
parsedEditEntries={data.parsedEdits}
parsedReadEntries={data.parsedReads}
parsedCommandDetails={data.parsedCommand ?? null}
onOpenDetails={() => handleOpenToolCallDetails({ payload })}
/>
);
} else {
@@ -275,7 +257,6 @@ export function AgentStreamView({
args={data.arguments}
result={data.result}
status={data.status}
onOpenDetails={() => handleOpenToolCallDetails({ payload })}
/>
);
}
@@ -423,11 +404,6 @@ export function AgentStreamView({
</Animated.View>
)}
<ToolCallBottomSheet
bottomSheetRef={bottomSheetRef}
selectedToolCall={selectedToolCall}
onDismiss={handleBottomSheetDismiss}
/>
</View>
);
}
File diff suppressed because it is too large Load Diff
+501 -367
View File
@@ -1,5 +1,6 @@
import { View, Text, Pressable, Animated } from "react-native";
import { View, Text, Pressable, Animated, ScrollView } from "react-native";
import { useState, useEffect, useRef, memo, useMemo, useCallback } from "react";
import type { ReactNode, ComponentType } from "react";
import type { AgentProvider } from "@server/server/agent/agent-sdk-types";
import { getAgentProviderDefinition } from "@server/server/agent/provider-manifest";
import Markdown from "react-native-markdown-display";
@@ -18,14 +19,14 @@ import {
Pencil,
Eye,
SquareTerminal,
Brain,
Search,
Brain,
} from "lucide-react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { baseColors, theme } from "@/styles/theme";
import { Colors } from "@/constants/theme";
import * as Clipboard from "expo-clipboard";
import type { TodoEntry } from "@/types/stream";
import type { TodoEntry, ThoughtStatus } from "@/types/stream";
import type { CommandDetails, EditEntry, ReadEntry } from "@/utils/tool-call-parsers";
import { DiffViewer } from "./diff-viewer";
import { resolveToolCallPreview } from "./tool-call-preview";
@@ -246,6 +247,55 @@ export const assistantMessageStylesheet = StyleSheet.create((theme) => ({
},
}));
const expandableBadgeStylesheet = StyleSheet.create((theme) => ({
container: {
marginHorizontal: theme.spacing[2],
marginBottom: theme.spacing[2],
},
pressable: {
borderRadius: theme.borderRadius.lg,
borderWidth: theme.borderWidth[1],
borderColor: theme.colors.border,
backgroundColor: theme.colors.secondary,
paddingHorizontal: theme.spacing[2],
paddingVertical: theme.spacing[1],
},
pressablePressed: {
opacity: 0.9,
},
headerRow: {
flexDirection: "row",
alignItems: "center",
},
iconBadge: {
width: 22,
height: 22,
borderRadius: 11,
alignItems: "center",
justifyContent: "center",
marginRight: theme.spacing[2],
backgroundColor: "transparent",
},
label: {
flex: 1,
color: theme.colors.mutedForeground,
fontSize: theme.fontSize.base,
fontWeight: theme.fontWeight.normal,
},
chevron: {
marginLeft: theme.spacing[1],
},
detailWrapper: {
marginTop: theme.spacing[2],
borderRadius: theme.borderRadius.md,
borderWidth: theme.borderWidth[1],
borderColor: theme.colors.border,
backgroundColor: theme.colors.background,
padding: theme.spacing[2],
gap: theme.spacing[2],
},
}));
function isLikelyPathToken(value: string): boolean {
if (!value || value.length > 300) {
return false;
@@ -848,74 +898,187 @@ export const TodoListCard = memo(function TodoListCard({
interface AgentThoughtMessageProps {
message: string;
status?: ThoughtStatus;
}
interface ExpandableBadgeProps {
label: string;
icon?: ComponentType<{ size?: number; color?: string }>;
isExpanded: boolean;
onToggle: () => void;
renderDetails?: () => ReactNode;
isLoading?: boolean;
isError?: boolean;
}
const ExpandableBadge = memo(function ExpandableBadge({
label,
icon,
isExpanded,
onToggle,
renderDetails,
isLoading = false,
isError = false,
}: ExpandableBadgeProps) {
const { theme } = useUnistyles();
const hasDetails = Boolean(renderDetails);
const detailContent = hasDetails && isExpanded ? renderDetails?.() : null;
const spinAnim = useRef(new Animated.Value(0)).current;
useEffect(() => {
let loop: Animated.CompositeAnimation | null = null;
if (isLoading) {
spinAnim.setValue(0);
loop = Animated.loop(
Animated.timing(spinAnim, {
toValue: 1,
duration: 1400,
useNativeDriver: true,
easing: (t) => t,
})
);
loop.start();
} else {
spinAnim.stopAnimation();
}
return () => {
loop?.stop();
};
}, [isLoading, spinAnim]);
const spin = spinAnim.interpolate({
inputRange: [0, 1],
outputRange: ["0deg", "360deg"],
});
const IconComponent = icon;
const iconColor = isError
? theme.colors.destructive
: theme.colors.foreground;
let iconNode: ReactNode = null;
if (isLoading) {
iconNode = (
<Animated.View style={{ transform: [{ rotate: spin }] }}>
<Loader2 size={12} color={iconColor} />
</Animated.View>
);
} else if (isError) {
iconNode = <X size={12} color={iconColor} />;
} else if (IconComponent) {
iconNode = <IconComponent size={12} color={iconColor} />;
}
return (
<View style={expandableBadgeStylesheet.container}>
<Pressable
onPress={hasDetails ? onToggle : undefined}
disabled={!hasDetails}
accessibilityRole={hasDetails ? "button" : undefined}
accessibilityState={hasDetails ? { expanded: isExpanded } : undefined}
style={({ pressed }) => [
expandableBadgeStylesheet.pressable,
pressed && hasDetails ? expandableBadgeStylesheet.pressablePressed : null,
]}
>
<View style={expandableBadgeStylesheet.headerRow}>
<View style={expandableBadgeStylesheet.iconBadge}>{iconNode}</View>
<Text style={expandableBadgeStylesheet.label} numberOfLines={1}>
{label}
</Text>
{hasDetails ? (
<ChevronRight
size={14}
color={theme.colors.mutedForeground}
style={[
expandableBadgeStylesheet.chevron,
{ transform: [{ rotate: isExpanded ? "90deg" : "0deg" }] },
]}
/>
) : null}
</View>
{detailContent ? (
<View style={expandableBadgeStylesheet.detailWrapper}>{detailContent}</View>
) : null}
</Pressable>
</View>
);
});
const agentThoughtStylesheet = StyleSheet.create((theme) => ({
container: {
marginHorizontal: theme.spacing[2],
marginBottom: theme.spacing[2],
},
card: {
backgroundColor: theme.colors.card,
borderRadius: theme.borderRadius.lg,
borderWidth: theme.borderWidth[1],
borderColor: theme.colors.border,
paddingVertical: theme.spacing[2],
paddingHorizontal: theme.spacing[3],
flexDirection: "row",
alignItems: "flex-start",
gap: theme.spacing[3],
},
iconContainer: {
width: 28,
height: 28,
borderRadius: 14,
alignItems: "center",
justifyContent: "center",
flexShrink: 0,
paddingTop: theme.spacing[1],
},
icon: {
color: theme.colors.mutedForeground,
opacity: 0.8,
},
textContainer: {
flex: 1,
paddingVertical: theme.spacing[1],
},
label: {
emptyText: {
color: theme.colors.mutedForeground,
fontSize: theme.fontSize.xs,
fontWeight: theme.fontWeight.semibold,
textTransform: "uppercase",
marginBottom: theme.spacing[1],
},
text: {
color: theme.colors.mutedForeground,
fontSize: theme.fontSize.base,
lineHeight: 20,
fontStyle: "italic" as const,
},
}));
export function AgentThoughtMessage({ message }: AgentThoughtMessageProps) {
const messageText = useMemo(() => {
return message
.trim()
.replace(/^\*\*|\*\*$/g, "")
.trim();
}, [message]);
export function AgentThoughtMessage({ message, status = "ready" }: AgentThoughtMessageProps) {
const { theme } = useUnistyles();
const [isExpanded, setIsExpanded] = useState(false);
const markdownContent = useMemo(() => message?.trim() ?? "", [message]);
const markdownStyles = useMemo(
() => ({
body: {
color: theme.colors.foreground,
fontSize: theme.fontSize.sm,
lineHeight: 20,
},
text: {
color: theme.colors.foreground,
},
paragraph: {
marginBottom: theme.spacing[2],
},
strong: {
fontWeight: theme.fontWeight.semibold,
},
em: {
fontStyle: "italic" as const,
},
code_inline: {
backgroundColor: theme.colors.secondary,
color: theme.colors.secondaryForeground,
paddingHorizontal: theme.spacing[1],
paddingVertical: 2,
borderRadius: theme.borderRadius.sm,
fontFamily: "monospace",
fontSize: theme.fontSize.xs,
},
code_block: {
backgroundColor: theme.colors.secondary,
color: theme.colors.secondaryForeground,
padding: theme.spacing[3],
borderRadius: theme.borderRadius.md,
fontFamily: "monospace",
fontSize: theme.fontSize.sm,
},
}),
[theme]
);
const toggle = useCallback(() => {
setIsExpanded((prev) => !prev);
}, []);
const renderDetails = useCallback(() => {
if (!markdownContent) {
return <Text style={agentThoughtStylesheet.emptyText}>No captured thinking</Text>;
}
return <Markdown style={markdownStyles}>{markdownContent}</Markdown>;
}, [markdownContent, markdownStyles]);
return (
<View style={agentThoughtStylesheet.container}>
<View style={agentThoughtStylesheet.card}>
<View style={agentThoughtStylesheet.iconContainer}>
<Brain size={18} style={agentThoughtStylesheet.icon} />
</View>
<View style={agentThoughtStylesheet.textContainer}>
<Text style={agentThoughtStylesheet.text}>{messageText}</Text>
</View>
</View>
</View>
<ExpandableBadge
label="Thinking"
icon={status === "ready" ? Brain : undefined}
isExpanded={isExpanded}
onToggle={toggle}
renderDetails={renderDetails}
isLoading={status !== "ready"}
/>
);
}
@@ -929,77 +1092,14 @@ interface ToolCallProps {
parsedEditEntries?: EditEntry[];
parsedReadEntries?: ReadEntry[];
parsedCommandDetails?: CommandDetails | null;
onOpenDetails?: () => void;
}
const toolCallStylesheet = StyleSheet.create((theme) => ({
pressable: {
marginHorizontal: theme.spacing[2],
marginBottom: theme.spacing[2],
backgroundColor: theme.colors.secondary,
borderRadius: theme.borderRadius.lg,
borderWidth: theme.borderWidth[1],
borderColor: "transparent",
overflow: "hidden",
shadowColor: theme.colors.palette.black,
shadowOpacity: 0.2,
shadowOffset: { width: 0, height: 8 },
shadowRadius: 16,
elevation: 4,
},
pressableActive: {
opacity: 0.8,
},
executingBorder: {
borderColor: theme.colors.primary,
},
completedBorder: {
borderColor: theme.colors.border,
},
failedBorder: {
borderColor: theme.colors.destructive,
},
content: {
padding: theme.spacing[3],
},
headerRow: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
},
chevronContainer: {
marginRight: theme.spacing[2],
},
toolName: {
color: theme.colors.foreground,
fontFamily: "monospace",
fontWeight: theme.fontWeight.semibold,
fontSize: theme.fontSize.sm,
flex: 1,
},
statusBadge: {
alignItems: "center",
justifyContent: "center",
padding: theme.spacing[1],
borderRadius: theme.borderRadius.base,
width: 28,
height: 28,
},
executingBadgeBg: {
backgroundColor: theme.colors.accent,
},
completedBadgeBg: {
backgroundColor: "transparent",
},
failedBadgeBg: {
backgroundColor: "rgba(239, 68, 68, 0.2)",
},
expandedContent: {
marginTop: theme.spacing[3],
gap: theme.spacing[2],
detailContent: {
gap: theme.spacing[3],
},
section: {
// empty - just for grouping
gap: theme.spacing[1],
},
sectionTitle: {
color: theme.colors.mutedForeground,
@@ -1007,26 +1107,78 @@ const toolCallStylesheet = StyleSheet.create((theme) => ({
fontWeight: theme.fontWeight.semibold,
textTransform: "uppercase",
letterSpacing: 0.5,
marginBottom: 6,
},
errorSectionTitle: {
color: theme.colors.palette.red[300],
},
sectionContent: {
backgroundColor: theme.colors.background,
fileBadge: {
alignSelf: "flex-start",
paddingHorizontal: theme.spacing[2],
paddingVertical: theme.spacing[1],
borderRadius: theme.borderRadius.base,
borderWidth: theme.borderWidth[1],
borderColor: theme.colors.border,
backgroundColor: theme.colors.card,
},
fileBadgeText: {
color: theme.colors.foreground,
fontFamily: "monospace",
fontSize: theme.fontSize.xs,
},
diffContainer: {
borderWidth: theme.borderWidth[1],
borderColor: theme.colors.border,
borderRadius: theme.borderRadius.base,
overflow: "hidden",
backgroundColor: theme.colors.card,
},
scrollArea: {
borderWidth: theme.borderWidth[1],
borderColor: theme.colors.border,
borderRadius: theme.borderRadius.base,
maxHeight: 260,
},
scrollContent: {
padding: theme.spacing[2],
},
errorSectionContent: {
borderColor: theme.colors.palette.red[800],
},
sectionText: {
color: theme.colors.foreground,
fontSize: theme.fontSize.xs,
scrollText: {
fontFamily: "monospace",
lineHeight: 16,
fontSize: theme.fontSize.xs,
color: theme.colors.foreground,
lineHeight: 18,
},
jsonScroll: {
borderWidth: theme.borderWidth[1],
borderColor: theme.colors.border,
borderRadius: theme.borderRadius.base,
},
jsonScrollError: {
borderColor: theme.colors.destructive,
},
jsonContent: {
padding: theme.spacing[2],
},
errorText: {
color: theme.colors.destructive,
},
emptyStateText: {
color: theme.colors.mutedForeground,
fontSize: theme.fontSize.sm,
fontStyle: "italic",
},
metaRow: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
},
metaLabel: {
color: theme.colors.mutedForeground,
fontSize: theme.fontSize.xs,
textTransform: "uppercase",
letterSpacing: 0.5,
},
metaValue: {
color: theme.colors.foreground,
fontFamily: "monospace",
fontSize: theme.fontSize.xs,
flex: 1,
},
}));
@@ -1039,24 +1191,18 @@ const toolKindIcons: Record<string, any> = {
// Add more mappings as needed
};
function formatPreviewValue(value: unknown, limit = 800): string {
if (value === undefined || value === null) {
function formatFullValue(value: unknown): string {
if (value === undefined) {
return "";
}
let text: string;
if (typeof value === "string") {
text = value.trim();
} else {
try {
text = JSON.stringify(value, null, 2);
} catch {
text = String(value);
}
return value;
}
if (text.length <= limit) {
return text;
try {
return JSON.stringify(value, null, 2);
} catch {
return String(value);
}
return `${text.slice(0, limit)}…`;
}
export const ToolCall = memo(function ToolCall({
@@ -1069,7 +1215,6 @@ export const ToolCall = memo(function ToolCall({
parsedEditEntries,
parsedReadEntries,
parsedCommandDetails,
onOpenDetails,
}: ToolCallProps) {
const { editEntries, readEntries, commandDetails } = useMemo(
() =>
@@ -1083,227 +1228,216 @@ export const ToolCall = memo(function ToolCall({
[args, result, parsedEditEntries, parsedReadEntries, parsedCommandDetails]
);
const primaryEditEntry = editEntries[0];
const primaryReadEntry = readEntries[0];
const genericResult =
result !== undefined &&
!commandDetails?.output &&
!primaryReadEntry &&
!primaryEditEntry
? formatPreviewValue(result)
: null;
const formattedError =
error !== undefined ? formatPreviewValue(error ?? null, 600) : null;
const [isExpanded, setIsExpanded] = useState(false);
const spinAnim = useRef(new Animated.Value(0)).current;
const IconComponent = kind
? toolKindIcons[kind.toLowerCase()] || Wrench
: Wrench;
useEffect(() => {
if (status === "executing") {
// Reset to 0 before starting
spinAnim.setValue(0);
Animated.loop(
Animated.timing(spinAnim, {
toValue: 1,
duration: 1500,
useNativeDriver: true,
easing: (t) => t, // Linear easing for smooth continuous rotation
})
).start();
} else {
spinAnim.stopAnimation();
const serializedArgs = useMemo(
() => (args !== undefined ? formatFullValue(args) : ""),
[args]
);
const serializedResult = useMemo(
() => (result !== undefined ? formatFullValue(result) : ""),
[result]
);
const serializedError = useMemo(
() => (error !== undefined ? formatFullValue(error) : ""),
[error]
);
const handleToggle = useCallback(() => {
setIsExpanded((prev) => !prev);
}, []);
const hasCommandDetails = Boolean(
commandDetails &&
(commandDetails.command ||
commandDetails.cwd ||
commandDetails.exitCode !== undefined ||
commandDetails.output)
);
function renderJsonSection(
label: string,
text: string,
isError = false
): ReactNode {
const displayText = text.length ? text : "(empty)";
return (
<View key={label} style={toolCallStylesheet.section}>
<Text style={toolCallStylesheet.sectionTitle}>{label}</Text>
<ScrollView
horizontal
nestedScrollEnabled
style={[
toolCallStylesheet.jsonScroll,
isError ? toolCallStylesheet.jsonScrollError : null,
]}
contentContainerStyle={toolCallStylesheet.jsonContent}
showsHorizontalScrollIndicator={true}
>
<Text
style={[
toolCallStylesheet.scrollText,
isError ? toolCallStylesheet.errorText : null,
]}
>
{displayText}
</Text>
</ScrollView>
</View>
);
}
const commandSection = useMemo(() => {
if (!hasCommandDetails || !commandDetails) {
return null;
}
}, [status, spinAnim]);
return (
<View style={toolCallStylesheet.section}>
<Text style={toolCallStylesheet.sectionTitle}>Command</Text>
{commandDetails.command ? (
<ScrollView
horizontal
nestedScrollEnabled
style={toolCallStylesheet.jsonScroll}
contentContainerStyle={toolCallStylesheet.jsonContent}
showsHorizontalScrollIndicator={true}
>
<Text style={toolCallStylesheet.scrollText}>
{commandDetails.command}
</Text>
</ScrollView>
) : null}
{commandDetails.cwd ? (
<View style={toolCallStylesheet.metaRow}>
<Text style={toolCallStylesheet.metaLabel}>Directory</Text>
<Text style={toolCallStylesheet.metaValue}>{commandDetails.cwd}</Text>
</View>
) : null}
{commandDetails.exitCode !== undefined ? (
<View style={toolCallStylesheet.metaRow}>
<Text style={toolCallStylesheet.metaLabel}>Exit Code</Text>
<Text style={toolCallStylesheet.metaValue}>
{commandDetails.exitCode === null
? "Unknown"
: commandDetails.exitCode}
</Text>
</View>
) : null}
{commandDetails.output ? (
<ScrollView
style={toolCallStylesheet.scrollArea}
contentContainerStyle={toolCallStylesheet.scrollContent}
nestedScrollEnabled
showsVerticalScrollIndicator={true}
>
<Text style={toolCallStylesheet.scrollText}>
{commandDetails.output}
</Text>
</ScrollView>
) : null}
</View>
);
}, [commandDetails, hasCommandDetails]);
const spin = spinAnim.interpolate({
inputRange: [0, 1],
outputRange: ["0deg", "360deg"],
});
const editSections = useMemo(
() =>
editEntries.map((entry, index) => (
<View
key={`${entry.filePath ?? "diff"}-${index}`}
style={toolCallStylesheet.section}
>
<Text style={toolCallStylesheet.sectionTitle}>Diff</Text>
{entry.filePath ? (
<View style={toolCallStylesheet.fileBadge}>
<Text style={toolCallStylesheet.fileBadgeText}>{entry.filePath}</Text>
</View>
) : null}
<View style={toolCallStylesheet.diffContainer}>
<DiffViewer diffLines={entry.diffLines} maxHeight={240} />
</View>
</View>
)),
[editEntries]
);
const statusConfig = {
executing: {
border: toolCallStylesheet.executingBorder,
badgeBg: toolCallStylesheet.executingBadgeBg,
color: "#fff",
},
completed: {
border: toolCallStylesheet.completedBorder,
badgeBg: toolCallStylesheet.completedBadgeBg,
color: "#71717a",
},
failed: {
border: toolCallStylesheet.failedBorder,
badgeBg: toolCallStylesheet.failedBadgeBg,
color: "#fca5a5",
},
};
const readSections = useMemo(
() =>
readEntries.map((entry, index) => (
<View
key={`${entry.filePath ?? "read"}-${index}`}
style={toolCallStylesheet.section}
>
<Text style={toolCallStylesheet.sectionTitle}>Read Result</Text>
{entry.filePath ? (
<View style={toolCallStylesheet.fileBadge}>
<Text style={toolCallStylesheet.fileBadgeText}>{entry.filePath}</Text>
</View>
) : null}
<ScrollView
style={toolCallStylesheet.scrollArea}
contentContainerStyle={toolCallStylesheet.scrollContent}
nestedScrollEnabled
showsVerticalScrollIndicator={true}
>
<Text style={toolCallStylesheet.scrollText}>{entry.content}</Text>
</ScrollView>
</View>
)),
[readEntries]
);
const config = statusConfig[status];
// Get the appropriate icon for the tool kind
const getToolIcon = () => {
if (kind) {
const IconComponent = toolKindIcons[kind.toLowerCase()] || Wrench;
return IconComponent;
const jsonSections = useMemo(() => {
const sections: ReactNode[] = [];
if (args !== undefined) {
sections.push(renderJsonSection("Arguments", serializedArgs));
}
return Wrench; // Default icon
};
if (result !== undefined) {
sections.push(renderJsonSection("Result", serializedResult));
}
if (error !== undefined) {
sections.push(renderJsonSection("Error", serializedError, true));
}
return sections;
}, [args, serializedArgs, result, serializedResult, error, serializedError]);
const renderDetails = useCallback(() => {
if (
!commandSection &&
editSections.length === 0 &&
readSections.length === 0 &&
jsonSections.length === 0
) {
return (
<Text style={toolCallStylesheet.emptyStateText}>
No additional details available
</Text>
);
}
return (
<View style={toolCallStylesheet.detailContent}>
{commandSection}
{editSections}
{readSections}
{jsonSections}
</View>
);
}, [commandSection, editSections, readSections, jsonSections]);
return (
<Pressable
onPress={onOpenDetails}
style={[
toolCallStylesheet.pressable,
toolCallStylesheet.pressableActive,
config.border,
]}
>
<View style={toolCallStylesheet.content}>
<View style={toolCallStylesheet.headerRow}>
<View style={[toolCallStylesheet.statusBadge, config.badgeBg]}>
{status === "executing" ? (
<Animated.View style={{ transform: [{ rotate: spin }] }}>
<Loader2 size={16} color={config.color} />
</Animated.View>
) : status === "completed" ? (
(() => {
const IconComponent = getToolIcon();
return <IconComponent size={16} color={config.color} />;
})()
) : (
<X size={16} color={config.color} />
)}
</View>
<Text
style={toolCallStylesheet.toolName}
numberOfLines={1}
ellipsizeMode="tail"
>
{toolName}
</Text>
</View>
{(commandDetails ||
primaryEditEntry ||
primaryReadEntry ||
genericResult ||
formattedError) && (
<View style={toolCallStylesheet.expandedContent}>
{commandDetails &&
(commandDetails.command ||
commandDetails.cwd ||
commandDetails.exitCode !== undefined ||
commandDetails.output) && (
<View style={toolCallStylesheet.section}>
<Text style={toolCallStylesheet.sectionTitle}>Command</Text>
<View style={toolCallStylesheet.sectionContent}>
{commandDetails.command && (
<Text
style={toolCallStylesheet.sectionText}
numberOfLines={2}
>
{commandDetails.command}
</Text>
)}
{commandDetails.cwd && (
<Text
style={toolCallStylesheet.sectionText}
numberOfLines={1}
>
{commandDetails.cwd}
</Text>
)}
{commandDetails.exitCode !== undefined && (
<Text style={toolCallStylesheet.sectionText}>
Exit code:{" "}
{commandDetails.exitCode === null
? "Unknown"
: commandDetails.exitCode}
</Text>
)}
{commandDetails.output && (
<Text
style={toolCallStylesheet.sectionText}
numberOfLines={6}
>
{formatPreviewValue(commandDetails.output)}
</Text>
)}
</View>
</View>
)}
{primaryReadEntry && (
<View style={toolCallStylesheet.section}>
<Text style={toolCallStylesheet.sectionTitle}>
{primaryReadEntry.filePath
? `Read: ${primaryReadEntry.filePath}`
: "Read Output"}
</Text>
<View style={toolCallStylesheet.sectionContent}>
<Text
style={toolCallStylesheet.sectionText}
numberOfLines={6}
>
{formatPreviewValue(primaryReadEntry.content)}
</Text>
</View>
</View>
)}
{primaryEditEntry && (
<View style={toolCallStylesheet.section}>
<Text style={toolCallStylesheet.sectionTitle}>
{primaryEditEntry.filePath
? `Diff: ${primaryEditEntry.filePath}`
: "Diff"}
</Text>
<View style={toolCallStylesheet.sectionContent}>
<DiffViewer diffLines={primaryEditEntry.diffLines} maxHeight={160} />
</View>
</View>
)}
{genericResult && (
<View style={toolCallStylesheet.section}>
<Text style={toolCallStylesheet.sectionTitle}>Result</Text>
<View style={toolCallStylesheet.sectionContent}>
<Text style={toolCallStylesheet.sectionText} numberOfLines={6}>
{genericResult}
</Text>
</View>
</View>
)}
{formattedError && (
<View style={toolCallStylesheet.section}>
<Text
style={[
toolCallStylesheet.sectionTitle,
toolCallStylesheet.errorSectionTitle,
]}
>
Error
</Text>
<View
style={[
toolCallStylesheet.sectionContent,
toolCallStylesheet.errorSectionContent,
]}
>
<Text
style={[
toolCallStylesheet.sectionText,
toolCallStylesheet.errorSectionTitle,
]}
numberOfLines={6}
>
{formattedError}
</Text>
</View>
</View>
)}
</View>
)}
</View>
</Pressable>
<ExpandableBadge
label={toolName}
icon={IconComponent}
isExpanded={isExpanded}
onToggle={handleToggle}
renderDetails={renderDetails}
isLoading={status === "executing"}
isError={status === "failed"}
/>
);
});
@@ -1,16 +1,13 @@
import { View, ScrollView } from "react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { forwardRef, useRef, useState } from "react";
import { BottomSheetModal } from "@gorhom/bottom-sheet";
import { StyleSheet } from "react-native-unistyles";
import { forwardRef } from "react";
import {
UserMessage,
AssistantMessage,
ActivityLog,
ToolCall,
} from "@/components/message";
import { ToolCallBottomSheet } from "./tool-call-bottom-sheet";
import type { MessageEntry } from "@/contexts/session-context";
import type { SelectedToolCall } from "@/types/shared";
interface OrchestratorMessagesViewProps {
messages: MessageEntry[];
@@ -20,43 +17,6 @@ interface OrchestratorMessagesViewProps {
export const OrchestratorMessagesView = forwardRef<ScrollView, OrchestratorMessagesViewProps>(
function OrchestratorMessagesView({ messages, currentAssistantMessage, onArtifactClick }, ref) {
const { theme } = useUnistyles();
const bottomSheetRef = useRef<BottomSheetModal | null>(null);
const [selectedToolCall, setSelectedToolCall] = useState<SelectedToolCall | null>(null);
const [isBottomSheetVisible, setIsBottomSheetVisible] = useState(false);
function handleOpenToolCallDetails(toolCall: {
toolName: string;
status: 'executing' | 'completed' | 'failed';
args: any;
result?: any;
error?: any;
}) {
// Wrap orchestrator tool call in payload structure
setSelectedToolCall({
payload: {
source: 'orchestrator',
data: {
toolCallId: `orchestrator_${Date.now()}`, // Generate a simple ID
toolName: toolCall.toolName,
arguments: toolCall.args,
result: toolCall.result,
error: toolCall.error,
status: toolCall.status,
},
},
});
setIsBottomSheetVisible(true);
// Delay present to next frame to ensure component is mounted
setTimeout(() => {
bottomSheetRef.current?.present();
}, 0);
}
function handleBottomSheetDismiss() {
setIsBottomSheetVisible(false);
setSelectedToolCall(null);
}
return (
<>
@@ -125,13 +85,6 @@ export const OrchestratorMessagesView = forwardRef<ScrollView, OrchestratorMessa
result={msg.result}
error={msg.error}
status={msg.status}
onOpenDetails={() => handleOpenToolCallDetails({
toolName: msg.toolName,
status: msg.status,
args: msg.args,
result: msg.result,
error: msg.error,
})}
/>
);
}
@@ -149,13 +102,6 @@ export const OrchestratorMessagesView = forwardRef<ScrollView, OrchestratorMessa
)}
</ScrollView>
{isBottomSheetVisible && (
<ToolCallBottomSheet
bottomSheetRef={bottomSheetRef}
selectedToolCall={selectedToolCall}
onDismiss={handleBottomSheetDismiss}
/>
)}
</>
);
}
@@ -1,389 +0,0 @@
import React, { useCallback, useMemo } from "react";
import { View, Text } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { ScrollView } from "react-native-gesture-handler";
import { StyleSheet } from "react-native-unistyles";
import {
BottomSheetModal,
BottomSheetScrollView,
BottomSheetBackdrop,
BottomSheetBackdropProps,
} from "@gorhom/bottom-sheet";
import type { SelectedToolCall } from "@/types/shared";
import {
extractCommandDetails,
extractEditEntries,
extractReadEntries,
type CommandDetails,
type EditEntry,
type ReadEntry,
} from "@/utils/tool-call-parsers";
import { DiffViewer } from "./diff-viewer";
interface ToolCallBottomSheetProps {
bottomSheetRef: React.RefObject<BottomSheetModal | null>;
selectedToolCall: SelectedToolCall | null;
onDismiss: () => void;
}
export function ToolCallBottomSheet({
bottomSheetRef,
selectedToolCall,
onDismiss,
}: ToolCallBottomSheetProps) {
const insets = useSafeAreaInsets();
const snapPoints = useMemo(() => ["80%"], []);
const renderBackdrop = useCallback(
(props: BottomSheetBackdropProps) => (
<BottomSheetBackdrop
{...props}
disappearsOnIndex={-1}
appearsOnIndex={0}
opacity={0.5}
/>
),
[]
);
// Extract data based on source
const { toolName, args, result, error, parsedEdits, parsedReads, parsedCommand } = useMemo(() => {
if (!selectedToolCall) {
return {
toolName: "Tool Call",
args: undefined,
result: undefined,
error: undefined,
parsedEdits: undefined,
parsedReads: undefined,
parsedCommand: undefined,
};
}
const { payload } = selectedToolCall;
if (payload.source === "agent") {
const data = payload.data;
return {
toolName: data.displayName ?? `${data.server}/${data.tool}`,
args: data.raw,
result: data.result,
error: data.error,
parsedEdits: data.parsedEdits,
parsedReads: data.parsedReads,
parsedCommand: data.parsedCommand,
};
}
const data = payload.data;
return {
toolName: data.toolName,
args: data.arguments,
result: data.result,
error: data.error,
parsedEdits: undefined,
parsedReads: undefined,
parsedCommand: undefined,
};
}, [selectedToolCall]);
const fallbackEditEntries = useMemo(() => extractEditEntries(args, result), [
args,
result,
]);
const editEntries: EditEntry[] = parsedEdits ?? fallbackEditEntries;
const fallbackReadEntries = useMemo(() => extractReadEntries(result, args), [
args,
result,
]);
const readEntries: ReadEntry[] = parsedReads ?? fallbackReadEntries;
const fallbackCommandDetails = useMemo(
() => extractCommandDetails(args, result),
[args, result]
);
const commandDetails: CommandDetails | null =
parsedCommand ?? fallbackCommandDetails;
return (
<BottomSheetModal
ref={bottomSheetRef}
snapPoints={snapPoints}
backdropComponent={renderBackdrop}
enablePanDownToClose={true}
handleIndicatorStyle={styles.handleIndicator}
backgroundStyle={styles.background}
topInset={insets.top}
onDismiss={onDismiss}
>
{/* Header */}
<View style={styles.header}>
<Text style={styles.toolName}>{toolName || "Tool Call"}</Text>
</View>
{/* Scrollable content */}
<BottomSheetScrollView
contentContainerStyle={styles.sheetContent}
showsVerticalScrollIndicator={true}
>
{editEntries.length > 0 &&
editEntries.map((entry, index) => (
<View key={`${entry.filePath ?? "file"}-${index}`} style={styles.section}>
<Text style={styles.sectionTitle}>File</Text>
<View style={styles.fileInfoContainer}>
<Text style={styles.fileInfoText}>
{entry.filePath ?? "Unknown file"}
</Text>
</View>
<Text style={styles.sectionTitle}>Diff</Text>
<View style={styles.diffContainer}>
<DiffViewer diffLines={entry.diffLines} />
</View>
</View>
))}
{readEntries.length > 0 &&
readEntries.map((entry, index) => (
<View key={`${entry.filePath ?? "read"}-${index}`} style={styles.section}>
<Text style={styles.sectionTitle}>Read Result</Text>
{entry.filePath && (
<View style={styles.fileInfoContainer}>
<Text style={styles.fileInfoText}>{entry.filePath}</Text>
</View>
)}
<ScrollView
style={styles.contentScroll}
contentContainerStyle={styles.contentContainer}
nestedScrollEnabled={true}
showsVerticalScrollIndicator={true}
>
<Text style={styles.contentText}>{entry.content}</Text>
</ScrollView>
</View>
))}
{commandDetails && (
<View style={styles.section}>
<Text style={styles.sectionTitle}>Command Output</Text>
{commandDetails.command && (
<View style={styles.commandMetaContainer}>
<Text style={styles.commandMetaLabel}>Command</Text>
<ScrollView horizontal nestedScrollEnabled={true} showsHorizontalScrollIndicator={true}>
<Text style={styles.commandMetaValue}>{commandDetails.command}</Text>
</ScrollView>
</View>
)}
{commandDetails.cwd && (
<View style={styles.commandMetaContainer}>
<Text style={styles.commandMetaLabel}>Directory</Text>
<ScrollView horizontal nestedScrollEnabled={true} showsHorizontalScrollIndicator={true}>
<Text style={styles.commandMetaValue}>{commandDetails.cwd}</Text>
</ScrollView>
</View>
)}
{commandDetails.exitCode !== undefined && (
<View style={styles.commandMetaContainer}>
<Text style={styles.commandMetaLabel}>Exit Code</Text>
<Text style={styles.commandMetaValue}>
{commandDetails.exitCode === null ? "Unknown" : commandDetails.exitCode}
</Text>
</View>
)}
{commandDetails.output && (
<View style={styles.commandOutputContainer}>
<Text style={styles.commandOutputLabel}>Output</Text>
<ScrollView
style={styles.contentScroll}
contentContainerStyle={styles.contentContainer}
nestedScrollEnabled={true}
showsVerticalScrollIndicator={true}
>
<Text style={styles.contentText}>{commandDetails.output}</Text>
</ScrollView>
</View>
)}
</View>
)}
{/* Content sections */}
{args !== undefined && (
<View style={styles.section}>
<Text style={styles.sectionTitle}>Arguments</Text>
<ScrollView
horizontal
style={styles.jsonContainer}
contentContainerStyle={styles.jsonContent}
showsHorizontalScrollIndicator={true}
nestedScrollEnabled={true}
>
<Text style={styles.jsonText}>
{JSON.stringify(args, null, 2)}
</Text>
</ScrollView>
</View>
)}
{result !== undefined && (
<View style={styles.section}>
<Text style={styles.sectionTitle}>Result</Text>
<ScrollView
horizontal
style={styles.jsonContainer}
contentContainerStyle={styles.jsonContent}
showsHorizontalScrollIndicator={true}
nestedScrollEnabled={true}
>
<Text style={styles.jsonText}>
{typeof result === "string"
? result
: JSON.stringify(result, null, 2)}
</Text>
</ScrollView>
</View>
)}
{error !== undefined && (
<View style={styles.section}>
<Text style={styles.sectionTitle}>Error</Text>
<ScrollView
horizontal
style={[styles.jsonContainer, styles.errorContainer]}
contentContainerStyle={styles.jsonContent}
showsHorizontalScrollIndicator={true}
nestedScrollEnabled={true}
>
<Text style={[styles.jsonText, styles.errorText]}>
{JSON.stringify(error, null, 2)}
</Text>
</ScrollView>
</View>
)}
</BottomSheetScrollView>
</BottomSheetModal>
);
}
const styles = StyleSheet.create((theme) => ({
handleIndicator: {
backgroundColor: theme.colors.border,
},
background: {
backgroundColor: theme.colors.popover,
},
header: {
paddingHorizontal: theme.spacing[6],
paddingTop: theme.spacing[1],
paddingBottom: theme.spacing[4],
borderBottomWidth: theme.borderWidth[1],
borderBottomColor: theme.colors.border,
backgroundColor: theme.colors.popover,
},
toolName: {
fontSize: theme.fontSize.xl,
fontWeight: theme.fontWeight.semibold,
color: theme.colors.popoverForeground,
},
sheetContent: {
paddingTop: theme.spacing[6],
paddingBottom: theme.spacing[6],
},
section: {
marginBottom: theme.spacing[6],
paddingHorizontal: theme.spacing[6],
},
sectionTitle: {
fontSize: theme.fontSize.sm,
fontWeight: theme.fontWeight.semibold,
color: theme.colors.mutedForeground,
marginBottom: theme.spacing[2],
textTransform: "uppercase" as const,
letterSpacing: 0.5,
},
fileInfoContainer: {
backgroundColor: theme.colors.background,
borderRadius: theme.borderRadius.lg,
borderWidth: theme.borderWidth[1],
borderColor: theme.colors.border,
padding: theme.spacing[3],
marginBottom: theme.spacing[4],
},
fileInfoText: {
fontFamily: "monospace",
fontSize: theme.fontSize.sm,
color: theme.colors.foreground,
},
diffContainer: {
borderRadius: theme.borderRadius.lg,
borderWidth: theme.borderWidth[1],
borderColor: theme.colors.border,
backgroundColor: theme.colors.card,
overflow: "hidden",
},
jsonContainer: {
backgroundColor: theme.colors.background,
borderRadius: theme.borderRadius.lg,
borderWidth: theme.borderWidth[1],
borderColor: theme.colors.border,
// Natural height based on content
},
jsonContent: {
padding: theme.spacing[3],
},
jsonText: {
fontFamily: "monospace",
fontSize: theme.fontSize.sm,
color: theme.colors.foreground,
lineHeight: 20,
// Text maintains whitespace and formatting
},
contentScroll: {
borderRadius: theme.borderRadius.lg,
borderWidth: theme.borderWidth[1],
borderColor: theme.colors.border,
backgroundColor: theme.colors.background,
maxHeight: 280,
},
contentContainer: {
padding: theme.spacing[3],
},
contentText: {
fontFamily: "monospace",
fontSize: theme.fontSize.sm,
color: theme.colors.foreground,
lineHeight: 20,
},
errorContainer: {
borderColor: theme.colors.destructive,
backgroundColor: theme.colors.background,
},
errorText: {
color: theme.colors.destructive,
},
commandMetaContainer: {
marginBottom: theme.spacing[2],
},
commandMetaLabel: {
fontSize: theme.fontSize.xs,
color: theme.colors.mutedForeground,
marginBottom: theme.spacing[1],
textTransform: "uppercase" as const,
letterSpacing: 0.5,
},
commandMetaValue: {
fontFamily: "monospace",
fontSize: theme.fontSize.sm,
color: theme.colors.foreground,
},
commandOutputContainer: {
marginTop: theme.spacing[3],
},
commandOutputLabel: {
fontSize: theme.fontSize.xs,
color: theme.colors.mutedForeground,
marginBottom: theme.spacing[1],
textTransform: "uppercase" as const,
letterSpacing: 0.5,
},
}));
@@ -21,6 +21,7 @@ import type {
AgentPermissionRequest,
AgentMode,
AgentCapabilityFlags,
AgentModelDefinition,
AgentUsage,
AgentPersistenceHandle,
} from "@server/server/agent/agent-sdk-types";
@@ -78,6 +79,13 @@ export type MessageEntry =
status: "executing" | "completed" | "failed";
};
type ProviderModelState = {
models: AgentModelDefinition[] | null;
fetchedAt: Date | null;
error: string | null;
isLoading: boolean;
};
export interface Agent {
id: string;
provider: AgentProvider;
@@ -245,6 +253,9 @@ interface SessionContextValue {
requestFilePreview: (agentId: string, path: string) => void;
navigateExplorerBack: (agentId: string) => string | null;
providerModels: Map<AgentProvider, ProviderModelState>;
requestProviderModels: (provider: AgentProvider, options?: { cwd?: string }) => void;
// Helpers
restartServer: (reason?: string) => void;
initializeAgent: (params: { agentId: string; requestId?: string }) => void;
@@ -305,10 +316,12 @@ export function SessionProvider({ children, serverUrl }: SessionProviderProps) {
const [pendingPermissions, setPendingPermissions] = useState<Map<string, PendingPermission>>(new Map());
const [gitDiffs, setGitDiffs] = useState<Map<string, string>>(new Map());
const [fileExplorer, setFileExplorer] = useState<Map<string, AgentFileExplorerState>>(new Map());
const [providerModels, setProviderModels] = useState<Map<AgentProvider, ProviderModelState>>(new Map());
const [draftInputs, setDraftInputs] = useState<SessionContextValue["draftInputs"]>(new Map());
const [queuedMessages, setQueuedMessages] = useState<SessionContextValue["queuedMessages"]>(new Map());
const activeAudioGroupsRef = useRef<Set<string>>(new Set());
const previousAgentStatusRef = useRef<Map<string, AgentLifecycleStatus>>(new Map());
const providerModelRequestIdsRef = useRef<Map<AgentProvider, string>>(new Map());
// Buffer for streaming audio chunks
interface AudioChunk {
@@ -879,6 +892,33 @@ export function SessionProvider({ children, serverUrl }: SessionProviderProps) {
});
});
const unsubProviderModels = ws.on(
"list_provider_models_response",
(message) => {
if (message.type !== "list_provider_models_response") {
return;
}
const { provider, models, error, fetchedAt, requestId } = message.payload;
const latestRequestId = providerModelRequestIdsRef.current.get(provider);
if (latestRequestId && requestId && requestId !== latestRequestId) {
return;
}
if (requestId) {
providerModelRequestIdsRef.current.delete(provider);
}
setProviderModels((prev) => {
const next = new Map(prev);
next.set(provider, {
models: models ?? null,
error: error ?? null,
fetchedAt: new Date(fetchedAt),
isLoading: false,
});
return next;
});
}
);
const unsubAgentDeleted = ws.on("agent_deleted", (message) => {
if (message.type !== "agent_deleted") {
return;
@@ -958,6 +998,7 @@ export function SessionProvider({ children, serverUrl }: SessionProviderProps) {
unsubTranscription();
unsubGitDiff();
unsubFileExplorer();
unsubProviderModels();
unsubAgentDeleted();
};
}, [ws, audioPlayer, setIsPlayingAudio, updateExplorerState]);
@@ -1010,6 +1051,37 @@ export function SessionProvider({ children, serverUrl }: SessionProviderProps) {
ws.send(msg);
}, [ws]);
const requestProviderModels = useCallback((provider: AgentProvider, options?: { cwd?: string }) => {
const requestId = generateMessageId();
providerModelRequestIdsRef.current.set(provider, requestId);
setProviderModels((prev) => {
const next = new Map(prev);
const current =
prev.get(provider) ?? {
models: null,
fetchedAt: null,
error: null,
isLoading: false,
};
next.set(provider, {
...current,
isLoading: true,
error: null,
});
return next;
});
const msg: WSInboundMessage = {
type: "session",
message: {
type: "list_provider_models_request",
provider,
...(options?.cwd ? { cwd: options.cwd } : {}),
requestId,
},
};
ws.send(msg);
}, [ws]);
const sendAgentMessage = useCallback(async (agentId: string, message: string, imageUris?: string[]) => {
// Generate unique message ID for deduplication
const messageId = generateMessageId();
@@ -1333,6 +1405,8 @@ export function SessionProvider({ children, serverUrl }: SessionProviderProps) {
gitDiffs,
requestGitDiff,
fileExplorer,
providerModels,
requestProviderModels,
draftInputs,
setDraftInputs,
queuedMessages,
-9
View File
@@ -1,13 +1,4 @@
import type { AgentPermissionRequest } from "@server/server/agent/agent-sdk-types";
import type { ToolCallPayload } from "./stream";
/**
* Generic tool call selection for bottom sheet
* Reuses ToolCallPayload so we don't duplicate shapes
*/
export interface SelectedToolCall {
payload: ToolCallPayload;
}
/**
* Pending permission structure
+42 -7
View File
@@ -83,11 +83,14 @@ export interface AssistantMessageItem {
timestamp: Date;
}
export type ThoughtStatus = "loading" | "ready";
export interface ThoughtItem {
kind: "thought";
id: string;
text: string;
timestamp: Date;
status: ThoughtStatus;
}
export type ToolCallStatus = "executing" | "completed" | "failed";
@@ -418,6 +421,7 @@ function appendThought(state: StreamItem[], text: string, timestamp: Date): Stre
...last,
text: `${last.text}${chunk}`,
timestamp,
status: "loading",
};
return [...state.slice(0, -1), updated];
}
@@ -432,10 +436,24 @@ function appendThought(state: StreamItem[], text: string, timestamp: Date): Stre
id: createUniqueTimelineId(state, "thought", idSeed, timestamp),
text: chunk,
timestamp,
status: "loading",
};
return [...state, item];
}
function finalizeActiveThoughts(state: StreamItem[]): StreamItem[] {
let mutated = false;
const nextState = state.map((entry) => {
if (entry.kind === "thought" && entry.status !== "ready") {
mutated = true;
return { ...entry, status: "ready" as ThoughtStatus };
}
return entry;
});
return mutated ? nextState : state;
}
function mergeToolCallRaw(existingRaw: unknown, nextRaw: unknown): unknown {
if (existingRaw === undefined || existingRaw === null) {
return nextRaw;
@@ -922,11 +940,14 @@ export function reduceStreamUpdate(
switch (event.type) {
case "timeline": {
const item = event.item;
let nextState = state;
switch (item.type) {
case "user_message":
return appendUserMessage(state, item.text, timestamp, item.messageId);
nextState = appendUserMessage(state, item.text, timestamp, item.messageId);
break;
case "assistant_message":
return appendAssistantMessage(state, item.text, timestamp);
nextState = appendAssistantMessage(state, item.text, timestamp);
break;
case "reasoning":
return appendThought(state, item.text, timestamp);
case "tool_call": {
@@ -935,7 +956,7 @@ export function reduceStreamUpdate(
}
const rawPayload =
item.raw ?? { input: item.input, output: item.output, error: item.error };
return appendAgentToolCall(
nextState = appendAgentToolCall(
state,
{
provider: event.provider,
@@ -951,10 +972,12 @@ export function reduceStreamUpdate(
},
timestamp
);
break;
}
case "todo": {
const items = (item.items ?? []) as TodoEntry[];
return appendTodoList(state, event.provider, items, timestamp, item.raw);
nextState = appendTodoList(state, event.provider, items, timestamp, item.raw);
break;
}
case "error": {
const activity: ActivityLogItem = {
@@ -965,19 +988,29 @@ export function reduceStreamUpdate(
message: formatErrorMessage(item.message ?? "Unknown error"),
metadata: item.raw ? { raw: item.raw } : undefined,
};
return appendActivityLog(state, activity);
nextState = appendActivityLog(state, activity);
break;
}
default:
return state;
}
return finalizeActiveThoughts(nextState);
}
case "provider_event": {
const converted = convertCodexProviderEvent(event.provider, event.raw);
if (!converted) {
return state;
}
return appendAgentToolCall(state, converted, timestamp);
return finalizeActiveThoughts(appendAgentToolCall(state, converted, timestamp));
}
case "thread_started":
case "turn_started":
case "turn_completed":
case "turn_failed":
case "permission_requested":
case "permission_resolved":
return finalizeActiveThoughts(state);
default:
return state;
}
@@ -989,7 +1022,9 @@ export function reduceStreamUpdate(
export function hydrateStreamState(
events: Array<{ event: AgentStreamEventPayload; timestamp: Date }>
): StreamItem[] {
return events.reduce<StreamItem[]>((state, { event, timestamp }) => {
const hydrated = events.reduce<StreamItem[]>((state, { event, timestamp }) => {
return reduceStreamUpdate(state, event, timestamp);
}, []);
return finalizeActiveThoughts(hydrated);
}