refactor(agent): consolidate agent type definitions and add multi-agent-type support

This commit is contained in:
Mohamed Boudra
2025-10-26 18:03:03 +01:00
parent 700840da5a
commit edf47a24bc
22 changed files with 677 additions and 1224 deletions
@@ -61,7 +61,7 @@ function GroupedTextItem({ item }: { item: GroupedTextMessage }) {
return (
<View style={[stylesheet.card, isThought && stylesheet.thoughtCard]}>
<Text style={stylesheet.timestamp}>
<Text style={[stylesheet.timestamp, isThought && stylesheet.thoughtTimestamp]}>
{formatTimestamp(item.startTimestamp)}
</Text>
{isThought && (
@@ -260,17 +260,21 @@ const stylesheet = StyleSheet.create((theme) => ({
backgroundColor: theme.colors.muted,
borderLeftWidth: 3,
borderLeftColor: theme.colors.palette.purple[500],
paddingVertical: theme.spacing[2],
},
timestamp: {
color: theme.colors.mutedForeground,
fontSize: theme.fontSize.xs,
marginBottom: theme.spacing[1],
},
thoughtTimestamp: {
marginBottom: theme.spacing[0],
},
thoughtLabel: {
color: theme.colors.palette.purple[500],
fontSize: theme.fontSize.xs,
fontWeight: theme.fontWeight.semibold,
marginBottom: theme.spacing[1],
marginBottom: theme.spacing[0],
},
text: {
color: theme.colors.foreground,
@@ -19,18 +19,16 @@ import {
UserMessage,
ActivityLog,
ToolCall,
AgentThoughtMessage,
} from "./message";
import { ToolCallBottomSheet } from "./tool-call-bottom-sheet";
import type { StreamItem } from "@/types/stream";
import type {
SelectedToolCall,
PendingPermission,
AgentInfo,
} from "@/types/shared";
import type { SelectedToolCall, PendingPermission } from "@/types/shared";
import type { Agent } from "@/contexts/session-context";
export interface AgentStreamViewProps {
agentId: string;
agent: AgentInfo;
agent: Agent;
streamItems: StreamItem[];
pendingPermissions: Map<string, PendingPermission>;
onPermissionResponse: (requestId: string, optionId: string) => void;
@@ -53,6 +51,7 @@ export function AgentStreamView({
const hasAutoScrolledOnce = useRef(false);
const isProgrammaticScrollRef = useRef(false);
const isNearBottomRef = useRef(true);
const isUserScrollingRef = useRef(false);
useEffect(() => {
hasScrolledInitially.current = false;
@@ -77,12 +76,41 @@ export function AgentStreamView({
const { contentOffset } = event.nativeEvent;
const threshold = Math.max(insets.bottom, 32);
const nearBottom = contentOffset.y <= threshold;
if (isProgrammaticScrollRef.current) {
if (nearBottom && !isNearBottomRef.current) {
isNearBottomRef.current = true;
setIsNearBottom(true);
}
return;
}
if (!nearBottom && !isUserScrollingRef.current) {
return;
}
if (isNearBottomRef.current === nearBottom) {
return;
}
isNearBottomRef.current = nearBottom;
setIsNearBottom(nearBottom);
},
[insets.bottom]
);
const handleScrollBeginDrag = useCallback(() => {
isUserScrollingRef.current = true;
}, []);
const handleMomentumScrollBegin = useCallback(() => {
isUserScrollingRef.current = true;
}, []);
const handleScrollEnd = useCallback(() => {
isUserScrollingRef.current = false;
}, []);
const scrollToBottomInternal = useCallback(
({ animated }: { animated: boolean }) => {
const list = flatListRef.current;
@@ -156,10 +184,8 @@ export function AgentStreamView({
case "thought":
return (
<ActivityLog
type="info"
<AgentThoughtMessage
message={item.text}
timestamp={item.timestamp.getTime()}
/>
);
@@ -235,6 +261,10 @@ export function AgentStreamView({
}}
style={stylesheet.list}
onScroll={handleScroll}
onScrollBeginDrag={handleScrollBeginDrag}
onScrollEndDrag={handleScrollEnd}
onMomentumScrollBegin={handleMomentumScrollBegin}
onMomentumScrollEnd={handleScrollEnd}
scrollEventThrottle={16}
ListEmptyComponent={
<View style={stylesheet.invertedWrapper}>
@@ -27,24 +27,30 @@ import { useRecentPaths } from "@/hooks/use-recent-paths";
import { useSession } from "@/contexts/session-context";
import { useRouter } from "expo-router";
import { generateMessageId } from "@/types/stream";
import {
listAgentTypeDefinitions,
type AgentType,
type AgentTypeDefinition,
} from "@server/server/acp/agent-types";
interface CreateAgentModalProps {
isVisible: boolean;
onClose: () => void;
}
const MODES = [
{
value: "plan",
label: "Plan",
description: "Plan and design before implementing",
},
{
value: "bypassPermissions",
label: "Bypass Permissions",
description: "Skip permission prompts for faster execution",
},
] as const;
const agentTypeDefinitions = listAgentTypeDefinitions();
const agentTypeDefinitionMap: Record<AgentType, AgentTypeDefinition> =
{} as Record<AgentType, AgentTypeDefinition>;
for (const definition of agentTypeDefinitions) {
agentTypeDefinitionMap[definition.id] = definition;
}
const fallbackDefinition = agentTypeDefinitionMap.claude ?? agentTypeDefinitions[0];
const DEFAULT_AGENT_TYPE: AgentType = fallbackDefinition
? fallbackDefinition.id
: "claude";
const DEFAULT_MODE_FOR_DEFAULT_AGENT = fallbackDefinition?.defaultModeId;
export function CreateAgentModal({
isVisible,
@@ -58,11 +64,41 @@ export function CreateAgentModal({
const router = useRouter();
const [workingDir, setWorkingDir] = useState("");
const [selectedMode, setSelectedMode] = useState("plan");
const [selectedAgentType, setSelectedAgentType] = useState<AgentType>(
DEFAULT_AGENT_TYPE
);
const [selectedMode, setSelectedMode] = useState(
DEFAULT_MODE_FOR_DEFAULT_AGENT ?? ""
);
const [worktreeName, setWorktreeName] = useState("");
const [errorMessage, setErrorMessage] = useState("");
const [isLoading, setIsLoading] = useState(false);
const [pendingRequestId, setPendingRequestId] = useState<string | null>(null);
const agentDefinition = agentTypeDefinitionMap[selectedAgentType];
const modeOptions = agentDefinition?.availableModes ?? [];
useEffect(() => {
if (!agentDefinition) {
return;
}
const availableModeIds = agentDefinition.availableModes.map(
(mode) => mode.id
);
if (availableModeIds.length === 0) {
if (selectedMode !== "") {
setSelectedMode("");
}
return;
}
if (!availableModeIds.includes(selectedMode)) {
const fallbackModeId =
agentDefinition.defaultModeId ?? availableModeIds[0];
setSelectedMode(fallbackModeId);
}
}, [agentDefinition, selectedMode]);
// Use ref instead of state to survive state resets in onDismiss
const pendingNavigationAgentIdRef = useRef<string | null>(null);
@@ -164,11 +200,15 @@ export function CreateAgentModal({
setPendingRequestId(requestId);
setErrorMessage("");
const modeId =
modeOptions.length > 0 && selectedMode !== "" ? selectedMode : undefined;
// Create the agent
try {
createAgent({
cwd: path,
initialMode: selectedMode,
agentType: selectedAgentType,
initialMode: modeId,
worktreeName: worktree || undefined,
requestId,
});
@@ -178,7 +218,17 @@ export function CreateAgentModal({
setIsLoading(false);
setPendingRequestId(null);
}
}, [workingDir, worktreeName, selectedMode, isLoading, validateWorktreeName, addRecentPath, createAgent]);
}, [
workingDir,
worktreeName,
selectedMode,
selectedAgentType,
modeOptions,
isLoading,
validateWorktreeName,
addRecentPath,
createAgent,
]);
const renderFooter = useCallback(
(props: BottomSheetFooterProps) => (
@@ -254,7 +304,8 @@ export function CreateAgentModal({
// Reset all state
setWorkingDir("");
setWorktreeName("");
setSelectedMode("plan");
setSelectedAgentType(DEFAULT_AGENT_TYPE);
setSelectedMode(DEFAULT_MODE_FOR_DEFAULT_AGENT ?? "");
setErrorMessage("");
setIsLoading(false);
setPendingRequestId(null);
@@ -338,6 +389,58 @@ export function CreateAgentModal({
)}
</View>
{/* Agent Type Selector */}
<View style={styles.formSection}>
<Text style={styles.label}>Agent Type</Text>
<View style={styles.agentTypeContainer}>
{agentTypeDefinitions.map((definition) => {
const isSelected = selectedAgentType === definition.id;
return (
<Pressable
key={definition.id}
onPress={() => setSelectedAgentType(definition.id)}
disabled={isLoading}
style={[
styles.agentTypeOption,
isSelected && styles.agentTypeOptionSelected,
isLoading && styles.agentTypeOptionDisabled,
]}
>
<View style={styles.agentTypeOptionContent}>
<View
style={[
styles.radioOuter,
isSelected
? styles.radioOuterSelected
: styles.radioOuterUnselected,
]}
>
{isSelected && <View style={styles.radioInner} />}
</View>
<View style={styles.agentTypeTextContainer}>
<Text style={styles.agentTypeLabel}>
{definition.label}
</Text>
{definition.description ? (
<Text style={styles.agentTypeDescription}>
{definition.description}
</Text>
) : null}
<Text style={styles.agentTypeMeta}>
{definition.availableModes.length > 0
? `Modes: ${definition.availableModes
.map((mode) => mode.name)
.join(", ")}`
: "Modes: none"}
</Text>
</View>
</View>
</Pressable>
);
})}
</View>
</View>
{/* Worktree Name Input (Optional) */}
<View style={styles.formSection}>
<Text style={styles.label}>Worktree Name (Optional)</Text>
@@ -364,41 +467,50 @@ export function CreateAgentModal({
{/* Mode Selector */}
<View style={styles.formSection}>
<Text style={styles.label}>Mode</Text>
<View style={styles.modeContainer}>
{MODES.map((mode) => (
<Pressable
key={mode.value}
onPress={() => setSelectedMode(mode.value)}
disabled={isLoading}
style={[
styles.modeOption,
selectedMode === mode.value && styles.modeOptionSelected,
isLoading && styles.modeOptionDisabled,
]}
>
<View style={styles.modeOptionContent}>
<View
{modeOptions.length === 0 ? (
<Text style={styles.helperText}>
This agent type does not expose selectable modes.
</Text>
) : (
<View style={styles.modeContainer}>
{modeOptions.map((mode) => {
const isSelected = selectedMode === mode.id;
return (
<Pressable
key={mode.id}
onPress={() => setSelectedMode(mode.id)}
disabled={isLoading}
style={[
styles.radioOuter,
selectedMode === mode.value
? styles.radioOuterSelected
: styles.radioOuterUnselected,
styles.modeOption,
isSelected && styles.modeOptionSelected,
isLoading && styles.modeOptionDisabled,
]}
>
{selectedMode === mode.value && (
<View style={styles.radioInner} />
)}
</View>
<View style={styles.modeTextContainer}>
<Text style={styles.modeLabel}>{mode.label}</Text>
<Text style={styles.modeDescription}>
{mode.description}
</Text>
</View>
</View>
</Pressable>
))}
</View>
<View style={styles.modeOptionContent}>
<View
style={[
styles.radioOuter,
isSelected
? styles.radioOuterSelected
: styles.radioOuterUnselected,
]}
>
{isSelected && <View style={styles.radioInner} />}
</View>
<View style={styles.modeTextContainer}>
<Text style={styles.modeLabel}>{mode.name}</Text>
{mode.description ? (
<Text style={styles.modeDescription}>
{mode.description}
</Text>
) : null}
</View>
</View>
</Pressable>
);
})}
</View>
)}
</View>
</BottomSheetScrollView>
</BottomSheetModal>
@@ -516,6 +628,45 @@ const styles = StyleSheet.create((theme) => ({
color: theme.colors.mutedForeground,
fontSize: theme.fontSize.sm,
},
agentTypeContainer: {
gap: theme.spacing[3],
},
agentTypeOption: {
backgroundColor: theme.colors.background,
borderRadius: theme.borderRadius.lg,
borderWidth: theme.borderWidth[1],
borderColor: theme.colors.border,
padding: theme.spacing[4],
},
agentTypeOptionSelected: {
borderColor: theme.colors.palette.blue[500],
backgroundColor: theme.colors.muted,
},
agentTypeOptionDisabled: {
opacity: theme.opacity[50],
},
agentTypeOptionContent: {
flexDirection: "row",
alignItems: "center",
},
agentTypeTextContainer: {
flex: 1,
},
agentTypeLabel: {
color: theme.colors.foreground,
fontSize: theme.fontSize.base,
fontWeight: theme.fontWeight.semibold,
marginBottom: theme.spacing[1],
},
agentTypeDescription: {
color: theme.colors.mutedForeground,
fontSize: theme.fontSize.sm,
marginBottom: theme.spacing[1],
},
agentTypeMeta: {
color: theme.colors.mutedForeground,
fontSize: theme.fontSize.sm,
},
footer: {
borderTopWidth: theme.borderWidth[1],
borderTopColor: theme.colors.border,
+94 -17
View File
@@ -1,5 +1,5 @@
import { View, Text, Pressable, Animated } from "react-native";
import { useState, useEffect, useRef, memo } from "react";
import { useState, useEffect, useRef, memo, useMemo } from "react";
import Markdown from "react-native-markdown-display";
import {
Circle,
@@ -16,6 +16,7 @@ import {
Pencil,
Eye,
SquareTerminal,
Brain,
} from "lucide-react-native";
import { StyleSheet } from "react-native-unistyles";
import { baseColors, theme } from "@/styles/theme";
@@ -48,7 +49,10 @@ const userMessageStylesheet = StyleSheet.create((theme) => ({
},
}));
export const UserMessage = memo(function UserMessage({ message, timestamp }: UserMessageProps) {
export const UserMessage = memo(function UserMessage({
message,
timestamp,
}: UserMessageProps) {
return (
<View style={userMessageStylesheet.container}>
<View style={userMessageStylesheet.bubble}>
@@ -64,7 +68,7 @@ interface AssistantMessageProps {
isStreaming?: boolean;
}
const assistantMessageStylesheet = StyleSheet.create((theme) => ({
export const assistantMessageStylesheet = StyleSheet.create((theme) => ({
container: {
marginBottom: theme.spacing[3],
paddingHorizontal: theme.spacing[4],
@@ -133,6 +137,20 @@ const assistantMessageStylesheet = StyleSheet.create((theme) => ({
},
}));
const markdownStyles = {
body: assistantMessageStylesheet.markdownBody,
paragraph: assistantMessageStylesheet.markdownParagraph,
strong: assistantMessageStylesheet.markdownStrong,
em: assistantMessageStylesheet.markdownEm,
code_inline: assistantMessageStylesheet.markdownCodeInline,
code_block: assistantMessageStylesheet.markdownCodeBlock,
fence: assistantMessageStylesheet.markdownFence,
link: assistantMessageStylesheet.markdownLink,
bullet_list: assistantMessageStylesheet.markdownList,
ordered_list: assistantMessageStylesheet.markdownList,
list_item: assistantMessageStylesheet.markdownListItem,
};
export const AssistantMessage = memo(function AssistantMessage({
message,
timestamp,
@@ -162,20 +180,6 @@ export const AssistantMessage = memo(function AssistantMessage({
}
}, [isStreaming, fadeAnim]);
const markdownStyles = {
body: assistantMessageStylesheet.markdownBody,
paragraph: assistantMessageStylesheet.markdownParagraph,
strong: assistantMessageStylesheet.markdownStrong,
em: assistantMessageStylesheet.markdownEm,
code_inline: assistantMessageStylesheet.markdownCodeInline,
code_block: assistantMessageStylesheet.markdownCodeBlock,
fence: assistantMessageStylesheet.markdownFence,
link: assistantMessageStylesheet.markdownLink,
bullet_list: assistantMessageStylesheet.markdownList,
ordered_list: assistantMessageStylesheet.markdownList,
list_item: assistantMessageStylesheet.markdownListItem,
};
return (
<View style={assistantMessageStylesheet.container}>
<Markdown style={markdownStyles}>{message}</Markdown>
@@ -376,6 +380,79 @@ export const ActivityLog = memo(function ActivityLog({
);
});
interface AgentThoughtMessageProps {
message: string;
}
const agentThoughtStylesheet = StyleSheet.create((theme) => ({
container: {
marginHorizontal: theme.spacing[2],
marginBottom: theme.spacing[2],
},
card: {
backgroundColor: theme.colors.card,
borderRadius: theme.borderRadius.lg,
borderWidth: theme.borderWidth[1],
borderColor: theme.colors.border,
paddingVertical: theme.spacing[2],
paddingHorizontal: theme.spacing[3],
flexDirection: "row",
alignItems: "flex-start",
gap: theme.spacing[3],
},
iconContainer: {
width: 28,
height: 28,
borderRadius: 14,
alignItems: "center",
justifyContent: "center",
flexShrink: 0,
paddingTop: theme.spacing[1],
},
icon: {
color: theme.colors.mutedForeground,
opacity: 0.8,
},
textContainer: {
flex: 1,
paddingVertical: theme.spacing[1],
},
label: {
color: theme.colors.mutedForeground,
fontSize: theme.fontSize.xs,
fontWeight: theme.fontWeight.semibold,
textTransform: "uppercase",
marginBottom: theme.spacing[1],
},
text: {
color: theme.colors.mutedForeground,
fontSize: theme.fontSize.base,
lineHeight: 20,
},
}));
export function AgentThoughtMessage({ message }: AgentThoughtMessageProps) {
const messageText = useMemo(() => {
return message
.trim()
.replace(/^\*\*|\*\*$/g, "")
.trim();
}, [message]);
return (
<View style={agentThoughtStylesheet.container}>
<View style={agentThoughtStylesheet.card}>
<View style={agentThoughtStylesheet.iconContainer}>
<Brain size={18} style={agentThoughtStylesheet.icon} />
</View>
<View style={agentThoughtStylesheet.textContainer}>
<Text style={agentThoughtStylesheet.text}>{messageText}</Text>
</View>
</View>
</View>
);
}
interface ToolCallProps {
toolName: string;
kind?: string; // Optional kind for ACP tool calls