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 { AgentStreamView } from "@/components/agent-stream-view";
|
||||||
import { AgentInputArea } from "@/components/agent-input-area";
|
import { AgentInputArea } from "@/components/agent-input-area";
|
||||||
import { CreateAgentModal, ImportAgentModal, type CreateAgentInitialValues } from "@/components/create-agent-modal";
|
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 { useFooterControls } from "@/contexts/footer-controls-context";
|
||||||
import { useDaemonConnections } from "@/contexts/daemon-connections-context";
|
import { useDaemonConnections } from "@/contexts/daemon-connections-context";
|
||||||
import type { ConnectionStatus } from "@/contexts/daemon-connections-context";
|
import type { ConnectionStatus } from "@/contexts/daemon-connections-context";
|
||||||
import { formatConnectionStatus } from "@/utils/daemons";
|
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 { useDaemonRequest } from "@/hooks/use-daemon-request";
|
||||||
|
import type { StreamItem } from "@/types/stream";
|
||||||
import type { SessionOutboundMessage } from "@server/server/messages";
|
import type { SessionOutboundMessage } from "@server/server/messages";
|
||||||
import {
|
import {
|
||||||
buildAgentNavigationKey,
|
buildAgentNavigationKey,
|
||||||
@@ -35,6 +36,7 @@ import {
|
|||||||
} from "@/utils/navigation-timing";
|
} from "@/utils/navigation-timing";
|
||||||
|
|
||||||
const DROPDOWN_WIDTH = 220;
|
const DROPDOWN_WIDTH = 220;
|
||||||
|
const EMPTY_STREAM_ITEMS: StreamItem[] = [];
|
||||||
|
|
||||||
type GitRepoInfoResponseMessage = Extract<
|
type GitRepoInfoResponseMessage = Extract<
|
||||||
SessionOutboundMessage,
|
SessionOutboundMessage,
|
||||||
@@ -90,13 +92,11 @@ export default function AgentScreen() {
|
|||||||
const resolvedAgentId = typeof agentId === "string" ? agentId : undefined;
|
const resolvedAgentId = typeof agentId === "string" ? agentId : undefined;
|
||||||
const resolvedServerId = typeof serverId === "string" ? serverId : undefined;
|
const resolvedServerId = typeof serverId === "string" ? serverId : undefined;
|
||||||
const { connectionStates } = useDaemonConnections();
|
const { connectionStates } = useDaemonConnections();
|
||||||
const hasConnectionEntry = resolvedServerId ? connectionStates.has(resolvedServerId) : false;
|
|
||||||
|
|
||||||
const session = useDaemonSession(resolvedServerId, {
|
// Check if session exists for this serverId
|
||||||
suppressUnavailableAlert: true,
|
const hasSession = useSessionStore((state) =>
|
||||||
allowUnavailable: true,
|
resolvedServerId ? !!state.sessions[resolvedServerId] : false
|
||||||
});
|
);
|
||||||
const sessionServerId = session?.serverId ?? null;
|
|
||||||
|
|
||||||
const connectionServerId = resolvedServerId ?? null;
|
const connectionServerId = resolvedServerId ?? null;
|
||||||
const connection = connectionServerId ? connectionStates.get(connectionServerId) : null;
|
const connection = connectionServerId ? connectionStates.get(connectionServerId) : null;
|
||||||
@@ -107,7 +107,7 @@ export default function AgentScreen() {
|
|||||||
const lastConnectionError = connection?.lastError ?? null;
|
const lastConnectionError = connection?.lastError ?? null;
|
||||||
|
|
||||||
const handleBackToHome = useCallback(() => {
|
const handleBackToHome = useCallback(() => {
|
||||||
const targetServerId = resolvedServerId ?? sessionServerId;
|
const targetServerId = resolvedServerId;
|
||||||
const targetAgentId = resolvedAgentId ?? null;
|
const targetAgentId = resolvedAgentId ?? null;
|
||||||
if (targetServerId && targetAgentId) {
|
if (targetServerId && targetAgentId) {
|
||||||
startNavigationTiming(HOME_NAVIGATION_KEY, {
|
startNavigationTiming(HOME_NAVIGATION_KEY, {
|
||||||
@@ -127,10 +127,10 @@ export default function AgentScreen() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
router.replace("/");
|
router.replace("/");
|
||||||
}, [resolvedAgentId, resolvedServerId, router, sessionServerId]);
|
}, [resolvedAgentId, resolvedServerId, router]);
|
||||||
|
|
||||||
const focusServerId = resolvedServerId ?? sessionServerId;
|
const focusServerId = resolvedServerId;
|
||||||
const navigationStatus = session ? "ready" : "session_unavailable";
|
const navigationStatus = hasSession ? "ready" : "session_unavailable";
|
||||||
|
|
||||||
useFocusEffect(
|
useFocusEffect(
|
||||||
useCallback(() => {
|
useCallback(() => {
|
||||||
@@ -145,7 +145,7 @@ export default function AgentScreen() {
|
|||||||
}, [focusServerId, navigationStatus, resolvedAgentId])
|
}, [focusServerId, navigationStatus, resolvedAgentId])
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!session) {
|
if (!hasSession || !resolvedServerId) {
|
||||||
return (
|
return (
|
||||||
<AgentSessionUnavailableState
|
<AgentSessionUnavailableState
|
||||||
onBack={handleBackToHome}
|
onBack={handleBackToHome}
|
||||||
@@ -158,26 +158,22 @@ export default function AgentScreen() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const routeServerId = resolvedServerId ?? session.serverId;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AgentScreenContent
|
<AgentScreenContent
|
||||||
session={session}
|
serverId={resolvedServerId}
|
||||||
agentId={resolvedAgentId}
|
agentId={resolvedAgentId}
|
||||||
routeServerId={routeServerId}
|
|
||||||
onBack={handleBackToHome}
|
onBack={handleBackToHome}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
type AgentScreenContentProps = {
|
type AgentScreenContentProps = {
|
||||||
session: import("@/hooks/use-daemon-session").DaemonSession;
|
serverId: string;
|
||||||
agentId?: string;
|
agentId?: string;
|
||||||
routeServerId: string;
|
|
||||||
onBack: () => void;
|
onBack: () => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
function AgentScreenContent({ session, agentId, routeServerId, onBack }: AgentScreenContentProps) {
|
function AgentScreenContent({ serverId, agentId, onBack }: AgentScreenContentProps) {
|
||||||
const { theme } = useUnistyles();
|
const { theme } = useUnistyles();
|
||||||
const insets = useSafeAreaInsets();
|
const insets = useSafeAreaInsets();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@@ -192,18 +188,53 @@ function AgentScreenContent({ session, agentId, routeServerId, onBack }: AgentSc
|
|||||||
const [createAgentInitialValues, setCreateAgentInitialValues] =
|
const [createAgentInitialValues, setCreateAgentInitialValues] =
|
||||||
useState<CreateAgentInitialValues | undefined>();
|
useState<CreateAgentInitialValues | undefined>();
|
||||||
|
|
||||||
const {
|
|
||||||
agents,
|
|
||||||
agentStreamState,
|
|
||||||
initializingAgents,
|
|
||||||
pendingPermissions,
|
|
||||||
initializeAgent,
|
|
||||||
refreshAgent,
|
|
||||||
setFocusedAgentId,
|
|
||||||
ws,
|
|
||||||
} = session;
|
|
||||||
const resolvedAgentId = agentId;
|
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 { height: keyboardHeight } = useReanimatedKeyboardAnimation();
|
||||||
const bottomInset = useSharedValue(insets.bottom);
|
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 agentModel = extractAgentModel(agent);
|
||||||
const modelDisplayValue = agentModel ?? "Unknown";
|
const modelDisplayValue = agentModel ?? "Unknown";
|
||||||
|
|
||||||
@@ -289,26 +315,28 @@ function AgentScreenContent({ session, agentId, routeServerId, onBack }: AgentSc
|
|||||||
};
|
};
|
||||||
}, [resolvedAgentId, setFocusedAgentId]);
|
}, [resolvedAgentId, setFocusedAgentId]);
|
||||||
|
|
||||||
const hasStreamState = resolvedAgentId ? agentStreamState.has(resolvedAgentId) : false;
|
const hasStreamState = useSessionStore((state) =>
|
||||||
const initializationState = resolvedAgentId ? initializingAgents.get(resolvedAgentId) : undefined;
|
resolvedAgentId ? state.sessions[serverId]?.agentStreamState?.has(resolvedAgentId) ?? false : false
|
||||||
|
);
|
||||||
|
|
||||||
const isInitializing = resolvedAgentId
|
const isInitializing = resolvedAgentId
|
||||||
? initializationState !== undefined
|
? isInitializingFromMap !== false
|
||||||
? initializationState
|
? isInitializingFromMap
|
||||||
: !hasStreamState
|
: !hasStreamState
|
||||||
: false;
|
: false;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!resolvedAgentId) {
|
if (!resolvedAgentId || !initializeAgent) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log("[AgentScreen] initializeAgent effect", {
|
console.log("[AgentScreen] initializeAgent effect", {
|
||||||
agentId: resolvedAgentId,
|
agentId: resolvedAgentId,
|
||||||
hasStreamState,
|
hasStreamState,
|
||||||
initializationState,
|
isInitializingFromMap,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (initializationState !== undefined) {
|
if (isInitializingFromMap !== false) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -317,12 +345,12 @@ function AgentScreenContent({ session, agentId, routeServerId, onBack }: AgentSc
|
|||||||
}
|
}
|
||||||
|
|
||||||
initializeAgent({ agentId: resolvedAgentId });
|
initializeAgent({ agentId: resolvedAgentId });
|
||||||
}, [resolvedAgentId, initializeAgent, initializationState, hasStreamState]);
|
}, [resolvedAgentId, initializeAgent, isInitializingFromMap, hasStreamState]);
|
||||||
|
|
||||||
const agentControls = useMemo(() => {
|
const agentControls = useMemo(() => {
|
||||||
if (!resolvedAgentId) return null;
|
if (!resolvedAgentId) return null;
|
||||||
return <AgentInputArea agentId={resolvedAgentId} serverId={routeServerId} />;
|
return <AgentInputArea agentId={resolvedAgentId} serverId={serverId} />;
|
||||||
}, [resolvedAgentId, routeServerId]);
|
}, [resolvedAgentId, serverId]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!agentControls || !agent || isInitializing) {
|
if (!agentControls || !agent || isInitializing) {
|
||||||
@@ -416,11 +444,11 @@ function AgentScreenContent({ session, agentId, routeServerId, onBack }: AgentSc
|
|||||||
pathname: "/git-diff",
|
pathname: "/git-diff",
|
||||||
params: {
|
params: {
|
||||||
agentId: resolvedAgentId,
|
agentId: resolvedAgentId,
|
||||||
serverId: routeServerId,
|
serverId: serverId,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}, [resolvedAgentId, routeServerId, router, handleCloseMenu]);
|
}, [resolvedAgentId, serverId, router, handleCloseMenu]);
|
||||||
|
|
||||||
const handleBrowseFiles = useCallback(() => {
|
const handleBrowseFiles = useCallback(() => {
|
||||||
handleCloseMenu();
|
handleCloseMenu();
|
||||||
@@ -429,14 +457,14 @@ function AgentScreenContent({ session, agentId, routeServerId, onBack }: AgentSc
|
|||||||
pathname: "/file-explorer",
|
pathname: "/file-explorer",
|
||||||
params: {
|
params: {
|
||||||
agentId: resolvedAgentId,
|
agentId: resolvedAgentId,
|
||||||
serverId: routeServerId,
|
serverId: serverId,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}, [handleCloseMenu, resolvedAgentId, routeServerId, router]);
|
}, [handleCloseMenu, resolvedAgentId, serverId, router]);
|
||||||
|
|
||||||
const handleRefreshAgent = useCallback(() => {
|
const handleRefreshAgent = useCallback(() => {
|
||||||
if (!resolvedAgentId) {
|
if (!resolvedAgentId || !refreshAgent) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
handleCloseMenu();
|
handleCloseMenu();
|
||||||
@@ -475,7 +503,7 @@ function AgentScreenContent({ session, agentId, routeServerId, onBack }: AgentSc
|
|||||||
isVisible={showCreateAgentModal}
|
isVisible={showCreateAgentModal}
|
||||||
onClose={handleCloseCreateAgentModal}
|
onClose={handleCloseCreateAgentModal}
|
||||||
initialValues={createAgentInitialValues}
|
initialValues={createAgentInitialValues}
|
||||||
serverId={routeServerId}
|
serverId={serverId}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -483,7 +511,7 @@ function AgentScreenContent({ session, agentId, routeServerId, onBack }: AgentSc
|
|||||||
<ImportAgentModal
|
<ImportAgentModal
|
||||||
isVisible={showImportAgentModal}
|
isVisible={showImportAgentModal}
|
||||||
onClose={handleCloseImportAgentModal}
|
onClose={handleCloseImportAgentModal}
|
||||||
serverId={routeServerId}
|
serverId={serverId}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -529,10 +557,10 @@ function AgentScreenContent({ session, agentId, routeServerId, onBack }: AgentSc
|
|||||||
) : (
|
) : (
|
||||||
<AgentStreamView
|
<AgentStreamView
|
||||||
agentId={agent.id}
|
agentId={agent.id}
|
||||||
serverId={routeServerId}
|
serverId={serverId}
|
||||||
agent={agent}
|
agent={agent}
|
||||||
streamItems={streamItems}
|
streamItems={streamItems}
|
||||||
pendingPermissions={agentPermissions}
|
pendingPermissions={pendingPermissions}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</ReanimatedAnimated.View>
|
</ReanimatedAnimated.View>
|
||||||
|
|||||||
@@ -29,11 +29,11 @@ import {
|
|||||||
X,
|
X,
|
||||||
} from "lucide-react-native";
|
} from "lucide-react-native";
|
||||||
import { BackHeader } from "@/components/headers/back-header";
|
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 type { ConnectionStatus } from "@/contexts/daemon-connections-context";
|
||||||
import { useDaemonConnections } from "@/contexts/daemon-connections-context";
|
import { useDaemonConnections } from "@/contexts/daemon-connections-context";
|
||||||
import { formatConnectionStatus } from "@/utils/daemons";
|
import { formatConnectionStatus } from "@/utils/daemons";
|
||||||
import { useDaemonSession } from "@/hooks/use-daemon-session";
|
import { useSessionStore } from "@/stores/session-store";
|
||||||
|
|
||||||
export default function FileExplorerScreen() {
|
export default function FileExplorerScreen() {
|
||||||
const {
|
const {
|
||||||
@@ -49,14 +49,14 @@ export default function FileExplorerScreen() {
|
|||||||
}>();
|
}>();
|
||||||
const resolvedServerId = typeof serverId === "string" ? serverId : undefined;
|
const resolvedServerId = typeof serverId === "string" ? serverId : undefined;
|
||||||
const { connectionStates } = useDaemonConnections();
|
const { connectionStates } = useDaemonConnections();
|
||||||
const session = useDaemonSession(resolvedServerId, {
|
|
||||||
suppressUnavailableAlert: true,
|
const session = useSessionStore((state) =>
|
||||||
allowUnavailable: true,
|
resolvedServerId ? state.sessions[resolvedServerId] : undefined
|
||||||
});
|
);
|
||||||
|
|
||||||
const connectionServerId = resolvedServerId ?? null;
|
const connectionServerId = resolvedServerId ?? null;
|
||||||
const connection = connectionServerId ? connectionStates.get(connectionServerId) : 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 connectionStatus = connection?.status ?? "idle";
|
||||||
const connectionStatusLabel = formatConnectionStatus(connectionStatus);
|
const connectionStatusLabel = formatConnectionStatus(connectionStatus);
|
||||||
const lastError = connection?.lastError ?? null;
|
const lastError = connection?.lastError ?? null;
|
||||||
@@ -78,21 +78,19 @@ export default function FileExplorerScreen() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<FileExplorerContent
|
<FileExplorerContent
|
||||||
session={session}
|
serverId={routeServerId}
|
||||||
agentId={agentId}
|
agentId={agentId}
|
||||||
pathParamRaw={pathParamRaw}
|
pathParamRaw={pathParamRaw}
|
||||||
fileParamRaw={fileParamRaw}
|
fileParamRaw={fileParamRaw}
|
||||||
routeServerId={routeServerId}
|
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
type FileExplorerContentProps = {
|
type FileExplorerContentProps = {
|
||||||
session: import("@/hooks/use-daemon-session").DaemonSession;
|
serverId: string;
|
||||||
agentId?: string;
|
agentId?: string;
|
||||||
pathParamRaw?: string | string[];
|
pathParamRaw?: string | string[];
|
||||||
fileParamRaw?: string | string[];
|
fileParamRaw?: string | string[];
|
||||||
routeServerId: string;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
type FileExplorerSessionUnavailableProps = {
|
type FileExplorerSessionUnavailableProps = {
|
||||||
@@ -105,20 +103,29 @@ type FileExplorerSessionUnavailableProps = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
function FileExplorerContent({
|
function FileExplorerContent({
|
||||||
session,
|
serverId,
|
||||||
agentId,
|
agentId,
|
||||||
pathParamRaw,
|
pathParamRaw,
|
||||||
fileParamRaw,
|
fileParamRaw,
|
||||||
routeServerId,
|
|
||||||
}: FileExplorerContentProps) {
|
}: FileExplorerContentProps) {
|
||||||
const { theme } = useUnistyles();
|
const { theme } = useUnistyles();
|
||||||
const {
|
|
||||||
agents,
|
const agent = useSessionStore((state) =>
|
||||||
fileExplorer,
|
agentId && state.sessions[serverId]
|
||||||
requestDirectoryListing,
|
? state.sessions[serverId]?.agents.get(agentId)
|
||||||
requestFilePreview,
|
: undefined
|
||||||
navigateExplorerBack,
|
);
|
||||||
} = session;
|
|
||||||
|
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 [viewMode, setViewMode] = useState<"list" | "grid">("list");
|
||||||
const [selectedEntryPath, setSelectedEntryPath] = useState<string | null>(null);
|
const [selectedEntryPath, setSelectedEntryPath] = useState<string | null>(null);
|
||||||
const pendingPathParamRef = useRef<string | null>(null);
|
const pendingPathParamRef = useRef<string | null>(null);
|
||||||
@@ -136,9 +143,6 @@ function FileExplorerContent({
|
|||||||
const derivedDirectoryFromFile = normalizedFileParam
|
const derivedDirectoryFromFile = normalizedFileParam
|
||||||
? deriveDirectoryFromFile(normalizedFileParam)
|
? deriveDirectoryFromFile(normalizedFileParam)
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
const agent = agentId ? agents.get(agentId) : undefined;
|
|
||||||
const explorerState = agentId ? fileExplorer.get(agentId) : undefined;
|
|
||||||
const history = explorerState?.history ?? [];
|
const history = explorerState?.history ?? [];
|
||||||
const lastKnownDirectory = history[history.length - 1];
|
const lastKnownDirectory = history[history.length - 1];
|
||||||
const rememberedDirectory = explorerState?.lastVisitedPath;
|
const rememberedDirectory = explorerState?.lastVisitedPath;
|
||||||
@@ -237,7 +241,7 @@ function FileExplorerContent({
|
|||||||
}, [activePath]);
|
}, [activePath]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!agentId || !initialTargetDirectory) {
|
if (!agentId || !initialTargetDirectory || !requestDirectoryListing) {
|
||||||
pendingPathParamRef.current = null;
|
pendingPathParamRef.current = null;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -251,7 +255,7 @@ function FileExplorerContent({
|
|||||||
}, [agentId, initialTargetDirectory, requestDirectoryListing]);
|
}, [agentId, initialTargetDirectory, requestDirectoryListing]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!agentId || !normalizedFileParam) {
|
if (!agentId || !normalizedFileParam || !requestFilePreview) {
|
||||||
pendingFileParamRef.current = null;
|
pendingFileParamRef.current = null;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -281,7 +285,7 @@ function FileExplorerContent({
|
|||||||
|
|
||||||
const handleEntryPress = useCallback(
|
const handleEntryPress = useCallback(
|
||||||
(entry: ExplorerEntry) => {
|
(entry: ExplorerEntry) => {
|
||||||
if (!agentId) {
|
if (!agentId || !requestDirectoryListing || !requestFilePreview) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -330,13 +334,13 @@ function FileExplorerContent({
|
|||||||
if (agentId) {
|
if (agentId) {
|
||||||
router.replace({
|
router.replace({
|
||||||
pathname: "/agent/[serverId]/[agentId]",
|
pathname: "/agent/[serverId]/[agentId]",
|
||||||
params: { serverId: routeServerId, agentId },
|
params: { serverId, agentId },
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
router.back();
|
router.back();
|
||||||
}, [agentId, routeServerId]);
|
}, [agentId, serverId]);
|
||||||
|
|
||||||
const handleBackNavigation = useCallback(() => {
|
const handleBackNavigation = useCallback(() => {
|
||||||
if (!agentId) {
|
if (!agentId) {
|
||||||
@@ -349,7 +353,7 @@ function FileExplorerContent({
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
if ((explorerState?.history?.length ?? 0) > 1) {
|
if ((explorerState?.history?.length ?? 0) > 1 && navigateExplorerBack) {
|
||||||
navigateExplorerBack(agentId);
|
navigateExplorerBack(agentId);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@@ -472,7 +476,7 @@ function FileExplorerContent({
|
|||||||
|
|
||||||
const handleViewableItemsChanged = useCallback(
|
const handleViewableItemsChanged = useCallback(
|
||||||
({ viewableItems }: { viewableItems: Array<ViewToken> }) => {
|
({ viewableItems }: { viewableItems: Array<ViewToken> }) => {
|
||||||
if (!agentId || viewMode !== "grid") {
|
if (!agentId || viewMode !== "grid" || !requestFilePreview) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,11 +3,10 @@ import { View, Text, ScrollView, ActivityIndicator } from "react-native";
|
|||||||
import { useLocalSearchParams } from "expo-router";
|
import { useLocalSearchParams } from "expo-router";
|
||||||
import { StyleSheet } from "react-native-unistyles";
|
import { StyleSheet } from "react-native-unistyles";
|
||||||
import { BackHeader } from "@/components/headers/back-header";
|
import { BackHeader } from "@/components/headers/back-header";
|
||||||
import type { SessionContextValue } from "@/contexts/session-context";
|
|
||||||
import type { ConnectionStatus } from "@/contexts/daemon-connections-context";
|
import type { ConnectionStatus } from "@/contexts/daemon-connections-context";
|
||||||
import { useDaemonConnections } from "@/contexts/daemon-connections-context";
|
import { useDaemonConnections } from "@/contexts/daemon-connections-context";
|
||||||
import { formatConnectionStatus } from "@/utils/daemons";
|
import { formatConnectionStatus } from "@/utils/daemons";
|
||||||
import { useDaemonSession } from "@/hooks/use-daemon-session";
|
import { useSessionStore } from "@/stores/session-store";
|
||||||
|
|
||||||
interface ParsedDiffFile {
|
interface ParsedDiffFile {
|
||||||
path: string;
|
path: string;
|
||||||
@@ -58,10 +57,8 @@ export default function GitDiffScreen() {
|
|||||||
const { agentId, serverId } = useLocalSearchParams<{ agentId: string; serverId?: string }>();
|
const { agentId, serverId } = useLocalSearchParams<{ agentId: string; serverId?: string }>();
|
||||||
const resolvedServerId = typeof serverId === "string" ? serverId : undefined;
|
const resolvedServerId = typeof serverId === "string" ? serverId : undefined;
|
||||||
const { connectionStates } = useDaemonConnections();
|
const { connectionStates } = useDaemonConnections();
|
||||||
const session = useDaemonSession(resolvedServerId, {
|
|
||||||
suppressUnavailableAlert: true,
|
const session = useSessionStore((state) => resolvedServerId ? state.sessions[resolvedServerId] : undefined);
|
||||||
allowUnavailable: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
const connectionServerId = resolvedServerId ?? null;
|
const connectionServerId = resolvedServerId ?? null;
|
||||||
const connection = connectionServerId ? connectionStates.get(connectionServerId) : null;
|
const connection = connectionServerId ? connectionStates.get(connectionServerId) : null;
|
||||||
@@ -83,7 +80,7 @@ export default function GitDiffScreen() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<GitDiffContent
|
<GitDiffContent
|
||||||
session={session}
|
serverId={session.serverId}
|
||||||
agentId={agentId}
|
agentId={agentId}
|
||||||
serverLabel={serverLabel}
|
serverLabel={serverLabel}
|
||||||
/>
|
/>
|
||||||
@@ -91,22 +88,30 @@ export default function GitDiffScreen() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function GitDiffContent({
|
function GitDiffContent({
|
||||||
session,
|
serverId,
|
||||||
agentId,
|
agentId,
|
||||||
serverLabel,
|
serverLabel,
|
||||||
}: {
|
}: {
|
||||||
session: import("@/hooks/use-daemon-session").DaemonSession;
|
serverId: string;
|
||||||
agentId?: string;
|
agentId?: string;
|
||||||
serverLabel: string;
|
serverLabel: string;
|
||||||
}) {
|
}) {
|
||||||
const { agents, gitDiffs, requestGitDiff } = session;
|
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
|
||||||
const agent = agentId ? agents.get(agentId) : undefined;
|
const agent = useSessionStore((state) =>
|
||||||
const diffText = agentId ? gitDiffs.get(agentId) : undefined;
|
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(() => {
|
useEffect(() => {
|
||||||
if (!agentId) {
|
if (!agentId || !requestGitDiff) {
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,8 +19,7 @@ import { useDaemonConnections, type ConnectionStatus } from "@/contexts/daemon-c
|
|||||||
import { formatConnectionStatus, getConnectionStatusTone } from "@/utils/daemons";
|
import { formatConnectionStatus, getConnectionStatusTone } from "@/utils/daemons";
|
||||||
import { theme as defaultTheme } from "@/styles/theme";
|
import { theme as defaultTheme } from "@/styles/theme";
|
||||||
import { BackHeader } from "@/components/headers/back-header";
|
import { BackHeader } from "@/components/headers/back-header";
|
||||||
import { useSessionForServer } from "@/hooks/use-session-directory";
|
import { useSessionStore } from "@/stores/session-store";
|
||||||
import { useDaemonSession } from "@/hooks/use-daemon-session";
|
|
||||||
import type { UseWebSocketReturn } from "@/hooks/use-websocket";
|
import type { UseWebSocketReturn } from "@/hooks/use-websocket";
|
||||||
|
|
||||||
const delay = (ms: number) =>
|
const delay = (ms: number) =>
|
||||||
@@ -799,12 +798,8 @@ function DaemonCard({
|
|||||||
: theme.colors.mutedForeground;
|
: theme.colors.mutedForeground;
|
||||||
const badgeText = statusLabel;
|
const badgeText = statusLabel;
|
||||||
const connectionError = typeof lastError === "string" && lastError.trim().length > 0 ? lastError.trim() : null;
|
const connectionError = typeof lastError === "string" && lastError.trim().length > 0 ? lastError.trim() : null;
|
||||||
const daemonWs = useSessionForServer<UseWebSocketReturn | null>(
|
const daemonWs = useSessionStore((state) => state.sessions[daemon.id]?.ws);
|
||||||
daemon.id,
|
const restartServerFn = useSessionStore((state) => state.sessions[daemon.id]?.methods?.restartServer);
|
||||||
useCallback((session) => session?.ws ?? null, [])
|
|
||||||
);
|
|
||||||
const fullSession = useDaemonSession(daemon.id, { allowUnavailable: true, suppressUnavailableAlert: true });
|
|
||||||
const restartServerFn = fullSession?.restartServer ?? null;
|
|
||||||
const [isRestarting, setIsRestarting] = useState(false);
|
const [isRestarting, setIsRestarting] = useState(false);
|
||||||
const wsIsConnectedRef = useRef(daemonWs?.isConnected ?? false);
|
const wsIsConnectedRef = useRef(daemonWs?.isConnected ?? false);
|
||||||
const isTesting = testState?.status === "testing";
|
const isTesting = testState?.status === "testing";
|
||||||
|
|||||||
@@ -31,9 +31,8 @@ import { RealtimeControls } from "./realtime-controls";
|
|||||||
import { useImageAttachmentPicker } from "@/hooks/use-image-attachment-picker";
|
import { useImageAttachmentPicker } from "@/hooks/use-image-attachment-picker";
|
||||||
import { AUDIO_DEBUG_ENABLED } from "@/config/audio-debug";
|
import { AUDIO_DEBUG_ENABLED } from "@/config/audio-debug";
|
||||||
import { AudioDebugNotice, type AudioDebugInfo } from "./audio-debug-notice";
|
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 { UseWebSocketReturn } from "@/hooks/use-websocket";
|
||||||
import type { SessionContextValue, Agent } from "@/contexts/session-context";
|
|
||||||
import type { SessionOutboundMessage } from "@server/server/messages";
|
import type { SessionOutboundMessage } from "@server/server/messages";
|
||||||
|
|
||||||
type QueuedMessage = {
|
type QueuedMessage = {
|
||||||
@@ -44,11 +43,12 @@ type QueuedMessage = {
|
|||||||
|
|
||||||
interface AgentInputAreaProps {
|
interface AgentInputAreaProps {
|
||||||
agentId: string;
|
agentId: string;
|
||||||
serverId?: string;
|
serverId: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const MIN_INPUT_HEIGHT = 50;
|
const MIN_INPUT_HEIGHT = 50;
|
||||||
const MAX_INPUT_HEIGHT = 160;
|
const MAX_INPUT_HEIGHT = 160;
|
||||||
|
const EMPTY_ARRAY: readonly QueuedMessage[] = [];
|
||||||
const MAX_INPUT_WIDTH = 960;
|
const MAX_INPUT_WIDTH = 960;
|
||||||
const BASE_VERTICAL_PADDING = (FOOTER_HEIGHT - MIN_INPUT_HEIGHT) / 2;
|
const BASE_VERTICAL_PADDING = (FOOTER_HEIGHT - MIN_INPUT_HEIGHT) / 2;
|
||||||
// Android currently crashes inside ViewGroup.dispatchDraw when running Reanimated
|
// Android currently crashes inside ViewGroup.dispatchDraw when running Reanimated
|
||||||
@@ -85,7 +85,38 @@ type DictationToastConfig = {
|
|||||||
|
|
||||||
export function AgentInputArea({ agentId, serverId }: AgentInputAreaProps) {
|
export function AgentInputArea({ agentId, serverId }: AgentInputAreaProps) {
|
||||||
const { theme } = useUnistyles();
|
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>(
|
const inertWebSocket = useMemo<UseWebSocketReturn>(
|
||||||
() => ({
|
() => ({
|
||||||
isConnected: false,
|
isConnected: false,
|
||||||
@@ -101,21 +132,8 @@ export function AgentInputArea({ agentId, serverId }: AgentInputAreaProps) {
|
|||||||
}),
|
}),
|
||||||
[]
|
[]
|
||||||
);
|
);
|
||||||
const noopSendAgentMessage = useCallback(async () => {}, []);
|
|
||||||
const noopSendAgentAudio = useCallback(async () => {}, []);
|
const wsOrInert = ws ?? inertWebSocket;
|
||||||
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 { startRealtime, stopRealtime, isRealtimeMode } = useRealtime();
|
const { startRealtime, stopRealtime, isRealtimeMode } = useRealtime();
|
||||||
|
|
||||||
const [userInput, setUserInput] = useState("");
|
const [userInput, setUserInput] = useState("");
|
||||||
@@ -125,7 +143,7 @@ export function AgentInputArea({ agentId, serverId }: AgentInputAreaProps) {
|
|||||||
const [isCancellingAgent, setIsCancellingAgent] = useState(false);
|
const [isCancellingAgent, setIsCancellingAgent] = useState(false);
|
||||||
const [audioDebugInfo, setAudioDebugInfo] = useState<AudioDebugInfo | null>(null);
|
const [audioDebugInfo, setAudioDebugInfo] = useState<AudioDebugInfo | null>(null);
|
||||||
const [connectionStatus, setConnectionStatus] = useState(() =>
|
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);
|
const [lastSuccessToastAt, setLastSuccessToastAt] = useState<number | null>(null);
|
||||||
|
|
||||||
@@ -180,7 +198,7 @@ export function AgentInputArea({ agentId, serverId }: AgentInputAreaProps) {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const canStartDictation = useCallback(() => {
|
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;
|
const allowed = !isRealtimeMode && socketConnected;
|
||||||
console.log("[AgentInput] canStartDictation", {
|
console.log("[AgentInput] canStartDictation", {
|
||||||
allowed,
|
allowed,
|
||||||
@@ -188,17 +206,17 @@ export function AgentInputArea({ agentId, serverId }: AgentInputAreaProps) {
|
|||||||
wsConnected: socketConnected,
|
wsConnected: socketConnected,
|
||||||
});
|
});
|
||||||
return allowed;
|
return allowed;
|
||||||
}, [isRealtimeMode, ws]);
|
}, [isRealtimeMode, wsOrInert]);
|
||||||
|
|
||||||
const canConfirmDictation = useCallback(() => {
|
const canConfirmDictation = useCallback(() => {
|
||||||
const socketConnected = ws.getConnectionState ? ws.getConnectionState().isConnected : ws.isConnected;
|
const socketConnected = wsOrInert.getConnectionState ? wsOrInert.getConnectionState().isConnected : wsOrInert.isConnected;
|
||||||
const allowed = socketConnected;
|
const allowed = socketConnected;
|
||||||
console.log("[AgentInput] canConfirmDictation", {
|
console.log("[AgentInput] canConfirmDictation", {
|
||||||
allowed,
|
allowed,
|
||||||
wsConnected: socketConnected,
|
wsConnected: socketConnected,
|
||||||
});
|
});
|
||||||
return allowed;
|
return allowed;
|
||||||
}, [ws]);
|
}, [wsOrInert]);
|
||||||
|
|
||||||
const {
|
const {
|
||||||
isRecording: isDictating,
|
isRecording: isDictating,
|
||||||
@@ -220,7 +238,7 @@ export function AgentInputArea({ agentId, serverId }: AgentInputAreaProps) {
|
|||||||
} = useDictation({
|
} = useDictation({
|
||||||
agentId,
|
agentId,
|
||||||
sendAgentAudio,
|
sendAgentAudio,
|
||||||
ws,
|
ws: wsOrInert,
|
||||||
mode: "transcribe_only",
|
mode: "transcribe_only",
|
||||||
onTranscript: handleDictationTranscript,
|
onTranscript: handleDictationTranscript,
|
||||||
onError: handleDictationError,
|
onError: handleDictationError,
|
||||||
@@ -235,13 +253,13 @@ export function AgentInputArea({ agentId, serverId }: AgentInputAreaProps) {
|
|||||||
}, [dictationPendingRequestId]);
|
}, [dictationPendingRequestId]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!ws.subscribeConnectionStatus) {
|
if (!wsOrInert.subscribeConnectionStatus) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
return ws.subscribeConnectionStatus((status) => {
|
return wsOrInert.subscribeConnectionStatus((status) => {
|
||||||
setConnectionStatus(status);
|
setConnectionStatus(status);
|
||||||
});
|
});
|
||||||
}, [ws]);
|
}, [wsOrInert]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (dictationLastOutcome?.type === "success") {
|
if (dictationLastOutcome?.type === "success") {
|
||||||
@@ -274,9 +292,9 @@ export function AgentInputArea({ agentId, serverId }: AgentInputAreaProps) {
|
|||||||
const dictationToast = useMemo<DictationToastConfig | null>(() => {
|
const dictationToast = useMemo<DictationToastConfig | null>(() => {
|
||||||
console.log('[AgentInputArea] Connection status:', {
|
console.log('[AgentInputArea] Connection status:', {
|
||||||
isConnected: connectionStatus.isConnected,
|
isConnected: connectionStatus.isConnected,
|
||||||
wsIsConnected: ws.isConnected,
|
wsIsConnected: wsOrInert.isConnected,
|
||||||
wsIsConnecting: ws.isConnecting,
|
wsIsConnecting: wsOrInert.isConnecting,
|
||||||
hasSession: !!session,
|
hasWs: !!ws,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!connectionStatus.isConnected) {
|
if (!connectionStatus.isConnected) {
|
||||||
@@ -375,8 +393,8 @@ export function AgentInputArea({ agentId, serverId }: AgentInputAreaProps) {
|
|||||||
}, [sendAgentMessage]);
|
}, [sendAgentMessage]);
|
||||||
|
|
||||||
async function handleSendMessage() {
|
async function handleSendMessage() {
|
||||||
const socketConnected = ws.getConnectionState ? ws.getConnectionState().isConnected : ws.isConnected;
|
const socketConnected = wsOrInert.getConnectionState ? wsOrInert.getConnectionState().isConnected : wsOrInert.isConnected;
|
||||||
if (!userInput.trim() || !socketConnected) return;
|
if (!userInput.trim() || !socketConnected || !sendAgentMessage) return;
|
||||||
|
|
||||||
const message = userInput.trim();
|
const message = userInput.trim();
|
||||||
const imageAttachments = selectedImages.length > 0 ? selectedImages : undefined;
|
const imageAttachments = selectedImages.length > 0 ? selectedImages : undefined;
|
||||||
@@ -416,7 +434,7 @@ export function AgentInputArea({ agentId, serverId }: AgentInputAreaProps) {
|
|||||||
console.log("[AgentInput] handleVoicePress", {
|
console.log("[AgentInput] handleVoicePress", {
|
||||||
isDictating,
|
isDictating,
|
||||||
isRealtimeMode,
|
isRealtimeMode,
|
||||||
wsConnected: ws.isConnected,
|
wsConnected: wsOrInert.isConnected,
|
||||||
});
|
});
|
||||||
if (isDictating || isRealtimeMode) {
|
if (isDictating || isRealtimeMode) {
|
||||||
return;
|
return;
|
||||||
@@ -480,7 +498,7 @@ export function AgentInputArea({ agentId, serverId }: AgentInputAreaProps) {
|
|||||||
if (!shouldShowAudioDebug) {
|
if (!shouldShowAudioDebug) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const unsubscribe = ws.on("transcription_result", (message: SessionOutboundMessage) => {
|
const unsubscribe = wsOrInert.on("transcription_result", (message: SessionOutboundMessage) => {
|
||||||
if (message.type !== "transcription_result") {
|
if (message.type !== "transcription_result") {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -506,7 +524,7 @@ export function AgentInputArea({ agentId, serverId }: AgentInputAreaProps) {
|
|||||||
return () => {
|
return () => {
|
||||||
unsubscribe();
|
unsubscribe();
|
||||||
};
|
};
|
||||||
}, [shouldShowAudioDebug, ws]);
|
}, [shouldShowAudioDebug, wsOrInert]);
|
||||||
|
|
||||||
function isTextAreaLike(value: unknown): value is TextAreaHandle {
|
function isTextAreaLike(value: unknown): value is TextAreaHandle {
|
||||||
if (!value || typeof value !== "object") {
|
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";
|
const isAgentRunning = agent?.status === "running";
|
||||||
agentStatusRef.current = agent?.status;
|
agentStatusRef.current = agent?.status;
|
||||||
const hasText = userInput.trim().length > 0;
|
const hasText = userInput.trim().length > 0;
|
||||||
@@ -664,18 +681,17 @@ export function AgentInputArea({ agentId, serverId }: AgentInputAreaProps) {
|
|||||||
const hasSendableContent = hasText || hasImages;
|
const hasSendableContent = hasText || hasImages;
|
||||||
const shouldShowSendButton = !isAgentRunning && hasSendableContent;
|
const shouldShowSendButton = !isAgentRunning && hasSendableContent;
|
||||||
const shouldShowVoiceControls = !hasSendableContent;
|
const shouldShowVoiceControls = !hasSendableContent;
|
||||||
const queuedMessages = queuedMessagesByAgent.get(agentId) ?? [];
|
|
||||||
const shouldHandleDesktopSubmit = IS_WEB;
|
const shouldHandleDesktopSubmit = IS_WEB;
|
||||||
|
|
||||||
const updateQueue = useCallback(
|
const updateQueue = useCallback(
|
||||||
(updater: (current: QueuedMessage[]) => QueuedMessage[]) => {
|
(updater: (current: QueuedMessage[]) => QueuedMessage[]) => {
|
||||||
setQueuedMessagesByAgent((prev: Map<string, QueuedMessage[]>) => {
|
setQueuedMessages(serverId, (prev: Map<string, QueuedMessage[]>) => {
|
||||||
const next = new Map(prev);
|
const next = new Map(prev);
|
||||||
next.set(agentId, updater(prev.get(agentId) ?? []));
|
next.set(agentId, updater(prev.get(agentId) ?? []));
|
||||||
return next;
|
return next;
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
[agentId, setQueuedMessagesByAgent],
|
[agentId, serverId, setQueuedMessages],
|
||||||
);
|
);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
updateQueueRef.current = updateQueue;
|
updateQueueRef.current = updateQueue;
|
||||||
@@ -702,23 +718,23 @@ export function AgentInputArea({ agentId, serverId }: AgentInputAreaProps) {
|
|||||||
}
|
}
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
if (isAgentRunning) {
|
if (isAgentRunning) {
|
||||||
if (!hasSendableContent || !ws.isConnected) {
|
if (!hasSendableContent || !wsOrInert.isConnected) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
handleQueueCurrentInput();
|
handleQueueCurrentInput();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!shouldShowSendButton || isProcessing || !ws.isConnected) {
|
if (!shouldShowSendButton || isProcessing || !wsOrInert.isConnected) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
void handleSendMessage();
|
void handleSendMessage();
|
||||||
}
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isAgentRunning || !ws.isConnected) {
|
if (!isAgentRunning || !wsOrInert.isConnected) {
|
||||||
setIsCancellingAgent(false);
|
setIsCancellingAgent(false);
|
||||||
}
|
}
|
||||||
}, [isAgentRunning, ws.isConnected]);
|
}, [isAgentRunning, wsOrInert.isConnected]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!IS_WEB) {
|
if (!IS_WEB) {
|
||||||
@@ -738,7 +754,7 @@ export function AgentInputArea({ agentId, serverId }: AgentInputAreaProps) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!isRealtimeMode && shouldShowVoiceControls && ws.isConnected) {
|
if (!isRealtimeMode && shouldShowVoiceControls && wsOrInert.isConnected) {
|
||||||
void handleVoicePress();
|
void handleVoicePress();
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
@@ -759,7 +775,7 @@ export function AgentInputArea({ agentId, serverId }: AgentInputAreaProps) {
|
|||||||
isDictating,
|
isDictating,
|
||||||
isRealtimeMode,
|
isRealtimeMode,
|
||||||
shouldShowVoiceControls,
|
shouldShowVoiceControls,
|
||||||
ws,
|
wsOrInert,
|
||||||
handleVoicePress,
|
handleVoicePress,
|
||||||
handleSendRecording,
|
handleSendRecording,
|
||||||
handleCancelRecording,
|
handleCancelRecording,
|
||||||
@@ -820,7 +836,7 @@ export function AgentInputArea({ agentId, serverId }: AgentInputAreaProps) {
|
|||||||
if (isRealtimeMode) {
|
if (isRealtimeMode) {
|
||||||
await stopRealtime();
|
await stopRealtime();
|
||||||
} else {
|
} else {
|
||||||
if (!ws.isConnected || !serverId) {
|
if (!wsOrInert.isConnected || !serverId) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
await startRealtime(serverId);
|
await startRealtime(serverId);
|
||||||
@@ -834,7 +850,7 @@ export function AgentInputArea({ agentId, serverId }: AgentInputAreaProps) {
|
|||||||
if (!agent || agent.status !== "running" || isCancellingAgent) {
|
if (!agent || agent.status !== "running" || isCancellingAgent) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!ws.isConnected) {
|
if (!wsOrInert.isConnected || !cancelAgentRun) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setIsCancellingAgent(true);
|
setIsCancellingAgent(true);
|
||||||
@@ -867,7 +883,7 @@ export function AgentInputArea({ agentId, serverId }: AgentInputAreaProps) {
|
|||||||
|
|
||||||
async function handleSendQueuedNow(id: string) {
|
async function handleSendQueuedNow(id: string) {
|
||||||
const item = queuedMessages.find((q) => q.id === id);
|
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));
|
updateQueue((current) => current.filter((q) => q.id !== id));
|
||||||
|
|
||||||
@@ -879,11 +895,11 @@ export function AgentInputArea({ agentId, serverId }: AgentInputAreaProps) {
|
|||||||
const realtimeButton = (
|
const realtimeButton = (
|
||||||
<Pressable
|
<Pressable
|
||||||
onPress={handleRealtimePress}
|
onPress={handleRealtimePress}
|
||||||
disabled={!ws.isConnected && !isRealtimeMode}
|
disabled={!wsOrInert.isConnected && !isRealtimeMode}
|
||||||
style={[
|
style={[
|
||||||
styles.realtimeButton as any,
|
styles.realtimeButton as any,
|
||||||
(isRealtimeMode ? styles.realtimeButtonActive : undefined) 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 ? (
|
{isRealtimeMode ? (
|
||||||
@@ -897,10 +913,10 @@ export function AgentInputArea({ agentId, serverId }: AgentInputAreaProps) {
|
|||||||
const voiceButton = (
|
const voiceButton = (
|
||||||
<Pressable
|
<Pressable
|
||||||
onPress={handleVoicePress}
|
onPress={handleVoicePress}
|
||||||
disabled={!ws.isConnected || isRealtimeMode}
|
disabled={!wsOrInert.isConnected || isRealtimeMode}
|
||||||
style={[
|
style={[
|
||||||
styles.voiceButton as any,
|
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,
|
(isDictating ? styles.voiceButtonRecording : undefined) as any,
|
||||||
]}
|
]}
|
||||||
>
|
>
|
||||||
@@ -991,7 +1007,7 @@ export function AgentInputArea({ agentId, serverId }: AgentInputAreaProps) {
|
|||||||
multiline
|
multiline
|
||||||
scrollEnabled={inputHeight >= MAX_INPUT_HEIGHT}
|
scrollEnabled={inputHeight >= MAX_INPUT_HEIGHT}
|
||||||
onContentSizeChange={handleContentSizeChange}
|
onContentSizeChange={handleContentSizeChange}
|
||||||
editable={!isDictating && ws.isConnected}
|
editable={!isDictating && wsOrInert.isConnected}
|
||||||
onKeyPress={shouldHandleDesktopSubmit ? handleDesktopSubmitKeyPress : undefined}
|
onKeyPress={shouldHandleDesktopSubmit ? handleDesktopSubmitKeyPress : undefined}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@@ -1001,10 +1017,10 @@ export function AgentInputArea({ agentId, serverId }: AgentInputAreaProps) {
|
|||||||
<View style={styles.leftButtonGroup}>
|
<View style={styles.leftButtonGroup}>
|
||||||
<Pressable
|
<Pressable
|
||||||
onPress={handlePickImage}
|
onPress={handlePickImage}
|
||||||
disabled={!ws.isConnected}
|
disabled={!wsOrInert.isConnected}
|
||||||
style={[
|
style={[
|
||||||
styles.attachButton as any,
|
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} />
|
<Paperclip size={20} color={theme.colors.foreground} />
|
||||||
@@ -1020,12 +1036,12 @@ export function AgentInputArea({ agentId, serverId }: AgentInputAreaProps) {
|
|||||||
{hasSendableContent && (
|
{hasSendableContent && (
|
||||||
<Pressable
|
<Pressable
|
||||||
onPress={handleQueueCurrentInput}
|
onPress={handleQueueCurrentInput}
|
||||||
disabled={!ws.isConnected}
|
disabled={!wsOrInert.isConnected}
|
||||||
accessibilityLabel="Queue message while agent is running"
|
accessibilityLabel="Queue message while agent is running"
|
||||||
accessibilityRole="button"
|
accessibilityRole="button"
|
||||||
style={[
|
style={[
|
||||||
styles.queueButton as any,
|
styles.queueButton as any,
|
||||||
(!ws.isConnected ? styles.buttonDisabled : undefined) as any,
|
(!wsOrInert.isConnected ? styles.buttonDisabled : undefined) as any,
|
||||||
]}
|
]}
|
||||||
>
|
>
|
||||||
<ArrowUp size={20} color="white" />
|
<ArrowUp size={20} color="white" />
|
||||||
@@ -1034,12 +1050,12 @@ export function AgentInputArea({ agentId, serverId }: AgentInputAreaProps) {
|
|||||||
{realtimeButton}
|
{realtimeButton}
|
||||||
<Pressable
|
<Pressable
|
||||||
onPress={handleCancelAgent}
|
onPress={handleCancelAgent}
|
||||||
disabled={!ws.isConnected || isCancellingAgent}
|
disabled={!wsOrInert.isConnected || isCancellingAgent}
|
||||||
accessibilityLabel={isCancellingAgent ? "Canceling agent" : "Stop agent"}
|
accessibilityLabel={isCancellingAgent ? "Canceling agent" : "Stop agent"}
|
||||||
accessibilityRole="button"
|
accessibilityRole="button"
|
||||||
style={[
|
style={[
|
||||||
styles.cancelButton as any,
|
styles.cancelButton as any,
|
||||||
(!ws.isConnected || isCancellingAgent ? styles.buttonDisabled : undefined) as any,
|
(!wsOrInert.isConnected || isCancellingAgent ? styles.buttonDisabled : undefined) as any,
|
||||||
]}
|
]}
|
||||||
>
|
>
|
||||||
{isCancellingAgent ? (
|
{isCancellingAgent ? (
|
||||||
@@ -1052,10 +1068,10 @@ export function AgentInputArea({ agentId, serverId }: AgentInputAreaProps) {
|
|||||||
) : shouldShowSendButton ? (
|
) : shouldShowSendButton ? (
|
||||||
<Pressable
|
<Pressable
|
||||||
onPress={handleSendMessage}
|
onPress={handleSendMessage}
|
||||||
disabled={!ws.isConnected || isProcessing}
|
disabled={!wsOrInert.isConnected || isProcessing}
|
||||||
style={[
|
style={[
|
||||||
styles.sendButton as any,
|
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" />
|
<ArrowUp size={20} color="white" />
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { formatTimeAgo } from "@/utils/time";
|
|||||||
import { getAgentStatusColor, getAgentStatusLabel } from "@/utils/agent-status";
|
import { getAgentStatusColor, getAgentStatusLabel } from "@/utils/agent-status";
|
||||||
import { getAgentProviderDefinition } from "@server/server/agent/provider-manifest";
|
import { getAgentProviderDefinition } from "@server/server/agent/provider-manifest";
|
||||||
import { type AggregatedAgent } from "@/hooks/use-aggregated-agents";
|
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";
|
import { buildAgentNavigationKey, startNavigationTiming } from "@/utils/navigation-timing";
|
||||||
|
|
||||||
interface AgentListProps {
|
interface AgentListProps {
|
||||||
@@ -18,14 +18,15 @@ interface AgentListProps {
|
|||||||
export function AgentList({ agents, isRefreshing = false, onRefresh }: AgentListProps) {
|
export function AgentList({ agents, isRefreshing = false, onRefresh }: AgentListProps) {
|
||||||
const { theme } = useUnistyles();
|
const { theme } = useUnistyles();
|
||||||
const [actionAgent, setActionAgent] = useState<AggregatedAgent | null>(null);
|
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 isActionSheetVisible = actionAgent !== null;
|
||||||
const isActionDaemonUnavailable = Boolean(actionAgent?.serverId && !actionSession);
|
const isActionDaemonUnavailable = Boolean(actionAgent?.serverId && !methods);
|
||||||
|
|
||||||
const handleAgentPress = useCallback(
|
const handleAgentPress = useCallback(
|
||||||
(serverId: string, agentId: string) => {
|
(serverId: string, agentId: string) => {
|
||||||
|
|||||||
@@ -1,36 +1,42 @@
|
|||||||
import { View, Text, Pressable } from "react-native";
|
import { View, Text, Pressable } from "react-native";
|
||||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||||
import { ChevronDown } from "lucide-react-native";
|
import { ChevronDown } from "lucide-react-native";
|
||||||
import { useState, useCallback, useRef } from "react";
|
import { useState, useRef } from "react";
|
||||||
import { ModeSelectorModal } from "./mode-selector-modal";
|
import { ModeSelectorModal } from "./mode-selector-modal";
|
||||||
import { useDaemonSession } from "@/hooks/use-daemon-session";
|
import { useSessionStore } from "@/stores/session-store";
|
||||||
import type { SessionContextValue, Agent } from "@/contexts/session-context";
|
|
||||||
|
|
||||||
interface AgentStatusBarProps {
|
interface AgentStatusBarProps {
|
||||||
agentId: string;
|
agentId: string;
|
||||||
serverId?: string;
|
serverId: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function AgentStatusBar({ agentId, serverId }: AgentStatusBarProps) {
|
export function AgentStatusBar({ agentId, serverId }: AgentStatusBarProps) {
|
||||||
// INVESTIGATION: Log rerenders
|
// INVESTIGATION: Log rerenders
|
||||||
const renderCountRef = useRef(0);
|
const renderCountRef = useRef(0);
|
||||||
renderCountRef.current++;
|
renderCountRef.current++;
|
||||||
console.log(`[INVESTIGATION] AgentStatusBar render #${renderCountRef.current}`, { agentId });
|
console.log(`[INVESTIGATION] AgentStatusBar render #${renderCountRef.current}`, { agentId, serverId });
|
||||||
const { theme } = useUnistyles();
|
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) {
|
if (!agent) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleModeChange(modeId: string) {
|
function handleModeChange(modeId: string) {
|
||||||
setAgentMode(agentId, modeId);
|
if (setAgentMode) {
|
||||||
|
setAgentMode(agentId, modeId);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ import type { StreamItem } from "@/types/stream";
|
|||||||
import type { PendingPermission } from "@/types/shared";
|
import type { PendingPermission } from "@/types/shared";
|
||||||
import type { AgentPermissionResponse } from "@server/server/agent/agent-sdk-types";
|
import type { AgentPermissionResponse } from "@server/server/agent/agent-sdk-types";
|
||||||
import type { Agent } from "@/contexts/session-context";
|
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 { useDaemonRequest } from "@/hooks/use-daemon-request";
|
||||||
import type { UseWebSocketReturn } from "@/hooks/use-websocket";
|
import type { UseWebSocketReturn } from "@/hooks/use-websocket";
|
||||||
import type { SessionOutboundMessage } from "@server/server/messages";
|
import type { SessionOutboundMessage } from "@server/server/messages";
|
||||||
@@ -79,7 +79,19 @@ export function AgentStreamView({
|
|||||||
const isNearBottomRef = useRef(true);
|
const isNearBottomRef = useRef(true);
|
||||||
const isUserScrollingRef = useRef(false);
|
const isUserScrollingRef = useRef(false);
|
||||||
const router = useRouter();
|
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>(
|
const inertWebSocket = useMemo<UseWebSocketReturn>(
|
||||||
() => ({
|
() => ({
|
||||||
isConnected: false,
|
isConnected: false,
|
||||||
@@ -93,10 +105,9 @@ export function AgentStreamView({
|
|||||||
}),
|
}),
|
||||||
[]
|
[]
|
||||||
);
|
);
|
||||||
const ws = session?.ws ?? inertWebSocket;
|
const wsOrInert = ws ?? inertWebSocket;
|
||||||
const requestDirectoryListing = session?.requestDirectoryListing ?? (() => {});
|
const requestDirectoryListingOrInert = requestDirectoryListing ?? (() => {});
|
||||||
const requestFilePreview = session?.requestFilePreview ?? (() => {});
|
const requestFilePreviewOrInert = requestFilePreview ?? (() => {});
|
||||||
const resolvedServerId = serverId ?? session?.serverId ?? agent.serverId ?? "";
|
|
||||||
// Keep entry/exit animations off on Android due to RN dispatchDraw crashes
|
// Keep entry/exit animations off on Android due to RN dispatchDraw crashes
|
||||||
// tracked in react-native-reanimated#8422.
|
// tracked in react-native-reanimated#8422.
|
||||||
const shouldDisableEntryExitAnimations = Platform.OS === "android";
|
const shouldDisableEntryExitAnimations = Platform.OS === "android";
|
||||||
@@ -120,9 +131,9 @@ export function AgentStreamView({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
requestDirectoryListing(agentId, normalized.directory);
|
requestDirectoryListingOrInert(agentId, normalized.directory);
|
||||||
if (normalized.file) {
|
if (normalized.file) {
|
||||||
requestFilePreview(agentId, normalized.file);
|
requestFilePreviewOrInert(agentId, normalized.file);
|
||||||
}
|
}
|
||||||
|
|
||||||
router.push({
|
router.push({
|
||||||
@@ -141,7 +152,7 @@ export function AgentStreamView({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
[agentId, requestDirectoryListing, requestFilePreview, resolvedServerId, router]
|
[agentId, requestDirectoryListingOrInert, requestFilePreviewOrInert, resolvedServerId, router]
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleScroll = useCallback(
|
const handleScroll = useCallback(
|
||||||
@@ -350,7 +361,7 @@ export function AgentStreamView({
|
|||||||
{pendingPermissionItems.length > 0 ? (
|
{pendingPermissionItems.length > 0 ? (
|
||||||
<View style={stylesheet.permissionsContainer}>
|
<View style={stylesheet.permissionsContainer}>
|
||||||
{pendingPermissionItems.map((permission) => (
|
{pendingPermissionItems.map((permission) => (
|
||||||
<PermissionRequestCard key={permission.key} permission={permission} ws={ws} />
|
<PermissionRequestCard key={permission.key} permission={permission} ws={wsOrInert} />
|
||||||
))}
|
))}
|
||||||
</View>
|
</View>
|
||||||
) : null}
|
) : null}
|
||||||
@@ -363,7 +374,7 @@ export function AgentStreamView({
|
|||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
}, [pendingPermissionItems, showWorkingIndicator, ws]);
|
}, [pendingPermissionItems, showWorkingIndicator, wsOrInert]);
|
||||||
|
|
||||||
const flatListData = useMemo(() => {
|
const flatListData = useMemo(() => {
|
||||||
return [...streamItems].reverse();
|
return [...streamItems].reverse();
|
||||||
|
|||||||
@@ -65,7 +65,6 @@ import type { WSInboundMessage, SessionOutboundMessage } from "@server/server/me
|
|||||||
import { formatConnectionStatus } from "@/utils/daemons";
|
import { formatConnectionStatus } from "@/utils/daemons";
|
||||||
import { trackAnalyticsEvent } from "@/utils/analytics";
|
import { trackAnalyticsEvent } from "@/utils/analytics";
|
||||||
import type { SessionContextValue } from "@/contexts/session-context";
|
import type { SessionContextValue } from "@/contexts/session-context";
|
||||||
import { useDaemonSession } from "@/hooks/use-daemon-session";
|
|
||||||
import type { UseWebSocketReturn } from "@/hooks/use-websocket";
|
import type { UseWebSocketReturn } from "@/hooks/use-websocket";
|
||||||
import { useSessionStore } from "@/stores/session-store";
|
import { useSessionStore } from "@/stores/session-store";
|
||||||
|
|
||||||
@@ -263,26 +262,29 @@ function AgentFlowModal({
|
|||||||
}, [serverId, daemonEntries]);
|
}, [serverId, daemonEntries]);
|
||||||
const [selectedServerId, setSelectedServerId] = useState<string | null>(initialServerId);
|
const [selectedServerId, setSelectedServerId] = useState<string | null>(initialServerId);
|
||||||
|
|
||||||
// Use useDaemonSession to get both state AND APIs
|
// [INVESTIGATION] Using useSessionStore directly with granular selectors
|
||||||
const fullSession = useDaemonSession(selectedServerId, { allowUnavailable: true, suppressUnavailableAlert: true });
|
// 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
|
// Extract only what we need for CreateAgentSessionSlice
|
||||||
const session = useMemo<CreateAgentSessionSlice | null>(() => {
|
const session = useMemo<CreateAgentSessionSlice | null>(() => {
|
||||||
if (!fullSession) {
|
if (!selectedServerId || !sessionState || !sessionState.methods) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
const slice: CreateAgentSessionSlice = {
|
const slice: CreateAgentSessionSlice = {
|
||||||
serverId: fullSession.serverId,
|
serverId: selectedServerId,
|
||||||
ws: fullSession.ws,
|
ws: sessionState.ws,
|
||||||
createAgent: fullSession.createAgent,
|
createAgent: sessionState.methods.createAgent,
|
||||||
resumeAgent: fullSession.resumeAgent,
|
resumeAgent: sessionState.methods.resumeAgent,
|
||||||
sendAgentAudio: fullSession.sendAgentAudio,
|
sendAgentAudio: sessionState.methods.sendAgentAudio,
|
||||||
agents: fullSession.agents,
|
agents: sessionState.agents,
|
||||||
providerModels: fullSession.providerModels,
|
providerModels: sessionState.providerModels,
|
||||||
requestProviderModels: fullSession.requestProviderModels,
|
requestProviderModels: sessionState.methods.requestProviderModels,
|
||||||
};
|
};
|
||||||
return slice;
|
return slice;
|
||||||
}, [fullSession]);
|
}, [selectedServerId, sessionState]);
|
||||||
|
|
||||||
// Helper to get session state for background operations
|
// Helper to get session state for background operations
|
||||||
// Note: This only returns state, not APIs. For calling methods, need to send WS messages directly.
|
// 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]);
|
}, [daemonEntries, initialServerId, isVisible, selectedServerId]);
|
||||||
|
|
||||||
const ws = session?.ws ?? null;
|
|
||||||
const inertWebSocket = useMemo<UseWebSocketReturn>(
|
const inertWebSocket = useMemo<UseWebSocketReturn>(
|
||||||
() => ({
|
() => ({
|
||||||
isConnected: false,
|
isConnected: false,
|
||||||
@@ -340,6 +341,7 @@ function AgentFlowModal({
|
|||||||
}),
|
}),
|
||||||
[]
|
[]
|
||||||
);
|
);
|
||||||
|
const ws = session?.ws ?? null;
|
||||||
const effectiveWs: UseWebSocketReturn = ws ?? inertWebSocket;
|
const effectiveWs: UseWebSocketReturn = ws ?? inertWebSocket;
|
||||||
const createAgent = session?.createAgent;
|
const createAgent = session?.createAgent;
|
||||||
const resumeAgent = session?.resumeAgent;
|
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)
|
// Memoize the methods object to avoid infinite re-renders (object reference must be stable)
|
||||||
const setSessionMethods = useSessionStore((state) => state.setSessionMethods);
|
const setSessionMethods = useSessionStore((state) => state.setSessionMethods);
|
||||||
const methods = useMemo(() => ({
|
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