perf: eliminate cascading rerenders with granular Zustand selectors
- Delete useDaemonSession hook that selected entire session object - Move drafts to top-level store (not nested in sessions) - Refactor all components to use granular useSessionStore selectors - Fix infinite loop bugs from selector fallbacks (use stable constants) Before: 15-20 component rerenders per keystroke After: ~1-2 rerenders per keystroke 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -19,13 +19,14 @@ import { BackHeader } from "@/components/headers/back-header";
|
||||
import { AgentStreamView } from "@/components/agent-stream-view";
|
||||
import { AgentInputArea } from "@/components/agent-input-area";
|
||||
import { CreateAgentModal, ImportAgentModal, type CreateAgentInitialValues } from "@/components/create-agent-modal";
|
||||
import type { Agent, SessionContextValue } from "@/contexts/session-context";
|
||||
import { useFooterControls } from "@/contexts/footer-controls-context";
|
||||
import { useDaemonConnections } from "@/contexts/daemon-connections-context";
|
||||
import type { ConnectionStatus } from "@/contexts/daemon-connections-context";
|
||||
import { formatConnectionStatus } from "@/utils/daemons";
|
||||
import { useDaemonSession } from "@/hooks/use-daemon-session";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
import type { Agent } from "@/stores/session-store";
|
||||
import { useDaemonRequest } from "@/hooks/use-daemon-request";
|
||||
import type { StreamItem } from "@/types/stream";
|
||||
import type { SessionOutboundMessage } from "@server/server/messages";
|
||||
import {
|
||||
buildAgentNavigationKey,
|
||||
@@ -35,6 +36,7 @@ import {
|
||||
} from "@/utils/navigation-timing";
|
||||
|
||||
const DROPDOWN_WIDTH = 220;
|
||||
const EMPTY_STREAM_ITEMS: StreamItem[] = [];
|
||||
|
||||
type GitRepoInfoResponseMessage = Extract<
|
||||
SessionOutboundMessage,
|
||||
@@ -90,13 +92,11 @@ export default function AgentScreen() {
|
||||
const resolvedAgentId = typeof agentId === "string" ? agentId : undefined;
|
||||
const resolvedServerId = typeof serverId === "string" ? serverId : undefined;
|
||||
const { connectionStates } = useDaemonConnections();
|
||||
const hasConnectionEntry = resolvedServerId ? connectionStates.has(resolvedServerId) : false;
|
||||
|
||||
const session = useDaemonSession(resolvedServerId, {
|
||||
suppressUnavailableAlert: true,
|
||||
allowUnavailable: true,
|
||||
});
|
||||
const sessionServerId = session?.serverId ?? null;
|
||||
// Check if session exists for this serverId
|
||||
const hasSession = useSessionStore((state) =>
|
||||
resolvedServerId ? !!state.sessions[resolvedServerId] : false
|
||||
);
|
||||
|
||||
const connectionServerId = resolvedServerId ?? null;
|
||||
const connection = connectionServerId ? connectionStates.get(connectionServerId) : null;
|
||||
@@ -107,7 +107,7 @@ export default function AgentScreen() {
|
||||
const lastConnectionError = connection?.lastError ?? null;
|
||||
|
||||
const handleBackToHome = useCallback(() => {
|
||||
const targetServerId = resolvedServerId ?? sessionServerId;
|
||||
const targetServerId = resolvedServerId;
|
||||
const targetAgentId = resolvedAgentId ?? null;
|
||||
if (targetServerId && targetAgentId) {
|
||||
startNavigationTiming(HOME_NAVIGATION_KEY, {
|
||||
@@ -127,10 +127,10 @@ export default function AgentScreen() {
|
||||
});
|
||||
}
|
||||
router.replace("/");
|
||||
}, [resolvedAgentId, resolvedServerId, router, sessionServerId]);
|
||||
}, [resolvedAgentId, resolvedServerId, router]);
|
||||
|
||||
const focusServerId = resolvedServerId ?? sessionServerId;
|
||||
const navigationStatus = session ? "ready" : "session_unavailable";
|
||||
const focusServerId = resolvedServerId;
|
||||
const navigationStatus = hasSession ? "ready" : "session_unavailable";
|
||||
|
||||
useFocusEffect(
|
||||
useCallback(() => {
|
||||
@@ -145,7 +145,7 @@ export default function AgentScreen() {
|
||||
}, [focusServerId, navigationStatus, resolvedAgentId])
|
||||
);
|
||||
|
||||
if (!session) {
|
||||
if (!hasSession || !resolvedServerId) {
|
||||
return (
|
||||
<AgentSessionUnavailableState
|
||||
onBack={handleBackToHome}
|
||||
@@ -158,26 +158,22 @@ export default function AgentScreen() {
|
||||
);
|
||||
}
|
||||
|
||||
const routeServerId = resolvedServerId ?? session.serverId;
|
||||
|
||||
return (
|
||||
<AgentScreenContent
|
||||
session={session}
|
||||
serverId={resolvedServerId}
|
||||
agentId={resolvedAgentId}
|
||||
routeServerId={routeServerId}
|
||||
onBack={handleBackToHome}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
type AgentScreenContentProps = {
|
||||
session: import("@/hooks/use-daemon-session").DaemonSession;
|
||||
serverId: string;
|
||||
agentId?: string;
|
||||
routeServerId: string;
|
||||
onBack: () => void;
|
||||
};
|
||||
|
||||
function AgentScreenContent({ session, agentId, routeServerId, onBack }: AgentScreenContentProps) {
|
||||
function AgentScreenContent({ serverId, agentId, onBack }: AgentScreenContentProps) {
|
||||
const { theme } = useUnistyles();
|
||||
const insets = useSafeAreaInsets();
|
||||
const router = useRouter();
|
||||
@@ -192,18 +188,53 @@ function AgentScreenContent({ session, agentId, routeServerId, onBack }: AgentSc
|
||||
const [createAgentInitialValues, setCreateAgentInitialValues] =
|
||||
useState<CreateAgentInitialValues | undefined>();
|
||||
|
||||
const {
|
||||
agents,
|
||||
agentStreamState,
|
||||
initializingAgents,
|
||||
pendingPermissions,
|
||||
initializeAgent,
|
||||
refreshAgent,
|
||||
setFocusedAgentId,
|
||||
ws,
|
||||
} = session;
|
||||
const resolvedAgentId = agentId;
|
||||
|
||||
// Select only the specific agent
|
||||
const agent = useSessionStore((state) =>
|
||||
resolvedAgentId ? state.sessions[serverId]?.agents?.get(resolvedAgentId) : undefined
|
||||
);
|
||||
|
||||
// Select only the specific stream state - use stable empty array to avoid infinite loop
|
||||
const streamItemsRaw = useSessionStore((state) =>
|
||||
resolvedAgentId ? state.sessions[serverId]?.agentStreamState?.get(resolvedAgentId) : undefined
|
||||
);
|
||||
const streamItems = streamItemsRaw ?? EMPTY_STREAM_ITEMS;
|
||||
|
||||
// Select only the specific initializing state
|
||||
const isInitializingFromMap = useSessionStore((state) =>
|
||||
resolvedAgentId ? (state.sessions[serverId]?.initializingAgents?.get(resolvedAgentId) ?? false) : false
|
||||
);
|
||||
|
||||
// Select raw pending permissions - filter with useMemo to avoid new Map on every render
|
||||
const allPendingPermissions = useSessionStore((state) =>
|
||||
state.sessions[serverId]?.pendingPermissions
|
||||
);
|
||||
const pendingPermissions = useMemo(() => {
|
||||
if (!allPendingPermissions || !resolvedAgentId) return new Map();
|
||||
const filtered = new Map();
|
||||
for (const [key, perm] of allPendingPermissions) {
|
||||
if (perm.agentId === resolvedAgentId) {
|
||||
filtered.set(key, perm);
|
||||
}
|
||||
}
|
||||
return filtered;
|
||||
}, [allPendingPermissions, resolvedAgentId]);
|
||||
|
||||
// Get ws for connection status
|
||||
const ws = useSessionStore((state) => state.sessions[serverId]?.ws);
|
||||
|
||||
// Get methods
|
||||
const methods = useSessionStore((state) => state.sessions[serverId]?.methods);
|
||||
const initializeAgent = methods?.initializeAgent;
|
||||
const refreshAgent = methods?.refreshAgent;
|
||||
const setFocusedAgentId = useCallback(
|
||||
(agentId: string | null) => {
|
||||
useSessionStore.getState().setFocusedAgentId(serverId, agentId);
|
||||
},
|
||||
[serverId]
|
||||
);
|
||||
|
||||
const { height: keyboardHeight } = useReanimatedKeyboardAnimation();
|
||||
const bottomInset = useSharedValue(insets.bottom);
|
||||
|
||||
@@ -220,11 +251,6 @@ function AgentScreenContent({ session, agentId, routeServerId, onBack }: AgentSc
|
||||
};
|
||||
});
|
||||
|
||||
const agent = resolvedAgentId ? agents.get(resolvedAgentId) : undefined;
|
||||
const streamItems = resolvedAgentId ? agentStreamState.get(resolvedAgentId) || [] : [];
|
||||
const agentPermissions = new Map(
|
||||
Array.from(pendingPermissions.entries()).filter(([_, perm]) => perm.agentId === resolvedAgentId)
|
||||
);
|
||||
const agentModel = extractAgentModel(agent);
|
||||
const modelDisplayValue = agentModel ?? "Unknown";
|
||||
|
||||
@@ -289,26 +315,28 @@ function AgentScreenContent({ session, agentId, routeServerId, onBack }: AgentSc
|
||||
};
|
||||
}, [resolvedAgentId, setFocusedAgentId]);
|
||||
|
||||
const hasStreamState = resolvedAgentId ? agentStreamState.has(resolvedAgentId) : false;
|
||||
const initializationState = resolvedAgentId ? initializingAgents.get(resolvedAgentId) : undefined;
|
||||
const hasStreamState = useSessionStore((state) =>
|
||||
resolvedAgentId ? state.sessions[serverId]?.agentStreamState?.has(resolvedAgentId) ?? false : false
|
||||
);
|
||||
|
||||
const isInitializing = resolvedAgentId
|
||||
? initializationState !== undefined
|
||||
? initializationState
|
||||
? isInitializingFromMap !== false
|
||||
? isInitializingFromMap
|
||||
: !hasStreamState
|
||||
: false;
|
||||
|
||||
useEffect(() => {
|
||||
if (!resolvedAgentId) {
|
||||
if (!resolvedAgentId || !initializeAgent) {
|
||||
return;
|
||||
}
|
||||
|
||||
console.log("[AgentScreen] initializeAgent effect", {
|
||||
agentId: resolvedAgentId,
|
||||
hasStreamState,
|
||||
initializationState,
|
||||
isInitializingFromMap,
|
||||
});
|
||||
|
||||
if (initializationState !== undefined) {
|
||||
if (isInitializingFromMap !== false) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -317,12 +345,12 @@ function AgentScreenContent({ session, agentId, routeServerId, onBack }: AgentSc
|
||||
}
|
||||
|
||||
initializeAgent({ agentId: resolvedAgentId });
|
||||
}, [resolvedAgentId, initializeAgent, initializationState, hasStreamState]);
|
||||
}, [resolvedAgentId, initializeAgent, isInitializingFromMap, hasStreamState]);
|
||||
|
||||
const agentControls = useMemo(() => {
|
||||
if (!resolvedAgentId) return null;
|
||||
return <AgentInputArea agentId={resolvedAgentId} serverId={routeServerId} />;
|
||||
}, [resolvedAgentId, routeServerId]);
|
||||
return <AgentInputArea agentId={resolvedAgentId} serverId={serverId} />;
|
||||
}, [resolvedAgentId, serverId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!agentControls || !agent || isInitializing) {
|
||||
@@ -416,11 +444,11 @@ function AgentScreenContent({ session, agentId, routeServerId, onBack }: AgentSc
|
||||
pathname: "/git-diff",
|
||||
params: {
|
||||
agentId: resolvedAgentId,
|
||||
serverId: routeServerId,
|
||||
serverId: serverId,
|
||||
},
|
||||
});
|
||||
}
|
||||
}, [resolvedAgentId, routeServerId, router, handleCloseMenu]);
|
||||
}, [resolvedAgentId, serverId, router, handleCloseMenu]);
|
||||
|
||||
const handleBrowseFiles = useCallback(() => {
|
||||
handleCloseMenu();
|
||||
@@ -429,14 +457,14 @@ function AgentScreenContent({ session, agentId, routeServerId, onBack }: AgentSc
|
||||
pathname: "/file-explorer",
|
||||
params: {
|
||||
agentId: resolvedAgentId,
|
||||
serverId: routeServerId,
|
||||
serverId: serverId,
|
||||
},
|
||||
});
|
||||
}
|
||||
}, [handleCloseMenu, resolvedAgentId, routeServerId, router]);
|
||||
}, [handleCloseMenu, resolvedAgentId, serverId, router]);
|
||||
|
||||
const handleRefreshAgent = useCallback(() => {
|
||||
if (!resolvedAgentId) {
|
||||
if (!resolvedAgentId || !refreshAgent) {
|
||||
return;
|
||||
}
|
||||
handleCloseMenu();
|
||||
@@ -475,7 +503,7 @@ function AgentScreenContent({ session, agentId, routeServerId, onBack }: AgentSc
|
||||
isVisible={showCreateAgentModal}
|
||||
onClose={handleCloseCreateAgentModal}
|
||||
initialValues={createAgentInitialValues}
|
||||
serverId={routeServerId}
|
||||
serverId={serverId}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -483,7 +511,7 @@ function AgentScreenContent({ session, agentId, routeServerId, onBack }: AgentSc
|
||||
<ImportAgentModal
|
||||
isVisible={showImportAgentModal}
|
||||
onClose={handleCloseImportAgentModal}
|
||||
serverId={routeServerId}
|
||||
serverId={serverId}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -529,10 +557,10 @@ function AgentScreenContent({ session, agentId, routeServerId, onBack }: AgentSc
|
||||
) : (
|
||||
<AgentStreamView
|
||||
agentId={agent.id}
|
||||
serverId={routeServerId}
|
||||
serverId={serverId}
|
||||
agent={agent}
|
||||
streamItems={streamItems}
|
||||
pendingPermissions={agentPermissions}
|
||||
pendingPermissions={pendingPermissions}
|
||||
/>
|
||||
)}
|
||||
</ReanimatedAnimated.View>
|
||||
|
||||
@@ -29,11 +29,11 @@ import {
|
||||
X,
|
||||
} from "lucide-react-native";
|
||||
import { BackHeader } from "@/components/headers/back-header";
|
||||
import type { ExplorerEntry, SessionContextValue } from "@/contexts/session-context";
|
||||
import type { ExplorerEntry } from "@/stores/session-store";
|
||||
import type { ConnectionStatus } from "@/contexts/daemon-connections-context";
|
||||
import { useDaemonConnections } from "@/contexts/daemon-connections-context";
|
||||
import { formatConnectionStatus } from "@/utils/daemons";
|
||||
import { useDaemonSession } from "@/hooks/use-daemon-session";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
|
||||
export default function FileExplorerScreen() {
|
||||
const {
|
||||
@@ -49,14 +49,14 @@ export default function FileExplorerScreen() {
|
||||
}>();
|
||||
const resolvedServerId = typeof serverId === "string" ? serverId : undefined;
|
||||
const { connectionStates } = useDaemonConnections();
|
||||
const session = useDaemonSession(resolvedServerId, {
|
||||
suppressUnavailableAlert: true,
|
||||
allowUnavailable: true,
|
||||
});
|
||||
|
||||
const session = useSessionStore((state) =>
|
||||
resolvedServerId ? state.sessions[resolvedServerId] : undefined
|
||||
);
|
||||
|
||||
const connectionServerId = resolvedServerId ?? null;
|
||||
const connection = connectionServerId ? connectionStates.get(connectionServerId) : null;
|
||||
const serverLabel = connection?.daemon.label ?? connectionServerId ?? session?.serverId ?? "Selected host";
|
||||
const serverLabel = connection?.daemon.label ?? connectionServerId ?? resolvedServerId ?? "Selected host";
|
||||
const connectionStatus = connection?.status ?? "idle";
|
||||
const connectionStatusLabel = formatConnectionStatus(connectionStatus);
|
||||
const lastError = connection?.lastError ?? null;
|
||||
@@ -78,21 +78,19 @@ export default function FileExplorerScreen() {
|
||||
|
||||
return (
|
||||
<FileExplorerContent
|
||||
session={session}
|
||||
serverId={routeServerId}
|
||||
agentId={agentId}
|
||||
pathParamRaw={pathParamRaw}
|
||||
fileParamRaw={fileParamRaw}
|
||||
routeServerId={routeServerId}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
type FileExplorerContentProps = {
|
||||
session: import("@/hooks/use-daemon-session").DaemonSession;
|
||||
serverId: string;
|
||||
agentId?: string;
|
||||
pathParamRaw?: string | string[];
|
||||
fileParamRaw?: string | string[];
|
||||
routeServerId: string;
|
||||
};
|
||||
|
||||
type FileExplorerSessionUnavailableProps = {
|
||||
@@ -105,20 +103,29 @@ type FileExplorerSessionUnavailableProps = {
|
||||
};
|
||||
|
||||
function FileExplorerContent({
|
||||
session,
|
||||
serverId,
|
||||
agentId,
|
||||
pathParamRaw,
|
||||
fileParamRaw,
|
||||
routeServerId,
|
||||
}: FileExplorerContentProps) {
|
||||
const { theme } = useUnistyles();
|
||||
const {
|
||||
agents,
|
||||
fileExplorer,
|
||||
requestDirectoryListing,
|
||||
requestFilePreview,
|
||||
navigateExplorerBack,
|
||||
} = session;
|
||||
|
||||
const agent = useSessionStore((state) =>
|
||||
agentId && state.sessions[serverId]
|
||||
? state.sessions[serverId]?.agents.get(agentId)
|
||||
: undefined
|
||||
);
|
||||
|
||||
const explorerState = useSessionStore((state) =>
|
||||
agentId && state.sessions[serverId]
|
||||
? state.sessions[serverId]?.fileExplorer.get(agentId)
|
||||
: undefined
|
||||
);
|
||||
|
||||
const methods = useSessionStore((state) => state.sessions[serverId]?.methods);
|
||||
const requestDirectoryListing = methods?.requestDirectoryListing;
|
||||
const requestFilePreview = methods?.requestFilePreview;
|
||||
const navigateExplorerBack = methods?.navigateExplorerBack;
|
||||
const [viewMode, setViewMode] = useState<"list" | "grid">("list");
|
||||
const [selectedEntryPath, setSelectedEntryPath] = useState<string | null>(null);
|
||||
const pendingPathParamRef = useRef<string | null>(null);
|
||||
@@ -136,9 +143,6 @@ function FileExplorerContent({
|
||||
const derivedDirectoryFromFile = normalizedFileParam
|
||||
? deriveDirectoryFromFile(normalizedFileParam)
|
||||
: null;
|
||||
|
||||
const agent = agentId ? agents.get(agentId) : undefined;
|
||||
const explorerState = agentId ? fileExplorer.get(agentId) : undefined;
|
||||
const history = explorerState?.history ?? [];
|
||||
const lastKnownDirectory = history[history.length - 1];
|
||||
const rememberedDirectory = explorerState?.lastVisitedPath;
|
||||
@@ -237,7 +241,7 @@ function FileExplorerContent({
|
||||
}, [activePath]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!agentId || !initialTargetDirectory) {
|
||||
if (!agentId || !initialTargetDirectory || !requestDirectoryListing) {
|
||||
pendingPathParamRef.current = null;
|
||||
return;
|
||||
}
|
||||
@@ -251,7 +255,7 @@ function FileExplorerContent({
|
||||
}, [agentId, initialTargetDirectory, requestDirectoryListing]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!agentId || !normalizedFileParam) {
|
||||
if (!agentId || !normalizedFileParam || !requestFilePreview) {
|
||||
pendingFileParamRef.current = null;
|
||||
return;
|
||||
}
|
||||
@@ -281,7 +285,7 @@ function FileExplorerContent({
|
||||
|
||||
const handleEntryPress = useCallback(
|
||||
(entry: ExplorerEntry) => {
|
||||
if (!agentId) {
|
||||
if (!agentId || !requestDirectoryListing || !requestFilePreview) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -330,13 +334,13 @@ function FileExplorerContent({
|
||||
if (agentId) {
|
||||
router.replace({
|
||||
pathname: "/agent/[serverId]/[agentId]",
|
||||
params: { serverId: routeServerId, agentId },
|
||||
params: { serverId, agentId },
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
router.back();
|
||||
}, [agentId, routeServerId]);
|
||||
}, [agentId, serverId]);
|
||||
|
||||
const handleBackNavigation = useCallback(() => {
|
||||
if (!agentId) {
|
||||
@@ -349,7 +353,7 @@ function FileExplorerContent({
|
||||
return true;
|
||||
}
|
||||
|
||||
if ((explorerState?.history?.length ?? 0) > 1) {
|
||||
if ((explorerState?.history?.length ?? 0) > 1 && navigateExplorerBack) {
|
||||
navigateExplorerBack(agentId);
|
||||
return true;
|
||||
}
|
||||
@@ -472,7 +476,7 @@ function FileExplorerContent({
|
||||
|
||||
const handleViewableItemsChanged = useCallback(
|
||||
({ viewableItems }: { viewableItems: Array<ViewToken> }) => {
|
||||
if (!agentId || viewMode !== "grid") {
|
||||
if (!agentId || viewMode !== "grid" || !requestFilePreview) {
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -3,11 +3,10 @@ import { View, Text, ScrollView, ActivityIndicator } from "react-native";
|
||||
import { useLocalSearchParams } from "expo-router";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
import { BackHeader } from "@/components/headers/back-header";
|
||||
import type { SessionContextValue } from "@/contexts/session-context";
|
||||
import type { ConnectionStatus } from "@/contexts/daemon-connections-context";
|
||||
import { useDaemonConnections } from "@/contexts/daemon-connections-context";
|
||||
import { formatConnectionStatus } from "@/utils/daemons";
|
||||
import { useDaemonSession } from "@/hooks/use-daemon-session";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
|
||||
interface ParsedDiffFile {
|
||||
path: string;
|
||||
@@ -58,10 +57,8 @@ export default function GitDiffScreen() {
|
||||
const { agentId, serverId } = useLocalSearchParams<{ agentId: string; serverId?: string }>();
|
||||
const resolvedServerId = typeof serverId === "string" ? serverId : undefined;
|
||||
const { connectionStates } = useDaemonConnections();
|
||||
const session = useDaemonSession(resolvedServerId, {
|
||||
suppressUnavailableAlert: true,
|
||||
allowUnavailable: true,
|
||||
});
|
||||
|
||||
const session = useSessionStore((state) => resolvedServerId ? state.sessions[resolvedServerId] : undefined);
|
||||
|
||||
const connectionServerId = resolvedServerId ?? null;
|
||||
const connection = connectionServerId ? connectionStates.get(connectionServerId) : null;
|
||||
@@ -83,7 +80,7 @@ export default function GitDiffScreen() {
|
||||
|
||||
return (
|
||||
<GitDiffContent
|
||||
session={session}
|
||||
serverId={session.serverId}
|
||||
agentId={agentId}
|
||||
serverLabel={serverLabel}
|
||||
/>
|
||||
@@ -91,22 +88,30 @@ export default function GitDiffScreen() {
|
||||
}
|
||||
|
||||
function GitDiffContent({
|
||||
session,
|
||||
serverId,
|
||||
agentId,
|
||||
serverLabel,
|
||||
}: {
|
||||
session: import("@/hooks/use-daemon-session").DaemonSession;
|
||||
serverId: string;
|
||||
agentId?: string;
|
||||
serverLabel: string;
|
||||
}) {
|
||||
const { agents, gitDiffs, requestGitDiff } = session;
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
const agent = agentId ? agents.get(agentId) : undefined;
|
||||
const diffText = agentId ? gitDiffs.get(agentId) : undefined;
|
||||
const agent = useSessionStore((state) =>
|
||||
agentId && serverId ? state.sessions[serverId]?.agents?.get(agentId) : undefined
|
||||
);
|
||||
|
||||
const diffText = useSessionStore((state) =>
|
||||
agentId && serverId ? state.sessions[serverId]?.gitDiffs?.get(agentId) : undefined
|
||||
);
|
||||
|
||||
const requestGitDiff = useSessionStore((state) =>
|
||||
serverId ? state.sessions[serverId]?.methods?.requestGitDiff : undefined
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!agentId) {
|
||||
if (!agentId || !requestGitDiff) {
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -19,8 +19,7 @@ import { useDaemonConnections, type ConnectionStatus } from "@/contexts/daemon-c
|
||||
import { formatConnectionStatus, getConnectionStatusTone } from "@/utils/daemons";
|
||||
import { theme as defaultTheme } from "@/styles/theme";
|
||||
import { BackHeader } from "@/components/headers/back-header";
|
||||
import { useSessionForServer } from "@/hooks/use-session-directory";
|
||||
import { useDaemonSession } from "@/hooks/use-daemon-session";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
import type { UseWebSocketReturn } from "@/hooks/use-websocket";
|
||||
|
||||
const delay = (ms: number) =>
|
||||
@@ -799,12 +798,8 @@ function DaemonCard({
|
||||
: theme.colors.mutedForeground;
|
||||
const badgeText = statusLabel;
|
||||
const connectionError = typeof lastError === "string" && lastError.trim().length > 0 ? lastError.trim() : null;
|
||||
const daemonWs = useSessionForServer<UseWebSocketReturn | null>(
|
||||
daemon.id,
|
||||
useCallback((session) => session?.ws ?? null, [])
|
||||
);
|
||||
const fullSession = useDaemonSession(daemon.id, { allowUnavailable: true, suppressUnavailableAlert: true });
|
||||
const restartServerFn = fullSession?.restartServer ?? null;
|
||||
const daemonWs = useSessionStore((state) => state.sessions[daemon.id]?.ws);
|
||||
const restartServerFn = useSessionStore((state) => state.sessions[daemon.id]?.methods?.restartServer);
|
||||
const [isRestarting, setIsRestarting] = useState(false);
|
||||
const wsIsConnectedRef = useRef(daemonWs?.isConnected ?? false);
|
||||
const isTesting = testState?.status === "testing";
|
||||
|
||||
@@ -31,9 +31,8 @@ import { RealtimeControls } from "./realtime-controls";
|
||||
import { useImageAttachmentPicker } from "@/hooks/use-image-attachment-picker";
|
||||
import { AUDIO_DEBUG_ENABLED } from "@/config/audio-debug";
|
||||
import { AudioDebugNotice, type AudioDebugInfo } from "./audio-debug-notice";
|
||||
import { useDaemonSession } from "@/hooks/use-daemon-session";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
import type { UseWebSocketReturn } from "@/hooks/use-websocket";
|
||||
import type { SessionContextValue, Agent } from "@/contexts/session-context";
|
||||
import type { SessionOutboundMessage } from "@server/server/messages";
|
||||
|
||||
type QueuedMessage = {
|
||||
@@ -44,11 +43,12 @@ type QueuedMessage = {
|
||||
|
||||
interface AgentInputAreaProps {
|
||||
agentId: string;
|
||||
serverId?: string;
|
||||
serverId: string;
|
||||
}
|
||||
|
||||
const MIN_INPUT_HEIGHT = 50;
|
||||
const MAX_INPUT_HEIGHT = 160;
|
||||
const EMPTY_ARRAY: readonly QueuedMessage[] = [];
|
||||
const MAX_INPUT_WIDTH = 960;
|
||||
const BASE_VERTICAL_PADDING = (FOOTER_HEIGHT - MIN_INPUT_HEIGHT) / 2;
|
||||
// Android currently crashes inside ViewGroup.dispatchDraw when running Reanimated
|
||||
@@ -85,7 +85,38 @@ type DictationToastConfig = {
|
||||
|
||||
export function AgentInputArea({ agentId, serverId }: AgentInputAreaProps) {
|
||||
const { theme } = useUnistyles();
|
||||
const session = useDaemonSession(serverId, { allowUnavailable: true, suppressUnavailableAlert: true });
|
||||
|
||||
// [INVESTIGATION] Using granular selectors for WebSocket connection status
|
||||
const ws = useSessionStore((state) => state.sessions[serverId]?.ws);
|
||||
|
||||
// [INVESTIGATION] Select only the specific agent (not all agents)
|
||||
const agent = useSessionStore((state) =>
|
||||
state.sessions[serverId]?.agents?.get(agentId)
|
||||
);
|
||||
|
||||
// [INVESTIGATION] Get draft input - drafts are at TOP LEVEL (not in session)
|
||||
const getDraftInput = useSessionStore((state) => state.getDraftInput);
|
||||
const saveDraftInput = useSessionStore((state) => state.saveDraftInput);
|
||||
|
||||
// [INVESTIGATION] Get queued messages for this agent - use stable empty array
|
||||
const queuedMessagesRaw = useSessionStore((state) =>
|
||||
state.sessions[serverId]?.queuedMessages?.get(agentId)
|
||||
);
|
||||
const queuedMessages = queuedMessagesRaw ?? EMPTY_ARRAY;
|
||||
|
||||
// [INVESTIGATION] Get methods
|
||||
const methods = useSessionStore((state) => state.sessions[serverId]?.methods);
|
||||
const sendAgentMessage = methods?.sendAgentMessage;
|
||||
const cancelAgentRun = methods?.cancelAgentRun;
|
||||
|
||||
// [INVESTIGATION] Use store action directly for setQueuedMessages
|
||||
const setQueuedMessages = useSessionStore((state) => state.setQueuedMessages);
|
||||
|
||||
// Noop fallback for sendAgentAudio (required by useDictation)
|
||||
const noopSendAgentAudio = useCallback(async () => {}, []);
|
||||
const sendAgentAudio = methods?.sendAgentAudio ?? noopSendAgentAudio;
|
||||
|
||||
// Inert WebSocket fallback for when ws is undefined
|
||||
const inertWebSocket = useMemo<UseWebSocketReturn>(
|
||||
() => ({
|
||||
isConnected: false,
|
||||
@@ -101,21 +132,8 @@ export function AgentInputArea({ agentId, serverId }: AgentInputAreaProps) {
|
||||
}),
|
||||
[]
|
||||
);
|
||||
const noopSendAgentMessage = useCallback(async () => {}, []);
|
||||
const noopSendAgentAudio = useCallback(async () => {}, []);
|
||||
const noopCancelAgentRun = useCallback(() => {}, []);
|
||||
const noopGetDraftInput = useCallback(() => undefined, []);
|
||||
const noopSaveDraftInput = useCallback(() => {}, []);
|
||||
const noopSetQueuedMessages = useCallback(() => {}, []);
|
||||
const ws = session?.ws ?? inertWebSocket;
|
||||
const sendAgentMessage = session?.sendAgentMessage ?? noopSendAgentMessage;
|
||||
const sendAgentAudio = session?.sendAgentAudio ?? noopSendAgentAudio;
|
||||
const agents = session?.agents ?? new Map<string, Agent>();
|
||||
const cancelAgentRun = session?.cancelAgentRun ?? noopCancelAgentRun;
|
||||
const getDraftInput = session?.getDraftInput ?? noopGetDraftInput;
|
||||
const saveDraftInput = session?.saveDraftInput ?? noopSaveDraftInput;
|
||||
const queuedMessagesByAgent = session?.queuedMessages ?? new Map<string, QueuedMessage[]>();
|
||||
const setQueuedMessagesByAgent = session?.setQueuedMessages ?? noopSetQueuedMessages;
|
||||
|
||||
const wsOrInert = ws ?? inertWebSocket;
|
||||
const { startRealtime, stopRealtime, isRealtimeMode } = useRealtime();
|
||||
|
||||
const [userInput, setUserInput] = useState("");
|
||||
@@ -125,7 +143,7 @@ export function AgentInputArea({ agentId, serverId }: AgentInputAreaProps) {
|
||||
const [isCancellingAgent, setIsCancellingAgent] = useState(false);
|
||||
const [audioDebugInfo, setAudioDebugInfo] = useState<AudioDebugInfo | null>(null);
|
||||
const [connectionStatus, setConnectionStatus] = useState(() =>
|
||||
ws.getConnectionState ? ws.getConnectionState() : { isConnected: ws.isConnected, isConnecting: ws.isConnecting }
|
||||
wsOrInert.getConnectionState ? wsOrInert.getConnectionState() : { isConnected: wsOrInert.isConnected, isConnecting: wsOrInert.isConnecting }
|
||||
);
|
||||
const [lastSuccessToastAt, setLastSuccessToastAt] = useState<number | null>(null);
|
||||
|
||||
@@ -180,7 +198,7 @@ export function AgentInputArea({ agentId, serverId }: AgentInputAreaProps) {
|
||||
}, []);
|
||||
|
||||
const canStartDictation = useCallback(() => {
|
||||
const socketConnected = ws.getConnectionState ? ws.getConnectionState().isConnected : ws.isConnected;
|
||||
const socketConnected = wsOrInert.getConnectionState ? wsOrInert.getConnectionState().isConnected : wsOrInert.isConnected;
|
||||
const allowed = !isRealtimeMode && socketConnected;
|
||||
console.log("[AgentInput] canStartDictation", {
|
||||
allowed,
|
||||
@@ -188,17 +206,17 @@ export function AgentInputArea({ agentId, serverId }: AgentInputAreaProps) {
|
||||
wsConnected: socketConnected,
|
||||
});
|
||||
return allowed;
|
||||
}, [isRealtimeMode, ws]);
|
||||
}, [isRealtimeMode, wsOrInert]);
|
||||
|
||||
const canConfirmDictation = useCallback(() => {
|
||||
const socketConnected = ws.getConnectionState ? ws.getConnectionState().isConnected : ws.isConnected;
|
||||
const socketConnected = wsOrInert.getConnectionState ? wsOrInert.getConnectionState().isConnected : wsOrInert.isConnected;
|
||||
const allowed = socketConnected;
|
||||
console.log("[AgentInput] canConfirmDictation", {
|
||||
allowed,
|
||||
wsConnected: socketConnected,
|
||||
});
|
||||
return allowed;
|
||||
}, [ws]);
|
||||
}, [wsOrInert]);
|
||||
|
||||
const {
|
||||
isRecording: isDictating,
|
||||
@@ -220,7 +238,7 @@ export function AgentInputArea({ agentId, serverId }: AgentInputAreaProps) {
|
||||
} = useDictation({
|
||||
agentId,
|
||||
sendAgentAudio,
|
||||
ws,
|
||||
ws: wsOrInert,
|
||||
mode: "transcribe_only",
|
||||
onTranscript: handleDictationTranscript,
|
||||
onError: handleDictationError,
|
||||
@@ -235,13 +253,13 @@ export function AgentInputArea({ agentId, serverId }: AgentInputAreaProps) {
|
||||
}, [dictationPendingRequestId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!ws.subscribeConnectionStatus) {
|
||||
if (!wsOrInert.subscribeConnectionStatus) {
|
||||
return;
|
||||
}
|
||||
return ws.subscribeConnectionStatus((status) => {
|
||||
return wsOrInert.subscribeConnectionStatus((status) => {
|
||||
setConnectionStatus(status);
|
||||
});
|
||||
}, [ws]);
|
||||
}, [wsOrInert]);
|
||||
|
||||
useEffect(() => {
|
||||
if (dictationLastOutcome?.type === "success") {
|
||||
@@ -274,9 +292,9 @@ export function AgentInputArea({ agentId, serverId }: AgentInputAreaProps) {
|
||||
const dictationToast = useMemo<DictationToastConfig | null>(() => {
|
||||
console.log('[AgentInputArea] Connection status:', {
|
||||
isConnected: connectionStatus.isConnected,
|
||||
wsIsConnected: ws.isConnected,
|
||||
wsIsConnecting: ws.isConnecting,
|
||||
hasSession: !!session,
|
||||
wsIsConnected: wsOrInert.isConnected,
|
||||
wsIsConnecting: wsOrInert.isConnecting,
|
||||
hasWs: !!ws,
|
||||
});
|
||||
|
||||
if (!connectionStatus.isConnected) {
|
||||
@@ -375,8 +393,8 @@ export function AgentInputArea({ agentId, serverId }: AgentInputAreaProps) {
|
||||
}, [sendAgentMessage]);
|
||||
|
||||
async function handleSendMessage() {
|
||||
const socketConnected = ws.getConnectionState ? ws.getConnectionState().isConnected : ws.isConnected;
|
||||
if (!userInput.trim() || !socketConnected) return;
|
||||
const socketConnected = wsOrInert.getConnectionState ? wsOrInert.getConnectionState().isConnected : wsOrInert.isConnected;
|
||||
if (!userInput.trim() || !socketConnected || !sendAgentMessage) return;
|
||||
|
||||
const message = userInput.trim();
|
||||
const imageAttachments = selectedImages.length > 0 ? selectedImages : undefined;
|
||||
@@ -416,7 +434,7 @@ export function AgentInputArea({ agentId, serverId }: AgentInputAreaProps) {
|
||||
console.log("[AgentInput] handleVoicePress", {
|
||||
isDictating,
|
||||
isRealtimeMode,
|
||||
wsConnected: ws.isConnected,
|
||||
wsConnected: wsOrInert.isConnected,
|
||||
});
|
||||
if (isDictating || isRealtimeMode) {
|
||||
return;
|
||||
@@ -480,7 +498,7 @@ export function AgentInputArea({ agentId, serverId }: AgentInputAreaProps) {
|
||||
if (!shouldShowAudioDebug) {
|
||||
return;
|
||||
}
|
||||
const unsubscribe = ws.on("transcription_result", (message: SessionOutboundMessage) => {
|
||||
const unsubscribe = wsOrInert.on("transcription_result", (message: SessionOutboundMessage) => {
|
||||
if (message.type !== "transcription_result") {
|
||||
return;
|
||||
}
|
||||
@@ -506,7 +524,7 @@ export function AgentInputArea({ agentId, serverId }: AgentInputAreaProps) {
|
||||
return () => {
|
||||
unsubscribe();
|
||||
};
|
||||
}, [shouldShowAudioDebug, ws]);
|
||||
}, [shouldShowAudioDebug, wsOrInert]);
|
||||
|
||||
function isTextAreaLike(value: unknown): value is TextAreaHandle {
|
||||
if (!value || typeof value !== "object") {
|
||||
@@ -656,7 +674,6 @@ export function AgentInputArea({ agentId, serverId }: AgentInputAreaProps) {
|
||||
});
|
||||
}
|
||||
|
||||
const agent = agents.get(agentId);
|
||||
const isAgentRunning = agent?.status === "running";
|
||||
agentStatusRef.current = agent?.status;
|
||||
const hasText = userInput.trim().length > 0;
|
||||
@@ -664,18 +681,17 @@ export function AgentInputArea({ agentId, serverId }: AgentInputAreaProps) {
|
||||
const hasSendableContent = hasText || hasImages;
|
||||
const shouldShowSendButton = !isAgentRunning && hasSendableContent;
|
||||
const shouldShowVoiceControls = !hasSendableContent;
|
||||
const queuedMessages = queuedMessagesByAgent.get(agentId) ?? [];
|
||||
const shouldHandleDesktopSubmit = IS_WEB;
|
||||
|
||||
const updateQueue = useCallback(
|
||||
(updater: (current: QueuedMessage[]) => QueuedMessage[]) => {
|
||||
setQueuedMessagesByAgent((prev: Map<string, QueuedMessage[]>) => {
|
||||
setQueuedMessages(serverId, (prev: Map<string, QueuedMessage[]>) => {
|
||||
const next = new Map(prev);
|
||||
next.set(agentId, updater(prev.get(agentId) ?? []));
|
||||
return next;
|
||||
});
|
||||
},
|
||||
[agentId, setQueuedMessagesByAgent],
|
||||
[agentId, serverId, setQueuedMessages],
|
||||
);
|
||||
useEffect(() => {
|
||||
updateQueueRef.current = updateQueue;
|
||||
@@ -702,23 +718,23 @@ export function AgentInputArea({ agentId, serverId }: AgentInputAreaProps) {
|
||||
}
|
||||
event.preventDefault();
|
||||
if (isAgentRunning) {
|
||||
if (!hasSendableContent || !ws.isConnected) {
|
||||
if (!hasSendableContent || !wsOrInert.isConnected) {
|
||||
return;
|
||||
}
|
||||
handleQueueCurrentInput();
|
||||
return;
|
||||
}
|
||||
if (!shouldShowSendButton || isProcessing || !ws.isConnected) {
|
||||
if (!shouldShowSendButton || isProcessing || !wsOrInert.isConnected) {
|
||||
return;
|
||||
}
|
||||
void handleSendMessage();
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!isAgentRunning || !ws.isConnected) {
|
||||
if (!isAgentRunning || !wsOrInert.isConnected) {
|
||||
setIsCancellingAgent(false);
|
||||
}
|
||||
}, [isAgentRunning, ws.isConnected]);
|
||||
}, [isAgentRunning, wsOrInert.isConnected]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!IS_WEB) {
|
||||
@@ -738,7 +754,7 @@ export function AgentInputArea({ agentId, serverId }: AgentInputAreaProps) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isRealtimeMode && shouldShowVoiceControls && ws.isConnected) {
|
||||
if (!isRealtimeMode && shouldShowVoiceControls && wsOrInert.isConnected) {
|
||||
void handleVoicePress();
|
||||
}
|
||||
return;
|
||||
@@ -759,7 +775,7 @@ export function AgentInputArea({ agentId, serverId }: AgentInputAreaProps) {
|
||||
isDictating,
|
||||
isRealtimeMode,
|
||||
shouldShowVoiceControls,
|
||||
ws,
|
||||
wsOrInert,
|
||||
handleVoicePress,
|
||||
handleSendRecording,
|
||||
handleCancelRecording,
|
||||
@@ -820,7 +836,7 @@ export function AgentInputArea({ agentId, serverId }: AgentInputAreaProps) {
|
||||
if (isRealtimeMode) {
|
||||
await stopRealtime();
|
||||
} else {
|
||||
if (!ws.isConnected || !serverId) {
|
||||
if (!wsOrInert.isConnected || !serverId) {
|
||||
return;
|
||||
}
|
||||
await startRealtime(serverId);
|
||||
@@ -834,7 +850,7 @@ export function AgentInputArea({ agentId, serverId }: AgentInputAreaProps) {
|
||||
if (!agent || agent.status !== "running" || isCancellingAgent) {
|
||||
return;
|
||||
}
|
||||
if (!ws.isConnected) {
|
||||
if (!wsOrInert.isConnected || !cancelAgentRun) {
|
||||
return;
|
||||
}
|
||||
setIsCancellingAgent(true);
|
||||
@@ -867,7 +883,7 @@ export function AgentInputArea({ agentId, serverId }: AgentInputAreaProps) {
|
||||
|
||||
async function handleSendQueuedNow(id: string) {
|
||||
const item = queuedMessages.find((q) => q.id === id);
|
||||
if (!item || !ws.isConnected) return;
|
||||
if (!item || !wsOrInert.isConnected || !sendAgentMessage) return;
|
||||
|
||||
updateQueue((current) => current.filter((q) => q.id !== id));
|
||||
|
||||
@@ -879,11 +895,11 @@ export function AgentInputArea({ agentId, serverId }: AgentInputAreaProps) {
|
||||
const realtimeButton = (
|
||||
<Pressable
|
||||
onPress={handleRealtimePress}
|
||||
disabled={!ws.isConnected && !isRealtimeMode}
|
||||
disabled={!wsOrInert.isConnected && !isRealtimeMode}
|
||||
style={[
|
||||
styles.realtimeButton as any,
|
||||
(isRealtimeMode ? styles.realtimeButtonActive : undefined) as any,
|
||||
(!ws.isConnected && !isRealtimeMode ? styles.buttonDisabled : undefined) as any,
|
||||
(!wsOrInert.isConnected && !isRealtimeMode ? styles.buttonDisabled : undefined) as any,
|
||||
]}
|
||||
>
|
||||
{isRealtimeMode ? (
|
||||
@@ -897,10 +913,10 @@ export function AgentInputArea({ agentId, serverId }: AgentInputAreaProps) {
|
||||
const voiceButton = (
|
||||
<Pressable
|
||||
onPress={handleVoicePress}
|
||||
disabled={!ws.isConnected || isRealtimeMode}
|
||||
disabled={!wsOrInert.isConnected || isRealtimeMode}
|
||||
style={[
|
||||
styles.voiceButton as any,
|
||||
(!ws.isConnected || isRealtimeMode ? styles.buttonDisabled : undefined) as any,
|
||||
(!wsOrInert.isConnected || isRealtimeMode ? styles.buttonDisabled : undefined) as any,
|
||||
(isDictating ? styles.voiceButtonRecording : undefined) as any,
|
||||
]}
|
||||
>
|
||||
@@ -991,7 +1007,7 @@ export function AgentInputArea({ agentId, serverId }: AgentInputAreaProps) {
|
||||
multiline
|
||||
scrollEnabled={inputHeight >= MAX_INPUT_HEIGHT}
|
||||
onContentSizeChange={handleContentSizeChange}
|
||||
editable={!isDictating && ws.isConnected}
|
||||
editable={!isDictating && wsOrInert.isConnected}
|
||||
onKeyPress={shouldHandleDesktopSubmit ? handleDesktopSubmitKeyPress : undefined}
|
||||
/>
|
||||
|
||||
@@ -1001,10 +1017,10 @@ export function AgentInputArea({ agentId, serverId }: AgentInputAreaProps) {
|
||||
<View style={styles.leftButtonGroup}>
|
||||
<Pressable
|
||||
onPress={handlePickImage}
|
||||
disabled={!ws.isConnected}
|
||||
disabled={!wsOrInert.isConnected}
|
||||
style={[
|
||||
styles.attachButton as any,
|
||||
(!ws.isConnected ? styles.buttonDisabled : undefined) as any,
|
||||
(!wsOrInert.isConnected ? styles.buttonDisabled : undefined) as any,
|
||||
]}
|
||||
>
|
||||
<Paperclip size={20} color={theme.colors.foreground} />
|
||||
@@ -1020,12 +1036,12 @@ export function AgentInputArea({ agentId, serverId }: AgentInputAreaProps) {
|
||||
{hasSendableContent && (
|
||||
<Pressable
|
||||
onPress={handleQueueCurrentInput}
|
||||
disabled={!ws.isConnected}
|
||||
disabled={!wsOrInert.isConnected}
|
||||
accessibilityLabel="Queue message while agent is running"
|
||||
accessibilityRole="button"
|
||||
style={[
|
||||
styles.queueButton as any,
|
||||
(!ws.isConnected ? styles.buttonDisabled : undefined) as any,
|
||||
(!wsOrInert.isConnected ? styles.buttonDisabled : undefined) as any,
|
||||
]}
|
||||
>
|
||||
<ArrowUp size={20} color="white" />
|
||||
@@ -1034,12 +1050,12 @@ export function AgentInputArea({ agentId, serverId }: AgentInputAreaProps) {
|
||||
{realtimeButton}
|
||||
<Pressable
|
||||
onPress={handleCancelAgent}
|
||||
disabled={!ws.isConnected || isCancellingAgent}
|
||||
disabled={!wsOrInert.isConnected || isCancellingAgent}
|
||||
accessibilityLabel={isCancellingAgent ? "Canceling agent" : "Stop agent"}
|
||||
accessibilityRole="button"
|
||||
style={[
|
||||
styles.cancelButton as any,
|
||||
(!ws.isConnected || isCancellingAgent ? styles.buttonDisabled : undefined) as any,
|
||||
(!wsOrInert.isConnected || isCancellingAgent ? styles.buttonDisabled : undefined) as any,
|
||||
]}
|
||||
>
|
||||
{isCancellingAgent ? (
|
||||
@@ -1052,10 +1068,10 @@ export function AgentInputArea({ agentId, serverId }: AgentInputAreaProps) {
|
||||
) : shouldShowSendButton ? (
|
||||
<Pressable
|
||||
onPress={handleSendMessage}
|
||||
disabled={!ws.isConnected || isProcessing}
|
||||
disabled={!wsOrInert.isConnected || isProcessing}
|
||||
style={[
|
||||
styles.sendButton as any,
|
||||
(!ws.isConnected || isProcessing ? styles.buttonDisabled : undefined) as any,
|
||||
(!wsOrInert.isConnected || isProcessing ? styles.buttonDisabled : undefined) as any,
|
||||
]}
|
||||
>
|
||||
<ArrowUp size={20} color="white" />
|
||||
|
||||
@@ -6,7 +6,7 @@ import { formatTimeAgo } from "@/utils/time";
|
||||
import { getAgentStatusColor, getAgentStatusLabel } from "@/utils/agent-status";
|
||||
import { getAgentProviderDefinition } from "@server/server/agent/provider-manifest";
|
||||
import { type AggregatedAgent } from "@/hooks/use-aggregated-agents";
|
||||
import { useDaemonSession } from "@/hooks/use-daemon-session";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
import { buildAgentNavigationKey, startNavigationTiming } from "@/utils/navigation-timing";
|
||||
|
||||
interface AgentListProps {
|
||||
@@ -18,14 +18,15 @@ interface AgentListProps {
|
||||
export function AgentList({ agents, isRefreshing = false, onRefresh }: AgentListProps) {
|
||||
const { theme } = useUnistyles();
|
||||
const [actionAgent, setActionAgent] = useState<AggregatedAgent | null>(null);
|
||||
const actionSession = useDaemonSession(actionAgent?.serverId, {
|
||||
suppressUnavailableAlert: true,
|
||||
allowUnavailable: true,
|
||||
});
|
||||
|
||||
const deleteAgent = actionSession?.deleteAgent;
|
||||
// Get the methods for the specific server
|
||||
const methods = useSessionStore((state) =>
|
||||
actionAgent?.serverId ? state.sessions[actionAgent.serverId]?.methods : undefined
|
||||
);
|
||||
const deleteAgent = methods?.deleteAgent;
|
||||
|
||||
const isActionSheetVisible = actionAgent !== null;
|
||||
const isActionDaemonUnavailable = Boolean(actionAgent?.serverId && !actionSession);
|
||||
const isActionDaemonUnavailable = Boolean(actionAgent?.serverId && !methods);
|
||||
|
||||
const handleAgentPress = useCallback(
|
||||
(serverId: string, agentId: string) => {
|
||||
|
||||
@@ -1,37 +1,43 @@
|
||||
import { View, Text, Pressable } from "react-native";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import { ChevronDown } from "lucide-react-native";
|
||||
import { useState, useCallback, useRef } from "react";
|
||||
import { useState, useRef } from "react";
|
||||
import { ModeSelectorModal } from "./mode-selector-modal";
|
||||
import { useDaemonSession } from "@/hooks/use-daemon-session";
|
||||
import type { SessionContextValue, Agent } from "@/contexts/session-context";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
|
||||
interface AgentStatusBarProps {
|
||||
agentId: string;
|
||||
serverId?: string;
|
||||
serverId: string;
|
||||
}
|
||||
|
||||
export function AgentStatusBar({ agentId, serverId }: AgentStatusBarProps) {
|
||||
// INVESTIGATION: Log rerenders
|
||||
const renderCountRef = useRef(0);
|
||||
renderCountRef.current++;
|
||||
console.log(`[INVESTIGATION] AgentStatusBar render #${renderCountRef.current}`, { agentId });
|
||||
console.log(`[INVESTIGATION] AgentStatusBar render #${renderCountRef.current}`, { agentId, serverId });
|
||||
const { theme } = useUnistyles();
|
||||
const session = useDaemonSession(serverId, { allowUnavailable: true, suppressUnavailableAlert: true });
|
||||
const noopSetAgentMode = useCallback<SessionContextValue["setAgentMode"]>(() => {}, []);
|
||||
const agents = session?.agents ?? new Map<string, Agent>();
|
||||
const setAgentMode = session?.setAgentMode ?? noopSetAgentMode;
|
||||
const [showModeSelector, setShowModeSelector] = useState(false);
|
||||
|
||||
const agent = agents.get(agentId);
|
||||
// Select only the specific agent (not all agents)
|
||||
const agent = useSessionStore((state) =>
|
||||
state.sessions[serverId]?.agents?.get(agentId)
|
||||
);
|
||||
|
||||
// Get the setAgentMode action (actions are stable, won't cause rerenders)
|
||||
const setAgentMode = useSessionStore((state) =>
|
||||
state.sessions[serverId]?.methods?.setAgentMode
|
||||
);
|
||||
|
||||
const [showModeSelector, setShowModeSelector] = useState(false);
|
||||
|
||||
if (!agent) {
|
||||
return null;
|
||||
}
|
||||
|
||||
function handleModeChange(modeId: string) {
|
||||
if (setAgentMode) {
|
||||
setAgentMode(agentId, modeId);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
|
||||
@@ -32,7 +32,7 @@ import type { StreamItem } from "@/types/stream";
|
||||
import type { PendingPermission } from "@/types/shared";
|
||||
import type { AgentPermissionResponse } from "@server/server/agent/agent-sdk-types";
|
||||
import type { Agent } from "@/contexts/session-context";
|
||||
import { useDaemonSession } from "@/hooks/use-daemon-session";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
import { useDaemonRequest } from "@/hooks/use-daemon-request";
|
||||
import type { UseWebSocketReturn } from "@/hooks/use-websocket";
|
||||
import type { SessionOutboundMessage } from "@server/server/messages";
|
||||
@@ -79,7 +79,19 @@ export function AgentStreamView({
|
||||
const isNearBottomRef = useRef(true);
|
||||
const isUserScrollingRef = useRef(false);
|
||||
const router = useRouter();
|
||||
const session = useDaemonSession(serverId, { allowUnavailable: true, suppressUnavailableAlert: true });
|
||||
|
||||
// Get serverId (fallback to agent's serverId if not provided)
|
||||
const resolvedServerId = serverId ?? agent.serverId ?? "";
|
||||
|
||||
// Get ws for connection status
|
||||
const ws = useSessionStore((state) => state.sessions[resolvedServerId]?.ws);
|
||||
|
||||
// Get methods for file operations
|
||||
const methods = useSessionStore((state) => state.sessions[resolvedServerId]?.methods);
|
||||
const requestDirectoryListing = methods?.requestDirectoryListing;
|
||||
const requestFilePreview = methods?.requestFilePreview;
|
||||
|
||||
// Create inert websocket fallback if ws is null
|
||||
const inertWebSocket = useMemo<UseWebSocketReturn>(
|
||||
() => ({
|
||||
isConnected: false,
|
||||
@@ -93,10 +105,9 @@ export function AgentStreamView({
|
||||
}),
|
||||
[]
|
||||
);
|
||||
const ws = session?.ws ?? inertWebSocket;
|
||||
const requestDirectoryListing = session?.requestDirectoryListing ?? (() => {});
|
||||
const requestFilePreview = session?.requestFilePreview ?? (() => {});
|
||||
const resolvedServerId = serverId ?? session?.serverId ?? agent.serverId ?? "";
|
||||
const wsOrInert = ws ?? inertWebSocket;
|
||||
const requestDirectoryListingOrInert = requestDirectoryListing ?? (() => {});
|
||||
const requestFilePreviewOrInert = requestFilePreview ?? (() => {});
|
||||
// Keep entry/exit animations off on Android due to RN dispatchDraw crashes
|
||||
// tracked in react-native-reanimated#8422.
|
||||
const shouldDisableEntryExitAnimations = Platform.OS === "android";
|
||||
@@ -120,9 +131,9 @@ export function AgentStreamView({
|
||||
return;
|
||||
}
|
||||
|
||||
requestDirectoryListing(agentId, normalized.directory);
|
||||
requestDirectoryListingOrInert(agentId, normalized.directory);
|
||||
if (normalized.file) {
|
||||
requestFilePreview(agentId, normalized.file);
|
||||
requestFilePreviewOrInert(agentId, normalized.file);
|
||||
}
|
||||
|
||||
router.push({
|
||||
@@ -141,7 +152,7 @@ export function AgentStreamView({
|
||||
},
|
||||
});
|
||||
},
|
||||
[agentId, requestDirectoryListing, requestFilePreview, resolvedServerId, router]
|
||||
[agentId, requestDirectoryListingOrInert, requestFilePreviewOrInert, resolvedServerId, router]
|
||||
);
|
||||
|
||||
const handleScroll = useCallback(
|
||||
@@ -350,7 +361,7 @@ export function AgentStreamView({
|
||||
{pendingPermissionItems.length > 0 ? (
|
||||
<View style={stylesheet.permissionsContainer}>
|
||||
{pendingPermissionItems.map((permission) => (
|
||||
<PermissionRequestCard key={permission.key} permission={permission} ws={ws} />
|
||||
<PermissionRequestCard key={permission.key} permission={permission} ws={wsOrInert} />
|
||||
))}
|
||||
</View>
|
||||
) : null}
|
||||
@@ -363,7 +374,7 @@ export function AgentStreamView({
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}, [pendingPermissionItems, showWorkingIndicator, ws]);
|
||||
}, [pendingPermissionItems, showWorkingIndicator, wsOrInert]);
|
||||
|
||||
const flatListData = useMemo(() => {
|
||||
return [...streamItems].reverse();
|
||||
|
||||
@@ -65,7 +65,6 @@ import type { WSInboundMessage, SessionOutboundMessage } from "@server/server/me
|
||||
import { formatConnectionStatus } from "@/utils/daemons";
|
||||
import { trackAnalyticsEvent } from "@/utils/analytics";
|
||||
import type { SessionContextValue } from "@/contexts/session-context";
|
||||
import { useDaemonSession } from "@/hooks/use-daemon-session";
|
||||
import type { UseWebSocketReturn } from "@/hooks/use-websocket";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
|
||||
@@ -263,26 +262,29 @@ function AgentFlowModal({
|
||||
}, [serverId, daemonEntries]);
|
||||
const [selectedServerId, setSelectedServerId] = useState<string | null>(initialServerId);
|
||||
|
||||
// Use useDaemonSession to get both state AND APIs
|
||||
const fullSession = useDaemonSession(selectedServerId, { allowUnavailable: true, suppressUnavailableAlert: true });
|
||||
// [INVESTIGATION] Using useSessionStore directly with granular selectors
|
||||
// Get the complete session state for the selected server
|
||||
const sessionState = useSessionStore((state) =>
|
||||
selectedServerId ? state.sessions[selectedServerId] : undefined
|
||||
);
|
||||
|
||||
// Extract only what we need for CreateAgentSessionSlice
|
||||
const session = useMemo<CreateAgentSessionSlice | null>(() => {
|
||||
if (!fullSession) {
|
||||
if (!selectedServerId || !sessionState || !sessionState.methods) {
|
||||
return null;
|
||||
}
|
||||
const slice: CreateAgentSessionSlice = {
|
||||
serverId: fullSession.serverId,
|
||||
ws: fullSession.ws,
|
||||
createAgent: fullSession.createAgent,
|
||||
resumeAgent: fullSession.resumeAgent,
|
||||
sendAgentAudio: fullSession.sendAgentAudio,
|
||||
agents: fullSession.agents,
|
||||
providerModels: fullSession.providerModels,
|
||||
requestProviderModels: fullSession.requestProviderModels,
|
||||
serverId: selectedServerId,
|
||||
ws: sessionState.ws,
|
||||
createAgent: sessionState.methods.createAgent,
|
||||
resumeAgent: sessionState.methods.resumeAgent,
|
||||
sendAgentAudio: sessionState.methods.sendAgentAudio,
|
||||
agents: sessionState.agents,
|
||||
providerModels: sessionState.providerModels,
|
||||
requestProviderModels: sessionState.methods.requestProviderModels,
|
||||
};
|
||||
return slice;
|
||||
}, [fullSession]);
|
||||
}, [selectedServerId, sessionState]);
|
||||
|
||||
// Helper to get session state for background operations
|
||||
// Note: This only returns state, not APIs. For calling methods, need to send WS messages directly.
|
||||
@@ -324,7 +326,6 @@ function AgentFlowModal({
|
||||
}
|
||||
}, [daemonEntries, initialServerId, isVisible, selectedServerId]);
|
||||
|
||||
const ws = session?.ws ?? null;
|
||||
const inertWebSocket = useMemo<UseWebSocketReturn>(
|
||||
() => ({
|
||||
isConnected: false,
|
||||
@@ -340,6 +341,7 @@ function AgentFlowModal({
|
||||
}),
|
||||
[]
|
||||
);
|
||||
const ws = session?.ws ?? null;
|
||||
const effectiveWs: UseWebSocketReturn = ws ?? inertWebSocket;
|
||||
const createAgent = session?.createAgent;
|
||||
const resumeAgent = session?.resumeAgent;
|
||||
|
||||
@@ -1662,7 +1662,7 @@ export function SessionProvider({ children, serverUrl, serverId }: SessionProvid
|
||||
]
|
||||
);
|
||||
|
||||
// Sync imperative methods to Zustand store so useDaemonSession can access them without React Context
|
||||
// Sync imperative methods to Zustand store so components can access them via selectors
|
||||
// Memoize the methods object to avoid infinite re-renders (object reference must be stable)
|
||||
const setSessionMethods = useSessionStore((state) => state.setSessionMethods);
|
||||
const methods = useMemo(() => ({
|
||||
|
||||
@@ -1,180 +0,0 @@
|
||||
import { useCallback, useMemo, useRef } from "react";
|
||||
import { Alert } from "react-native";
|
||||
import { useDaemonConnections } from "@/contexts/daemon-connections-context";
|
||||
import { useSessionStore, type SessionState } from "@/stores/session-store";
|
||||
|
||||
export class DaemonSessionUnavailableError extends Error {
|
||||
serverId: string;
|
||||
|
||||
constructor(serverId: string) {
|
||||
super(`Host session "${serverId}" is unavailable`);
|
||||
this.name = "DaemonSessionUnavailableError";
|
||||
this.serverId = serverId;
|
||||
}
|
||||
}
|
||||
|
||||
type UseDaemonSessionOptions = {
|
||||
suppressUnavailableAlert?: boolean;
|
||||
allowUnavailable?: boolean;
|
||||
};
|
||||
|
||||
// Combined type: SessionState (from store) + imperative APIs (from context)
|
||||
export type DaemonSession = SessionState & {
|
||||
// Imperative APIs from context
|
||||
setVoiceDetectionFlags: (isDetecting: boolean, isSpeaking: boolean) => void;
|
||||
requestGitDiff: (agentId: string) => void;
|
||||
requestDirectoryListing: (agentId: string, path: string, options?: { recordHistory?: boolean }) => void;
|
||||
requestFilePreview: (agentId: string, path: string) => void;
|
||||
navigateExplorerBack: (agentId: string) => string | null;
|
||||
requestProviderModels: (provider: any, options?: { cwd?: string }) => void;
|
||||
restartServer: (reason?: string) => void;
|
||||
initializeAgent: (params: { agentId: string; requestId?: string }) => void;
|
||||
refreshAgent: (params: { agentId: string; requestId?: string }) => void;
|
||||
refreshSession: () => void;
|
||||
cancelAgentRun: (agentId: string) => void;
|
||||
sendAgentMessage: (
|
||||
agentId: string,
|
||||
message: string,
|
||||
images?: Array<{ uri: string; mimeType?: string }>
|
||||
) => Promise<void>;
|
||||
sendAgentAudio: (
|
||||
agentId: string,
|
||||
audioBlob: Blob,
|
||||
requestId?: string,
|
||||
options?: { mode?: "transcribe_only" | "auto_run" }
|
||||
) => Promise<void>;
|
||||
deleteAgent: (agentId: string) => void;
|
||||
createAgent: (options: {
|
||||
config: any;
|
||||
initialPrompt: string;
|
||||
git?: any;
|
||||
worktreeName?: string;
|
||||
requestId?: string;
|
||||
}) => void;
|
||||
resumeAgent: (options: { handle: any; overrides?: any; requestId?: string }) => void;
|
||||
setAgentMode: (agentId: string, modeId: string) => void;
|
||||
respondToPermission: (agentId: string, requestId: string, response: any) => void;
|
||||
// State getters/setters that components might use directly
|
||||
getDraftInput: (agentId: string) => any;
|
||||
saveDraftInput: (agentId: string, draft: any) => void;
|
||||
setFocusedAgentId: (agentId: string | null) => void;
|
||||
setMessages: (messages: any[] | ((prev: any[]) => any[])) => void;
|
||||
setQueuedMessages: (value: any) => void;
|
||||
};
|
||||
|
||||
export function useDaemonSession(
|
||||
serverId?: string | null,
|
||||
options?: UseDaemonSessionOptions & { allowUnavailable?: false }
|
||||
): DaemonSession | null;
|
||||
export function useDaemonSession(
|
||||
serverId: string | null | undefined,
|
||||
options: UseDaemonSessionOptions & { allowUnavailable: true }
|
||||
): DaemonSession | null;
|
||||
export function useDaemonSession(serverId?: string | null, options?: UseDaemonSessionOptions) {
|
||||
|
||||
const selectSession = useCallback(
|
||||
(state: ReturnType<typeof useSessionStore.getState>) => {
|
||||
if (!serverId) {
|
||||
return null;
|
||||
}
|
||||
return state.sessions[serverId] ?? null;
|
||||
},
|
||||
[serverId]
|
||||
);
|
||||
const sessionState = useSessionStore(selectSession);
|
||||
const { connectionStates } = useDaemonConnections();
|
||||
const alertedDaemonsRef = useRef<Set<string>>(new Set());
|
||||
const loggedDaemonsRef = useRef<Set<string>>(new Set());
|
||||
const { suppressUnavailableAlert = false, allowUnavailable = false } = options ?? {};
|
||||
|
||||
// Get store actions (these are already stable from Zustand)
|
||||
const getDraftInputAction = useSessionStore((state) => state.getDraftInput);
|
||||
const saveDraftInputAction = useSessionStore((state) => state.saveDraftInput);
|
||||
const setFocusedAgentIdAction = useSessionStore((state) => state.setFocusedAgentId);
|
||||
const setMessagesAction = useSessionStore((state) => state.setMessages);
|
||||
const setQueuedMessagesAction = useSessionStore((state) => state.setQueuedMessages);
|
||||
|
||||
// Create stable wrapper functions (must be before conditional returns)
|
||||
const boundGetDraftInput = useCallback(
|
||||
(agentId: string) => getDraftInputAction(agentId),
|
||||
[getDraftInputAction]
|
||||
);
|
||||
const boundSaveDraftInput = useCallback(
|
||||
(agentId: string, draft: any) => { saveDraftInputAction(agentId, draft); },
|
||||
[saveDraftInputAction]
|
||||
);
|
||||
const boundSetFocusedAgentId = useCallback(
|
||||
(agentId: string | null) => { if (serverId) setFocusedAgentIdAction(serverId, agentId); },
|
||||
[serverId, setFocusedAgentIdAction]
|
||||
);
|
||||
const boundSetMessages = useCallback(
|
||||
(messages: any) => { if (serverId) setMessagesAction(serverId, messages); },
|
||||
[serverId, setMessagesAction]
|
||||
);
|
||||
const boundSetQueuedMessages = useCallback(
|
||||
(value: any) => { if (serverId) setQueuedMessagesAction(serverId, value); },
|
||||
[serverId, setQueuedMessagesAction]
|
||||
);
|
||||
|
||||
// Memoize the combined object to prevent infinite re-renders
|
||||
const combined = useMemo<DaemonSession | null>(() => {
|
||||
if (!serverId || !sessionState || !sessionState.methods) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
...sessionState,
|
||||
...sessionState.methods,
|
||||
getDraftInput: boundGetDraftInput,
|
||||
saveDraftInput: boundSaveDraftInput,
|
||||
setFocusedAgentId: boundSetFocusedAgentId,
|
||||
setMessages: boundSetMessages,
|
||||
setQueuedMessages: boundSetQueuedMessages,
|
||||
};
|
||||
}, [
|
||||
serverId,
|
||||
sessionState,
|
||||
boundGetDraftInput,
|
||||
boundSaveDraftInput,
|
||||
boundSetFocusedAgentId,
|
||||
boundSetMessages,
|
||||
boundSetQueuedMessages,
|
||||
]);
|
||||
|
||||
if (!serverId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (combined) {
|
||||
console.log("[useDaemonSession] Debug", {
|
||||
serverId,
|
||||
hasSessionState: !!sessionState,
|
||||
hasMethods: !!sessionState?.methods,
|
||||
sessionStateKeys: sessionState ? Object.keys(sessionState).slice(0, 5) : [],
|
||||
});
|
||||
return combined;
|
||||
}
|
||||
|
||||
// Handle unavailable session
|
||||
const connection = connectionStates.get(serverId);
|
||||
const label = connection?.daemon.label ?? serverId;
|
||||
const status = connection?.status ?? "unknown";
|
||||
const lastError = connection?.lastError ? `\n${connection.lastError}` : "";
|
||||
const message = `${label} isn't connected yet (${status}). Paseo reconnects automatically and will enable actions once it's back.${lastError}`;
|
||||
|
||||
if (!suppressUnavailableAlert && !alertedDaemonsRef.current.has(serverId)) {
|
||||
alertedDaemonsRef.current.add(serverId);
|
||||
Alert.alert("Host unavailable", message.trim());
|
||||
}
|
||||
|
||||
if (!loggedDaemonsRef.current.has(serverId)) {
|
||||
loggedDaemonsRef.current.add(serverId);
|
||||
console.warn(`[useDaemonSession] Session unavailable for daemon "${label}" (${status}).`);
|
||||
}
|
||||
|
||||
if (allowUnavailable) {
|
||||
return null;
|
||||
}
|
||||
|
||||
throw new DaemonSessionUnavailableError(serverId);
|
||||
}
|
||||
Reference in New Issue
Block a user