diff --git a/packages/app/src/components/agent-input-area.tsx b/packages/app/src/components/agent-input-area.tsx index 7be46513d..30f3c1c35 100644 --- a/packages/app/src/components/agent-input-area.tsx +++ b/packages/app/src/components/agent-input-area.tsx @@ -39,7 +39,6 @@ import { markScrollInvestigationRender } from '@/utils/scroll-jank-investigation import { useKeyboardShiftStyle } from '@/hooks/use-keyboard-shift-style' import { useKeyboardActionHandler } from '@/hooks/use-keyboard-action-handler' import type { KeyboardActionDefinition } from '@/keyboard/keyboard-action-dispatcher' -import { shouldClearAgentAttention } from '@/utils/agent-attention' type QueuedMessage = { id: string @@ -67,11 +66,15 @@ interface AgentInputAreaProps { /** Called when a message is about to be sent (any path: keyboard, dictation, queued). */ onMessageSent?: () => void onComposerHeightChange?: (height: number) => void + onAttentionInputFocus?: () => void + onAttentionPromptSend?: () => void /** Controlled status controls rendered in input area (draft flows). */ statusControls?: DraftAgentStatusBarProps } const EMPTY_ARRAY: readonly QueuedMessage[] = [] +const DESKTOP_MESSAGE_PLACEHOLDER = 'Message the agent, tag @files, or use /commands and /skills' +const MOBILE_MESSAGE_PLACEHOLDER = 'Message, @files, /commands' export function AgentInputArea({ agentId, @@ -87,6 +90,8 @@ export function AgentInputArea({ commandDraftConfig, onMessageSent, onComposerHeightChange, + onAttentionInputFocus, + onAttentionPromptSend, statusControls, }: AgentInputAreaProps) { markScrollInvestigationRender(`AgentInputArea:${serverId}:${agentId}`) @@ -127,6 +132,9 @@ export function AgentInputArea({ Platform.OS === 'web' && UnistylesRuntime.breakpoint !== 'xs' && UnistylesRuntime.breakpoint !== 'sm' + const messagePlaceholder = isDesktopWebBreakpoint + ? DESKTOP_MESSAGE_PLACEHOLDER + : MOBILE_MESSAGE_PLACEHOLDER const userInput = value ?? internalInput const setUserInput = onChangeText ?? setInternalInput const [cursorIndex, setCursorIndex] = useState(0) @@ -243,18 +251,9 @@ export function AgentInputArea({ messageId: clientMessageId, ...(imagesData && imagesData.length > 0 ? { images: imagesData } : {}), }) - if ( - shouldClearAgentAttention({ - agentId, - isConnected, - requiresAttention: agent?.requiresAttention, - attentionReason: agent?.attentionReason, - }) - ) { - client.clearAgentAttention(agentId) - } + onAttentionPromptSend?.() } - }, [agent?.attentionReason, agent?.requiresAttention, client, isConnected, serverId, setAgentStreamTail, setAgentStreamHead]) + }, [client, onAttentionPromptSend, serverId, setAgentStreamTail, setAgentStreamHead]) useEffect(() => { onSubmitMessageRef.current = onSubmitMessage @@ -808,7 +807,7 @@ export function AgentInputArea({ onRemoveImage={handleRemoveImage} client={client} isReadyForDictation={isDictationReady} - placeholder="Message the agent, tag @files, or use /commands and /skills" + placeholder={messagePlaceholder} autoFocus={autoFocus && isDesktopWebBreakpoint} autoFocusKey={`${serverId}:${agentId}`} disabled={isSubmitLoading} @@ -824,7 +823,12 @@ export function AgentInputArea({ onSelectionChange={(selection) => { setCursorIndex(selection.start) }} - onFocusChange={setIsMessageInputFocused} + onFocusChange={(focused) => { + setIsMessageInputFocused(focused) + if (focused) { + onAttentionInputFocus?.() + } + }} onHeightChange={onComposerHeightChange} /> diff --git a/packages/app/src/components/agent-list.tsx b/packages/app/src/components/agent-list.tsx index ce8219b91..cde799af8 100644 --- a/packages/app/src/components/agent-list.tsx +++ b/packages/app/src/components/agent-list.tsx @@ -4,37 +4,23 @@ import { Pressable, Modal, RefreshControl, - SectionList, - type ViewToken, - type SectionListRenderItem, + FlatList, + type ListRenderItem, } from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import { useCallback, useMemo, useState, type ReactElement } from "react"; import { router, usePathname, type Href } from "expo-router"; -import { StyleSheet, useUnistyles } from "react-native-unistyles"; -import { useQueryClient } from "@tanstack/react-query"; +import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles"; import { formatTimeAgo } from "@/utils/time"; import { shortenPath } from "@/utils/shorten-path"; -import { deriveBranchLabel, deriveProjectPath } from "@/utils/agent-display-info"; import { type AggregatedAgent } from "@/hooks/use-aggregated-agents"; import { useSessionStore } from "@/stores/session-store"; -import { - getHostRuntimeStore, - isHostRuntimeConnected, -} from "@/runtime/host-runtime"; import { AgentStatusDot } from "@/components/agent-status-dot"; -import { - CHECKOUT_STATUS_STALE_TIME, - checkoutStatusQueryKey, - useCheckoutStatusCacheOnly, -} from "@/hooks/use-checkout-status-query"; import { buildAgentNavigationKey, startNavigationTiming, } from "@/utils/navigation-timing"; -import { - buildHostWorkspaceAgentRoute, -} from "@/utils/host-routes"; +import { buildHostWorkspaceAgentRoute } from "@/utils/host-routes"; interface AgentListProps { agents: AggregatedAgent[]; @@ -52,6 +38,25 @@ interface AgentListSection { data: AggregatedAgent[]; } +type SessionColumnKey = "session" | "project" | "host" | "status" | "updated"; + +interface SessionColumnDefinition { + key: SessionColumnKey; + label: string; + flex: number; + align?: "left" | "right"; + mobile?: boolean; + requiresMultiHost?: boolean; +} + +const SESSION_COLUMNS: SessionColumnDefinition[] = [ + { key: "session", label: "Session", flex: 2.3, mobile: true }, + { key: "project", label: "Project", flex: 2.6 }, + { key: "host", label: "Host", flex: 1.2, requiresMultiHost: true }, + { key: "status", label: "Status", flex: 1.2, mobile: true }, + { key: "updated", label: "Updated", flex: 1, align: "right", mobile: true }, +]; + function deriveDateSectionLabel(lastActivityAt: Date): string { const now = new Date(); const todayStart = new Date(now.getFullYear(), now.getMonth(), now.getDate()); @@ -80,76 +85,290 @@ function deriveDateSectionLabel(lastActivityAt: Date): string { return "Older"; } -interface AgentListRowProps { - agent: AggregatedAgent; - selectedAgentId?: string; - showCheckoutInfo: boolean; - onPress: (agent: AggregatedAgent) => void; - onLongPress: (agent: AggregatedAgent) => void; +function formatStatusLabel(status: AggregatedAgent["status"]): string { + switch (status) { + case "initializing": + return "Starting"; + case "idle": + return "Idle"; + case "running": + return "Running"; + case "error": + return "Error"; + case "closed": + return "Closed"; + default: + return status; + } } -function AgentListRow({ +function getVisibleColumns(input: { + isMobile: boolean; + showHostColumn: boolean; +}): SessionColumnDefinition[] { + return SESSION_COLUMNS.filter((column) => { + if (!input.showHostColumn && column.requiresMultiHost) { + return false; + } + if (input.isMobile && !column.mobile) { + return false; + } + return true; + }); +} + +function SessionCell({ + align = "left", + flex, + children, +}: { + align?: "left" | "right"; + flex: number; + children: ReactElement; +}) { + return ( + + {children} + + ); +} + +function SessionBadge({ + label, + tone = "neutral", +}: { + label: string; + tone?: "neutral" | "warning" | "danger"; +}) { + return ( + + + {label} + + + ); +} + +function SessionTableRow({ agent, + columns, + isMobile, selectedAgentId, - showCheckoutInfo, onPress, onLongPress, -}: AgentListRowProps) { +}: { + agent: AggregatedAgent; + columns: SessionColumnDefinition[]; + isMobile: boolean; + selectedAgentId?: string; + onPress: (agent: AggregatedAgent) => void; + onLongPress: (agent: AggregatedAgent) => void; +}) { const timeAgo = formatTimeAgo(agent.lastActivityAt); const agentKey = `${agent.serverId}:${agent.id}`; const isSelected = selectedAgentId === agentKey; - const archivedLabel = agent.archivedAt ? "Archived" : null; - const checkoutQuery = useCheckoutStatusCacheOnly({ - serverId: agent.serverId, - cwd: agent.cwd, - }); - const checkout = checkoutQuery.data ?? null; - const projectPath = showCheckoutInfo - ? deriveProjectPath(agent.cwd, checkout) - : agent.cwd; - const branchLabel = showCheckoutInfo ? deriveBranchLabel(checkout) : null; + const statusLabel = formatStatusLabel(agent.status); + const projectPath = shortenPath(agent.cwd); return ( [ - styles.agentItem, - isSelected && styles.agentItemSelected, - hovered && styles.agentItemHovered, - pressed && styles.agentItemPressed, + styles.row, + isSelected && styles.rowSelected, + hovered && styles.rowHovered, + pressed && styles.rowPressed, ]} onPress={() => onPress(agent)} onLongPress={() => onLongPress(agent)} testID={`agent-row-${agent.serverId}-${agent.id}`} > {({ hovered }) => ( - - - - - {agent.title || "New agent"} - - + + {columns.map((column) => { + if (column.key === "session") { + return ( + + + + + {agent.title || "New session"} + + {agent.archivedAt ? : null} + {(agent.pendingPermissionCount ?? 0) > 0 ? ( + + ) : null} + + {isMobile ? ( + + + {projectPath} + + · + {statusLabel} + {agent.serverLabel ? ( + <> + · + + {agent.serverLabel} + + + ) : null} + + ) : ( + + {agent.requiresAttention ? ( + + ) : null} + + )} + + + ); + } - - {shortenPath(projectPath)} - {branchLabel ? ` · ${branchLabel}` : ""} - {archivedLabel ? ` · ${archivedLabel}` : ""} · {timeAgo} - + if (column.key === "project") { + return ( + + + + {projectPath} + + + {agent.provider} + + + + ); + } + + if (column.key === "host") { + return ( + + + {agent.serverLabel} + + + ); + } + + if (column.key === "status") { + return ( + + + + + {statusLabel} + + + + ); + } + + return ( + + + {timeAgo} + + + ); + })} )} ); } +function SessionTableSection({ + section, + columns, + isMobile, + selectedAgentId, + onAgentPress, + onAgentLongPress, +}: { + section: AgentListSection; + columns: SessionColumnDefinition[]; + isMobile: boolean; + selectedAgentId?: string; + onAgentPress: (agent: AggregatedAgent) => void; + onAgentLongPress: (agent: AggregatedAgent) => void; +}) { + return ( + + + {section.title} + + + + + + {columns.map((column) => ( + + + {column.label} + + + ))} + + + {section.data.map((agent, index) => ( + 0 ? styles.rowDivider : undefined} + > + + + ))} + + + ); +} + export function AgentList({ agents, - showCheckoutInfo = true, isRefreshing = false, onRefresh, selectedAgentId, @@ -158,9 +377,10 @@ export function AgentList({ }: AgentListProps) { const { theme } = useUnistyles(); const pathname = usePathname(); - const queryClient = useQueryClient(); const insets = useSafeAreaInsets(); const [actionAgent, setActionAgent] = useState(null); + const isMobile = + UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm"; const actionClient = useSessionStore((state) => actionAgent?.serverId ? state.sessions[actionAgent.serverId]?.client ?? null : null @@ -168,6 +388,14 @@ export function AgentList({ const isActionSheetVisible = actionAgent !== null; const isActionDaemonUnavailable = Boolean(actionAgent?.serverId && !actionClient); + const showHostColumn = useMemo( + () => new Set(agents.map((agent) => agent.serverId)).size > 1, + [agents] + ); + const columns = useMemo( + () => getVisibleColumns({ isMobile, showHostColumn }), + [isMobile, showHostColumn] + ); const handleAgentPress = useCallback( (agent: AggregatedAgent) => { @@ -215,51 +443,6 @@ export function AgentList({ setActionAgent(null); }, [actionAgent, actionClient]); - const viewabilityConfig = useMemo( - () => ({ itemVisiblePercentThreshold: 30 }), - [] - ); - - const onViewableItemsChanged = useCallback( - ({ viewableItems }: { viewableItems: Array }) => { - if (!showCheckoutInfo) { - return; - } - for (const token of viewableItems) { - const agent = token.item as AggregatedAgent | undefined; - if (!agent) { - continue; - } - - const runtime = getHostRuntimeStore(); - const client = runtime.getClient(agent.serverId); - const isConnected = isHostRuntimeConnected(runtime.getSnapshot(agent.serverId)); - if (!client || !isConnected) { - continue; - } - - const queryKey = checkoutStatusQueryKey(agent.serverId, agent.cwd); - const queryState = queryClient.getQueryState(queryKey); - const isFetching = queryState?.fetchStatus === "fetching"; - const isFresh = - typeof queryState?.dataUpdatedAt === "number" && - Date.now() - queryState.dataUpdatedAt < CHECKOUT_STATUS_STALE_TIME; - if (isFetching || isFresh) { - continue; - } - - void queryClient.prefetchQuery({ - queryKey, - queryFn: async () => await client.getCheckoutStatus(agent.cwd), - staleTime: CHECKOUT_STATUS_STALE_TIME, - }).catch((error) => { - console.warn("[checkout_status] prefetch failed", error); - }); - } - }, - [queryClient, showCheckoutInfo] - ); - const sections = useMemo((): AgentListSection[] => { const order = ["Today", "Yesterday", "This week", "This month", "Older"] as const; const buckets = new Map(); @@ -281,55 +464,33 @@ export function AgentList({ return result; }, [agents]); - const renderAgentItem: SectionListRenderItem = - useCallback( - ({ item: agent }) => ( - - ), - [handleAgentLongPress, handleAgentPress, selectedAgentId, showCheckoutInfo] - ); - - const renderSectionHeader = useCallback( - ({ section }: { section: AgentListSection }) => ( - - {section.title} - + const renderSection: ListRenderItem = useCallback( + ({ item: section }) => ( + ), - [] + [columns, handleAgentLongPress, handleAgentPress, isMobile, selectedAgentId] ); - const keyExtractor = useCallback( - (agent: AggregatedAgent) => `${agent.serverId}:${agent.id}`, - [] - ); + const keyExtractor = useCallback((section: AgentListSection) => section.key, []); return ( <> - - + - {isActionDaemonUnavailable - ? "Host offline" - : "Archive this agent?"} + {isActionDaemonUnavailable ? "Host offline" : "Archive this session?"} ({ minHeight: 0, }, listContent: { - paddingHorizontal: theme.spacing[4], + paddingHorizontal: { + xs: theme.spacing[3], + md: theme.spacing[6], + }, paddingTop: theme.spacing[2], - paddingBottom: theme.spacing[4], + paddingBottom: theme.spacing[6], + gap: theme.spacing[1], }, - sectionHeader: { - paddingVertical: theme.spacing[2], - paddingHorizontal: theme.spacing[3], + sectionBlock: { marginTop: theme.spacing[2], }, + sectionHeading: { + flexDirection: "row", + alignItems: "center", + gap: theme.spacing[3], + paddingHorizontal: theme.spacing[1], + marginBottom: theme.spacing[2], + }, sectionTitle: { fontSize: theme.fontSize.sm, - fontWeight: "500", + fontWeight: "600", color: theme.colors.foregroundMuted, - textAlign: "left", + textTransform: "uppercase", + letterSpacing: 0.6, }, - agentItem: { - paddingVertical: theme.spacing[2], - paddingHorizontal: theme.spacing[3], - borderRadius: theme.borderRadius.lg, - marginBottom: theme.spacing[1], - }, - agentItemSelected: { + sectionLine: { + flex: 1, + height: StyleSheet.hairlineWidth, backgroundColor: theme.colors.surface2, }, - agentItemHovered: { + tableCard: { + overflow: "hidden", + borderRadius: theme.borderRadius.xl, + borderWidth: StyleSheet.hairlineWidth, + borderColor: theme.colors.surface2, backgroundColor: theme.colors.surface1, }, - agentItemPressed: { - backgroundColor: theme.colors.surface2, + tableHeader: { + flexDirection: "row", + alignItems: "center", + paddingHorizontal: { + xs: theme.spacing[3], + md: theme.spacing[4], + }, + paddingVertical: theme.spacing[2], + backgroundColor: theme.colors.surface0, + borderBottomWidth: StyleSheet.hairlineWidth, + borderBottomColor: theme.colors.surface2, }, - agentContent: { - flex: 1, - gap: theme.spacing[0], + columnLabel: { + fontSize: theme.fontSize.xs, + fontWeight: "600", + color: theme.colors.foregroundMuted, + textTransform: "uppercase", + letterSpacing: 0.6, + }, + columnLabelRight: { + textAlign: "right", + }, + rowDivider: { + borderTopWidth: StyleSheet.hairlineWidth, + borderTopColor: theme.colors.surface2, }, row: { + paddingHorizontal: { + xs: theme.spacing[3], + md: theme.spacing[4], + }, + paddingVertical: { + xs: theme.spacing[2], + md: theme.spacing[3], + }, + }, + rowInner: { + flexDirection: "row", + alignItems: "center", + gap: theme.spacing[3], + }, + rowSelected: { + backgroundColor: theme.colors.surface2, + }, + rowHovered: { + backgroundColor: theme.colors.surface0, + }, + rowPressed: { + backgroundColor: theme.colors.surface2, + }, + cell: { + minWidth: 0, + }, + cellLeft: { + alignItems: "flex-start", + }, + cellRight: { + alignItems: "flex-end", + }, + primaryCell: { + width: "100%", + gap: theme.spacing[1], + }, + sessionTitleRow: { + flexDirection: "row", + alignItems: "center", + flexWrap: "wrap", + gap: theme.spacing[2], + }, + sessionTitle: { + flexShrink: 1, + fontSize: theme.fontSize.base, + fontWeight: "500", + color: theme.colors.foreground, + opacity: 0.86, + }, + sessionTitleHighlighted: { + opacity: 1, + }, + sessionMetaRow: { + flexDirection: "row", + alignItems: "center", + flexWrap: "wrap", + gap: theme.spacing[1], + }, + sessionMetaText: { + maxWidth: "100%", + fontSize: theme.fontSize.sm, + color: theme.colors.foregroundMuted, + }, + sessionMetaSeparator: { + fontSize: theme.fontSize.sm, + color: theme.colors.foregroundMuted, + opacity: 0.7, + }, + secondaryBadgeRow: { + minHeight: theme.spacing[6], + justifyContent: "center", + }, + projectCell: { + width: "100%", + gap: theme.spacing[1], + }, + projectPath: { + fontSize: theme.fontSize.sm, + color: theme.colors.foreground, + }, + projectProvider: { + fontSize: theme.fontSize.xs, + color: theme.colors.foregroundMuted, + textTransform: "uppercase", + letterSpacing: 0.6, + }, + hostText: { + fontSize: theme.fontSize.sm, + color: theme.colors.foreground, + }, + statusCell: { flexDirection: "row", alignItems: "center", gap: theme.spacing[2], }, - agentTitle: { - flex: 1, - fontSize: theme.fontSize.base, - fontWeight: "400", - color: theme.colors.foreground, - opacity: 0.8, - }, - agentTitleHighlighted: { - color: theme.colors.foreground, - opacity: 1, - }, - secondaryRow: { + statusText: { + fontSize: theme.fontSize.sm, + color: theme.colors.foreground, + }, + updatedText: { fontSize: theme.fontSize.sm, - fontWeight: "300", color: theme.colors.foregroundMuted, + textAlign: "right", + }, + badge: { + paddingHorizontal: theme.spacing[2], + paddingVertical: theme.spacing[1], + borderRadius: theme.borderRadius.full, + backgroundColor: theme.colors.surface2, + }, + badgeWarning: { + backgroundColor: "rgba(245, 158, 11, 0.12)", + }, + badgeDanger: { + backgroundColor: "rgba(239, 68, 68, 0.14)", + }, + badgeText: { + fontSize: theme.fontSize.xs, + fontWeight: "600", + color: theme.colors.foregroundMuted, + textTransform: "uppercase", + letterSpacing: 0.4, + }, + badgeTextWarning: { + color: theme.colors.palette.amber[500], + }, + badgeTextDanger: { + color: theme.colors.palette.red[300], }, sheetOverlay: { flex: 1, diff --git a/packages/app/src/components/file-pane.tsx b/packages/app/src/components/file-pane.tsx index ff699d036..e42514849 100644 --- a/packages/app/src/components/file-pane.tsx +++ b/packages/app/src/components/file-pane.tsx @@ -41,10 +41,12 @@ function FilePreviewBody({ preview, isLoading, showDesktopWebScrollbar, + isMobile, }: { preview: ExplorerFile | null; isLoading: boolean; showDesktopWebScrollbar: boolean; + isMobile: boolean; }) { const enablePreviewDesktopScrollbar = showDesktopWebScrollbar; const previewScrollRef = useRef(null); @@ -101,14 +103,20 @@ function FilePreviewBody({ scrollEventThrottle={enablePreviewDesktopScrollbar ? 16 : undefined} showsVerticalScrollIndicator={!enablePreviewDesktopScrollbar} > - - {preview.content} - + {isMobile ? ( + + {preview.content} + + ) : ( + + {preview.content} + + )} ); diff --git a/packages/app/src/components/left-sidebar.tsx b/packages/app/src/components/left-sidebar.tsx index 60e2ed55e..06c7607e1 100644 --- a/packages/app/src/components/left-sidebar.tsx +++ b/packages/app/src/components/left-sidebar.tsx @@ -10,7 +10,7 @@ import Animated, { } from 'react-native-reanimated' import { Gesture, GestureDetector } from 'react-native-gesture-handler' import { StyleSheet, UnistylesRuntime, useUnistyles } from 'react-native-unistyles' -import { Plus, Settings, Users } from 'lucide-react-native' +import { MessagesSquare, Plus, Settings } from 'lucide-react-native' import { router, usePathname } from 'expo-router' import { usePanelStore } from '@/stores/panel-store' import { SidebarWorkspaceList } from './sidebar-workspace-list' @@ -389,12 +389,12 @@ export function LeftSidebar({ selectedAgentId: _selectedAgentId }: LeftSidebarPr nativeID="sidebar-all-agents" collapsable={false} accessible - accessibilityLabel="All agents" + accessibilityLabel="Sessions" accessibilityRole="button" onPress={handleViewMore} > {({ hovered }) => ( - @@ -509,12 +509,12 @@ export function LeftSidebar({ selectedAgentId: _selectedAgentId }: LeftSidebarPr nativeID="sidebar-all-agents" collapsable={false} accessible - accessibilityLabel="All agents" + accessibilityLabel="Sessions" accessibilityRole="button" onPress={handleViewMore} > {({ hovered }) => ( - diff --git a/packages/app/src/components/message-input.tsx b/packages/app/src/components/message-input.tsx index 130287c31..039a76822 100644 --- a/packages/app/src/components/message-input.tsx +++ b/packages/app/src/components/message-input.tsx @@ -719,6 +719,7 @@ export const MessageInput = forwardRef(funct onChangeText={handleInputChange} placeholder={placeholder} placeholderTextColor={theme.colors.surface4} + accessibilityLabel="Message agent..." onFocus={() => { isInputFocusedRef.current = true onFocusChange?.(true) diff --git a/packages/app/src/components/sidebar-workspace-list.tsx b/packages/app/src/components/sidebar-workspace-list.tsx index 06f8404e0..3a128d789 100644 --- a/packages/app/src/components/sidebar-workspace-list.tsx +++ b/packages/app/src/components/sidebar-workspace-list.tsx @@ -147,6 +147,11 @@ function WorkspaceStatusIndicator({ const { theme } = useUnistyles() const color = resolveStatusDotColor({ theme, bucket }) const shouldShowSyncedLoader = shouldRenderSyncedStatusLoader({ bucket }) + const shouldRenderIdlePlaceholder = !loading && !shouldShowSyncedLoader && bucket === 'done' + + if (shouldRenderIdlePlaceholder) { + return null + } return ( @@ -1548,7 +1553,10 @@ const styles = StyleSheet.create((theme) => ({ marginBottom: theme.spacing[1], }, workspaceListContainer: { - marginLeft: 0, + marginLeft: theme.spacing[3], + paddingLeft: theme.spacing[2], + borderLeftWidth: StyleSheet.hairlineWidth, + borderLeftColor: theme.colors.surface1, }, emptyText: { color: theme.colors.foregroundMuted, @@ -1668,7 +1676,7 @@ const styles = StyleSheet.create((theme) => ({ workspaceRowLeft: { flexDirection: 'row', alignItems: 'center', - gap: theme.spacing[2], + gap: theme.spacing[1], flex: 1, minWidth: 0, }, @@ -1703,11 +1711,11 @@ const styles = StyleSheet.create((theme) => ({ position: 'relative', }, workspaceStatusDot: { - width: 16, + width: 11, height: 16, borderRadius: theme.borderRadius.full, flexShrink: 0, - alignItems: 'center', + alignItems: 'flex-start', justifyContent: 'center', }, workspaceStatusDotFill: { diff --git a/packages/app/src/hooks/use-agent-attention-clear.ts b/packages/app/src/hooks/use-agent-attention-clear.ts new file mode 100644 index 000000000..ee2c1b803 --- /dev/null +++ b/packages/app/src/hooks/use-agent-attention-clear.ts @@ -0,0 +1,152 @@ +import { useCallback, useEffect, useRef, useState } from "react"; +import { AppState, Platform } from "react-native"; +import type { DaemonClient } from "@server/client/daemon-client"; +import { + shouldClearAgentAttention, + type AgentAttentionClearTrigger, +} from "@/utils/agent-attention"; + +type AttentionReason = "finished" | "error" | "permission" | null | undefined; + +interface UseAgentAttentionClearParams { + agentId: string | null | undefined; + client: DaemonClient | null; + isConnected: boolean; + requiresAttention: boolean | null | undefined; + attentionReason: AttentionReason; + isScreenFocused: boolean; +} + +interface AgentAttentionClearController { + clearOnInputFocus: () => void; + clearOnPromptSend: () => void; + clearOnAgentBlur: () => void; +} + +function getIsAppVisible(): boolean { + const isAppStateActive = AppState.currentState === "active"; + if (Platform.OS !== "web") { + return isAppStateActive; + } + const documentVisible = + typeof document === "undefined" || document.visibilityState === "visible"; + const windowFocused = + typeof document === "undefined" || + typeof document.hasFocus !== "function" || + document.hasFocus(); + return isAppStateActive && documentVisible && windowFocused; +} + +export function useAgentAttentionClear({ + agentId, + client, + isConnected, + requiresAttention, + attentionReason, + isScreenFocused, +}: UseAgentAttentionClearParams): AgentAttentionClearController { + const [isAppVisible, setIsAppVisible] = useState(() => getIsAppVisible()); + const deferredFocusEntryClearRef = useRef(false); + const prevRequiresAttentionRef = useRef(Boolean(requiresAttention)); + const prevActivelyViewedRef = useRef(isScreenFocused && getIsAppVisible()); + const prevScreenFocusedRef = useRef(false); + const prevAppVisibleRef = useRef(getIsAppVisible()); + + const clearAttention = useCallback( + (trigger: AgentAttentionClearTrigger) => { + const resolvedAgentId = agentId?.trim(); + if (!client || !resolvedAgentId) { + return; + } + if ( + !shouldClearAgentAttention({ + agentId: resolvedAgentId, + isConnected, + requiresAttention, + attentionReason, + trigger, + hasDeferredFocusEntryClear: deferredFocusEntryClearRef.current, + }) + ) { + return; + } + deferredFocusEntryClearRef.current = false; + client.clearAgentAttention(resolvedAgentId); + }, + [agentId, attentionReason, client, isConnected, requiresAttention] + ); + + useEffect(() => { + const updateVisibility = () => { + setIsAppVisible(getIsAppVisible()); + }; + + const appStateSubscription = AppState.addEventListener( + "change", + updateVisibility + ); + + if (Platform.OS === "web" && typeof document !== "undefined") { + document.addEventListener("visibilitychange", updateVisibility); + window.addEventListener("focus", updateVisibility); + window.addEventListener("blur", updateVisibility); + + return () => { + appStateSubscription.remove(); + document.removeEventListener("visibilitychange", updateVisibility); + window.removeEventListener("focus", updateVisibility); + window.removeEventListener("blur", updateVisibility); + }; + } + + return () => { + appStateSubscription.remove(); + }; + }, []); + + useEffect(() => { + if (!requiresAttention) { + deferredFocusEntryClearRef.current = false; + } + }, [requiresAttention]); + + useEffect(() => { + const isActivelyViewed = isScreenFocused && isAppVisible; + if ( + !prevRequiresAttentionRef.current && + Boolean(requiresAttention) && + prevActivelyViewedRef.current && + isActivelyViewed + ) { + deferredFocusEntryClearRef.current = true; + } + prevRequiresAttentionRef.current = Boolean(requiresAttention); + prevActivelyViewedRef.current = isActivelyViewed; + }, [isAppVisible, isScreenFocused, requiresAttention]); + + useEffect(() => { + const enteredScreenFocus = + !prevScreenFocusedRef.current && isScreenFocused && isAppVisible; + const resumedIntoFocusedAgent = + !prevAppVisibleRef.current && isAppVisible && isScreenFocused; + + if (enteredScreenFocus || resumedIntoFocusedAgent) { + clearAttention("focus-entry"); + } + + prevScreenFocusedRef.current = isScreenFocused; + prevAppVisibleRef.current = isAppVisible; + }, [clearAttention, isAppVisible, isScreenFocused]); + + return { + clearOnInputFocus: useCallback(() => { + clearAttention("input-focus"); + }, [clearAttention]), + clearOnPromptSend: useCallback(() => { + clearAttention("prompt-send"); + }, [clearAttention]), + clearOnAgentBlur: useCallback(() => { + clearAttention("agent-blur"); + }, [clearAttention]), + }; +} diff --git a/packages/app/src/hooks/use-aggregated-agents.ts b/packages/app/src/hooks/use-aggregated-agents.ts index 30324af3b..6b9934260 100644 --- a/packages/app/src/hooks/use-aggregated-agents.ts +++ b/packages/app/src/hooks/use-aggregated-agents.ts @@ -19,9 +19,12 @@ export interface AggregatedAgentsResult { refreshAll: () => void; } -export function useAggregatedAgents(): AggregatedAgentsResult { +export function useAggregatedAgents(options?: { + includeArchived?: boolean; +}): AggregatedAgentsResult { const { daemons } = useDaemonRegistry(); const runtime = getHostRuntimeStore(); + const includeArchived = options?.includeArchived ?? false; const runtimeVersion = useSyncExternalStore( (onStoreChange) => runtime.subscribeAll(onStoreChange), () => runtime.getVersion(), @@ -55,7 +58,7 @@ export function useAggregatedAgents(): AggregatedAgentsResult { } const serverLabel = serverLabelById.get(serverId) ?? serverId; for (const agent of agents.values()) { - if (agent.archivedAt) { + if (!includeArchived && agent.archivedAt) { continue; } const nextAgent: AggregatedAgent = { @@ -112,7 +115,7 @@ export function useAggregatedAgents(): AggregatedAgentsResult { isInitialLoad, isRevalidating, }; - }, [daemons, runtime, runtimeVersion, sessionAgents]); + }, [daemons, includeArchived, runtime, runtimeVersion, sessionAgents]); return { ...result, diff --git a/packages/app/src/hooks/use-all-agents-list.test.ts b/packages/app/src/hooks/use-all-agents-list.test.ts new file mode 100644 index 000000000..1353dcd62 --- /dev/null +++ b/packages/app/src/hooks/use-all-agents-list.test.ts @@ -0,0 +1,79 @@ +import { describe, expect, it } from "vitest"; +import { __private__ } from "./use-all-agents-list"; +import type { Agent } from "@/stores/session-store"; + +function makeAgent(input?: Partial): Agent { + const timestamp = new Date("2026-03-08T10:00:00.000Z"); + return { + serverId: "server-1", + id: input?.id ?? "agent-1", + provider: input?.provider ?? "codex", + status: input?.status ?? "idle", + createdAt: input?.createdAt ?? timestamp, + updatedAt: input?.updatedAt ?? timestamp, + lastUserMessageAt: input?.lastUserMessageAt ?? null, + lastActivityAt: input?.lastActivityAt ?? timestamp, + capabilities: input?.capabilities ?? { + supportsStreaming: true, + supportsSessionPersistence: true, + supportsDynamicModes: true, + supportsMcpServers: true, + supportsReasoningStream: true, + supportsToolInvocations: true, + }, + currentModeId: input?.currentModeId ?? null, + availableModes: input?.availableModes ?? [], + pendingPermissions: input?.pendingPermissions ?? [], + persistence: input?.persistence ?? null, + runtimeInfo: input?.runtimeInfo, + lastUsage: input?.lastUsage, + lastError: input?.lastError ?? null, + title: input?.title ?? "Agent", + cwd: input?.cwd ?? "/tmp/project", + model: input?.model ?? null, + thinkingOptionId: input?.thinkingOptionId, + requiresAttention: input?.requiresAttention ?? false, + attentionReason: input?.attentionReason ?? null, + attentionTimestamp: input?.attentionTimestamp ?? null, + archivedAt: input?.archivedAt ?? null, + labels: input?.labels ?? {}, + projectPlacement: input?.projectPlacement ?? null, + }; +} + +describe("useAllAgentsList", () => { + it("excludes archived agents by default", () => { + const visibleAgent = makeAgent({ id: "visible" }); + const archivedAgent = makeAgent({ + id: "archived", + archivedAt: new Date("2026-03-08T11:00:00.000Z"), + }); + + const result = __private__.buildAllAgentsList({ + agents: [visibleAgent, archivedAgent], + serverId: "server-1", + serverLabel: "Local", + includeArchived: false, + }); + + expect(result.map((agent) => agent.id)).toEqual(["visible"]); + }); + + it("includes archived agents when requested", () => { + const visibleAgent = makeAgent({ id: "visible" }); + const archivedAgent = makeAgent({ + id: "archived", + archivedAt: new Date("2026-03-08T11:00:00.000Z"), + }); + + const result = __private__.buildAllAgentsList({ + agents: [visibleAgent, archivedAgent], + serverId: "server-1", + serverLabel: "Local", + includeArchived: true, + }); + + expect(result.map((agent) => agent.id)).toEqual(["visible", "archived"]); + expect(result[1]?.archivedAt).toEqual(archivedAgent.archivedAt); + }); +}); diff --git a/packages/app/src/hooks/use-all-agents-list.ts b/packages/app/src/hooks/use-all-agents-list.ts index cbb68103d..914941480 100644 --- a/packages/app/src/hooks/use-all-agents-list.ts +++ b/packages/app/src/hooks/use-all-agents-list.ts @@ -35,8 +35,44 @@ function toAggregatedAgent(params: { }; } +function buildAllAgentsList(params: { + agents: Iterable; + serverId: string; + serverLabel: string; + includeArchived: boolean; +}): AggregatedAgent[] { + const list: AggregatedAgent[] = []; + + for (const agent of params.agents) { + const aggregated = toAggregatedAgent({ + source: agent, + serverId: params.serverId, + serverLabel: params.serverLabel, + }); + if (!params.includeArchived && aggregated.archivedAt) { + continue; + } + list.push(aggregated); + } + + list.sort((left, right) => { + const leftRunning = left.status === "running"; + const rightRunning = right.status === "running"; + if (leftRunning && !rightRunning) { + return -1; + } + if (!leftRunning && rightRunning) { + return 1; + } + return right.lastActivityAt.getTime() - left.lastActivityAt.getTime(); + }); + + return list; +} + export function useAllAgentsList(options?: { serverId?: string | null; + includeArchived?: boolean; }): AggregatedAgentsResult { const { daemons } = useDaemonRegistry(); const runtime = getHostRuntimeStore(); @@ -47,6 +83,7 @@ export function useAllAgentsList(options?: { ? value.trim() : null; }, [options?.serverId]); + const includeArchived = options?.includeArchived ?? false; const liveAgents = useSessionStore((state) => serverId ? state.sessions[serverId]?.agents ?? null : null @@ -66,34 +103,13 @@ export function useAllAgentsList(options?: { } const serverLabel = daemons.find((daemon) => daemon.serverId === serverId)?.label ?? serverId; - const list: AggregatedAgent[] = []; - - for (const agent of liveAgents.values()) { - const aggregated = toAggregatedAgent({ - source: agent, - serverId, - serverLabel, - }); - if (aggregated.archivedAt) { - continue; - } - list.push(aggregated); - } - - list.sort((left, right) => { - const leftRunning = left.status === "running"; - const rightRunning = right.status === "running"; - if (leftRunning && !rightRunning) { - return -1; - } - if (!leftRunning && rightRunning) { - return 1; - } - return right.lastActivityAt.getTime() - left.lastActivityAt.getTime(); + return buildAllAgentsList({ + agents: liveAgents.values(), + serverId, + serverLabel, + includeArchived, }); - - return list; - }, [daemons, liveAgents, serverId]); + }, [daemons, includeArchived, liveAgents, serverId]); const isDirectoryLoading = Boolean(serverId && isHostRuntimeDirectoryLoading(snapshot)); const isInitialLoad = isDirectoryLoading && agents.length === 0; @@ -107,3 +123,8 @@ export function useAllAgentsList(options?: { refreshAll, }; } + +export const __private__ = { + buildAllAgentsList, + toAggregatedAgent, +}; diff --git a/packages/app/src/screens/agent/agent-ready-screen.tsx b/packages/app/src/screens/agent/agent-ready-screen.tsx index ac510dfaa..585288a68 100644 --- a/packages/app/src/screens/agent/agent-ready-screen.tsx +++ b/packages/app/src/screens/agent/agent-ready-screen.tsx @@ -6,7 +6,7 @@ import { Platform, BackHandler, } from "react-native"; -import { useFocusEffect } from "@react-navigation/native"; +import { useFocusEffect, useIsFocused } from "@react-navigation/native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import { useQueryClient } from "@tanstack/react-query"; import ReanimatedAnimated from "react-native-reanimated"; @@ -55,7 +55,7 @@ import { } from "@/utils/agent-snapshots"; import { mergePendingCreateImages } from "@/utils/pending-create-images"; import { useKeyboardShiftStyle } from "@/hooks/use-keyboard-shift-style"; -import { shouldClearAgentAttention } from "@/utils/agent-attention"; +import { useAgentAttentionClear } from "@/hooks/use-agent-attention-clear"; import type { DaemonClient } from "@server/client/daemon-client"; import { useExplorerOpenGesture } from "@/hooks/use-explorer-open-gesture"; import type { ExplorerCheckoutContext } from "@/stores/panel-store"; @@ -431,9 +431,7 @@ function AgentScreenContent({ const hasSession = useSessionStore( (state) => Boolean(state.sessions[serverId]) ); - const focusedAgentId = useSessionStore( - (state) => state.sessions[serverId]?.focusedAgentId ?? null - ); + const isScreenFocused = useIsFocused(); const { ensureAgentIsInitialized } = useAgentInitialization({ serverId, client: hasSession ? client : null, @@ -447,8 +445,6 @@ function AgentScreenContent({ routeKey: string; reason: "initial-entry" | "resume"; } | null>(null); - const attentionClientRef = useRef(client); - const attentionConnectedRef = useRef(isConnected); const setFocusedAgentId = useCallback( (agentId: string | null) => { useSessionStore.getState().setFocusedAgentId(serverId, agentId); @@ -456,10 +452,14 @@ function AgentScreenContent({ [serverId] ); - useEffect(() => { - attentionClientRef.current = client; - attentionConnectedRef.current = isConnected; - }, [client, isConnected]); + const attentionController = useAgentAttentionClear({ + agentId: resolvedAgentId, + client, + isConnected, + requiresAttention: agent?.requiresAttention, + attentionReason: agent?.attentionReason, + isScreenFocused, + }); const { style: animatedKeyboardStyle } = useKeyboardShiftStyle({ mode: "translate", @@ -534,33 +534,20 @@ function AgentScreenContent({ isArchivingAgent({ serverId, agentId: resolvedAgentId }) ); - useEffect(() => { - if (!resolvedAgentId) { - setFocusedAgentId(null); - return; - } - - setFocusedAgentId(resolvedAgentId); - return () => { - const latestClient = attentionClientRef.current; - const latestAgent = useSessionStore - .getState() - .sessions[serverId] - ?.agents.get(resolvedAgentId); - if ( - latestClient && - shouldClearAgentAttention({ - agentId: resolvedAgentId, - isConnected: attentionConnectedRef.current, - requiresAttention: latestAgent?.requiresAttention, - attentionReason: latestAgent?.attentionReason, - }) - ) { - latestClient.clearAgentAttention(resolvedAgentId); + useFocusEffect( + useCallback(() => { + if (!resolvedAgentId) { + setFocusedAgentId(null); + return; } - setFocusedAgentId(null); - }; - }, [resolvedAgentId, serverId, setFocusedAgentId]); + + setFocusedAgentId(resolvedAgentId); + return () => { + attentionController.clearOnAgentBlur(); + setFocusedAgentId(null); + }; + }, [attentionController, resolvedAgentId, setFocusedAgentId]) + ); const isInitializing = resolvedAgentId ? isInitializingFromMap !== false : false; const isHistorySyncing = useMemo(() => { @@ -956,6 +943,8 @@ function AgentScreenContent({ serverId={serverId} autoFocus isSubmitLoading={showPendingCreateSubmitLoading} + onAttentionInputFocus={attentionController.clearOnInputFocus} + onAttentionPromptSend={attentionController.clearOnPromptSend} onAddImages={handleAddImagesCallback} onComposerHeightChange={() => streamViewRef.current?.prepareForViewportChange() diff --git a/packages/app/src/screens/agents-screen.tsx b/packages/app/src/screens/agents-screen.tsx index 415d4752f..e816eeeb3 100644 --- a/packages/app/src/screens/agents-screen.tsx +++ b/packages/app/src/screens/agents-screen.tsx @@ -10,6 +10,7 @@ import { buildHostRootRoute } from "@/utils/host-routes"; export function AgentsScreen({ serverId }: { serverId: string }) { const { agents, isRevalidating, refreshAll } = useAllAgentsList({ serverId, + includeArchived: true, }); // Track user-initiated refresh to avoid showing spinner on background revalidation @@ -43,7 +44,7 @@ export function AgentsScreen({ serverId }: { serverId: string }) { return ( router.replace(buildHostRootRoute(serverId) as any)} /> { setHoveredTabKey(tab.key); @@ -273,11 +273,19 @@ export function WorkspaceDesktopTabsRow({ (hovered || pressed) && styles.tabCloseButtonActive, ]} > - {isClosingTab ? ( - - ) : ( - - )} + {({ hovered, pressed }) => + isClosingTab ? ( + + ) : ( + + ) + } ) : null} @@ -459,7 +467,7 @@ const styles = StyleSheet.create((theme) => ({ flexShrink: 0, }, tabActive: { - backgroundColor: theme.colors.surface2, + backgroundColor: theme.colors.surface3, }, tabHovered: { backgroundColor: theme.colors.surface2, diff --git a/packages/app/src/utils/agent-attention.test.ts b/packages/app/src/utils/agent-attention.test.ts index aac24e62e..df1aa0362 100644 --- a/packages/app/src/utils/agent-attention.test.ts +++ b/packages/app/src/utils/agent-attention.test.ts @@ -57,4 +57,52 @@ describe("shouldClearAgentAttention", () => { }) ).toBe(false); }); + + it("returns false for focus entry when clear was deferred while already focused", () => { + expect( + shouldClearAgentAttention({ + agentId: "agent-1", + isConnected: true, + requiresAttention: true, + attentionReason: "finished", + trigger: "focus-entry", + hasDeferredFocusEntryClear: true, + }) + ).toBe(false); + }); + + it("returns true for explicit follow-up entrypoints after deferred focus clear", () => { + expect( + shouldClearAgentAttention({ + agentId: "agent-1", + isConnected: true, + requiresAttention: true, + attentionReason: "finished", + trigger: "input-focus", + hasDeferredFocusEntryClear: true, + }) + ).toBe(true); + + expect( + shouldClearAgentAttention({ + agentId: "agent-1", + isConnected: true, + requiresAttention: true, + attentionReason: "finished", + trigger: "prompt-send", + hasDeferredFocusEntryClear: true, + }) + ).toBe(true); + + expect( + shouldClearAgentAttention({ + agentId: "agent-1", + isConnected: true, + requiresAttention: true, + attentionReason: "finished", + trigger: "agent-blur", + hasDeferredFocusEntryClear: true, + }) + ).toBe(true); + }); }); diff --git a/packages/app/src/utils/agent-attention.ts b/packages/app/src/utils/agent-attention.ts index 941bc6971..07bbaa463 100644 --- a/packages/app/src/utils/agent-attention.ts +++ b/packages/app/src/utils/agent-attention.ts @@ -3,8 +3,16 @@ interface ShouldClearAgentAttentionInput { isConnected: boolean; requiresAttention: boolean | null | undefined; attentionReason?: "finished" | "error" | "permission" | null | undefined; + trigger?: AgentAttentionClearTrigger; + hasDeferredFocusEntryClear?: boolean; } +export type AgentAttentionClearTrigger = + | "focus-entry" + | "input-focus" + | "prompt-send" + | "agent-blur"; + export function shouldClearAgentAttention( input: ShouldClearAgentAttentionInput ): boolean { @@ -21,5 +29,11 @@ export function shouldClearAgentAttention( if (input.attentionReason === "permission") { return false; } + if ( + input.trigger === "focus-entry" && + input.hasDeferredFocusEntryClear === true + ) { + return false; + } return true; } diff --git a/packages/server/src/server/agent/agent-management-mcp.ts b/packages/server/src/server/agent/agent-management-mcp.ts index 45f51c6b4..12b9c1e92 100644 --- a/packages/server/src/server/agent/agent-management-mcp.ts +++ b/packages/server/src/server/agent/agent-management-mcp.ts @@ -153,9 +153,16 @@ function startAgentRun( agentManager: AgentManager, agentId: string, prompt: AgentPromptInput, - logger: Logger + logger: Logger, + options?: { replaceRunning?: boolean } ): void { - const iterator = agentManager.streamAgent(agentId, prompt); + const snapshot = agentManager.getAgent(agentId); + const shouldReplace = + options?.replaceRunning && + Boolean(snapshot && (snapshot.lifecycle === "running" || snapshot.pendingRun)); + const iterator = shouldReplace + ? agentManager.replaceAgentRun(agentId, prompt) + : agentManager.streamAgent(agentId, prompt); void (async () => { try { for await (const _ of iterator) { @@ -565,44 +572,7 @@ export async function createAgentManagementMcpServer( } if (snapshot.lifecycle === "running" || snapshot.pendingRun) { - childLogger.debug( - { agentId }, - "Interrupting active run before sending new prompt" - ); - try { - const cancelled = await agentManager.cancelAgentRun(agentId); - if (!cancelled) { - childLogger.warn( - { agentId }, - "Agent reported running but no active run was cancelled" - ); - } - waitTracker.cancel(agentId, "Agent run interrupted by new prompt"); - - const maxWaitMs = 5000; - const pollIntervalMs = 50; - const startTime = Date.now(); - while (Date.now() - startTime < maxWaitMs) { - const current = agentManager.getAgent(agentId); - if (!current) { - throw new Error( - `Agent ${agentId} not found during cancellation wait` - ); - } - if (current.lifecycle !== "running" && !current.pendingRun) { - break; - } - await new Promise((resolve) => - setTimeout(resolve, pollIntervalMs) - ); - } - } catch (error) { - childLogger.error( - { err: error, agentId }, - "Failed to interrupt agent" - ); - throw error; - } + waitTracker.cancel(agentId, "Agent run interrupted by new prompt"); } if (sessionMode) { @@ -620,7 +590,9 @@ export async function createAgentManagementMcpServer( ); } - startAgentRun(agentManager, agentId, prompt, childLogger); + startAgentRun(agentManager, agentId, prompt, childLogger, { + replaceRunning: true, + }); if (!background) { const result = await waitForAgentWithTimeout(agentManager, agentId, { diff --git a/packages/server/src/server/agent/agent-manager.test.ts b/packages/server/src/server/agent/agent-manager.test.ts index 3910ad786..88a6f05c0 100644 --- a/packages/server/src/server/agent/agent-manager.test.ts +++ b/packages/server/src/server/agent/agent-manager.test.ts @@ -1029,6 +1029,115 @@ describe("AgentManager", () => { await consumePromise; }); + test("replaceAgentRun does not emit idle or resolve waiters between interrupted and replacement runs", async () => { + const workdir = mkdtempSync(join(tmpdir(), "agent-manager-replace-run-")); + const storagePath = join(workdir, "agents"); + const storage = new AgentStorage(storagePath, logger); + const allowFirstRunToEnd = deferred(); + const allowSecondRunToEnd = deferred(); + + class ReplaceRunSession extends TestAgentSession { + private streamCount = 0; + + override async *stream(): AsyncGenerator { + this.streamCount += 1; + + if (this.streamCount === 1) { + yield { type: "turn_started", provider: this.provider }; + await allowFirstRunToEnd.promise; + yield { type: "turn_completed", provider: this.provider }; + return; + } + + yield { type: "turn_started", provider: this.provider }; + await allowSecondRunToEnd.promise; + yield { type: "turn_completed", provider: this.provider }; + } + + override async interrupt(): Promise { + allowFirstRunToEnd.resolve(); + } + } + + class ReplaceRunClient extends TestAgentClient { + override async createSession(config: AgentSessionConfig): Promise { + return new ReplaceRunSession(config); + } + } + + const manager = new AgentManager({ + clients: { + codex: new ReplaceRunClient(), + }, + registry: storage, + logger, + idFactory: () => "00000000-0000-4000-8000-000000000125", + }); + + const snapshot = await manager.createAgent({ + provider: "codex", + cwd: workdir, + }); + + const lifecycleUpdates: string[] = []; + const unsubscribe = manager.subscribe( + (event) => { + if (event.type !== "agent_state" || event.agent.id !== snapshot.id) { + return; + } + lifecycleUpdates.push(event.agent.lifecycle); + }, + { agentId: snapshot.id, replayState: false } + ); + + const firstRun = manager.streamAgent(snapshot.id, "first run"); + const firstRunDrain = (async () => { + for await (const _event of firstRun) { + // Drain events so lifecycle updates are applied. + } + })(); + + await manager.waitForAgentRunStart(snapshot.id); + + const waitPromise = manager.waitForAgentEvent(snapshot.id); + const secondRun = manager.replaceAgentRun(snapshot.id, "second run"); + const secondRunDrain = (async () => { + for await (const _event of secondRun) { + // Drain replacement run. + } + })(); + + await manager.waitForAgentRunStart(snapshot.id); + + const prematureResolution = await Promise.race([ + waitPromise.then(() => "resolved"), + new Promise<"pending">((resolve) => setTimeout(() => resolve("pending"), 50)), + ]); + expect(prematureResolution).toBe("pending"); + + const runningIndexes = lifecycleUpdates.reduce((indexes, status, index) => { + if (status === "running") { + indexes.push(index); + } + return indexes; + }, []); + expect(runningIndexes.length).toBeGreaterThanOrEqual(2); + + const firstReplacementRunningIndex = runningIndexes[1]!; + expect( + lifecycleUpdates.slice(0, firstReplacementRunningIndex).includes("idle") + ).toBe(false); + + allowSecondRunToEnd.resolve(); + + const waited = await waitPromise; + expect(waited.status).toBe("idle"); + + await firstRunDrain; + await secondRunDrain; + unsubscribe(); + }); + test("applies live autonomous events while no foreground run is active", async () => { const workdir = mkdtempSync(join(tmpdir(), "agent-manager-live-events-")); const storagePath = join(workdir, "agents"); diff --git a/packages/server/src/server/agent/agent-manager.ts b/packages/server/src/server/agent/agent-manager.ts index 689ab962c..404894b8f 100644 --- a/packages/server/src/server/agent/agent-manager.ts +++ b/packages/server/src/server/agent/agent-manager.ts @@ -153,6 +153,7 @@ type ManagedAgentBase = { availableModes: AgentMode[]; currentModeId: string | null; pendingPermissions: Map; + pendingReplacement: boolean; timeline: AgentTimelineItem[]; timelineRows: AgentTimelineRow[]; timelineEpoch: string; @@ -1038,6 +1039,7 @@ export class AgentManager { const agent = existingAgent as ActiveManagedAgent; const iterator = agent.session.stream(prompt, options); + agent.pendingReplacement = false; agent.lastError = undefined; let finalized = false; @@ -1076,7 +1078,13 @@ export class AgentManager { const mutableAgent = agent as ActiveManagedAgent; mutableAgent.pendingRun = null; const terminalError = error ?? mutableAgent.lastError; - mutableAgent.lifecycle = terminalError ? "error" : "idle"; + const shouldHoldBusyForReplacement = + mutableAgent.pendingReplacement && !terminalError; + mutableAgent.lifecycle = shouldHoldBusyForReplacement + ? "running" + : terminalError + ? "error" + : "idle"; mutableAgent.lastError = terminalError; const persistenceHandle = mutableAgent.session.describePersistence() ?? @@ -1095,11 +1103,14 @@ export class AgentManager { lifecycle: mutableAgent.lifecycle, hasPendingRun: Boolean(mutableAgent.pendingRun), terminalError, + pendingReplacement: mutableAgent.pendingReplacement, }, "streamAgent.finalize: applying terminal state" ); - this.emitState(mutableAgent); - this.flushLiveEventBacklog(mutableAgent); + if (!shouldHoldBusyForReplacement) { + this.emitState(mutableAgent); + this.flushLiveEventBacklog(mutableAgent); + } }; const self = this; @@ -1162,17 +1173,61 @@ export class AgentManager { return streamForwarder; } + replaceAgentRun( + agentId: string, + prompt: AgentPromptInput, + options?: AgentRunOptions + ): AsyncGenerator { + const snapshot = this.requireAgent(agentId); + if (snapshot.lifecycle !== "running" && !snapshot.pendingRun) { + return this.streamAgent(agentId, prompt, options); + } + + const agent = snapshot as ActiveManagedAgent; + agent.pendingReplacement = true; + + const self = this; + return (async function* replaceRunForwarder() { + try { + await self.cancelAgentRun(agentId); + const nextRun = self.streamAgent(agentId, prompt, options); + for await (const event of nextRun) { + yield event; + } + } catch (error) { + const latest = self.agents.get(agentId); + if (latest) { + const latestActive = latest as ActiveManagedAgent; + latestActive.pendingReplacement = false; + const hasForegroundRun = Boolean( + (latestActive as { pendingRun: AsyncGenerator | null }).pendingRun + ); + const lifecycle = (latestActive as { lifecycle: AgentLifecycleStatus }).lifecycle; + if (!hasForegroundRun && lifecycle === "running") { + latestActive.lifecycle = "idle"; + self.emitState(latestActive); + self.flushLiveEventBacklog(latestActive); + } + } + throw error; + } + })(); + } + async waitForAgentRunStart(agentId: string, options?: WaitForAgentStartOptions): Promise { const snapshot = this.getAgent(agentId); if (!snapshot) { throw new Error(`Agent ${agentId} not found`); } - if (snapshot.lifecycle === "running") { + if (snapshot.lifecycle === "running" && !snapshot.pendingReplacement) { return; } - if (!("pendingRun" in snapshot) || !snapshot.pendingRun) { + if ( + (!("pendingRun" in snapshot) || !snapshot.pendingRun) && + !snapshot.pendingReplacement + ) { throw new Error(`Agent ${agentId} has no pending run`); } @@ -1229,7 +1284,7 @@ export class AgentManager { if (event.agent.id !== agentId) { return; } - if (event.agent.lifecycle === "running") { + if (event.agent.lifecycle === "running" && !event.agent.pendingReplacement) { finishOk(); return; } @@ -1575,6 +1630,7 @@ export class AgentManager { availableModes: [], currentModeId: null, pendingPermissions: new Map(), + pendingReplacement: false, pendingRun: null, timeline: initialTimeline, timelineRows: initialTimelineRows, diff --git a/packages/server/src/server/agent/mcp-server.ts b/packages/server/src/server/agent/mcp-server.ts index cfa6f664b..268cb96ec 100644 --- a/packages/server/src/server/agent/mcp-server.ts +++ b/packages/server/src/server/agent/mcp-server.ts @@ -225,9 +225,16 @@ function startAgentRun( agentManager: AgentManager, agentId: string, prompt: AgentPromptInput, - logger: Logger + logger: Logger, + options?: { replaceRunning?: boolean } ): void { - const iterator = agentManager.streamAgent(agentId, prompt); + const snapshot = agentManager.getAgent(agentId); + const shouldReplace = + options?.replaceRunning && + Boolean(snapshot && (snapshot.lifecycle === "running" || snapshot.pendingRun)); + const iterator = shouldReplace + ? agentManager.replaceAgentRun(agentId, prompt) + : agentManager.streamAgent(agentId, prompt); void (async () => { try { for await (const _ of iterator) { @@ -764,52 +771,13 @@ export async function createAgentMcpServer( }, }, async ({ agentId, prompt, sessionMode, background = false }) => { - // Check if agent is running and interrupt if necessary (matches app behavior) const snapshot = agentManager.getAgent(agentId); if (!snapshot) { throw new Error(`Agent ${agentId} not found`); } if (snapshot.lifecycle === "running" || snapshot.pendingRun) { - childLogger.debug( - { agentId }, - "Interrupting active run before sending new prompt" - ); - try { - const cancelled = await agentManager.cancelAgentRun(agentId); - if (!cancelled) { - childLogger.warn( - { agentId }, - "Agent reported running but no active run was cancelled" - ); - } - // Also cancel any pending wait_for_agent calls for this agent - waitTracker.cancel(agentId, "Agent run interrupted by new prompt"); - - // Wait for the agent to become idle after cancellation - // Poll until the agent is no longer running and has no pending run - // This is necessary because cancelAgentRun only initiates cancellation - // and doesn't wait for the generator to fully terminate - const maxWaitMs = 5000; - const pollIntervalMs = 50; - const startTime = Date.now(); - while (Date.now() - startTime < maxWaitMs) { - const current = agentManager.getAgent(agentId); - if (!current) { - throw new Error(`Agent ${agentId} not found during cancellation wait`); - } - if (current.lifecycle !== "running" && !current.pendingRun) { - break; - } - await new Promise((resolve) => setTimeout(resolve, pollIntervalMs)); - } - } catch (error) { - childLogger.error( - { err: error, agentId }, - "Failed to interrupt agent" - ); - throw error; - } + waitTracker.cancel(agentId, "Agent run interrupted by new prompt"); } if (sessionMode) { @@ -827,7 +795,9 @@ export async function createAgentMcpServer( ); } - startAgentRun(agentManager, agentId, prompt, childLogger); + startAgentRun(agentManager, agentId, prompt, childLogger, { + replaceRunning: true, + }); // If not running in background, wait for completion if (!background) { diff --git a/packages/server/src/server/daemon-e2e/wait-for-idle.e2e.test.ts b/packages/server/src/server/daemon-e2e/wait-for-idle.e2e.test.ts index dd370dcfe..cb6901e69 100644 --- a/packages/server/src/server/daemon-e2e/wait-for-idle.e2e.test.ts +++ b/packages/server/src/server/daemon-e2e/wait-for-idle.e2e.test.ts @@ -6,7 +6,9 @@ import { createDaemonTestContext, type DaemonTestContext, } from "../test-utils/index.js"; +import { DaemonClient } from "../test-utils/index.js"; import { createMessageCollector, type MessageCollector } from "../test-utils/message-collector.js"; +import { getFullAccessConfig } from "./agent-configs.js"; function tmpCwd(): string { return mkdtempSync(path.join(tmpdir(), "wait-for-idle-e2e-")); @@ -186,4 +188,49 @@ describe("waitForFinish edge cases", () => { rmSync(cwd, { recursive: true, force: true }); } }, 60000); + + test("waitForFinish stays blocked when sendMessage transactionally replaces a running turn", async () => { + const cwd = tmpCwd(); + const secondary = new DaemonClient({ url: `ws://127.0.0.1:${ctx.daemon.port}/ws` }); + + const agent = await ctx.client.createAgent({ + ...getFullAccessConfig("codex"), + cwd, + title: "Transactional Replacement Wait", + }); + + try { + await secondary.connect(); + await secondary.fetchAgents({ subscribe: { subscriptionId: "secondary" } }); + + await ctx.client.sendMessage(agent.id, "Run: sleep 5"); + await ctx.client.waitForAgentUpsert( + agent.id, + (snapshot) => snapshot.status === "running", + 5_000 + ); + + const waitPromise = ctx.client.waitForFinish(agent.id, 5_000); + + await secondary.sendMessage(agent.id, "Run: sleep 5"); + await secondary.waitForAgentUpsert( + agent.id, + (snapshot) => snapshot.status === "running", + 5_000 + ); + + const prematureResolution = await Promise.race([ + waitPromise.then(() => "resolved"), + new Promise<"pending">((resolve) => setTimeout(() => resolve("pending"), 100)), + ]); + expect(prematureResolution).toBe("pending"); + + const finalState = await waitPromise; + expect(finalState.status).toBe("idle"); + } finally { + await secondary.close(); + await ctx.client.deleteAgent(agent.id); + rmSync(cwd, { recursive: true, force: true }); + } + }, 30000); }); diff --git a/packages/server/src/server/session.ts b/packages/server/src/server/session.ts index 6badeb111..d1d97134d 100644 --- a/packages/server/src/server/session.ts +++ b/packages/server/src/server/session.ts @@ -844,8 +844,15 @@ export class Session { ) let iterator: AsyncGenerator try { - iterator = this.agentManager.streamAgent(agentId, prompt, runOptions) - this.sessionLogger.trace({ agentId }, 'startAgentStream: streamAgent returned iterator') + const snapshot = this.agentManager.getAgent(agentId) + const shouldReplace = Boolean(snapshot && (snapshot.lifecycle === 'running' || snapshot.pendingRun)) + iterator = shouldReplace + ? this.agentManager.replaceAgentRun(agentId, prompt, runOptions) + : this.agentManager.streamAgent(agentId, prompt, runOptions) + this.sessionLogger.trace( + { agentId, shouldReplace }, + 'startAgentStream: agent iterator returned' + ) } catch (error) { this.handleAgentRunError(agentId, error, 'Failed to start agent run') const message = @@ -2457,19 +2464,6 @@ export class Session { return } - try { - await this.interruptAgentIfRunning(agentId) - } catch (error) { - this.handleAgentRunError( - agentId, - error, - 'Failed to interrupt running agent before sending prompt' - ) - return - } - - const prompt = this.buildAgentPrompt(text, images) - try { this.agentManager.recordUserMessage(agentId, text, { messageId, @@ -2482,6 +2476,8 @@ export class Session { ) } + const prompt = this.buildAgentPrompt(text, images) + this.startAgentStream(agentId, prompt, runOptions) } @@ -6100,7 +6096,6 @@ export class Session { await this.unarchiveAgentState(agentId) await this.ensureAgentLoaded(agentId) - await this.interruptAgentIfRunning(agentId) try { this.agentManager.recordUserMessage(agentId, msg.text, {