From 11e7bc5fcc92ea782ba91df5e29c2d3334256447 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Thu, 25 Dec 2025 21:22:24 +0700 Subject: [PATCH] Fix: Clear attention when agent finishes while user is viewing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a user is viewing an agent screen and the agent transitions from running to idle, immediately call clearAgentAttention since the user witnessed the completion. This prevents the "requires attention" state from appearing when the user navigates back to the home screen. Added a useEffect in AgentScreenContent that: 1. Tracks the previous agent status via a ref 2. Detects transitions from "running" to "idle" 3. Clears attention immediately on such transitions 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- .../src/app/agent/[serverId]/[agentId].tsx | 250 ++++++++++++++---- 1 file changed, 195 insertions(+), 55 deletions(-) diff --git a/packages/app/src/app/agent/[serverId]/[agentId].tsx b/packages/app/src/app/agent/[serverId]/[agentId].tsx index 237d1ed1b..8cf009b82 100644 --- a/packages/app/src/app/agent/[serverId]/[agentId].tsx +++ b/packages/app/src/app/agent/[serverId]/[agentId].tsx @@ -14,15 +14,29 @@ import { useLocalSearchParams, useRouter } from "expo-router"; import { useFocusEffect } from "@react-navigation/native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import { useReanimatedKeyboardAnimation } from "react-native-keyboard-controller"; -import ReanimatedAnimated, { useAnimatedStyle, useSharedValue } from "react-native-reanimated"; +import ReanimatedAnimated, { + useAnimatedStyle, + useSharedValue, +} from "react-native-reanimated"; import { StyleSheet, useUnistyles } from "react-native-unistyles"; -import { MoreVertical, GitBranch, Folder, RotateCcw, PlusCircle, Download, Users, ChevronRight } from "lucide-react-native"; +import { + MoreVertical, + GitBranch, + Folder, + RotateCcw, + Plus, + Download, + Users, + ChevronRight, + PlusIcon, +} from "lucide-react-native"; import { BackHeader } from "@/components/headers/back-header"; import { AgentStreamView } from "@/components/agent-stream-view"; import { AgentInputArea } from "@/components/agent-input-area"; import { AgentList } from "@/components/agent-list"; import { useAggregatedAgents } from "@/hooks/use-aggregated-agents"; -import { CreateAgentModal, ImportAgentModal, type CreateAgentInitialValues } from "@/components/create-agent-modal"; +import { CreateAgentModal, ImportAgentModal } from "@/components/create-agent-modal"; +import type { CreateAgentInitialValues } from "@/hooks/use-agent-form-state"; import { useDaemonConnections } from "@/contexts/daemon-connections-context"; import type { ConnectionStatus } from "@/contexts/daemon-connections-context"; import { formatConnectionStatus } from "@/utils/daemons"; @@ -51,7 +65,10 @@ type BranchStatus = "idle" | "loading" | "ready" | "error"; export default function AgentScreen() { const router = useRouter(); - const { serverId, agentId } = useLocalSearchParams<{ serverId: string; agentId: string }>(); + const { serverId, agentId } = useLocalSearchParams<{ + serverId: string; + agentId: string; + }>(); const resolvedAgentId = typeof agentId === "string" ? agentId : undefined; const resolvedServerId = typeof serverId === "string" ? serverId : undefined; const { connectionStates } = useDaemonConnections(); @@ -62,10 +79,14 @@ export default function AgentScreen() { ); const connectionServerId = resolvedServerId ?? null; - const connection = connectionServerId ? connectionStates.get(connectionServerId) : null; - const serverLabel = connection?.daemon.label ?? connectionServerId ?? "Selected host"; + const connection = connectionServerId + ? connectionStates.get(connectionServerId) + : null; + const serverLabel = + connection?.daemon.label ?? connectionServerId ?? "Selected host"; const isUnknownDaemon = Boolean(connectionServerId && !connection); - const connectionStatus = connection?.status ?? (isUnknownDaemon ? "offline" : "idle"); + const connectionStatus = + connection?.status ?? (isUnknownDaemon ? "offline" : "idle"); const connectionStatusLabel = formatConnectionStatus(connectionStatus); const lastConnectionError = connection?.lastError ?? null; @@ -100,7 +121,10 @@ export default function AgentScreen() { if (!resolvedAgentId || !focusServerId) { return; } - const navigationKey = buildAgentNavigationKey(focusServerId, resolvedAgentId); + const navigationKey = buildAgentNavigationKey( + focusServerId, + resolvedAgentId + ); endNavigationTiming(navigationKey, { screen: "agent", status: navigationStatus, @@ -139,28 +163,39 @@ type AgentScreenContentProps = { const SIDEBAR_WIDTH = 280; const LARGE_SCREEN_BREAKPOINT = 768; -function AgentScreenContent({ serverId, agentId, onBack }: AgentScreenContentProps) { +function AgentScreenContent({ + serverId, + agentId, + onBack, +}: AgentScreenContentProps) { const { theme } = useUnistyles(); const insets = useSafeAreaInsets(); const router = useRouter(); const { width: windowWidth, height: windowHeight } = useWindowDimensions(); const isLargeScreen = windowWidth >= LARGE_SCREEN_BREAKPOINT; - const { agents: aggregatedAgents, isRevalidating, refreshAll } = useAggregatedAgents(); + const { + agents: aggregatedAgents, + isRevalidating, + refreshAll, + } = useAggregatedAgents(); const [menuVisible, setMenuVisible] = useState(false); const [menuPosition, setMenuPosition] = useState({ top: 0, left: 0 }); const [menuContentHeight, setMenuContentHeight] = useState(0); const menuButtonRef = useRef(null); const [showCreateAgentModal, setShowCreateAgentModal] = useState(false); const [showImportAgentModal, setShowImportAgentModal] = useState(false); - const [createAgentInitialValues, setCreateAgentInitialValues] = - useState(); + const [createAgentInitialValues, setCreateAgentInitialValues] = useState< + CreateAgentInitialValues | undefined + >(); const resolvedAgentId = agentId; // Select only the specific agent const agent = useSessionStore((state) => - resolvedAgentId ? state.sessions[serverId]?.agents?.get(resolvedAgentId) : undefined + resolvedAgentId + ? state.sessions[serverId]?.agents?.get(resolvedAgentId) + : undefined ); // Get parent agent ID for back navigation @@ -184,7 +219,9 @@ function AgentScreenContent({ serverId, agentId, onBack }: AgentScreenContentPro }, [parentAgentId, router, serverId, onBack]); // Select the agents Map directly - this is a stable reference that only changes when agents are added/removed - const allAgents = useSessionStore((state) => state.sessions[serverId]?.agents); + const allAgents = useSessionStore( + (state) => state.sessions[serverId]?.agents + ); // Derive child agents in useMemo to avoid creating new references on every store update // This prevents infinite loops caused by useShallow not doing deep equality on object arrays @@ -198,7 +235,12 @@ function AgentScreenContent({ serverId, agentId, onBack }: AgentScreenContentPro }> = []; for (const [id, a] of allAgents) { if (a.parentAgentId === resolvedAgentId) { - children.push({ id, title: a.title, createdAt: a.createdAt, status: a.status }); + children.push({ + id, + title: a.title, + createdAt: a.createdAt, + status: a.status, + }); } } children.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime()); @@ -207,18 +249,23 @@ function AgentScreenContent({ serverId, agentId, onBack }: AgentScreenContentPro // Select only the specific stream state - use stable empty array to avoid infinite loop const streamItemsRaw = useSessionStore((state) => - resolvedAgentId ? state.sessions[serverId]?.agentStreamState?.get(resolvedAgentId) : undefined + resolvedAgentId + ? state.sessions[serverId]?.agentStreamState?.get(resolvedAgentId) + : undefined ); const streamItems = streamItemsRaw ?? EMPTY_STREAM_ITEMS; // Select only the specific initializing state const isInitializingFromMap = useSessionStore((state) => - resolvedAgentId ? (state.sessions[serverId]?.initializingAgents?.get(resolvedAgentId) ?? false) : false + resolvedAgentId + ? state.sessions[serverId]?.initializingAgents?.get(resolvedAgentId) ?? + false + : false ); // Select raw pending permissions - filter with useMemo to avoid new Map on every render - const allPendingPermissions = useSessionStore((state) => - state.sessions[serverId]?.pendingPermissions + const allPendingPermissions = useSessionStore( + (state) => state.sessions[serverId]?.pendingPermissions ); const pendingPermissions = useMemo(() => { if (!allPendingPermissions || !resolvedAgentId) return new Map(); @@ -288,16 +335,17 @@ function AgentScreenContent({ serverId, agentId, onBack }: AgentScreenContentPro message.payload.error ? new Error(message.payload.error) : null, keepPreviousData: false, }); - const { execute: fetchGitRepoInfo, reset: resetGitRepoInfo } = gitRepoInfoRequest; + const { execute: fetchGitRepoInfo, reset: resetGitRepoInfo } = + gitRepoInfoRequest; const branchStatus: BranchStatus = !agent?.cwd ? "idle" : gitRepoInfoRequest.status === "loading" - ? "loading" - : gitRepoInfoRequest.status === "error" - ? "error" - : gitRepoInfoRequest.status === "success" - ? "ready" - : "idle"; + ? "loading" + : gitRepoInfoRequest.status === "error" + ? "error" + : gitRepoInfoRequest.status === "success" + ? "ready" + : "idle"; const branchLabel = gitRepoInfoRequest.data?.currentBranch ?? null; const branchError = gitRepoInfoRequest.error?.message ?? null; const branchDisplayValue = @@ -325,7 +373,9 @@ function AgentScreenContent({ serverId, agentId, onBack }: AgentScreenContentPro }; }, [resolvedAgentId, setFocusedAgentId]); - const isInitializing = resolvedAgentId ? isInitializingFromMap !== false : false; + const isInitializing = resolvedAgentId + ? isInitializingFromMap !== false + : false; // Track which agent we've already triggered initialization for const initializedAgentRef = useRef(null); @@ -366,6 +416,26 @@ function AgentScreenContent({ serverId, agentId, onBack }: AgentScreenContentPro document.title = title; }, [agent?.title]); + // Track previous agent status to detect completion while viewing + const previousStatusRef = useRef(null); + + // Clear attention when agent finishes while user is viewing this screen + useEffect(() => { + if (!resolvedAgentId || !agent || !ws) { + return; + } + + const previousStatus = previousStatusRef.current; + const currentStatus = agent.status; + previousStatusRef.current = currentStatus; + + // If agent transitioned from running to idle while we're viewing, + // immediately clear attention since user witnessed the completion + if (previousStatus === "running" && currentStatus === "idle") { + ws.clearAgentAttention(resolvedAgentId); + } + }, [resolvedAgentId, agent?.status, ws]); + const recalculateMenuPosition = useCallback( (onMeasured?: () => void) => { requestAnimationFrame(() => { @@ -383,7 +453,10 @@ function AgentScreenContent({ serverId, agentId, onBack }: AgentScreenContentPro const horizontalMargin = 16; const desiredLeft = x + width - DROPDOWN_WIDTH; const maxLeft = windowWidth - DROPDOWN_WIDTH - horizontalMargin; - const clampedLeft = Math.min(Math.max(desiredLeft, horizontalMargin), maxLeft); + const clampedLeft = Math.min( + Math.max(desiredLeft, horizontalMargin), + maxLeft + ); // Position menu below button using the raw coordinates from measureInWindow const buttonBottom = y + height; @@ -392,9 +465,10 @@ function AgentScreenContent({ serverId, agentId, onBack }: AgentScreenContentPro // If menu would go off screen, clamp to visible area const bottomEdge = top + menuContentHeight; const maxBottom = windowHeight - horizontalMargin; - const clampedTop = bottomEdge > maxBottom - ? Math.max(verticalOffset, maxBottom - menuContentHeight) - : top; + const clampedTop = + bottomEdge > maxBottom + ? Math.max(verticalOffset, maxBottom - menuContentHeight) + : top; setMenuPosition({ top: clampedTop, @@ -477,14 +551,21 @@ function AgentScreenContent({ serverId, agentId, onBack }: AgentScreenContentPro return; } handleCloseMenu(); - setCreateAgentInitialValues({ - workingDir: agent.cwd, - provider: agent.provider, - modeId: agent.currentModeId, - model: agentModel ?? undefined, - }); - setShowCreateAgentModal(true); - }, [agent, agentModel, handleCloseMenu]); + const params: Record = { serverId }; + if (agent.cwd) { + params.workingDir = agent.cwd; + } + if (agent.provider) { + params.provider = agent.provider; + } + if (agent.currentModeId) { + params.modeId = agent.currentModeId; + } + if (agentModel) { + params.model = agentModel; + } + router.push({ pathname: "/agent/new", params }); + }, [agent, agentModel, handleCloseMenu, router, serverId]); const handleCloseCreateAgentModal = useCallback(() => { setShowCreateAgentModal(false); @@ -548,10 +629,31 @@ function AgentScreenContent({ serverId, agentId, onBack }: AgentScreenContentPro return ( <> - + {/* Sidebar - only on large screens */} {isLargeScreen && ( + + + + + New Agent + + + - + {isInitializing ? ( - + Loading agent... ) : ( @@ -656,13 +763,17 @@ function AgentScreenContent({ serverId, agentId, onBack }: AgentScreenContentPro size="small" color={theme.colors.mutedForeground} /> - Fetching… + + Fetching… + ) : ( ) : null} - + ))} @@ -718,19 +832,19 @@ function AgentScreenContent({ serverId, agentId, onBack }: AgentScreenContentPro - + View Changes - + Browse Files - + Import Agent - + New Agent - Cannot open this agent because {serverLabel} is not configured on this device. + Cannot open this agent because {serverLabel} is not configured on + this device. - Add the host in Settings or open an agent on a configured server to continue. + Add the host in Settings or open an agent on a configured server to + continue. @@ -803,8 +919,12 @@ function AgentSessionUnavailableState({ {isConnecting ? ( <> - Connecting to {serverLabel}... - We will show this agent once the host is online. + + Connecting to {serverLabel}... + + + We will show this agent once the host is online. + ) : ( <> @@ -812,9 +932,12 @@ function AgentSessionUnavailableState({ {serverLabel} is currently {connectionStatusLabel.toLowerCase()}. - We'll reconnect automatically and show this agent as soon as the host comes back online. + We'll reconnect automatically and show this agent as soon as the + host comes back online. - {lastError ? {lastError} : null} + {lastError ? ( + {lastError} + ) : null} )} @@ -837,6 +960,23 @@ const styles = StyleSheet.create((theme) => ({ borderRightWidth: 1, borderRightColor: theme.colors.border, }, + sidebarHeader: { + paddingHorizontal: theme.spacing[4], + paddingTop: theme.spacing[4], + paddingBottom: theme.spacing[2], + }, + newAgentButton: { + flexDirection: "row", + alignItems: "center", + justifyContent: "center", + gap: theme.spacing[2], + paddingVertical: theme.spacing[3], + borderRadius: theme.borderRadius.lg, + }, + newAgentButtonText: { + fontSize: theme.fontSize.base, + fontWeight: theme.fontWeight.normal, + }, agentPanel: { flex: 1, },