Refine agent list attention handling
This commit is contained in:
@@ -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>
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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()
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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, {
|
||||||
|
|||||||
Reference in New Issue
Block a user