diff --git a/packages/app/src/components/agent-status-bar.tsx b/packages/app/src/components/agent-status-bar.tsx index da6081a34..ea3a2a59b 100644 --- a/packages/app/src/components/agent-status-bar.tsx +++ b/packages/app/src/components/agent-status-bar.tsx @@ -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 ( {/* Agent Mode Badge */} @@ -62,14 +44,6 @@ export function AgentStatusBar({ agentId }: AgentStatusBarProps) { )} - {/* Agent Status Indicator - just a dot */} - - {/* Mode selector modal */} ({ 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, - }, })); diff --git a/packages/app/src/components/agent-stream-view.tsx b/packages/app/src/components/agent-stream-view.tsx index 1587e4d06..b721b4a55 100644 --- a/packages/app/src/components/agent-stream-view.tsx +++ b/packages/app/src/components/agent-stream-view.tsx @@ -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 ( + + {pendingPermissionItems.length > 0 ? ( + + {pendingPermissionItems.map((permission) => ( + + ))} + + ) : null} + + {showWorkingIndicator ? ( + + + + ) : null} + + ); + }, [onPermissionResponse, pendingPermissionItems, showWorkingIndicator]); + const flatListData = useMemo(() => { return [...streamItems].reverse(); }, [streamItems]); + const flatListExtraData = useMemo( + () => ({ + pendingPermissionCount: pendingPermissionItems.length, + showWorkingIndicator, + }), + [pendingPermissionItems.length, showWorkingIndicator] + ); + return ( } - ListHeaderComponent={ - pendingPermissionItems.length > 0 ? ( - - {pendingPermissionItems.map((permission) => ( - - ))} - - ) : 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 ( + + Working + + + + + + + ); +} + // 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%", diff --git a/plan.md b/plan.md index 26de30490..8ccff894c 100644 --- a/plan.md +++ b/plan.md @@ -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 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. -- [ ] 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.