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.
This commit is contained in:
Mohamed Boudra
2026-01-10 22:44:57 +07:00
parent 584b0413a5
commit 42129926ae
41 changed files with 834 additions and 135 deletions
@@ -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 -1
View File
@@ -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],
},
+46 -10
View File
@@ -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: {
+4 -4
View File
@@ -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,
},