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, {