feat: add opencode provider and refactor stream model to head/tail architecture

This commit is contained in:
Mohamed Boudra
2026-01-11 16:00:25 +07:00
parent 7fe10f9b4a
commit ebabcc09e5
42 changed files with 4416 additions and 2926 deletions
@@ -301,6 +301,308 @@ export function AdaptiveSelect({
);
}
interface ComboSelectOption {
id: string;
label: string;
description?: string;
}
interface ComboSelectProps {
label: string;
title: string;
value: string;
options: ComboSelectOption[];
placeholder?: string;
disabled?: boolean;
allowCustomValue?: boolean;
isLoading?: boolean;
onSelect: (id: string) => void;
}
export function ComboSelect({
label,
title,
value,
options,
placeholder,
disabled,
allowCustomValue = false,
isLoading,
onSelect,
}: ComboSelectProps): ReactElement {
const [isOpen, setIsOpen] = useState(false);
const anchorRef = useRef<View>(null);
const inputRef = useRef<TextInput | null>(null);
const [searchQuery, setSearchQuery] = useState("");
const selectedOption = options.find((opt) => opt.id === value);
const displayValue = selectedOption?.label ?? (value || "");
const handleOpen = useCallback(() => setIsOpen(true), []);
const handleClose = useCallback(() => {
setIsOpen(false);
setSearchQuery("");
}, []);
useEffect(() => {
if (isOpen) {
setSearchQuery("");
inputRef.current?.focus();
}
}, [isOpen]);
const normalizedSearch = searchQuery.trim().toLowerCase();
const filteredOptions = useMemo(() => {
if (!normalizedSearch) {
return options;
}
return options.filter(
(opt) =>
opt.label.toLowerCase().includes(normalizedSearch) ||
opt.id.toLowerCase().includes(normalizedSearch) ||
opt.description?.toLowerCase().includes(normalizedSearch)
);
}, [options, normalizedSearch]);
const hasMatches = filteredOptions.length > 0;
const sanitizedSearchValue = searchQuery.trim();
const showCustomOption =
allowCustomValue &&
sanitizedSearchValue.length > 0 &&
!options.some(
(opt) =>
opt.id.toLowerCase() === sanitizedSearchValue.toLowerCase() ||
opt.label.toLowerCase() === sanitizedSearchValue.toLowerCase()
);
const handleSelect = useCallback(
(id: string) => {
onSelect(id);
handleClose();
},
[handleClose, onSelect]
);
return (
<>
<CompactSelectField
label={label}
value={displayValue}
placeholder={placeholder}
onPress={handleOpen}
disabled={disabled}
isLoading={isLoading}
controlRef={anchorRef}
/>
<AdaptiveSelect
title={title}
visible={isOpen}
onClose={handleClose}
anchorRef={anchorRef}
>
<TextInput
ref={inputRef}
style={styles.dropdownSearchInput}
placeholder={`Search ${label.toLowerCase()}...`}
placeholderTextColor={defaultTheme.colors.mutedForeground}
value={searchQuery}
onChangeText={setSearchQuery}
autoCapitalize="none"
autoCorrect={false}
/>
{showCustomOption ? (
<View style={styles.dropdownSheetList}>
<Pressable
style={styles.dropdownSheetOption}
onPress={() => handleSelect(sanitizedSearchValue)}
>
<Text style={styles.dropdownSheetOptionLabel} numberOfLines={1}>
{`Use "${sanitizedSearchValue}"`}
</Text>
</Pressable>
</View>
) : null}
{hasMatches ? (
<View style={styles.dropdownSheetList}>
{filteredOptions.map((opt) => {
const isSelected = opt.id === value;
return (
<Pressable
key={opt.id}
style={[
styles.dropdownSheetOption,
isSelected && styles.dropdownSheetOptionSelected,
]}
onPress={() => handleSelect(opt.id)}
>
<Text style={styles.dropdownSheetOptionLabel}>{opt.label}</Text>
{opt.description ? (
<Text style={styles.dropdownSheetOptionDescription}>
{opt.description}
</Text>
) : null}
</Pressable>
);
})}
</View>
) : !showCustomOption ? (
<Text style={styles.helperText}>No options match your search.</Text>
) : null}
{isLoading ? (
<View style={styles.dropdownSheetLoading}>
<ActivityIndicator size="small" color={defaultTheme.colors.foreground} />
</View>
) : null}
</AdaptiveSelect>
</>
);
}
interface CompactSelectFieldProps {
label: string;
value: string;
placeholder?: string;
onPress: () => void;
disabled?: boolean;
isLoading?: boolean;
controlRef?: React.RefObject<View | null>;
}
function CompactSelectField({
label,
value,
placeholder,
onPress,
disabled,
isLoading,
controlRef,
}: CompactSelectFieldProps): ReactElement {
return (
<Pressable
ref={controlRef}
onPress={onPress}
disabled={disabled}
style={[styles.compactSelectControl, disabled && styles.compactSelectControlDisabled]}
>
<Text style={styles.compactSelectLabel}>{label}</Text>
{isLoading ? (
<ActivityIndicator size="small" color={defaultTheme.colors.mutedForeground} />
) : (
<Text
style={value ? styles.compactSelectValue : styles.compactSelectPlaceholder}
numberOfLines={1}
>
{value || placeholder || "Select..."}
</Text>
)}
</Pressable>
);
}
interface AgentConfigRowProps {
providerDefinitions: AgentProviderDefinition[];
selectedProvider: AgentProvider;
onSelectProvider: (provider: AgentProvider) => void;
modeOptions: AgentMode[];
selectedMode: string;
onSelectMode: (modeId: string) => void;
models: AgentModelDefinition[];
selectedModel: string;
isModelLoading: boolean;
onSelectModel: (modelId: string) => void;
disabled?: boolean;
}
export function AgentConfigRow({
providerDefinitions,
selectedProvider,
onSelectProvider,
modeOptions,
selectedMode,
onSelectMode,
models,
selectedModel,
isModelLoading,
onSelectModel,
disabled,
}: AgentConfigRowProps): ReactElement {
const providerOptions: ComboSelectOption[] = useMemo(
() =>
providerDefinitions.map((def) => ({
id: def.id,
label: def.label,
description: def.description,
})),
[providerDefinitions]
);
const modeSelectOptions: ComboSelectOption[] = useMemo(() => {
if (modeOptions.length === 0) {
return [{ id: "", label: "Default", description: "Provider default mode" }];
}
return modeOptions.map((mode) => ({
id: mode.id,
label: mode.label,
description: mode.description,
}));
}, [modeOptions]);
const modelSelectOptions: ComboSelectOption[] = useMemo(() => {
const opts: ComboSelectOption[] = [
{ id: "", label: "Auto", description: "Provider default model" },
];
for (const model of models) {
opts.push({
id: model.id,
label: model.label,
description: model.description,
});
}
return opts;
}, [models]);
const effectiveSelectedMode = selectedMode || (modeOptions.length > 0 ? modeOptions[0]?.id : "");
return (
<View style={styles.agentConfigRow}>
<View style={styles.agentConfigColumn}>
<ComboSelect
label="PROVIDER"
title="Select Provider"
value={selectedProvider}
options={providerOptions}
placeholder="Select..."
disabled={disabled}
onSelect={onSelectProvider}
/>
</View>
<View style={styles.agentConfigColumn}>
<ComboSelect
label="MODEL"
title="Select Model"
value={selectedModel}
options={modelSelectOptions}
placeholder="Auto"
disabled={disabled}
isLoading={isModelLoading}
onSelect={onSelectModel}
/>
</View>
<View style={styles.agentConfigColumn}>
<ComboSelect
label="MODE"
title="Select Mode"
value={effectiveSelectedMode}
options={modeSelectOptions}
placeholder="Default"
disabled={disabled || modeOptions.length === 0}
onSelect={onSelectMode}
/>
</View>
</View>
);
}
interface AssistantDropdownProps {
providerDefinitions: AgentProviderDefinition[];
selectedProvider: AgentProvider;
@@ -1184,4 +1486,38 @@ const styles = StyleSheet.create((theme) => ({
desktopDropdownScrollContent: {
padding: theme.spacing[2],
},
agentConfigRow: {
flexDirection: "row",
gap: theme.spacing[2],
},
agentConfigColumn: {
flex: 1,
},
compactSelectControl: {
backgroundColor: theme.colors.background,
borderWidth: theme.borderWidth[1],
borderColor: theme.colors.border,
borderRadius: theme.borderRadius.lg,
paddingVertical: theme.spacing[2],
paddingHorizontal: theme.spacing[3],
gap: theme.spacing[1],
},
compactSelectControlDisabled: {
opacity: theme.opacity[50],
},
compactSelectLabel: {
color: theme.colors.mutedForeground,
fontSize: theme.fontSize.xs,
fontWeight: theme.fontWeight.medium,
textTransform: "uppercase",
letterSpacing: 0.5,
},
compactSelectValue: {
color: theme.colors.foreground,
fontSize: theme.fontSize.sm,
},
compactSelectPlaceholder: {
color: theme.colors.mutedForeground,
fontSize: theme.fontSize.sm,
},
}));
+209 -56
View File
@@ -16,7 +16,17 @@ import Markdown from "react-native-markdown-display";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { Fonts } from "@/constants/theme";
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 { useExplorerSidebarStore } from "@/stores/explorer-sidebar-store";
import {
@@ -72,19 +82,22 @@ export function AgentStreamView({
const isProgrammaticScrollRef = useRef(false);
const isNearBottomRef = useRef(true);
const isUserScrollingRef = useRef(false);
const { open: openExplorer, setActiveTab: setExplorerTab } = useExplorerSidebarStore();
const { open: openExplorer, setActiveTab: setExplorerTab } =
useExplorerSidebarStore();
// Get serverId (fallback to agent's serverId if not provided)
const resolvedServerId = serverId ?? agent.serverId ?? "";
// Get ws for connection status
const ws = useSessionStore((state) => state.sessions[resolvedServerId]?.ws);
const streamingBuffer = useSessionStore((state) =>
state.sessions[resolvedServerId]?.agentStreamingBuffer?.get(agentId)
const streamHead = useSessionStore((state) =>
state.sessions[resolvedServerId]?.agentStreamHead?.get(agentId)
);
// Get methods for file operations
const methods = useSessionStore((state) => state.sessions[resolvedServerId]?.methods);
const methods = useSessionStore(
(state) => state.sessions[resolvedServerId]?.methods
);
const requestDirectoryListing = methods?.requestDirectoryListing;
const requestFilePreview = methods?.requestFilePreview;
@@ -109,8 +122,12 @@ export function AgentStreamView({
// Keep entry/exit animations off on Android due to RN dispatchDraw crashes
// tracked in react-native-reanimated#8422.
const shouldDisableEntryExitAnimations = Platform.OS === "android";
const scrollIndicatorFadeIn = shouldDisableEntryExitAnimations ? undefined : FadeIn.duration(200);
const scrollIndicatorFadeOut = shouldDisableEntryExitAnimations ? undefined : FadeOut.duration(200);
const scrollIndicatorFadeIn = shouldDisableEntryExitAnimations
? undefined
: FadeIn.duration(200);
const scrollIndicatorFadeOut = shouldDisableEntryExitAnimations
? undefined
: FadeOut.duration(200);
useEffect(() => {
hasScrolledInitially.current = false;
@@ -265,7 +282,10 @@ export function AgentStreamView({
break;
case "thought":
content = <AgentThoughtMessage message={item.text} status={item.status} />;
console.log("[AgentStreamView] renderStreamItem", { item });
content = (
<AgentThoughtMessage message={item.text} status={item.status} />
);
break;
case "tool_call": {
@@ -343,9 +363,9 @@ export function AgentStreamView({
const listHeaderComponent = useMemo(() => {
const hasPermissions = pendingPermissionItems.length > 0;
const hasStreaming = !!streamingBuffer;
const hasHeadItems = streamHead && streamHead.length > 0;
if (!hasPermissions && !showWorkingIndicator && !hasStreaming) {
if (!hasPermissions && !showWorkingIndicator && !hasHeadItems) {
return null;
}
@@ -355,7 +375,11 @@ export function AgentStreamView({
{hasPermissions ? (
<View style={stylesheet.permissionsContainer}>
{pendingPermissionItems.map((permission) => (
<PermissionRequestCard key={permission.key} permission={permission} ws={wsOrInert} />
<PermissionRequestCard
key={permission.key}
permission={permission}
ws={wsOrInert}
/>
))}
</View>
) : null}
@@ -366,19 +390,32 @@ export function AgentStreamView({
</View>
) : null}
{hasStreaming ? (
<View style={stylesheet.streamItemWrapper}>
<AssistantMessage
message={streamingBuffer.text}
timestamp={streamingBuffer.timestamp.getTime()}
onInlinePathPress={handleInlinePathPress}
/>
</View>
) : null}
{hasHeadItems
? [...streamHead].reverse().map((item) => {
const rendered = renderStreamItem({
item,
index: 0,
separators: {
highlight: () => {},
unhighlight: () => {},
updateProps: () => {},
},
});
return rendered ? (
<View key={item.id}>{rendered}</View>
) : null;
})
: null}
</View>
</View>
);
}, [pendingPermissionItems, showWorkingIndicator, wsOrInert, streamingBuffer, handleInlinePathPress]);
}, [
pendingPermissionItems,
showWorkingIndicator,
wsOrInert,
streamHead,
renderStreamItem,
]);
const flatListData = useMemo(() => {
return [...streamItems].reverse();
@@ -457,9 +494,7 @@ export function AgentStreamView({
function normalizeInlinePath(
rawPath: string,
cwd?: string
):
| { directory: string; file?: string }
| null {
): { directory: string; file?: string } | null {
if (!rawPath) {
return null;
}
@@ -506,7 +541,10 @@ function normalizePathInput(value: string | undefined): string | null {
return null;
}
const trimmed = value.trim().replace(/^['"`]/, "").replace(/['"`]$/, "");
const trimmed = value
.trim()
.replace(/^['"`]/, "")
.replace(/['"`]$/, "");
if (!trimmed) {
return null;
}
@@ -516,7 +554,11 @@ function normalizePathInput(value: string | undefined): string | null {
function resolvePathAgainstCwd(pathValue: string, cwd?: string): string | null {
const normalizedCwd = normalizePathInput(cwd);
if (!normalizedCwd || !isAbsolutePath(pathValue) || !isAbsolutePath(normalizedCwd)) {
if (
!normalizedCwd ||
!isAbsolutePath(pathValue) ||
!isAbsolutePath(normalizedCwd)
) {
return null;
}
@@ -616,7 +658,9 @@ function PermissionRequestCard({
const { request } = permission;
const title = request.title ?? request.name ?? "Permission Required";
const description = request.description ?? "";
const inputPreview = request.input ? JSON.stringify(request.input, null, 2) : null;
const inputPreview = request.input
? JSON.stringify(request.input, null, 2)
: null;
const planMarkdown = useMemo(() => {
if (!request) {
@@ -651,10 +695,7 @@ function PermissionRequestCard({
[request]
);
const markdownStyles = useMemo(
() => createMarkdownStyles(theme),
[theme]
);
const markdownStyles = useMemo(() => createMarkdownStyles(theme), [theme]);
const markdownRules = useMemo(() => {
return {
@@ -676,7 +717,11 @@ function PermissionRequestCard({
styles: any,
inheritedStyles: any = {}
) => (
<Text key={node.key} style={[inheritedStyles, styles.textgroup]} selectable>
<Text
key={node.key}
style={[inheritedStyles, styles.textgroup]}
selectable
>
{children}
</Text>
),
@@ -687,7 +732,11 @@ function PermissionRequestCard({
styles: any,
inheritedStyles: any = {}
) => (
<Text key={node.key} style={[inheritedStyles, styles.code_block]} selectable>
<Text
key={node.key}
style={[inheritedStyles, styles.code_block]}
selectable
>
{node.content}
</Text>
),
@@ -709,7 +758,11 @@ function PermissionRequestCard({
styles: any,
inheritedStyles: any = {}
) => (
<Text key={node.key} style={[inheritedStyles, styles.code_inline]} selectable>
<Text
key={node.key}
style={[inheritedStyles, styles.code_inline]}
selectable
>
{node.content}
</Text>
),
@@ -752,7 +805,10 @@ function PermissionRequestCard({
return (
<View key={node.key} style={[styles.list_item, { flexShrink: 0 }]}>
<Text style={iconStyle}>{bullet}</Text>
<Text style={[contentStyle, { flex: 1, flexShrink: 1, minWidth: 0 }]} selectable>
<Text
style={[contentStyle, { flex: 1, flexShrink: 1, minWidth: 0 }]}
selectable
>
{children}
</Text>
</View>
@@ -799,7 +855,10 @@ function PermissionRequestCard({
response,
})
.catch((error) => {
console.error("[PermissionRequestCard] Failed to respond to permission:", error);
console.error(
"[PermissionRequestCard] Failed to respond to permission:",
error
);
});
},
[permission.agentId, permission.request.id, permissionResponse]
@@ -815,19 +874,33 @@ function PermissionRequestCard({
},
]}
>
<Text style={[permissionStyles.title, { color: theme.colors.foreground }]}>
<Text
style={[permissionStyles.title, { color: theme.colors.foreground }]}
>
{title}
</Text>
{description ? (
<Text style={[permissionStyles.description, { color: theme.colors.mutedForeground }]}>
<Text
style={[
permissionStyles.description,
{ color: theme.colors.mutedForeground },
]}
>
{description}
</Text>
) : null}
{planMarkdown ? (
<View style={permissionStyles.section}>
<Text style={[permissionStyles.sectionTitle, { color: theme.colors.mutedForeground }]}>Proposed Plan</Text>
<Text
style={[
permissionStyles.sectionTitle,
{ color: theme.colors.mutedForeground },
]}
>
Proposed Plan
</Text>
<View
style={[
permissionStyles.contentCard,
@@ -837,26 +910,59 @@ function PermissionRequestCard({
},
]}
>
<Markdown style={markdownStyles} rules={markdownRules}>{planMarkdown}</Markdown>
<Markdown style={markdownStyles} rules={markdownRules}>
{planMarkdown}
</Markdown>
</View>
</View>
) : null}
{commandDetails ? (
<View style={permissionStyles.section}>
<Text style={[permissionStyles.sectionTitle, { color: theme.colors.mutedForeground }]}>Command</Text>
<Text
style={[
permissionStyles.sectionTitle,
{ color: theme.colors.mutedForeground },
]}
>
Command
</Text>
{commandDetails.command ? (
<View style={permissionStyles.metadataRow}>
<Text style={[permissionStyles.metadataLabel, { color: theme.colors.mutedForeground }]}>Command</Text>
<Text style={[permissionStyles.metadataValue, { color: theme.colors.foreground }]}>
<Text
style={[
permissionStyles.metadataLabel,
{ color: theme.colors.mutedForeground },
]}
>
Command
</Text>
<Text
style={[
permissionStyles.metadataValue,
{ color: theme.colors.foreground },
]}
>
{commandDetails.command}
</Text>
</View>
) : null}
{commandDetails.cwd ? (
<View style={permissionStyles.metadataRow}>
<Text style={[permissionStyles.metadataLabel, { color: theme.colors.mutedForeground }]}>Directory</Text>
<Text style={[permissionStyles.metadataValue, { color: theme.colors.foreground }]}>
<Text
style={[
permissionStyles.metadataLabel,
{ color: theme.colors.mutedForeground },
]}
>
Directory
</Text>
<Text
style={[
permissionStyles.metadataValue,
{ color: theme.colors.foreground },
]}
>
{commandDetails.cwd}
</Text>
</View>
@@ -866,9 +972,19 @@ function PermissionRequestCard({
{editEntries.length > 0 ? (
<View style={permissionStyles.section}>
<Text style={[permissionStyles.sectionTitle, { color: theme.colors.mutedForeground }]}>Proposed Changes</Text>
<Text
style={[
permissionStyles.sectionTitle,
{ color: theme.colors.mutedForeground },
]}
>
Proposed Changes
</Text>
{editEntries.map((entry, index) => (
<View key={`${entry.filePath ?? "change"}-${index}`} style={permissionStyles.diffSection}>
<View
key={`${entry.filePath ?? "change"}-${index}`}
style={permissionStyles.diffSection}
>
{entry.filePath ? (
<View
style={[
@@ -879,7 +995,12 @@ function PermissionRequestCard({
},
]}
>
<Text style={[permissionStyles.fileBadgeText, { color: theme.colors.foreground }]}>
<Text
style={[
permissionStyles.fileBadgeText,
{ color: theme.colors.foreground },
]}
>
{entry.filePath}
</Text>
</View>
@@ -902,7 +1023,14 @@ function PermissionRequestCard({
{readEntries.length > 0 ? (
<View style={permissionStyles.section}>
<Text style={[permissionStyles.sectionTitle, { color: theme.colors.mutedForeground }]}>File Content</Text>
<Text
style={[
permissionStyles.sectionTitle,
{ color: theme.colors.mutedForeground },
]}
>
File Content
</Text>
{readEntries.map((entry, index) => (
<View
key={`${entry.filePath ?? "content"}-${index}`}
@@ -918,13 +1046,21 @@ function PermissionRequestCard({
<Text
style={[
permissionStyles.metadataLabel,
{ color: theme.colors.mutedForeground, marginBottom: theme.spacing[1] },
{
color: theme.colors.mutedForeground,
marginBottom: theme.spacing[1],
},
]}
>
{entry.filePath}
</Text>
) : null}
<Text style={[permissionStyles.rawContentText, { color: theme.colors.foreground }]}>
<Text
style={[
permissionStyles.rawContentText,
{ color: theme.colors.foreground },
]}
>
{entry.content}
</Text>
</View>
@@ -934,7 +1070,14 @@ function PermissionRequestCard({
{inputPreview ? (
<View style={permissionStyles.section}>
<Text style={[permissionStyles.sectionTitle, { color: theme.colors.mutedForeground }]}>Raw Request</Text>
<Text
style={[
permissionStyles.sectionTitle,
{ color: theme.colors.mutedForeground },
]}
>
Raw Request
</Text>
<View
style={[
permissionStyles.contentCard,
@@ -944,7 +1087,12 @@ function PermissionRequestCard({
},
]}
>
<Text style={[permissionStyles.rawContentText, { color: theme.colors.foreground }]}>
<Text
style={[
permissionStyles.rawContentText,
{ color: theme.colors.foreground },
]}
>
{inputPreview}
</Text>
</View>
@@ -970,7 +1118,10 @@ function PermissionRequestCard({
disabled={isResponding}
>
{isResponding ? (
<ActivityIndicator size="small" color={theme.colors.primaryForeground} />
<ActivityIndicator
size="small"
color={theme.colors.primaryForeground}
/>
) : (
<Text
style={[
@@ -996,7 +1147,10 @@ function PermissionRequestCard({
disabled={isResponding}
>
{isResponding ? (
<ActivityIndicator size="small" color={theme.colors.primaryForeground} />
<ActivityIndicator
size="small"
color={theme.colors.primaryForeground}
/>
) : (
<Text
style={[
@@ -1013,7 +1167,6 @@ function PermissionRequestCard({
);
}
const stylesheet = StyleSheet.create((theme) => ({
container: {
flex: 1,
File diff suppressed because it is too large Load Diff
+52 -34
View File
@@ -4,9 +4,10 @@ import {
Text,
ActivityIndicator,
Pressable,
RefreshControl,
FlatList,
type NativeSyntheticEvent,
type NativeScrollEvent,
type ListRenderItem,
} from "react-native";
import { ScrollView, type ScrollView as ScrollViewType } from "react-native-gesture-handler";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
@@ -258,6 +259,18 @@ export function GitDiffPane({ serverId, agentId }: GitDiffPaneProps) {
state.sessions[serverId]?.agents?.has(agentId) ?? false
);
const renderFileSection: ListRenderItem<ParsedDiffFile> = useCallback(
({ item, index }) => (
<DiffFileSection file={item} testID={`diff-file-${index}`} />
),
[]
);
const keyExtractor = useCallback(
(item: ParsedDiffFile, index: number) => `${item.path}-${index}`,
[]
);
if (!agentExists) {
return (
<View style={styles.errorContainer}>
@@ -269,40 +282,45 @@ export function GitDiffPane({ serverId, agentId }: GitDiffPaneProps) {
const hasChanges = files.length > 0;
const errorMessage = isError && error instanceof Error ? error.message : null;
return (
<ScrollView
style={styles.scrollView}
testID="git-diff-scroll"
refreshControl={
<RefreshControl
refreshing={isManualRefresh && isFetching}
onRefresh={handleRefresh}
tintColor={theme.colors.mutedForeground}
colors={[theme.colors.primary]}
/>
}
>
<View style={styles.contentContainer} testID="git-diff-content">
{isLoading ? (
<View style={styles.loadingContainer}>
<ActivityIndicator size="large" />
<Text style={styles.loadingText}>Loading changes...</Text>
</View>
) : isError ? (
<View style={styles.errorContainer}>
<Text style={styles.errorText}>{errorMessage ?? "Failed to load changes"}</Text>
</View>
) : !hasChanges ? (
<View style={styles.emptyContainer}>
<Text style={styles.emptyText}>No changes</Text>
</View>
) : (
files.map((file, fileIndex) => (
<DiffFileSection key={fileIndex} file={file} testID={`diff-file-${fileIndex}`} />
))
)}
if (isLoading) {
return (
<View style={styles.loadingContainer}>
<ActivityIndicator size="large" />
<Text style={styles.loadingText}>Loading changes...</Text>
</View>
</ScrollView>
);
}
if (isError) {
return (
<View style={styles.errorContainer}>
<Text style={styles.errorText}>{errorMessage ?? "Failed to load changes"}</Text>
</View>
);
}
if (!hasChanges) {
return (
<View style={styles.emptyContainer}>
<Text style={styles.emptyText}>No changes</Text>
</View>
);
}
return (
<FlatList
data={files}
renderItem={renderFileSection}
keyExtractor={keyExtractor}
style={styles.scrollView}
contentContainerStyle={styles.contentContainer}
testID="git-diff-scroll"
onRefresh={handleRefresh}
refreshing={isManualRefresh && isFetching}
initialNumToRender={3}
maxToRenderPerBatch={3}
windowSize={5}
/>
);
}
+125 -40
View File
@@ -22,16 +22,26 @@ import {
Search,
Brain,
} from "lucide-react-native";
import { StyleSheet, useUnistyles, UnistylesRuntime } from "react-native-unistyles";
import {
StyleSheet,
useUnistyles,
UnistylesRuntime,
} from "react-native-unistyles";
import { baseColors, theme } from "@/styles/theme";
import { createMarkdownStyles, createCompactMarkdownStyles } from "@/styles/markdown-styles";
import {
createMarkdownStyles,
createCompactMarkdownStyles,
} from "@/styles/markdown-styles";
import { Colors, Fonts } from "@/constants/theme";
import * as Clipboard from "expo-clipboard";
import type { TodoEntry, ThoughtStatus } from "@/types/stream";
import { extractPrincipalParam } from "@/utils/tool-call-parsers";
import { resolveToolCallPreview } from "./tool-call-preview";
import { useToolCallSheet } from "./tool-call-sheet";
import { ToolCallDetailsContent, useToolCallDetails } from "./tool-call-details";
import {
ToolCallDetailsContent,
useToolCallDetails,
} from "./tool-call-details";
interface UserMessageProps {
message: string;
@@ -259,13 +269,17 @@ function isLikelyPathToken(value: string): boolean {
return false;
}
const looksLikeDir = value.endsWith("/") || value.startsWith("./") || value.startsWith("../");
const looksLikeDir =
value.endsWith("/") || value.startsWith("./") || value.startsWith("../");
return hasExtension || looksLikeDir || value.includes("/");
}
function normalizeInlinePathValue(value: string): string | null {
const trimmed = value.trim().replace(/^['"`]/, "").replace(/['"`]$/, "");
const trimmed = value
.trim()
.replace(/^['"`]/, "")
.replace(/['"`]$/, "");
if (!trimmed) {
return null;
}
@@ -290,7 +304,9 @@ function parseInlinePathToken(
return null;
}
const lineStart = parseInt(rangeOnlyMatch[1], 10);
const lineEnd = rangeOnlyMatch[2] ? parseInt(rangeOnlyMatch[2], 10) : undefined;
const lineEnd = rangeOnlyMatch[2]
? parseInt(rangeOnlyMatch[2], 10)
: undefined;
return {
raw: rawValue,
path: basePath,
@@ -335,10 +351,7 @@ export const AssistantMessage = memo(function AssistantMessage({
const { theme } = useUnistyles();
const lastPathRef = useRef<string | null>(null);
const markdownStyles = useMemo(
() => createMarkdownStyles(theme),
[theme]
);
const markdownStyles = useMemo(() => createMarkdownStyles(theme), [theme]);
const markdownRules = useMemo(() => {
return {
@@ -360,7 +373,11 @@ export const AssistantMessage = memo(function AssistantMessage({
styles: any,
inheritedStyles: any = {}
) => (
<Text key={node.key} style={[inheritedStyles, styles.textgroup]} selectable>
<Text
key={node.key}
style={[inheritedStyles, styles.textgroup]}
selectable
>
{children}
</Text>
),
@@ -371,7 +388,11 @@ export const AssistantMessage = memo(function AssistantMessage({
styles: any,
inheritedStyles: any = {}
) => (
<Text key={node.key} style={[inheritedStyles, styles.code_block]} selectable>
<Text
key={node.key}
style={[inheritedStyles, styles.code_block]}
selectable
>
{node.content}
</Text>
),
@@ -402,7 +423,10 @@ export const AssistantMessage = memo(function AssistantMessage({
return (
<Text
key={node.key}
style={[inheritedStyles, assistantMessageStylesheet.markdownCodeInline]}
style={[
inheritedStyles,
assistantMessageStylesheet.markdownCodeInline,
]}
selectable
>
{content}
@@ -415,7 +439,10 @@ export const AssistantMessage = memo(function AssistantMessage({
key={node.key}
onPress={() => parsed && onInlinePathPress?.(parsed)}
selectable={false}
style={[assistantMessageStylesheet.pathChip, assistantMessageStylesheet.pathChipText]}
style={[
assistantMessageStylesheet.pathChip,
assistantMessageStylesheet.pathChipText,
]}
>
{content}
</Text>
@@ -460,7 +487,9 @@ export const AssistantMessage = memo(function AssistantMessage({
return (
<View key={node.key} style={styles.list_item}>
<Text style={iconStyle}>{bullet}</Text>
<View style={[contentStyle, { flex: 1, flexShrink: 1, minWidth: 0 }]}>
<View
style={[contentStyle, { flex: 1, flexShrink: 1, minWidth: 0 }]}
>
{children}
</View>
</View>
@@ -781,7 +810,10 @@ export const TodoListCard = memo(function TodoListCard({
[items]
);
const timestampLabel = useMemo(() => formatPlanTimestamp(timestamp), [timestamp]);
const timestampLabel = useMemo(
() => formatPlanTimestamp(timestamp),
[timestamp]
);
const iconColor = theme.colors.background;
@@ -791,10 +823,14 @@ export const TodoListCard = memo(function TodoListCard({
<View style={todoListCardStylesheet.header}>
<View style={todoListCardStylesheet.headerMeta}>
<Text style={todoListCardStylesheet.title}>Plan</Text>
<Text style={todoListCardStylesheet.timestamp}>{timestampLabel}</Text>
<Text style={todoListCardStylesheet.timestamp}>
{timestampLabel}
</Text>
</View>
<View style={todoListCardStylesheet.providerBadge}>
<Text style={todoListCardStylesheet.providerText}>{providerLabel}</Text>
<Text style={todoListCardStylesheet.providerText}>
{providerLabel}
</Text>
</View>
</View>
<Text style={todoListCardStylesheet.progressText}>
@@ -809,7 +845,10 @@ export const TodoListCard = memo(function TodoListCard({
</Text>
) : (
items.map((item, idx) => (
<View key={`${item.text}-${idx}`} style={todoListCardStylesheet.itemRow}>
<View
key={`${item.text}-${idx}`}
style={todoListCardStylesheet.itemRow}
>
<View
style={[
todoListCardStylesheet.checkbox,
@@ -889,10 +928,14 @@ const ExpandableBadge = memo(function ExpandableBadge({
};
}, [isLoading, spinAnim]);
const spin = spinAnim.interpolate({
inputRange: [0, 1],
outputRange: ["0deg", "360deg"],
});
const spin = useMemo(
() =>
spinAnim.interpolate({
inputRange: [0, 1],
outputRange: ["0deg", "360deg"],
}),
[spinAnim]
);
const IconComponent = icon;
const iconColor = isError
@@ -921,7 +964,9 @@ const ExpandableBadge = memo(function ExpandableBadge({
accessibilityState={hasDetails ? { expanded: isExpanded } : undefined}
style={({ pressed }) => [
expandableBadgeStylesheet.pressable,
pressed && hasDetails ? expandableBadgeStylesheet.pressablePressed : null,
pressed && hasDetails
? expandableBadgeStylesheet.pressablePressed
: null,
]}
>
<View style={expandableBadgeStylesheet.headerRow}>
@@ -930,7 +975,10 @@ const ExpandableBadge = memo(function ExpandableBadge({
{label}
</Text>
{secondaryLabel ? (
<Text style={expandableBadgeStylesheet.secondaryLabel} numberOfLines={1}>
<Text
style={expandableBadgeStylesheet.secondaryLabel}
numberOfLines={1}
>
{secondaryLabel}
</Text>
) : (
@@ -948,7 +996,9 @@ const ExpandableBadge = memo(function ExpandableBadge({
) : null}
</View>
{detailContent ? (
<View style={expandableBadgeStylesheet.detailWrapper}>{detailContent}</View>
<View style={expandableBadgeStylesheet.detailWrapper}>
{detailContent}
</View>
) : null}
</Pressable>
</View>
@@ -963,7 +1013,10 @@ const agentThoughtStylesheet = StyleSheet.create((theme) => ({
},
}));
export const AgentThoughtMessage = memo(function AgentThoughtMessage({ message, status = "ready" }: AgentThoughtMessageProps) {
export const AgentThoughtMessage = memo(function AgentThoughtMessage({
message,
status = "ready",
}: AgentThoughtMessageProps) {
const { theme } = useUnistyles();
const [isExpanded, setIsExpanded] = useState(false);
const markdownContent = useMemo(() => message?.trim() ?? "", [message]);
@@ -996,7 +1049,11 @@ export const AgentThoughtMessage = memo(function AgentThoughtMessage({ message,
styles: any,
inheritedStyles: any = {}
) => (
<Text key={node.key} style={[inheritedStyles, styles.textgroup]} selectable>
<Text
key={node.key}
style={[inheritedStyles, styles.textgroup]}
selectable
>
{children}
</Text>
),
@@ -1007,7 +1064,11 @@ export const AgentThoughtMessage = memo(function AgentThoughtMessage({ message,
styles: any,
inheritedStyles: any = {}
) => (
<Text key={node.key} style={[inheritedStyles, styles.code_block]} selectable>
<Text
key={node.key}
style={[inheritedStyles, styles.code_block]}
selectable
>
{node.content}
</Text>
),
@@ -1029,7 +1090,11 @@ export const AgentThoughtMessage = memo(function AgentThoughtMessage({ message,
styles: any,
inheritedStyles: any = {}
) => (
<Text key={node.key} style={[inheritedStyles, styles.code_inline]} selectable>
<Text
key={node.key}
style={[inheritedStyles, styles.code_inline]}
selectable
>
{node.content}
</Text>
),
@@ -1072,7 +1137,9 @@ export const AgentThoughtMessage = memo(function AgentThoughtMessage({ message,
return (
<View key={node.key} style={styles.list_item}>
<Text style={iconStyle}>{bullet}</Text>
<View style={[contentStyle, { flex: 1, flexShrink: 1, minWidth: 0 }]}>
<View
style={[contentStyle, { flex: 1, flexShrink: 1, minWidth: 0 }]}
>
{children}
</View>
</View>
@@ -1083,7 +1150,11 @@ export const AgentThoughtMessage = memo(function AgentThoughtMessage({ message,
const renderDetails = useCallback(() => {
if (!markdownContent) {
return <Text style={agentThoughtStylesheet.emptyText}>No captured thinking</Text>;
return (
<Text style={agentThoughtStylesheet.emptyText}>
No captured thinking
</Text>
);
}
return (
<Markdown style={markdownStyles} rules={markdownRules}>
@@ -1124,14 +1195,16 @@ const toolKindIcons: Record<string, any> = {
// Derive tool kind from tool name for icon selection
function getToolKindFromName(toolName: string): string {
const lower = toolName.toLowerCase();
if (lower === "read" || lower === "read_file" || lower.startsWith("read")) return "read";
if (lower === "edit" || lower === "write" || lower === "apply_patch") return "edit";
if (lower === "read" || lower === "read_file" || lower.startsWith("read"))
return "read";
if (lower === "edit" || lower === "write" || lower === "apply_patch")
return "edit";
if (lower === "bash" || lower === "shell") return "execute";
if (lower === "grep" || lower === "glob" || lower === "web_search") return "search";
if (lower === "grep" || lower === "glob" || lower === "web_search")
return "search";
return "tool";
}
export const ToolCall = memo(function ToolCall({
toolName,
args,
@@ -1145,7 +1218,8 @@ export const ToolCall = memo(function ToolCall({
// Check if we're on mobile (use bottom sheet) or desktop (inline expand)
const isMobile =
UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
UnistylesRuntime.breakpoint === "xs" ||
UnistylesRuntime.breakpoint === "sm";
const kind = getToolKindFromName(toolName);
const IconComponent = toolKindIcons[kind] || Wrench;
@@ -1157,7 +1231,8 @@ export const ToolCall = memo(function ToolCall({
);
// Check if there's any content to display
const hasDetails = args !== undefined || result !== undefined || error !== undefined;
const hasDetails =
args !== undefined || result !== undefined || error !== undefined;
// Parse tool call details for inline rendering
const { display, errorText } = useToolCallDetails({ args, result, error });
@@ -1184,7 +1259,11 @@ export const ToolCall = memo(function ToolCall({
if (isMobile) return null;
return (
<View style={toolCallInlineStyles.detailsContainer}>
<ToolCallDetailsContent display={display} errorText={errorText} maxHeight={400} />
<ToolCallDetailsContent
display={display}
errorText={errorText}
maxHeight={400}
/>
</View>
);
}, [isMobile, display, errorText]);
@@ -1196,7 +1275,13 @@ export const ToolCall = memo(function ToolCall({
icon={IconComponent}
isExpanded={!isMobile && isExpanded}
onToggle={hasDetails ? handleToggle : undefined}
renderDetails={hasDetails && !isMobile ? renderDetails : (hasDetails ? () => null : undefined)}
renderDetails={
hasDetails && !isMobile
? renderDetails
: hasDetails
? () => null
: undefined
}
isLoading={status === "executing"}
isError={status === "failed"}
/>