feat: add live model and thinking option preferences UI (#8)

* feat: add live model and thinking option preferences UI

* refactor: remove agent variant support and clean up thinking options

* feat: live model + thinking presets (RPC)
This commit is contained in:
Mohamed Boudra
2026-02-04 08:18:15 +07:00
committed by GitHub
parent 03f383b908
commit 469a25c724
50 changed files with 1976 additions and 894 deletions
+18 -16
View File
@@ -815,7 +815,7 @@ function DaemonCard({
const daemonConnection = useSessionStore(
(state) => state.sessions[daemon.id]?.connection ?? null
);
const restartServerFn = useSessionStore((state) => state.sessions[daemon.id]?.methods?.restartServer);
const daemonClient = useSessionStore((state) => state.sessions[daemon.id]?.client ?? null);
const [isRestarting, setIsRestarting] = useState(false);
const isConnected = daemonConnection?.isConnected ?? false;
const isConnectedRef = useRef(isConnected);
@@ -848,7 +848,7 @@ function DaemonCard({
}, [daemon.label, isScreenMountedRef, waitForCondition]);
const beginServerRestart = useCallback(() => {
if (!restartServerFn) {
if (!daemonClient) {
Alert.alert(
"Host unavailable",
`${daemon.label} is not connected. Wait for it to come online before restarting.`
@@ -865,23 +865,25 @@ function DaemonCard({
}
setIsRestarting(true);
try {
restartServerFn(`settings_daemon_restart_${daemon.id}`);
} catch (error) {
console.error(`[Settings] Failed to restart daemon ${daemon.label}`, error);
setIsRestarting(false);
Alert.alert(
"Error",
"Failed to send the restart request. Paseo reconnects automatically—try again once the host shows as online."
);
return;
}
void daemonClient
.restartServer(`settings_daemon_restart_${daemon.id}`)
.catch((error) => {
console.error(`[Settings] Failed to restart daemon ${daemon.label}`, error);
if (!isScreenMountedRef.current) {
return;
}
setIsRestarting(false);
Alert.alert(
"Error",
"Failed to send the restart request. Paseo reconnects automatically—try again once the host shows as online."
);
});
void waitForDaemonRestart();
}, [daemon.id, daemon.label, restartServerFn, waitForDaemonRestart]);
}, [daemon.id, daemon.label, daemonClient, isScreenMountedRef, waitForDaemonRestart]);
const handleRestartPress = useCallback(() => {
if (!restartServerFn) {
if (!daemonClient) {
Alert.alert(
"Host unavailable",
`${daemon.label} is not connected. Wait for it to come online before restarting.`
@@ -912,7 +914,7 @@ function DaemonCard({
onPress: beginServerRestart,
},
]);
}, [beginServerRestart, daemon.label, restartConfirmationMessage, restartServerFn]);
}, [beginServerRestart, daemon.label, daemonClient, restartConfirmationMessage]);
// Status pill background with 10% opacity
const statusPillBg =
@@ -27,6 +27,7 @@ import {
import { Theme } from "@/styles/theme";
import { CommandAutocomplete } from "./command-autocomplete";
import { useAgentCommandsQuery } from "@/hooks/use-agent-commands-query";
import { encodeImages } from "@/utils/encode-images";
type QueuedMessage = {
id: string;
@@ -85,11 +86,8 @@ export function AgentInputArea({
);
const queuedMessages = queuedMessagesRaw ?? EMPTY_ARRAY;
const methods = useSessionStore((state) => state.sessions[serverId]?.methods);
const sendAgentMessage = methods?.sendAgentMessage;
const cancelAgentRun = methods?.cancelAgentRun;
const setQueuedMessages = useSessionStore((state) => state.setQueuedMessages);
const setAgentStreamTail = useSessionStore((state) => state.setAgentStreamTail);
const { isVoiceMode, isMuted: isVoiceMuted, toggleMute: toggleVoiceMute } = useVoice();
@@ -129,7 +127,9 @@ export function AgentInputArea({
const { pickImages } = useImageAttachmentPicker();
const agentIdRef = useRef(agentId);
const sendAgentMessageRef = useRef(sendAgentMessage);
const sendAgentMessageRef = useRef<
((agentId: string, text: string, images?: ImageAttachment[]) => Promise<void>) | null
>(null);
const onSubmitMessageRef = useRef(onSubmitMessage);
const messageInputRef = useRef<MessageInputRef>(null);
@@ -148,7 +148,10 @@ export function AgentInputArea({
await onSubmitMessageRef.current({ text, images });
return;
}
await sendAgentMessageRef.current?.(agentIdRef.current, text, images);
if (!sendAgentMessageRef.current) {
throw new Error("Host is not connected");
}
await sendAgentMessageRef.current(agentIdRef.current, text, images);
},
[]
);
@@ -158,8 +161,36 @@ export function AgentInputArea({
}, [agentId]);
useEffect(() => {
sendAgentMessageRef.current = sendAgentMessage;
}, [sendAgentMessage]);
sendAgentMessageRef.current = async (
agentId: string,
text: string,
images?: ImageAttachment[]
) => {
if (!client) {
throw new Error("Host is not connected");
}
const messageId = generateMessageId();
setAgentStreamTail(serverId, (prev) => {
const currentStream = prev.get(agentId) || [];
const nextItem: any = {
kind: "user_message",
id: messageId,
text,
timestamp: new Date(),
};
const updated = new Map(prev);
updated.set(agentId, [...currentStream, nextItem]);
return updated;
});
const imagesData = await encodeImages(images);
await client.sendAgentMessage(agentId, text, {
messageId,
...(imagesData && imagesData.length > 0 ? { images: imagesData } : {}),
});
};
}, [client, serverId, setAgentStreamTail]);
useEffect(() => {
onSubmitMessageRef.current = onSubmitMessage;
@@ -213,7 +244,7 @@ export function AgentInputArea({
// decide what to do even if the socket is currently disconnected (so we
// don't no-op and lose deterministic error handling in the UI/tests).
if (!onSubmitMessageRef.current && !socketConnected) return;
if (!sendAgentMessage && !onSubmitMessageRef.current) return;
if (!sendAgentMessageRef.current && !onSubmitMessageRef.current) return;
if (agent?.status === "running" && !forceSend) {
queueMessage(trimmedMessage, imageAttachments);
@@ -334,11 +365,11 @@ export function AgentInputArea({
if (!agent || agent.status !== "running" || isCancellingAgent) {
return;
}
if (!isConnected || !cancelAgentRun) {
if (!isConnected || !client) {
return;
}
setIsCancellingAgent(true);
cancelAgentRun(agentIdRef.current);
void client.cancelAgent(agentIdRef.current);
messageInputRef.current?.focus();
}
@@ -354,7 +385,7 @@ export function AgentInputArea({
async function handleSendQueuedNow(id: string) {
const item = queuedMessages.find((q) => q.id === id);
if (!item || !isConnected) return;
if (!sendAgentMessage && !onSubmitMessageRef.current) return;
if (!sendAgentMessageRef.current && !onSubmitMessageRef.current) return;
updateQueue((current) => current.filter((q) => q.id !== id));
+8 -13
View File
@@ -85,16 +85,12 @@ export function AgentList({
const insets = useSafeAreaInsets();
const [actionAgent, setActionAgent] = useState<AggregatedAgent | null>(null);
// Get the methods for the specific server
const methods = useSessionStore((state) =>
actionAgent?.serverId
? state.sessions[actionAgent.serverId]?.methods
: undefined
const actionClient = useSessionStore((state) =>
actionAgent?.serverId ? state.sessions[actionAgent.serverId]?.client ?? null : null
);
const archiveAgent = methods?.archiveAgent;
const isActionSheetVisible = actionAgent !== null;
const isActionDaemonUnavailable = Boolean(actionAgent?.serverId && !methods);
const isActionDaemonUnavailable = Boolean(actionAgent?.serverId && !actionClient);
const handleAgentPress = useCallback(
(serverId: string, agentId: string) => {
@@ -134,12 +130,12 @@ export function AgentList({
}, []);
const handleArchiveAgent = useCallback(() => {
if (!actionAgent || !archiveAgent) {
if (!actionAgent || !actionClient) {
return;
}
archiveAgent(actionAgent.id);
void actionClient.archiveAgent(actionAgent.id);
setActionAgent(null);
}, [actionAgent, archiveAgent]);
}, [actionAgent, actionClient]);
const viewabilityConfig = useMemo(
() => ({ itemVisiblePercentThreshold: 30 }),
@@ -347,7 +343,7 @@ export function AgentList({
<Text style={styles.sheetCancelText}>Cancel</Text>
</Pressable>
<Pressable
disabled={!archiveAgent || isActionDaemonUnavailable}
disabled={isActionDaemonUnavailable}
style={[styles.sheetButton, styles.sheetArchiveButton]}
onPress={handleArchiveAgent}
testID="agent-action-archive"
@@ -355,8 +351,7 @@ export function AgentList({
<Text
style={[
styles.sheetArchiveText,
(!archiveAgent || isActionDaemonUnavailable) &&
styles.sheetArchiveTextDisabled,
isActionDaemonUnavailable && styles.sheetArchiveTextDisabled,
]}
>
Archive
@@ -1,6 +1,6 @@
import { View, Text } from "react-native";
import { View, Text, Platform, Pressable } from "react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { ChevronDown } from "lucide-react-native";
import { ChevronDown, SlidersHorizontal } from "lucide-react-native";
import { useSessionStore } from "@/stores/session-store";
import {
DropdownMenu,
@@ -9,6 +9,9 @@ import {
DropdownMenuLabel,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { AdaptiveModalSheet } from "@/components/adaptive-modal-sheet";
import { useMemo, useState } from "react";
import { useQuery } from "@tanstack/react-query";
interface AgentStatusBarProps {
agentId: string;
@@ -17,27 +20,62 @@ interface AgentStatusBarProps {
export function AgentStatusBar({ agentId, serverId }: AgentStatusBarProps) {
const { theme } = useUnistyles();
const IS_WEB = Platform.OS === "web";
const [prefsOpen, setPrefsOpen] = useState(false);
// 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 client = useSessionStore((state) => state.sessions[serverId]?.client ?? null);
if (!agent) {
return null;
}
const canFetchModels = Boolean(client) && Boolean(agent.provider) && (IS_WEB || prefsOpen);
const modelsQuery = useQuery({
queryKey: ["providerModels", serverId, agent.provider, agent.cwd],
enabled: canFetchModels,
staleTime: 5 * 60 * 1000,
queryFn: async () => {
if (!client) {
throw new Error("Daemon client unavailable");
}
const payload = await client.listProviderModels(agent.provider, { cwd: agent.cwd });
if (payload.error) {
throw new Error(payload.error);
}
return payload.models ?? [];
},
});
const models = modelsQuery.data ?? null;
function handleModeChange(modeId: string) {
if (setAgentMode) {
setAgentMode(agentId, modeId);
if (!client) {
return;
}
void client.setAgentMode(agentId, modeId).catch((error) => {
console.warn("[AgentStatusBar] setAgentMode failed", error);
});
}
const selectedModel = useMemo(() => {
if (!models || !agent.model) return null;
return models.find((m) => m.id === agent.model) ?? null;
}, [models, agent.model]);
const displayModel = selectedModel?.label ?? agent.model ?? "default";
const thinkingOptions = selectedModel?.thinkingOptions ?? null;
const selectedThinkingId =
agent.thinkingOptionId ??
selectedModel?.defaultThinkingOptionId ??
"default";
const selectedThinking = thinkingOptions?.find((o) => o.id === selectedThinkingId) ?? null;
const displayThinking = selectedThinking?.label ?? selectedThinkingId ?? "default";
return (
<View style={styles.container}>
{/* Agent Mode Badge */}
@@ -83,6 +121,208 @@ export function AgentStatusBar({ agentId, serverId }: AgentStatusBarProps) {
</DropdownMenuContent>
</DropdownMenu>
)}
{/* Desktop: inline dropdowns for model/thinking */}
{IS_WEB && (
<>
<DropdownMenu>
<DropdownMenuTrigger
style={({ pressed }) => [
styles.modeBadge,
pressed && styles.modeBadgePressed,
]}
accessibilityRole="button"
accessibilityLabel="Select agent model"
testID="agent-model-selector"
>
<Text style={styles.modeBadgeText}>{displayModel}</Text>
<ChevronDown size={14} color={theme.colors.foregroundMuted} />
</DropdownMenuTrigger>
<DropdownMenuContent side="top" align="start" testID="agent-model-menu">
<DropdownMenuLabel>Model</DropdownMenuLabel>
{models?.map((model) => {
const isActive = model.id === agent.model;
return (
<DropdownMenuItem
key={model.id}
selected={isActive}
selectedVariant="accent"
description={model.description}
onSelect={() => {
if (!client) {
return;
}
void client.setAgentModel(agentId, model.id).catch((error) => {
console.warn("[AgentStatusBar] setAgentModel failed", error);
});
}}
>
{model.label}
</DropdownMenuItem>
);
})}
</DropdownMenuContent>
</DropdownMenu>
{thinkingOptions && thinkingOptions.length > 1 && (
<DropdownMenu>
<DropdownMenuTrigger
style={({ pressed }) => [
styles.modeBadge,
pressed && styles.modeBadgePressed,
]}
accessibilityRole="button"
accessibilityLabel="Select thinking option"
testID="agent-thinking-selector"
>
<Text style={styles.modeBadgeText}>{displayThinking}</Text>
<ChevronDown size={14} color={theme.colors.foregroundMuted} />
</DropdownMenuTrigger>
<DropdownMenuContent side="top" align="start" testID="agent-thinking-menu">
<DropdownMenuLabel>Thinking</DropdownMenuLabel>
{thinkingOptions.map((opt) => {
const isActive = opt.id === selectedThinkingId;
return (
<DropdownMenuItem
key={opt.id}
selected={isActive}
selectedVariant="accent"
description={opt.description}
onSelect={() => {
if (!client) {
return;
}
void client
.setAgentThinkingOption(agentId, opt.id === "default" ? null : opt.id)
.catch((error) => {
console.warn("[AgentStatusBar] setAgentThinkingOption failed", error);
});
}}
>
{opt.label}
</DropdownMenuItem>
);
})}
</DropdownMenuContent>
</DropdownMenu>
)}
</>
)}
{/* Mobile: preferences button opens a bottom sheet */}
{!IS_WEB && (
<>
<Pressable
onPress={() => setPrefsOpen(true)}
style={({ pressed }) => [
styles.prefsButton,
pressed && styles.prefsButtonPressed,
]}
accessibilityRole="button"
accessibilityLabel="Agent preferences"
testID="agent-preferences-button"
>
<SlidersHorizontal size={16} color={theme.colors.foregroundMuted} />
</Pressable>
<AdaptiveModalSheet
title="Preferences"
visible={prefsOpen}
onClose={() => setPrefsOpen(false)}
testID="agent-preferences-sheet"
>
<View style={styles.sheetSection}>
<Text style={styles.sheetLabel}>Model</Text>
<DropdownMenu>
<DropdownMenuTrigger
style={({ pressed }) => [
styles.sheetSelect,
pressed && styles.sheetSelectPressed,
]}
accessibilityRole="button"
accessibilityLabel="Select agent model"
testID="agent-preferences-model"
>
<Text style={styles.sheetSelectText}>{displayModel}</Text>
<ChevronDown size={16} color={theme.colors.foregroundMuted} />
</DropdownMenuTrigger>
<DropdownMenuContent side="top" align="start">
<DropdownMenuLabel>Model</DropdownMenuLabel>
{models?.map((model) => {
const isActive = model.id === agent.model;
return (
<DropdownMenuItem
key={model.id}
selected={isActive}
selectedVariant="accent"
description={model.description}
onSelect={() => {
if (!client) {
return;
}
void client.setAgentModel(agentId, model.id).catch((error) => {
console.warn("[AgentStatusBar] setAgentModel failed", error);
});
}}
>
{model.label}
</DropdownMenuItem>
);
})}
</DropdownMenuContent>
</DropdownMenu>
</View>
{thinkingOptions && thinkingOptions.length > 1 && (
<View style={styles.sheetSection}>
<Text style={styles.sheetLabel}>Thinking</Text>
<DropdownMenu>
<DropdownMenuTrigger
style={({ pressed }) => [
styles.sheetSelect,
pressed && styles.sheetSelectPressed,
]}
accessibilityRole="button"
accessibilityLabel="Select thinking option"
testID="agent-preferences-thinking"
>
<Text style={styles.sheetSelectText}>{displayThinking}</Text>
<ChevronDown size={16} color={theme.colors.foregroundMuted} />
</DropdownMenuTrigger>
<DropdownMenuContent side="top" align="start">
<DropdownMenuLabel>Thinking</DropdownMenuLabel>
{thinkingOptions.map((opt) => {
const isActive = opt.id === selectedThinkingId;
return (
<DropdownMenuItem
key={opt.id}
selected={isActive}
selectedVariant="accent"
description={opt.description}
onSelect={() => {
if (!client) {
return;
}
void client
.setAgentThinkingOption(agentId, opt.id === "default" ? null : opt.id)
.catch((error) => {
console.warn("[AgentStatusBar] setAgentThinkingOption failed", error);
});
}}
>
{opt.label}
</DropdownMenuItem>
);
})}
</DropdownMenuContent>
</DropdownMenu>
</View>
)}
</AdaptiveModalSheet>
</>
)}
</View>
);
}
@@ -111,4 +351,44 @@ const styles = StyleSheet.create((theme) => ({
fontWeight: theme.fontWeight.semibold,
textTransform: "capitalize",
},
prefsButton: {
width: 32,
height: 32,
borderRadius: theme.borderRadius["2xl"],
alignItems: "center",
justifyContent: "center",
backgroundColor: theme.colors.surface2,
},
prefsButtonPressed: {
backgroundColor: theme.colors.surface0,
},
sheetSection: {
gap: theme.spacing[2],
},
sheetLabel: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.sm,
fontWeight: theme.fontWeight.semibold,
},
sheetSelect: {
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
gap: theme.spacing[3],
paddingHorizontal: theme.spacing[4],
paddingVertical: theme.spacing[3],
borderRadius: theme.borderRadius.lg,
borderWidth: 1,
borderColor: theme.colors.surface2,
backgroundColor: theme.colors.surface0,
},
sheetSelectPressed: {
backgroundColor: theme.colors.surface2,
},
sheetSelectText: {
flex: 1,
color: theme.colors.foreground,
fontSize: theme.fontSize.sm,
fontWeight: theme.fontWeight.semibold,
},
}));
@@ -44,6 +44,7 @@ import type { PendingPermission } from "@/types/shared";
import type { AgentPermissionResponse } from "@server/server/agent/agent-sdk-types";
import type { Agent } from "@/contexts/session-context";
import { useSessionStore } from "@/stores/session-store";
import { useFileExplorerActions } from "@/hooks/use-file-explorer-actions";
import type { DaemonClientV2 } from "@server/client/daemon-client-v2";
import { parseToolCallDisplay } from "@/utils/tool-call-parsers";
import { ToolCallDetailsContent } from "./tool-call-details";
@@ -94,15 +95,7 @@ export function AgentStreamView({
state.sessions[resolvedServerId]?.agentStreamHead?.get(agentId)
);
// Get methods for file operations
const methods = useSessionStore(
(state) => state.sessions[resolvedServerId]?.methods
);
const requestDirectoryListing = methods?.requestDirectoryListing;
const requestFilePreview = methods?.requestFilePreview;
const requestDirectoryListingOrInert = requestDirectoryListing ?? (() => {});
const requestFilePreviewOrInert = requestFilePreview ?? (() => {});
const { requestDirectoryListing, requestFilePreview } = useFileExplorerActions(resolvedServerId);
// Keep entry/exit animations off on Android due to RN dispatchDraw crashes
// tracked in react-native-reanimated#8422.
const shouldDisableEntryExitAnimations = Platform.OS === "android";
@@ -130,9 +123,9 @@ export function AgentStreamView({
return;
}
requestDirectoryListingOrInert(agentId, normalized.directory);
requestDirectoryListing(agentId, normalized.directory);
if (normalized.file) {
requestFilePreviewOrInert(agentId, normalized.file);
requestFilePreview(agentId, normalized.file);
}
setExplorerTab("files");
@@ -141,8 +134,8 @@ export function AgentStreamView({
[
agent.cwd,
agentId,
requestDirectoryListingOrInert,
requestFilePreviewOrInert,
requestDirectoryListing,
requestFilePreview,
setExplorerTab,
openFileExplorer,
]
@@ -39,6 +39,7 @@ import type { ExplorerEntry } from "@/stores/session-store";
import { useDaemonConnections } from "@/contexts/daemon-connections-context";
import { useSessionStore } from "@/stores/session-store";
import { useDownloadStore } from "@/stores/download-store";
import { useFileExplorerActions } from "@/hooks/use-file-explorer-actions";
import {
usePanelStore,
type SortOption,
@@ -81,11 +82,12 @@ export function FileExplorerPane({
: undefined
);
const methods = useSessionStore((state) => state.sessions[serverId]?.methods);
const requestDirectoryListing = methods?.requestDirectoryListing;
const requestFilePreview = methods?.requestFilePreview;
const requestFileDownloadToken = methods?.requestFileDownloadToken;
const navigateExplorerBack = methods?.navigateExplorerBack;
const {
requestDirectoryListing,
requestFilePreview,
requestFileDownloadToken,
navigateExplorerBack,
} = useFileExplorerActions(serverId);
const viewMode = usePanelStore((state) => state.explorerViewMode);
const sortOption = usePanelStore((state) => state.explorerSortOption);
const setSortOption = usePanelStore((state) => state.setExplorerSortOption);
@@ -213,16 +213,12 @@ export function GroupedAgentList({
new Set()
);
// Get the methods for the specific server
const methods = useSessionStore((state) =>
actionAgent?.serverId
? state.sessions[actionAgent.serverId]?.methods
: undefined
const actionClient = useSessionStore((state) =>
actionAgent?.serverId ? state.sessions[actionAgent.serverId]?.client ?? null : null
);
const archiveAgent = methods?.archiveAgent;
const isActionSheetVisible = actionAgent !== null;
const isActionDaemonUnavailable = Boolean(actionAgent?.serverId && !methods);
const isActionDaemonUnavailable = Boolean(actionAgent?.serverId && !actionClient);
const handleAgentPress = useCallback(
(serverId: string, agentId: string) => {
@@ -262,12 +258,12 @@ export function GroupedAgentList({
}, []);
const handleArchiveFromSheet = useCallback(() => {
if (!actionAgent || !archiveAgent) {
if (!actionAgent || !actionClient) {
return;
}
archiveAgent(actionAgent.id);
void actionClient.archiveAgent(actionAgent.id);
setActionAgent(null);
}, [actionAgent, archiveAgent]);
}, [actionAgent, actionClient]);
const toggleSection = useCallback((sectionKey: string) => {
setCollapsedSections((prev) => {
@@ -413,9 +409,9 @@ export function GroupedAgentList({
(e: { stopPropagation: () => void }, agent: AggregatedAgent) => {
e.stopPropagation();
const session = useSessionStore.getState().sessions[agent.serverId];
const archiveMethod = session?.methods?.archiveAgent;
if (archiveMethod) {
archiveMethod(agent.id);
const client = session?.client ?? null;
if (client) {
void client.archiveAgent(agent.id);
}
},
[]
@@ -595,7 +591,7 @@ export function GroupedAgentList({
<Text style={styles.sheetCancelText}>Cancel</Text>
</Pressable>
<Pressable
disabled={!archiveAgent || isActionDaemonUnavailable}
disabled={isActionDaemonUnavailable}
style={[styles.sheetButton, styles.sheetArchiveButton]}
onPress={handleArchiveFromSheet}
testID="agent-action-archive"
@@ -603,8 +599,7 @@ export function GroupedAgentList({
<Text
style={[
styles.sheetArchiveText,
(!archiveAgent || isActionDaemonUnavailable) &&
styles.sheetArchiveTextDisabled,
isActionDaemonUnavailable && styles.sheetArchiveTextDisabled,
]}
>
Archive
+34 -441
View File
@@ -1,12 +1,4 @@
import {
createContext,
useRef,
ReactNode,
useCallback,
useEffect,
useMemo,
useState,
} from "react";
import { useRef, ReactNode, useCallback, useEffect, useMemo, useState } from "react";
import { AppState, Platform } from "react-native";
import AsyncStorage from "@react-native-async-storage/async-storage";
import { useMutation } from "@tanstack/react-query";
@@ -39,12 +31,13 @@ import {
import { useDraftStore } from "@/stores/draft-store";
import type { AgentDirectoryEntry } from "@/types/agent-directory";
import { sendOsNotification } from "@/utils/os-notifications";
import { getInitKey, getInitDeferred, resolveInitDeferred, rejectInitDeferred, createInitDeferred } from "@/utils/agent-initialization";
import { encodeImages } from "@/utils/encode-images";
// Re-export types from session-store and draft-store for backward compatibility
export type { DraftInput } from "@/stores/draft-store";
export type {
MessageEntry,
ProviderModelState,
Agent,
ExplorerEntry,
ExplorerFile,
@@ -195,20 +188,6 @@ type FileDownloadTokenPayload = Extract<
{ type: "file_download_token_response" }
>["payload"];
// Module-level map for agent initialization promises
// Key: `${serverId}:${agentId}`, Value: { promise, resolve, reject }
// This survives Fast Refresh because it's outside React component tree
interface DeferredInit {
promise: Promise<void>;
resolve: () => void;
reject: (error: Error) => void;
}
const agentInitializationPromises = new Map<string, DeferredInit>();
function getInitKey(serverId: string, agentId: string): string {
return `${serverId}:${agentId}`;
}
function normalizeAgentSnapshot(
snapshot: AgentSnapshotPayload,
serverId: string
@@ -244,6 +223,7 @@ function normalizeAgentSnapshot(
title: snapshot.title ?? null,
cwd: snapshot.cwd,
model: snapshot.model ?? null,
thinkingOptionId: snapshot.thinkingOptionId ?? null,
requiresAttention: snapshot.requiresAttention ?? false,
attentionReason: snapshot.attentionReason ?? null,
attentionTimestamp,
@@ -272,56 +252,6 @@ const pushHistory = (history: string[], path: string): string[] => {
return [...normalizedHistory, path];
};
// Lightweight context for imperative APIs only (no state)
export interface SessionContextValue {
serverId: string;
client: DaemonClientV2;
audioPlayer: ReturnType<typeof useAudioPlayer>;
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;
requestFileDownloadToken: (
agentId: string,
path: string
) => Promise<FileDownloadTokenPayload>;
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>;
deleteAgent: (agentId: string) => void;
archiveAgent: (agentId: string) => void;
createAgent: (options: {
config: any;
initialPrompt: string;
images?: Array<{ uri: string; mimeType?: string }>;
git?: any;
worktreeName?: string;
requestId?: string;
}) => Promise<unknown>;
setAgentMode: (agentId: string, modeId: string) => void;
respondToPermission: (
agentId: string,
requestId: string,
response: any
) => void;
ensureAgentIsInitialized: (agentId: string) => Promise<void>;
}
const SessionContext = createContext<SessionContextValue | null>(null);
interface SessionProviderProps {
children: ReactNode;
serverUrl: string;
@@ -373,7 +303,6 @@ export function SessionProvider({
);
const setGitDiffs = useSessionStore((state) => state.setGitDiffs);
const setFileExplorer = useSessionStore((state) => state.setFileExplorer);
const setProviderModels = useSessionStore((state) => state.setProviderModels);
const clearDraftInput = useDraftStore((state) => state.clearDraftInput);
const setQueuedMessages = useSessionStore((state) => state.setQueuedMessages);
const getSession = useSessionStore((state) => state.getSession);
@@ -404,7 +333,6 @@ export function SessionProvider({
const previousAgentStatusRef = useRef<Map<string, AgentLifecycleStatus>>(
new Map()
);
const providerModelRequestIdsRef = useRef<Map<any, string>>(new Map());
const sendAgentMessageRef = useRef<
| ((
agentId: string,
@@ -1029,11 +957,7 @@ export function SessionProvider({
// Resolve the initialization promise (even for empty history)
const initKey = getInitKey(serverId, agentId);
const deferred = agentInitializationPromises.get(initKey);
if (deferred) {
deferred.resolve();
// Keep the promise in the map so subsequent calls return immediately
}
resolveInitDeferred(initKey);
}
);
@@ -1351,35 +1275,6 @@ export function SessionProvider({
}
});
const unsubProviderModels = client.on(
"list_provider_models_response",
(message) => {
if (message.type !== "list_provider_models_response") {
return;
}
const { provider, models, error, fetchedAt, requestId } =
message.payload;
const latestRequestId =
providerModelRequestIdsRef.current.get(provider);
if (latestRequestId && requestId && requestId !== latestRequestId) {
return;
}
if (requestId) {
providerModelRequestIdsRef.current.delete(provider);
}
setProviderModels(serverId, (prev) => {
const next = new Map(prev);
next.set(provider, {
models: models ?? null,
error: error ?? null,
fetchedAt: new Date(fetchedAt),
isLoading: false,
});
return next;
});
}
);
const unsubAgentDeleted = client.on("agent_deleted", (message) => {
if (message.type !== "agent_deleted") {
return;
@@ -1494,7 +1389,6 @@ export function SessionProvider({
unsubActivity();
unsubChunk();
unsubTranscription();
unsubProviderModels();
unsubAgentDeleted();
unsubAgentArchived();
};
@@ -1514,7 +1408,6 @@ export function SessionProvider({
setPendingPermissions,
setGitDiffs,
setFileExplorer,
setProviderModels,
setHasHydratedAgents,
updateConnectionStatus,
getSession,
@@ -1595,226 +1488,6 @@ export function SessionProvider({
]
);
const INIT_TIMEOUT_MS = 10000;
const ensureAgentIsInitialized = useCallback(
(agentId: string): Promise<void> => {
const key = getInitKey(serverId, agentId);
// If we already have a promise (resolved or in-flight), return it
const existing = agentInitializationPromises.get(key);
if (existing) {
return existing.promise;
}
// Create a deferred promise
let resolve: () => void;
let reject: (error: Error) => void;
const promise = new Promise<void>((res, rej) => {
resolve = res;
reject = rej;
});
const deferred: DeferredInit = {
promise,
resolve: resolve!,
reject: reject!,
};
agentInitializationPromises.set(key, deferred);
// Set up timeout
const timeoutId = setTimeout(() => {
const entry = agentInitializationPromises.get(key);
if (entry === deferred) {
agentInitializationPromises.delete(key);
deferred.reject(new Error(`Agent initialization timed out after ${INIT_TIMEOUT_MS}ms`));
}
}, INIT_TIMEOUT_MS);
// Set UI loading state
setInitializingAgents(serverId, (prev) => {
const next = new Map(prev);
next.set(agentId, true);
return next;
});
// Clear existing stream state
setAgentStreamTail(serverId, (prev) => {
const next = new Map(prev);
next.set(agentId, []);
return next;
});
clearAgentStreamHead(serverId, agentId);
if (!client) {
console.warn("[Session] ensureAgentIsInitialized skipped: daemon unavailable");
clearTimeout(timeoutId);
agentInitializationPromises.delete(key);
setInitializingAgents(serverId, (prev) => {
const next = new Map(prev);
next.set(agentId, false);
return next;
});
deferred.reject(new Error("Daemon unavailable"));
return promise;
}
client
.initializeAgent(agentId)
.then(() => {
// Note: We don't resolve here - we wait for agent_stream_snapshot
// The snapshot handler will call deferred.resolve()
clearTimeout(timeoutId);
})
.catch((error) => {
console.warn("[Session] ensureAgentIsInitialized failed", { agentId, error });
clearTimeout(timeoutId);
agentInitializationPromises.delete(key);
setInitializingAgents(serverId, (prev) => {
const next = new Map(prev);
next.set(agentId, false);
return next;
});
deferred.reject(error instanceof Error ? error : new Error(String(error)));
});
return promise;
},
[serverId, client, setAgentStreamTail, setInitializingAgents, clearAgentStreamHead]
);
const requestProviderModels = useCallback(
(provider: any, options?: { cwd?: string }) => {
const requestId = generateMessageId();
providerModelRequestIdsRef.current.set(provider, requestId);
setProviderModels(serverId, (prev) => {
const next = new Map(prev);
const current = prev.get(provider) ?? {
models: null,
fetchedAt: null,
error: null,
isLoading: false,
};
next.set(provider, {
...current,
isLoading: true,
error: null,
});
return next;
});
if (!client) {
console.warn(
"[Session] requestProviderModels skipped: daemon unavailable",
{ provider }
);
providerModelRequestIdsRef.current.delete(provider);
setProviderModels(serverId, (prev) => {
const next = new Map(prev);
const current = prev.get(provider) ?? {
models: null,
fetchedAt: null,
error: null,
isLoading: false,
};
next.set(provider, {
...current,
error: "Daemon unavailable",
isLoading: false,
});
return next;
});
return;
}
void client
.listProviderModels(provider, { cwd: options?.cwd, requestId })
.catch((error) => {
providerModelRequestIdsRef.current.delete(provider);
setProviderModels(serverId, (prev) => {
const next = new Map(prev);
const current = next.get(provider) ?? {
models: null,
fetchedAt: null,
error: null,
isLoading: false,
};
next.set(provider, {
...current,
error: error instanceof Error ? error.message : String(error),
isLoading: false,
});
return next;
});
});
},
[serverId, client, setProviderModels]
);
const encodeImages = useCallback(
async (images?: Array<{ uri: string; mimeType?: string }>) => {
if (!images || images.length === 0) {
return undefined;
}
const encodedImages = await Promise.all(
images.map(async ({ uri, mimeType }) => {
try {
const data = await (async () => {
if (Platform.OS === "web") {
if (uri.startsWith("data:")) {
const [, base64] = uri.split(",", 2);
if (!base64) {
throw new Error("Malformed data URI for image.");
}
return base64;
}
const response = await fetch(uri);
if (!response.ok) {
throw new Error(`Failed to fetch image: ${response.status}`);
}
const blob = await response.blob();
const base64 = await new Promise<string>((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => {
if (typeof reader.result !== "string") {
reject(new Error("Unexpected FileReader result type."));
return;
}
const [, resultBase64] = reader.result.split(",", 2);
if (!resultBase64) {
reject(new Error("Failed to read image data as base64."));
return;
}
resolve(resultBase64);
};
reader.onerror = () => {
reject(
reader.error ?? new Error("Failed to read image data.")
);
};
reader.readAsDataURL(blob);
});
return base64;
}
const file = new File(uri);
return await file.base64();
})();
return {
data,
mimeType: mimeType ?? "image/jpeg",
};
} catch (error) {
console.error("[Session] Failed to convert image:", error);
return null;
}
})
);
const validImages = encodedImages.filter(
(entry): entry is { data: string; mimeType: string } => entry !== null
);
return validImages.length > 0 ? validImages : undefined;
},
[]
);
const sendAgentMessage = useCallback(
async (
agentId: string,
@@ -1979,6 +1652,34 @@ export function SessionProvider({
[client]
);
const setAgentModel = useCallback(
(agentId: string, modelId: string | null) => {
if (!client) {
console.warn("[Session] setAgentModel skipped: daemon unavailable");
return;
}
void client.setAgentModel(agentId, modelId).catch((error) => {
console.error("[Session] Failed to set agent model:", error);
});
},
[client]
);
const setAgentThinkingOption = useCallback(
(agentId: string, thinkingOptionId: string | null) => {
if (!client) {
console.warn("[Session] setAgentThinkingOption skipped: daemon unavailable");
return;
}
void client
.setAgentThinkingOption(agentId, thinkingOptionId)
.catch((error) => {
console.error("[Session] Failed to set agent thinking option:", error);
});
},
[client]
);
const respondToPermission = useCallback(
(agentId: string, requestId: string, response: any) => {
if (!client) {
@@ -2236,113 +1937,5 @@ export function SessionProvider({
};
}, [serverId, clearSession]);
const value = useMemo<SessionContextValue>(
() => ({
serverId,
client,
audioPlayer,
setVoiceDetectionFlags,
requestGitDiff,
requestDirectoryListing,
requestFilePreview,
requestFileDownloadToken,
navigateExplorerBack,
requestProviderModels,
restartServer,
initializeAgent,
refreshAgent,
refreshSession,
cancelAgentRun,
deleteAgent,
archiveAgent,
sendAgentMessage,
createAgent,
setAgentMode,
respondToPermission,
ensureAgentIsInitialized,
}),
[
serverId,
client,
audioPlayer,
setVoiceDetectionFlags,
requestGitDiff,
requestDirectoryListing,
requestFilePreview,
requestFileDownloadToken,
navigateExplorerBack,
requestProviderModels,
restartServer,
initializeAgent,
refreshAgent,
refreshSession,
cancelAgentRun,
deleteAgent,
archiveAgent,
sendAgentMessage,
createAgent,
setAgentMode,
respondToPermission,
ensureAgentIsInitialized,
]
);
// Sync imperative methods to Zustand store so components can access them via selectors
// Memoize the methods object to avoid infinite re-renders (object reference must be stable)
const setSessionMethods = useSessionStore((state) => state.setSessionMethods);
const methods = useMemo(
() => ({
setVoiceDetectionFlags,
requestGitDiff,
requestDirectoryListing,
requestFilePreview,
requestFileDownloadToken,
navigateExplorerBack,
requestProviderModels,
restartServer,
initializeAgent,
refreshAgent,
refreshSession,
cancelAgentRun,
sendAgentMessage,
deleteAgent,
archiveAgent,
createAgent,
setAgentMode,
respondToPermission,
ensureAgentIsInitialized,
}),
[
setVoiceDetectionFlags,
requestGitDiff,
requestDirectoryListing,
requestFilePreview,
requestFileDownloadToken,
navigateExplorerBack,
requestProviderModels,
restartServer,
initializeAgent,
refreshAgent,
refreshSession,
cancelAgentRun,
sendAgentMessage,
deleteAgent,
archiveAgent,
createAgent,
setAgentMode,
respondToPermission,
ensureAgentIsInitialized,
]
);
useEffect(() => {
setSessionMethods(serverId, methods);
}, [serverId, setSessionMethods, methods]);
return (
<SessionContext.Provider value={value}>{children}</SessionContext.Provider>
);
return children;
}
// Export the context for components that need imperative APIs
export { SessionContext };
+36 -115
View File
@@ -1,4 +1,5 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useQuery } from "@tanstack/react-query";
import {
AGENT_PROVIDER_DEFINITIONS,
type AgentProviderDefinition,
@@ -76,11 +77,6 @@ type UseAgentFormStateResult = {
isModelLoading: boolean;
modelError: string | null;
refreshProviderModels: () => void;
queueProviderModelFetch: (
serverId: string | null,
options?: { cwd?: string; delayMs?: number }
) => void;
clearQueuedProviderModelRequest: (serverId: string | null) => void;
workingDirIsEmpty: boolean;
persistFormPreferences: () => Promise<void>;
};
@@ -278,20 +274,40 @@ export function useAgentFormState(
}
}, [isVisible]);
// Get session state for provider models
// Session state for provider model listing
const sessionState = useSessionStore((state) =>
formState.serverId ? state.sessions[formState.serverId] : undefined
);
const providerModels = sessionState?.providerModels;
const requestProviderModels = sessionState?.methods?.requestProviderModels;
const getSessionState = useCallback(
(serverId: string) => useSessionStore.getState().sessions[serverId] ?? null,
[]
);
const client = sessionState?.client ?? null;
const isConnected = sessionState?.connection?.isConnected ?? false;
// Get available models for current provider
const modelState = providerModels?.get(formState.provider);
const availableModels = modelState?.models ?? null;
const [debouncedCwd, setDebouncedCwd] = useState<string | undefined>(undefined);
useEffect(() => {
const trimmed = formState.workingDir.trim();
const next = trimmed.length > 0 ? trimmed : undefined;
const timer = setTimeout(() => setDebouncedCwd(next), 180);
return () => clearTimeout(timer);
}, [formState.workingDir]);
const providerModelsQuery = useQuery({
queryKey: ["providerModels", formState.serverId, formState.provider, debouncedCwd],
enabled: Boolean(isVisible && isTargetDaemonReady && formState.serverId && client && isConnected),
staleTime: 5 * 60 * 1000,
queryFn: async () => {
if (!client) {
throw new Error("Host is not connected");
}
const payload = await client.listProviderModels(formState.provider, {
cwd: debouncedCwd,
});
if (payload.error) {
throw new Error(payload.error);
}
return payload.models ?? [];
},
});
const availableModels = providerModelsQuery.data ?? null;
// Combine initialValues with initialServerId for resolution
const combinedInitialValues = useMemo((): FormInitialValues | undefined => {
@@ -360,11 +376,6 @@ export function useAgentFormState(
updatePreferences,
]);
// Provider model request timers
const providerModelRequestTimersRef = useRef<
Map<string, ReturnType<typeof setTimeout>>
>(new Map());
// User setters - mark fields as modified and persist to preferences
const setSelectedServerIdFromUser = useCallback(
(value: string | null) => {
@@ -432,14 +443,8 @@ export function useAgentFormState(
}, []);
const refreshProviderModels = useCallback(() => {
if (!requestProviderModels) {
return;
}
const trimmed = formState.workingDir.trim();
requestProviderModels(formState.provider, {
cwd: trimmed.length > 0 ? trimmed : undefined,
});
}, [requestProviderModels, formState.provider, formState.workingDir]);
void providerModelsQuery.refetch();
}, [providerModelsQuery]);
const persistFormPreferences = useCallback(async () => {
await updatePreferences({
@@ -461,91 +466,11 @@ export function useAgentFormState(
updateProviderPreferences,
]);
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,
options?: { cwd?: string; delayMs?: number }
) => {
if (!serverId || !getSessionState) {
clearQueuedProviderModelRequest(serverId);
return;
}
const sessionState = getSessionState(serverId);
if (!sessionState?.connection?.isConnected) {
clearQueuedProviderModelRequest(serverId);
return;
}
const currentState = sessionState.providerModels?.get(formState.provider);
if (currentState?.models?.length || currentState?.isLoading) {
clearQueuedProviderModelRequest(serverId);
return;
}
const delayMs = options?.delayMs ?? 0;
const trigger = () => {
providerModelRequestTimersRef.current.delete(serverId);
sessionState.methods?.requestProviderModels(formState.provider, {
...(options?.cwd ? { cwd: options.cwd } : {}),
});
};
clearQueuedProviderModelRequest(serverId);
if (delayMs > 0) {
providerModelRequestTimersRef.current.set(serverId, setTimeout(trigger, delayMs));
} else {
trigger();
}
},
[clearQueuedProviderModelRequest, getSessionState, formState.provider]
);
useEffect(() => {
return () => {
providerModelRequestTimersRef.current.forEach((timer) => {
clearTimeout(timer);
});
providerModelRequestTimersRef.current.clear();
};
}, []);
useEffect(() => {
if (!isVisible || !isTargetDaemonReady || !formState.serverId) {
clearQueuedProviderModelRequest(formState.serverId);
return;
}
const trimmed = formState.workingDir.trim();
queueProviderModelFetch(formState.serverId, {
cwd: trimmed.length > 0 ? trimmed : undefined,
delayMs: 180,
});
return () => {
clearQueuedProviderModelRequest(formState.serverId);
};
}, [
clearQueuedProviderModelRequest,
isTargetDaemonReady,
isVisible,
queueProviderModelFetch,
formState.serverId,
formState.workingDir,
]);
const agentDefinition = providerDefinitionMap.get(formState.provider);
const modeOptions = agentDefinition?.modes ?? [];
const isModelLoading = modelState?.isLoading ?? false;
const modelError = modelState?.error ?? null;
const isModelLoading = providerModelsQuery.isLoading || providerModelsQuery.isFetching;
const modelError =
providerModelsQuery.error instanceof Error ? providerModelsQuery.error.message : null;
const workingDirIsEmpty = !formState.workingDir.trim();
@@ -571,8 +496,6 @@ export function useAgentFormState(
isModelLoading,
modelError,
refreshProviderModels,
queueProviderModelFetch,
clearQueuedProviderModelRequest,
workingDirIsEmpty,
persistFormPreferences,
}),
@@ -595,8 +518,6 @@ export function useAgentFormState(
isModelLoading,
modelError,
refreshProviderModels,
queueProviderModelFetch,
clearQueuedProviderModelRequest,
workingDirIsEmpty,
persistFormPreferences,
]
@@ -0,0 +1,113 @@
import { useCallback } from "react";
import { useSessionStore } from "@/stores/session-store";
import {
createInitDeferred,
getInitDeferred,
getInitKey,
rejectInitDeferred,
} from "@/utils/agent-initialization";
const INIT_TIMEOUT_MS = 10000;
export function useAgentInitialization(serverId: string) {
const client = useSessionStore((state) => state.sessions[serverId]?.client ?? null);
const setInitializingAgents = useSessionStore((state) => state.setInitializingAgents);
const setAgentStreamTail = useSessionStore((state) => state.setAgentStreamTail);
const clearAgentStreamHead = useSessionStore((state) => state.clearAgentStreamHead);
const ensureAgentIsInitialized = useCallback(
(agentId: string): Promise<void> => {
const key = getInitKey(serverId, agentId);
const existing = getInitDeferred(key);
if (existing) {
return existing.promise;
}
const deferred = createInitDeferred(key);
const timeoutId = setTimeout(() => {
rejectInitDeferred(key, new Error(`Agent initialization timed out after ${INIT_TIMEOUT_MS}ms`));
}, INIT_TIMEOUT_MS);
setInitializingAgents(serverId, (prev) => {
const next = new Map(prev);
next.set(agentId, true);
return next;
});
setAgentStreamTail(serverId, (prev) => {
const next = new Map(prev);
next.set(agentId, []);
return next;
});
clearAgentStreamHead(serverId, agentId);
if (!client) {
clearTimeout(timeoutId);
setInitializingAgents(serverId, (prev) => {
const next = new Map(prev);
next.set(agentId, false);
return next;
});
rejectInitDeferred(key, new Error("Host is not connected"));
return deferred.promise;
}
client
.initializeAgent(agentId)
.then(() => {
clearTimeout(timeoutId);
})
.catch((error) => {
clearTimeout(timeoutId);
setInitializingAgents(serverId, (prev) => {
const next = new Map(prev);
next.set(agentId, false);
return next;
});
rejectInitDeferred(
key,
error instanceof Error ? error : new Error(String(error))
);
});
return deferred.promise;
},
[clearAgentStreamHead, client, serverId, setAgentStreamTail, setInitializingAgents]
);
const refreshAgent = useCallback(
async (agentId: string) => {
if (!client) {
throw new Error("Host is not connected");
}
setInitializingAgents(serverId, (prev) => {
const next = new Map(prev);
next.set(agentId, true);
return next;
});
setAgentStreamTail(serverId, (prev) => {
const next = new Map(prev);
next.set(agentId, []);
return next;
});
clearAgentStreamHead(serverId, agentId);
try {
await client.refreshAgent(agentId);
} catch (error) {
setInitializingAgents(serverId, (prev) => {
const next = new Map(prev);
next.set(agentId, false);
return next;
});
throw error;
}
},
[clearAgentStreamHead, client, serverId, setAgentStreamTail, setInitializingAgents]
);
return { ensureAgentIsInitialized, refreshAgent };
}
@@ -5,6 +5,7 @@ import { useSessionStore } from "@/stores/session-store";
import type { AgentDirectoryEntry } from "@/types/agent-directory";
import type { Agent } from "@/stores/session-store";
import { isPerfLoggingEnabled } from "@/utils/perf";
import { derivePendingPermissionKey, normalizeAgentSnapshot } from "@/utils/agent-snapshots";
export interface AggregatedAgent extends AgentDirectoryEntry {
serverId: string;
@@ -32,11 +33,11 @@ export function useAggregatedAgents(): AggregatedAgentsResult {
})
);
const sessionMethods = useSessionStore(
const sessionClients = useSessionStore(
useShallow((state) => {
const result: Record<string, NonNullable<typeof state.sessions[string]["methods"]> | undefined> = {};
const result: Record<string, NonNullable<typeof state.sessions[string]["client"]> | null> = {};
for (const [serverId, session] of Object.entries(state.sessions)) {
result[serverId] = session.methods ?? undefined;
result[serverId] = session.client ?? null;
}
return result;
})
@@ -44,13 +45,43 @@ export function useAggregatedAgents(): AggregatedAgentsResult {
const refreshAll = useCallback(() => {
console.log('[useAggregatedAgents] Manual refresh triggered for all sessions');
for (const [serverId, methods] of Object.entries(sessionMethods)) {
if (methods?.refreshSession) {
console.log(`[useAggregatedAgents] Refreshing session ${serverId}`);
methods.refreshSession();
for (const [serverId, client] of Object.entries(sessionClients)) {
if (!client) {
continue;
}
void (async () => {
try {
console.log(`[useAggregatedAgents] Refreshing session ${serverId}`);
const agentsList = await client.fetchAgents({
filter: { labels: { ui: "true" } },
});
const agents = new Map();
const pendingPermissions = new Map();
const agentLastActivity = new Map();
for (const snapshot of agentsList) {
const agent = normalizeAgentSnapshot(snapshot, serverId);
agents.set(agent.id, agent);
agentLastActivity.set(agent.id, agent.lastActivityAt);
for (const request of agent.pendingPermissions) {
const key = derivePendingPermissionKey(agent.id, request);
pendingPermissions.set(key, { key, agentId: agent.id, request });
}
}
const store = useSessionStore.getState();
store.setAgents(serverId, agents);
for (const [agentId, timestamp] of agentLastActivity.entries()) {
store.setAgentLastActivity(agentId, timestamp);
}
store.setPendingPermissions(serverId, pendingPermissions);
} catch (error) {
console.warn("[useAggregatedAgents] Failed to refresh session", { serverId, error });
}
})();
}
}, [sessionMethods]);
}, [sessionClients]);
const result = useMemo(() => {
const allAgents: AggregatedAgent[] = [];
@@ -0,0 +1,206 @@
import { useCallback } from "react";
import { useSessionStore, type AgentFileExplorerState } from "@/stores/session-store";
function createExplorerState(): AgentFileExplorerState {
return {
directories: new Map(),
files: new Map(),
isLoading: false,
lastError: null,
pendingRequest: null,
currentPath: ".",
history: ["."],
lastVisitedPath: ".",
};
}
function pushHistory(history: string[], path: string): string[] {
const normalizedHistory = history.length === 0 ? ["."] : history;
const last = normalizedHistory[normalizedHistory.length - 1];
if (last === path) {
return normalizedHistory;
}
return [...normalizedHistory, path];
}
export function useFileExplorerActions(serverId: string) {
const client = useSessionStore((state) => state.sessions[serverId]?.client ?? null);
const setFileExplorer = useSessionStore((state) => state.setFileExplorer);
const updateExplorerState = useCallback(
(agentId: string, updater: (prev: AgentFileExplorerState) => AgentFileExplorerState) => {
setFileExplorer(serverId, (prev) => {
const next = new Map(prev);
const current = next.get(agentId) ?? createExplorerState();
next.set(agentId, updater(current));
return next;
});
},
[serverId, setFileExplorer]
);
const requestDirectoryListing = useCallback(
(agentId: string, path: string, options?: { recordHistory?: boolean }) => {
const normalizedPath = path && path.length > 0 ? path : ".";
const shouldRecordHistory = options?.recordHistory ?? true;
updateExplorerState(agentId, (state) => ({
...state,
isLoading: true,
lastError: null,
pendingRequest: { path: normalizedPath, mode: "list" },
currentPath: normalizedPath,
history: shouldRecordHistory ? pushHistory(state.history, normalizedPath) : state.history,
lastVisitedPath: normalizedPath,
}));
if (!client) {
updateExplorerState(agentId, (state) => ({
...state,
isLoading: false,
lastError: "Host is not connected",
pendingRequest: null,
}));
return;
}
void client
.exploreFileSystem(agentId, normalizedPath, "list")
.then((payload) => {
updateExplorerState(agentId, (state) => {
const nextState: AgentFileExplorerState = {
...state,
isLoading: false,
lastError: payload.error ?? null,
pendingRequest: null,
directories: state.directories,
files: state.files,
};
if (!payload.error && payload.directory) {
const directories = new Map(state.directories);
directories.set(payload.directory.path, payload.directory);
nextState.directories = directories;
}
return nextState;
});
})
.catch((error) => {
updateExplorerState(agentId, (state) => ({
...state,
isLoading: false,
lastError: error instanceof Error ? error.message : "Failed to list directory",
pendingRequest: null,
}));
});
},
[client, updateExplorerState]
);
const requestFilePreview = useCallback(
(agentId: string, path: string) => {
const normalizedPath = path && path.length > 0 ? path : ".";
updateExplorerState(agentId, (state) => ({
...state,
isLoading: true,
pendingRequest: { path: normalizedPath, mode: "file" },
}));
if (!client) {
updateExplorerState(agentId, (state) => ({
...state,
isLoading: false,
lastError: "Host is not connected",
pendingRequest: null,
}));
return;
}
void client
.exploreFileSystem(agentId, normalizedPath, "file")
.then((payload) => {
updateExplorerState(agentId, (state) => {
const nextState: AgentFileExplorerState = {
...state,
isLoading: false,
pendingRequest: null,
directories: state.directories,
files: state.files,
};
if (!payload.error && payload.file) {
const files = new Map(state.files);
files.set(payload.file.path, payload.file);
nextState.files = files;
} else if (payload.error) {
nextState.lastError = payload.error;
}
return nextState;
});
})
.catch(() => {
updateExplorerState(agentId, (state) => ({
...state,
isLoading: false,
pendingRequest: null,
}));
});
},
[client, updateExplorerState]
);
const requestFileDownloadToken = useCallback(
async (agentId: string, path: string) => {
if (!client) {
throw new Error("Host is not connected");
}
const payload = await client.requestDownloadToken(agentId, path);
if (payload.error) {
throw new Error(payload.error);
}
return payload;
},
[client]
);
const navigateExplorerBack = useCallback(
(agentId: string) => {
let targetPath: string | null = null;
updateExplorerState(agentId, (state) => {
if (state.history.length <= 1) {
return state;
}
const nextHistory = state.history.slice(0, -1);
targetPath = nextHistory[nextHistory.length - 1] ?? ".";
return {
...state,
isLoading: true,
lastError: null,
pendingRequest: { path: targetPath, mode: "list" },
currentPath: targetPath,
history: nextHistory,
lastVisitedPath: targetPath,
};
});
if (!targetPath) {
return null;
}
requestDirectoryListing(agentId, targetPath, { recordHistory: false });
return targetPath;
},
[requestDirectoryListing, updateExplorerState]
);
return {
requestDirectoryListing,
requestFilePreview,
requestFileDownloadToken,
navigateExplorerBack,
};
}
@@ -60,6 +60,7 @@ import { startPerfMonitor } from "@/utils/perf-monitor";
import { shortenPath } from "@/utils/shorten-path";
import { deriveBranchLabel, deriveProjectPath } from "@/utils/agent-display-info";
import { useCheckoutStatusQuery } from "@/hooks/use-checkout-status-query";
import { useAgentInitialization } from "@/hooks/use-agent-initialization";
import {
DropdownMenu,
DropdownMenuContent,
@@ -338,10 +339,7 @@ function AgentScreenContent({
const isConnected = useSessionStore(
(state) => state.sessions[serverId]?.connection.isConnected ?? false
);
// Get methods
const methods = useSessionStore((state) => state.sessions[serverId]?.methods);
const refreshAgent = methods?.refreshAgent;
const { ensureAgentIsInitialized, refreshAgent } = useAgentInitialization(serverId);
const setFocusedAgentId = useCallback(
(agentId: string | null) => {
useSessionStore.getState().setFocusedAgentId(serverId, agentId);
@@ -484,9 +482,6 @@ function AgentScreenContent({
streamItems,
]);
// Get ensureAgentIsInitialized from methods
const ensureAgentIsInitialized = methods?.ensureAgentIsInitialized;
useEffect(() => {
if (!resolvedAgentId || !ensureAgentIsInitialized) {
return;
@@ -546,10 +541,12 @@ function AgentScreenContent({
}, [setExplorerTab, openFileExplorer]);
const handleRefreshAgent = useCallback(() => {
if (!resolvedAgentId || !refreshAgent) {
if (!resolvedAgentId) {
return;
}
refreshAgent({ agentId: resolvedAgentId });
void refreshAgent(resolvedAgentId).catch((error) => {
console.warn("[AgentScreen] refreshAgent failed", { agentId: resolvedAgentId, error });
});
}, [resolvedAgentId, refreshAgent]);
if (!effectiveAgent) {
@@ -41,6 +41,7 @@ import { WelcomeScreen } from "@/components/welcome-screen";
import type { Agent } from "@/contexts/session-context";
import type { StreamItem } from "@/types/stream";
import { generateMessageId } from "@/types/stream";
import { encodeImages } from "@/utils/encode-images";
import type {
AgentProvider,
AgentCapabilityFlags,
@@ -567,8 +568,8 @@ export function DraftAgentScreen({
}
}, [isNonGitDirectory, worktreeMode]);
const sessionMethods = useSessionStore((state) =>
selectedServerId ? state.sessions[selectedServerId]?.methods : undefined
const createAgentClient = useSessionStore((state) =>
selectedServerId ? state.sessions[selectedServerId]?.client ?? null : null
);
const promptValue = machine.tag === "draft" ? machine.promptText : "";
@@ -688,8 +689,7 @@ export function DraftAgentScreen({
dispatch({ type: "DRAFT_SET_ERROR", message: baseBranchError });
throw new Error(baseBranchError);
}
const createAgent = sessionMethods?.createAgent;
if (!createAgent) {
if (!createAgentClient) {
dispatch({ type: "DRAFT_SET_ERROR", message: "Host is not connected" });
throw new Error("Host is not connected");
}
@@ -740,10 +740,12 @@ export function DraftAgentScreen({
onCreateFlowActiveChange?.(true);
try {
const result = await createAgent({
const imagesData = await encodeImages(images);
const result = await createAgentClient.createAgent({
config,
labels: { ui: "true" },
initialPrompt: trimmedPrompt,
images,
...(imagesData && imagesData.length > 0 ? { images: imagesData } : {}),
git: gitOptions,
});
@@ -785,7 +787,7 @@ export function DraftAgentScreen({
selectedModel,
selectedProvider,
selectedServerId,
sessionMethods,
createAgentClient,
setPendingCreateAttempt,
updatePendingAgentId,
clearPendingCreateAttempt,
+1 -96
View File
@@ -60,13 +60,6 @@ export type MessageEntry =
status: "executing" | "completed" | "failed";
};
export type ProviderModelState = {
models: AgentModelDefinition[] | null;
fetchedAt: Date | null;
error: string | null;
isLoading: boolean;
};
export interface AgentRuntimeInfo {
provider: AgentProvider;
sessionId: string | null;
@@ -95,6 +88,7 @@ export interface Agent {
title: string | null;
cwd: string;
model: string | null;
thinkingOptionId?: string | null;
requiresAttention?: boolean;
attentionReason?: "finished" | "error" | "permission" | null;
attentionTimestamp?: Date | null;
@@ -164,40 +158,6 @@ export interface SessionState {
// Audio player (immutable reference)
audioPlayer: ReturnType<typeof useAudioPlayer> | null;
// Imperative methods from SessionProvider
methods: {
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;
requestFileDownloadToken: (agentId: string, path: string) => Promise<FileDownloadTokenResponse["payload"]>;
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>;
deleteAgent: (agentId: string) => void;
archiveAgent: (agentId: string) => void;
createAgent: (options: {
config: any;
initialPrompt: string;
images?: Array<{ uri: string; mimeType?: string }>;
git?: any;
worktreeName?: string;
requestId?: string;
}) => Promise<unknown>;
setAgentMode: (agentId: string, modeId: string) => void;
respondToPermission: (agentId: string, requestId: string, response: any) => void;
ensureAgentIsInitialized: (agentId: string) => Promise<void>;
} | null;
// Hydration status
hasHydratedAgents: boolean;
@@ -230,9 +190,6 @@ export interface SessionState {
// File explorer
fileExplorer: Map<string, AgentFileExplorerState>;
// Provider models
providerModels: Map<AgentProvider, ProviderModelState>;
// Queued messages
queuedMessages: Map<string, Array<{ id: string; text: string; images?: Array<{ uri: string; mimeType: string }> }>>;
}
@@ -287,18 +244,12 @@ interface SessionStoreActions {
// File explorer
setFileExplorer: (serverId: string, state: Map<string, AgentFileExplorerState> | ((prev: Map<string, AgentFileExplorerState>) => Map<string, AgentFileExplorerState>)) => void;
// Provider models
setProviderModels: (serverId: string, models: Map<AgentProvider, ProviderModelState> | ((prev: Map<AgentProvider, ProviderModelState>) => Map<AgentProvider, ProviderModelState>)) => void;
// Queued messages
setQueuedMessages: (serverId: string, value: Map<string, Array<{ id: string; text: string; images?: Array<{ uri: string; mimeType: string }> }>> | ((prev: Map<string, Array<{ id: string; text: string; images?: Array<{ uri: string; mimeType: string }> }>>) => Map<string, Array<{ id: string; text: string; images?: Array<{ uri: string; mimeType: string }> }>>)) => void;
// Hydration
setHasHydratedAgents: (serverId: string, hydrated: boolean) => void;
// Imperative methods
setSessionMethods: (serverId: string, methods: SessionState["methods"]) => void;
// Agent directory (derived from agents)
getAgentDirectory: (serverId: string) => AgentDirectoryEntry[] | undefined;
}
@@ -348,7 +299,6 @@ function createInitialSessionState(serverId: string, client: DaemonClientV2, aud
client,
connection: createDefaultConnectionSnapshot(client),
audioPlayer,
methods: null,
hasHydratedAgents: false,
isPlayingAudio: false,
focusedAgentId: null,
@@ -361,7 +311,6 @@ function createInitialSessionState(serverId: string, client: DaemonClientV2, aud
pendingPermissions: new Map(),
gitDiffs: new Map(),
fileExplorer: new Map(),
providerModels: new Map(),
queuedMessages: new Map(),
};
}
@@ -732,28 +681,6 @@ export const useSessionStore = create<SessionStore>()(
});
},
// Provider models
setProviderModels: (serverId, models) => {
set((prev) => {
const session = prev.sessions[serverId];
if (!session) {
return prev;
}
const nextModels = typeof models === "function" ? models(session.providerModels) : models;
if (session.providerModels === nextModels) {
return prev;
}
logSessionStoreUpdate("setProviderModels", serverId, { providerCount: nextModels.size });
return {
...prev,
sessions: {
...prev.sessions,
[serverId]: { ...session, providerModels: nextModels },
},
};
});
},
// Queued messages
setQueuedMessages: (serverId, value) => {
set((prev) => {
@@ -794,28 +721,6 @@ export const useSessionStore = create<SessionStore>()(
});
},
// Imperative methods
setSessionMethods: (serverId, methods) => {
set((prev) => {
const session = prev.sessions[serverId];
if (!session) {
return prev;
}
// Skip if methods reference is the same (already set)
if (session.methods === methods) {
return prev;
}
logSessionStoreUpdate("setSessionMethods", serverId, { hasValue: !!methods });
return {
...prev,
sessions: {
...prev.sessions,
[serverId]: { ...session, methods },
},
};
});
},
// Agent directory - derived from agents (computed on-demand)
getAgentDirectory: (serverId) => {
const state = get();
@@ -0,0 +1,44 @@
export interface DeferredInit {
promise: Promise<void>;
resolve: () => void;
reject: (error: Error) => void;
}
const initPromises = new Map<string, DeferredInit>();
export function getInitKey(serverId: string, agentId: string): string {
return `${serverId}:${agentId}`;
}
export function getInitDeferred(key: string): DeferredInit | undefined {
return initPromises.get(key);
}
export function createInitDeferred(key: string): DeferredInit {
let resolve!: () => void;
let reject!: (error: Error) => void;
const promise = new Promise<void>((res, rej) => {
resolve = res;
reject = rej;
});
const deferred: DeferredInit = { promise, resolve, reject };
initPromises.set(key, deferred);
return deferred;
}
export function resolveInitDeferred(key: string): void {
const deferred = initPromises.get(key);
deferred?.resolve();
}
export function rejectInitDeferred(key: string, error: Error): void {
const deferred = initPromises.get(key);
if (!deferred) {
return;
}
initPromises.delete(key);
deferred.reject(error);
}
+56
View File
@@ -0,0 +1,56 @@
import type { AgentLifecycleStatus } from "@server/shared/agent-lifecycle";
import type { AgentSnapshotPayload } from "@server/shared/messages";
import type { AgentPermissionRequest } from "@server/server/agent/agent-sdk-types";
export function derivePendingPermissionKey(
agentId: string,
request: AgentPermissionRequest
): string {
const fallbackId =
request.id ||
(typeof request.metadata?.id === "string" ? request.metadata.id : undefined) ||
request.name ||
request.title ||
`${request.kind}:${JSON.stringify(request.input ?? request.metadata ?? {})}`;
return `${agentId}:${fallbackId}`;
}
export function normalizeAgentSnapshot(snapshot: AgentSnapshotPayload, serverId: string) {
const createdAt = new Date(snapshot.createdAt);
const updatedAt = new Date(snapshot.updatedAt);
const lastUserMessageAt = snapshot.lastUserMessageAt ? new Date(snapshot.lastUserMessageAt) : null;
const attentionTimestamp = snapshot.attentionTimestamp
? new Date(snapshot.attentionTimestamp)
: null;
const archivedAt = snapshot.archivedAt ? new Date(snapshot.archivedAt) : null;
return {
serverId,
id: snapshot.id,
provider: snapshot.provider,
status: snapshot.status as AgentLifecycleStatus,
createdAt,
updatedAt,
lastUserMessageAt,
lastActivityAt: updatedAt,
capabilities: snapshot.capabilities,
currentModeId: snapshot.currentModeId,
availableModes: snapshot.availableModes ?? [],
pendingPermissions: snapshot.pendingPermissions ?? [],
persistence: snapshot.persistence ?? null,
runtimeInfo: snapshot.runtimeInfo,
lastUsage: snapshot.lastUsage,
lastError: snapshot.lastError ?? null,
title: snapshot.title ?? null,
cwd: snapshot.cwd,
model: snapshot.model ?? null,
thinkingOptionId: snapshot.thinkingOptionId ?? null,
requiresAttention: snapshot.requiresAttention ?? false,
attentionReason: snapshot.attentionReason ?? null,
attentionTimestamp,
archivedAt,
labels: snapshot.labels,
};
}
+68
View File
@@ -0,0 +1,68 @@
import { Platform } from "react-native";
import { File } from "expo-file-system";
type ImageInput = { uri: string; mimeType?: string };
export async function encodeImages(
images?: ImageInput[]
): Promise<Array<{ data: string; mimeType: string }> | undefined> {
if (!images || images.length === 0) {
return undefined;
}
const encodedImages = await Promise.all(
images.map(async ({ uri, mimeType }) => {
try {
const data = await (async () => {
if (Platform.OS === "web") {
if (uri.startsWith("data:")) {
const [, base64] = uri.split(",", 2);
if (!base64) {
throw new Error("Malformed data URI for image.");
}
return base64;
}
const response = await fetch(uri);
const blob = await response.blob();
const base64 = await new Promise<string>((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => {
if (typeof reader.result !== "string") {
reject(new Error("Unexpected FileReader result type."));
return;
}
const [, resultBase64] = reader.result.split(",", 2);
if (!resultBase64) {
reject(new Error("Failed to read image data as base64."));
return;
}
resolve(resultBase64);
};
reader.onerror = () => {
reject(reader.error ?? new Error("Failed to read image data."));
};
reader.readAsDataURL(blob);
});
return base64;
}
const file = new File(uri);
return await file.base64();
})();
return {
data,
mimeType: mimeType ?? "image/jpeg",
};
} catch (error) {
console.error("[encodeImages] Failed to convert image:", error);
return null;
}
})
);
const validImages = encodedImages.filter(
(entry): entry is { data: string; mimeType: string } => entry !== null
);
return validImages.length > 0 ? validImages : undefined;
}