feat: show agent working indicator in stream
This commit is contained in:
@@ -2,7 +2,6 @@ import { View, Text, Pressable } from "react-native";
|
|||||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||||
import { ChevronDown } from "lucide-react-native";
|
import { ChevronDown } from "lucide-react-native";
|
||||||
import { useSession } from "@/contexts/session-context";
|
import { useSession } from "@/contexts/session-context";
|
||||||
import type { Agent } from "@/contexts/session-context";
|
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { ModeSelectorModal } from "./mode-selector-modal";
|
import { ModeSelectorModal } from "./mode-selector-modal";
|
||||||
|
|
||||||
@@ -25,23 +24,6 @@ export function AgentStatusBar({ agentId }: AgentStatusBarProps) {
|
|||||||
setAgentMode(agentId, modeId);
|
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 (
|
return (
|
||||||
<View style={styles.container}>
|
<View style={styles.container}>
|
||||||
{/* Agent Mode Badge */}
|
{/* Agent Mode Badge */}
|
||||||
@@ -62,14 +44,6 @@ export function AgentStatusBar({ agentId }: AgentStatusBarProps) {
|
|||||||
</Pressable>
|
</Pressable>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Agent Status Indicator - just a dot */}
|
|
||||||
<View
|
|
||||||
style={[
|
|
||||||
styles.statusDot,
|
|
||||||
{ backgroundColor: getStatusColor(agent.status) },
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Mode selector modal */}
|
{/* Mode selector modal */}
|
||||||
<ModeSelectorModal
|
<ModeSelectorModal
|
||||||
visible={showModeSelector}
|
visible={showModeSelector}
|
||||||
@@ -105,19 +79,4 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
fontWeight: theme.fontWeight.semibold,
|
fontWeight: theme.fontWeight.semibold,
|
||||||
textTransform: "capitalize",
|
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,
|
|
||||||
},
|
|
||||||
}));
|
}));
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import Markdown from "react-native-markdown-display";
|
|||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||||
import { BottomSheetModal } from "@gorhom/bottom-sheet";
|
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 { ChevronDown } from "lucide-react-native";
|
||||||
import { useRouter } from "expo-router";
|
import { useRouter } from "expo-router";
|
||||||
import {
|
import {
|
||||||
@@ -310,10 +310,48 @@ export function AgentStreamView({
|
|||||||
[pendingPermissions, agentId]
|
[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(() => {
|
const flatListData = useMemo(() => {
|
||||||
return [...streamItems].reverse();
|
return [...streamItems].reverse();
|
||||||
}, [streamItems]);
|
}, [streamItems]);
|
||||||
|
|
||||||
|
const flatListExtraData = useMemo(
|
||||||
|
() => ({
|
||||||
|
pendingPermissionCount: pendingPermissionItems.length,
|
||||||
|
showWorkingIndicator,
|
||||||
|
}),
|
||||||
|
[pendingPermissionItems.length, showWorkingIndicator]
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={stylesheet.container}>
|
<View style={stylesheet.container}>
|
||||||
<FlatList
|
<FlatList
|
||||||
@@ -339,20 +377,8 @@ export function AgentStreamView({
|
|||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
}
|
}
|
||||||
ListHeaderComponent={
|
ListHeaderComponent={listHeaderComponent}
|
||||||
pendingPermissionItems.length > 0 ? (
|
extraData={flatListExtraData}
|
||||||
<View style={stylesheet.permissionsContainer}>
|
|
||||||
{pendingPermissionItems.map((permission) => (
|
|
||||||
<PermissionRequestCard
|
|
||||||
key={permission.key}
|
|
||||||
permission={permission}
|
|
||||||
onResponse={onPermissionResponse}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</View>
|
|
||||||
) : null
|
|
||||||
}
|
|
||||||
extraData={pendingPermissionItems.length}
|
|
||||||
maintainVisibleContentPosition={{
|
maintainVisibleContentPosition={{
|
||||||
minIndexForVisible: 0,
|
minIndexForVisible: 0,
|
||||||
autoscrollToTopThreshold: 40,
|
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
|
// Permission Request Card Component
|
||||||
function PermissionRequestCard({
|
function PermissionRequestCard({
|
||||||
permission,
|
permission,
|
||||||
@@ -754,6 +836,39 @@ const stylesheet = StyleSheet.create((theme) => ({
|
|||||||
permissionsContainer: {
|
permissionsContainer: {
|
||||||
gap: theme.spacing[2],
|
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: {
|
invertedWrapper: {
|
||||||
transform: [{ scaleY: -1 }],
|
transform: [{ scaleY: -1 }],
|
||||||
width: "100%",
|
width: "100%",
|
||||||
|
|||||||
@@ -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.
|
- 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 we’re 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.
|
- [x] When the agent input has text and we’re 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.
|
- `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.
|
||||||
|
|||||||
Reference in New Issue
Block a user