chore: land multi-daemon rollout
This commit is contained in:
@@ -101,16 +101,16 @@ export default function LegacyAgentRedirectScreen() {
|
||||
</View>
|
||||
);
|
||||
} else if (matches.length === 0) {
|
||||
body = (
|
||||
<View style={styles.centerState}>
|
||||
<Text style={styles.title}>Agent not found</Text>
|
||||
<Text style={styles.subtitle}>
|
||||
We couldn't find "{agentId}" on any connected daemon. Make sure the daemon is online
|
||||
or reopen the agent from the Home screen after it reconnects.
|
||||
</Text>
|
||||
<Pressable style={styles.primaryButton} onPress={handleGoHome}>
|
||||
<Text style={styles.primaryButtonText}>Go Home</Text>
|
||||
</Pressable>
|
||||
body = (
|
||||
<View style={styles.centerState}>
|
||||
<Text style={styles.title}>Agent not found</Text>
|
||||
<Text style={styles.subtitle}>
|
||||
We could not find {agentId} on any connected daemon. Make sure the daemon is online
|
||||
or reopen the agent from the Home screen after it reconnects.
|
||||
</Text>
|
||||
<Pressable style={styles.primaryButton} onPress={handleGoHome}>
|
||||
<Text style={styles.primaryButtonText}>Go Home</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
);
|
||||
} else if (matches.length > 1) {
|
||||
|
||||
@@ -23,7 +23,7 @@ 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, DaemonSessionUnavailableError } from "@/hooks/use-daemon-session";
|
||||
import { useDaemonSession } from "@/hooks/use-daemon-session";
|
||||
import { useDaemonRequest } from "@/hooks/use-daemon-request";
|
||||
import type { SessionOutboundMessage } from "@server/server/messages";
|
||||
|
||||
@@ -78,20 +78,15 @@ function extractAgentModel(agent?: Agent | null): string | null {
|
||||
}
|
||||
|
||||
export default function AgentScreen() {
|
||||
const { theme } = useUnistyles();
|
||||
const insets = useSafeAreaInsets();
|
||||
const router = useRouter();
|
||||
const { serverId, agentId } = useLocalSearchParams<{ serverId: string; agentId: string }>();
|
||||
const resolvedAgentId = typeof agentId === "string" ? agentId : undefined;
|
||||
const resolvedServerId = typeof serverId === "string" ? serverId : undefined;
|
||||
const handleBackToHome = useCallback(() => {
|
||||
router.replace("/");
|
||||
}, [router]);
|
||||
const { connectionStates, activeDaemonId, setActiveDaemonId } = useDaemonConnections();
|
||||
let session: SessionContextValue | null = null;
|
||||
const hasConnectionEntry = resolvedServerId ? connectionStates.has(resolvedServerId) : false;
|
||||
|
||||
useEffect(() => {
|
||||
if (!resolvedServerId) {
|
||||
if (!resolvedServerId || !hasConnectionEntry) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -99,26 +94,26 @@ export default function AgentScreen() {
|
||||
return;
|
||||
}
|
||||
|
||||
setActiveDaemonId(resolvedServerId);
|
||||
}, [resolvedServerId, activeDaemonId, setActiveDaemonId]);
|
||||
setActiveDaemonId(resolvedServerId, { source: "agent_route" });
|
||||
}, [resolvedServerId, hasConnectionEntry, activeDaemonId, setActiveDaemonId]);
|
||||
|
||||
try {
|
||||
session = useDaemonSession(resolvedServerId, { suppressUnavailableAlert: true });
|
||||
} catch (error) {
|
||||
if (error instanceof DaemonSessionUnavailableError) {
|
||||
session = null;
|
||||
} else {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
const session = useDaemonSession(resolvedServerId, {
|
||||
suppressUnavailableAlert: true,
|
||||
allowUnavailable: true,
|
||||
});
|
||||
|
||||
const connectionServerId = resolvedServerId ?? null;
|
||||
const connection = connectionServerId ? connectionStates.get(connectionServerId) : null;
|
||||
const serverLabel = connection?.daemon.label ?? connectionServerId ?? "Selected daemon";
|
||||
const connectionStatus = connection?.status ?? "idle";
|
||||
const isUnknownDaemon = Boolean(connectionServerId && !connection);
|
||||
const connectionStatus = connection?.status ?? (isUnknownDaemon ? "offline" : "idle");
|
||||
const connectionStatusLabel = formatConnectionStatus(connectionStatus);
|
||||
const lastConnectionError = connection?.lastError ?? null;
|
||||
|
||||
const handleBackToHome = useCallback(() => {
|
||||
router.replace("/");
|
||||
}, [router]);
|
||||
|
||||
if (!session) {
|
||||
return (
|
||||
<AgentSessionUnavailableState
|
||||
@@ -127,21 +122,34 @@ export default function AgentScreen() {
|
||||
connectionStatus={connectionStatus}
|
||||
connectionStatusLabel={connectionStatusLabel}
|
||||
lastError={lastConnectionError}
|
||||
isUnknownDaemon={isUnknownDaemon}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const {
|
||||
agents,
|
||||
agentStreamState,
|
||||
initializingAgents,
|
||||
pendingPermissions,
|
||||
initializeAgent,
|
||||
refreshAgent,
|
||||
setFocusedAgentId,
|
||||
ws,
|
||||
} = session;
|
||||
const routeServerId = resolvedServerId ?? session.serverId;
|
||||
|
||||
return (
|
||||
<AgentScreenContent
|
||||
session={session}
|
||||
agentId={resolvedAgentId}
|
||||
routeServerId={routeServerId}
|
||||
onBack={handleBackToHome}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
type AgentScreenContentProps = {
|
||||
session: SessionContextValue;
|
||||
agentId?: string;
|
||||
routeServerId: string;
|
||||
onBack: () => void;
|
||||
};
|
||||
|
||||
function AgentScreenContent({ session, agentId, routeServerId, onBack }: AgentScreenContentProps) {
|
||||
const { theme } = useUnistyles();
|
||||
const insets = useSafeAreaInsets();
|
||||
const router = useRouter();
|
||||
const { registerFooterControls, unregisterFooterControls } = useFooterControls();
|
||||
const { width: windowWidth, height: windowHeight } = useWindowDimensions();
|
||||
const [menuVisible, setMenuVisible] = useState(false);
|
||||
@@ -153,7 +161,18 @@ export default function AgentScreen() {
|
||||
const [createAgentInitialValues, setCreateAgentInitialValues] =
|
||||
useState<CreateAgentInitialValues | undefined>();
|
||||
|
||||
// Keyboard animation
|
||||
const {
|
||||
agents,
|
||||
agentStreamState,
|
||||
initializingAgents,
|
||||
pendingPermissions,
|
||||
initializeAgent,
|
||||
refreshAgent,
|
||||
setFocusedAgentId,
|
||||
ws,
|
||||
} = session;
|
||||
const resolvedAgentId = agentId;
|
||||
|
||||
const { height: keyboardHeight } = useReanimatedKeyboardAnimation();
|
||||
const bottomInset = useSharedValue(insets.bottom);
|
||||
|
||||
@@ -303,11 +322,11 @@ export default function AgentScreen() {
|
||||
// Position menu below button using the raw coordinates from measureInWindow
|
||||
const buttonBottom = y + height;
|
||||
const top = buttonBottom + verticalOffset;
|
||||
|
||||
|
||||
// If menu would go off screen, clamp to visible area
|
||||
const bottomEdge = top + menuContentHeight;
|
||||
const maxBottom = windowHeight - horizontalMargin;
|
||||
const clampedTop = bottomEdge > maxBottom
|
||||
const clampedTop = bottomEdge > maxBottom
|
||||
? Math.max(verticalOffset, maxBottom - menuContentHeight)
|
||||
: top;
|
||||
|
||||
@@ -435,7 +454,7 @@ export default function AgentScreen() {
|
||||
return (
|
||||
<>
|
||||
<View style={styles.container}>
|
||||
<BackHeader onBack={handleBackToHome} />
|
||||
<BackHeader onBack={onBack} />
|
||||
<View style={styles.errorContainer}>
|
||||
<Text style={styles.errorText}>Agent not found</Text>
|
||||
</View>
|
||||
@@ -452,7 +471,7 @@ export default function AgentScreen() {
|
||||
{/* Header */}
|
||||
<BackHeader
|
||||
title={agent.title || "Agent"}
|
||||
onBack={handleBackToHome}
|
||||
onBack={onBack}
|
||||
rightContent={
|
||||
<View ref={menuButtonRef} collapsable={false}>
|
||||
<Pressable onPress={handleOpenMenu} style={styles.menuButton}>
|
||||
@@ -607,13 +626,31 @@ function AgentSessionUnavailableState({
|
||||
connectionStatus,
|
||||
connectionStatusLabel,
|
||||
lastError,
|
||||
isUnknownDaemon = false,
|
||||
}: {
|
||||
onBack: () => void;
|
||||
serverLabel: string;
|
||||
connectionStatus: ConnectionStatus;
|
||||
connectionStatusLabel: string;
|
||||
lastError: string | null;
|
||||
isUnknownDaemon?: boolean;
|
||||
}) {
|
||||
if (isUnknownDaemon) {
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<BackHeader title="Agent" onBack={onBack} />
|
||||
<View style={styles.centerState}>
|
||||
<Text style={styles.errorText}>
|
||||
Cannot open this agent because {serverLabel} is not configured on this device.
|
||||
</Text>
|
||||
<Text style={styles.statusText}>
|
||||
Add the daemon in Settings or open an agent on a configured server to continue.
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const isConnecting = connectionStatus === "connecting";
|
||||
|
||||
return (
|
||||
@@ -624,12 +661,12 @@ function AgentSessionUnavailableState({
|
||||
<>
|
||||
<ActivityIndicator size="large" />
|
||||
<Text style={styles.loadingText}>Connecting to {serverLabel}...</Text>
|
||||
<Text style={styles.statusText}>We'll show this agent once the daemon is online.</Text>
|
||||
<Text style={styles.statusText}>We will show this agent once the daemon is online.</Text>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Text style={styles.errorText}>
|
||||
Can't open this agent while {serverLabel} is {connectionStatusLabel.toLowerCase()}.
|
||||
Cannot open this agent while {serverLabel} is {connectionStatusLabel.toLowerCase()}.
|
||||
</Text>
|
||||
<Text style={styles.statusText}>
|
||||
Connect this daemon or switch to another one to continue.
|
||||
|
||||
@@ -33,7 +33,7 @@ import type { ExplorerEntry, SessionContextValue } from "@/contexts/session-cont
|
||||
import type { ConnectionStatus } from "@/contexts/daemon-connections-context";
|
||||
import { useDaemonConnections } from "@/contexts/daemon-connections-context";
|
||||
import { formatConnectionStatus } from "@/utils/daemons";
|
||||
import { useDaemonSession, DaemonSessionUnavailableError } from "@/hooks/use-daemon-session";
|
||||
import { useDaemonSession } from "@/hooks/use-daemon-session";
|
||||
|
||||
export default function FileExplorerScreen() {
|
||||
const {
|
||||
@@ -49,18 +49,10 @@ export default function FileExplorerScreen() {
|
||||
}>();
|
||||
const resolvedServerId = typeof serverId === "string" ? serverId : undefined;
|
||||
const { connectionStates } = useDaemonConnections();
|
||||
|
||||
let session: SessionContextValue | null = null;
|
||||
|
||||
try {
|
||||
session = useDaemonSession(resolvedServerId, { suppressUnavailableAlert: true });
|
||||
} catch (error) {
|
||||
if (error instanceof DaemonSessionUnavailableError) {
|
||||
session = null;
|
||||
} else {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
const session = useDaemonSession(resolvedServerId, {
|
||||
suppressUnavailableAlert: true,
|
||||
allowUnavailable: true,
|
||||
});
|
||||
|
||||
const connectionServerId = resolvedServerId ?? null;
|
||||
const connection = connectionServerId ? connectionStates.get(connectionServerId) : null;
|
||||
@@ -478,25 +470,6 @@ function FileExplorerContent({
|
||||
);
|
||||
}, [activePath, showListLoadingBanner, viewMode]);
|
||||
|
||||
if (!agent) {
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<BackHeader
|
||||
title="Files"
|
||||
onBack={handleBackNavigation}
|
||||
rightContent={
|
||||
<Pressable style={styles.closeButton} onPress={handleCloseExplorer}>
|
||||
<X size={18} color={theme.colors.foreground} />
|
||||
</Pressable>
|
||||
}
|
||||
/>
|
||||
<View style={styles.centerState}>
|
||||
<Text style={styles.errorText}>Agent not found</Text>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const handleViewableItemsChanged = useCallback(
|
||||
({ viewableItems }: { viewableItems: Array<ViewToken> }) => {
|
||||
if (!agentId || viewMode !== "grid") {
|
||||
@@ -549,6 +522,25 @@ function FileExplorerContent({
|
||||
setThumbnailLoadingMap({});
|
||||
}, [activePath, viewMode]);
|
||||
|
||||
if (!agent) {
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<BackHeader
|
||||
title="Files"
|
||||
onBack={handleBackNavigation}
|
||||
rightContent={
|
||||
<Pressable style={styles.closeButton} onPress={handleCloseExplorer}>
|
||||
<X size={18} color={theme.colors.foreground} />
|
||||
</Pressable>
|
||||
}
|
||||
/>
|
||||
<View style={styles.centerState}>
|
||||
<Text style={styles.errorText}>Agent not found</Text>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<BackHeader
|
||||
@@ -696,12 +688,12 @@ function FileExplorerSessionUnavailable({
|
||||
<>
|
||||
<ActivityIndicator size="small" />
|
||||
<Text style={styles.loadingText}>Connecting to {serverLabel}...</Text>
|
||||
<Text style={styles.statusText}>We'll load files once this daemon is online.</Text>
|
||||
<Text style={styles.statusText}>We will load files once this daemon is online.</Text>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Text style={styles.errorText}>
|
||||
Can't open files while {serverLabel} is {connectionStatusLabel.toLowerCase()}.
|
||||
Cannot open files while {serverLabel} is {connectionStatusLabel.toLowerCase()}.
|
||||
</Text>
|
||||
<Text style={styles.statusText}>Connect this daemon and try again.</Text>
|
||||
{lastError ? <Text style={styles.errorDetails}>{lastError}</Text> : null}
|
||||
|
||||
@@ -7,7 +7,7 @@ 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, DaemonSessionUnavailableError } from "@/hooks/use-daemon-session";
|
||||
import { useDaemonSession } from "@/hooks/use-daemon-session";
|
||||
|
||||
interface ParsedDiffFile {
|
||||
path: string;
|
||||
@@ -58,18 +58,10 @@ export default function GitDiffScreen() {
|
||||
const { agentId, serverId } = useLocalSearchParams<{ agentId: string; serverId?: string }>();
|
||||
const resolvedServerId = typeof serverId === "string" ? serverId : undefined;
|
||||
const { connectionStates } = useDaemonConnections();
|
||||
|
||||
let session: SessionContextValue | null = null;
|
||||
|
||||
try {
|
||||
session = useDaemonSession(resolvedServerId, { suppressUnavailableAlert: true });
|
||||
} catch (error) {
|
||||
if (error instanceof DaemonSessionUnavailableError) {
|
||||
session = null;
|
||||
} else {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
const session = useDaemonSession(resolvedServerId, {
|
||||
suppressUnavailableAlert: true,
|
||||
allowUnavailable: true,
|
||||
});
|
||||
|
||||
const connectionServerId = resolvedServerId ?? null;
|
||||
const connection = connectionServerId ? connectionStates.get(connectionServerId) : null;
|
||||
@@ -230,12 +222,12 @@ function SessionUnavailableState({
|
||||
<>
|
||||
<ActivityIndicator size="large" />
|
||||
<Text style={styles.loadingText}>Connecting to {serverLabel}...</Text>
|
||||
<Text style={styles.statusText}>We'll show changes once this session is online.</Text>
|
||||
<Text style={styles.statusText}>We will show changes once this session is online.</Text>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Text style={styles.errorText}>
|
||||
Can't load changes while {serverLabel} is {connectionStatusLabel.toLowerCase()}.
|
||||
Cannot load changes while {serverLabel} is {connectionStatusLabel.toLowerCase()}.
|
||||
</Text>
|
||||
<Text style={styles.statusText}>Connect this daemon or switch to another one to continue.</Text>
|
||||
{lastError ? <Text style={styles.errorDetails}>{lastError}</Text> : null}
|
||||
|
||||
@@ -476,10 +476,10 @@ export default function SettingsScreen() {
|
||||
};
|
||||
if (daemonForm.id) {
|
||||
await updateDaemon(daemonForm.id, payload);
|
||||
setActiveDaemonId(daemonForm.id);
|
||||
setActiveDaemonId(daemonForm.id, { source: "settings_save_daemon" });
|
||||
} else {
|
||||
const created = await addDaemon(payload);
|
||||
setActiveDaemonId(created.id);
|
||||
setActiveDaemonId(created.id, { source: "settings_save_daemon" });
|
||||
}
|
||||
handleCloseDaemonForm();
|
||||
} catch (error) {
|
||||
@@ -524,7 +524,7 @@ export default function SettingsScreen() {
|
||||
|
||||
const handleSetActiveDaemon = useCallback(
|
||||
(profile: DaemonProfile) => {
|
||||
setActiveDaemonId(profile.id);
|
||||
setActiveDaemonId(profile.id, { source: "settings_switch" });
|
||||
},
|
||||
[setActiveDaemonId]
|
||||
);
|
||||
@@ -545,15 +545,15 @@ export default function SettingsScreen() {
|
||||
return;
|
||||
}
|
||||
updateDaemonTestState(profile.id, { status: "testing" });
|
||||
updateConnectionStatus(profile.id, "connecting");
|
||||
updateConnectionStatus(profile.id, { status: "connecting" });
|
||||
try {
|
||||
await testServerConnection(url, 4000);
|
||||
updateDaemonTestState(profile.id, { status: "success", message: "Reachable" });
|
||||
updateConnectionStatus(profile.id, "online", { lastOnlineAt: new Date().toISOString() });
|
||||
updateConnectionStatus(profile.id, { status: "online", lastOnlineAt: new Date().toISOString() });
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Connection failed";
|
||||
updateDaemonTestState(profile.id, { status: "error", message });
|
||||
updateConnectionStatus(profile.id, "offline", { lastError: message });
|
||||
updateConnectionStatus(profile.id, { status: "offline", lastError: message });
|
||||
}
|
||||
},
|
||||
[testServerConnection, updateConnectionStatus, updateDaemonTestState]
|
||||
|
||||
@@ -8,7 +8,7 @@ import { getAgentStatusColor, getAgentStatusLabel } from "@/utils/agent-status";
|
||||
import { getAgentProviderDefinition } from "@server/server/agent/provider-manifest";
|
||||
import { useDaemonConnections } from "@/contexts/daemon-connections-context";
|
||||
import { type AggregatedAgentGroup } from "@/hooks/use-aggregated-agents";
|
||||
import { useDaemonSession, DaemonSessionUnavailableError } from "@/hooks/use-daemon-session";
|
||||
import { useDaemonSession } from "@/hooks/use-daemon-session";
|
||||
|
||||
interface AgentListProps {
|
||||
agentGroups: AggregatedAgentGroup[];
|
||||
@@ -19,29 +19,20 @@ export function AgentList({ agentGroups }: AgentListProps) {
|
||||
const { setActiveDaemonId } = useDaemonConnections();
|
||||
const [actionAgent, setActionAgent] = useState<Agent | null>(null);
|
||||
const [actionAgentServerId, setActionAgentServerId] = useState<string | null>(null);
|
||||
let actionSession = null;
|
||||
let actionSessionUnavailable = false;
|
||||
|
||||
try {
|
||||
actionSession = useDaemonSession(actionAgentServerId ?? undefined, { suppressUnavailableAlert: true });
|
||||
} catch (error) {
|
||||
if (error instanceof DaemonSessionUnavailableError) {
|
||||
actionSessionUnavailable = true;
|
||||
actionSession = null;
|
||||
} else {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
const actionSession = useDaemonSession(actionAgentServerId ?? undefined, {
|
||||
suppressUnavailableAlert: true,
|
||||
allowUnavailable: true,
|
||||
});
|
||||
|
||||
const deleteAgent = actionSession?.deleteAgent;
|
||||
const isActionSheetVisible = actionAgent !== null;
|
||||
const isActionDaemonUnavailable = Boolean(actionAgentServerId && actionSessionUnavailable);
|
||||
const isActionDaemonUnavailable = Boolean(actionAgentServerId && !actionSession);
|
||||
|
||||
const handleAgentPress = useCallback((serverId: string, agentId: string) => {
|
||||
if (isActionSheetVisible) {
|
||||
return;
|
||||
}
|
||||
setActiveDaemonId(serverId);
|
||||
setActiveDaemonId(serverId, { source: "agent_row_press" });
|
||||
router.push({
|
||||
pathname: "/agent/[serverId]/[agentId]",
|
||||
params: {
|
||||
|
||||
@@ -40,9 +40,8 @@ export function AgentSidebar({
|
||||
const insets = useSafeAreaInsets();
|
||||
const savedTranslateX = useSharedValue(0);
|
||||
const [isVisible, setIsVisible] = useState(isOpen);
|
||||
|
||||
// Use edgeSwipeTranslateX directly since it's always provided
|
||||
const translateX = edgeSwipeTranslateX || useSharedValue(-300);
|
||||
const fallbackTranslateX = useSharedValue(-300);
|
||||
const translateX = edgeSwipeTranslateX ?? fallbackTranslateX;
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
|
||||
@@ -49,7 +49,7 @@ import {
|
||||
AGENT_PROVIDER_DEFINITIONS,
|
||||
type AgentProviderDefinition,
|
||||
} from "@server/server/agent/provider-manifest";
|
||||
import { useDaemonConnections } from "@/contexts/daemon-connections-context";
|
||||
import { useDaemonConnections, type ConnectionStatus } from "@/contexts/daemon-connections-context";
|
||||
import type {
|
||||
AgentProvider,
|
||||
AgentMode,
|
||||
@@ -61,8 +61,9 @@ import type {
|
||||
import { useDaemonRequest } from "@/hooks/use-daemon-request";
|
||||
import type { WSInboundMessage, SessionOutboundMessage } from "@server/server/messages";
|
||||
import { formatConnectionStatus } from "@/utils/daemons";
|
||||
import { useSession } from "@/contexts/session-context";
|
||||
import { useSessionForServer } from "@/hooks/use-session-directory";
|
||||
import { trackAnalyticsEvent } from "@/utils/analytics";
|
||||
import { useSession, type SessionContextValue } from "@/contexts/session-context";
|
||||
import { useSessionDirectory, useSessionForServer } from "@/hooks/use-session-directory";
|
||||
import type { UseWebSocketReturn } from "@/hooks/use-websocket";
|
||||
|
||||
export type CreateAgentInitialValues = {
|
||||
@@ -230,6 +231,7 @@ function AgentFlowModal({
|
||||
const selectedSession = useSessionForServer(selectedServerId);
|
||||
const session =
|
||||
selectedServerId === null ? activeSession : selectedSession ?? null;
|
||||
const sessionDirectory = useSessionDirectory();
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedServerId) {
|
||||
@@ -307,21 +309,29 @@ function AgentFlowModal({
|
||||
const isWsConnected = ws?.isConnected ?? false;
|
||||
const router = useRouter();
|
||||
const activeServerId = session?.serverId ?? null;
|
||||
const selectedDaemonConnection = selectedServerId
|
||||
? connectionStates.get(selectedServerId)
|
||||
const selectedDaemonId = selectedServerId ?? activeServerId;
|
||||
const selectedDaemonConnection = selectedDaemonId
|
||||
? connectionStates.get(selectedDaemonId)
|
||||
: null;
|
||||
const selectedDaemonStatus: ConnectionStatus =
|
||||
selectedDaemonConnection?.status ??
|
||||
(ws?.isConnected
|
||||
? "online"
|
||||
: ws?.isConnecting
|
||||
? "connecting"
|
||||
: ws?.lastError
|
||||
? "error"
|
||||
: "offline");
|
||||
const selectedDaemonLabel =
|
||||
selectedDaemonConnection?.daemon.label ??
|
||||
selectedDaemonConnection?.daemon.wsUrl ??
|
||||
selectedServerId ??
|
||||
selectedDaemonId ??
|
||||
"Selected daemon";
|
||||
const selectedDaemonStatusLabel = selectedDaemonConnection
|
||||
? formatConnectionStatus(selectedDaemonConnection.status)
|
||||
: "offline";
|
||||
const selectedDaemonIsUnavailable = Boolean(
|
||||
selectedServerId &&
|
||||
(!session || selectedDaemonConnection?.status !== "online")
|
||||
);
|
||||
const selectedDaemonStatusLabel = formatConnectionStatus(selectedDaemonStatus);
|
||||
const selectedDaemonIsUnavailable =
|
||||
!session || selectedDaemonStatus !== "online" || !ws?.isConnected;
|
||||
const isBackgroundDaemonTarget =
|
||||
Boolean(selectedDaemonId && activeDaemonId && selectedDaemonId !== activeDaemonId);
|
||||
const selectedDaemonLastError = selectedDaemonConnection?.lastError?.trim();
|
||||
const daemonAvailabilityError = selectedDaemonIsUnavailable
|
||||
? `${selectedDaemonLabel} is ${selectedDaemonStatusLabel}. Connect to it before creating or importing agents.${
|
||||
@@ -390,6 +400,12 @@ function AgentFlowModal({
|
||||
const shouldListenForDictation =
|
||||
isCreateFlow && (isVisible || hasPendingDictation);
|
||||
|
||||
const providerModelRequestTimersRef = useRef<
|
||||
Map<string, ReturnType<typeof setTimeout>>
|
||||
>(new Map());
|
||||
const idleProviderPrefetchHandleRef = useRef<ReturnType<
|
||||
typeof InteractionManager.runAfterInteractions
|
||||
> | null>(null);
|
||||
const pendingNavigationAgentIdRef = useRef<string | null>(null);
|
||||
const openDropdownSheet = useCallback((key: DropdownKey) => {
|
||||
setOpenDropdown(key);
|
||||
@@ -397,6 +413,46 @@ function AgentFlowModal({
|
||||
const closeDropdown = useCallback(() => {
|
||||
setOpenDropdown(null);
|
||||
}, []);
|
||||
const clearQueuedProviderModelRequest = useCallback((serverId: string | null) => {
|
||||
if (!serverId) {
|
||||
return;
|
||||
}
|
||||
const timer = providerModelRequestTimersRef.current.get(serverId);
|
||||
if (timer) {
|
||||
clearTimeout(timer);
|
||||
providerModelRequestTimersRef.current.delete(serverId);
|
||||
}
|
||||
}, []);
|
||||
const queueProviderModelFetch = useCallback(
|
||||
(
|
||||
serverId: string | null,
|
||||
targetSession: SessionContextValue | null,
|
||||
options?: { cwd?: string; delayMs?: number }
|
||||
) => {
|
||||
if (!serverId || !targetSession?.requestProviderModels) {
|
||||
clearQueuedProviderModelRequest(serverId);
|
||||
return;
|
||||
}
|
||||
const currentState = targetSession.providerModels?.get(selectedProvider);
|
||||
if (currentState?.models?.length || currentState?.isLoading) {
|
||||
clearQueuedProviderModelRequest(serverId);
|
||||
return;
|
||||
}
|
||||
|
||||
const delayMs = options?.delayMs ?? 0;
|
||||
const trigger = () => {
|
||||
providerModelRequestTimersRef.current.delete(serverId);
|
||||
targetSession.requestProviderModels(selectedProvider, options?.cwd ? { cwd: options.cwd } : undefined);
|
||||
};
|
||||
clearQueuedProviderModelRequest(serverId);
|
||||
if (delayMs > 0) {
|
||||
providerModelRequestTimersRef.current.set(serverId, setTimeout(trigger, delayMs));
|
||||
} else {
|
||||
trigger();
|
||||
}
|
||||
},
|
||||
[clearQueuedProviderModelRequest, selectedProvider]
|
||||
);
|
||||
|
||||
const setProviderFromUser = useCallback((provider: AgentProvider) => {
|
||||
userEditedPreferencesRef.current.provider = true;
|
||||
@@ -586,18 +642,28 @@ function AgentFlowModal({
|
||||
const modelError = modelState?.error ?? null;
|
||||
|
||||
useEffect(() => {
|
||||
if (!isVisible || !providerModels || !requestProviderModels || !isTargetDaemonReady) {
|
||||
const targetServerId = selectedServerId ?? session?.serverId ?? null;
|
||||
if (!isVisible || !isTargetDaemonReady || !targetServerId || !session) {
|
||||
clearQueuedProviderModelRequest(targetServerId);
|
||||
return;
|
||||
}
|
||||
const trimmed = workingDir.trim();
|
||||
const currentState = providerModels.get(selectedProvider);
|
||||
if (currentState?.models?.length || currentState?.isLoading) {
|
||||
return;
|
||||
}
|
||||
requestProviderModels(selectedProvider, {
|
||||
queueProviderModelFetch(targetServerId, session, {
|
||||
cwd: trimmed.length > 0 ? trimmed : undefined,
|
||||
delayMs: 180,
|
||||
});
|
||||
}, [isVisible, providerModels, requestProviderModels, selectedProvider, workingDir, isTargetDaemonReady]);
|
||||
return () => {
|
||||
clearQueuedProviderModelRequest(targetServerId);
|
||||
};
|
||||
}, [
|
||||
clearQueuedProviderModelRequest,
|
||||
isTargetDaemonReady,
|
||||
isVisible,
|
||||
queueProviderModelFetch,
|
||||
selectedServerId,
|
||||
session,
|
||||
workingDir,
|
||||
]);
|
||||
const setPromptHeight = useCallback((nextHeight: number) => {
|
||||
const bounded = Math.min(
|
||||
PROMPT_MAX_HEIGHT,
|
||||
@@ -790,6 +856,51 @@ function AgentFlowModal({
|
||||
focusPromptInput();
|
||||
}, [focusPromptInput, isCreateFlow, isVisible, shouldAutoFocusPrompt]);
|
||||
|
||||
const logOfflineDaemonAction = useCallback(
|
||||
(action: "create" | "resume" | "dictation" | "import_list", reason?: string | null) => {
|
||||
trackAnalyticsEvent({
|
||||
type: "offline_daemon_action_attempt",
|
||||
action,
|
||||
daemonId: selectedDaemonId,
|
||||
activeDaemonId,
|
||||
status: selectedDaemonStatus ?? null,
|
||||
isBackground: isBackgroundDaemonTarget,
|
||||
reason: reason ?? daemonAvailabilityError,
|
||||
});
|
||||
},
|
||||
[
|
||||
activeDaemonId,
|
||||
daemonAvailabilityError,
|
||||
isBackgroundDaemonTarget,
|
||||
selectedDaemonId,
|
||||
selectedDaemonStatus,
|
||||
]
|
||||
);
|
||||
|
||||
const logBackgroundAgentAction = useCallback(
|
||||
(
|
||||
action: "create" | "resume",
|
||||
payload: { cwd?: string; provider?: string; modeId?: string; model?: string; baseBranch?: string }
|
||||
) => {
|
||||
if (!isBackgroundDaemonTarget || !selectedDaemonId) {
|
||||
return;
|
||||
}
|
||||
trackAnalyticsEvent({
|
||||
type: "background_agent_action",
|
||||
action,
|
||||
daemonId: selectedDaemonId,
|
||||
activeDaemonId,
|
||||
isBackground: true,
|
||||
cwd: payload.cwd,
|
||||
provider: payload.provider,
|
||||
modeId: payload.modeId,
|
||||
model: payload.model,
|
||||
baseBranch: payload.baseBranch,
|
||||
});
|
||||
},
|
||||
[activeDaemonId, isBackgroundDaemonTarget, selectedDaemonId]
|
||||
);
|
||||
|
||||
const resetFormState = useCallback(() => {
|
||||
setInitialPrompt("");
|
||||
promptBaselineHeightRef.current = null;
|
||||
@@ -820,7 +931,14 @@ function AgentFlowModal({
|
||||
}, [cancelRepoInfo, resetRepoInfo, setPromptHeight]);
|
||||
|
||||
const handleDictationStart = useCallback(async () => {
|
||||
if (!isCreateFlow || isLoading || !isWsConnected || isDictating || isDictationProcessing) {
|
||||
if (!isCreateFlow || isLoading || isDictating || isDictationProcessing) {
|
||||
return;
|
||||
}
|
||||
if (!isTargetDaemonReady || !isWsConnected) {
|
||||
logOfflineDaemonAction(
|
||||
"dictation",
|
||||
daemonAvailabilityError ?? "WebSocket disconnected"
|
||||
);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
@@ -834,7 +952,18 @@ function AgentFlowModal({
|
||||
} catch (error) {
|
||||
console.error("[CreateAgentModal] Failed to start dictation:", error);
|
||||
}
|
||||
}, [dictationRecorder, isCreateFlow, isDictating, isDictationProcessing, isLoading, isWsConnected, shouldShowAudioDebug]);
|
||||
}, [
|
||||
daemonAvailabilityError,
|
||||
dictationRecorder,
|
||||
isCreateFlow,
|
||||
isDictating,
|
||||
isDictationProcessing,
|
||||
isLoading,
|
||||
isTargetDaemonReady,
|
||||
isWsConnected,
|
||||
logOfflineDaemonAction,
|
||||
shouldShowAudioDebug,
|
||||
]);
|
||||
|
||||
const handleDictationCancel = useCallback(async () => {
|
||||
if (!isDictating) {
|
||||
@@ -854,7 +983,8 @@ function AgentFlowModal({
|
||||
if (!isDictating || isDictationProcessing) {
|
||||
return;
|
||||
}
|
||||
if (!sendAgentAudio) {
|
||||
if (!sendAgentAudio || !isTargetDaemonReady) {
|
||||
logOfflineDaemonAction("dictation");
|
||||
setErrorMessage(
|
||||
daemonAvailabilityError ??
|
||||
"Connect to the selected daemon before using dictation."
|
||||
@@ -887,6 +1017,8 @@ function AgentFlowModal({
|
||||
dictationRecorder,
|
||||
isDictating,
|
||||
isDictationProcessing,
|
||||
isTargetDaemonReady,
|
||||
logOfflineDaemonAction,
|
||||
sendAgentAudio,
|
||||
]);
|
||||
|
||||
@@ -898,7 +1030,7 @@ function AgentFlowModal({
|
||||
|
||||
pendingNavigationAgentIdRef.current = null;
|
||||
if (activeDaemonId !== activeServerId) {
|
||||
setActiveDaemonId(activeServerId);
|
||||
setActiveDaemonId(activeServerId, { source: "agent_create_resume_nav" });
|
||||
}
|
||||
InteractionManager.runAfterInteractions(() => {
|
||||
router.push({
|
||||
@@ -926,6 +1058,7 @@ function AgentFlowModal({
|
||||
(provider?: AgentProvider) => {
|
||||
if (!isTargetDaemonReady || !ws || !ws.isConnected) {
|
||||
setIsImportLoading(false);
|
||||
logOfflineDaemonAction("import_list");
|
||||
setImportError(
|
||||
daemonAvailabilityError ??
|
||||
"Connect to the selected daemon to load import candidates."
|
||||
@@ -953,7 +1086,7 @@ function AgentFlowModal({
|
||||
setImportError("Unable to load agents to import. Please try again.");
|
||||
}
|
||||
},
|
||||
[daemonAvailabilityError, isTargetDaemonReady, ws]
|
||||
[daemonAvailabilityError, isTargetDaemonReady, logOfflineDaemonAction, ws]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -1040,6 +1173,16 @@ function AgentFlowModal({
|
||||
};
|
||||
}, [dictationRecorder]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
providerModelRequestTimersRef.current.forEach((timer) => {
|
||||
clearTimeout(timer);
|
||||
});
|
||||
providerModelRequestTimersRef.current.clear();
|
||||
idleProviderPrefetchHandleRef.current?.cancel?.();
|
||||
};
|
||||
}, []);
|
||||
|
||||
const footerAnimatedStyle = useAnimatedStyle(() => {
|
||||
"worklet";
|
||||
const absoluteHeight = Math.abs(keyboardHeight.value);
|
||||
@@ -1129,6 +1272,44 @@ function AgentFlowModal({
|
||||
shouldSyncBaseBranchRef.current = true;
|
||||
}, [isCreateFlow, isVisible, workingDir]);
|
||||
|
||||
useEffect(() => {
|
||||
const activeServerIds = new Set(daemonEntries.map(({ daemon }) => daemon.id));
|
||||
providerModelRequestTimersRef.current.forEach((timer, serverId) => {
|
||||
if (!activeServerIds.has(serverId)) {
|
||||
clearTimeout(timer);
|
||||
providerModelRequestTimersRef.current.delete(serverId);
|
||||
}
|
||||
});
|
||||
|
||||
idleProviderPrefetchHandleRef.current?.cancel?.();
|
||||
idleProviderPrefetchHandleRef.current = InteractionManager.runAfterInteractions(() => {
|
||||
daemonEntries.forEach(({ daemon, status }) => {
|
||||
const serverId = daemon.id;
|
||||
const isSelected = serverId === selectedServerId;
|
||||
const isReady = status === "online" || status === "idle";
|
||||
if (isSelected || !isReady) {
|
||||
clearQueuedProviderModelRequest(serverId);
|
||||
return;
|
||||
}
|
||||
const daemonSession = sessionDirectory.get(serverId) ?? null;
|
||||
if (!daemonSession?.ws?.isConnected) {
|
||||
return;
|
||||
}
|
||||
queueProviderModelFetch(serverId, daemonSession, { delayMs: 320 });
|
||||
});
|
||||
});
|
||||
return () => {
|
||||
idleProviderPrefetchHandleRef.current?.cancel?.();
|
||||
};
|
||||
}, [
|
||||
clearQueuedProviderModelRequest,
|
||||
daemonEntries,
|
||||
queueProviderModelFetch,
|
||||
selectedProvider,
|
||||
selectedServerId,
|
||||
sessionDirectory,
|
||||
]);
|
||||
|
||||
const trimmedWorkingDir = workingDir.trim();
|
||||
const shouldInspectRepo = isCreateFlow && isVisible && trimmedWorkingDir.length > 0;
|
||||
const repoAvailabilityError = shouldInspectRepo && (!isTargetDaemonReady || !isWsConnected)
|
||||
@@ -1208,6 +1389,7 @@ function AgentFlowModal({
|
||||
}
|
||||
|
||||
if (!createAgent || !isTargetDaemonReady) {
|
||||
logOfflineDaemonAction("create");
|
||||
setErrorMessage(
|
||||
daemonAvailabilityError ??
|
||||
"Connect to the selected daemon before creating an agent."
|
||||
@@ -1257,10 +1439,18 @@ function AgentFlowModal({
|
||||
createWorktree: true,
|
||||
worktreeSlug: (worktreeSlug || branchName).trim(),
|
||||
}
|
||||
: {}),
|
||||
: {}),
|
||||
}
|
||||
: undefined;
|
||||
|
||||
logBackgroundAgentAction("create", {
|
||||
cwd: trimmedPath,
|
||||
provider: selectedProvider,
|
||||
modeId,
|
||||
model: trimmedModel,
|
||||
baseBranch: trimmedBaseBranch || undefined,
|
||||
});
|
||||
|
||||
try {
|
||||
createAgent({
|
||||
config,
|
||||
@@ -1285,6 +1475,8 @@ function AgentFlowModal({
|
||||
repoInfo,
|
||||
selectedMode,
|
||||
modeOptions,
|
||||
logBackgroundAgentAction,
|
||||
logOfflineDaemonAction,
|
||||
selectedProvider,
|
||||
isLoading,
|
||||
validateWorktreeName,
|
||||
@@ -1300,12 +1492,17 @@ function AgentFlowModal({
|
||||
return;
|
||||
}
|
||||
if (!resumeAgent || !isTargetDaemonReady) {
|
||||
logOfflineDaemonAction("resume");
|
||||
setImportError(
|
||||
daemonAvailabilityError ??
|
||||
"Connect to the selected daemon before importing an agent."
|
||||
);
|
||||
return;
|
||||
}
|
||||
logBackgroundAgentAction("resume", {
|
||||
cwd: candidate.cwd,
|
||||
provider: candidate.provider,
|
||||
});
|
||||
setErrorMessage("");
|
||||
const requestId = generateMessageId();
|
||||
pendingRequestIdRef.current = requestId;
|
||||
@@ -1318,6 +1515,8 @@ function AgentFlowModal({
|
||||
[
|
||||
daemonAvailabilityError,
|
||||
isLoading,
|
||||
logBackgroundAgentAction,
|
||||
logOfflineDaemonAction,
|
||||
isTargetDaemonReady,
|
||||
resumeAgent,
|
||||
setImportError,
|
||||
@@ -1955,7 +2154,7 @@ function AgentFlowModal({
|
||||
<View style={styles.resumeEmptyState}>
|
||||
<Text style={styles.resumeEmptyTitle}>No agents to import</Text>
|
||||
<Text style={styles.resumeEmptySubtitle}>
|
||||
We'll load the latest Claude and Codex sessions from your
|
||||
We will load the latest Claude and Codex sessions from your
|
||||
local history so you can import them.
|
||||
</Text>
|
||||
<Pressable
|
||||
@@ -2422,7 +2621,7 @@ function WorkingDirectoryDropdown({
|
||||
/>
|
||||
{!hasRecentPaths && !showCustomOption ? (
|
||||
<Text style={styles.helperText}>
|
||||
We'll remember the directories you use most often.
|
||||
We will remember the directories you use most often.
|
||||
</Text>
|
||||
) : null}
|
||||
{showCustomOption ? (
|
||||
|
||||
@@ -1,33 +1,67 @@
|
||||
import { useMemo } from "react";
|
||||
import { useEffect, useMemo, useRef } from "react";
|
||||
import { SessionProvider } from "@/contexts/session-context";
|
||||
import { useDaemonRegistry, type DaemonProfile } from "@/contexts/daemon-registry-context";
|
||||
import { useDaemonConnections } from "@/contexts/daemon-connections-context";
|
||||
|
||||
export function MultiDaemonSessionHost() {
|
||||
const { daemons } = useDaemonRegistry();
|
||||
const { activeDaemonId } = useDaemonConnections();
|
||||
const { activeDaemonId, sessionAccessorRoles } = useDaemonConnections();
|
||||
const autoConnectStatesRef = useRef<Map<string, boolean>>(new Map());
|
||||
|
||||
const primaryDaemonIds = useMemo(() => {
|
||||
const ids = new Set<string>();
|
||||
sessionAccessorRoles.forEach((roles, daemonId) => {
|
||||
if (roles.has("primary")) {
|
||||
ids.add(daemonId);
|
||||
}
|
||||
});
|
||||
return ids;
|
||||
}, [sessionAccessorRoles]);
|
||||
|
||||
useEffect(() => {
|
||||
const trackedStates = autoConnectStatesRef.current;
|
||||
const activeIds = new Set<string>();
|
||||
|
||||
for (const daemon of daemons) {
|
||||
activeIds.add(daemon.id);
|
||||
const previousAutoConnect = trackedStates.get(daemon.id);
|
||||
if (daemon.autoConnect === false && previousAutoConnect !== false) {
|
||||
console.info("[DaemonConnection]", {
|
||||
event: "auto_connect_skip",
|
||||
daemonId: daemon.id,
|
||||
label: daemon.label,
|
||||
reason: "autoConnect disabled",
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
trackedStates.set(daemon.id, daemon.autoConnect);
|
||||
}
|
||||
|
||||
for (const daemonId of Array.from(trackedStates.keys())) {
|
||||
if (!activeIds.has(daemonId)) {
|
||||
trackedStates.delete(daemonId);
|
||||
}
|
||||
}
|
||||
}, [daemons]);
|
||||
|
||||
const backgroundDaemons = useMemo<DaemonProfile[]>(() => {
|
||||
const shouldConnect = new Map<string, DaemonProfile>();
|
||||
|
||||
for (const daemon of daemons) {
|
||||
if (daemon.autoConnect) {
|
||||
shouldConnect.set(daemon.id, daemon);
|
||||
if (!daemon.autoConnect) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
if (activeDaemonId) {
|
||||
const activeDaemon = daemons.find((daemon) => daemon.id === activeDaemonId);
|
||||
if (activeDaemon) {
|
||||
shouldConnect.set(activeDaemon.id, activeDaemon);
|
||||
if (daemon.id === activeDaemonId) {
|
||||
continue;
|
||||
}
|
||||
// The active daemon already wraps the UI tree via ProvidersWrapper,
|
||||
// so we skip rendering it here to avoid duplicate websocket connections.
|
||||
shouldConnect.delete(activeDaemonId);
|
||||
if (primaryDaemonIds.has(daemon.id)) {
|
||||
continue;
|
||||
}
|
||||
shouldConnect.set(daemon.id, daemon);
|
||||
}
|
||||
|
||||
return Array.from(shouldConnect.values());
|
||||
}, [daemons, activeDaemonId]);
|
||||
}, [daemons, activeDaemonId, primaryDaemonIds]);
|
||||
|
||||
if (backgroundDaemons.length === 0) {
|
||||
return null;
|
||||
@@ -36,7 +70,12 @@ export function MultiDaemonSessionHost() {
|
||||
return (
|
||||
<>
|
||||
{backgroundDaemons.map((daemon) => (
|
||||
<SessionProvider key={daemon.id} serverUrl={daemon.wsUrl} serverId={daemon.id}>
|
||||
<SessionProvider
|
||||
key={daemon.id}
|
||||
serverUrl={daemon.wsUrl}
|
||||
serverId={daemon.id}
|
||||
role="background"
|
||||
>
|
||||
{null}
|
||||
</SessionProvider>
|
||||
))}
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { ReactNode } from "react";
|
||||
import AsyncStorage from "@react-native-async-storage/async-storage";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useDaemonRegistry, type DaemonProfile } from "./daemon-registry-context";
|
||||
import { trackAnalyticsEvent } from "@/utils/analytics";
|
||||
import type { SessionContextValue } from "./session-context";
|
||||
|
||||
export interface SessionDirectoryEntry {
|
||||
@@ -10,29 +11,44 @@ export interface SessionDirectoryEntry {
|
||||
subscribe: (listener: () => void) => () => void;
|
||||
}
|
||||
|
||||
export type ConnectionStatus = "idle" | "connecting" | "online" | "offline" | "error";
|
||||
export type SessionAccessorRole = "primary" | "background";
|
||||
type SessionAccessorRegistry = Map<string, Map<SessionAccessorRole, SessionDirectoryEntry>>;
|
||||
|
||||
export interface DaemonConnectionRecord {
|
||||
export type ConnectionState =
|
||||
| { status: "idle"; lastError: null; lastOnlineAt: string | null }
|
||||
| { status: "connecting"; lastError: null; lastOnlineAt: string | null }
|
||||
| { status: "online"; lastError: null; lastOnlineAt: string }
|
||||
| { status: "offline"; lastError: string | null; lastOnlineAt: string | null }
|
||||
| { status: "error"; lastError: string; lastOnlineAt: string | null };
|
||||
|
||||
export type ConnectionStatus = ConnectionState["status"];
|
||||
|
||||
type ConnectionStateUpdate =
|
||||
| { status: "idle" }
|
||||
| { status: "connecting"; lastOnlineAt?: string | null }
|
||||
| { status: "online"; lastOnlineAt: string }
|
||||
| { status: "offline"; lastError?: string | null; lastOnlineAt?: string | null }
|
||||
| { status: "error"; lastError: string; lastOnlineAt?: string | null };
|
||||
|
||||
export type DaemonConnectionRecord = {
|
||||
daemon: DaemonProfile;
|
||||
status: ConnectionStatus;
|
||||
lastOnlineAt?: string;
|
||||
lastError?: string | null;
|
||||
}
|
||||
} & ConnectionState;
|
||||
|
||||
interface DaemonConnectionsContextValue {
|
||||
activeDaemonId: string | null;
|
||||
activeDaemon: DaemonProfile | null;
|
||||
connectionStates: Map<string, DaemonConnectionRecord>;
|
||||
isLoading: boolean;
|
||||
setActiveDaemonId: (daemonId: string) => void;
|
||||
updateConnectionStatus: (
|
||||
daemonId: string,
|
||||
status: ConnectionStatus,
|
||||
extras?: { lastError?: string | null; lastOnlineAt?: string }
|
||||
) => void;
|
||||
setActiveDaemonId: (daemonId: string, options?: SetActiveDaemonOptions) => void;
|
||||
updateConnectionStatus: (daemonId: string, update: ConnectionStateUpdate) => void;
|
||||
sessionAccessors: Map<string, SessionDirectoryEntry>;
|
||||
registerSessionAccessor: (daemonId: string, entry: SessionDirectoryEntry) => void;
|
||||
unregisterSessionAccessor: (daemonId: string) => void;
|
||||
sessionAccessorRoles: Map<string, Set<SessionAccessorRole>>;
|
||||
registerSessionAccessor: (
|
||||
daemonId: string,
|
||||
entry: SessionDirectoryEntry,
|
||||
role?: SessionAccessorRole
|
||||
) => void;
|
||||
unregisterSessionAccessor: (daemonId: string, role?: SessionAccessorRole) => void;
|
||||
subscribeToSessionDirectory: (listener: () => void) => () => void;
|
||||
notifySessionDirectoryChange: () => void;
|
||||
}
|
||||
@@ -42,6 +58,70 @@ const DaemonConnectionsContext = createContext<DaemonConnectionsContextValue | n
|
||||
const ACTIVE_DAEMON_STORAGE_KEY = "@paseo:active-daemon-id";
|
||||
const ACTIVE_DAEMON_QUERY_KEY = ["active-daemon-id"];
|
||||
|
||||
export type SetActiveDaemonOptions = {
|
||||
source?: string;
|
||||
};
|
||||
|
||||
function createDefaultConnectionState(): ConnectionState {
|
||||
return {
|
||||
status: "idle",
|
||||
lastError: null,
|
||||
lastOnlineAt: null,
|
||||
};
|
||||
}
|
||||
|
||||
function resolveNextConnectionState(
|
||||
existing: ConnectionState,
|
||||
update: ConnectionStateUpdate
|
||||
): ConnectionState {
|
||||
switch (update.status) {
|
||||
case "idle":
|
||||
return { status: "idle", lastError: null, lastOnlineAt: existing.lastOnlineAt };
|
||||
case "connecting":
|
||||
return {
|
||||
status: "connecting",
|
||||
lastError: null,
|
||||
lastOnlineAt: update.lastOnlineAt ?? existing.lastOnlineAt,
|
||||
};
|
||||
case "online":
|
||||
return { status: "online", lastError: null, lastOnlineAt: update.lastOnlineAt };
|
||||
case "offline":
|
||||
return {
|
||||
status: "offline",
|
||||
lastError: update.lastError ?? null,
|
||||
lastOnlineAt: update.lastOnlineAt ?? existing.lastOnlineAt,
|
||||
};
|
||||
case "error":
|
||||
return {
|
||||
status: "error",
|
||||
lastError: update.lastError,
|
||||
lastOnlineAt: update.lastOnlineAt ?? existing.lastOnlineAt,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function logConnectionLifecycle(daemon: DaemonProfile, previous: ConnectionState, next: ConnectionState) {
|
||||
const logPayload = {
|
||||
event: "daemon_connection_state",
|
||||
daemonId: daemon.id,
|
||||
label: daemon.label,
|
||||
from: previous.status,
|
||||
to: next.status,
|
||||
lastError: next.lastError ?? null,
|
||||
lastOnlineAt: next.lastOnlineAt ?? null,
|
||||
timestamp: new Date().toISOString(),
|
||||
};
|
||||
|
||||
const logger =
|
||||
next.status === "error"
|
||||
? console.error
|
||||
: next.status === "offline"
|
||||
? console.warn
|
||||
: console.info;
|
||||
|
||||
logger("[DaemonConnection]", logPayload);
|
||||
}
|
||||
|
||||
export function useDaemonConnections(): DaemonConnectionsContextValue {
|
||||
const ctx = useContext(DaemonConnectionsContext);
|
||||
if (!ctx) {
|
||||
@@ -54,7 +134,7 @@ export function DaemonConnectionsProvider({ children }: { children: ReactNode })
|
||||
const { daemons, isLoading: registryLoading } = useDaemonRegistry();
|
||||
const [activeDaemonId, setActiveDaemonIdState] = useState<string | null>(null);
|
||||
const [connectionStates, setConnectionStates] = useState<Map<string, DaemonConnectionRecord>>(new Map());
|
||||
const [sessionAccessors, setSessionAccessors] = useState<Map<string, SessionDirectoryEntry>>(new Map());
|
||||
const [sessionAccessorRegistry, setSessionAccessorRegistry] = useState<SessionAccessorRegistry>(new Map());
|
||||
const queryClient = useQueryClient();
|
||||
const activeDaemonPreference = useQuery({
|
||||
queryKey: ACTIVE_DAEMON_QUERY_KEY,
|
||||
@@ -79,9 +159,7 @@ export function DaemonConnectionsProvider({ children }: { children: ReactNode })
|
||||
const existing = prev.get(daemon.id);
|
||||
next.set(daemon.id, {
|
||||
daemon,
|
||||
status: existing?.status ?? "idle",
|
||||
lastOnlineAt: existing?.lastOnlineAt,
|
||||
lastError: existing?.lastError,
|
||||
...(existing ?? createDefaultConnectionState()),
|
||||
});
|
||||
}
|
||||
return next;
|
||||
@@ -89,14 +167,20 @@ export function DaemonConnectionsProvider({ children }: { children: ReactNode })
|
||||
}, [daemons]);
|
||||
|
||||
useEffect(() => {
|
||||
setSessionAccessors((prev) => {
|
||||
const next = new Map(prev);
|
||||
for (const key of Array.from(next.keys())) {
|
||||
if (!daemons.some((daemon) => daemon.id === key)) {
|
||||
next.delete(key);
|
||||
setSessionAccessorRegistry((prev) => {
|
||||
const validDaemonIds = new Set(daemons.map((daemon) => daemon.id));
|
||||
let changed = false;
|
||||
const next: SessionAccessorRegistry = new Map();
|
||||
|
||||
for (const [daemonId, roleMap] of prev.entries()) {
|
||||
if (!validDaemonIds.has(daemonId)) {
|
||||
changed = true;
|
||||
continue;
|
||||
}
|
||||
next.set(daemonId, roleMap);
|
||||
}
|
||||
return next;
|
||||
|
||||
return changed ? next : prev;
|
||||
});
|
||||
}, [daemons]);
|
||||
|
||||
@@ -115,8 +199,18 @@ export function DaemonConnectionsProvider({ children }: { children: ReactNode })
|
||||
}, [queryClient]);
|
||||
|
||||
const setActiveDaemonId = useCallback(
|
||||
(daemonId: string) => {
|
||||
setActiveDaemonIdState(daemonId);
|
||||
(daemonId: string, options?: SetActiveDaemonOptions) => {
|
||||
setActiveDaemonIdState((previous) => {
|
||||
if (previous !== daemonId) {
|
||||
trackAnalyticsEvent({
|
||||
type: "daemon_active_changed",
|
||||
daemonId,
|
||||
previousDaemonId: previous,
|
||||
source: options?.source,
|
||||
});
|
||||
}
|
||||
return daemonId;
|
||||
});
|
||||
void persistActiveDaemonId(daemonId);
|
||||
},
|
||||
[persistActiveDaemonId]
|
||||
@@ -138,7 +232,7 @@ export function DaemonConnectionsProvider({ children }: { children: ReactNode })
|
||||
}
|
||||
|
||||
const fallback = daemons.find((daemon) => daemon.isDefault) ?? daemons[0];
|
||||
setActiveDaemonId(fallback.id);
|
||||
setActiveDaemonId(fallback.id, { source: "auto_fallback" });
|
||||
}, [daemons, activeDaemonId, activeDaemonPreference.isPending, persistActiveDaemonId, setActiveDaemonId]);
|
||||
|
||||
const activeDaemon = useMemo(() => {
|
||||
@@ -149,45 +243,65 @@ export function DaemonConnectionsProvider({ children }: { children: ReactNode })
|
||||
}, [activeDaemonId, daemons]);
|
||||
|
||||
const updateConnectionStatus = useCallback(
|
||||
(
|
||||
daemonId: string,
|
||||
status: ConnectionStatus,
|
||||
extras?: { lastError?: string | null; lastOnlineAt?: string }
|
||||
) => {
|
||||
(daemonId: string, update: ConnectionStateUpdate) => {
|
||||
setConnectionStates((prev) => {
|
||||
const next = new Map(prev);
|
||||
const existing = next.get(daemonId);
|
||||
const existing = prev.get(daemonId);
|
||||
if (!existing) {
|
||||
return prev;
|
||||
}
|
||||
const hasExplicitLastError = Boolean(extras && Object.prototype.hasOwnProperty.call(extras, "lastError"));
|
||||
next.set(daemonId, {
|
||||
...existing,
|
||||
status,
|
||||
lastError: hasExplicitLastError ? (extras!.lastError ?? null) : existing.lastError ?? null,
|
||||
lastOnlineAt: extras?.lastOnlineAt ?? existing.lastOnlineAt,
|
||||
});
|
||||
const nextState = resolveNextConnectionState(existing, update);
|
||||
const hasChanged =
|
||||
existing.status !== nextState.status ||
|
||||
existing.lastError !== nextState.lastError ||
|
||||
existing.lastOnlineAt !== nextState.lastOnlineAt;
|
||||
|
||||
if (hasChanged) {
|
||||
logConnectionLifecycle(existing.daemon, existing, nextState);
|
||||
}
|
||||
|
||||
const next = new Map(prev);
|
||||
next.set(daemonId, { daemon: existing.daemon, ...nextState });
|
||||
return next;
|
||||
});
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const registerSessionAccessor = useCallback((daemonId: string, entry: SessionDirectoryEntry) => {
|
||||
setSessionAccessors((prev) => {
|
||||
const next = new Map(prev);
|
||||
next.set(daemonId, entry);
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
const registerSessionAccessor = useCallback(
|
||||
(daemonId: string, entry: SessionDirectoryEntry, role: SessionAccessorRole = "primary") => {
|
||||
setSessionAccessorRegistry((prev) => {
|
||||
const next = new Map(prev);
|
||||
const existing = new Map(next.get(daemonId) ?? []);
|
||||
existing.set(role, entry);
|
||||
next.set(daemonId, existing);
|
||||
return next;
|
||||
});
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const unregisterSessionAccessor = useCallback((daemonId: string) => {
|
||||
setSessionAccessors((prev) => {
|
||||
const next = new Map(prev);
|
||||
next.delete(daemonId);
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
const unregisterSessionAccessor = useCallback(
|
||||
(daemonId: string, role: SessionAccessorRole = "primary") => {
|
||||
setSessionAccessorRegistry((prev) => {
|
||||
const existing = prev.get(daemonId);
|
||||
if (!existing) {
|
||||
return prev;
|
||||
}
|
||||
|
||||
const nextRoleMap = new Map(existing);
|
||||
nextRoleMap.delete(role);
|
||||
|
||||
const next = new Map(prev);
|
||||
if (nextRoleMap.size === 0) {
|
||||
next.delete(daemonId);
|
||||
} else {
|
||||
next.set(daemonId, nextRoleMap);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const subscribeToSessionDirectory = useCallback((listener: () => void) => {
|
||||
sessionDirectoryListenersRef.current.add(listener);
|
||||
@@ -207,6 +321,34 @@ export function DaemonConnectionsProvider({ children }: { children: ReactNode })
|
||||
}
|
||||
}, []);
|
||||
|
||||
const { sessionAccessors, sessionAccessorRoles } = useMemo(() => {
|
||||
const flattened = new Map<string, SessionDirectoryEntry>();
|
||||
const roleSets = new Map<string, Set<SessionAccessorRole>>();
|
||||
|
||||
sessionAccessorRegistry.forEach((roleMap, daemonId) => {
|
||||
if (roleMap.size === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const roles = new Set<SessionAccessorRole>();
|
||||
roleMap.forEach((_entry, role) => roles.add(role));
|
||||
roleSets.set(daemonId, roles);
|
||||
|
||||
const primaryEntry = roleMap.get("primary");
|
||||
if (primaryEntry) {
|
||||
flattened.set(daemonId, primaryEntry);
|
||||
return;
|
||||
}
|
||||
|
||||
const backgroundEntry = roleMap.get("background");
|
||||
if (backgroundEntry) {
|
||||
flattened.set(daemonId, backgroundEntry);
|
||||
}
|
||||
});
|
||||
|
||||
return { sessionAccessors: flattened, sessionAccessorRoles: roleSets };
|
||||
}, [sessionAccessorRegistry]);
|
||||
|
||||
const value: DaemonConnectionsContextValue = {
|
||||
activeDaemonId,
|
||||
activeDaemon,
|
||||
@@ -215,6 +357,7 @@ export function DaemonConnectionsProvider({ children }: { children: ReactNode })
|
||||
setActiveDaemonId,
|
||||
updateConnectionStatus,
|
||||
sessionAccessors,
|
||||
sessionAccessorRoles,
|
||||
registerSessionAccessor,
|
||||
unregisterSessionAccessor,
|
||||
subscribeToSessionDirectory,
|
||||
|
||||
@@ -29,7 +29,7 @@ import type {
|
||||
} from "@server/server/agent/agent-sdk-types";
|
||||
import { ScrollView } from "react-native";
|
||||
import * as FileSystem from 'expo-file-system';
|
||||
import { useDaemonConnections } from "./daemon-connections-context";
|
||||
import { useDaemonConnections, type SessionAccessorRole } from "./daemon-connections-context";
|
||||
|
||||
const derivePendingPermissionKey = (agentId: string, request: AgentPermissionRequest) => {
|
||||
const fallbackId =
|
||||
@@ -388,9 +388,11 @@ interface SessionProviderProps {
|
||||
children: ReactNode;
|
||||
serverUrl: string;
|
||||
serverId: string;
|
||||
role?: SessionAccessorRole;
|
||||
}
|
||||
|
||||
export function SessionProvider({ children, serverUrl, serverId }: SessionProviderProps) {
|
||||
export function SessionProvider({ children, serverUrl, serverId, role }: SessionProviderProps) {
|
||||
const sessionRole = role ?? "primary";
|
||||
const ws = useWebSocket(serverUrl);
|
||||
const wsIsConnected = ws.isConnected;
|
||||
const {
|
||||
@@ -402,29 +404,29 @@ export function SessionProvider({ children, serverUrl, serverId }: SessionProvid
|
||||
|
||||
useEffect(() => {
|
||||
if (ws.isConnected) {
|
||||
updateConnectionStatus(serverId, "online", {
|
||||
updateConnectionStatus(serverId, {
|
||||
status: "online",
|
||||
lastOnlineAt: new Date().toISOString(),
|
||||
lastError: null,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (ws.isConnecting) {
|
||||
updateConnectionStatus(serverId, "connecting");
|
||||
updateConnectionStatus(serverId, { status: "connecting" });
|
||||
return;
|
||||
}
|
||||
|
||||
if (ws.lastError) {
|
||||
updateConnectionStatus(serverId, "error", { lastError: ws.lastError });
|
||||
updateConnectionStatus(serverId, { status: "error", lastError: ws.lastError });
|
||||
return;
|
||||
}
|
||||
|
||||
updateConnectionStatus(serverId, "offline");
|
||||
updateConnectionStatus(serverId, { status: "offline" });
|
||||
}, [serverId, updateConnectionStatus, ws.isConnected, ws.isConnecting, ws.lastError]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
updateConnectionStatus(serverId, "offline", { lastError: null });
|
||||
updateConnectionStatus(serverId, { status: "offline", lastError: null });
|
||||
};
|
||||
}, [serverId, updateConnectionStatus]);
|
||||
|
||||
@@ -1794,11 +1796,11 @@ export function SessionProvider({ children, serverUrl, serverId }: SessionProvid
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
registerSessionAccessor(serverId, sessionDirectoryEntry);
|
||||
registerSessionAccessor(serverId, sessionDirectoryEntry, sessionRole);
|
||||
return () => {
|
||||
unregisterSessionAccessor(serverId);
|
||||
unregisterSessionAccessor(serverId, sessionRole);
|
||||
};
|
||||
}, [serverId, registerSessionAccessor, unregisterSessionAccessor, sessionDirectoryEntry]);
|
||||
}, [serverId, sessionRole, registerSessionAccessor, unregisterSessionAccessor, sessionDirectoryEntry]);
|
||||
|
||||
return (
|
||||
<SessionContext.Provider value={value}>
|
||||
|
||||
@@ -28,15 +28,24 @@ export function getSessionForServer(
|
||||
|
||||
type UseDaemonSessionOptions = {
|
||||
suppressUnavailableAlert?: boolean;
|
||||
allowUnavailable?: boolean;
|
||||
};
|
||||
|
||||
export function useDaemonSession(
|
||||
serverId?: string,
|
||||
options?: UseDaemonSessionOptions & { allowUnavailable?: false }
|
||||
): SessionContextValue;
|
||||
export function useDaemonSession(
|
||||
serverId: string | undefined,
|
||||
options: UseDaemonSessionOptions & { allowUnavailable: true }
|
||||
): SessionContextValue | null;
|
||||
export function useDaemonSession(serverId?: string, options?: UseDaemonSessionOptions) {
|
||||
const activeSession = useSession();
|
||||
const sessionDirectory = useSessionDirectory();
|
||||
const { connectionStates } = useDaemonConnections();
|
||||
const alertedDaemonsRef = useRef<Set<string>>(new Set());
|
||||
const loggedDaemonsRef = useRef<Set<string>>(new Set());
|
||||
const { suppressUnavailableAlert = false } = options ?? {};
|
||||
const { suppressUnavailableAlert = false, allowUnavailable = false } = options ?? {};
|
||||
|
||||
const targetServerId = serverId ?? activeSession.serverId;
|
||||
const isActiveSession = targetServerId === activeSession.serverId;
|
||||
@@ -64,6 +73,10 @@ export function useDaemonSession(serverId?: string, options?: UseDaemonSessionOp
|
||||
loggedDaemonsRef.current.add(targetServerId);
|
||||
console.warn(`[useDaemonSession] Session unavailable for daemon "${label}" (${status}).`);
|
||||
}
|
||||
|
||||
if (allowUnavailable) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
type BackgroundAgentAction = "create" | "resume";
|
||||
type OfflineAction =
|
||||
| "create"
|
||||
| "resume"
|
||||
| "dictation"
|
||||
| "import_list";
|
||||
|
||||
type AnalyticsEvent =
|
||||
| {
|
||||
type: "daemon_active_changed";
|
||||
daemonId: string;
|
||||
previousDaemonId: string | null;
|
||||
source?: string;
|
||||
}
|
||||
| {
|
||||
type: "background_agent_action";
|
||||
action: BackgroundAgentAction;
|
||||
daemonId: string;
|
||||
activeDaemonId: string | null;
|
||||
isBackground: boolean;
|
||||
cwd?: string;
|
||||
provider?: string;
|
||||
modeId?: string;
|
||||
model?: string;
|
||||
baseBranch?: string;
|
||||
}
|
||||
| {
|
||||
type: "offline_daemon_action_attempt";
|
||||
action: OfflineAction;
|
||||
daemonId: string | null;
|
||||
activeDaemonId: string | null;
|
||||
status: string | null;
|
||||
isBackground: boolean;
|
||||
reason?: string | null;
|
||||
};
|
||||
|
||||
export function trackAnalyticsEvent(event: AnalyticsEvent) {
|
||||
try {
|
||||
const payload = {
|
||||
...event,
|
||||
timestamp: new Date().toISOString(),
|
||||
};
|
||||
console.info("[Analytics]", payload);
|
||||
} catch (error) {
|
||||
console.error("[Analytics] Failed to emit event", error);
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { ConnectionStatus } from "@/contexts/daemon-connections-context";
|
||||
import { assertUnreachable } from "./exhaustive";
|
||||
|
||||
export function formatConnectionStatus(status: ConnectionStatus): string {
|
||||
switch (status) {
|
||||
@@ -10,8 +11,10 @@ export function formatConnectionStatus(status: ConnectionStatus): string {
|
||||
return "Offline";
|
||||
case "error":
|
||||
return "Error";
|
||||
default:
|
||||
case "idle":
|
||||
return "Idle";
|
||||
default:
|
||||
return assertUnreachable(status);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,7 +28,11 @@ export function getConnectionStatusTone(status: ConnectionStatus): ConnectionSta
|
||||
return "warning";
|
||||
case "error":
|
||||
return "error";
|
||||
default:
|
||||
case "offline":
|
||||
return "warning";
|
||||
case "idle":
|
||||
return "muted";
|
||||
default:
|
||||
return assertUnreachable(status);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
export function assertUnreachable(value: never): never {
|
||||
throw new Error(`Unhandled case: ${String(value)}`);
|
||||
}
|
||||
Reference in New Issue
Block a user