feat: show agent working indicator in stream

This commit is contained in:
Mohamed Boudra
2025-11-15 19:51:57 +01:00
parent 94862b73f4
commit 42a51124af
3 changed files with 132 additions and 57 deletions

View File

@@ -2,7 +2,6 @@ import { View, Text, Pressable } from "react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { ChevronDown } from "lucide-react-native";
import { useSession } from "@/contexts/session-context";
import type { Agent } from "@/contexts/session-context";
import { useState } from "react";
import { ModeSelectorModal } from "./mode-selector-modal";
@@ -25,23 +24,6 @@ export function AgentStatusBar({ agentId }: AgentStatusBarProps) {
setAgentMode(agentId, modeId);
}
function getStatusColor(status: Agent["status"]): string {
switch (status) {
case "initializing":
return theme.colors.palette.orange[500];
case "idle":
return theme.colors.palette.green[500];
case "running":
return theme.colors.palette.blue[500];
case "error":
return theme.colors.palette.red[500];
case "closed":
return theme.colors.palette.gray[500];
default:
return theme.colors.mutedForeground;
}
}
return (
<View style={styles.container}>
{/* Agent Mode Badge */}
@@ -62,14 +44,6 @@ export function AgentStatusBar({ agentId }: AgentStatusBarProps) {
</Pressable>
)}
{/* Agent Status Indicator - just a dot */}
<View
style={[
styles.statusDot,
{ backgroundColor: getStatusColor(agent.status) },
]}
/>
{/* Mode selector modal */}
<ModeSelectorModal
visible={showModeSelector}
@@ -105,19 +79,4 @@ const styles = StyleSheet.create((theme) => ({
fontWeight: theme.fontWeight.semibold,
textTransform: "capitalize",
},
statusIndicator: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
},
statusDot: {
width: 8,
height: 8,
borderRadius: 4,
},
statusText: {
color: theme.colors.mutedForeground,
fontSize: theme.fontSize.xs,
fontWeight: theme.fontWeight.semibold,
},
}));

View File

@@ -14,7 +14,7 @@ import Markdown from "react-native-markdown-display";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { BottomSheetModal } from "@gorhom/bottom-sheet";
import Animated, { FadeIn, FadeOut } 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 { useRouter } from "expo-router";
import {
@@ -310,10 +310,48 @@ export function AgentStreamView({
[pendingPermissions, agentId]
);
const showWorkingIndicator = agent.status === "running";
const listHeaderComponent = useMemo(() => {
if (pendingPermissionItems.length === 0 && !showWorkingIndicator) {
return null;
}
return (
<View style={stylesheet.listHeaderContent}>
{pendingPermissionItems.length > 0 ? (
<View style={stylesheet.permissionsContainer}>
{pendingPermissionItems.map((permission) => (
<PermissionRequestCard
key={permission.key}
permission={permission}
onResponse={onPermissionResponse}
/>
))}
</View>
) : null}
{showWorkingIndicator ? (
<View style={stylesheet.workingIndicatorWrapper}>
<WorkingIndicator />
</View>
) : null}
</View>
);
}, [onPermissionResponse, pendingPermissionItems, showWorkingIndicator]);
const flatListData = useMemo(() => {
return [...streamItems].reverse();
}, [streamItems]);
const flatListExtraData = useMemo(
() => ({
pendingPermissionCount: pendingPermissionItems.length,
showWorkingIndicator,
}),
[pendingPermissionItems.length, showWorkingIndicator]
);
return (
<View style={stylesheet.container}>
<FlatList
@@ -339,20 +377,8 @@ export function AgentStreamView({
</Text>
</View>
}
ListHeaderComponent={
pendingPermissionItems.length > 0 ? (
<View style={stylesheet.permissionsContainer}>
{pendingPermissionItems.map((permission) => (
<PermissionRequestCard
key={permission.key}
permission={permission}
onResponse={onPermissionResponse}
/>
))}
</View>
) : null
}
extraData={pendingPermissionItems.length}
ListHeaderComponent={listHeaderComponent}
extraData={flatListExtraData}
maintainVisibleContentPosition={{
minIndexForVisible: 0,
autoscrollToTopThreshold: 40,
@@ -432,6 +458,62 @@ function normalizeInlinePath(rawPath: string):
};
}
function WorkingIndicator() {
const dotOne = useSharedValue(0);
const dotTwo = useSharedValue(0);
const dotThree = useSharedValue(0);
useEffect(() => {
const sharedValues = [dotOne, dotTwo, dotThree];
sharedValues.forEach((value, index) => {
value.value = withDelay(
index * 120,
withRepeat(
withSequence(
withTiming(1, { duration: 320 }),
withTiming(0, { duration: 320 })
),
-1,
true
)
);
});
return () => {
sharedValues.forEach((value) => {
cancelAnimation(value);
value.value = 0;
});
};
}, [dotOne, dotTwo, dotThree]);
const dotOneStyle = useAnimatedStyle(() => ({
opacity: 0.3 + dotOne.value * 0.7,
transform: [{ translateY: dotOne.value * -4 }],
}));
const dotTwoStyle = useAnimatedStyle(() => ({
opacity: 0.3 + dotTwo.value * 0.7,
transform: [{ translateY: dotTwo.value * -4 }],
}));
const dotThreeStyle = useAnimatedStyle(() => ({
opacity: 0.3 + dotThree.value * 0.7,
transform: [{ translateY: dotThree.value * -4 }],
}));
return (
<View style={stylesheet.workingIndicatorBubble}>
<Text style={stylesheet.workingIndicatorText}>Working</Text>
<View style={stylesheet.workingDotsRow}>
<Animated.View style={[stylesheet.workingDot, dotOneStyle]} />
<Animated.View style={[stylesheet.workingDot, dotTwoStyle]} />
<Animated.View style={[stylesheet.workingDot, dotThreeStyle]} />
</View>
</View>
);
}
// Permission Request Card Component
function PermissionRequestCard({
permission,
@@ -754,6 +836,39 @@ const stylesheet = StyleSheet.create((theme) => ({
permissionsContainer: {
gap: theme.spacing[2],
},
listHeaderContent: {
gap: theme.spacing[3],
},
workingIndicatorWrapper: {
alignItems: "center",
},
workingIndicatorBubble: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
paddingHorizontal: theme.spacing[4],
paddingVertical: theme.spacing[2],
borderRadius: theme.borderRadius.full,
backgroundColor: theme.colors.card,
borderWidth: theme.borderWidth[1],
borderColor: theme.colors.border,
},
workingIndicatorText: {
color: theme.colors.mutedForeground,
fontSize: theme.fontSize.sm,
fontWeight: theme.fontWeight.medium,
},
workingDotsRow: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[1],
},
workingDot: {
width: 6,
height: 6,
borderRadius: 3,
backgroundColor: theme.colors.mutedForeground,
},
invertedWrapper: {
transform: [{ scaleY: -1 }],
width: "100%",

View File

@@ -80,4 +80,5 @@
- Replaced the text-based pill with a compact circular stop control that reuses the Square media glyph (plus accessibility labels), making the interrupt action visually distinct without impacting the other pending AgentInput tweaks.
- [x] When the agent input has text and were not in the cancellable state, replace both the mic (Dictate) and Realtime buttons with a single Send button—only surface Dictate/Realtime when the input is empty or the agent is running.
- `packages/app/src/components/agent-input-area.tsx` now shows a lone Send control when text/images are queued while keeping Dictate + Realtime visible only when the input is empty or the agent is actively running.
- [ ] Remove the agent status pill next to the permission selector; instead show a “working” indicator inside the chat scroll view itself (three bouncing dots animation) whenever the agent is busy.
- [x] Remove the agent status pill next to the permission selector; instead show a “working” indicator inside the chat scroll view itself (three bouncing dots animation) whenever the agent is busy.
- Dropped the old status dot from `AgentStatusBar` (the permission/mode selector now stands alone) and introduced an in-stream "Working" chip inside `AgentStreamView` that renders three bouncing dots via Reanimated whenever the agent reports `status === "running"`, so busy turns surface directly in the chat timeline.