feat(app): add maestro tests, download progress, and task hierarchy
Add Maestro E2E test flows for app launch and sidebar interactions. Implement download progress tracking with speed/ETA display in file explorer. Extend task CLI with parent-child hierarchy, body content from stdin, and ancestor context in task show. Fix explorer sidebar gesture to allow horizontal scrolling while preserving close swipe. Replace hardcoded monospace font with Fonts.mono constant. Fix RefreshControl to only show spinner on manual pull-to-refresh.
BIN
packages/app/01-diff-sidebar-open.png
Normal file
|
After Width: | Height: | Size: 338 KiB |
BIN
packages/app/02-after-vertical-scroll.png
Normal file
|
After Width: | Height: | Size: 339 KiB |
BIN
packages/app/02-tabs-work.png
Normal file
|
After Width: | Height: | Size: 270 KiB |
BIN
packages/app/03-after-horizontal-scroll-right.png
Normal file
|
After Width: | Height: | Size: 68 KiB |
BIN
packages/app/03-after-vertical-scroll.png
Normal file
|
After Width: | Height: | Size: 338 KiB |
BIN
packages/app/04-after-horizontal-scroll-right.png
Normal file
|
After Width: | Height: | Size: 338 KiB |
BIN
packages/app/04-back-at-left-edge.png
Normal file
|
After Width: | Height: | Size: 327 KiB |
BIN
packages/app/05-back-at-left-edge.png
Normal file
|
After Width: | Height: | Size: 338 KiB |
BIN
packages/app/app-launched.png
Normal file
|
After Width: | Height: | Size: 116 KiB |
BIN
packages/app/current-state.png
Normal file
|
After Width: | Height: | Size: 1.1 MiB |
BIN
packages/app/dev-client.png
Normal file
|
After Width: | Height: | Size: 1.1 MiB |
BIN
packages/app/diff-sidebar-open.png
Normal file
|
After Width: | Height: | Size: 339 KiB |
BIN
packages/app/fail3.png
Normal file
|
After Width: | Height: | Size: 217 KiB |
BIN
packages/app/launch-screen.png
Normal file
|
After Width: | Height: | Size: 93 KiB |
BIN
packages/app/maestro-fail.png
Normal file
|
After Width: | Height: | Size: 126 KiB |
BIN
packages/app/maestro-fail2.png
Normal file
|
After Width: | Height: | Size: 126 KiB |
26
packages/app/maestro/flows/launch.yaml
Normal file
@@ -0,0 +1,26 @@
|
||||
appId: com.moboudra.paseo
|
||||
---
|
||||
# Reusable flow to handle dev client screens after launchApp
|
||||
# Works with both release builds (no-ops) and dev builds
|
||||
|
||||
# Handle dev launcher - tap localhost server (dev builds only)
|
||||
- tapOn:
|
||||
text: ".*localhost.*"
|
||||
optional: true
|
||||
|
||||
# Dismiss dev menu intro modal
|
||||
- runFlow:
|
||||
when:
|
||||
visible: "Continue"
|
||||
commands:
|
||||
- tapOn: "Continue"
|
||||
|
||||
# Dismiss dev menu if it opens
|
||||
- runFlow:
|
||||
when:
|
||||
visible: "Go home"
|
||||
commands:
|
||||
- tapOn:
|
||||
point: "50%,20%"
|
||||
|
||||
- assertVisible: "New Agent"
|
||||
11
packages/app/maestro/poc.yaml
Normal file
@@ -0,0 +1,11 @@
|
||||
appId: com.moboudra.paseo
|
||||
---
|
||||
- launchApp:
|
||||
clearState: true
|
||||
- runFlow: flows/launch.yaml
|
||||
- takeScreenshot: app-launched
|
||||
- swipe:
|
||||
direction: RIGHT
|
||||
duration: 300
|
||||
- assertVisible: "Settings"
|
||||
- tapOn: "Settings"
|
||||
74
packages/app/maestro/sidebar-swipe-bug.yaml
Normal file
@@ -0,0 +1,74 @@
|
||||
appId: com.moboudra.paseo
|
||||
---
|
||||
- launchApp:
|
||||
clearState: true
|
||||
- runFlow: flows/launch.yaml
|
||||
|
||||
# Open left sidebar
|
||||
- swipe:
|
||||
direction: RIGHT
|
||||
duration: 300
|
||||
|
||||
# Tap on an agent conversation
|
||||
- assertVisible:
|
||||
text: ".*Build Production APK.*"
|
||||
- tapOn:
|
||||
text: ".*Build Production APK.*"
|
||||
|
||||
# Wait for chat to load
|
||||
- assertVisible: "Message agent..."
|
||||
|
||||
# Swipe right-to-left to reveal git diff sidebar
|
||||
- swipe:
|
||||
direction: LEFT
|
||||
duration: 300
|
||||
|
||||
# Assert diff sidebar is visible
|
||||
- assertVisible: "Changes"
|
||||
- assertVisible: "Files"
|
||||
- takeScreenshot: 01-diff-sidebar-open
|
||||
|
||||
# TEST 1: Scroll diff vertically down (should work)
|
||||
# Tap on Files tab to switch, then back to Changes - proves interaction works
|
||||
- tapOn: "Files"
|
||||
- assertVisible: "Files"
|
||||
- tapOn: "Changes"
|
||||
- assertVisible: "Changes"
|
||||
- takeScreenshot: 02-tabs-work
|
||||
|
||||
# TEST 2: Scroll diff content vertically (should work)
|
||||
- swipe:
|
||||
start: "50%,70%"
|
||||
end: "50%,30%"
|
||||
duration: 300
|
||||
- assertVisible: "Changes"
|
||||
- takeScreenshot: 03-after-vertical-scroll
|
||||
|
||||
# TEST 3: Scroll diff horizontally to the right (should work)
|
||||
- swipe:
|
||||
start: "80%,50%"
|
||||
end: "20%,50%"
|
||||
duration: 300
|
||||
- assertVisible: "Changes"
|
||||
- takeScreenshot: 04-after-horizontal-scroll-right
|
||||
|
||||
# TEST 4: Scroll diff back to left edge (scrollLeft = 0)
|
||||
- swipe:
|
||||
start: "20%,50%"
|
||||
end: "80%,50%"
|
||||
duration: 300
|
||||
- swipe:
|
||||
start: "20%,50%"
|
||||
end: "80%,50%"
|
||||
duration: 300
|
||||
- assertVisible: "Changes"
|
||||
- takeScreenshot: 05-back-at-left-edge
|
||||
|
||||
# TEST 5 (BUG): At scrollLeft 0, swipe right should close sidebar
|
||||
# Currently fails - the gesture bounces the scrollview instead
|
||||
- swipe:
|
||||
start: "10%,50%"
|
||||
end: "90%,50%"
|
||||
duration: 300
|
||||
- assertVisible: "Message agent..."
|
||||
- takeScreenshot: 06-sidebar-closed
|
||||
BIN
packages/app/manual-launch.png
Normal file
|
After Width: | Height: | Size: 135 KiB |
BIN
packages/app/settings-screen.png
Normal file
|
After Width: | Height: | Size: 188 KiB |
BIN
packages/app/sidebar-fail.png
Normal file
|
After Width: | Height: | Size: 225 KiB |
BIN
packages/app/sidebar-fail2.png
Normal file
|
After Width: | Height: | Size: 226 KiB |
@@ -258,16 +258,13 @@ export default function RootLayout() {
|
||||
screenOptions={{
|
||||
headerShown: false,
|
||||
animation: "none",
|
||||
gestureEnabled: true,
|
||||
gestureDirection: "horizontal",
|
||||
fullScreenGestureEnabled: true,
|
||||
}}
|
||||
>
|
||||
<Stack.Screen name="index" />
|
||||
<Stack.Screen name="agents" />
|
||||
<Stack.Screen name="orchestrator" />
|
||||
<Stack.Screen name="agent/[id]" />
|
||||
<Stack.Screen name="agent/[serverId]/[agentId]" />
|
||||
<Stack.Screen name="agent/[id]" options={{ gestureEnabled: false }} />
|
||||
<Stack.Screen name="agent/[serverId]/[agentId]" options={{ gestureEnabled: false }} />
|
||||
<Stack.Screen name="settings" />
|
||||
<Stack.Screen name="audio-test" />
|
||||
</Stack>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useMemo } from "react";
|
||||
import { useMemo, useState, useCallback, useEffect } from "react";
|
||||
import { View } from "react-native";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
import { BackHeader } from "@/components/headers/back-header";
|
||||
@@ -8,6 +8,21 @@ import { useAggregatedAgents } from "@/hooks/use-aggregated-agents";
|
||||
export default function AgentsScreen() {
|
||||
const { agents, isRevalidating, refreshAll } = useAggregatedAgents();
|
||||
|
||||
// Track user-initiated refresh to avoid showing spinner on background revalidation
|
||||
const [isManualRefresh, setIsManualRefresh] = useState(false);
|
||||
|
||||
const handleRefresh = useCallback(() => {
|
||||
setIsManualRefresh(true);
|
||||
refreshAll();
|
||||
}, [refreshAll]);
|
||||
|
||||
// Reset manual refresh flag when revalidation completes
|
||||
useEffect(() => {
|
||||
if (!isRevalidating && isManualRefresh) {
|
||||
setIsManualRefresh(false);
|
||||
}
|
||||
}, [isRevalidating, isManualRefresh]);
|
||||
|
||||
const sortedAgents = useMemo(() => {
|
||||
return [...agents].sort((a, b) => {
|
||||
if (a.requiresAttention && !b.requiresAttention) return -1;
|
||||
@@ -21,8 +36,8 @@ export default function AgentsScreen() {
|
||||
<BackHeader title="All Agents" />
|
||||
<AgentList
|
||||
agents={sortedAgents}
|
||||
isRefreshing={isRevalidating}
|
||||
onRefresh={refreshAll}
|
||||
isRefreshing={isManualRefresh && isRevalidating}
|
||||
onRefresh={handleRefresh}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
} from "react-native";
|
||||
import { router } from "expo-router";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import { Fonts } from "@/constants/theme";
|
||||
import { useAppSettings } from "@/hooks/use-settings";
|
||||
import { useDaemonRegistry, type DaemonProfile } from "@/contexts/daemon-registry-context";
|
||||
import { useDaemonConnections, type ConnectionStatus } from "@/contexts/daemon-connections-context";
|
||||
@@ -82,7 +83,7 @@ const styles = StyleSheet.create((theme) => ({
|
||||
fontSize: theme.fontSize.base,
|
||||
},
|
||||
inputUrl: {
|
||||
fontFamily: "monospace",
|
||||
fontFamily: Fonts.mono,
|
||||
},
|
||||
// Host card styles
|
||||
hostCard: {
|
||||
@@ -110,7 +111,7 @@ const styles = StyleSheet.create((theme) => ({
|
||||
hostUrl: {
|
||||
color: theme.colors.mutedForeground,
|
||||
fontSize: theme.fontSize.sm,
|
||||
fontFamily: "monospace",
|
||||
fontFamily: Fonts.mono,
|
||||
},
|
||||
hostError: {
|
||||
color: theme.colors.palette.red[300],
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useState } from 'react';
|
||||
import { View, Text, Pressable } from 'react-native';
|
||||
import { StyleSheet } from 'react-native-unistyles';
|
||||
import { Fonts } from "@/constants/theme";
|
||||
import type { AgentActivity, GroupedTextMessage, MergedToolCall, SessionUpdate } from '@/types/agent-activity';
|
||||
|
||||
interface AgentActivityItemProps {
|
||||
@@ -346,7 +347,7 @@ const stylesheet = StyleSheet.create((theme) => ({
|
||||
code: {
|
||||
color: theme.colors.mutedForeground,
|
||||
fontSize: theme.fontSize.xs,
|
||||
fontFamily: 'monospace',
|
||||
fontFamily: Fonts.mono,
|
||||
backgroundColor: theme.colors.muted,
|
||||
padding: theme.spacing[2],
|
||||
borderRadius: theme.borderRadius.md,
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
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 { ChevronDown } from "lucide-react-native";
|
||||
import { useExplorerSidebarStore } from "@/stores/explorer-sidebar-store";
|
||||
@@ -1160,7 +1161,7 @@ const permissionStyles = StyleSheet.create((theme) => ({
|
||||
letterSpacing: 0.5,
|
||||
},
|
||||
metadataValue: {
|
||||
fontFamily: "monospace",
|
||||
fontFamily: Fonts.mono,
|
||||
fontSize: theme.fontSize.sm,
|
||||
},
|
||||
diffSection: {
|
||||
@@ -1174,7 +1175,7 @@ const permissionStyles = StyleSheet.create((theme) => ({
|
||||
borderWidth: theme.borderWidth[1],
|
||||
},
|
||||
fileBadgeText: {
|
||||
fontFamily: "monospace",
|
||||
fontFamily: Fonts.mono,
|
||||
fontSize: theme.fontSize.xs,
|
||||
},
|
||||
diffWrapper: {
|
||||
@@ -1183,7 +1184,7 @@ const permissionStyles = StyleSheet.create((theme) => ({
|
||||
overflow: "hidden",
|
||||
},
|
||||
rawContentText: {
|
||||
fontFamily: "monospace",
|
||||
fontFamily: Fonts.mono,
|
||||
fontSize: theme.fontSize.sm,
|
||||
lineHeight: 20,
|
||||
},
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
import { SafeAreaView } from "react-native-safe-area-context";
|
||||
import { useEffect } from "react";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
import { Fonts } from "@/constants/theme";
|
||||
|
||||
export interface Artifact {
|
||||
id: string;
|
||||
@@ -120,7 +121,7 @@ const styles = StyleSheet.create((theme) => ({
|
||||
codeText: {
|
||||
color: theme.colors.foreground,
|
||||
fontSize: theme.fontSize.sm,
|
||||
fontFamily: "monospace",
|
||||
fontFamily: Fonts.mono,
|
||||
},
|
||||
metadataContainer: {
|
||||
backgroundColor: theme.colors.card,
|
||||
@@ -148,7 +149,7 @@ const styles = StyleSheet.create((theme) => ({
|
||||
color: theme.colors.mutedForeground,
|
||||
fontSize: theme.fontSize.xs,
|
||||
flex: 1,
|
||||
fontFamily: "monospace",
|
||||
fontFamily: Fonts.mono,
|
||||
},
|
||||
}));
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ import React from "react";
|
||||
import { View, Text } from "react-native";
|
||||
import { ScrollView } from "react-native-gesture-handler";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
import { Fonts } from "@/constants/theme";
|
||||
import type { DiffLine } from "@/utils/tool-call-parsers";
|
||||
|
||||
interface DiffViewerProps {
|
||||
@@ -80,7 +81,7 @@ const styles = StyleSheet.create((theme) => ({
|
||||
paddingVertical: theme.spacing[1],
|
||||
},
|
||||
lineText: {
|
||||
fontFamily: "monospace",
|
||||
fontFamily: Fonts.mono,
|
||||
fontSize: theme.fontSize.xs,
|
||||
color: theme.colors.foreground,
|
||||
},
|
||||
|
||||
@@ -66,8 +66,10 @@ export function ExplorerSidebar({ serverId, agentId }: ExplorerSidebarProps) {
|
||||
Gesture.Pan()
|
||||
.withRef(closeGestureRef)
|
||||
.enabled(isMobile && isOpen)
|
||||
// Only activate after 15px horizontal movement (creates deadzone for taps)
|
||||
.activeOffsetX([-15, 15])
|
||||
// Only activate on rightward swipe (positive X), fail on leftward or vertical
|
||||
// This allows ScrollViews using waitFor to scroll left normally
|
||||
.activeOffsetX(15)
|
||||
.failOffsetX(-10)
|
||||
.failOffsetY([-10, 10])
|
||||
.onStart(() => {
|
||||
isGesturing.value = true;
|
||||
|
||||
@@ -18,8 +18,10 @@ import {
|
||||
useWindowDimensions,
|
||||
} from "react-native";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import { Fonts } from "@/constants/theme";
|
||||
import * as Clipboard from "expo-clipboard";
|
||||
import { File as FSFile, Paths } from "expo-file-system";
|
||||
import * as LegacyFileSystem from "expo-file-system/legacy";
|
||||
import * as Sharing from "expo-sharing";
|
||||
import {
|
||||
BottomSheetModal,
|
||||
@@ -169,7 +171,16 @@ export function FileExplorerPane({
|
||||
status: "downloading" | "complete" | "error";
|
||||
fileName: string;
|
||||
message?: string;
|
||||
progress?: {
|
||||
percent: number;
|
||||
bytesWritten: number;
|
||||
totalBytes: number;
|
||||
speed: number;
|
||||
eta: number;
|
||||
};
|
||||
} | null>(null);
|
||||
const downloadStartTimeRef = useRef<number>(0);
|
||||
const lastProgressRef = useRef<{ bytes: number; time: number } | null>(null);
|
||||
const downloadToastTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const agentIdRef = useRef(agentId);
|
||||
const viewModeRef = useRef(viewMode);
|
||||
@@ -467,21 +478,59 @@ export function FileExplorerPane({
|
||||
return;
|
||||
}
|
||||
|
||||
downloadStartTimeRef.current = Date.now();
|
||||
lastProgressRef.current = null;
|
||||
showDownloadToast({ status: "downloading", fileName: displayName });
|
||||
|
||||
const targetFile = resolveDownloadTargetFile(fileName);
|
||||
const downloadedFile = await FSFile.downloadFileAsync(
|
||||
const downloadResumable = LegacyFileSystem.createDownloadResumable(
|
||||
downloadUrl,
|
||||
targetFile,
|
||||
targetFile.uri,
|
||||
downloadTarget.authHeader
|
||||
? { headers: { Authorization: downloadTarget.authHeader } }
|
||||
: undefined
|
||||
: undefined,
|
||||
(data) => {
|
||||
const now = Date.now();
|
||||
const { totalBytesWritten, totalBytesExpectedToWrite } = data;
|
||||
|
||||
if (totalBytesExpectedToWrite <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const percent = totalBytesWritten / totalBytesExpectedToWrite;
|
||||
const elapsed = (now - downloadStartTimeRef.current) / 1000;
|
||||
const speed = elapsed > 0 ? totalBytesWritten / elapsed : 0;
|
||||
const remaining = totalBytesExpectedToWrite - totalBytesWritten;
|
||||
const eta = speed > 0 ? remaining / speed : 0;
|
||||
|
||||
lastProgressRef.current = { bytes: totalBytesWritten, time: now };
|
||||
|
||||
setDownloadToast((prev) =>
|
||||
prev?.status === "downloading"
|
||||
? {
|
||||
...prev,
|
||||
progress: {
|
||||
percent,
|
||||
bytesWritten: totalBytesWritten,
|
||||
totalBytes: totalBytesExpectedToWrite,
|
||||
speed,
|
||||
eta,
|
||||
},
|
||||
}
|
||||
: prev
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
const result = await downloadResumable.downloadAsync();
|
||||
if (!result) {
|
||||
throw new Error("Download was cancelled.");
|
||||
}
|
||||
|
||||
showDownloadToast({ status: "complete", fileName: displayName });
|
||||
|
||||
if (await Sharing.isAvailableAsync()) {
|
||||
await Sharing.shareAsync(downloadedFile.uri, {
|
||||
await Sharing.shareAsync(result.uri, {
|
||||
mimeType: tokenResponse.mimeType ?? undefined,
|
||||
dialogTitle: fileName ? `Share ${fileName}` : "Share file",
|
||||
});
|
||||
@@ -947,11 +996,23 @@ export function FileExplorerPane({
|
||||
</Text>
|
||||
<Text style={styles.downloadToastStatus}>
|
||||
{downloadToast.status === "downloading"
|
||||
? "Downloading..."
|
||||
? downloadToast.progress
|
||||
? `${Math.round(downloadToast.progress.percent * 100)}% · ${formatSpeed(downloadToast.progress.speed)} · ${formatEta(downloadToast.progress.eta)}`
|
||||
: "Starting..."
|
||||
: downloadToast.status === "complete"
|
||||
? "Download complete"
|
||||
: downloadToast.message ?? "Download failed"}
|
||||
</Text>
|
||||
{downloadToast.status === "downloading" && downloadToast.progress && (
|
||||
<View style={styles.downloadProgressBar}>
|
||||
<View
|
||||
style={[
|
||||
styles.downloadProgressFill,
|
||||
{ width: `${Math.round(downloadToast.progress.percent * 100)}%` },
|
||||
]}
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
{downloadToast.status !== "downloading" && (
|
||||
<Pressable
|
||||
@@ -983,6 +1044,28 @@ function formatFileSize({ size }: { size: number }): string {
|
||||
return `${(size / (1024 * 1024)).toFixed(1)} MB`;
|
||||
}
|
||||
|
||||
function formatSpeed(bytesPerSecond: number): string {
|
||||
if (bytesPerSecond < 1024) {
|
||||
return `${Math.round(bytesPerSecond)} B/s`;
|
||||
}
|
||||
if (bytesPerSecond < 1024 * 1024) {
|
||||
return `${(bytesPerSecond / 1024).toFixed(1)} KB/s`;
|
||||
}
|
||||
return `${(bytesPerSecond / (1024 * 1024)).toFixed(1)} MB/s`;
|
||||
}
|
||||
|
||||
function formatEta(seconds: number): string {
|
||||
if (seconds < 1) {
|
||||
return "< 1s";
|
||||
}
|
||||
if (seconds < 60) {
|
||||
return `${Math.round(seconds)}s`;
|
||||
}
|
||||
const mins = Math.floor(seconds / 60);
|
||||
const secs = Math.round(seconds % 60);
|
||||
return `${mins}m ${secs}s`;
|
||||
}
|
||||
|
||||
function formatModifiedTime({ value }: { value: string }): string {
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
@@ -1241,7 +1324,7 @@ const styles = StyleSheet.create((theme) => ({
|
||||
flex: 1,
|
||||
fontSize: theme.fontSize.sm,
|
||||
color: theme.colors.mutedForeground,
|
||||
fontFamily: "monospace",
|
||||
fontFamily: Fonts.mono,
|
||||
},
|
||||
backButton: {
|
||||
padding: theme.spacing[1],
|
||||
@@ -1379,7 +1462,7 @@ const styles = StyleSheet.create((theme) => ({
|
||||
},
|
||||
codeText: {
|
||||
color: theme.colors.foreground,
|
||||
fontFamily: "monospace",
|
||||
fontFamily: Fonts.mono,
|
||||
fontSize: theme.fontSize.sm,
|
||||
flexShrink: 0,
|
||||
},
|
||||
@@ -1510,6 +1593,18 @@ const styles = StyleSheet.create((theme) => ({
|
||||
color: theme.colors.mutedForeground,
|
||||
fontSize: theme.fontSize.xs,
|
||||
},
|
||||
downloadProgressBar: {
|
||||
height: 3,
|
||||
backgroundColor: theme.colors.muted,
|
||||
borderRadius: theme.borderRadius.full,
|
||||
marginTop: theme.spacing[1],
|
||||
overflow: "hidden",
|
||||
},
|
||||
downloadProgressFill: {
|
||||
height: "100%",
|
||||
backgroundColor: theme.colors.primary,
|
||||
borderRadius: theme.borderRadius.full,
|
||||
},
|
||||
downloadToastDismiss: {
|
||||
padding: theme.spacing[1],
|
||||
},
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState, useCallback, useEffect, useId } from "react";
|
||||
import { useState, useCallback, useEffect, useId, useRef } from "react";
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
type NativeSyntheticEvent,
|
||||
type NativeScrollEvent,
|
||||
} from "react-native";
|
||||
import { ScrollView } from "react-native-gesture-handler";
|
||||
import { ScrollView, type ScrollView as ScrollViewType } from "react-native-gesture-handler";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import { ChevronRight } from "lucide-react-native";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
@@ -19,6 +19,8 @@ import {
|
||||
type HighlightToken,
|
||||
} from "@/hooks/use-highlighted-diff-query";
|
||||
import { useHorizontalScrollOptional } from "@/contexts/horizontal-scroll-context";
|
||||
import { useExplorerSidebarAnimation } from "@/contexts/explorer-sidebar-animation-context";
|
||||
import { Fonts } from "@/constants/theme";
|
||||
|
||||
type HighlightStyle = NonNullable<HighlightToken["style"]>;
|
||||
|
||||
@@ -120,8 +122,19 @@ function DiffFileSection({ file, defaultExpanded = true, testID }: DiffFileSecti
|
||||
const { theme } = useUnistyles();
|
||||
const [isExpanded, setIsExpanded] = useState(defaultExpanded);
|
||||
const [scrollViewWidth, setScrollViewWidth] = useState(0);
|
||||
const [isAtLeftEdge, setIsAtLeftEdge] = useState(true);
|
||||
const horizontalScroll = useHorizontalScrollOptional();
|
||||
const scrollId = useId();
|
||||
const scrollViewRef = useRef<ScrollViewType>(null);
|
||||
|
||||
// Get the close gesture ref from animation context (may not be available outside sidebar)
|
||||
let closeGestureRef: React.MutableRefObject<any> | undefined;
|
||||
try {
|
||||
const animation = useExplorerSidebarAnimation();
|
||||
closeGestureRef = animation.closeGestureRef;
|
||||
} catch {
|
||||
// Not inside ExplorerSidebarAnimationProvider, which is fine
|
||||
}
|
||||
|
||||
const toggleExpanded = useCallback(() => {
|
||||
setIsExpanded((prev) => !prev);
|
||||
@@ -139,9 +152,12 @@ function DiffFileSection({ file, defaultExpanded = true, testID }: DiffFileSecti
|
||||
|
||||
const handleScroll = useCallback(
|
||||
(event: NativeSyntheticEvent<NativeScrollEvent>) => {
|
||||
if (!horizontalScroll) return;
|
||||
const offsetX = event.nativeEvent.contentOffset.x;
|
||||
horizontalScroll.registerScrollOffset(scrollId, offsetX);
|
||||
// Track if we're at the left edge (with small threshold for float precision)
|
||||
setIsAtLeftEdge(offsetX <= 1);
|
||||
if (horizontalScroll) {
|
||||
horizontalScroll.registerScrollOffset(scrollId, offsetX);
|
||||
}
|
||||
},
|
||||
[horizontalScroll, scrollId]
|
||||
);
|
||||
@@ -183,6 +199,7 @@ function DiffFileSection({ file, defaultExpanded = true, testID }: DiffFileSecti
|
||||
</Pressable>
|
||||
{isExpanded && (
|
||||
<ScrollView
|
||||
ref={scrollViewRef}
|
||||
horizontal
|
||||
nestedScrollEnabled
|
||||
showsHorizontalScrollIndicator
|
||||
@@ -192,6 +209,11 @@ function DiffFileSection({ file, defaultExpanded = true, testID }: DiffFileSecti
|
||||
onScroll={handleScroll}
|
||||
scrollEventThrottle={16}
|
||||
onLayout={(e) => setScrollViewWidth(e.nativeEvent.layout.width)}
|
||||
// When at left edge, wait for close gesture to fail before scrolling.
|
||||
// The close gesture fails quickly on leftward swipes (failOffsetX=-10),
|
||||
// so scrolling left works normally. On rightward swipes, close gesture
|
||||
// activates and closes the sidebar.
|
||||
waitFor={isAtLeftEdge && closeGestureRef?.current ? closeGestureRef : undefined}
|
||||
>
|
||||
<View style={[styles.linesContainer, scrollViewWidth > 0 && { minWidth: scrollViewWidth }]}>
|
||||
{file.hunks.map((hunk, hunkIndex) =>
|
||||
@@ -217,6 +239,20 @@ export function GitDiffPane({ serverId, agentId }: GitDiffPaneProps) {
|
||||
serverId,
|
||||
agentId,
|
||||
});
|
||||
// Track user-initiated refresh to avoid iOS RefreshControl animation on background fetches
|
||||
const [isManualRefresh, setIsManualRefresh] = useState(false);
|
||||
|
||||
const handleRefresh = useCallback(() => {
|
||||
setIsManualRefresh(true);
|
||||
refresh();
|
||||
}, [refresh]);
|
||||
|
||||
// Reset manual refresh flag when fetch completes
|
||||
useEffect(() => {
|
||||
if (!isFetching && isManualRefresh) {
|
||||
setIsManualRefresh(false);
|
||||
}
|
||||
}, [isFetching, isManualRefresh]);
|
||||
|
||||
const agentExists = useSessionStore((state) =>
|
||||
state.sessions[serverId]?.agents?.has(agentId) ?? false
|
||||
@@ -239,8 +275,8 @@ export function GitDiffPane({ serverId, agentId }: GitDiffPaneProps) {
|
||||
testID="git-diff-scroll"
|
||||
refreshControl={
|
||||
<RefreshControl
|
||||
refreshing={isFetching && !isLoading}
|
||||
onRefresh={refresh}
|
||||
refreshing={isManualRefresh && isFetching}
|
||||
onRefresh={handleRefresh}
|
||||
tintColor={theme.colors.mutedForeground}
|
||||
colors={[theme.colors.primary]}
|
||||
/>
|
||||
@@ -354,7 +390,7 @@ const styles = StyleSheet.create((theme) => ({
|
||||
fontSize: theme.fontSize.sm,
|
||||
fontWeight: theme.fontWeight.normal,
|
||||
color: theme.colors.foreground,
|
||||
fontFamily: "monospace",
|
||||
fontFamily: Fonts.mono,
|
||||
flex: 1,
|
||||
},
|
||||
newBadge: {
|
||||
@@ -373,13 +409,13 @@ const styles = StyleSheet.create((theme) => ({
|
||||
fontSize: theme.fontSize.sm,
|
||||
fontWeight: theme.fontWeight.normal,
|
||||
color: theme.colors.palette.green[400],
|
||||
fontFamily: "monospace",
|
||||
fontFamily: Fonts.mono,
|
||||
},
|
||||
deletions: {
|
||||
fontSize: theme.fontSize.sm,
|
||||
fontWeight: theme.fontWeight.normal,
|
||||
color: theme.colors.palette.red[500],
|
||||
fontFamily: "monospace",
|
||||
fontFamily: Fonts.mono,
|
||||
},
|
||||
diffContent: {
|
||||
borderTopWidth: theme.borderWidth[1],
|
||||
@@ -398,7 +434,7 @@ const styles = StyleSheet.create((theme) => ({
|
||||
},
|
||||
diffLineText: {
|
||||
fontSize: theme.fontSize.xs,
|
||||
fontFamily: "monospace",
|
||||
fontFamily: Fonts.mono,
|
||||
color: theme.colors.foreground,
|
||||
},
|
||||
addLineContainer: {
|
||||
|
||||
@@ -25,7 +25,7 @@ import {
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import { baseColors, theme } from "@/styles/theme";
|
||||
import { createMarkdownStyles, createCompactMarkdownStyles } from "@/styles/markdown-styles";
|
||||
import { Colors } from "@/constants/theme";
|
||||
import { Colors, Fonts } from "@/constants/theme";
|
||||
import * as Clipboard from "expo-clipboard";
|
||||
import type { TodoEntry, ThoughtStatus } from "@/types/stream";
|
||||
import type { CommandDetails, EditEntry, ReadEntry } from "@/utils/tool-call-parsers";
|
||||
@@ -162,7 +162,7 @@ export const assistantMessageStylesheet = StyleSheet.create((theme) => ({
|
||||
paddingHorizontal: theme.spacing[2],
|
||||
paddingVertical: 2,
|
||||
borderRadius: theme.borderRadius.sm,
|
||||
fontFamily: "monospace",
|
||||
fontFamily: Fonts.mono,
|
||||
fontSize: 13,
|
||||
},
|
||||
// Used in custom markdownRules for path chip styling
|
||||
@@ -176,7 +176,7 @@ export const assistantMessageStylesheet = StyleSheet.create((theme) => ({
|
||||
},
|
||||
pathChipText: {
|
||||
color: theme.colors.secondaryForeground,
|
||||
fontFamily: "monospace",
|
||||
fontFamily: Fonts.mono,
|
||||
fontSize: 13,
|
||||
},
|
||||
}));
|
||||
@@ -554,7 +554,7 @@ const activityLogStylesheet = StyleSheet.create((theme) => ({
|
||||
metadataText: {
|
||||
color: theme.colors.foreground,
|
||||
fontSize: theme.fontSize.xs,
|
||||
fontFamily: "monospace",
|
||||
fontFamily: Fonts.mono,
|
||||
lineHeight: 16,
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useMemo } from "react";
|
||||
import { useCallback, useMemo, useState, useEffect } from "react";
|
||||
import { View, Pressable, Text, Platform } from "react-native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import Animated, {
|
||||
@@ -37,6 +37,21 @@ export function SlidingSidebar({ selectedAgentId }: SlidingSidebarProps) {
|
||||
isGesturing,
|
||||
} = useSidebarAnimation();
|
||||
|
||||
// Track user-initiated refresh to avoid showing spinner on background revalidation
|
||||
const [isManualRefresh, setIsManualRefresh] = useState(false);
|
||||
|
||||
const handleRefresh = useCallback(() => {
|
||||
setIsManualRefresh(true);
|
||||
refreshAll();
|
||||
}, [refreshAll]);
|
||||
|
||||
// Reset manual refresh flag when revalidation completes
|
||||
useEffect(() => {
|
||||
if (!isRevalidating && isManualRefresh) {
|
||||
setIsManualRefresh(false);
|
||||
}
|
||||
}, [isRevalidating, isManualRefresh]);
|
||||
|
||||
const isMobile =
|
||||
UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
|
||||
|
||||
@@ -198,8 +213,8 @@ export function SlidingSidebar({ selectedAgentId }: SlidingSidebarProps) {
|
||||
{/* Middle: scrollable agent list */}
|
||||
<AgentList
|
||||
agents={limitedAgents}
|
||||
isRefreshing={isRevalidating}
|
||||
onRefresh={refreshAll}
|
||||
isRefreshing={isManualRefresh && isRevalidating}
|
||||
onRefresh={handleRefresh}
|
||||
selectedAgentId={selectedAgentId}
|
||||
onAgentSelect={handleAgentSelectMobile}
|
||||
listFooterComponent={viewMoreButton}
|
||||
@@ -246,8 +261,8 @@ export function SlidingSidebar({ selectedAgentId }: SlidingSidebarProps) {
|
||||
{/* Middle: scrollable agent list */}
|
||||
<AgentList
|
||||
agents={limitedAgents}
|
||||
isRefreshing={isRevalidating}
|
||||
onRefresh={refreshAll}
|
||||
isRefreshing={isManualRefresh && isRevalidating}
|
||||
onRefresh={handleRefresh}
|
||||
selectedAgentId={selectedAgentId}
|
||||
listFooterComponent={viewMoreButton}
|
||||
/>
|
||||
|
||||
@@ -8,6 +8,7 @@ import React, {
|
||||
} from "react";
|
||||
import { View, Text, Pressable, ScrollView } from "react-native";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
import { Fonts } from "@/constants/theme";
|
||||
import {
|
||||
BottomSheetModal,
|
||||
BottomSheetScrollView,
|
||||
@@ -765,7 +766,7 @@ const styles = StyleSheet.create((theme) => ({
|
||||
},
|
||||
fileBadgeText: {
|
||||
color: theme.colors.foreground,
|
||||
fontFamily: "monospace",
|
||||
fontFamily: Fonts.mono,
|
||||
fontSize: theme.fontSize.xs,
|
||||
},
|
||||
diffContainer: {
|
||||
@@ -786,7 +787,7 @@ const styles = StyleSheet.create((theme) => ({
|
||||
padding: theme.spacing[2],
|
||||
},
|
||||
scrollText: {
|
||||
fontFamily: "monospace",
|
||||
fontFamily: Fonts.mono,
|
||||
fontSize: theme.fontSize.xs,
|
||||
color: theme.colors.foreground,
|
||||
lineHeight: 18,
|
||||
@@ -824,7 +825,7 @@ const styles = StyleSheet.create((theme) => ({
|
||||
},
|
||||
metaValue: {
|
||||
color: theme.colors.foreground,
|
||||
fontFamily: "monospace",
|
||||
fontFamily: Fonts.mono,
|
||||
fontSize: theme.fontSize.xs,
|
||||
flex: 1,
|
||||
},
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { Theme } from "./theme";
|
||||
import { Fonts } from "@/constants/theme";
|
||||
|
||||
/**
|
||||
* Creates comprehensive markdown styles for react-native-markdown-display.
|
||||
@@ -135,7 +136,7 @@ export function createMarkdownStyles(theme: Theme) {
|
||||
paddingHorizontal: theme.spacing[1],
|
||||
paddingVertical: 2,
|
||||
borderRadius: theme.borderRadius.sm,
|
||||
fontFamily: "monospace",
|
||||
fontFamily: Fonts.mono,
|
||||
fontSize: theme.fontSize.sm,
|
||||
},
|
||||
|
||||
@@ -144,7 +145,7 @@ export function createMarkdownStyles(theme: Theme) {
|
||||
color: theme.colors.secondaryForeground,
|
||||
padding: theme.spacing[3],
|
||||
borderRadius: theme.borderRadius.md,
|
||||
fontFamily: "monospace",
|
||||
fontFamily: Fonts.mono,
|
||||
fontSize: theme.fontSize.sm,
|
||||
marginVertical: theme.spacing[2],
|
||||
},
|
||||
@@ -156,7 +157,7 @@ export function createMarkdownStyles(theme: Theme) {
|
||||
borderRadius: theme.borderRadius.md,
|
||||
borderWidth: 1,
|
||||
borderColor: theme.colors.border,
|
||||
fontFamily: "monospace",
|
||||
fontFamily: Fonts.mono,
|
||||
fontSize: theme.fontSize.sm,
|
||||
marginVertical: theme.spacing[3],
|
||||
},
|
||||
|
||||