Refine agent list attention handling

This commit is contained in:
Mohamed Boudra
2026-03-08 15:39:40 +07:00
parent 03e1915316
commit faa5aaab6f
21 changed files with 1178 additions and 390 deletions
@@ -39,7 +39,6 @@ import { markScrollInvestigationRender } from '@/utils/scroll-jank-investigation
import { useKeyboardShiftStyle } from '@/hooks/use-keyboard-shift-style' import { useKeyboardShiftStyle } from '@/hooks/use-keyboard-shift-style'
import { useKeyboardActionHandler } from '@/hooks/use-keyboard-action-handler' import { useKeyboardActionHandler } from '@/hooks/use-keyboard-action-handler'
import type { KeyboardActionDefinition } from '@/keyboard/keyboard-action-dispatcher' import type { KeyboardActionDefinition } from '@/keyboard/keyboard-action-dispatcher'
import { shouldClearAgentAttention } from '@/utils/agent-attention'
type QueuedMessage = { type QueuedMessage = {
id: string id: string
@@ -67,11 +66,15 @@ interface AgentInputAreaProps {
/** Called when a message is about to be sent (any path: keyboard, dictation, queued). */ /** Called when a message is about to be sent (any path: keyboard, dictation, queued). */
onMessageSent?: () => void onMessageSent?: () => void
onComposerHeightChange?: (height: number) => void onComposerHeightChange?: (height: number) => void
onAttentionInputFocus?: () => void
onAttentionPromptSend?: () => void
/** Controlled status controls rendered in input area (draft flows). */ /** Controlled status controls rendered in input area (draft flows). */
statusControls?: DraftAgentStatusBarProps statusControls?: DraftAgentStatusBarProps
} }
const EMPTY_ARRAY: readonly QueuedMessage[] = [] 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({ export function AgentInputArea({
agentId, agentId,
@@ -87,6 +90,8 @@ export function AgentInputArea({
commandDraftConfig, commandDraftConfig,
onMessageSent, onMessageSent,
onComposerHeightChange, onComposerHeightChange,
onAttentionInputFocus,
onAttentionPromptSend,
statusControls, statusControls,
}: AgentInputAreaProps) { }: AgentInputAreaProps) {
markScrollInvestigationRender(`AgentInputArea:${serverId}:${agentId}`) markScrollInvestigationRender(`AgentInputArea:${serverId}:${agentId}`)
@@ -127,6 +132,9 @@ export function AgentInputArea({
Platform.OS === 'web' && Platform.OS === 'web' &&
UnistylesRuntime.breakpoint !== 'xs' && UnistylesRuntime.breakpoint !== 'xs' &&
UnistylesRuntime.breakpoint !== 'sm' UnistylesRuntime.breakpoint !== 'sm'
const messagePlaceholder = isDesktopWebBreakpoint
? DESKTOP_MESSAGE_PLACEHOLDER
: MOBILE_MESSAGE_PLACEHOLDER
const userInput = value ?? internalInput const userInput = value ?? internalInput
const setUserInput = onChangeText ?? setInternalInput const setUserInput = onChangeText ?? setInternalInput
const [cursorIndex, setCursorIndex] = useState(0) const [cursorIndex, setCursorIndex] = useState(0)
@@ -243,18 +251,9 @@ export function AgentInputArea({
messageId: clientMessageId, messageId: clientMessageId,
...(imagesData && imagesData.length > 0 ? { images: imagesData } : {}), ...(imagesData && imagesData.length > 0 ? { images: imagesData } : {}),
}) })
if ( onAttentionPromptSend?.()
shouldClearAgentAttention({
agentId,
isConnected,
requiresAttention: agent?.requiresAttention,
attentionReason: agent?.attentionReason,
})
) {
client.clearAgentAttention(agentId)
}
} }
}, [agent?.attentionReason, agent?.requiresAttention, client, isConnected, serverId, setAgentStreamTail, setAgentStreamHead]) }, [client, onAttentionPromptSend, serverId, setAgentStreamTail, setAgentStreamHead])
useEffect(() => { useEffect(() => {
onSubmitMessageRef.current = onSubmitMessage onSubmitMessageRef.current = onSubmitMessage
@@ -808,7 +807,7 @@ export function AgentInputArea({
onRemoveImage={handleRemoveImage} onRemoveImage={handleRemoveImage}
client={client} client={client}
isReadyForDictation={isDictationReady} isReadyForDictation={isDictationReady}
placeholder="Message the agent, tag @files, or use /commands and /skills" placeholder={messagePlaceholder}
autoFocus={autoFocus && isDesktopWebBreakpoint} autoFocus={autoFocus && isDesktopWebBreakpoint}
autoFocusKey={`${serverId}:${agentId}`} autoFocusKey={`${serverId}:${agentId}`}
disabled={isSubmitLoading} disabled={isSubmitLoading}
@@ -824,7 +823,12 @@ export function AgentInputArea({
onSelectionChange={(selection) => { onSelectionChange={(selection) => {
setCursorIndex(selection.start) setCursorIndex(selection.start)
}} }}
onFocusChange={setIsMessageInputFocused} onFocusChange={(focused) => {
setIsMessageInputFocused(focused)
if (focused) {
onAttentionInputFocus?.()
}
}}
onHeightChange={onComposerHeightChange} onHeightChange={onComposerHeightChange}
/> />
</View> </View>
+480 -178
View File
@@ -4,37 +4,23 @@ import {
Pressable, Pressable,
Modal, Modal,
RefreshControl, RefreshControl,
SectionList, FlatList,
type ViewToken, type ListRenderItem,
type SectionListRenderItem,
} from "react-native"; } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context"; import { useSafeAreaInsets } from "react-native-safe-area-context";
import { useCallback, useMemo, useState, type ReactElement } from "react"; import { useCallback, useMemo, useState, type ReactElement } from "react";
import { router, usePathname, type Href } from "expo-router"; import { router, usePathname, type Href } from "expo-router";
import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles";
import { useQueryClient } from "@tanstack/react-query";
import { formatTimeAgo } from "@/utils/time"; import { formatTimeAgo } from "@/utils/time";
import { shortenPath } from "@/utils/shorten-path"; import { shortenPath } from "@/utils/shorten-path";
import { deriveBranchLabel, deriveProjectPath } from "@/utils/agent-display-info";
import { type AggregatedAgent } from "@/hooks/use-aggregated-agents"; import { type AggregatedAgent } from "@/hooks/use-aggregated-agents";
import { useSessionStore } from "@/stores/session-store"; import { useSessionStore } from "@/stores/session-store";
import {
getHostRuntimeStore,
isHostRuntimeConnected,
} from "@/runtime/host-runtime";
import { AgentStatusDot } from "@/components/agent-status-dot"; import { AgentStatusDot } from "@/components/agent-status-dot";
import {
CHECKOUT_STATUS_STALE_TIME,
checkoutStatusQueryKey,
useCheckoutStatusCacheOnly,
} from "@/hooks/use-checkout-status-query";
import { import {
buildAgentNavigationKey, buildAgentNavigationKey,
startNavigationTiming, startNavigationTiming,
} from "@/utils/navigation-timing"; } from "@/utils/navigation-timing";
import { import { buildHostWorkspaceAgentRoute } from "@/utils/host-routes";
buildHostWorkspaceAgentRoute,
} from "@/utils/host-routes";
interface AgentListProps { interface AgentListProps {
agents: AggregatedAgent[]; agents: AggregatedAgent[];
@@ -52,6 +38,25 @@ interface AgentListSection {
data: AggregatedAgent[]; 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 { function deriveDateSectionLabel(lastActivityAt: Date): string {
const now = new Date(); const now = new Date();
const todayStart = new Date(now.getFullYear(), now.getMonth(), now.getDate()); const todayStart = new Date(now.getFullYear(), now.getMonth(), now.getDate());
@@ -80,76 +85,290 @@ function deriveDateSectionLabel(lastActivityAt: Date): string {
return "Older"; return "Older";
} }
interface AgentListRowProps { function formatStatusLabel(status: AggregatedAgent["status"]): string {
agent: AggregatedAgent; switch (status) {
selectedAgentId?: string; case "initializing":
showCheckoutInfo: boolean; return "Starting";
onPress: (agent: AggregatedAgent) => void; case "idle":
onLongPress: (agent: AggregatedAgent) => void; 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 (
<View
style={[
styles.cell,
{ flex },
align === "right" ? styles.cellRight : styles.cellLeft,
]}
>
{children}
</View>
);
}
function SessionBadge({
label,
tone = "neutral",
}: {
label: string;
tone?: "neutral" | "warning" | "danger";
}) {
return (
<View
style={[
styles.badge,
tone === "warning" && styles.badgeWarning,
tone === "danger" && styles.badgeDanger,
]}
>
<Text
style={[
styles.badgeText,
tone === "warning" && styles.badgeTextWarning,
tone === "danger" && styles.badgeTextDanger,
]}
>
{label}
</Text>
</View>
);
}
function SessionTableRow({
agent, agent,
columns,
isMobile,
selectedAgentId, selectedAgentId,
showCheckoutInfo,
onPress, onPress,
onLongPress, onLongPress,
}: AgentListRowProps) { }: {
agent: AggregatedAgent;
columns: SessionColumnDefinition[];
isMobile: boolean;
selectedAgentId?: string;
onPress: (agent: AggregatedAgent) => void;
onLongPress: (agent: AggregatedAgent) => void;
}) {
const timeAgo = formatTimeAgo(agent.lastActivityAt); const timeAgo = formatTimeAgo(agent.lastActivityAt);
const agentKey = `${agent.serverId}:${agent.id}`; const agentKey = `${agent.serverId}:${agent.id}`;
const isSelected = selectedAgentId === agentKey; const isSelected = selectedAgentId === agentKey;
const archivedLabel = agent.archivedAt ? "Archived" : null; const statusLabel = formatStatusLabel(agent.status);
const checkoutQuery = useCheckoutStatusCacheOnly({ const projectPath = shortenPath(agent.cwd);
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;
return ( return (
<Pressable <Pressable
style={({ pressed, hovered }) => [ style={({ pressed, hovered }) => [
styles.agentItem, styles.row,
isSelected && styles.agentItemSelected, isSelected && styles.rowSelected,
hovered && styles.agentItemHovered, hovered && styles.rowHovered,
pressed && styles.agentItemPressed, pressed && styles.rowPressed,
]} ]}
onPress={() => onPress(agent)} onPress={() => onPress(agent)}
onLongPress={() => onLongPress(agent)} onLongPress={() => onLongPress(agent)}
testID={`agent-row-${agent.serverId}-${agent.id}`} testID={`agent-row-${agent.serverId}-${agent.id}`}
> >
{({ hovered }) => ( {({ hovered }) => (
<View style={styles.agentContent}> <View style={styles.rowInner}>
<View style={styles.row}> {columns.map((column) => {
<AgentStatusDot status={agent.status} requiresAttention={agent.requiresAttention} /> if (column.key === "session") {
<Text return (
style={[ <SessionCell key={column.key} flex={column.flex} align={column.align}>
styles.agentTitle, <View style={styles.primaryCell}>
(isSelected || hovered) && styles.agentTitleHighlighted, <View style={styles.sessionTitleRow}>
]} <Text
numberOfLines={1} style={[
> styles.sessionTitle,
{agent.title || "New agent"} (isSelected || hovered) && styles.sessionTitleHighlighted,
</Text> ]}
</View> numberOfLines={1}
>
{agent.title || "New session"}
</Text>
{agent.archivedAt ? <SessionBadge label="Archived" /> : null}
{(agent.pendingPermissionCount ?? 0) > 0 ? (
<SessionBadge
label={`${agent.pendingPermissionCount} pending`}
tone="warning"
/>
) : null}
</View>
{isMobile ? (
<View style={styles.sessionMetaRow}>
<Text style={styles.sessionMetaText} numberOfLines={1}>
{projectPath}
</Text>
<Text style={styles.sessionMetaSeparator}>·</Text>
<Text style={styles.sessionMetaText}>{statusLabel}</Text>
{agent.serverLabel ? (
<>
<Text style={styles.sessionMetaSeparator}>·</Text>
<Text style={styles.sessionMetaText} numberOfLines={1}>
{agent.serverLabel}
</Text>
</>
) : null}
</View>
) : (
<View style={styles.secondaryBadgeRow}>
{agent.requiresAttention ? (
<SessionBadge label="Attention" tone="danger" />
) : null}
</View>
)}
</View>
</SessionCell>
);
}
<Text style={styles.secondaryRow} numberOfLines={1}> if (column.key === "project") {
{shortenPath(projectPath)} return (
{branchLabel ? ` · ${branchLabel}` : ""} <SessionCell key={column.key} flex={column.flex} align={column.align}>
{archivedLabel ? ` · ${archivedLabel}` : ""} · {timeAgo} <View style={styles.projectCell}>
</Text> <Text style={styles.projectPath} numberOfLines={1}>
{projectPath}
</Text>
<Text style={styles.projectProvider} numberOfLines={1}>
{agent.provider}
</Text>
</View>
</SessionCell>
);
}
if (column.key === "host") {
return (
<SessionCell key={column.key} flex={column.flex} align={column.align}>
<Text style={styles.hostText} numberOfLines={1}>
{agent.serverLabel}
</Text>
</SessionCell>
);
}
if (column.key === "status") {
return (
<SessionCell key={column.key} flex={column.flex} align={column.align}>
<View style={styles.statusCell}>
<AgentStatusDot
status={agent.status}
requiresAttention={agent.requiresAttention}
/>
<Text style={styles.statusText} numberOfLines={1}>
{statusLabel}
</Text>
</View>
</SessionCell>
);
}
return (
<SessionCell key={column.key} flex={column.flex} align={column.align}>
<Text style={styles.updatedText} numberOfLines={1}>
{timeAgo}
</Text>
</SessionCell>
);
})}
</View> </View>
)} )}
</Pressable> </Pressable>
); );
} }
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 (
<View style={styles.sectionBlock}>
<View style={styles.sectionHeading}>
<Text style={styles.sectionTitle}>{section.title}</Text>
<View style={styles.sectionLine} />
</View>
<View style={styles.tableCard}>
<View style={styles.tableHeader}>
{columns.map((column) => (
<SessionCell key={column.key} flex={column.flex} align={column.align}>
<Text
style={[
styles.columnLabel,
column.align === "right" && styles.columnLabelRight,
]}
numberOfLines={1}
>
{column.label}
</Text>
</SessionCell>
))}
</View>
{section.data.map((agent, index) => (
<View
key={`${agent.serverId}:${agent.id}`}
style={index > 0 ? styles.rowDivider : undefined}
>
<SessionTableRow
agent={agent}
columns={columns}
isMobile={isMobile}
selectedAgentId={selectedAgentId}
onPress={onAgentPress}
onLongPress={onAgentLongPress}
/>
</View>
))}
</View>
</View>
);
}
export function AgentList({ export function AgentList({
agents, agents,
showCheckoutInfo = true,
isRefreshing = false, isRefreshing = false,
onRefresh, onRefresh,
selectedAgentId, selectedAgentId,
@@ -158,9 +377,10 @@ export function AgentList({
}: AgentListProps) { }: AgentListProps) {
const { theme } = useUnistyles(); const { theme } = useUnistyles();
const pathname = usePathname(); const pathname = usePathname();
const queryClient = useQueryClient();
const insets = useSafeAreaInsets(); const insets = useSafeAreaInsets();
const [actionAgent, setActionAgent] = useState<AggregatedAgent | null>(null); const [actionAgent, setActionAgent] = useState<AggregatedAgent | null>(null);
const isMobile =
UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
const actionClient = useSessionStore((state) => const actionClient = useSessionStore((state) =>
actionAgent?.serverId ? state.sessions[actionAgent.serverId]?.client ?? null : null actionAgent?.serverId ? state.sessions[actionAgent.serverId]?.client ?? null : null
@@ -168,6 +388,14 @@ export function AgentList({
const isActionSheetVisible = actionAgent !== null; const isActionSheetVisible = actionAgent !== null;
const isActionDaemonUnavailable = Boolean(actionAgent?.serverId && !actionClient); 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( const handleAgentPress = useCallback(
(agent: AggregatedAgent) => { (agent: AggregatedAgent) => {
@@ -215,51 +443,6 @@ export function AgentList({
setActionAgent(null); setActionAgent(null);
}, [actionAgent, actionClient]); }, [actionAgent, actionClient]);
const viewabilityConfig = useMemo(
() => ({ itemVisiblePercentThreshold: 30 }),
[]
);
const onViewableItemsChanged = useCallback(
({ viewableItems }: { viewableItems: Array<ViewToken> }) => {
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 sections = useMemo((): AgentListSection[] => {
const order = ["Today", "Yesterday", "This week", "This month", "Older"] as const; const order = ["Today", "Yesterday", "This week", "This month", "Older"] as const;
const buckets = new Map<string, AggregatedAgent[]>(); const buckets = new Map<string, AggregatedAgent[]>();
@@ -281,55 +464,33 @@ export function AgentList({
return result; return result;
}, [agents]); }, [agents]);
const renderAgentItem: SectionListRenderItem<AggregatedAgent, AgentListSection> = const renderSection: ListRenderItem<AgentListSection> = useCallback(
useCallback( ({ item: section }) => (
({ item: agent }) => ( <SessionTableSection
<AgentListRow section={section}
agent={agent} columns={columns}
selectedAgentId={selectedAgentId} isMobile={isMobile}
showCheckoutInfo={showCheckoutInfo} selectedAgentId={selectedAgentId}
onPress={handleAgentPress} onAgentPress={handleAgentPress}
onLongPress={handleAgentLongPress} onAgentLongPress={handleAgentLongPress}
/> />
),
[handleAgentLongPress, handleAgentPress, selectedAgentId, showCheckoutInfo]
);
const renderSectionHeader = useCallback(
({ section }: { section: AgentListSection }) => (
<View style={styles.sectionHeader}>
<Text style={styles.sectionTitle}>{section.title}</Text>
</View>
), ),
[] [columns, handleAgentLongPress, handleAgentPress, isMobile, selectedAgentId]
); );
const keyExtractor = useCallback( const keyExtractor = useCallback((section: AgentListSection) => section.key, []);
(agent: AggregatedAgent) => `${agent.serverId}:${agent.id}`,
[]
);
return ( return (
<> <>
<SectionList <FlatList
sections={sections} data={sections}
style={styles.list} style={styles.list}
contentContainerStyle={styles.listContent} contentContainerStyle={styles.listContent}
keyExtractor={keyExtractor} keyExtractor={keyExtractor}
renderItem={renderAgentItem} renderItem={renderSection}
renderSectionHeader={renderSectionHeader}
stickySectionHeadersEnabled={false}
extraData={selectedAgentId}
showsVerticalScrollIndicator={false} showsVerticalScrollIndicator={false}
keyboardShouldPersistTaps="handled" keyboardShouldPersistTaps="handled"
initialNumToRender={12}
windowSize={7}
maxToRenderPerBatch={12}
updateCellsBatchingPeriod={16}
removeClippedSubviews={true}
ListFooterComponent={listFooterComponent} ListFooterComponent={listFooterComponent}
onViewableItemsChanged={onViewableItemsChanged}
viewabilityConfig={viewabilityConfig}
refreshControl={ refreshControl={
onRefresh ? ( onRefresh ? (
<RefreshControl <RefreshControl
@@ -353,12 +514,15 @@ export function AgentList({
style={styles.sheetBackdrop} style={styles.sheetBackdrop}
onPress={handleCloseActionSheet} onPress={handleCloseActionSheet}
/> />
<View style={[styles.sheetContainer, { paddingBottom: Math.max(insets.bottom, theme.spacing[6]) }]}> <View
style={[
styles.sheetContainer,
{ paddingBottom: Math.max(insets.bottom, theme.spacing[6]) },
]}
>
<View style={styles.sheetHandle} /> <View style={styles.sheetHandle} />
<Text style={styles.sheetTitle}> <Text style={styles.sheetTitle}>
{isActionDaemonUnavailable {isActionDaemonUnavailable ? "Host offline" : "Archive this session?"}
? "Host offline"
: "Archive this agent?"}
</Text> </Text>
<View style={styles.sheetButtonRow}> <View style={styles.sheetButtonRow}>
<Pressable <Pressable
@@ -397,60 +561,198 @@ const styles = StyleSheet.create((theme) => ({
minHeight: 0, minHeight: 0,
}, },
listContent: { listContent: {
paddingHorizontal: theme.spacing[4], paddingHorizontal: {
xs: theme.spacing[3],
md: theme.spacing[6],
},
paddingTop: theme.spacing[2], paddingTop: theme.spacing[2],
paddingBottom: theme.spacing[4], paddingBottom: theme.spacing[6],
gap: theme.spacing[1],
}, },
sectionHeader: { sectionBlock: {
paddingVertical: theme.spacing[2],
paddingHorizontal: theme.spacing[3],
marginTop: theme.spacing[2], marginTop: theme.spacing[2],
}, },
sectionHeading: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[3],
paddingHorizontal: theme.spacing[1],
marginBottom: theme.spacing[2],
},
sectionTitle: { sectionTitle: {
fontSize: theme.fontSize.sm, fontSize: theme.fontSize.sm,
fontWeight: "500", fontWeight: "600",
color: theme.colors.foregroundMuted, color: theme.colors.foregroundMuted,
textAlign: "left", textTransform: "uppercase",
letterSpacing: 0.6,
}, },
agentItem: { sectionLine: {
paddingVertical: theme.spacing[2], flex: 1,
paddingHorizontal: theme.spacing[3], height: StyleSheet.hairlineWidth,
borderRadius: theme.borderRadius.lg,
marginBottom: theme.spacing[1],
},
agentItemSelected: {
backgroundColor: theme.colors.surface2, backgroundColor: theme.colors.surface2,
}, },
agentItemHovered: { tableCard: {
overflow: "hidden",
borderRadius: theme.borderRadius.xl,
borderWidth: StyleSheet.hairlineWidth,
borderColor: theme.colors.surface2,
backgroundColor: theme.colors.surface1, backgroundColor: theme.colors.surface1,
}, },
agentItemPressed: { tableHeader: {
backgroundColor: theme.colors.surface2, 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: { columnLabel: {
flex: 1, fontSize: theme.fontSize.xs,
gap: theme.spacing[0], fontWeight: "600",
color: theme.colors.foregroundMuted,
textTransform: "uppercase",
letterSpacing: 0.6,
},
columnLabelRight: {
textAlign: "right",
},
rowDivider: {
borderTopWidth: StyleSheet.hairlineWidth,
borderTopColor: theme.colors.surface2,
}, },
row: { 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", flexDirection: "row",
alignItems: "center", alignItems: "center",
gap: theme.spacing[2], gap: theme.spacing[2],
}, },
agentTitle: { statusText: {
flex: 1, fontSize: theme.fontSize.sm,
fontSize: theme.fontSize.base,
fontWeight: "400",
color: theme.colors.foreground, color: theme.colors.foreground,
opacity: 0.8,
}, },
agentTitleHighlighted: { updatedText: {
color: theme.colors.foreground,
opacity: 1,
},
secondaryRow: {
fontSize: theme.fontSize.sm, fontSize: theme.fontSize.sm,
fontWeight: "300",
color: theme.colors.foregroundMuted, 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: { sheetOverlay: {
flex: 1, flex: 1,
+17 -8
View File
@@ -41,10 +41,12 @@ function FilePreviewBody({
preview, preview,
isLoading, isLoading,
showDesktopWebScrollbar, showDesktopWebScrollbar,
isMobile,
}: { }: {
preview: ExplorerFile | null; preview: ExplorerFile | null;
isLoading: boolean; isLoading: boolean;
showDesktopWebScrollbar: boolean; showDesktopWebScrollbar: boolean;
isMobile: boolean;
}) { }) {
const enablePreviewDesktopScrollbar = showDesktopWebScrollbar; const enablePreviewDesktopScrollbar = showDesktopWebScrollbar;
const previewScrollRef = useRef<RNScrollView>(null); const previewScrollRef = useRef<RNScrollView>(null);
@@ -101,14 +103,20 @@ function FilePreviewBody({
scrollEventThrottle={enablePreviewDesktopScrollbar ? 16 : undefined} scrollEventThrottle={enablePreviewDesktopScrollbar ? 16 : undefined}
showsVerticalScrollIndicator={!enablePreviewDesktopScrollbar} showsVerticalScrollIndicator={!enablePreviewDesktopScrollbar}
> >
<RNScrollView {isMobile ? (
horizontal <View style={styles.previewCodeScrollContent}>
nestedScrollEnabled <Text style={styles.codeText}>{preview.content}</Text>
showsHorizontalScrollIndicator </View>
contentContainerStyle={styles.previewCodeScrollContent} ) : (
> <RNScrollView
<Text style={styles.codeText}>{preview.content}</Text> horizontal
</RNScrollView> nestedScrollEnabled
showsHorizontalScrollIndicator
contentContainerStyle={styles.previewCodeScrollContent}
>
<Text style={styles.codeText}>{preview.content}</Text>
</RNScrollView>
)}
</RNScrollView> </RNScrollView>
<WebDesktopScrollbarOverlay <WebDesktopScrollbarOverlay
enabled={enablePreviewDesktopScrollbar} enabled={enablePreviewDesktopScrollbar}
@@ -211,6 +219,7 @@ export function FilePane({
preview={query.data?.file ?? null} preview={query.data?.file ?? null}
isLoading={query.isFetching} isLoading={query.isFetching}
showDesktopWebScrollbar={showDesktopWebScrollbar} showDesktopWebScrollbar={showDesktopWebScrollbar}
isMobile={isMobile}
/> />
</View> </View>
); );
+5 -5
View File
@@ -10,7 +10,7 @@ import Animated, {
} from 'react-native-reanimated' } from 'react-native-reanimated'
import { Gesture, GestureDetector } from 'react-native-gesture-handler' import { Gesture, GestureDetector } from 'react-native-gesture-handler'
import { StyleSheet, UnistylesRuntime, useUnistyles } from 'react-native-unistyles' 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 { router, usePathname } from 'expo-router'
import { usePanelStore } from '@/stores/panel-store' import { usePanelStore } from '@/stores/panel-store'
import { SidebarWorkspaceList } from './sidebar-workspace-list' import { SidebarWorkspaceList } from './sidebar-workspace-list'
@@ -389,12 +389,12 @@ export function LeftSidebar({ selectedAgentId: _selectedAgentId }: LeftSidebarPr
nativeID="sidebar-all-agents" nativeID="sidebar-all-agents"
collapsable={false} collapsable={false}
accessible accessible
accessibilityLabel="All agents" accessibilityLabel="Sessions"
accessibilityRole="button" accessibilityRole="button"
onPress={handleViewMore} onPress={handleViewMore}
> >
{({ hovered }) => ( {({ hovered }) => (
<Users <MessagesSquare
size={theme.iconSize.lg} size={theme.iconSize.lg}
color={hovered ? theme.colors.foreground : theme.colors.foregroundMuted} color={hovered ? theme.colors.foreground : theme.colors.foregroundMuted}
/> />
@@ -509,12 +509,12 @@ export function LeftSidebar({ selectedAgentId: _selectedAgentId }: LeftSidebarPr
nativeID="sidebar-all-agents" nativeID="sidebar-all-agents"
collapsable={false} collapsable={false}
accessible accessible
accessibilityLabel="All agents" accessibilityLabel="Sessions"
accessibilityRole="button" accessibilityRole="button"
onPress={handleViewMore} onPress={handleViewMore}
> >
{({ hovered }) => ( {({ hovered }) => (
<Users <MessagesSquare
size={theme.iconSize.lg} size={theme.iconSize.lg}
color={hovered ? theme.colors.foreground : theme.colors.foregroundMuted} color={hovered ? theme.colors.foreground : theme.colors.foregroundMuted}
/> />
@@ -719,6 +719,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
onChangeText={handleInputChange} onChangeText={handleInputChange}
placeholder={placeholder} placeholder={placeholder}
placeholderTextColor={theme.colors.surface4} placeholderTextColor={theme.colors.surface4}
accessibilityLabel="Message agent..."
onFocus={() => { onFocus={() => {
isInputFocusedRef.current = true isInputFocusedRef.current = true
onFocusChange?.(true) onFocusChange?.(true)
@@ -147,6 +147,11 @@ function WorkspaceStatusIndicator({
const { theme } = useUnistyles() const { theme } = useUnistyles()
const color = resolveStatusDotColor({ theme, bucket }) const color = resolveStatusDotColor({ theme, bucket })
const shouldShowSyncedLoader = shouldRenderSyncedStatusLoader({ bucket }) const shouldShowSyncedLoader = shouldRenderSyncedStatusLoader({ bucket })
const shouldRenderIdlePlaceholder = !loading && !shouldShowSyncedLoader && bucket === 'done'
if (shouldRenderIdlePlaceholder) {
return null
}
return ( return (
<View style={styles.workspaceStatusDot}> <View style={styles.workspaceStatusDot}>
@@ -1548,7 +1553,10 @@ const styles = StyleSheet.create((theme) => ({
marginBottom: theme.spacing[1], marginBottom: theme.spacing[1],
}, },
workspaceListContainer: { workspaceListContainer: {
marginLeft: 0, marginLeft: theme.spacing[3],
paddingLeft: theme.spacing[2],
borderLeftWidth: StyleSheet.hairlineWidth,
borderLeftColor: theme.colors.surface1,
}, },
emptyText: { emptyText: {
color: theme.colors.foregroundMuted, color: theme.colors.foregroundMuted,
@@ -1668,7 +1676,7 @@ const styles = StyleSheet.create((theme) => ({
workspaceRowLeft: { workspaceRowLeft: {
flexDirection: 'row', flexDirection: 'row',
alignItems: 'center', alignItems: 'center',
gap: theme.spacing[2], gap: theme.spacing[1],
flex: 1, flex: 1,
minWidth: 0, minWidth: 0,
}, },
@@ -1703,11 +1711,11 @@ const styles = StyleSheet.create((theme) => ({
position: 'relative', position: 'relative',
}, },
workspaceStatusDot: { workspaceStatusDot: {
width: 16, width: 11,
height: 16, height: 16,
borderRadius: theme.borderRadius.full, borderRadius: theme.borderRadius.full,
flexShrink: 0, flexShrink: 0,
alignItems: 'center', alignItems: 'flex-start',
justifyContent: 'center', justifyContent: 'center',
}, },
workspaceStatusDotFill: { workspaceStatusDotFill: {
@@ -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<boolean>(() => 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]),
};
}
@@ -19,9 +19,12 @@ export interface AggregatedAgentsResult {
refreshAll: () => void; refreshAll: () => void;
} }
export function useAggregatedAgents(): AggregatedAgentsResult { export function useAggregatedAgents(options?: {
includeArchived?: boolean;
}): AggregatedAgentsResult {
const { daemons } = useDaemonRegistry(); const { daemons } = useDaemonRegistry();
const runtime = getHostRuntimeStore(); const runtime = getHostRuntimeStore();
const includeArchived = options?.includeArchived ?? false;
const runtimeVersion = useSyncExternalStore( const runtimeVersion = useSyncExternalStore(
(onStoreChange) => runtime.subscribeAll(onStoreChange), (onStoreChange) => runtime.subscribeAll(onStoreChange),
() => runtime.getVersion(), () => runtime.getVersion(),
@@ -55,7 +58,7 @@ export function useAggregatedAgents(): AggregatedAgentsResult {
} }
const serverLabel = serverLabelById.get(serverId) ?? serverId; const serverLabel = serverLabelById.get(serverId) ?? serverId;
for (const agent of agents.values()) { for (const agent of agents.values()) {
if (agent.archivedAt) { if (!includeArchived && agent.archivedAt) {
continue; continue;
} }
const nextAgent: AggregatedAgent = { const nextAgent: AggregatedAgent = {
@@ -112,7 +115,7 @@ export function useAggregatedAgents(): AggregatedAgentsResult {
isInitialLoad, isInitialLoad,
isRevalidating, isRevalidating,
}; };
}, [daemons, runtime, runtimeVersion, sessionAgents]); }, [daemons, includeArchived, runtime, runtimeVersion, sessionAgents]);
return { return {
...result, ...result,
@@ -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>): 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);
});
});
+48 -27
View File
@@ -35,8 +35,44 @@ function toAggregatedAgent(params: {
}; };
} }
function buildAllAgentsList(params: {
agents: Iterable<Agent>;
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?: { export function useAllAgentsList(options?: {
serverId?: string | null; serverId?: string | null;
includeArchived?: boolean;
}): AggregatedAgentsResult { }): AggregatedAgentsResult {
const { daemons } = useDaemonRegistry(); const { daemons } = useDaemonRegistry();
const runtime = getHostRuntimeStore(); const runtime = getHostRuntimeStore();
@@ -47,6 +83,7 @@ export function useAllAgentsList(options?: {
? value.trim() ? value.trim()
: null; : null;
}, [options?.serverId]); }, [options?.serverId]);
const includeArchived = options?.includeArchived ?? false;
const liveAgents = useSessionStore((state) => const liveAgents = useSessionStore((state) =>
serverId ? state.sessions[serverId]?.agents ?? null : null serverId ? state.sessions[serverId]?.agents ?? null : null
@@ -66,34 +103,13 @@ export function useAllAgentsList(options?: {
} }
const serverLabel = const serverLabel =
daemons.find((daemon) => daemon.serverId === serverId)?.label ?? serverId; daemons.find((daemon) => daemon.serverId === serverId)?.label ?? serverId;
const list: AggregatedAgent[] = []; return buildAllAgentsList({
agents: liveAgents.values(),
for (const agent of liveAgents.values()) { serverId,
const aggregated = toAggregatedAgent({ serverLabel,
source: agent, includeArchived,
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();
}); });
}, [daemons, includeArchived, liveAgents, serverId]);
return list;
}, [daemons, liveAgents, serverId]);
const isDirectoryLoading = Boolean(serverId && isHostRuntimeDirectoryLoading(snapshot)); const isDirectoryLoading = Boolean(serverId && isHostRuntimeDirectoryLoading(snapshot));
const isInitialLoad = isDirectoryLoading && agents.length === 0; const isInitialLoad = isDirectoryLoading && agents.length === 0;
@@ -107,3 +123,8 @@ export function useAllAgentsList(options?: {
refreshAll, refreshAll,
}; };
} }
export const __private__ = {
buildAllAgentsList,
toAggregatedAgent,
};
@@ -6,7 +6,7 @@ import {
Platform, Platform,
BackHandler, BackHandler,
} from "react-native"; } 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 { useSafeAreaInsets } from "react-native-safe-area-context";
import { useQueryClient } from "@tanstack/react-query"; import { useQueryClient } from "@tanstack/react-query";
import ReanimatedAnimated from "react-native-reanimated"; import ReanimatedAnimated from "react-native-reanimated";
@@ -55,7 +55,7 @@ import {
} from "@/utils/agent-snapshots"; } from "@/utils/agent-snapshots";
import { mergePendingCreateImages } from "@/utils/pending-create-images"; import { mergePendingCreateImages } from "@/utils/pending-create-images";
import { useKeyboardShiftStyle } from "@/hooks/use-keyboard-shift-style"; 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 type { DaemonClient } from "@server/client/daemon-client";
import { useExplorerOpenGesture } from "@/hooks/use-explorer-open-gesture"; import { useExplorerOpenGesture } from "@/hooks/use-explorer-open-gesture";
import type { ExplorerCheckoutContext } from "@/stores/panel-store"; import type { ExplorerCheckoutContext } from "@/stores/panel-store";
@@ -431,9 +431,7 @@ function AgentScreenContent({
const hasSession = useSessionStore( const hasSession = useSessionStore(
(state) => Boolean(state.sessions[serverId]) (state) => Boolean(state.sessions[serverId])
); );
const focusedAgentId = useSessionStore( const isScreenFocused = useIsFocused();
(state) => state.sessions[serverId]?.focusedAgentId ?? null
);
const { ensureAgentIsInitialized } = useAgentInitialization({ const { ensureAgentIsInitialized } = useAgentInitialization({
serverId, serverId,
client: hasSession ? client : null, client: hasSession ? client : null,
@@ -447,8 +445,6 @@ function AgentScreenContent({
routeKey: string; routeKey: string;
reason: "initial-entry" | "resume"; reason: "initial-entry" | "resume";
} | null>(null); } | null>(null);
const attentionClientRef = useRef(client);
const attentionConnectedRef = useRef(isConnected);
const setFocusedAgentId = useCallback( const setFocusedAgentId = useCallback(
(agentId: string | null) => { (agentId: string | null) => {
useSessionStore.getState().setFocusedAgentId(serverId, agentId); useSessionStore.getState().setFocusedAgentId(serverId, agentId);
@@ -456,10 +452,14 @@ function AgentScreenContent({
[serverId] [serverId]
); );
useEffect(() => { const attentionController = useAgentAttentionClear({
attentionClientRef.current = client; agentId: resolvedAgentId,
attentionConnectedRef.current = isConnected; client,
}, [client, isConnected]); isConnected,
requiresAttention: agent?.requiresAttention,
attentionReason: agent?.attentionReason,
isScreenFocused,
});
const { style: animatedKeyboardStyle } = useKeyboardShiftStyle({ const { style: animatedKeyboardStyle } = useKeyboardShiftStyle({
mode: "translate", mode: "translate",
@@ -534,33 +534,20 @@ function AgentScreenContent({
isArchivingAgent({ serverId, agentId: resolvedAgentId }) isArchivingAgent({ serverId, agentId: resolvedAgentId })
); );
useEffect(() => { useFocusEffect(
if (!resolvedAgentId) { useCallback(() => {
setFocusedAgentId(null); if (!resolvedAgentId) {
return; 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);
} }
setFocusedAgentId(null);
}; setFocusedAgentId(resolvedAgentId);
}, [resolvedAgentId, serverId, setFocusedAgentId]); return () => {
attentionController.clearOnAgentBlur();
setFocusedAgentId(null);
};
}, [attentionController, resolvedAgentId, setFocusedAgentId])
);
const isInitializing = resolvedAgentId ? isInitializingFromMap !== false : false; const isInitializing = resolvedAgentId ? isInitializingFromMap !== false : false;
const isHistorySyncing = useMemo(() => { const isHistorySyncing = useMemo(() => {
@@ -956,6 +943,8 @@ function AgentScreenContent({
serverId={serverId} serverId={serverId}
autoFocus autoFocus
isSubmitLoading={showPendingCreateSubmitLoading} isSubmitLoading={showPendingCreateSubmitLoading}
onAttentionInputFocus={attentionController.clearOnInputFocus}
onAttentionPromptSend={attentionController.clearOnPromptSend}
onAddImages={handleAddImagesCallback} onAddImages={handleAddImagesCallback}
onComposerHeightChange={() => onComposerHeightChange={() =>
streamViewRef.current?.prepareForViewportChange() streamViewRef.current?.prepareForViewportChange()
+2 -1
View File
@@ -10,6 +10,7 @@ import { buildHostRootRoute } from "@/utils/host-routes";
export function AgentsScreen({ serverId }: { serverId: string }) { export function AgentsScreen({ serverId }: { serverId: string }) {
const { agents, isRevalidating, refreshAll } = useAllAgentsList({ const { agents, isRevalidating, refreshAll } = useAllAgentsList({
serverId, serverId,
includeArchived: true,
}); });
// Track user-initiated refresh to avoid showing spinner on background revalidation // Track user-initiated refresh to avoid showing spinner on background revalidation
@@ -43,7 +44,7 @@ export function AgentsScreen({ serverId }: { serverId: string }) {
return ( return (
<View style={styles.container}> <View style={styles.container}>
<BackHeader <BackHeader
title="All agents" title="Sessions"
onBack={() => router.replace(buildHostRootRoute(serverId) as any)} onBack={() => router.replace(buildHostRootRoute(serverId) as any)}
/> />
<AgentList <AgentList
@@ -195,7 +195,7 @@ export function WorkspaceDesktopTabsRow({
maxWidth: resolvedTabWidth, maxWidth: resolvedTabWidth,
}, },
isActive && styles.tabActive, isActive && styles.tabActive,
(hovered || pressed || isCloseHovered) && styles.tabHovered, !isActive && (hovered || pressed || isCloseHovered) && styles.tabHovered,
]} ]}
onHoverIn={() => { onHoverIn={() => {
setHoveredTabKey(tab.key); setHoveredTabKey(tab.key);
@@ -273,11 +273,19 @@ export function WorkspaceDesktopTabsRow({
(hovered || pressed) && styles.tabCloseButtonActive, (hovered || pressed) && styles.tabCloseButtonActive,
]} ]}
> >
{isClosingTab ? ( {({ hovered, pressed }) =>
<ActivityIndicator size={12} color={theme.colors.foregroundMuted} /> isClosingTab ? (
) : ( <ActivityIndicator
<X size={12} color={theme.colors.foregroundMuted} /> size={12}
)} color={hovered || pressed ? theme.colors.foreground : theme.colors.foregroundMuted}
/>
) : (
<X
size={12}
color={hovered || pressed ? theme.colors.foreground : theme.colors.foregroundMuted}
/>
)
}
</Pressable> </Pressable>
) : null} ) : null}
</ContextMenuTrigger> </ContextMenuTrigger>
@@ -459,7 +467,7 @@ const styles = StyleSheet.create((theme) => ({
flexShrink: 0, flexShrink: 0,
}, },
tabActive: { tabActive: {
backgroundColor: theme.colors.surface2, backgroundColor: theme.colors.surface3,
}, },
tabHovered: { tabHovered: {
backgroundColor: theme.colors.surface2, backgroundColor: theme.colors.surface2,
@@ -57,4 +57,52 @@ describe("shouldClearAgentAttention", () => {
}) })
).toBe(false); ).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);
});
}); });
+14
View File
@@ -3,8 +3,16 @@ interface ShouldClearAgentAttentionInput {
isConnected: boolean; isConnected: boolean;
requiresAttention: boolean | null | undefined; requiresAttention: boolean | null | undefined;
attentionReason?: "finished" | "error" | "permission" | 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( export function shouldClearAgentAttention(
input: ShouldClearAgentAttentionInput input: ShouldClearAgentAttentionInput
): boolean { ): boolean {
@@ -21,5 +29,11 @@ export function shouldClearAgentAttention(
if (input.attentionReason === "permission") { if (input.attentionReason === "permission") {
return false; return false;
} }
if (
input.trigger === "focus-entry" &&
input.hasDeferredFocusEntryClear === true
) {
return false;
}
return true; return true;
} }
@@ -153,9 +153,16 @@ function startAgentRun(
agentManager: AgentManager, agentManager: AgentManager,
agentId: string, agentId: string,
prompt: AgentPromptInput, prompt: AgentPromptInput,
logger: Logger logger: Logger,
options?: { replaceRunning?: boolean }
): void { ): 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 () => { void (async () => {
try { try {
for await (const _ of iterator) { for await (const _ of iterator) {
@@ -565,44 +572,7 @@ export async function createAgentManagementMcpServer(
} }
if (snapshot.lifecycle === "running" || snapshot.pendingRun) { if (snapshot.lifecycle === "running" || snapshot.pendingRun) {
childLogger.debug( waitTracker.cancel(agentId, "Agent run interrupted by new prompt");
{ 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;
}
} }
if (sessionMode) { if (sessionMode) {
@@ -620,7 +590,9 @@ export async function createAgentManagementMcpServer(
); );
} }
startAgentRun(agentManager, agentId, prompt, childLogger); startAgentRun(agentManager, agentId, prompt, childLogger, {
replaceRunning: true,
});
if (!background) { if (!background) {
const result = await waitForAgentWithTimeout(agentManager, agentId, { const result = await waitForAgentWithTimeout(agentManager, agentId, {
@@ -1029,6 +1029,115 @@ describe("AgentManager", () => {
await consumePromise; 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<void>();
const allowSecondRunToEnd = deferred<void>();
class ReplaceRunSession extends TestAgentSession {
private streamCount = 0;
override async *stream(): AsyncGenerator<AgentStreamEvent> {
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<void> {
allowFirstRunToEnd.resolve();
}
}
class ReplaceRunClient extends TestAgentClient {
override async createSession(config: AgentSessionConfig): Promise<AgentSession> {
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<number[]>((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 () => { test("applies live autonomous events while no foreground run is active", async () => {
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-live-events-")); const workdir = mkdtempSync(join(tmpdir(), "agent-manager-live-events-"));
const storagePath = join(workdir, "agents"); const storagePath = join(workdir, "agents");
@@ -153,6 +153,7 @@ type ManagedAgentBase = {
availableModes: AgentMode[]; availableModes: AgentMode[];
currentModeId: string | null; currentModeId: string | null;
pendingPermissions: Map<string, AgentPermissionRequest>; pendingPermissions: Map<string, AgentPermissionRequest>;
pendingReplacement: boolean;
timeline: AgentTimelineItem[]; timeline: AgentTimelineItem[];
timelineRows: AgentTimelineRow[]; timelineRows: AgentTimelineRow[];
timelineEpoch: string; timelineEpoch: string;
@@ -1038,6 +1039,7 @@ export class AgentManager {
const agent = existingAgent as ActiveManagedAgent; const agent = existingAgent as ActiveManagedAgent;
const iterator = agent.session.stream(prompt, options); const iterator = agent.session.stream(prompt, options);
agent.pendingReplacement = false;
agent.lastError = undefined; agent.lastError = undefined;
let finalized = false; let finalized = false;
@@ -1076,7 +1078,13 @@ export class AgentManager {
const mutableAgent = agent as ActiveManagedAgent; const mutableAgent = agent as ActiveManagedAgent;
mutableAgent.pendingRun = null; mutableAgent.pendingRun = null;
const terminalError = error ?? mutableAgent.lastError; 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; mutableAgent.lastError = terminalError;
const persistenceHandle = const persistenceHandle =
mutableAgent.session.describePersistence() ?? mutableAgent.session.describePersistence() ??
@@ -1095,11 +1103,14 @@ export class AgentManager {
lifecycle: mutableAgent.lifecycle, lifecycle: mutableAgent.lifecycle,
hasPendingRun: Boolean(mutableAgent.pendingRun), hasPendingRun: Boolean(mutableAgent.pendingRun),
terminalError, terminalError,
pendingReplacement: mutableAgent.pendingReplacement,
}, },
"streamAgent.finalize: applying terminal state" "streamAgent.finalize: applying terminal state"
); );
this.emitState(mutableAgent); if (!shouldHoldBusyForReplacement) {
this.flushLiveEventBacklog(mutableAgent); this.emitState(mutableAgent);
this.flushLiveEventBacklog(mutableAgent);
}
}; };
const self = this; const self = this;
@@ -1162,17 +1173,61 @@ export class AgentManager {
return streamForwarder; return streamForwarder;
} }
replaceAgentRun(
agentId: string,
prompt: AgentPromptInput,
options?: AgentRunOptions
): AsyncGenerator<AgentStreamEvent> {
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<AgentStreamEvent> | 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<void> { async waitForAgentRunStart(agentId: string, options?: WaitForAgentStartOptions): Promise<void> {
const snapshot = this.getAgent(agentId); const snapshot = this.getAgent(agentId);
if (!snapshot) { if (!snapshot) {
throw new Error(`Agent ${agentId} not found`); throw new Error(`Agent ${agentId} not found`);
} }
if (snapshot.lifecycle === "running") { if (snapshot.lifecycle === "running" && !snapshot.pendingReplacement) {
return; return;
} }
if (!("pendingRun" in snapshot) || !snapshot.pendingRun) { if (
(!("pendingRun" in snapshot) || !snapshot.pendingRun) &&
!snapshot.pendingReplacement
) {
throw new Error(`Agent ${agentId} has no pending run`); throw new Error(`Agent ${agentId} has no pending run`);
} }
@@ -1229,7 +1284,7 @@ export class AgentManager {
if (event.agent.id !== agentId) { if (event.agent.id !== agentId) {
return; return;
} }
if (event.agent.lifecycle === "running") { if (event.agent.lifecycle === "running" && !event.agent.pendingReplacement) {
finishOk(); finishOk();
return; return;
} }
@@ -1575,6 +1630,7 @@ export class AgentManager {
availableModes: [], availableModes: [],
currentModeId: null, currentModeId: null,
pendingPermissions: new Map(), pendingPermissions: new Map(),
pendingReplacement: false,
pendingRun: null, pendingRun: null,
timeline: initialTimeline, timeline: initialTimeline,
timelineRows: initialTimelineRows, timelineRows: initialTimelineRows,
+13 -43
View File
@@ -225,9 +225,16 @@ function startAgentRun(
agentManager: AgentManager, agentManager: AgentManager,
agentId: string, agentId: string,
prompt: AgentPromptInput, prompt: AgentPromptInput,
logger: Logger logger: Logger,
options?: { replaceRunning?: boolean }
): void { ): 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 () => { void (async () => {
try { try {
for await (const _ of iterator) { for await (const _ of iterator) {
@@ -764,52 +771,13 @@ export async function createAgentMcpServer(
}, },
}, },
async ({ agentId, prompt, sessionMode, background = false }) => { async ({ agentId, prompt, sessionMode, background = false }) => {
// Check if agent is running and interrupt if necessary (matches app behavior)
const snapshot = agentManager.getAgent(agentId); const snapshot = agentManager.getAgent(agentId);
if (!snapshot) { if (!snapshot) {
throw new Error(`Agent ${agentId} not found`); throw new Error(`Agent ${agentId} not found`);
} }
if (snapshot.lifecycle === "running" || snapshot.pendingRun) { if (snapshot.lifecycle === "running" || snapshot.pendingRun) {
childLogger.debug( waitTracker.cancel(agentId, "Agent run interrupted by new prompt");
{ 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;
}
} }
if (sessionMode) { 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 not running in background, wait for completion
if (!background) { if (!background) {
@@ -6,7 +6,9 @@ import {
createDaemonTestContext, createDaemonTestContext,
type DaemonTestContext, type DaemonTestContext,
} from "../test-utils/index.js"; } from "../test-utils/index.js";
import { DaemonClient } from "../test-utils/index.js";
import { createMessageCollector, type MessageCollector } from "../test-utils/message-collector.js"; import { createMessageCollector, type MessageCollector } from "../test-utils/message-collector.js";
import { getFullAccessConfig } from "./agent-configs.js";
function tmpCwd(): string { function tmpCwd(): string {
return mkdtempSync(path.join(tmpdir(), "wait-for-idle-e2e-")); return mkdtempSync(path.join(tmpdir(), "wait-for-idle-e2e-"));
@@ -186,4 +188,49 @@ describe("waitForFinish edge cases", () => {
rmSync(cwd, { recursive: true, force: true }); rmSync(cwd, { recursive: true, force: true });
} }
}, 60000); }, 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);
}); });
+11 -16
View File
@@ -844,8 +844,15 @@ export class Session {
) )
let iterator: AsyncGenerator<AgentStreamEvent> let iterator: AsyncGenerator<AgentStreamEvent>
try { try {
iterator = this.agentManager.streamAgent(agentId, prompt, runOptions) const snapshot = this.agentManager.getAgent(agentId)
this.sessionLogger.trace({ agentId }, 'startAgentStream: streamAgent returned iterator') 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) { } catch (error) {
this.handleAgentRunError(agentId, error, 'Failed to start agent run') this.handleAgentRunError(agentId, error, 'Failed to start agent run')
const message = const message =
@@ -2457,19 +2464,6 @@ export class Session {
return 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 { try {
this.agentManager.recordUserMessage(agentId, text, { this.agentManager.recordUserMessage(agentId, text, {
messageId, messageId,
@@ -2482,6 +2476,8 @@ export class Session {
) )
} }
const prompt = this.buildAgentPrompt(text, images)
this.startAgentStream(agentId, prompt, runOptions) this.startAgentStream(agentId, prompt, runOptions)
} }
@@ -6100,7 +6096,6 @@ export class Session {
await this.unarchiveAgentState(agentId) await this.unarchiveAgentState(agentId)
await this.ensureAgentLoaded(agentId) await this.ensureAgentLoaded(agentId)
await this.interruptAgentIfRunning(agentId)
try { try {
this.agentManager.recordUserMessage(agentId, msg.text, { this.agentManager.recordUserMessage(agentId, msg.text, {