Files
paseo/packages/app/src/components/agent-stream-view.tsx
T
Mohamed BoudraandClaude 3483bd9afd fix: improve realtime controls layout and add agent status bar
- Change realtime controls to horizontal layout with fixed height (88px)
- Add orientation prop to VolumeMeter for horizontal/vertical modes
- Create AgentStatusBar component showing mode badge and agent status
- Add agent persistence system for managing agent state across restarts
- Fix bottom sheet dismiss handling to clear state properly
- Remove debug timer from realtime controls

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-24 11:17:05 +02:00

394 lines
11 KiB
TypeScript

import { useEffect, useRef, useState } from "react";
import { View, Text, ScrollView, Pressable } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { BottomSheetModal } from "@gorhom/bottom-sheet";
import {
AssistantMessage,
UserMessage,
ActivityLog,
ToolCall,
} from "./message";
import { ToolCallBottomSheet } from "./tool-call-bottom-sheet";
import type { StreamItem } from "@/types/stream";
import type {
SelectedToolCall,
PendingPermission,
AgentInfo,
} from "@/types/shared";
export interface AgentStreamViewProps {
agentId: string;
agent: AgentInfo;
streamItems: StreamItem[];
pendingPermissions: Map<string, PendingPermission>;
onPermissionResponse: (requestId: string, optionId: string) => void;
}
export function AgentStreamView({
agentId,
agent,
streamItems,
pendingPermissions,
onPermissionResponse,
}: AgentStreamViewProps) {
const scrollViewRef = useRef<ScrollView>(null);
const bottomSheetRef = useRef<BottomSheetModal | null>(null);
const insets = useSafeAreaInsets();
const [selectedToolCall, setSelectedToolCall] =
useState<SelectedToolCall | null>(null);
// Auto-scroll to bottom when new items arrive
useEffect(() => {
scrollViewRef.current?.scrollToEnd({ animated: true });
}, [streamItems]);
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);
}
return (
<View style={stylesheet.container}>
{/* Content list */}
<ScrollView
ref={scrollViewRef}
style={stylesheet.scrollView}
contentContainerStyle={{
paddingTop: 24,
paddingBottom: Math.max(insets.bottom, 32),
}}
>
{streamItems.length === 0 ? (
<View style={stylesheet.emptyState}>
<Text style={stylesheet.emptyStateText}>
Start chatting with this agent...
</Text>
</View>
) : (
streamItems.map((item) => {
switch (item.kind) {
case "user_message":
return (
<UserMessage
key={item.id}
message={item.text}
timestamp={item.timestamp.getTime()}
/>
);
case "assistant_message":
return (
<AssistantMessage
key={item.id}
message={item.text}
timestamp={item.timestamp.getTime()}
/>
);
case "thought":
return (
<ActivityLog
key={item.id}
type="info"
message={item.text}
timestamp={item.timestamp.getTime()}
/>
);
case "tool_call": {
const { payload } = item;
// Extract data based on source
if (payload.source === "acp") {
const data = payload.data;
// Map ACP status to display status
const toolStatus =
data.status === "pending" || data.status === "in_progress"
? ("executing" as const)
: data.status === "completed"
? ("completed" as const)
: ("failed" as const);
return (
<ToolCall
key={item.id}
toolName={data.title ?? "Unknown Tool"}
kind={data.kind}
args={data.rawInput}
result={data.rawOutput}
status={toolStatus}
onOpenDetails={() =>
handleOpenToolCallDetails({ payload })
}
/>
);
} else {
// Orchestrator tool call
const data = payload.data;
return (
<ToolCall
key={item.id}
toolName={data.toolName}
args={data.arguments}
result={data.result}
status={data.status}
onOpenDetails={() =>
handleOpenToolCallDetails({ payload })
}
/>
);
}
}
case "plan":
// TODO: Render plan component
return null;
case "activity_log":
case "artifact":
// These are orchestrator-only, skip for now
return null;
default:
return null;
}
})
)}
{/* Render pending permissions for this agent */}
{Array.from(pendingPermissions.values())
.filter((perm) => perm.agentId === agentId)
.map((permission) => (
<PermissionRequestCard
key={permission.requestId}
permission={permission}
onResponse={onPermissionResponse}
/>
))}
</ScrollView>
<ToolCallBottomSheet
bottomSheetRef={bottomSheetRef}
selectedToolCall={selectedToolCall}
onDismiss={handleBottomSheetDismiss}
/>
</View>
);
}
// Permission Request Card Component
function PermissionRequestCard({
permission,
onResponse,
}: {
permission: {
requestId: string;
toolCall: any;
options: Array<{
kind: string;
name: string;
optionId: string;
}>;
};
onResponse: (requestId: string, optionId: string) => void;
}) {
const { theme } = useUnistyles();
// Determine permission type and content based on toolCall
const getPermissionInfo = () => {
const rawInput = permission.toolCall?.rawInput || {};
const toolCallId = permission.toolCall?.toolCallId || "";
console.log("[PermissionCard] Tool call details:", {
toolCallId,
rawInputKeys: Object.keys(rawInput),
rawInput,
});
// Check if this is a plan (ExitPlanMode)
if (rawInput.plan) {
return {
title: "Plan Ready for Review",
content: rawInput.plan,
type: "plan" as const,
};
}
// Check if this is a file operation (Write, Edit, etc.)
if (rawInput.file_path) {
const operation = toolCallId.includes("Write") ? "Create" : "Edit";
const fileContent = rawInput.content || rawInput.new_string || "";
const preview =
fileContent.length > 500
? fileContent.slice(0, 500) + "\n\n... (truncated)"
: fileContent;
return {
title: `${operation} File Permission`,
content: `File: ${rawInput.file_path}\n\n${preview || "(empty file)"}`,
type: "file" as const,
};
}
// Check if this is a command (Bash)
if (rawInput.command) {
return {
title: "Run Command Permission",
content: `Command: ${rawInput.command}\n\nDescription: ${
rawInput.description || "No description"
}`,
type: "command" as const,
};
}
// Fallback - show whatever is in rawInput
return {
title: "Permission Required",
content: JSON.stringify(rawInput, null, 2),
type: "unknown" as const,
};
};
const permissionInfo = getPermissionInfo();
return (
<View
style={[
permissionStyles.container,
{
backgroundColor: theme.colors.secondary,
borderColor: theme.colors.border,
},
]}
>
<Text
style={[permissionStyles.title, { color: theme.colors.foreground }]}
>
{permissionInfo.title}
</Text>
{permissionInfo.content && (
<View
style={[
permissionStyles.planContainer,
{ backgroundColor: theme.colors.background },
]}
>
<Text
style={[
permissionStyles.planText,
{ color: theme.colors.foreground },
]}
>
{permissionInfo.content}
</Text>
</View>
)}
<Text
style={[
permissionStyles.question,
{ color: theme.colors.mutedForeground },
]}
>
How would you like to proceed?
</Text>
<View style={permissionStyles.optionsContainer}>
{permission.options.map((option) => (
<Pressable
key={option.optionId}
style={[
permissionStyles.optionButton,
{
backgroundColor: option.kind.includes("reject")
? theme.colors.destructive
: theme.colors.primary,
},
]}
onPress={() => onResponse(permission.requestId, option.optionId)}
>
<Text
style={[
permissionStyles.optionText,
{ color: theme.colors.primaryForeground },
]}
>
{option.name}
</Text>
</Pressable>
))}
</View>
</View>
);
}
const stylesheet = StyleSheet.create((theme) => ({
container: {
flex: 1,
backgroundColor: theme.colors.background,
},
scrollView: {
flex: 1,
paddingHorizontal: theme.spacing[2],
},
emptyState: {
flex: 1,
alignItems: "center",
justifyContent: "center",
paddingVertical: theme.spacing[12],
},
emptyStateText: {
color: theme.colors.mutedForeground,
fontSize: theme.fontSize.sm,
textAlign: "center",
},
}));
const permissionStyles = StyleSheet.create((theme) => ({
container: {
marginVertical: theme.spacing[3],
padding: theme.spacing[4],
borderRadius: theme.spacing[2],
borderWidth: 1,
},
title: {
fontSize: 16,
fontWeight: "600",
marginBottom: theme.spacing[3],
},
planContainer: {
padding: theme.spacing[3],
borderRadius: theme.spacing[1],
marginBottom: theme.spacing[3],
},
planText: {
fontSize: 14,
lineHeight: 20,
},
question: {
fontSize: 14,
marginBottom: theme.spacing[3],
},
optionsContainer: {
gap: theme.spacing[2],
},
optionButton: {
padding: theme.spacing[3],
borderRadius: theme.spacing[1],
alignItems: "center",
},
optionText: {
fontSize: 14,
fontWeight: "600",
},
}));