Fix reasoning timeline UI and type errors
This commit is contained in:
@@ -0,0 +1 @@
|
|||||||
|
patch
|
||||||
@@ -4,6 +4,33 @@ import type { PropsWithChildren } from "react";
|
|||||||
// Ensure Unistyles runs before Expo Router statically renders each page.
|
// Ensure Unistyles runs before Expo Router statically renders each page.
|
||||||
import "../styles/unistyles";
|
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) {
|
export default function Root({ children }: PropsWithChildren) {
|
||||||
return (
|
return (
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
@@ -12,10 +39,11 @@ export default function Root({ children }: PropsWithChildren) {
|
|||||||
<meta httpEquiv="X-UA-Compatible" content="IE=edge" />
|
<meta httpEquiv="X-UA-Compatible" content="IE=edge" />
|
||||||
<meta
|
<meta
|
||||||
name="viewport"
|
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. */}
|
{/* Reset scroll styles so React Native Web views behave like native. */}
|
||||||
<ScrollViewStyleReset />
|
<ScrollViewStyleReset />
|
||||||
|
<WebRespectfulStyleReset />
|
||||||
</head>
|
</head>
|
||||||
<body>{children}</body>
|
<body>{children}</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ interface AgentInputAreaProps {
|
|||||||
agentId: string;
|
agentId: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const MIN_INPUT_HEIGHT = 32;
|
const MIN_INPUT_HEIGHT = 50;
|
||||||
const MAX_INPUT_HEIGHT = 160;
|
const MAX_INPUT_HEIGHT = 160;
|
||||||
const MAX_INPUT_WIDTH = 960;
|
const MAX_INPUT_WIDTH = 960;
|
||||||
const BASE_VERTICAL_PADDING = (FOOTER_HEIGHT - MIN_INPUT_HEIGHT) / 2;
|
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 recordingIntervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||||
const textInputRef = useRef<TextInput | (TextInput & { getNativeRef?: () => unknown }) | 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 overlayTransition = useSharedValue(0);
|
||||||
const { pickImages } = useImageAttachmentPicker();
|
const { pickImages } = useImageAttachmentPicker();
|
||||||
|
|
||||||
@@ -102,6 +104,9 @@ export function AgentInputArea({ agentId }: AgentInputAreaProps) {
|
|||||||
}
|
}
|
||||||
console.log(`[AgentInput][InputHeight] ${label}`, payload);
|
console.log(`[AgentInput][InputHeight] ${label}`, payload);
|
||||||
};
|
};
|
||||||
|
useEffect(() => {
|
||||||
|
inputHeightRef.current = inputHeight;
|
||||||
|
}, [inputHeight]);
|
||||||
|
|
||||||
async function handleSendMessage() {
|
async function handleSendMessage() {
|
||||||
if (!userInput.trim() || !ws.isConnected) return;
|
if (!userInput.trim() || !ws.isConnected) return;
|
||||||
@@ -298,6 +303,32 @@ export function AgentInputArea({ agentId }: AgentInputAreaProps) {
|
|||||||
return null;
|
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 {
|
function measureWebInputHeight(source: string): boolean {
|
||||||
if (!IS_WEB) {
|
if (!IS_WEB) {
|
||||||
return false;
|
return false;
|
||||||
@@ -311,37 +342,59 @@ export function AgentInputArea({ agentId }: AgentInputAreaProps) {
|
|||||||
element.style.height = "auto";
|
element.style.height = "auto";
|
||||||
const measuredHeight = element.scrollHeight;
|
const measuredHeight = element.scrollHeight;
|
||||||
element.style.height = previousHeight ?? "";
|
element.style.height = previousHeight ?? "";
|
||||||
const bounded = Math.min(
|
const bounded = applyMeasuredHeight(measuredHeight, source, {
|
||||||
MAX_INPUT_HEIGHT,
|
|
||||||
Math.max(MIN_INPUT_HEIGHT, measuredHeight),
|
|
||||||
);
|
|
||||||
debugInputHeight(source, {
|
|
||||||
measuredHeight,
|
|
||||||
bounded,
|
|
||||||
scrollHeight: element.scrollHeight,
|
scrollHeight: element.scrollHeight,
|
||||||
inlineHeight: previousHeight,
|
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;
|
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) {
|
function setBoundedInputHeight(nextHeight: number) {
|
||||||
const boundedHeight = Math.min(
|
const boundedHeight = Math.min(
|
||||||
MAX_INPUT_HEIGHT,
|
MAX_INPUT_HEIGHT,
|
||||||
Math.max(MIN_INPUT_HEIGHT, nextHeight),
|
Math.max(MIN_INPUT_HEIGHT, nextHeight),
|
||||||
);
|
);
|
||||||
|
|
||||||
setInputHeight((currentHeight) => {
|
if (Math.abs(inputHeightRef.current - boundedHeight) < 1) {
|
||||||
if (Math.abs(currentHeight - boundedHeight) < 1) {
|
return;
|
||||||
return currentHeight;
|
}
|
||||||
}
|
|
||||||
debugInputHeight("set-state", {
|
debugInputHeight("set-state", {
|
||||||
nextHeight,
|
nextHeight,
|
||||||
boundedHeight,
|
boundedHeight,
|
||||||
previousHeight: currentHeight,
|
previousHeight: inputHeightRef.current,
|
||||||
});
|
|
||||||
return boundedHeight;
|
|
||||||
});
|
});
|
||||||
|
setInputHeight(boundedHeight);
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleContentSizeChange(
|
function handleContentSizeChange(
|
||||||
@@ -351,11 +404,9 @@ export function AgentInputArea({ agentId }: AgentInputAreaProps) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
debugInputHeight("native-measure", {
|
applyMeasuredHeight(event.nativeEvent.contentSize.height, "native-measure", {
|
||||||
height: event.nativeEvent.contentSize.height,
|
|
||||||
width: event.nativeEvent.contentSize.width,
|
width: event.nativeEvent.contentSize.width,
|
||||||
});
|
});
|
||||||
setBoundedInputHeight(event.nativeEvent.contentSize.height);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const agent = agents.get(agentId);
|
const agent = agents.get(agentId);
|
||||||
@@ -387,6 +438,13 @@ export function AgentInputArea({ agentId }: AgentInputAreaProps) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
|
if (isAgentRunning) {
|
||||||
|
if (!hasSendableContent || !ws.isConnected) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
handleQueueCurrentInput();
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (!shouldShowSendButton || isProcessing || !ws.isConnected) {
|
if (!shouldShowSendButton || isProcessing || !ws.isConnected) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -399,6 +457,10 @@ export function AgentInputArea({ agentId }: AgentInputAreaProps) {
|
|||||||
}
|
}
|
||||||
}, [isAgentRunning, ws.isConnected]);
|
}, [isAgentRunning, ws.isConnected]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
focusWebTextInput();
|
||||||
|
}, [agentId]);
|
||||||
|
|
||||||
// Hydrate draft only when switching agents
|
// Hydrate draft only when switching agents
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const draft = draftInputs.get(agentId);
|
const draft = draftInputs.get(agentId);
|
||||||
@@ -522,9 +584,9 @@ export function AgentInputArea({ agentId }: AgentInputAreaProps) {
|
|||||||
onPress={handleRealtimePress}
|
onPress={handleRealtimePress}
|
||||||
disabled={!ws.isConnected && !isRealtimeMode}
|
disabled={!ws.isConnected && !isRealtimeMode}
|
||||||
style={[
|
style={[
|
||||||
styles.realtimeButton,
|
styles.realtimeButton as any,
|
||||||
isRealtimeMode && styles.realtimeButtonActive,
|
(isRealtimeMode ? styles.realtimeButtonActive : undefined) as any,
|
||||||
(!ws.isConnected && !isRealtimeMode) && styles.buttonDisabled,
|
(!ws.isConnected && !isRealtimeMode ? styles.buttonDisabled : undefined) as any,
|
||||||
]}
|
]}
|
||||||
>
|
>
|
||||||
{isRealtimeMode ? (
|
{isRealtimeMode ? (
|
||||||
@@ -620,8 +682,8 @@ export function AgentInputArea({ agentId }: AgentInputAreaProps) {
|
|||||||
onPress={handlePickImage}
|
onPress={handlePickImage}
|
||||||
disabled={!ws.isConnected}
|
disabled={!ws.isConnected}
|
||||||
style={[
|
style={[
|
||||||
styles.attachButton,
|
styles.attachButton as any,
|
||||||
!ws.isConnected && styles.buttonDisabled,
|
(!ws.isConnected ? styles.buttonDisabled : undefined) as any,
|
||||||
]}
|
]}
|
||||||
>
|
>
|
||||||
<Paperclip size={20} color={theme.colors.foreground} />
|
<Paperclip size={20} color={theme.colors.foreground} />
|
||||||
@@ -639,7 +701,10 @@ export function AgentInputArea({ agentId }: AgentInputAreaProps) {
|
|||||||
disabled={!ws.isConnected}
|
disabled={!ws.isConnected}
|
||||||
accessibilityLabel="Queue message while agent is running"
|
accessibilityLabel="Queue message while agent is running"
|
||||||
accessibilityRole="button"
|
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" />
|
<ArrowUp size={20} color="white" />
|
||||||
</Pressable>
|
</Pressable>
|
||||||
@@ -651,8 +716,8 @@ export function AgentInputArea({ agentId }: AgentInputAreaProps) {
|
|||||||
accessibilityLabel={isCancellingAgent ? "Canceling agent" : "Stop agent"}
|
accessibilityLabel={isCancellingAgent ? "Canceling agent" : "Stop agent"}
|
||||||
accessibilityRole="button"
|
accessibilityRole="button"
|
||||||
style={[
|
style={[
|
||||||
styles.cancelButton,
|
styles.cancelButton as any,
|
||||||
(!ws.isConnected || isCancellingAgent) && styles.buttonDisabled,
|
(!ws.isConnected || isCancellingAgent ? styles.buttonDisabled : undefined) as any,
|
||||||
]}
|
]}
|
||||||
>
|
>
|
||||||
{isCancellingAgent ? (
|
{isCancellingAgent ? (
|
||||||
@@ -666,10 +731,10 @@ export function AgentInputArea({ agentId }: AgentInputAreaProps) {
|
|||||||
<Pressable
|
<Pressable
|
||||||
onPress={handleSendMessage}
|
onPress={handleSendMessage}
|
||||||
disabled={!ws.isConnected || isProcessing}
|
disabled={!ws.isConnected || isProcessing}
|
||||||
style={[
|
style={[
|
||||||
styles.sendButton,
|
styles.sendButton as any,
|
||||||
(!ws.isConnected || isProcessing) && styles.buttonDisabled,
|
(!ws.isConnected || isProcessing ? styles.buttonDisabled : undefined) as any,
|
||||||
]}
|
]}
|
||||||
>
|
>
|
||||||
<ArrowUp size={20} color="white" />
|
<ArrowUp size={20} color="white" />
|
||||||
</Pressable>
|
</Pressable>
|
||||||
@@ -679,9 +744,9 @@ export function AgentInputArea({ agentId }: AgentInputAreaProps) {
|
|||||||
onPress={handleVoicePress}
|
onPress={handleVoicePress}
|
||||||
disabled={!ws.isConnected || isRealtimeMode}
|
disabled={!ws.isConnected || isRealtimeMode}
|
||||||
style={[
|
style={[
|
||||||
styles.voiceButton,
|
styles.voiceButton as any,
|
||||||
(!ws.isConnected || isRealtimeMode) && styles.buttonDisabled,
|
(!ws.isConnected || isRealtimeMode ? styles.buttonDisabled : undefined) as any,
|
||||||
isRecording && styles.voiceButtonRecording,
|
(isRecording ? styles.voiceButtonRecording : undefined) as any,
|
||||||
]}
|
]}
|
||||||
>
|
>
|
||||||
{isRecording ? (
|
{isRecording ? (
|
||||||
@@ -715,7 +780,7 @@ export function AgentInputArea({ agentId }: AgentInputAreaProps) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const styles = StyleSheet.create((theme) => ({
|
const styles = StyleSheet.create(((theme: any) => ({
|
||||||
container: {
|
container: {
|
||||||
flexDirection: "column",
|
flexDirection: "column",
|
||||||
},
|
},
|
||||||
@@ -900,4 +965,4 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
queueSendButton: {
|
queueSendButton: {
|
||||||
backgroundColor: theme.colors.palette.blue[600],
|
backgroundColor: theme.colors.palette.blue[600],
|
||||||
},
|
},
|
||||||
}));
|
})) as any) as Record<string, any>;
|
||||||
|
|||||||
@@ -18,9 +18,20 @@ export function AgentList({ agents }: AgentListProps) {
|
|||||||
const [actionAgent, setActionAgent] = useState<Agent | null>(null);
|
const [actionAgent, setActionAgent] = useState<Agent | null>(null);
|
||||||
const isActionSheetVisible = actionAgent !== 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(() => {
|
const agentArray = useMemo(() => {
|
||||||
return Array.from(agents.values()).sort((a, b) => {
|
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 sortA = (a.lastUserMessageAt ?? a.lastActivityAt).getTime();
|
||||||
const sortB = (b.lastUserMessageAt ?? b.lastActivityAt).getTime();
|
const sortB = (b.lastUserMessageAt ?? b.lastActivityAt).getTime();
|
||||||
return sortB - sortA;
|
return sortB - sortA;
|
||||||
|
|||||||
@@ -14,7 +14,6 @@ import {
|
|||||||
import Markdown from "react-native-markdown-display";
|
import Markdown from "react-native-markdown-display";
|
||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
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 Animated, { FadeIn, FadeOut, cancelAnimation, useAnimatedStyle, useSharedValue, withDelay, withRepeat, withSequence, withTiming } from "react-native-reanimated";
|
||||||
import { ChevronDown } from "lucide-react-native";
|
import { ChevronDown } from "lucide-react-native";
|
||||||
import { useRouter } from "expo-router";
|
import { useRouter } from "expo-router";
|
||||||
@@ -27,10 +26,9 @@ import {
|
|||||||
TodoListCard,
|
TodoListCard,
|
||||||
type InlinePathTarget,
|
type InlinePathTarget,
|
||||||
} from "./message";
|
} from "./message";
|
||||||
import { ToolCallBottomSheet } from "./tool-call-bottom-sheet";
|
|
||||||
import { DiffViewer } from "./diff-viewer";
|
import { DiffViewer } from "./diff-viewer";
|
||||||
import type { StreamItem } from "@/types/stream";
|
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 { AgentPermissionResponse } from "@server/server/agent/agent-sdk-types";
|
||||||
import type { Agent } from "@/contexts/session-context";
|
import type { Agent } from "@/contexts/session-context";
|
||||||
import { useSession } from "@/contexts/session-context";
|
import { useSession } from "@/contexts/session-context";
|
||||||
@@ -57,10 +55,7 @@ export function AgentStreamView({
|
|||||||
onPermissionResponse,
|
onPermissionResponse,
|
||||||
}: AgentStreamViewProps) {
|
}: AgentStreamViewProps) {
|
||||||
const flatListRef = useRef<FlatList<StreamItem>>(null);
|
const flatListRef = useRef<FlatList<StreamItem>>(null);
|
||||||
const bottomSheetRef = useRef<BottomSheetModal | null>(null);
|
|
||||||
const insets = useSafeAreaInsets();
|
const insets = useSafeAreaInsets();
|
||||||
const [selectedToolCall, setSelectedToolCall] =
|
|
||||||
useState<SelectedToolCall | null>(null);
|
|
||||||
const [isNearBottom, setIsNearBottom] = useState(true);
|
const [isNearBottom, setIsNearBottom] = useState(true);
|
||||||
const hasScrolledInitially = useRef(false);
|
const hasScrolledInitially = useRef(false);
|
||||||
const hasAutoScrolledOnce = useRef(false);
|
const hasAutoScrolledOnce = useRef(false);
|
||||||
@@ -81,18 +76,6 @@ export function AgentStreamView({
|
|||||||
isNearBottomRef.current = true;
|
isNearBottomRef.current = true;
|
||||||
}, [agentId]);
|
}, [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(
|
const handleInlinePathPress = useCallback(
|
||||||
(target: InlinePathTarget) => {
|
(target: InlinePathTarget) => {
|
||||||
if (!target.path) {
|
if (!target.path) {
|
||||||
@@ -244,7 +227,7 @@ export function AgentStreamView({
|
|||||||
break;
|
break;
|
||||||
|
|
||||||
case "thought":
|
case "thought":
|
||||||
content = <AgentThoughtMessage message={item.text} />;
|
content = <AgentThoughtMessage message={item.text} status={item.status} />;
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case "tool_call": {
|
case "tool_call": {
|
||||||
@@ -264,7 +247,6 @@ export function AgentStreamView({
|
|||||||
parsedEditEntries={data.parsedEdits}
|
parsedEditEntries={data.parsedEdits}
|
||||||
parsedReadEntries={data.parsedReads}
|
parsedReadEntries={data.parsedReads}
|
||||||
parsedCommandDetails={data.parsedCommand ?? null}
|
parsedCommandDetails={data.parsedCommand ?? null}
|
||||||
onOpenDetails={() => handleOpenToolCallDetails({ payload })}
|
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
@@ -275,7 +257,6 @@ export function AgentStreamView({
|
|||||||
args={data.arguments}
|
args={data.arguments}
|
||||||
result={data.result}
|
result={data.result}
|
||||||
status={data.status}
|
status={data.status}
|
||||||
onOpenDetails={() => handleOpenToolCallDetails({ payload })}
|
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -423,11 +404,6 @@ export function AgentStreamView({
|
|||||||
</Animated.View>
|
</Animated.View>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<ToolCallBottomSheet
|
|
||||||
bottomSheetRef={bottomSheetRef}
|
|
||||||
selectedToolCall={selectedToolCall}
|
|
||||||
onDismiss={handleBottomSheetDismiss}
|
|
||||||
/>
|
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -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 { 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 type { AgentProvider } from "@server/server/agent/agent-sdk-types";
|
||||||
import { getAgentProviderDefinition } from "@server/server/agent/provider-manifest";
|
import { getAgentProviderDefinition } from "@server/server/agent/provider-manifest";
|
||||||
import Markdown from "react-native-markdown-display";
|
import Markdown from "react-native-markdown-display";
|
||||||
@@ -18,14 +19,14 @@ import {
|
|||||||
Pencil,
|
Pencil,
|
||||||
Eye,
|
Eye,
|
||||||
SquareTerminal,
|
SquareTerminal,
|
||||||
Brain,
|
|
||||||
Search,
|
Search,
|
||||||
|
Brain,
|
||||||
} from "lucide-react-native";
|
} from "lucide-react-native";
|
||||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||||
import { baseColors, theme } from "@/styles/theme";
|
import { baseColors, theme } from "@/styles/theme";
|
||||||
import { Colors } from "@/constants/theme";
|
import { Colors } from "@/constants/theme";
|
||||||
import * as Clipboard from "expo-clipboard";
|
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 type { CommandDetails, EditEntry, ReadEntry } from "@/utils/tool-call-parsers";
|
||||||
import { DiffViewer } from "./diff-viewer";
|
import { DiffViewer } from "./diff-viewer";
|
||||||
import { resolveToolCallPreview } from "./tool-call-preview";
|
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 {
|
function isLikelyPathToken(value: string): boolean {
|
||||||
if (!value || value.length > 300) {
|
if (!value || value.length > 300) {
|
||||||
return false;
|
return false;
|
||||||
@@ -848,74 +898,187 @@ export const TodoListCard = memo(function TodoListCard({
|
|||||||
|
|
||||||
interface AgentThoughtMessageProps {
|
interface AgentThoughtMessageProps {
|
||||||
message: string;
|
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) => ({
|
const agentThoughtStylesheet = StyleSheet.create((theme) => ({
|
||||||
container: {
|
emptyText: {
|
||||||
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: {
|
|
||||||
color: theme.colors.mutedForeground,
|
color: theme.colors.mutedForeground,
|
||||||
fontSize: theme.fontSize.xs,
|
fontSize: theme.fontSize.xs,
|
||||||
fontWeight: theme.fontWeight.semibold,
|
fontStyle: "italic" as const,
|
||||||
textTransform: "uppercase",
|
|
||||||
marginBottom: theme.spacing[1],
|
|
||||||
},
|
|
||||||
text: {
|
|
||||||
color: theme.colors.mutedForeground,
|
|
||||||
fontSize: theme.fontSize.base,
|
|
||||||
lineHeight: 20,
|
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
export function AgentThoughtMessage({ message }: AgentThoughtMessageProps) {
|
export function AgentThoughtMessage({ message, status = "ready" }: AgentThoughtMessageProps) {
|
||||||
const messageText = useMemo(() => {
|
const { theme } = useUnistyles();
|
||||||
return message
|
const [isExpanded, setIsExpanded] = useState(false);
|
||||||
.trim()
|
const markdownContent = useMemo(() => message?.trim() ?? "", [message]);
|
||||||
.replace(/^\*\*|\*\*$/g, "")
|
const markdownStyles = useMemo(
|
||||||
.trim();
|
() => ({
|
||||||
}, [message]);
|
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 (
|
return (
|
||||||
<View style={agentThoughtStylesheet.container}>
|
<ExpandableBadge
|
||||||
<View style={agentThoughtStylesheet.card}>
|
label="Thinking"
|
||||||
<View style={agentThoughtStylesheet.iconContainer}>
|
icon={status === "ready" ? Brain : undefined}
|
||||||
<Brain size={18} style={agentThoughtStylesheet.icon} />
|
isExpanded={isExpanded}
|
||||||
</View>
|
onToggle={toggle}
|
||||||
<View style={agentThoughtStylesheet.textContainer}>
|
renderDetails={renderDetails}
|
||||||
<Text style={agentThoughtStylesheet.text}>{messageText}</Text>
|
isLoading={status !== "ready"}
|
||||||
</View>
|
/>
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -929,77 +1092,14 @@ interface ToolCallProps {
|
|||||||
parsedEditEntries?: EditEntry[];
|
parsedEditEntries?: EditEntry[];
|
||||||
parsedReadEntries?: ReadEntry[];
|
parsedReadEntries?: ReadEntry[];
|
||||||
parsedCommandDetails?: CommandDetails | null;
|
parsedCommandDetails?: CommandDetails | null;
|
||||||
onOpenDetails?: () => void;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const toolCallStylesheet = StyleSheet.create((theme) => ({
|
const toolCallStylesheet = StyleSheet.create((theme) => ({
|
||||||
pressable: {
|
detailContent: {
|
||||||
marginHorizontal: theme.spacing[2],
|
gap: theme.spacing[3],
|
||||||
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],
|
|
||||||
},
|
},
|
||||||
section: {
|
section: {
|
||||||
// empty - just for grouping
|
gap: theme.spacing[1],
|
||||||
},
|
},
|
||||||
sectionTitle: {
|
sectionTitle: {
|
||||||
color: theme.colors.mutedForeground,
|
color: theme.colors.mutedForeground,
|
||||||
@@ -1007,26 +1107,78 @@ const toolCallStylesheet = StyleSheet.create((theme) => ({
|
|||||||
fontWeight: theme.fontWeight.semibold,
|
fontWeight: theme.fontWeight.semibold,
|
||||||
textTransform: "uppercase",
|
textTransform: "uppercase",
|
||||||
letterSpacing: 0.5,
|
letterSpacing: 0.5,
|
||||||
marginBottom: 6,
|
|
||||||
},
|
},
|
||||||
errorSectionTitle: {
|
fileBadge: {
|
||||||
color: theme.colors.palette.red[300],
|
alignSelf: "flex-start",
|
||||||
},
|
paddingHorizontal: theme.spacing[2],
|
||||||
sectionContent: {
|
paddingVertical: theme.spacing[1],
|
||||||
backgroundColor: theme.colors.background,
|
|
||||||
borderRadius: theme.borderRadius.base,
|
borderRadius: theme.borderRadius.base,
|
||||||
borderWidth: theme.borderWidth[1],
|
borderWidth: theme.borderWidth[1],
|
||||||
borderColor: theme.colors.border,
|
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],
|
padding: theme.spacing[2],
|
||||||
},
|
},
|
||||||
errorSectionContent: {
|
scrollText: {
|
||||||
borderColor: theme.colors.palette.red[800],
|
|
||||||
},
|
|
||||||
sectionText: {
|
|
||||||
color: theme.colors.foreground,
|
|
||||||
fontSize: theme.fontSize.xs,
|
|
||||||
fontFamily: "monospace",
|
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
|
// Add more mappings as needed
|
||||||
};
|
};
|
||||||
|
|
||||||
function formatPreviewValue(value: unknown, limit = 800): string {
|
function formatFullValue(value: unknown): string {
|
||||||
if (value === undefined || value === null) {
|
if (value === undefined) {
|
||||||
return "";
|
return "";
|
||||||
}
|
}
|
||||||
let text: string;
|
|
||||||
if (typeof value === "string") {
|
if (typeof value === "string") {
|
||||||
text = value.trim();
|
return value;
|
||||||
} else {
|
|
||||||
try {
|
|
||||||
text = JSON.stringify(value, null, 2);
|
|
||||||
} catch {
|
|
||||||
text = String(value);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
if (text.length <= limit) {
|
try {
|
||||||
return text;
|
return JSON.stringify(value, null, 2);
|
||||||
|
} catch {
|
||||||
|
return String(value);
|
||||||
}
|
}
|
||||||
return `${text.slice(0, limit)}…`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ToolCall = memo(function ToolCall({
|
export const ToolCall = memo(function ToolCall({
|
||||||
@@ -1069,7 +1215,6 @@ export const ToolCall = memo(function ToolCall({
|
|||||||
parsedEditEntries,
|
parsedEditEntries,
|
||||||
parsedReadEntries,
|
parsedReadEntries,
|
||||||
parsedCommandDetails,
|
parsedCommandDetails,
|
||||||
onOpenDetails,
|
|
||||||
}: ToolCallProps) {
|
}: ToolCallProps) {
|
||||||
const { editEntries, readEntries, commandDetails } = useMemo(
|
const { editEntries, readEntries, commandDetails } = useMemo(
|
||||||
() =>
|
() =>
|
||||||
@@ -1083,227 +1228,216 @@ export const ToolCall = memo(function ToolCall({
|
|||||||
[args, result, parsedEditEntries, parsedReadEntries, parsedCommandDetails]
|
[args, result, parsedEditEntries, parsedReadEntries, parsedCommandDetails]
|
||||||
);
|
);
|
||||||
|
|
||||||
const primaryEditEntry = editEntries[0];
|
const [isExpanded, setIsExpanded] = useState(false);
|
||||||
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 spinAnim = useRef(new Animated.Value(0)).current;
|
const IconComponent = kind
|
||||||
|
? toolKindIcons[kind.toLowerCase()] || Wrench
|
||||||
|
: Wrench;
|
||||||
|
|
||||||
useEffect(() => {
|
const serializedArgs = useMemo(
|
||||||
if (status === "executing") {
|
() => (args !== undefined ? formatFullValue(args) : ""),
|
||||||
// Reset to 0 before starting
|
[args]
|
||||||
spinAnim.setValue(0);
|
);
|
||||||
Animated.loop(
|
const serializedResult = useMemo(
|
||||||
Animated.timing(spinAnim, {
|
() => (result !== undefined ? formatFullValue(result) : ""),
|
||||||
toValue: 1,
|
[result]
|
||||||
duration: 1500,
|
);
|
||||||
useNativeDriver: true,
|
const serializedError = useMemo(
|
||||||
easing: (t) => t, // Linear easing for smooth continuous rotation
|
() => (error !== undefined ? formatFullValue(error) : ""),
|
||||||
})
|
[error]
|
||||||
).start();
|
);
|
||||||
} else {
|
|
||||||
spinAnim.stopAnimation();
|
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({
|
const editSections = useMemo(
|
||||||
inputRange: [0, 1],
|
() =>
|
||||||
outputRange: ["0deg", "360deg"],
|
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 = {
|
const readSections = useMemo(
|
||||||
executing: {
|
() =>
|
||||||
border: toolCallStylesheet.executingBorder,
|
readEntries.map((entry, index) => (
|
||||||
badgeBg: toolCallStylesheet.executingBadgeBg,
|
<View
|
||||||
color: "#fff",
|
key={`${entry.filePath ?? "read"}-${index}`}
|
||||||
},
|
style={toolCallStylesheet.section}
|
||||||
completed: {
|
>
|
||||||
border: toolCallStylesheet.completedBorder,
|
<Text style={toolCallStylesheet.sectionTitle}>Read Result</Text>
|
||||||
badgeBg: toolCallStylesheet.completedBadgeBg,
|
{entry.filePath ? (
|
||||||
color: "#71717a",
|
<View style={toolCallStylesheet.fileBadge}>
|
||||||
},
|
<Text style={toolCallStylesheet.fileBadgeText}>{entry.filePath}</Text>
|
||||||
failed: {
|
</View>
|
||||||
border: toolCallStylesheet.failedBorder,
|
) : null}
|
||||||
badgeBg: toolCallStylesheet.failedBadgeBg,
|
<ScrollView
|
||||||
color: "#fca5a5",
|
style={toolCallStylesheet.scrollArea}
|
||||||
},
|
contentContainerStyle={toolCallStylesheet.scrollContent}
|
||||||
};
|
nestedScrollEnabled
|
||||||
|
showsVerticalScrollIndicator={true}
|
||||||
|
>
|
||||||
|
<Text style={toolCallStylesheet.scrollText}>{entry.content}</Text>
|
||||||
|
</ScrollView>
|
||||||
|
</View>
|
||||||
|
)),
|
||||||
|
[readEntries]
|
||||||
|
);
|
||||||
|
|
||||||
const config = statusConfig[status];
|
const jsonSections = useMemo(() => {
|
||||||
|
const sections: ReactNode[] = [];
|
||||||
// Get the appropriate icon for the tool kind
|
if (args !== undefined) {
|
||||||
const getToolIcon = () => {
|
sections.push(renderJsonSection("Arguments", serializedArgs));
|
||||||
if (kind) {
|
|
||||||
const IconComponent = toolKindIcons[kind.toLowerCase()] || Wrench;
|
|
||||||
return IconComponent;
|
|
||||||
}
|
}
|
||||||
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 (
|
return (
|
||||||
<Pressable
|
<ExpandableBadge
|
||||||
onPress={onOpenDetails}
|
label={toolName}
|
||||||
style={[
|
icon={IconComponent}
|
||||||
toolCallStylesheet.pressable,
|
isExpanded={isExpanded}
|
||||||
toolCallStylesheet.pressableActive,
|
onToggle={handleToggle}
|
||||||
config.border,
|
renderDetails={renderDetails}
|
||||||
]}
|
isLoading={status === "executing"}
|
||||||
>
|
isError={status === "failed"}
|
||||||
<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>
|
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,16 +1,13 @@
|
|||||||
import { View, ScrollView } from "react-native";
|
import { View, ScrollView } from "react-native";
|
||||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
import { StyleSheet } from "react-native-unistyles";
|
||||||
import { forwardRef, useRef, useState } from "react";
|
import { forwardRef } from "react";
|
||||||
import { BottomSheetModal } from "@gorhom/bottom-sheet";
|
|
||||||
import {
|
import {
|
||||||
UserMessage,
|
UserMessage,
|
||||||
AssistantMessage,
|
AssistantMessage,
|
||||||
ActivityLog,
|
ActivityLog,
|
||||||
ToolCall,
|
ToolCall,
|
||||||
} from "@/components/message";
|
} from "@/components/message";
|
||||||
import { ToolCallBottomSheet } from "./tool-call-bottom-sheet";
|
|
||||||
import type { MessageEntry } from "@/contexts/session-context";
|
import type { MessageEntry } from "@/contexts/session-context";
|
||||||
import type { SelectedToolCall } from "@/types/shared";
|
|
||||||
|
|
||||||
interface OrchestratorMessagesViewProps {
|
interface OrchestratorMessagesViewProps {
|
||||||
messages: MessageEntry[];
|
messages: MessageEntry[];
|
||||||
@@ -20,43 +17,6 @@ interface OrchestratorMessagesViewProps {
|
|||||||
|
|
||||||
export const OrchestratorMessagesView = forwardRef<ScrollView, OrchestratorMessagesViewProps>(
|
export const OrchestratorMessagesView = forwardRef<ScrollView, OrchestratorMessagesViewProps>(
|
||||||
function OrchestratorMessagesView({ messages, currentAssistantMessage, onArtifactClick }, ref) {
|
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -125,13 +85,6 @@ export const OrchestratorMessagesView = forwardRef<ScrollView, OrchestratorMessa
|
|||||||
result={msg.result}
|
result={msg.result}
|
||||||
error={msg.error}
|
error={msg.error}
|
||||||
status={msg.status}
|
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>
|
</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,
|
AgentPermissionRequest,
|
||||||
AgentMode,
|
AgentMode,
|
||||||
AgentCapabilityFlags,
|
AgentCapabilityFlags,
|
||||||
|
AgentModelDefinition,
|
||||||
AgentUsage,
|
AgentUsage,
|
||||||
AgentPersistenceHandle,
|
AgentPersistenceHandle,
|
||||||
} from "@server/server/agent/agent-sdk-types";
|
} from "@server/server/agent/agent-sdk-types";
|
||||||
@@ -78,6 +79,13 @@ export type MessageEntry =
|
|||||||
status: "executing" | "completed" | "failed";
|
status: "executing" | "completed" | "failed";
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type ProviderModelState = {
|
||||||
|
models: AgentModelDefinition[] | null;
|
||||||
|
fetchedAt: Date | null;
|
||||||
|
error: string | null;
|
||||||
|
isLoading: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
export interface Agent {
|
export interface Agent {
|
||||||
id: string;
|
id: string;
|
||||||
provider: AgentProvider;
|
provider: AgentProvider;
|
||||||
@@ -245,6 +253,9 @@ interface SessionContextValue {
|
|||||||
requestFilePreview: (agentId: string, path: string) => void;
|
requestFilePreview: (agentId: string, path: string) => void;
|
||||||
navigateExplorerBack: (agentId: string) => string | null;
|
navigateExplorerBack: (agentId: string) => string | null;
|
||||||
|
|
||||||
|
providerModels: Map<AgentProvider, ProviderModelState>;
|
||||||
|
requestProviderModels: (provider: AgentProvider, options?: { cwd?: string }) => void;
|
||||||
|
|
||||||
// Helpers
|
// Helpers
|
||||||
restartServer: (reason?: string) => void;
|
restartServer: (reason?: string) => void;
|
||||||
initializeAgent: (params: { agentId: string; requestId?: 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 [pendingPermissions, setPendingPermissions] = useState<Map<string, PendingPermission>>(new Map());
|
||||||
const [gitDiffs, setGitDiffs] = useState<Map<string, string>>(new Map());
|
const [gitDiffs, setGitDiffs] = useState<Map<string, string>>(new Map());
|
||||||
const [fileExplorer, setFileExplorer] = useState<Map<string, AgentFileExplorerState>>(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 [draftInputs, setDraftInputs] = useState<SessionContextValue["draftInputs"]>(new Map());
|
||||||
const [queuedMessages, setQueuedMessages] = useState<SessionContextValue["queuedMessages"]>(new Map());
|
const [queuedMessages, setQueuedMessages] = useState<SessionContextValue["queuedMessages"]>(new Map());
|
||||||
const activeAudioGroupsRef = useRef<Set<string>>(new Set());
|
const activeAudioGroupsRef = useRef<Set<string>>(new Set());
|
||||||
const previousAgentStatusRef = useRef<Map<string, AgentLifecycleStatus>>(new Map());
|
const previousAgentStatusRef = useRef<Map<string, AgentLifecycleStatus>>(new Map());
|
||||||
|
const providerModelRequestIdsRef = useRef<Map<AgentProvider, string>>(new Map());
|
||||||
|
|
||||||
// Buffer for streaming audio chunks
|
// Buffer for streaming audio chunks
|
||||||
interface AudioChunk {
|
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) => {
|
const unsubAgentDeleted = ws.on("agent_deleted", (message) => {
|
||||||
if (message.type !== "agent_deleted") {
|
if (message.type !== "agent_deleted") {
|
||||||
return;
|
return;
|
||||||
@@ -958,6 +998,7 @@ export function SessionProvider({ children, serverUrl }: SessionProviderProps) {
|
|||||||
unsubTranscription();
|
unsubTranscription();
|
||||||
unsubGitDiff();
|
unsubGitDiff();
|
||||||
unsubFileExplorer();
|
unsubFileExplorer();
|
||||||
|
unsubProviderModels();
|
||||||
unsubAgentDeleted();
|
unsubAgentDeleted();
|
||||||
};
|
};
|
||||||
}, [ws, audioPlayer, setIsPlayingAudio, updateExplorerState]);
|
}, [ws, audioPlayer, setIsPlayingAudio, updateExplorerState]);
|
||||||
@@ -1010,6 +1051,37 @@ export function SessionProvider({ children, serverUrl }: SessionProviderProps) {
|
|||||||
ws.send(msg);
|
ws.send(msg);
|
||||||
}, [ws]);
|
}, [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[]) => {
|
const sendAgentMessage = useCallback(async (agentId: string, message: string, imageUris?: string[]) => {
|
||||||
// Generate unique message ID for deduplication
|
// Generate unique message ID for deduplication
|
||||||
const messageId = generateMessageId();
|
const messageId = generateMessageId();
|
||||||
@@ -1333,6 +1405,8 @@ export function SessionProvider({ children, serverUrl }: SessionProviderProps) {
|
|||||||
gitDiffs,
|
gitDiffs,
|
||||||
requestGitDiff,
|
requestGitDiff,
|
||||||
fileExplorer,
|
fileExplorer,
|
||||||
|
providerModels,
|
||||||
|
requestProviderModels,
|
||||||
draftInputs,
|
draftInputs,
|
||||||
setDraftInputs,
|
setDraftInputs,
|
||||||
queuedMessages,
|
queuedMessages,
|
||||||
|
|||||||
@@ -1,13 +1,4 @@
|
|||||||
import type { AgentPermissionRequest } from "@server/server/agent/agent-sdk-types";
|
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
|
* Pending permission structure
|
||||||
|
|||||||
@@ -83,11 +83,14 @@ export interface AssistantMessageItem {
|
|||||||
timestamp: Date;
|
timestamp: Date;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type ThoughtStatus = "loading" | "ready";
|
||||||
|
|
||||||
export interface ThoughtItem {
|
export interface ThoughtItem {
|
||||||
kind: "thought";
|
kind: "thought";
|
||||||
id: string;
|
id: string;
|
||||||
text: string;
|
text: string;
|
||||||
timestamp: Date;
|
timestamp: Date;
|
||||||
|
status: ThoughtStatus;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ToolCallStatus = "executing" | "completed" | "failed";
|
export type ToolCallStatus = "executing" | "completed" | "failed";
|
||||||
@@ -418,6 +421,7 @@ function appendThought(state: StreamItem[], text: string, timestamp: Date): Stre
|
|||||||
...last,
|
...last,
|
||||||
text: `${last.text}${chunk}`,
|
text: `${last.text}${chunk}`,
|
||||||
timestamp,
|
timestamp,
|
||||||
|
status: "loading",
|
||||||
};
|
};
|
||||||
return [...state.slice(0, -1), updated];
|
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),
|
id: createUniqueTimelineId(state, "thought", idSeed, timestamp),
|
||||||
text: chunk,
|
text: chunk,
|
||||||
timestamp,
|
timestamp,
|
||||||
|
status: "loading",
|
||||||
};
|
};
|
||||||
return [...state, item];
|
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 {
|
function mergeToolCallRaw(existingRaw: unknown, nextRaw: unknown): unknown {
|
||||||
if (existingRaw === undefined || existingRaw === null) {
|
if (existingRaw === undefined || existingRaw === null) {
|
||||||
return nextRaw;
|
return nextRaw;
|
||||||
@@ -922,11 +940,14 @@ export function reduceStreamUpdate(
|
|||||||
switch (event.type) {
|
switch (event.type) {
|
||||||
case "timeline": {
|
case "timeline": {
|
||||||
const item = event.item;
|
const item = event.item;
|
||||||
|
let nextState = state;
|
||||||
switch (item.type) {
|
switch (item.type) {
|
||||||
case "user_message":
|
case "user_message":
|
||||||
return appendUserMessage(state, item.text, timestamp, item.messageId);
|
nextState = appendUserMessage(state, item.text, timestamp, item.messageId);
|
||||||
|
break;
|
||||||
case "assistant_message":
|
case "assistant_message":
|
||||||
return appendAssistantMessage(state, item.text, timestamp);
|
nextState = appendAssistantMessage(state, item.text, timestamp);
|
||||||
|
break;
|
||||||
case "reasoning":
|
case "reasoning":
|
||||||
return appendThought(state, item.text, timestamp);
|
return appendThought(state, item.text, timestamp);
|
||||||
case "tool_call": {
|
case "tool_call": {
|
||||||
@@ -935,7 +956,7 @@ export function reduceStreamUpdate(
|
|||||||
}
|
}
|
||||||
const rawPayload =
|
const rawPayload =
|
||||||
item.raw ?? { input: item.input, output: item.output, error: item.error };
|
item.raw ?? { input: item.input, output: item.output, error: item.error };
|
||||||
return appendAgentToolCall(
|
nextState = appendAgentToolCall(
|
||||||
state,
|
state,
|
||||||
{
|
{
|
||||||
provider: event.provider,
|
provider: event.provider,
|
||||||
@@ -951,10 +972,12 @@ export function reduceStreamUpdate(
|
|||||||
},
|
},
|
||||||
timestamp
|
timestamp
|
||||||
);
|
);
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
case "todo": {
|
case "todo": {
|
||||||
const items = (item.items ?? []) as TodoEntry[];
|
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": {
|
case "error": {
|
||||||
const activity: ActivityLogItem = {
|
const activity: ActivityLogItem = {
|
||||||
@@ -965,19 +988,29 @@ export function reduceStreamUpdate(
|
|||||||
message: formatErrorMessage(item.message ?? "Unknown error"),
|
message: formatErrorMessage(item.message ?? "Unknown error"),
|
||||||
metadata: item.raw ? { raw: item.raw } : undefined,
|
metadata: item.raw ? { raw: item.raw } : undefined,
|
||||||
};
|
};
|
||||||
return appendActivityLog(state, activity);
|
nextState = appendActivityLog(state, activity);
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
default:
|
default:
|
||||||
return state;
|
return state;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return finalizeActiveThoughts(nextState);
|
||||||
}
|
}
|
||||||
case "provider_event": {
|
case "provider_event": {
|
||||||
const converted = convertCodexProviderEvent(event.provider, event.raw);
|
const converted = convertCodexProviderEvent(event.provider, event.raw);
|
||||||
if (!converted) {
|
if (!converted) {
|
||||||
return state;
|
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:
|
default:
|
||||||
return state;
|
return state;
|
||||||
}
|
}
|
||||||
@@ -989,7 +1022,9 @@ export function reduceStreamUpdate(
|
|||||||
export function hydrateStreamState(
|
export function hydrateStreamState(
|
||||||
events: Array<{ event: AgentStreamEventPayload; timestamp: Date }>
|
events: Array<{ event: AgentStreamEventPayload; timestamp: Date }>
|
||||||
): StreamItem[] {
|
): StreamItem[] {
|
||||||
return events.reduce<StreamItem[]>((state, { event, timestamp }) => {
|
const hydrated = events.reduce<StreamItem[]>((state, { event, timestamp }) => {
|
||||||
return reduceStreamUpdate(state, event, timestamp);
|
return reduceStreamUpdate(state, event, timestamp);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
return finalizeActiveThoughts(hydrated);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -354,6 +354,14 @@ export class AgentManager {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
finalized = true;
|
finalized = true;
|
||||||
|
|
||||||
|
if (agent.pendingRun !== iterator) {
|
||||||
|
if (error) {
|
||||||
|
agent.lastError = error;
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
agent.pendingRun = null;
|
agent.pendingRun = null;
|
||||||
agent.status = error ? "error" : "idle";
|
agent.status = error ? "error" : "idle";
|
||||||
agent.lastError = error;
|
agent.lastError = error;
|
||||||
@@ -396,11 +404,30 @@ export class AgentManager {
|
|||||||
|
|
||||||
async cancelAgentRun(agentId: string): Promise<boolean> {
|
async cancelAgentRun(agentId: string): Promise<boolean> {
|
||||||
const agent = this.requireAgent(agentId);
|
const agent = this.requireAgent(agentId);
|
||||||
if (!agent.pendingRun || typeof agent.pendingRun.return !== "function") {
|
const pendingRun = agent.pendingRun;
|
||||||
|
if (!pendingRun || typeof pendingRun.return !== "function") {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await agent.pendingRun.return(undefined as unknown as AgentStreamEvent);
|
await agent.session.interrupt();
|
||||||
|
} catch (error) {
|
||||||
|
console.error(
|
||||||
|
`[AgentManager] Failed to interrupt session for agent ${agentId}:`,
|
||||||
|
error
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const cancellation = pendingRun.return(
|
||||||
|
undefined as unknown as AgentStreamEvent
|
||||||
|
);
|
||||||
|
void cancellation.catch((error) => {
|
||||||
|
console.error(
|
||||||
|
`[AgentManager] Failed to cancel run for agent ${agentId}:`,
|
||||||
|
error
|
||||||
|
);
|
||||||
|
});
|
||||||
return true;
|
return true;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(
|
console.error(
|
||||||
|
|||||||
@@ -12,6 +12,15 @@ export type AgentMode = {
|
|||||||
description?: string;
|
description?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type AgentModelDefinition = {
|
||||||
|
provider: AgentProvider;
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
description?: string;
|
||||||
|
isDefault?: boolean;
|
||||||
|
metadata?: Record<string, unknown>;
|
||||||
|
};
|
||||||
|
|
||||||
export type AgentCapabilityFlags = {
|
export type AgentCapabilityFlags = {
|
||||||
supportsStreaming: boolean;
|
supportsStreaming: boolean;
|
||||||
supportsSessionPersistence: boolean;
|
supportsSessionPersistence: boolean;
|
||||||
@@ -159,6 +168,7 @@ export interface AgentSession {
|
|||||||
getPendingPermissions(): AgentPermissionRequest[];
|
getPendingPermissions(): AgentPermissionRequest[];
|
||||||
respondToPermission(requestId: string, response: AgentPermissionResponse): Promise<void>;
|
respondToPermission(requestId: string, response: AgentPermissionResponse): Promise<void>;
|
||||||
describePersistence(): AgentPersistenceHandle | null;
|
describePersistence(): AgentPersistenceHandle | null;
|
||||||
|
interrupt(): Promise<void>;
|
||||||
close(): Promise<void>;
|
close(): Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import { describe, expect, test } from "vitest";
|
||||||
|
|
||||||
|
import {
|
||||||
|
fetchClaudeModelCatalog,
|
||||||
|
fetchCodexModelCatalog,
|
||||||
|
} from "./model-catalog.js";
|
||||||
|
|
||||||
|
describe("provider model catalogs", () => {
|
||||||
|
test(
|
||||||
|
"Claude catalog exposes Sonnet and Haiku variants",
|
||||||
|
async () => {
|
||||||
|
const models = await fetchClaudeModelCatalog();
|
||||||
|
expect(models.length).toBeGreaterThan(0);
|
||||||
|
|
||||||
|
const descriptions = models
|
||||||
|
.map((model) => `${model.label} ${model.description ?? ""}`.toLowerCase());
|
||||||
|
expect(descriptions.some((text) => text.includes("sonnet 4.5"))).toBe(true);
|
||||||
|
expect(descriptions.some((text) => text.includes("haiku"))).toBe(true);
|
||||||
|
},
|
||||||
|
180_000
|
||||||
|
);
|
||||||
|
|
||||||
|
test(
|
||||||
|
"Codex catalog exposes gpt-5.1-codex",
|
||||||
|
async () => {
|
||||||
|
const models = await fetchCodexModelCatalog();
|
||||||
|
const ids = models.map((model) => model.id);
|
||||||
|
expect(ids).toContain("gpt-5.1-codex");
|
||||||
|
},
|
||||||
|
180_000
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -0,0 +1,248 @@
|
|||||||
|
import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process";
|
||||||
|
import path from "node:path";
|
||||||
|
import readline from "node:readline";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
|
||||||
|
import {
|
||||||
|
query,
|
||||||
|
type ModelInfo as ClaudeModelInfo,
|
||||||
|
type Options as ClaudeOptions,
|
||||||
|
type SDKUserMessage,
|
||||||
|
} from "@anthropic-ai/claude-agent-sdk";
|
||||||
|
|
||||||
|
import type {
|
||||||
|
AgentModelDefinition,
|
||||||
|
AgentProvider,
|
||||||
|
} from "./agent-sdk-types.js";
|
||||||
|
|
||||||
|
type ProviderModelCatalogOptions = {
|
||||||
|
cwd?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function fetchProviderModelCatalog(
|
||||||
|
provider: AgentProvider,
|
||||||
|
options?: ProviderModelCatalogOptions
|
||||||
|
): Promise<AgentModelDefinition[]> {
|
||||||
|
if (provider === "claude") {
|
||||||
|
return fetchClaudeModelCatalog(options);
|
||||||
|
}
|
||||||
|
if (provider === "codex") {
|
||||||
|
return fetchCodexModelCatalog();
|
||||||
|
}
|
||||||
|
throw new Error(`Unsupported provider '${provider}'`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchClaudeModelCatalog(
|
||||||
|
options?: ProviderModelCatalogOptions
|
||||||
|
): Promise<AgentModelDefinition[]> {
|
||||||
|
const prompt = emptySdkMessageStream();
|
||||||
|
const claudeOptions: ClaudeOptions = {
|
||||||
|
cwd: options?.cwd ?? process.cwd(),
|
||||||
|
permissionMode: "plan",
|
||||||
|
includePartialMessages: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
const claudeQuery = query({ prompt, options: claudeOptions });
|
||||||
|
try {
|
||||||
|
const models: ClaudeModelInfo[] = await claudeQuery.supportedModels();
|
||||||
|
return models.map((model) => ({
|
||||||
|
provider: "claude",
|
||||||
|
id: model.value,
|
||||||
|
label: model.displayName,
|
||||||
|
description: model.description,
|
||||||
|
metadata: {
|
||||||
|
description: model.description,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
} finally {
|
||||||
|
if (typeof claudeQuery.return === "function") {
|
||||||
|
try {
|
||||||
|
await claudeQuery.return();
|
||||||
|
} catch {
|
||||||
|
// ignore shutdown errors
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchCodexModelCatalog(): Promise<AgentModelDefinition[]> {
|
||||||
|
const binaryPath = resolveCodexBinary();
|
||||||
|
const child = spawn(binaryPath, ["app-server"], {
|
||||||
|
stdio: ["pipe", "pipe", "pipe"],
|
||||||
|
}) as ChildProcessWithoutNullStreams;
|
||||||
|
|
||||||
|
const client = new CodexAppServerClient(child);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await client.request("initialize", {
|
||||||
|
clientInfo: {
|
||||||
|
name: "voice-dev",
|
||||||
|
title: "Voice Dev",
|
||||||
|
version: "0.0.0",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = (await client.request("model/list", {})) as CodexModelListResponse;
|
||||||
|
return response.data.map((model) => ({
|
||||||
|
provider: "codex",
|
||||||
|
id: model.id,
|
||||||
|
label: model.displayName,
|
||||||
|
description: model.description,
|
||||||
|
isDefault: model.isDefault,
|
||||||
|
metadata: {
|
||||||
|
model: model.model,
|
||||||
|
defaultReasoningEffort: model.defaultReasoningEffort,
|
||||||
|
supportedReasoningEfforts: model.supportedReasoningEfforts,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
} finally {
|
||||||
|
await client.dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function emptySdkMessageStream(): AsyncIterable<SDKUserMessage> {
|
||||||
|
return (async function* empty() {})();
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveCodexBinary(): string {
|
||||||
|
const repoRoot = path.resolve(fileURLToPath(new URL("../../../../..", import.meta.url)));
|
||||||
|
const packageRoot = path.join(repoRoot, "node_modules", "@openai", "codex-sdk");
|
||||||
|
const vendorDir = path.join(packageRoot, "vendor");
|
||||||
|
|
||||||
|
const { platform, arch } = process;
|
||||||
|
const triples: Record<string, string> = {
|
||||||
|
"darwin:x64": "x86_64-apple-darwin",
|
||||||
|
"darwin:arm64": "aarch64-apple-darwin",
|
||||||
|
"linux:x64": "x86_64-unknown-linux-musl",
|
||||||
|
"linux:arm64": "aarch64-unknown-linux-musl",
|
||||||
|
"win32:x64": "x86_64-pc-windows-msvc",
|
||||||
|
"win32:arm64": "aarch64-pc-windows-msvc",
|
||||||
|
};
|
||||||
|
const triple = triples[`${platform}:${arch}`];
|
||||||
|
if (!triple) {
|
||||||
|
throw new Error(`Unsupported Codex platform: ${platform} (${arch})`);
|
||||||
|
}
|
||||||
|
const binary = platform === "win32" ? "codex.exe" : "codex";
|
||||||
|
return path.join(vendorDir, triple, "codex", binary);
|
||||||
|
}
|
||||||
|
|
||||||
|
type CodexModelListResponse = {
|
||||||
|
data: CodexModelInfo[];
|
||||||
|
nextCursor: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
type CodexModelInfo = {
|
||||||
|
id: string;
|
||||||
|
model: string;
|
||||||
|
displayName: string;
|
||||||
|
description: string;
|
||||||
|
supportedReasoningEfforts: Array<{ reasoningEffort: string; description: string }>;
|
||||||
|
defaultReasoningEffort: string;
|
||||||
|
isDefault: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
type PendingRequest = {
|
||||||
|
resolve: (value: unknown) => void;
|
||||||
|
reject: (error: Error) => void;
|
||||||
|
timer: NodeJS.Timeout;
|
||||||
|
};
|
||||||
|
|
||||||
|
class CodexAppServerClient {
|
||||||
|
private readonly rl: readline.Interface;
|
||||||
|
private readonly pending = new Map<number, PendingRequest>();
|
||||||
|
private nextId = 1;
|
||||||
|
private stderrBuffer = "";
|
||||||
|
private disposed = false;
|
||||||
|
|
||||||
|
constructor(private readonly child: ChildProcessWithoutNullStreams) {
|
||||||
|
this.rl = readline.createInterface({ input: child.stdout });
|
||||||
|
this.rl.on("line", (line) => this.handleLine(line));
|
||||||
|
|
||||||
|
child.stderr.setEncoding("utf8");
|
||||||
|
child.stderr.on("data", (chunk) => {
|
||||||
|
this.stderrBuffer += chunk;
|
||||||
|
});
|
||||||
|
|
||||||
|
child.on("exit", (code, signal) => {
|
||||||
|
if (this.disposed) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const message = code === 0
|
||||||
|
? "Codex app-server exited"
|
||||||
|
: `Codex app-server exited with code ${code ?? "null"} and signal ${signal ?? "null"}`;
|
||||||
|
const error = new Error(this.stderrBuffer || message);
|
||||||
|
this.rejectAll(error);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async request(method: string, params: Record<string, unknown>): Promise<unknown> {
|
||||||
|
if (this.disposed) {
|
||||||
|
throw new Error("Codex app-server client is closed");
|
||||||
|
}
|
||||||
|
const id = this.nextId++;
|
||||||
|
const payload = JSON.stringify({ jsonrpc: "2.0", id, method, params }) + "\n";
|
||||||
|
this.child.stdin.write(payload);
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
if (!this.pending.has(id)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.pending.delete(id);
|
||||||
|
reject(new Error(`Timeout waiting for '${method}' response`));
|
||||||
|
}, 60_000);
|
||||||
|
this.pending.set(id, { resolve, reject, timer });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async dispose(): Promise<void> {
|
||||||
|
if (this.disposed) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.disposed = true;
|
||||||
|
this.rl.close();
|
||||||
|
if (this.child.stdin && !this.child.killed) {
|
||||||
|
this.child.stdin.end();
|
||||||
|
setTimeout(() => {
|
||||||
|
if (this.child.exitCode === null && !this.child.killed) {
|
||||||
|
this.child.kill("SIGINT");
|
||||||
|
}
|
||||||
|
}, 100);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private handleLine(line: string) {
|
||||||
|
const trimmed = line.trim();
|
||||||
|
if (!trimmed) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let message: any;
|
||||||
|
try {
|
||||||
|
message = JSON.parse(trimmed);
|
||||||
|
} catch {
|
||||||
|
this.stderrBuffer += `\n[stdout] ${trimmed}`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (message.id && this.pending.has(message.id)) {
|
||||||
|
const pending = this.pending.get(message.id)!;
|
||||||
|
this.pending.delete(message.id);
|
||||||
|
clearTimeout(pending.timer);
|
||||||
|
if (message.error) {
|
||||||
|
const error = new Error(message.error.message ?? "Codex RPC error");
|
||||||
|
pending.reject(error);
|
||||||
|
} else {
|
||||||
|
pending.resolve(message.result);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// ignore notifications
|
||||||
|
}
|
||||||
|
|
||||||
|
private rejectAll(error: Error) {
|
||||||
|
for (const [, pending] of this.pending) {
|
||||||
|
clearTimeout(pending.timer);
|
||||||
|
pending.reject(error);
|
||||||
|
}
|
||||||
|
this.pending.clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -28,6 +28,40 @@ async function autoApprove(session: Awaited<ReturnType<ClaudeAgentClient["create
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type ToolCallItem = Extract<AgentTimelineItem, { type: "tool_call" }>;
|
||||||
|
|
||||||
|
function extractCommandText(input: unknown): string | null {
|
||||||
|
if (!input || typeof input !== "object") {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const command = (input as { command?: unknown }).command;
|
||||||
|
if (typeof command === "string" && command.length > 0) {
|
||||||
|
return command;
|
||||||
|
}
|
||||||
|
if (Array.isArray(command)) {
|
||||||
|
const tokens = command.filter((value): value is string => typeof value === "string");
|
||||||
|
if (tokens.length > 0) {
|
||||||
|
return tokens.join(" ");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (typeof (input as { description?: string }).description === "string") {
|
||||||
|
const description = (input as { description?: string }).description as string;
|
||||||
|
if (description.length > 0) {
|
||||||
|
return description;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isSleepCommandToolCall(item: ToolCallItem): boolean {
|
||||||
|
const display = typeof item.displayName === "string" ? item.displayName.toLowerCase() : "";
|
||||||
|
if (display.includes("sleep 60")) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
const inputCommand = extractCommandText(item.input)?.toLowerCase() ?? "";
|
||||||
|
return inputCommand.includes("sleep 60");
|
||||||
|
}
|
||||||
|
|
||||||
describe("ClaudeAgentClient (SDK integration)", () => {
|
describe("ClaudeAgentClient (SDK integration)", () => {
|
||||||
test(
|
test(
|
||||||
"responds with text",
|
"responds with text",
|
||||||
@@ -242,6 +276,68 @@ describe("ClaudeAgentClient (SDK integration)", () => {
|
|||||||
180_000
|
180_000
|
||||||
);
|
);
|
||||||
|
|
||||||
|
test(
|
||||||
|
"interrupts a long-running bash command before it finishes",
|
||||||
|
async () => {
|
||||||
|
const cwd = tmpCwd();
|
||||||
|
const client = new ClaudeAgentClient();
|
||||||
|
const config: AgentSessionConfig = {
|
||||||
|
provider: "claude",
|
||||||
|
cwd,
|
||||||
|
extra: { claude: { maxThinkingTokens: 2048 } },
|
||||||
|
};
|
||||||
|
let session: Awaited<ReturnType<typeof client.createSession>> | null = null;
|
||||||
|
let runStartedAt: number | null = null;
|
||||||
|
let durationMs = 0;
|
||||||
|
let sawSleepCommand = false;
|
||||||
|
let interruptIssued = false;
|
||||||
|
|
||||||
|
try {
|
||||||
|
session = await client.createSession(config);
|
||||||
|
const prompt = [
|
||||||
|
"Use your Bash command tool to run the exact command `sleep 60`.",
|
||||||
|
"Do not run any other commands or respond until that command finishes.",
|
||||||
|
].join(" ");
|
||||||
|
|
||||||
|
runStartedAt = Date.now();
|
||||||
|
const events = session.stream(prompt);
|
||||||
|
|
||||||
|
for await (const event of events) {
|
||||||
|
await autoApprove(session, event);
|
||||||
|
|
||||||
|
if (event.type === "timeline" && event.item.type === "tool_call" && isSleepCommandToolCall(event.item)) {
|
||||||
|
sawSleepCommand = true;
|
||||||
|
if (!interruptIssued) {
|
||||||
|
interruptIssued = true;
|
||||||
|
await session.interrupt();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.type === "turn_completed" || event.type === "turn_failed") {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (runStartedAt === null) {
|
||||||
|
throw new Error("Claude run never started");
|
||||||
|
}
|
||||||
|
durationMs = Date.now() - runStartedAt;
|
||||||
|
} finally {
|
||||||
|
if (durationMs === 0 && runStartedAt !== null) {
|
||||||
|
durationMs = Date.now() - runStartedAt;
|
||||||
|
}
|
||||||
|
await session?.close();
|
||||||
|
rmSync(cwd, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(sawSleepCommand).toBe(true);
|
||||||
|
expect(interruptIssued).toBe(true);
|
||||||
|
expect(durationMs).toBeGreaterThan(0);
|
||||||
|
expect(durationMs).toBeLessThan(60_000);
|
||||||
|
},
|
||||||
|
120_000
|
||||||
|
);
|
||||||
|
|
||||||
test(
|
test(
|
||||||
"supports multi-turn conversations",
|
"supports multi-turn conversations",
|
||||||
async () => {
|
async () => {
|
||||||
|
|||||||
@@ -216,6 +216,7 @@ class ClaudeAgentSession implements AgentSession {
|
|||||||
private turnCancelRequested = false;
|
private turnCancelRequested = false;
|
||||||
private streamedAssistantTextThisTurn = false;
|
private streamedAssistantTextThisTurn = false;
|
||||||
private streamedReasoningThisTurn = false;
|
private streamedReasoningThisTurn = false;
|
||||||
|
private cancelCurrentTurn: (() => void) | null = null;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
config: ClaudeAgentConfig,
|
config: ClaudeAgentConfig,
|
||||||
@@ -282,6 +283,7 @@ class ClaudeAgentSession implements AgentSession {
|
|||||||
void this.interruptActiveTurn();
|
void this.interruptActiveTurn();
|
||||||
queue.end();
|
queue.end();
|
||||||
};
|
};
|
||||||
|
this.cancelCurrentTurn = requestCancel;
|
||||||
if (this.historyPending && this.persistedHistory.length > 0) {
|
if (this.historyPending && this.persistedHistory.length > 0) {
|
||||||
for (const item of this.persistedHistory) {
|
for (const item of this.persistedHistory) {
|
||||||
queue.push({ type: "timeline", item, provider: "claude" });
|
queue.push({ type: "timeline", item, provider: "claude" });
|
||||||
@@ -306,9 +308,16 @@ class ClaudeAgentSession implements AgentSession {
|
|||||||
if (this.eventQueue === queue) {
|
if (this.eventQueue === queue) {
|
||||||
this.eventQueue = null;
|
this.eventQueue = null;
|
||||||
}
|
}
|
||||||
|
if (this.cancelCurrentTurn === requestCancel) {
|
||||||
|
this.cancelCurrentTurn = null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async interrupt(): Promise<void> {
|
||||||
|
this.cancelCurrentTurn?.();
|
||||||
|
}
|
||||||
|
|
||||||
async *streamHistory(): AsyncGenerator<AgentStreamEvent> {
|
async *streamHistory(): AsyncGenerator<AgentStreamEvent> {
|
||||||
if (!this.historyPending || this.persistedHistory.length === 0) {
|
if (!this.historyPending || this.persistedHistory.length === 0) {
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import type {
|
|||||||
AgentStreamEvent,
|
AgentStreamEvent,
|
||||||
AgentPermissionRequest,
|
AgentPermissionRequest,
|
||||||
AgentPersistenceHandle,
|
AgentPersistenceHandle,
|
||||||
|
AgentTimelineItem,
|
||||||
} from "../agent-sdk-types.js";
|
} from "../agent-sdk-types.js";
|
||||||
|
|
||||||
function tmpCwd(): string {
|
function tmpCwd(): string {
|
||||||
@@ -48,6 +49,34 @@ function log(message: string): void {
|
|||||||
console.info(`[CodexAgentTest] ${message}`);
|
console.info(`[CodexAgentTest] ${message}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type ToolCallItem = Extract<AgentTimelineItem, { type: "tool_call" }>;
|
||||||
|
|
||||||
|
function commandTextFromInput(input: unknown): string | null {
|
||||||
|
if (!input || typeof input !== "object") {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const command = (input as { command?: unknown }).command;
|
||||||
|
if (typeof command === "string" && command.length > 0) {
|
||||||
|
return command;
|
||||||
|
}
|
||||||
|
if (Array.isArray(command)) {
|
||||||
|
const tokens = command.filter((value): value is string => typeof value === "string");
|
||||||
|
if (tokens.length > 0) {
|
||||||
|
return tokens.join(" ");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isSleepCommandToolCall(item: ToolCallItem): boolean {
|
||||||
|
const display = typeof item.displayName === "string" ? item.displayName.toLowerCase() : "";
|
||||||
|
if (display.includes("sleep 60")) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
const inputText = commandTextFromInput(item.input)?.toLowerCase() ?? "";
|
||||||
|
return inputText.includes("sleep 60");
|
||||||
|
}
|
||||||
|
|
||||||
describe("CodexAgentClient (SDK integration)", () => {
|
describe("CodexAgentClient (SDK integration)", () => {
|
||||||
test(
|
test(
|
||||||
"responds with text",
|
"responds with text",
|
||||||
@@ -392,6 +421,76 @@ describe("CodexAgentClient (SDK integration)", () => {
|
|||||||
180_000
|
180_000
|
||||||
);
|
);
|
||||||
|
|
||||||
|
test(
|
||||||
|
"interrupts a long-running shell command before it completes",
|
||||||
|
async () => {
|
||||||
|
const cwd = tmpCwd();
|
||||||
|
const restoreSessionDir = useTempCodexSessionDir();
|
||||||
|
const client = new CodexAgentClient();
|
||||||
|
const config: AgentSessionConfig = { provider: "codex", cwd, modeId: "full-access" };
|
||||||
|
let session: Awaited<ReturnType<typeof client.createSession>> | null = null;
|
||||||
|
let runStartedAt: number | null = null;
|
||||||
|
let durationMs = 0;
|
||||||
|
let sawSleepCommand = false;
|
||||||
|
let interruptIssued = false;
|
||||||
|
|
||||||
|
try {
|
||||||
|
session = await client.createSession(config);
|
||||||
|
log("Launching sleep command interrupt test");
|
||||||
|
|
||||||
|
const prompt = [
|
||||||
|
"Run the exact shell command `sleep 60` using your shell tool.",
|
||||||
|
"Do not run any additional commands or send a response until that command finishes.",
|
||||||
|
].join(" ");
|
||||||
|
|
||||||
|
runStartedAt = Date.now();
|
||||||
|
const stream = session.stream(prompt);
|
||||||
|
|
||||||
|
for await (const event of stream) {
|
||||||
|
if (event.type === "permission_requested" && session) {
|
||||||
|
await session.respondToPermission(event.request.id, { behavior: "allow" });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
event.type === "timeline" &&
|
||||||
|
event.provider === "codex" &&
|
||||||
|
event.item.type === "tool_call" &&
|
||||||
|
event.item.server === "command" &&
|
||||||
|
isSleepCommandToolCall(event.item)
|
||||||
|
) {
|
||||||
|
sawSleepCommand = true;
|
||||||
|
if (!interruptIssued) {
|
||||||
|
interruptIssued = true;
|
||||||
|
await session.interrupt();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.type === "turn_completed" || event.type === "turn_failed") {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (runStartedAt === null) {
|
||||||
|
throw new Error("Codex run never started");
|
||||||
|
}
|
||||||
|
durationMs = Date.now() - runStartedAt;
|
||||||
|
} finally {
|
||||||
|
if (durationMs === 0 && runStartedAt !== null) {
|
||||||
|
durationMs = Date.now() - runStartedAt;
|
||||||
|
}
|
||||||
|
await session?.close();
|
||||||
|
rmSync(cwd, { recursive: true, force: true });
|
||||||
|
restoreSessionDir();
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(sawSleepCommand).toBe(true);
|
||||||
|
expect(interruptIssued).toBe(true);
|
||||||
|
expect(durationMs).toBeGreaterThan(0);
|
||||||
|
expect(durationMs).toBeLessThan(60_000);
|
||||||
|
},
|
||||||
|
90_000
|
||||||
|
);
|
||||||
|
|
||||||
// Codex CLI currently doesn't emit approval request events even when approvalPolicy is set to on-request,
|
// Codex CLI currently doesn't emit approval request events even when approvalPolicy is set to on-request,
|
||||||
// so we keep this test skipped until upstream support lands.
|
// so we keep this test skipped until upstream support lands.
|
||||||
test.skip(
|
test.skip(
|
||||||
|
|||||||
@@ -271,6 +271,7 @@ class CodexAgentSession implements AgentSession {
|
|||||||
private rolloutPath: string | null;
|
private rolloutPath: string | null;
|
||||||
private historyEvents: AgentStreamEvent[] = [];
|
private historyEvents: AgentStreamEvent[] = [];
|
||||||
private pendingPermissions = new Map<string, AgentPermissionRequest>();
|
private pendingPermissions = new Map<string, AgentPermissionRequest>();
|
||||||
|
private cancelCurrentTurn: (() => void) | null = null;
|
||||||
|
|
||||||
constructor(codex: Codex, config: CodexAgentConfig, handle?: AgentPersistenceHandle) {
|
constructor(codex: Codex, config: CodexAgentConfig, handle?: AgentPersistenceHandle) {
|
||||||
this.codex = codex;
|
this.codex = codex;
|
||||||
@@ -337,6 +338,29 @@ class CodexAgentSession implements AgentSession {
|
|||||||
const { events } = await thread.runStreamed(input, turnOptions);
|
const { events } = await thread.runStreamed(input, turnOptions);
|
||||||
|
|
||||||
let finishedNaturally = false;
|
let finishedNaturally = false;
|
||||||
|
let cancelIssued = false;
|
||||||
|
const requestCancel = () => {
|
||||||
|
if (cancelIssued) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
cancelIssued = true;
|
||||||
|
if (typeof events.return !== "function") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const cancellation = events.return({
|
||||||
|
type: "turn.completed",
|
||||||
|
} as ThreadEvent);
|
||||||
|
void cancellation.catch((error) => {
|
||||||
|
console.warn("[CodexAgentSession] Failed to stop Codex stream:", error);
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.warn("[CodexAgentSession] Failed to stop Codex stream:", error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
this.cancelCurrentTurn = requestCancel;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
for await (const rawEvent of events) {
|
for await (const rawEvent of events) {
|
||||||
yield* this.translateEvent(rawEvent);
|
yield* this.translateEvent(rawEvent);
|
||||||
@@ -350,16 +374,19 @@ class CodexAgentSession implements AgentSession {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
if (!finishedNaturally && typeof events.return === "function") {
|
if (!finishedNaturally) {
|
||||||
try {
|
requestCancel();
|
||||||
await events.return({ type: "turn.completed" } as ThreadEvent);
|
}
|
||||||
} catch (error) {
|
if (this.cancelCurrentTurn === requestCancel) {
|
||||||
console.warn("[CodexAgentSession] Failed to stop Codex stream:", error);
|
this.cancelCurrentTurn = null;
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async interrupt(): Promise<void> {
|
||||||
|
this.cancelCurrentTurn?.();
|
||||||
|
}
|
||||||
|
|
||||||
async *streamHistory(): AsyncGenerator<AgentStreamEvent> {
|
async *streamHistory(): AsyncGenerator<AgentStreamEvent> {
|
||||||
const replayEvents = this.drainHistoryEvents();
|
const replayEvents = this.drainHistoryEvents();
|
||||||
for (const event of replayEvents) {
|
for (const event of replayEvents) {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { z } from "zod";
|
|||||||
import type { AgentSnapshot } from "./agent/agent-manager.js";
|
import type { AgentSnapshot } from "./agent/agent-manager.js";
|
||||||
import type {
|
import type {
|
||||||
AgentCapabilityFlags,
|
AgentCapabilityFlags,
|
||||||
|
AgentModelDefinition,
|
||||||
AgentMode,
|
AgentMode,
|
||||||
AgentPermissionRequest,
|
AgentPermissionRequest,
|
||||||
AgentPermissionResponse,
|
AgentPermissionResponse,
|
||||||
@@ -45,6 +46,15 @@ const AgentModeSchema: z.ZodType<AgentMode> = z.object({
|
|||||||
description: z.string().optional(),
|
description: z.string().optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const AgentModelDefinitionSchema: z.ZodType<AgentModelDefinition> = z.object({
|
||||||
|
provider: AgentProviderSchema,
|
||||||
|
id: z.string(),
|
||||||
|
label: z.string(),
|
||||||
|
description: z.string().optional(),
|
||||||
|
isDefault: z.boolean().optional(),
|
||||||
|
metadata: z.record(z.unknown()).optional(),
|
||||||
|
});
|
||||||
|
|
||||||
const AgentCapabilityFlagsSchema: z.ZodType<AgentCapabilityFlags> = z.object({
|
const AgentCapabilityFlagsSchema: z.ZodType<AgentCapabilityFlags> = z.object({
|
||||||
supportsStreaming: z.boolean(),
|
supportsStreaming: z.boolean(),
|
||||||
supportsSessionPersistence: z.boolean(),
|
supportsSessionPersistence: z.boolean(),
|
||||||
@@ -345,6 +355,13 @@ export const CreateAgentRequestMessageSchema = z.object({
|
|||||||
requestId: z.string().optional(),
|
requestId: z.string().optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const ListProviderModelsRequestMessageSchema = z.object({
|
||||||
|
type: z.literal("list_provider_models_request"),
|
||||||
|
provider: AgentProviderSchema,
|
||||||
|
cwd: z.string().optional(),
|
||||||
|
requestId: z.string().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
export const GitRepoInfoRequestMessageSchema = z.object({
|
export const GitRepoInfoRequestMessageSchema = z.object({
|
||||||
type: z.literal("git_repo_info_request"),
|
type: z.literal("git_repo_info_request"),
|
||||||
cwd: z.string(),
|
cwd: z.string(),
|
||||||
@@ -447,6 +464,7 @@ export const SessionInboundMessageSchema = z.discriminatedUnion("type", [
|
|||||||
SendAgentMessageSchema,
|
SendAgentMessageSchema,
|
||||||
SendAgentAudioSchema,
|
SendAgentAudioSchema,
|
||||||
CreateAgentRequestMessageSchema,
|
CreateAgentRequestMessageSchema,
|
||||||
|
ListProviderModelsRequestMessageSchema,
|
||||||
ResumeAgentRequestMessageSchema,
|
ResumeAgentRequestMessageSchema,
|
||||||
RefreshAgentRequestMessageSchema,
|
RefreshAgentRequestMessageSchema,
|
||||||
CancelAgentRequestMessageSchema,
|
CancelAgentRequestMessageSchema,
|
||||||
@@ -701,6 +719,17 @@ export const GitRepoInfoResponseSchema = z.object({
|
|||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const ListProviderModelsResponseMessageSchema = z.object({
|
||||||
|
type: z.literal("list_provider_models_response"),
|
||||||
|
payload: z.object({
|
||||||
|
provider: AgentProviderSchema,
|
||||||
|
models: z.array(AgentModelDefinitionSchema).optional(),
|
||||||
|
error: z.string().optional(),
|
||||||
|
fetchedAt: z.string(),
|
||||||
|
requestId: z.string().optional(),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
export const SessionOutboundMessageSchema = z.discriminatedUnion("type", [
|
export const SessionOutboundMessageSchema = z.discriminatedUnion("type", [
|
||||||
ActivityLogMessageSchema,
|
ActivityLogMessageSchema,
|
||||||
AssistantChunkMessageSchema,
|
AssistantChunkMessageSchema,
|
||||||
@@ -723,6 +752,7 @@ export const SessionOutboundMessageSchema = z.discriminatedUnion("type", [
|
|||||||
GitDiffResponseSchema,
|
GitDiffResponseSchema,
|
||||||
FileExplorerResponseSchema,
|
FileExplorerResponseSchema,
|
||||||
GitRepoInfoResponseSchema,
|
GitRepoInfoResponseSchema,
|
||||||
|
ListProviderModelsResponseMessageSchema,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
export type SessionOutboundMessage = z.infer<
|
export type SessionOutboundMessage = z.infer<
|
||||||
@@ -750,6 +780,9 @@ export type AgentPermissionRequestMessage = z.infer<typeof AgentPermissionReques
|
|||||||
export type AgentPermissionResolvedMessage = z.infer<typeof AgentPermissionResolvedMessageSchema>;
|
export type AgentPermissionResolvedMessage = z.infer<typeof AgentPermissionResolvedMessageSchema>;
|
||||||
export type AgentDeletedMessage = z.infer<typeof AgentDeletedMessageSchema>;
|
export type AgentDeletedMessage = z.infer<typeof AgentDeletedMessageSchema>;
|
||||||
export type ListPersistedAgentsResponseMessage = z.infer<typeof ListPersistedAgentsResponseSchema>;
|
export type ListPersistedAgentsResponseMessage = z.infer<typeof ListPersistedAgentsResponseSchema>;
|
||||||
|
export type ListProviderModelsResponseMessage = z.infer<
|
||||||
|
typeof ListProviderModelsResponseMessageSchema
|
||||||
|
>;
|
||||||
|
|
||||||
// Type exports for payload types
|
// Type exports for payload types
|
||||||
export type ActivityLogPayload = z.infer<typeof ActivityLogPayloadSchema>;
|
export type ActivityLogPayload = z.infer<typeof ActivityLogPayloadSchema>;
|
||||||
@@ -760,6 +793,9 @@ export type RealtimeAudioChunkMessage = z.infer<typeof RealtimeAudioChunkMessage
|
|||||||
export type SendAgentMessage = z.infer<typeof SendAgentMessageSchema>;
|
export type SendAgentMessage = z.infer<typeof SendAgentMessageSchema>;
|
||||||
export type SendAgentAudio = z.infer<typeof SendAgentAudioSchema>;
|
export type SendAgentAudio = z.infer<typeof SendAgentAudioSchema>;
|
||||||
export type CreateAgentRequestMessage = z.infer<typeof CreateAgentRequestMessageSchema>;
|
export type CreateAgentRequestMessage = z.infer<typeof CreateAgentRequestMessageSchema>;
|
||||||
|
export type ListProviderModelsRequestMessage = z.infer<
|
||||||
|
typeof ListProviderModelsRequestMessageSchema
|
||||||
|
>;
|
||||||
export type ResumeAgentRequestMessage = z.infer<typeof ResumeAgentRequestMessageSchema>;
|
export type ResumeAgentRequestMessage = z.infer<typeof ResumeAgentRequestMessageSchema>;
|
||||||
export type DeleteAgentRequestMessage = z.infer<typeof DeleteAgentRequestMessageSchema>;
|
export type DeleteAgentRequestMessage = z.infer<typeof DeleteAgentRequestMessageSchema>;
|
||||||
export type ListPersistedAgentsRequestMessage = z.infer<typeof ListPersistedAgentsRequestMessageSchema>;
|
export type ListPersistedAgentsRequestMessage = z.infer<typeof ListPersistedAgentsRequestMessageSchema>;
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ import { experimental_createMCPClient } from "ai";
|
|||||||
import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js";
|
import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js";
|
||||||
import { createTerminalMcpServer } from "./terminal-mcp/index.js";
|
import { createTerminalMcpServer } from "./terminal-mcp/index.js";
|
||||||
import { createAgentMcpServer } from "./agent/mcp-server.js";
|
import { createAgentMcpServer } from "./agent/mcp-server.js";
|
||||||
|
import { fetchProviderModelCatalog } from "./agent/model-catalog.js";
|
||||||
import { AgentManager } from "./agent/agent-manager.js";
|
import { AgentManager } from "./agent/agent-manager.js";
|
||||||
import type { AgentSnapshot } from "./agent/agent-manager.js";
|
import type { AgentSnapshot } from "./agent/agent-manager.js";
|
||||||
import type {
|
import type {
|
||||||
@@ -748,6 +749,10 @@ export class Session {
|
|||||||
case "git_repo_info_request":
|
case "git_repo_info_request":
|
||||||
await this.handleGitRepoInfoRequest(msg);
|
await this.handleGitRepoInfoRequest(msg);
|
||||||
break;
|
break;
|
||||||
|
|
||||||
|
case "list_provider_models_request":
|
||||||
|
await this.handleListProviderModelsRequest(msg);
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
console.error(
|
console.error(
|
||||||
@@ -1167,24 +1172,27 @@ export class Session {
|
|||||||
await this.forwardAgentState(snapshot);
|
await this.forwardAgentState(snapshot);
|
||||||
|
|
||||||
const trimmedPrompt = initialPrompt?.trim();
|
const trimmedPrompt = initialPrompt?.trim();
|
||||||
if (trimmedPrompt && trimmedPrompt.length > 0) {
|
if (trimmedPrompt) {
|
||||||
try {
|
try {
|
||||||
this.agentManager.recordUserMessage(snapshot.id, trimmedPrompt);
|
await this.handleSendAgentMessage(
|
||||||
} catch (recordError) {
|
snapshot.id,
|
||||||
console.error(
|
trimmedPrompt,
|
||||||
`[Session ${this.clientId}] Failed to record initial prompt for agent ${snapshot.id}:`,
|
uuidv4()
|
||||||
recordError
|
|
||||||
);
|
);
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const normalizedPrompt = this.buildAgentPrompt(trimmedPrompt);
|
|
||||||
this.startAgentStream(snapshot.id, normalizedPrompt);
|
|
||||||
} catch (promptError) {
|
} catch (promptError) {
|
||||||
console.error(
|
console.error(
|
||||||
`[Session ${this.clientId}] Failed to run initial prompt for agent ${snapshot.id}:`,
|
`[Session ${this.clientId}] Failed to run initial prompt for agent ${snapshot.id}:`,
|
||||||
promptError
|
promptError
|
||||||
);
|
);
|
||||||
|
this.emit({
|
||||||
|
type: "activity_log",
|
||||||
|
payload: {
|
||||||
|
id: uuidv4(),
|
||||||
|
timestamp: new Date(),
|
||||||
|
type: "error",
|
||||||
|
content: `Initial prompt failed: ${(promptError as Error)?.message ?? promptError}`,
|
||||||
|
},
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1297,6 +1305,7 @@ export class Session {
|
|||||||
let snapshot: AgentSnapshot;
|
let snapshot: AgentSnapshot;
|
||||||
const existing = this.agentManager.getAgent(agentId);
|
const existing = this.agentManager.getAgent(agentId);
|
||||||
if (existing) {
|
if (existing) {
|
||||||
|
await this.interruptAgentIfRunning(agentId);
|
||||||
snapshot = await this.agentManager.refreshAgentFromPersistence(
|
snapshot = await this.agentManager.refreshAgentFromPersistence(
|
||||||
agentId
|
agentId
|
||||||
);
|
);
|
||||||
@@ -1478,6 +1487,40 @@ export class Session {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async handleListProviderModelsRequest(
|
||||||
|
msg: Extract<SessionInboundMessage, { type: "list_provider_models_request" }>
|
||||||
|
): Promise<void> {
|
||||||
|
const fetchedAt = new Date().toISOString();
|
||||||
|
try {
|
||||||
|
const models = await fetchProviderModelCatalog(msg.provider, {
|
||||||
|
cwd: msg.cwd ? expandTilde(msg.cwd) : undefined,
|
||||||
|
});
|
||||||
|
this.emit({
|
||||||
|
type: "list_provider_models_response",
|
||||||
|
payload: {
|
||||||
|
provider: msg.provider,
|
||||||
|
models,
|
||||||
|
fetchedAt,
|
||||||
|
...(msg.requestId ? { requestId: msg.requestId } : {}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error(
|
||||||
|
`[Session ${this.clientId}] Failed to list models for ${msg.provider}:`,
|
||||||
|
error
|
||||||
|
);
|
||||||
|
this.emit({
|
||||||
|
type: "list_provider_models_response",
|
||||||
|
payload: {
|
||||||
|
provider: msg.provider,
|
||||||
|
error: (error as Error)?.message ?? String(error),
|
||||||
|
fetchedAt,
|
||||||
|
...(msg.requestId ? { requestId: msg.requestId } : {}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private normalizeGitOptions(
|
private normalizeGitOptions(
|
||||||
gitOptions?: GitSetupOptions,
|
gitOptions?: GitSetupOptions,
|
||||||
legacyWorktreeName?: string
|
legacyWorktreeName?: string
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
ok
|
||||||
Reference in New Issue
Block a user