fix(app): stabilize hook order in agent archive flows

This commit is contained in:
Mohamed Boudra
2026-02-22 12:37:26 +07:00
parent 74a9a3c33b
commit 55b450c8b9
4 changed files with 116 additions and 96 deletions
+78 -62
View File
@@ -9,7 +9,7 @@ import {
type SectionListRenderItem, 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, useRef, useState, type ReactElement } from "react"; import { useCallback, useMemo, useState, type ReactElement } from "react";
import { router, usePathname } from "expo-router"; import { router, usePathname } from "expo-router";
import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { useQueryClient } from "@tanstack/react-query"; import { useQueryClient } from "@tanstack/react-query";
@@ -81,6 +81,71 @@ function deriveDateSectionLabel(lastActivityAt: Date): string {
return "Older"; return "Older";
} }
interface AgentListRowProps {
agent: AggregatedAgent;
selectedAgentId?: string;
showCheckoutInfo: boolean;
onPress: (serverId: string, agentId: string) => void;
onLongPress: (agent: AggregatedAgent) => void;
}
function AgentListRow({
agent,
selectedAgentId,
showCheckoutInfo,
onPress,
onLongPress,
}: AgentListRowProps) {
const timeAgo = formatTimeAgo(agent.lastActivityAt);
const agentKey = `${agent.serverId}:${agent.id}`;
const isSelected = selectedAgentId === agentKey;
const checkoutQuery = useCheckoutStatusCacheOnly({
serverId: agent.serverId,
cwd: agent.cwd,
});
const checkout = checkoutQuery.data ?? null;
const projectPath = showCheckoutInfo
? deriveProjectPath(agent.cwd, checkout)
: agent.cwd;
const branchLabel = showCheckoutInfo ? deriveBranchLabel(checkout) : null;
return (
<Pressable
style={({ pressed, hovered }) => [
styles.agentItem,
isSelected && styles.agentItemSelected,
hovered && styles.agentItemHovered,
pressed && styles.agentItemPressed,
]}
onPress={() => onPress(agent.serverId, agent.id)}
onLongPress={() => onLongPress(agent)}
testID={`agent-row-${agent.serverId}-${agent.id}`}
>
{({ hovered }) => (
<View style={styles.agentContent}>
<View style={styles.row}>
<AgentStatusDot status={agent.status} requiresAttention={agent.requiresAttention} />
<Text
style={[
styles.agentTitle,
(isSelected || hovered) && styles.agentTitleHighlighted,
]}
numberOfLines={1}
>
{agent.title || "New agent"}
</Text>
</View>
<Text style={styles.secondaryRow} numberOfLines={1}>
{shortenPath(projectPath)}
{branchLabel ? ` · ${branchLabel}` : ""} · {timeAgo}
</Text>
</View>
)}
</Pressable>
);
}
export function AgentList({ export function AgentList({
agents, agents,
showCheckoutInfo = true, showCheckoutInfo = true,
@@ -187,66 +252,6 @@ export function AgentList({
[queryClient, showCheckoutInfo] [queryClient, showCheckoutInfo]
); );
const AgentListRow = useCallback(
({ agent }: { agent: AggregatedAgent }) => {
const timeAgo = formatTimeAgo(agent.lastActivityAt);
const agentKey = `${agent.serverId}:${agent.id}`;
const isSelected = selectedAgentId === agentKey;
const checkoutQuery = useCheckoutStatusCacheOnly({
serverId: agent.serverId,
cwd: agent.cwd,
});
const checkout = checkoutQuery.data ?? null;
const projectPath = showCheckoutInfo
? deriveProjectPath(agent.cwd, checkout)
: agent.cwd;
const branchLabel = showCheckoutInfo ? deriveBranchLabel(checkout) : null;
return (
<Pressable
style={({ pressed, hovered }) => [
styles.agentItem,
isSelected && styles.agentItemSelected,
hovered && styles.agentItemHovered,
pressed && styles.agentItemPressed,
]}
onPress={() => handleAgentPress(agent.serverId, agent.id)}
onLongPress={() => handleAgentLongPress(agent)}
testID={`agent-row-${agent.serverId}-${agent.id}`}
>
{({ hovered }) => (
<View style={styles.agentContent}>
<View style={styles.row}>
<AgentStatusDot status={agent.status} requiresAttention={agent.requiresAttention} />
<Text
style={[
styles.agentTitle,
(isSelected || hovered) && styles.agentTitleHighlighted,
]}
numberOfLines={1}
>
{agent.title || "New agent"}
</Text>
</View>
<Text style={styles.secondaryRow} numberOfLines={1}>
{shortenPath(projectPath)}
{branchLabel ? ` · ${branchLabel}` : ""} · {timeAgo}
</Text>
</View>
)}
</Pressable>
);
},
[
handleAgentLongPress,
handleAgentPress,
selectedAgentId,
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[]>();
@@ -269,7 +274,18 @@ export function AgentList({
}, [agents]); }, [agents]);
const renderAgentItem: SectionListRenderItem<AggregatedAgent, AgentListSection> = const renderAgentItem: SectionListRenderItem<AggregatedAgent, AgentListSection> =
useCallback(({ item: agent }) => <AgentListRow agent={agent} />, [AgentListRow]); useCallback(
({ item: agent }) => (
<AgentListRow
agent={agent}
selectedAgentId={selectedAgentId}
showCheckoutInfo={showCheckoutInfo}
onPress={handleAgentPress}
onLongPress={handleAgentLongPress}
/>
),
[handleAgentLongPress, handleAgentPress, selectedAgentId, showCheckoutInfo]
);
const renderSectionHeader = useCallback( const renderSectionHeader = useCallback(
({ section }: { section: AgentListSection }) => ( ({ section }: { section: AgentListSection }) => (
@@ -38,17 +38,19 @@ export function AgentStatusBar({ agentId, serverId }: AgentStatusBarProps) {
const client = useSessionStore((state) => state.sessions[serverId]?.client ?? null); const client = useSessionStore((state) => state.sessions[serverId]?.client ?? null);
if (!agent) { const canFetchModels =
return null; Boolean(client) && Boolean(agent?.provider) && (IS_WEB || prefsOpen);
}
const canFetchModels = Boolean(client) && Boolean(agent.provider) && (IS_WEB || prefsOpen);
const modelsQuery = useQuery({ const modelsQuery = useQuery({
queryKey: ["providerModels", serverId, agent.provider, agent.cwd], queryKey: [
"providerModels",
serverId,
agent?.provider ?? "__missing_provider__",
agent?.cwd ?? "__missing_cwd__",
],
enabled: canFetchModels, enabled: canFetchModels,
staleTime: 5 * 60 * 1000, staleTime: 5 * 60 * 1000,
queryFn: async () => { queryFn: async () => {
if (!client) { if (!client || !agent) {
throw new Error("Daemon client unavailable"); throw new Error("Daemon client unavailable");
} }
const payload = await client.listProviderModels(agent.provider, { cwd: agent.cwd }); const payload = await client.listProviderModels(agent.provider, { cwd: agent.cwd });
@@ -61,7 +63,7 @@ export function AgentStatusBar({ agentId, serverId }: AgentStatusBarProps) {
const models = modelsQuery.data ?? null; const models = modelsQuery.data ?? null;
function handleModeChange(modeId: string) { function handleModeChange(modeId: string) {
if (!client) { if (!client || !agent) {
return; return;
} }
void client.setAgentMode(agentId, modeId).catch((error) => { void client.setAgentMode(agentId, modeId).catch((error) => {
@@ -69,8 +71,8 @@ export function AgentStatusBar({ agentId, serverId }: AgentStatusBarProps) {
}); });
} }
const normalizedRuntimeModelId = normalizeModelId(agent.runtimeInfo?.model); const normalizedRuntimeModelId = normalizeModelId(agent?.runtimeInfo?.model);
const normalizedConfiguredModelId = normalizeModelId(agent.model); const normalizedConfiguredModelId = normalizeModelId(agent?.model);
const preferredModelId = normalizedRuntimeModelId ?? normalizedConfiguredModelId; const preferredModelId = normalizedRuntimeModelId ?? normalizedConfiguredModelId;
const selectedModel = useMemo(() => { const selectedModel = useMemo(() => {
if (!models || !preferredModelId) return null; if (!models || !preferredModelId) return null;
@@ -82,7 +84,7 @@ export function AgentStatusBar({ agentId, serverId }: AgentStatusBarProps) {
const thinkingOptions = selectedModel?.thinkingOptions ?? null; const thinkingOptions = selectedModel?.thinkingOptions ?? null;
const explicitThinkingId = const explicitThinkingId =
agent.thinkingOptionId && agent.thinkingOptionId !== "default" agent?.thinkingOptionId && agent.thinkingOptionId !== "default"
? agent.thinkingOptionId ? agent.thinkingOptionId
: null; : null;
const selectedThinkingId = const selectedThinkingId =
@@ -93,10 +95,14 @@ export function AgentStatusBar({ agentId, serverId }: AgentStatusBarProps) {
(selectedThinkingId === "default" ? "Model default" : selectedThinkingId ?? "auto"); (selectedThinkingId === "default" ? "Model default" : selectedThinkingId ?? "auto");
const displayMode = const displayMode =
agent.availableModes?.find((m) => m.id === agent.currentModeId)?.label || agent?.availableModes?.find((m) => m.id === agent.currentModeId)?.label ||
agent.currentModeId || agent?.currentModeId ||
"default"; "default";
if (!agent) {
return null;
}
return ( return (
<View style={[styles.container, IS_WEB && { marginBottom: -theme.spacing[1] }]}> <View style={[styles.container, IS_WEB && { marginBottom: -theme.spacing[1] }]}>
{/* Agent Mode Badge (desktop only — on mobile, mode is in the preferences sheet) */} {/* Agent Mode Badge (desktop only — on mobile, mode is in the preferences sheet) */}
@@ -597,14 +597,6 @@ export function FileExplorerPane({ serverId, agentId }: FileExplorerPaneProps) {
}); });
}, [agentId, errorRecoveryPath, requestDirectoryListing, selectExplorerEntry]); }, [agentId, errorRecoveryPath, requestDirectoryListing, selectExplorerEntry]);
if (!agentExists) {
return (
<View style={styles.centerState}>
<Text style={styles.errorText}>Agent not found</Text>
</View>
);
}
const handleTreeListScroll = useCallback( const handleTreeListScroll = useCallback(
(event: NativeSyntheticEvent<NativeScrollEvent>) => { (event: NativeSyntheticEvent<NativeScrollEvent>) => {
if (showDesktopWebScrollbar) { if (showDesktopWebScrollbar) {
@@ -623,6 +615,14 @@ export function FileExplorerPane({ serverId, agentId }: FileExplorerPaneProps) {
[showDesktopWebScrollbar, treeScrollbarMetrics] [showDesktopWebScrollbar, treeScrollbarMetrics]
); );
if (!agentExists) {
return (
<View style={styles.centerState}>
<Text style={styles.errorText}>Agent not found</Text>
</View>
);
}
return ( return (
<View <View
style={styles.container} style={styles.container}
+11 -13
View File
@@ -24,19 +24,17 @@ export function useSessionForServer<T>(
selector?: SessionSelector<T>, selector?: SessionSelector<T>,
equalityFn?: EqualityFn<T> equalityFn?: EqualityFn<T>
): SessionState | null | T { ): SessionState | null | T {
const baseSelector = useCallback( const storeSelector = useCallback(
(state: ReturnType<typeof useSessionStore.getState>) => (state: ReturnType<typeof useSessionStore.getState>): SessionState | null | T => {
(serverId ? state.sessions[serverId] ?? null : null), const session = serverId ? state.sessions[serverId] ?? null : null;
[serverId] return selector ? selector(session) : session;
},
[selector, serverId]
); );
if (selector) { return useStoreWithEqualityFn(
const derivedSelector = useCallback( useSessionStore,
(state: ReturnType<typeof useSessionStore.getState>) => selector(baseSelector(state)), storeSelector,
[baseSelector, selector] equalityFn as EqualityFn<SessionState | null | T>
); );
return useStoreWithEqualityFn(useSessionStore, derivedSelector, equalityFn);
}
return useSessionStore(baseSelector);
} }