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

Binary file not shown.

After

Width:  |  Height:  |  Size: 338 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 339 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 270 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 338 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 338 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 327 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 338 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 116 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

BIN
packages/app/dev-client.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 339 KiB

BIN
packages/app/fail3.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 217 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 93 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 126 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 126 KiB

View 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"

View 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"

View 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 135 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 188 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 225 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 226 KiB

View File

@@ -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>

View File

@@ -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>
);

View File

@@ -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],

View File

@@ -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,

View File

@@ -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,
},

View File

@@ -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,
},
}));

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,
},

View File

@@ -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;

View File

@@ -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],
},

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: {

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,
},
}));

View File

@@ -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}
/>

View File

@@ -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,
},

View File

@@ -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],
},