diff --git a/packages/app/src/components/agent-input-area.tsx b/packages/app/src/components/agent-input-area.tsx index ef8f5d592..f8f9398bd 100644 --- a/packages/app/src/components/agent-input-area.tsx +++ b/packages/app/src/components/agent-input-area.tsx @@ -125,6 +125,7 @@ export function AgentInputArea({ agentId, serverId }: AgentInputAreaProps) { on: () => () => {}, sendPing: () => {}, sendUserMessage: () => {}, + clearAgentAttention: () => {}, subscribeConnectionStatus: () => () => {}, getConnectionState: () => ({ isConnected: false, isConnecting: false }), }), diff --git a/packages/app/src/components/agent-list.tsx b/packages/app/src/components/agent-list.tsx index a6e3612b3..26a8f08b8 100644 --- a/packages/app/src/components/agent-list.tsx +++ b/packages/app/src/components/agent-list.tsx @@ -1,5 +1,5 @@ -import { View, Text, Pressable, FlatList, Modal, RefreshControl, type ListRenderItem } from "react-native"; -import { useCallback, useState } from "react"; +import { View, Text, Pressable, SectionList, Modal, RefreshControl, type SectionListRenderItem } from "react-native"; +import { useCallback, useState, useMemo } from "react"; import { router } from "expo-router"; import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { formatTimeAgo } from "@/utils/time"; @@ -15,10 +15,39 @@ interface AgentListProps { onRefresh?: () => void; } +interface AgentSection { + title: string; + data: AggregatedAgent[]; +} + export function AgentList({ agents, isRefreshing = false, onRefresh }: AgentListProps) { const { theme } = useUnistyles(); const [actionAgent, setActionAgent] = useState(null); + // Group agents by attention status + const sections = useMemo(() => { + const requiresAttention: AggregatedAgent[] = []; + const normal: AggregatedAgent[] = []; + + for (const agent of agents) { + if (agent.requiresAttention) { + requiresAttention.push(agent); + } else { + normal.push(agent); + } + } + + const result: AgentSection[] = []; + if (requiresAttention.length > 0) { + result.push({ title: "Requires Attention", data: requiresAttention }); + } + if (normal.length > 0) { + result.push({ title: "All Agents", data: normal }); + } + + return result; + }, [agents]); + // Get the methods for the specific server const methods = useSessionStore((state) => actionAgent?.serverId ? state.sessions[actionAgent.serverId]?.methods : undefined @@ -33,6 +62,13 @@ export function AgentList({ agents, isRefreshing = false, onRefresh }: AgentList if (isActionSheetVisible) { return; } + + // Clear attention flag when opening agent + const session = useSessionStore.getState().sessions[serverId]; + if (session?.ws) { + session.ws.clearAgentAttention(agentId); + } + const navigationKey = buildAgentNavigationKey(serverId, agentId); startNavigationTiming(navigationKey, { from: "home", @@ -66,75 +102,75 @@ export function AgentList({ agents, isRefreshing = false, onRefresh }: AgentList setActionAgent(null); }, [actionAgent, deleteAgent]); - const renderAgentItem = useCallback>( + const renderAgentItem = useCallback>( ({ item: agent }) => { - const statusColor = getAgentStatusColor(agent.status); - const statusLabel = getAgentStatusLabel(agent.status); - const timeAgo = formatTimeAgo(agent.lastActivityAt); - const providerLabel = getAgentProviderDefinition(agent.provider).label; + const statusColor = getAgentStatusColor(agent.status); + const statusLabel = getAgentStatusLabel(agent.status); + const timeAgo = formatTimeAgo(agent.lastActivityAt); + const providerLabel = getAgentProviderDefinition(agent.provider).label; - return ( - [ - styles.agentItem, - pressed && styles.agentItemPressed, - ]} - onPress={() => handleAgentPress(agent.serverId, agent.id)} - onLongPress={() => handleAgentLongPress(agent)} - > - - + return ( + [ + styles.agentItem, + pressed && styles.agentItemPressed, + ]} + onPress={() => handleAgentPress(agent.serverId, agent.id)} + onLongPress={() => handleAgentLongPress(agent)} + > + + + + {agent.title || "New Agent"} + + - {agent.title || "New Agent"} + {agent.serverLabel} - + + + + + {agent.cwd} + + + + + - {agent.serverLabel} + {providerLabel} + + + + + + + {statusLabel} - - {agent.cwd} + + {timeAgo} - - - - - - {providerLabel} - - - - - - - {statusLabel} - - - - - - {timeAgo} - - - - ); - }, + + + ); + }, [handleAgentLongPress, handleAgentPress, theme.colors.muted, theme.colors.mutedForeground] ); @@ -143,16 +179,27 @@ export function AgentList({ agents, isRefreshing = false, onRefresh }: AgentList [] ); + const renderSectionHeader = useCallback( + ({ section }: { section: AgentSection }) => ( + + {section.title} + + ), + [] + ); + return ( <> - ({ paddingTop: theme.spacing[4], paddingBottom: theme.spacing[4], }, + sectionHeader: { + paddingTop: theme.spacing[2], + paddingBottom: theme.spacing[2], + paddingHorizontal: theme.spacing[2], + }, + sectionTitle: { + fontSize: theme.fontSize.sm, + fontWeight: theme.fontWeight.semibold, + color: theme.colors.mutedForeground, + textTransform: "uppercase", + }, agentItem: { paddingVertical: theme.spacing[4], paddingHorizontal: theme.spacing[4], diff --git a/packages/app/src/components/agent-stream-view.tsx b/packages/app/src/components/agent-stream-view.tsx index cdd494f42..80b52722c 100644 --- a/packages/app/src/components/agent-stream-view.tsx +++ b/packages/app/src/components/agent-stream-view.tsx @@ -94,6 +94,7 @@ export function AgentStreamView({ on: () => () => {}, sendPing: () => {}, sendUserMessage: () => {}, + clearAgentAttention: () => {}, }), [] ); diff --git a/packages/app/src/components/create-agent-modal.tsx b/packages/app/src/components/create-agent-modal.tsx index 1a395c436..29b05db02 100644 --- a/packages/app/src/components/create-agent-modal.tsx +++ b/packages/app/src/components/create-agent-modal.tsx @@ -334,6 +334,7 @@ function AgentFlowModal({ on: () => () => {}, sendPing: () => {}, sendUserMessage: () => {}, + clearAgentAttention: () => {}, subscribeConnectionStatus: () => () => {}, getConnectionState: () => ({ isConnected: false, isConnecting: false }), }), diff --git a/packages/app/src/contexts/session-context.tsx b/packages/app/src/contexts/session-context.tsx index bd04588d0..4f2bddc40 100644 --- a/packages/app/src/contexts/session-context.tsx +++ b/packages/app/src/contexts/session-context.tsx @@ -114,6 +114,7 @@ function normalizeAgentSnapshot(snapshot: AgentSnapshotPayload, serverId: string const createdAt = new Date(snapshot.createdAt); const updatedAt = new Date(snapshot.updatedAt); const lastUserMessageAt = snapshot.lastUserMessageAt ? new Date(snapshot.lastUserMessageAt) : null; + const attentionTimestamp = snapshot.attentionTimestamp ? new Date(snapshot.attentionTimestamp) : null; return { serverId, id: snapshot.id, @@ -134,6 +135,9 @@ function normalizeAgentSnapshot(snapshot: AgentSnapshotPayload, serverId: string title: snapshot.title ?? null, cwd: snapshot.cwd, model: snapshot.model ?? null, + requiresAttention: snapshot.requiresAttention ?? false, + attentionReason: snapshot.attentionReason ?? null, + attentionTimestamp, }; } diff --git a/packages/app/src/hooks/use-aggregated-agents.ts b/packages/app/src/hooks/use-aggregated-agents.ts index a499c86b3..034bb282c 100644 --- a/packages/app/src/hooks/use-aggregated-agents.ts +++ b/packages/app/src/hooks/use-aggregated-agents.ts @@ -71,6 +71,9 @@ export function useAggregatedAgents(): AggregatedAgentsResult { lastActivityAt: agent.lastActivityAt, cwd: agent.cwd, provider: agent.provider, + requiresAttention: agent.requiresAttention, + attentionReason: agent.attentionReason, + attentionTimestamp: agent.attentionTimestamp, }); } } diff --git a/packages/app/src/hooks/use-websocket.ts b/packages/app/src/hooks/use-websocket.ts index fd0a49813..9a2c1de7a 100644 --- a/packages/app/src/hooks/use-websocket.ts +++ b/packages/app/src/hooks/use-websocket.ts @@ -23,6 +23,7 @@ export interface UseWebSocketReturn { ) => () => void; sendPing: () => void; sendUserMessage: (message: string) => void; + clearAgentAttention: (agentId: string) => void; subscribeConnectionStatus?: (listener: (status: ConnectionStatusSnapshot) => void) => () => void; getConnectionState?: () => ConnectionStatusSnapshot; } @@ -289,6 +290,19 @@ export function useWebSocket(url: string, conversationId?: string | null): UseWe [send] ); + const clearAgentAttention = useCallback( + (agentId: string) => { + send({ + type: "session", + message: { + type: "clear_agent_attention", + agentId, + }, + }); + }, + [send] + ); + const subscribeConnectionStatus = useCallback( (listener: (status: ConnectionStatusSnapshot) => void) => { connectionListenersRef.current.add(listener); @@ -318,6 +332,7 @@ export function useWebSocket(url: string, conversationId?: string | null): UseWe on, sendPing, sendUserMessage, + clearAgentAttention, subscribeConnectionStatus, getConnectionState, }), @@ -330,6 +345,7 @@ export function useWebSocket(url: string, conversationId?: string | null): UseWe on, sendPing, sendUserMessage, + clearAgentAttention, subscribeConnectionStatus, getConnectionState, ] diff --git a/packages/app/src/stores/session-store.ts b/packages/app/src/stores/session-store.ts index 17a907566..a63c8fa91 100644 --- a/packages/app/src/stores/session-store.ts +++ b/packages/app/src/stores/session-store.ts @@ -94,6 +94,9 @@ export interface Agent { title: string | null; cwd: string; model: string | null; + requiresAttention?: boolean; + attentionReason?: "finished" | "error" | "permission" | null; + attentionTimestamp?: Date | null; } export interface Command { @@ -816,6 +819,9 @@ export const useSessionStore = create()( lastActivityAt, cwd: agent.cwd, provider: agent.provider, + requiresAttention: agent.requiresAttention ?? false, + attentionReason: agent.attentionReason ?? null, + attentionTimestamp: agent.attentionTimestamp ?? null, }); } return entries; diff --git a/packages/app/src/types/agent-directory.ts b/packages/app/src/types/agent-directory.ts index e1b7e8ee9..82e1e868e 100644 --- a/packages/app/src/types/agent-directory.ts +++ b/packages/app/src/types/agent-directory.ts @@ -9,4 +9,7 @@ export interface AgentDirectoryEntry { lastActivityAt: Date; cwd: string; provider: AgentProvider; + requiresAttention?: boolean; + attentionReason?: "finished" | "error" | "permission" | null; + attentionTimestamp?: Date | null; } diff --git a/packages/app/src/types/stream.ts b/packages/app/src/types/stream.ts index 2555bf8d0..268f5279a 100644 --- a/packages/app/src/types/stream.ts +++ b/packages/app/src/types/stream.ts @@ -819,6 +819,7 @@ export function reduceStreamUpdate( case "turn_failed": case "permission_requested": case "permission_resolved": + case "attention_required": return finalizeActiveThoughts(state); default: return state; diff --git a/packages/server/src/server/agent/agent-manager.ts b/packages/server/src/server/agent/agent-manager.ts index 631dda625..db5c29522 100644 --- a/packages/server/src/server/agent/agent-manager.ts +++ b/packages/server/src/server/agent/agent-manager.ts @@ -66,6 +66,14 @@ export type WaitForAgentResult = { lastMessage: string | null; }; +type AttentionState = + | { requiresAttention: false } + | { + requiresAttention: true; + attentionReason: "finished" | "error" | "permission"; + attentionTimestamp: Date; + }; + type ManagedAgentBase = { id: string; provider: AgentProvider; @@ -84,6 +92,7 @@ type ManagedAgentBase = { sessionId: string | null; lastUsage?: AgentUsage; lastError?: string; + attention: AttentionState; }; type ManagedAgentWithSession = ManagedAgentBase & { @@ -166,6 +175,7 @@ export class AgentManager { private readonly maxTimelineItems: number; private readonly idFactory: () => string; private readonly registry?: AgentRegistry; + private readonly previousStatuses = new Map(); constructor(options?: AgentManagerOptions) { this.maxTimelineItems = @@ -343,6 +353,8 @@ export class AgentManager { async closeAgent(agentId: string): Promise { const agent = this.requireAgent(agentId); this.agents.delete(agentId); + // Clean up previousStatus to prevent memory leak + this.previousStatuses.delete(agentId); const session = agent.session; const closedAgent: ManagedAgent = { ...agent, @@ -362,6 +374,15 @@ export class AgentManager { this.emitState(agent); } + async clearAgentAttention(agentId: string): Promise { + const agent = this.requireAgent(agentId); + if (agent.attention.requiresAttention) { + agent.attention = { requiresAttention: false }; + await this.persistSnapshot(agent); + this.emitState(agent); + } + } + async runAgent( agentId: string, prompt: AgentPromptInput, @@ -702,9 +723,12 @@ export class AgentManager { persistence: session.describePersistence(), historyPrimed: false, lastUserMessageAt: null, + attention: { requiresAttention: false }, } as ActiveManagedAgent; this.agents.set(agentId, managed); + // Initialize previousStatus to track transitions + this.previousStatuses.set(agentId, managed.lifecycle); await this.persistSnapshot(managed, { title: config.title ?? null, }); @@ -820,12 +844,76 @@ export class AgentManager { } private emitState(agent: ManagedAgent): void { + // Check if attention should be set based on status change + this.checkAndSetAttention(agent); + this.dispatch({ type: "agent_state", agent: { ...agent }, }); } + private checkAndSetAttention(agent: ManagedAgent): void { + const previousStatus = this.previousStatuses.get(agent.id); + const currentStatus = agent.lifecycle; + + // Track the new status + this.previousStatuses.set(agent.id, currentStatus); + + // Skip if already requires attention + if (agent.attention.requiresAttention) { + return; + } + + // Check if agent transitioned from running to idle (finished) + if (previousStatus === "running" && currentStatus === "idle") { + agent.attention = { + requiresAttention: true, + attentionReason: "finished", + attentionTimestamp: new Date(), + }; + this.broadcastAgentAttention(agent, "finished"); + void this.persistSnapshot(agent); + return; + } + + // Check if agent entered error state + if (currentStatus === "error") { + agent.attention = { + requiresAttention: true, + attentionReason: "error", + attentionTimestamp: new Date(), + }; + this.broadcastAgentAttention(agent, "error"); + void this.persistSnapshot(agent); + return; + } + + // Check if agent has pending permissions + if (agent.pendingPermissions.size > 0) { + agent.attention = { + requiresAttention: true, + attentionReason: "permission", + attentionTimestamp: new Date(), + }; + this.broadcastAgentAttention(agent, "permission"); + void this.persistSnapshot(agent); + return; + } + } + + private broadcastAgentAttention( + agent: ManagedAgent, + reason: "finished" | "error" | "permission" + ): void { + this.dispatchStream(agent.id, { + type: "attention_required", + provider: agent.provider, + reason, + timestamp: new Date().toISOString(), + }); + } + private dispatchStream(agentId: string, event: AgentStreamEvent): void { this.dispatch({ type: "agent_stream", agentId, event }); } diff --git a/packages/server/src/server/agent/agent-projections.ts b/packages/server/src/server/agent/agent-projections.ts index c5a724377..92a91e89a 100644 --- a/packages/server/src/server/agent/agent-projections.ts +++ b/packages/server/src/server/agent/agent-projections.ts @@ -43,6 +43,13 @@ export function toStoredAgentRecord( lastModeId: agent.currentModeId ?? config?.modeId ?? null, config: config ?? null, persistence, + requiresAttention: agent.attention.requiresAttention, + attentionReason: agent.attention.requiresAttention + ? agent.attention.attentionReason + : null, + attentionTimestamp: agent.attention.requiresAttention + ? agent.attention.attentionTimestamp.toISOString() + : null, } satisfies StoredAgentRecord; } @@ -79,6 +86,16 @@ export function toAgentPayload( payload.lastError = agent.lastError; } + // Handle attention state + payload.requiresAttention = agent.attention.requiresAttention; + if (agent.attention.requiresAttention) { + payload.attentionReason = agent.attention.attentionReason; + payload.attentionTimestamp = agent.attention.attentionTimestamp.toISOString(); + } else { + payload.attentionReason = null; + payload.attentionTimestamp = null; + } + return payload; } diff --git a/packages/server/src/server/agent/agent-registry.ts b/packages/server/src/server/agent/agent-registry.ts index 90e0e80c2..c1a898a77 100644 --- a/packages/server/src/server/agent/agent-registry.ts +++ b/packages/server/src/server/agent/agent-registry.ts @@ -41,6 +41,9 @@ const STORED_AGENT_SCHEMA = z.object({ lastModeId: z.string().nullable().optional(), config: SERIALIZABLE_CONFIG_SCHEMA, persistence: PERSISTENCE_HANDLE_SCHEMA, + requiresAttention: z.boolean().optional(), + attentionReason: z.enum(["finished", "error", "permission"]).nullable().optional(), + attentionTimestamp: z.string().nullable().optional(), }); export type SerializableAgentConfig = Pick< diff --git a/packages/server/src/server/agent/agent-sdk-types.ts b/packages/server/src/server/agent/agent-sdk-types.ts index 30f07124f..17b906fe3 100644 --- a/packages/server/src/server/agent/agent-sdk-types.ts +++ b/packages/server/src/server/agent/agent-sdk-types.ts @@ -80,6 +80,12 @@ export type AgentStreamEvent = provider: AgentProvider; requestId: string; resolution: AgentPermissionResponse; + } + | { + type: "attention_required"; + provider: AgentProvider; + reason: "finished" | "error" | "permission"; + timestamp: string; }; export type AgentPermissionRequestKind = "tool" | "plan" | "mode" | "other"; diff --git a/packages/server/src/server/messages.ts b/packages/server/src/server/messages.ts index e48f695e2..a3987e68d 100644 --- a/packages/server/src/server/messages.ts +++ b/packages/server/src/server/messages.ts @@ -196,6 +196,12 @@ export const AgentStreamEventPayloadSchema = z.discriminatedUnion("type", [ requestId: z.string(), resolution: AgentPermissionResponseSchema, }), + z.object({ + type: z.literal("attention_required"), + provider: AgentProviderSchema, + reason: z.enum(["finished", "error", "permission"]), + timestamp: z.string(), + }), ]); const AgentPersistenceHandleSchema: z.ZodType = @@ -226,6 +232,9 @@ export const AgentSnapshotPayloadSchema = z.object({ lastUsage: AgentUsageSchema.optional(), lastError: z.string().optional(), title: z.string().nullable(), + requiresAttention: z.boolean().optional(), + attentionReason: z.enum(["finished", "error", "permission"]).nullable().optional(), + attentionTimestamp: z.string().nullable().optional(), }); export type AgentSnapshotPayload = z.infer; @@ -448,6 +457,11 @@ export const FileExplorerRequestSchema = z.object({ mode: z.enum(["list", "file"]), }); +export const ClearAgentAttentionMessageSchema = z.object({ + type: z.literal("clear_agent_attention"), + agentId: z.string(), +}); + export const SessionInboundMessageSchema = z.discriminatedUnion("type", [ UserTextMessageSchema, RealtimeAudioChunkMessageSchema, @@ -473,6 +487,7 @@ export const SessionInboundMessageSchema = z.discriminatedUnion("type", [ FileExplorerRequestSchema, ListPersistedAgentsRequestMessageSchema, GitRepoInfoRequestMessageSchema, + ClearAgentAttentionMessageSchema, ]); export type SessionInboundMessage = z.infer; @@ -811,6 +826,7 @@ export type GitDiffResponse = z.infer; export type FileExplorerRequest = z.infer; export type FileExplorerResponse = z.infer; export type RestartServerRequestMessage = z.infer; +export type ClearAgentAttentionMessage = z.infer; // ============================================================================ // WebSocket Level Messages (wraps session messages) diff --git a/packages/server/src/server/session.ts b/packages/server/src/server/session.ts index d6d78e1b7..253bbb2bd 100644 --- a/packages/server/src/server/session.ts +++ b/packages/server/src/server/session.ts @@ -837,6 +837,10 @@ export class Session { case "list_provider_models_request": await this.handleListProviderModelsRequest(msg); break; + + case "clear_agent_attention": + await this.handleClearAgentAttention(msg.agentId); + break; } } catch (error: any) { console.error( @@ -1844,6 +1848,25 @@ export class Session { } } + /** + * Handle clearing agent attention flag + */ + private async handleClearAgentAttention(agentId: string): Promise { + console.log( + `[Session ${this.clientId}] Clearing attention for agent ${agentId}` + ); + + try { + await this.agentManager.clearAgentAttention(agentId); + } catch (error: any) { + console.error( + `[Session ${this.clientId}] Failed to clear agent attention:`, + error + ); + // Don't throw - this is not critical + } + } + /** * Handle agent permission response from user */