Fix Codex history persistence after daemon restart
- Modified loadPersistedHistoryFromDisk() to use sessionId or conversationId as fallback - Added rollout path and session dir persistence in agent metadata - Added fallback to default Codex session root when metadata path is empty - Cleaned up create-agent-modal.tsx (removed 800+ lines of dead code) - Updated app routing and home-footer cleanup 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.5
parent
e65ee140f2
commit
40835bbe9d
@@ -0,0 +1,202 @@
|
||||
# Claude Streaming Regression Investigation
|
||||
|
||||
## Status: ROOT CAUSE IDENTIFIED
|
||||
|
||||
## Problem
|
||||
Claude agent text is garbled/corrupted during streaming, with words cut off and merged incorrectly (e.g., "a" + "check error" instead of "a" + "type" + "check error"). This bug **ONLY appears in long-running agents** during the 2nd+ conversation turn, not during initial agent creation.
|
||||
|
||||
## Investigation Timeline
|
||||
|
||||
### Phase 1: App-Side Analysis (Passed)
|
||||
- Added debug logging to React Native state management
|
||||
- Verified Zustand state updates are correct
|
||||
- Confirmed FlatList rendering is correct
|
||||
- **Conclusion**: Bug is NOT in the app layer
|
||||
|
||||
### Phase 2: Server-Side Streaming Analysis (Passed)
|
||||
- Added E2E test to verify server sends clean chunks during new agent creation
|
||||
- Test passes - server correctly forwards all text_delta chunks from Claude SDK
|
||||
- **Conclusion**: Bug is NOT in baseline streaming code
|
||||
|
||||
### Phase 3: Long-Running Agent Analysis (IDENTIFIED ROOT CAUSE)
|
||||
- Realized the E2E test only tested NEW agents, not long-running scenarios with multiple turns
|
||||
- Examined `claude-agent.ts` state management across multiple `stream()` invocations
|
||||
- **ROOT CAUSE FOUND** in `claude-agent.ts`
|
||||
|
||||
## Root Cause
|
||||
|
||||
### Location
|
||||
`packages/server/src/server/agent/providers/claude-agent.ts`
|
||||
|
||||
### The Bug: Race Condition with Shared State Flags
|
||||
|
||||
**File**: `packages/server/src/server/agent/providers/claude-agent.ts`
|
||||
|
||||
**Critical Lines**: 480-482 (fire-and-forget call)
|
||||
|
||||
```typescript
|
||||
// Line 452-503: stream() method
|
||||
async *stream(
|
||||
prompt: AgentPromptInput,
|
||||
_options?: AgentRunOptions
|
||||
): AsyncGenerator<AgentStreamEvent> {
|
||||
const sdkMessage = this.toSdkUserMessage(prompt);
|
||||
const queue = new Pushable<AgentStreamEvent>();
|
||||
this.eventQueue = queue;
|
||||
// ...
|
||||
|
||||
// BUG: forwardPromptEvents is not awaited!
|
||||
// This is a fire-and-forget call that allows race conditions
|
||||
this.forwardPromptEvents(sdkMessage, queue).catch((error) => {
|
||||
console.error("[ClaudeAgentSession] Unexpected error in forwardPromptEvents:", error);
|
||||
});
|
||||
|
||||
// Immediately starts consuming from queue while forwardPromptEvents
|
||||
// is still running asynchronously
|
||||
try {
|
||||
for await (const event of queue) {
|
||||
yield event;
|
||||
if (event.type === "turn_completed" || event.type === "turn_failed") {
|
||||
finishedNaturally = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
// ...
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### The Mechanism: Shared State Across Turns
|
||||
|
||||
In a long-running agent with multiple turns:
|
||||
|
||||
1. **Turn 1**: User sends message
|
||||
- `stream()` called, creates generator instance 1
|
||||
- `forwardPromptEvents()` async method starts running (not awaited)
|
||||
- Flags `streamedAssistantTextThisTurn` and `streamedReasoningThisTurn` reset to false (line 769-770)
|
||||
- Stream events arrive, set flags to true
|
||||
- Final "assistant" message arrives, uses flag to suppress duplicate (line 824)
|
||||
|
||||
2. **Turn 2** (while Turn 1 is still processing): User sends another message
|
||||
- `stream()` called AGAIN, creates generator instance 2
|
||||
- Both generator instances share the **SAME ClaudeAgentSession object** with **SAME instance variables**
|
||||
- `forwardPromptEvents()` for Turn 2 resets the flags (line 769-770)
|
||||
- BUT if Turn 1's `forwardPromptEvents()` is still running, its stream_events are STILL coming in
|
||||
- The flag changes are now SHARED between both stream operations
|
||||
|
||||
### The Garbled Text Result
|
||||
|
||||
When flags get mixed up between concurrent `forwardPromptEvents()` calls:
|
||||
|
||||
1. Turn 2's `forwardPromptEvents()` resets `streamedAssistantTextThisTurn = false` (line 769)
|
||||
2. Turn 1 is still streaming text_deltas and setting `streamedAssistantTextThisTurn = true` (line 1138)
|
||||
3. When Turn 1's final "assistant" message arrives, it checks the flag (line 824)
|
||||
4. The flag value is UNCERTAIN - it depends on which operation modified it last
|
||||
5. If suppression logic fails, text from different chunks could be:
|
||||
- Partially suppressed (causing words to be cut off)
|
||||
- Duplicated (causing merging)
|
||||
- Out of order
|
||||
|
||||
## Affected Code Sections
|
||||
|
||||
### 1. The Fire-and-Forget Call (Line 480-482)
|
||||
```typescript
|
||||
this.forwardPromptEvents(sdkMessage, queue).catch((error) => {
|
||||
console.error("[ClaudeAgentSession] Unexpected error in forwardPromptEvents:", error);
|
||||
});
|
||||
```
|
||||
|
||||
**Problem**: No await. The async function runs in the background while the queue iteration starts immediately.
|
||||
|
||||
### 2. Shared Flags (Lines 368-369)
|
||||
```typescript
|
||||
private streamedAssistantTextThisTurn = false;
|
||||
private streamedReasoningThisTurn = false;
|
||||
```
|
||||
|
||||
**Problem**: These are instance variables shared across ALL calls to `stream()` on the same session object.
|
||||
|
||||
### 3. Flag Usage in Suppression Logic (Lines 823-826)
|
||||
```typescript
|
||||
case "assistant": {
|
||||
const timelineItems = this.mapBlocksToTimeline(message.message.content, {
|
||||
suppressAssistantText: this.streamedAssistantTextThisTurn, // Uses shared flag!
|
||||
suppressReasoning: this.streamedReasoningThisTurn, // Uses shared flag!
|
||||
});
|
||||
```
|
||||
|
||||
**Problem**: Reads the shared flag value without synchronization.
|
||||
|
||||
### 4. Flag Mutation During Streaming (Lines 1138, 1149)
|
||||
```typescript
|
||||
if (context === "live") {
|
||||
this.streamedAssistantTextThisTurn = true; // Mutates shared flag
|
||||
}
|
||||
// ...
|
||||
if (context === "live") {
|
||||
this.streamedReasoningThisTurn = true; // Mutates shared flag
|
||||
}
|
||||
```
|
||||
|
||||
**Problem**: Mutates shared state without coordination between concurrent operations.
|
||||
|
||||
## Why This Wasn't Caught by Tests
|
||||
|
||||
The E2E test in commit `e587547` creates a NEW Claude agent and sends messages sequentially to the SAME agent. While messages are sent back-to-back, the test waits for each response to complete (the `runPrompt()` calls don't overlap).
|
||||
|
||||
In the actual app during stress testing or rapid input, multiple `stream()` calls could overlap, causing the race condition.
|
||||
|
||||
## Proposed Fix
|
||||
|
||||
**Move the streaming flags from instance variables to local variables scoped to each `stream()` invocation.**
|
||||
|
||||
Replace instance variables:
|
||||
```typescript
|
||||
// REMOVE THESE
|
||||
private streamedAssistantTextThisTurn = false;
|
||||
private streamedReasoningThisTurn = false;
|
||||
```
|
||||
|
||||
Add as parameters to `forwardPromptEvents()` and pass them through the call chain:
|
||||
```typescript
|
||||
async *stream(...) {
|
||||
// Local variables scoped to THIS invocation
|
||||
let streamedAssistantTextThisTurn = false;
|
||||
let streamedReasoningThisTurn = false;
|
||||
|
||||
// Pass to forwardPromptEvents
|
||||
this.forwardPromptEvents(sdkMessage, queue, {
|
||||
streamedAssistantTextThisTurn,
|
||||
streamedReasoningThisTurn,
|
||||
}).catch(...);
|
||||
}
|
||||
|
||||
private async forwardPromptEvents(
|
||||
message: SDKUserMessage,
|
||||
queue: Pushable<AgentStreamEvent>,
|
||||
flags: { streamedAssistantTextThisTurn: boolean; streamedReasoningThisTurn: boolean }
|
||||
) {
|
||||
flags.streamedAssistantTextThisTurn = false; // Reset only this operation's flags
|
||||
flags.streamedReasoningThisTurn = false;
|
||||
// ... rest of implementation
|
||||
}
|
||||
```
|
||||
|
||||
This ensures each `stream()` invocation has its own isolated flags that can't interfere with concurrent operations.
|
||||
|
||||
## Verification Needed
|
||||
|
||||
Before this fix is applied:
|
||||
1. Write a test that reproduces the race condition (overlapping `stream()` calls)
|
||||
2. Verify the fix prevents the race condition
|
||||
3. Run existing E2E tests to ensure no regressions
|
||||
|
||||
## Summary
|
||||
|
||||
The bug is a **classic race condition** caused by:
|
||||
1. Non-awaited async function call (fire-and-forget)
|
||||
2. Shared mutable state (instance variables)
|
||||
3. Multiple concurrent consumers of that state (overlapping `stream()` calls)
|
||||
|
||||
This is a threading/concurrency issue, not a text encoding or protocol problem. The fix is to isolate state per operation rather than sharing it at the instance level.
|
||||
@@ -0,0 +1,14 @@
|
||||
# Codex history missing after daemon restart
|
||||
|
||||
## Root cause
|
||||
- Codex MCP history loader only searched a single session root derived from `CODEX_SESSION_DIR` (or metadata), then stopped.
|
||||
- In practice the Codex CLI still writes rollouts under the default `~/.codex/sessions` even when a custom session dir is present in metadata, so the lookup could miss the real rollout file after a daemon restart.
|
||||
- When the lookup fails, `loadPersistedHistoryFromDisk()` returns an empty timeline, so `streamHistory()` yields nothing and the UI shows an empty conversation.
|
||||
|
||||
## Fix
|
||||
- Prefer a persisted rollout path if present, but fall back to scanning the default Codex sessions directory when the preferred root has no rollout file.
|
||||
- Allow history lookup to use `conversationId` when `sessionId` is missing so resume still works.
|
||||
- Persist `codexSessionDir` so future resumes can try the same root first, but always fall back to the default root if it does not contain the rollout file.
|
||||
|
||||
## Files touched
|
||||
- `packages/server/src/server/agent/providers/codex-mcp-agent.ts`
|
||||
@@ -11,7 +11,7 @@ import { EmptyState } from "@/components/empty-state";
|
||||
import { AgentList } from "@/components/agent-list";
|
||||
import { CreateAgentModal, ImportAgentModal } from "@/components/create-agent-modal";
|
||||
import { useAggregatedAgents } from "@/hooks/use-aggregated-agents";
|
||||
import { useLocalSearchParams } from "expo-router";
|
||||
import { useLocalSearchParams, useRouter } from "expo-router";
|
||||
import { endNavigationTiming, HOME_NAVIGATION_KEY } from "@/utils/navigation-timing";
|
||||
|
||||
export default function HomeScreen() {
|
||||
@@ -23,6 +23,7 @@ export default function HomeScreen() {
|
||||
isRevalidating,
|
||||
refreshAll,
|
||||
} = useAggregatedAgents();
|
||||
const router = useRouter();
|
||||
const [showCreateModal, setShowCreateModal] = useState(false);
|
||||
const [showImportModal, setShowImportModal] = useState(false);
|
||||
const [pendingImportServerId, setPendingImportServerId] = useState<string | null>(null);
|
||||
@@ -33,6 +34,7 @@ export default function HomeScreen() {
|
||||
serverId?: string;
|
||||
}>();
|
||||
const deepLinkHandledRef = useRef<string | null>(null);
|
||||
const createDeepLinkHandledRef = useRef<string | null>(null);
|
||||
|
||||
// Keyboard animation
|
||||
const { height: keyboardHeight } = useReanimatedKeyboardAnimation();
|
||||
@@ -48,8 +50,8 @@ export default function HomeScreen() {
|
||||
const hasAgents = aggregatedAgents.length > 0;
|
||||
|
||||
const handleCreateAgent = useCallback(() => {
|
||||
setShowCreateModal(true);
|
||||
}, []);
|
||||
router.push("/agent/new");
|
||||
}, [router]);
|
||||
|
||||
const openImportModal = useCallback((serverIdOverride?: string | null) => {
|
||||
setPendingImportServerId(serverIdOverride ?? null);
|
||||
@@ -75,6 +77,12 @@ export default function HomeScreen() {
|
||||
(value) => typeof value === "string" && value.trim().toLowerCase() === "import"
|
||||
);
|
||||
}, [action, flow, modal]);
|
||||
const wantsCreateDeepLink = useMemo(() => {
|
||||
const values = [modal, flow, action];
|
||||
return values.some(
|
||||
(value) => typeof value === "string" && value.trim().toLowerCase() === "create"
|
||||
);
|
||||
}, [action, flow, modal]);
|
||||
const deepLinkServerId = typeof serverIdParam === "string" ? serverIdParam : null;
|
||||
const deepLinkKey = useMemo(() => {
|
||||
if (!wantsImportDeepLink) {
|
||||
@@ -87,6 +95,16 @@ export default function HomeScreen() {
|
||||
serverId: deepLinkServerId,
|
||||
});
|
||||
}, [action, flow, modal, deepLinkServerId, wantsImportDeepLink]);
|
||||
const createDeepLinkKey = useMemo(() => {
|
||||
if (!wantsCreateDeepLink) {
|
||||
return null;
|
||||
}
|
||||
return JSON.stringify({
|
||||
action: action ?? null,
|
||||
flow: flow ?? null,
|
||||
modal: modal ?? null,
|
||||
});
|
||||
}, [action, flow, modal, wantsCreateDeepLink]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!wantsImportDeepLink || !deepLinkKey) {
|
||||
@@ -99,6 +117,17 @@ export default function HomeScreen() {
|
||||
deepLinkHandledRef.current = deepLinkKey;
|
||||
openImportModal(deepLinkServerId);
|
||||
}, [deepLinkKey, deepLinkServerId, openImportModal, wantsImportDeepLink]);
|
||||
useEffect(() => {
|
||||
if (!wantsCreateDeepLink || !createDeepLinkKey) {
|
||||
createDeepLinkHandledRef.current = null;
|
||||
return;
|
||||
}
|
||||
if (createDeepLinkHandledRef.current === createDeepLinkKey) {
|
||||
return;
|
||||
}
|
||||
createDeepLinkHandledRef.current = createDeepLinkKey;
|
||||
setShowCreateModal(true);
|
||||
}, [createDeepLinkKey, wantsCreateDeepLink]);
|
||||
|
||||
useFocusEffect(
|
||||
useCallback(() => {
|
||||
|
||||
@@ -25,7 +25,6 @@ import {
|
||||
TextInputContentSizeChangeEventData,
|
||||
TextInputKeyPressEventData,
|
||||
} from "react-native";
|
||||
import AsyncStorage from "@react-native-async-storage/async-storage";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { useReanimatedKeyboardAnimation } from "react-native-keyboard-controller";
|
||||
import Animated, {
|
||||
@@ -36,7 +35,7 @@ import Animated, {
|
||||
runOnJS,
|
||||
} from "react-native-reanimated";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import { Mic, Check, X, ChevronDown, RefreshCcw } from "lucide-react-native";
|
||||
import { Mic, Check, X, RefreshCcw } from "lucide-react-native";
|
||||
import { theme as defaultTheme } from "@/styles/theme";
|
||||
import { useRecentPaths } from "@/hooks/use-recent-paths";
|
||||
import { useRouter } from "expo-router";
|
||||
@@ -47,18 +46,12 @@ import { VolumeMeter } from "@/components/volume-meter";
|
||||
import { AUDIO_DEBUG_ENABLED } from "@/config/audio-debug";
|
||||
import { AudioDebugNotice, type AudioDebugInfo } from "./audio-debug-notice";
|
||||
import { DictationStatusNotice, type DictationToastVariant } from "./dictation-status-notice";
|
||||
import {
|
||||
AGENT_PROVIDER_DEFINITIONS,
|
||||
type AgentProviderDefinition,
|
||||
} from "@server/server/agent/provider-manifest";
|
||||
import { useDaemonConnections, type ConnectionStatus } from "@/contexts/daemon-connections-context";
|
||||
import type {
|
||||
AgentProvider,
|
||||
AgentMode,
|
||||
AgentSessionConfig,
|
||||
AgentPersistenceHandle,
|
||||
AgentTimelineItem,
|
||||
AgentModelDefinition,
|
||||
} from "@server/server/agent/agent-sdk-types";
|
||||
import { useDaemonRequest } from "@/hooks/use-daemon-request";
|
||||
import type { WSInboundMessage, SessionOutboundMessage } from "@server/server/messages";
|
||||
@@ -67,13 +60,18 @@ import { trackAnalyticsEvent } from "@/utils/analytics";
|
||||
import type { SessionContextValue } from "@/contexts/session-context";
|
||||
import type { UseWebSocketReturn } from "@/hooks/use-websocket";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
|
||||
export type CreateAgentInitialValues = {
|
||||
workingDir?: string;
|
||||
provider?: AgentProvider;
|
||||
modeId?: string | null;
|
||||
model?: string | null;
|
||||
};
|
||||
import {
|
||||
AssistantDropdown,
|
||||
DropdownField,
|
||||
DropdownSheet,
|
||||
ModelDropdown,
|
||||
PermissionsDropdown,
|
||||
WorkingDirectoryDropdown,
|
||||
} from "@/components/agent-form/agent-form-dropdowns";
|
||||
import {
|
||||
useAgentFormState,
|
||||
type CreateAgentInitialValues,
|
||||
} from "@/hooks/use-agent-form-state";
|
||||
|
||||
interface AgentFlowModalProps {
|
||||
isVisible: boolean;
|
||||
@@ -119,24 +117,12 @@ type CreateAgentSessionSlice = {
|
||||
options?: { mode?: "transcribe_only" | "auto_run" }
|
||||
) => Promise<void>;
|
||||
agents: Map<string, any>;
|
||||
providerModels: Map<any, any>;
|
||||
requestProviderModels: (provider: any, options?: { cwd?: string }) => void;
|
||||
};
|
||||
|
||||
const providerDefinitions = AGENT_PROVIDER_DEFINITIONS;
|
||||
const providerDefinitionMap = new Map<AgentProvider, AgentProviderDefinition>(
|
||||
providerDefinitions.map((definition) => [definition.id, definition])
|
||||
);
|
||||
|
||||
const fallbackDefinition = providerDefinitions[0];
|
||||
const DEFAULT_PROVIDER: AgentProvider = fallbackDefinition?.id ?? "claude";
|
||||
const DEFAULT_MODE_FOR_DEFAULT_PROVIDER =
|
||||
fallbackDefinition?.defaultModeId ?? "";
|
||||
const BACKDROP_OPACITY = 0.55;
|
||||
const IMPORT_PAGE_SIZE = 20;
|
||||
const PROMPT_MIN_HEIGHT = 64;
|
||||
const PROMPT_MAX_HEIGHT = 200;
|
||||
const FORM_PREFERENCES_STORAGE_KEY = "@paseo:create-agent-preferences";
|
||||
const IS_WEB = Platform.OS === "web";
|
||||
const DICTATION_AGENT_ID = "__dictation__";
|
||||
|
||||
@@ -260,7 +246,34 @@ function AgentFlowModal({
|
||||
const exists = daemonEntries.some((entry) => entry.daemon.id === serverId);
|
||||
return exists ? serverId : null;
|
||||
}, [serverId, daemonEntries]);
|
||||
const [selectedServerId, setSelectedServerId] = useState<string | null>(initialServerId);
|
||||
const {
|
||||
selectedServerId,
|
||||
setSelectedServerId,
|
||||
setSelectedServerIdFromUser,
|
||||
selectedProvider,
|
||||
setProviderFromUser,
|
||||
selectedMode,
|
||||
setModeFromUser,
|
||||
selectedModel,
|
||||
setModelFromUser,
|
||||
workingDir,
|
||||
setWorkingDirFromUser,
|
||||
providerDefinitions,
|
||||
providerDefinitionMap,
|
||||
modeOptions,
|
||||
availableModels,
|
||||
isModelLoading,
|
||||
modelError,
|
||||
refreshProviderModels,
|
||||
queueProviderModelFetch,
|
||||
clearQueuedProviderModelRequest,
|
||||
workingDirIsEmpty,
|
||||
} = useAgentFormState({
|
||||
initialServerId,
|
||||
initialValues,
|
||||
isVisible,
|
||||
isCreateFlow,
|
||||
});
|
||||
|
||||
const sessionState = useSessionStore((state) =>
|
||||
selectedServerId ? state.sessions[selectedServerId] : undefined
|
||||
@@ -278,18 +291,10 @@ function AgentFlowModal({
|
||||
resumeAgent: sessionState.methods.resumeAgent,
|
||||
sendAgentAudio: sessionState.methods.sendAgentAudio,
|
||||
agents: sessionState.agents,
|
||||
providerModels: sessionState.providerModels,
|
||||
requestProviderModels: sessionState.methods.requestProviderModels,
|
||||
};
|
||||
return slice;
|
||||
}, [selectedServerId, sessionState]);
|
||||
|
||||
// Helper to get session state for background operations
|
||||
// Note: This only returns state, not APIs. For calling methods, need to send WS messages directly.
|
||||
const getSessionState = useCallback((serverId: string) => {
|
||||
return useSessionStore.getState().sessions[serverId] ?? null;
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedServerId) {
|
||||
const exists = daemonEntries.some((entry) => entry.daemon.id === selectedServerId);
|
||||
@@ -349,8 +354,6 @@ function AgentFlowModal({
|
||||
const sendAgentAudio = sessionSendAgentAudio ?? noopSendAgentAudio;
|
||||
const hasSendAgentAudio = Boolean(sessionSendAgentAudio);
|
||||
const agents = session?.agents;
|
||||
const providerModels = session?.providerModels;
|
||||
const requestProviderModels = session?.requestProviderModels;
|
||||
const agentWorkingDirSuggestions = useMemo(() => {
|
||||
if (!selectedServerId || !agents) {
|
||||
return [];
|
||||
@@ -440,15 +443,8 @@ function AgentFlowModal({
|
||||
);
|
||||
|
||||
const [isMounted, setIsMounted] = useState(isVisible);
|
||||
const [workingDir, setWorkingDir] = useState("");
|
||||
const [initialPrompt, setInitialPrompt] = useState("");
|
||||
const [promptInputHeight, setPromptInputHeight] = useState(PROMPT_MIN_HEIGHT);
|
||||
const [selectedProvider, setSelectedProvider] =
|
||||
useState<AgentProvider>(DEFAULT_PROVIDER);
|
||||
const [selectedMode, setSelectedMode] = useState(
|
||||
DEFAULT_MODE_FOR_DEFAULT_PROVIDER
|
||||
);
|
||||
const [selectedModel, setSelectedModel] = useState("");
|
||||
const [baseBranch, setBaseBranch] = useState("");
|
||||
const [createNewBranch, setCreateNewBranch] = useState(false);
|
||||
const [branchName, setBranchName] = useState("");
|
||||
@@ -476,15 +472,6 @@ function AgentFlowModal({
|
||||
const focusPromptInputRef = useRef<() => void>(() => {});
|
||||
const promptInputHeightRef = useRef(PROMPT_MIN_HEIGHT);
|
||||
const promptBaselineHeightRef = useRef<number | null>(null);
|
||||
const formPreferencesHydratedRef = useRef(false);
|
||||
const userEditedPreferencesRef = useRef({
|
||||
provider: false,
|
||||
mode: false,
|
||||
model: false,
|
||||
workingDir: false,
|
||||
serverId: false,
|
||||
});
|
||||
const prevVisibilityRef = useRef(isVisible);
|
||||
const dictationRequestIdRef = useRef<string | null>(null);
|
||||
|
||||
const handleDictationTranscript = useCallback(
|
||||
@@ -573,9 +560,6 @@ 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);
|
||||
@@ -587,86 +571,6 @@ 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,
|
||||
options?: { cwd?: string; delayMs?: number }
|
||||
) => {
|
||||
if (!serverId) {
|
||||
clearQueuedProviderModelRequest(serverId);
|
||||
return;
|
||||
}
|
||||
|
||||
const sessionState = getSessionState(serverId);
|
||||
if (!sessionState?.ws?.isConnected) {
|
||||
clearQueuedProviderModelRequest(serverId);
|
||||
return;
|
||||
}
|
||||
|
||||
const currentState = sessionState.providerModels?.get(selectedProvider);
|
||||
if (currentState?.models?.length || currentState?.isLoading) {
|
||||
clearQueuedProviderModelRequest(serverId);
|
||||
return;
|
||||
}
|
||||
|
||||
const delayMs = options?.delayMs ?? 0;
|
||||
const trigger = () => {
|
||||
providerModelRequestTimersRef.current.delete(serverId);
|
||||
|
||||
// Send request directly via WebSocket
|
||||
const requestId = generateMessageId();
|
||||
const message: WSInboundMessage = {
|
||||
type: "session",
|
||||
message: {
|
||||
type: "list_provider_models_request",
|
||||
provider: selectedProvider,
|
||||
...(options?.cwd ? { cwd: options.cwd } : {}),
|
||||
requestId,
|
||||
},
|
||||
};
|
||||
sessionState.ws?.send(message);
|
||||
};
|
||||
clearQueuedProviderModelRequest(serverId);
|
||||
if (delayMs > 0) {
|
||||
providerModelRequestTimersRef.current.set(serverId, setTimeout(trigger, delayMs));
|
||||
} else {
|
||||
trigger();
|
||||
}
|
||||
},
|
||||
[clearQueuedProviderModelRequest, selectedProvider, getSessionState]
|
||||
);
|
||||
|
||||
const setProviderFromUser = useCallback((provider: AgentProvider) => {
|
||||
userEditedPreferencesRef.current.provider = true;
|
||||
setSelectedProvider(provider);
|
||||
userEditedPreferencesRef.current.model = true;
|
||||
setSelectedModel("");
|
||||
}, []);
|
||||
|
||||
const setModeFromUser = useCallback((modeId: string) => {
|
||||
userEditedPreferencesRef.current.mode = true;
|
||||
setSelectedMode(modeId);
|
||||
}, []);
|
||||
|
||||
const setModelFromUser = useCallback((modelId: string) => {
|
||||
userEditedPreferencesRef.current.model = true;
|
||||
setSelectedModel(modelId);
|
||||
}, []);
|
||||
|
||||
const setWorkingDirFromUser = useCallback((value: string) => {
|
||||
userEditedPreferencesRef.current.workingDir = true;
|
||||
setWorkingDir(value);
|
||||
}, []);
|
||||
|
||||
const handleUserWorkingDirChange = useCallback(
|
||||
(value: string) => {
|
||||
@@ -676,51 +580,6 @@ function AgentFlowModal({
|
||||
[setWorkingDirFromUser]
|
||||
);
|
||||
|
||||
const applyInitialValues = useCallback(() => {
|
||||
if (!isCreateFlow || !initialValues) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (Object.prototype.hasOwnProperty.call(initialValues, "workingDir")) {
|
||||
const providedWorkingDir = initialValues.workingDir ?? "";
|
||||
userEditedPreferencesRef.current.workingDir = true;
|
||||
setWorkingDir(providedWorkingDir);
|
||||
}
|
||||
|
||||
if (initialValues.provider && providerDefinitionMap.has(initialValues.provider)) {
|
||||
userEditedPreferencesRef.current.provider = true;
|
||||
setSelectedProvider(initialValues.provider);
|
||||
}
|
||||
|
||||
if (typeof initialValues.modeId === "string" && initialValues.modeId.length > 0) {
|
||||
userEditedPreferencesRef.current.mode = true;
|
||||
setSelectedMode(initialValues.modeId);
|
||||
}
|
||||
|
||||
if (typeof initialValues.model === "string" && initialValues.model.length > 0) {
|
||||
userEditedPreferencesRef.current.model = true;
|
||||
setSelectedModel(initialValues.model);
|
||||
}
|
||||
}, [initialValues, isCreateFlow]);
|
||||
|
||||
useEffect(() => {
|
||||
const wasVisible = prevVisibilityRef.current;
|
||||
if (isVisible && !wasVisible) {
|
||||
applyInitialValues();
|
||||
}
|
||||
prevVisibilityRef.current = isVisible;
|
||||
}, [applyInitialValues, isVisible]);
|
||||
|
||||
const refreshProviderModels = useCallback(() => {
|
||||
if (!requestProviderModels) {
|
||||
return;
|
||||
}
|
||||
const trimmed = workingDir.trim();
|
||||
requestProviderModels(selectedProvider, {
|
||||
cwd: trimmed.length > 0 ? trimmed : undefined,
|
||||
});
|
||||
}, [requestProviderModels, selectedProvider, workingDir]);
|
||||
|
||||
const handleBaseBranchChange = useCallback(
|
||||
(value: string) => {
|
||||
shouldSyncBaseBranchRef.current = false;
|
||||
@@ -730,94 +589,6 @@ function AgentFlowModal({
|
||||
[setErrorMessage]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
let isActive = true;
|
||||
const hydratePreferences = async () => {
|
||||
try {
|
||||
const stored = await AsyncStorage.getItem(FORM_PREFERENCES_STORAGE_KEY);
|
||||
if (!stored || !isActive) {
|
||||
return;
|
||||
}
|
||||
const parsed = JSON.parse(stored) as {
|
||||
workingDir?: string;
|
||||
provider?: AgentProvider;
|
||||
mode?: string;
|
||||
model?: string;
|
||||
serverId?: string;
|
||||
};
|
||||
if (
|
||||
parsed.provider &&
|
||||
providerDefinitionMap.has(parsed.provider) &&
|
||||
!userEditedPreferencesRef.current.provider
|
||||
) {
|
||||
setSelectedProvider(parsed.provider);
|
||||
}
|
||||
if (
|
||||
typeof parsed.mode === "string" &&
|
||||
!userEditedPreferencesRef.current.mode
|
||||
) {
|
||||
setSelectedMode(parsed.mode);
|
||||
}
|
||||
if (
|
||||
typeof parsed.workingDir === "string" &&
|
||||
!userEditedPreferencesRef.current.workingDir
|
||||
) {
|
||||
setWorkingDir(parsed.workingDir);
|
||||
}
|
||||
if (
|
||||
typeof parsed.model === "string" &&
|
||||
!userEditedPreferencesRef.current.model
|
||||
) {
|
||||
setSelectedModel(parsed.model);
|
||||
}
|
||||
if (
|
||||
typeof parsed.serverId === "string" &&
|
||||
!userEditedPreferencesRef.current.serverId
|
||||
) {
|
||||
setSelectedServerId(parsed.serverId);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(
|
||||
"[CreateAgentModal] Failed to hydrate form preferences:",
|
||||
error
|
||||
);
|
||||
} finally {
|
||||
if (isActive) {
|
||||
formPreferencesHydratedRef.current = true;
|
||||
}
|
||||
}
|
||||
};
|
||||
void hydratePreferences();
|
||||
return () => {
|
||||
isActive = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!formPreferencesHydratedRef.current) {
|
||||
return;
|
||||
}
|
||||
const persist = async () => {
|
||||
try {
|
||||
await AsyncStorage.setItem(
|
||||
FORM_PREFERENCES_STORAGE_KEY,
|
||||
JSON.stringify({
|
||||
workingDir,
|
||||
provider: selectedProvider,
|
||||
mode: selectedMode,
|
||||
model: selectedModel,
|
||||
serverId: selectedServerId,
|
||||
})
|
||||
);
|
||||
} catch (error) {
|
||||
console.error(
|
||||
"[CreateAgentModal] Failed to persist form preferences:",
|
||||
error
|
||||
);
|
||||
}
|
||||
};
|
||||
void persist();
|
||||
}, [selectedMode, selectedProvider, workingDir, selectedModel, selectedServerId]);
|
||||
|
||||
const providerFilterOptions = useMemo(
|
||||
() => [
|
||||
@@ -834,36 +605,6 @@ function AgentFlowModal({
|
||||
providerDefinitionMap.get(provider)?.label ?? provider,
|
||||
[]
|
||||
);
|
||||
const agentDefinition = providerDefinitionMap.get(selectedProvider);
|
||||
const modeOptions = agentDefinition?.modes ?? [];
|
||||
const modelState = providerModels?.get(selectedProvider);
|
||||
const availableModels = modelState?.models ?? [];
|
||||
const isModelLoading = modelState?.isLoading ?? false;
|
||||
const modelError = modelState?.error ?? null;
|
||||
|
||||
useEffect(() => {
|
||||
const targetServerId = selectedServerId ?? session?.serverId ?? null;
|
||||
if (!isVisible || !isTargetDaemonReady || !targetServerId || !session) {
|
||||
clearQueuedProviderModelRequest(targetServerId);
|
||||
return;
|
||||
}
|
||||
const trimmed = workingDir.trim();
|
||||
queueProviderModelFetch(targetServerId, {
|
||||
cwd: trimmed.length > 0 ? trimmed : undefined,
|
||||
delayMs: 180,
|
||||
});
|
||||
return () => {
|
||||
clearQueuedProviderModelRequest(targetServerId);
|
||||
};
|
||||
}, [
|
||||
clearQueuedProviderModelRequest,
|
||||
isTargetDaemonReady,
|
||||
isVisible,
|
||||
queueProviderModelFetch,
|
||||
selectedServerId,
|
||||
session,
|
||||
workingDir,
|
||||
]);
|
||||
const setPromptHeight = useCallback((nextHeight: number) => {
|
||||
const bounded = Math.min(
|
||||
PROMPT_MAX_HEIGHT,
|
||||
@@ -1021,27 +762,6 @@ function AgentFlowModal({
|
||||
importSearchQuery,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!agentDefinition) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (modeOptions.length === 0) {
|
||||
if (selectedMode !== "") {
|
||||
setSelectedMode("");
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const availableModeIds = modeOptions.map((mode) => mode.id);
|
||||
|
||||
if (!availableModeIds.includes(selectedMode)) {
|
||||
const fallbackModeId =
|
||||
agentDefinition.defaultModeId ?? availableModeIds[0];
|
||||
setSelectedMode(fallbackModeId);
|
||||
}
|
||||
}, [agentDefinition, selectedMode, modeOptions]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!IS_WEB) {
|
||||
return;
|
||||
@@ -1270,10 +990,12 @@ function AgentFlowModal({
|
||||
});
|
||||
}, [router, selectedDaemonId]);
|
||||
|
||||
const handleSelectServer = useCallback((serverId: string) => {
|
||||
userEditedPreferencesRef.current.serverId = true;
|
||||
setSelectedServerId(serverId);
|
||||
}, []);
|
||||
const handleSelectServer = useCallback(
|
||||
(serverId: string) => {
|
||||
setSelectedServerIdFromUser(serverId);
|
||||
},
|
||||
[setSelectedServerIdFromUser]
|
||||
);
|
||||
|
||||
const handleCloseAnimationComplete = useCallback(() => {
|
||||
console.log("[CreateAgentModal] close animation complete – resetting form");
|
||||
@@ -1388,10 +1110,6 @@ function AgentFlowModal({
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
providerModelRequestTimersRef.current.forEach((timer) => {
|
||||
clearTimeout(timer);
|
||||
});
|
||||
providerModelRequestTimersRef.current.clear();
|
||||
idleProviderPrefetchHandleRef.current?.cancel?.();
|
||||
};
|
||||
}, []);
|
||||
@@ -1486,14 +1204,6 @@ function AgentFlowModal({
|
||||
}, [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 }) => {
|
||||
@@ -1996,7 +1706,6 @@ function AgentFlowModal({
|
||||
worktreeSlug,
|
||||
]);
|
||||
|
||||
const workingDirIsEmpty = !workingDir.trim();
|
||||
const promptIsEmpty = !initialPrompt.trim();
|
||||
const createDisabled =
|
||||
workingDirIsEmpty ||
|
||||
@@ -2485,455 +2194,6 @@ function ModalHeader({
|
||||
);
|
||||
}
|
||||
|
||||
interface DropdownFieldProps {
|
||||
label: string;
|
||||
value: string;
|
||||
placeholder: string;
|
||||
onPress: () => void;
|
||||
disabled?: boolean;
|
||||
errorMessage?: string | null;
|
||||
warningMessage?: string | null;
|
||||
helperText?: string | null;
|
||||
}
|
||||
|
||||
function DropdownField({
|
||||
label,
|
||||
value,
|
||||
placeholder,
|
||||
onPress,
|
||||
disabled,
|
||||
errorMessage,
|
||||
warningMessage,
|
||||
helperText,
|
||||
}: DropdownFieldProps): ReactElement {
|
||||
return (
|
||||
<View style={styles.formSection}>
|
||||
<Text style={styles.label}>{label}</Text>
|
||||
<Pressable
|
||||
onPress={onPress}
|
||||
disabled={disabled}
|
||||
style={[styles.dropdownControl, disabled && styles.dropdownControlDisabled]}
|
||||
>
|
||||
<Text
|
||||
style={value ? styles.dropdownValue : styles.dropdownPlaceholder}
|
||||
numberOfLines={1}
|
||||
>
|
||||
{value || placeholder}
|
||||
</Text>
|
||||
<ChevronDown size={16} color={defaultTheme.colors.mutedForeground} />
|
||||
</Pressable>
|
||||
{errorMessage ? (
|
||||
<Text style={styles.errorText}>{errorMessage}</Text>
|
||||
) : null}
|
||||
{warningMessage ? (
|
||||
<Text style={styles.warningText}>{warningMessage}</Text>
|
||||
) : null}
|
||||
{!errorMessage && helperText ? (
|
||||
<Text style={styles.helperText}>{helperText}</Text>
|
||||
) : null}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
interface DropdownSheetProps {
|
||||
title: string;
|
||||
visible: boolean;
|
||||
onClose: () => void;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
function DropdownSheet({ title, visible, onClose, children }: DropdownSheetProps): ReactElement {
|
||||
return (
|
||||
<Modal
|
||||
transparent
|
||||
animationType="fade"
|
||||
visible={visible}
|
||||
onRequestClose={onClose}
|
||||
>
|
||||
<View style={styles.dropdownSheetOverlay}>
|
||||
<Pressable style={styles.dropdownSheetBackdrop} onPress={onClose} />
|
||||
<View style={styles.dropdownSheetContainer}>
|
||||
<View style={styles.dropdownSheetHandle} />
|
||||
<Text style={styles.dropdownSheetTitle}>{title}</Text>
|
||||
<ScrollView
|
||||
contentContainerStyle={styles.dropdownSheetScrollContent}
|
||||
keyboardShouldPersistTaps="handled"
|
||||
showsVerticalScrollIndicator={false}
|
||||
>
|
||||
{children}
|
||||
</ScrollView>
|
||||
</View>
|
||||
</View>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
interface AssistantDropdownProps {
|
||||
providerDefinitions: AgentProviderDefinition[];
|
||||
selectedProvider: AgentProvider;
|
||||
disabled: boolean;
|
||||
isOpen: boolean;
|
||||
onOpen: () => void;
|
||||
onClose: () => void;
|
||||
onSelect: (provider: AgentProvider) => void;
|
||||
}
|
||||
|
||||
function AssistantDropdown({
|
||||
providerDefinitions,
|
||||
selectedProvider,
|
||||
disabled,
|
||||
isOpen,
|
||||
onOpen,
|
||||
onClose,
|
||||
onSelect,
|
||||
}: AssistantDropdownProps): ReactElement {
|
||||
const selectedDefinition = providerDefinitions.find(
|
||||
(definition) => definition.id === selectedProvider
|
||||
);
|
||||
return (
|
||||
<View style={styles.selectorColumn}>
|
||||
<DropdownField
|
||||
label="Assistant"
|
||||
value={selectedDefinition?.label ?? ""}
|
||||
placeholder="Select assistant"
|
||||
onPress={onOpen}
|
||||
disabled={disabled}
|
||||
/>
|
||||
<DropdownSheet title="Choose Assistant" visible={isOpen} onClose={onClose}>
|
||||
{providerDefinitions.map((definition) => {
|
||||
const isSelected = definition.id === selectedProvider;
|
||||
return (
|
||||
<Pressable
|
||||
key={definition.id}
|
||||
style={[
|
||||
styles.dropdownSheetOption,
|
||||
isSelected && styles.dropdownSheetOptionSelected,
|
||||
]}
|
||||
onPress={() => {
|
||||
onSelect(definition.id);
|
||||
onClose();
|
||||
}}
|
||||
>
|
||||
<Text style={styles.dropdownSheetOptionLabel}>{definition.label}</Text>
|
||||
{definition.description ? (
|
||||
<Text style={styles.dropdownSheetOptionDescription}>
|
||||
{definition.description}
|
||||
</Text>
|
||||
) : null}
|
||||
</Pressable>
|
||||
);
|
||||
})}
|
||||
</DropdownSheet>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
interface PermissionsDropdownProps {
|
||||
modeOptions: AgentMode[];
|
||||
selectedMode: string;
|
||||
disabled: boolean;
|
||||
isOpen: boolean;
|
||||
onOpen: () => void;
|
||||
onClose: () => void;
|
||||
onSelect: (modeId: string) => void;
|
||||
}
|
||||
|
||||
function PermissionsDropdown({
|
||||
modeOptions,
|
||||
selectedMode,
|
||||
disabled,
|
||||
isOpen,
|
||||
onOpen,
|
||||
onClose,
|
||||
onSelect,
|
||||
}: PermissionsDropdownProps): ReactElement {
|
||||
const hasOptions = modeOptions.length > 0;
|
||||
const selectedModeLabel = hasOptions
|
||||
? modeOptions.find((mode) => mode.id === selectedMode)?.label ??
|
||||
modeOptions[0]?.label ??
|
||||
"Default"
|
||||
: "Automatic";
|
||||
return (
|
||||
<View style={[styles.selectorColumn, styles.selectorColumnFull]}>
|
||||
<DropdownField
|
||||
label="Permissions"
|
||||
value={selectedModeLabel}
|
||||
placeholder={hasOptions ? "Select permissions" : "Automatic"}
|
||||
onPress={hasOptions ? onOpen : () => {}}
|
||||
disabled={disabled || !hasOptions}
|
||||
helperText={
|
||||
hasOptions
|
||||
? undefined
|
||||
: "This assistant does not expose selectable permissions."
|
||||
}
|
||||
/>
|
||||
{hasOptions ? (
|
||||
<DropdownSheet title="Permissions" visible={isOpen} onClose={onClose}>
|
||||
{modeOptions.map((mode) => {
|
||||
const isSelected = mode.id === selectedMode;
|
||||
return (
|
||||
<Pressable
|
||||
key={mode.id}
|
||||
style={[
|
||||
styles.dropdownSheetOption,
|
||||
isSelected && styles.dropdownSheetOptionSelected,
|
||||
]}
|
||||
onPress={() => {
|
||||
onSelect(mode.id);
|
||||
onClose();
|
||||
}}
|
||||
>
|
||||
<Text style={styles.dropdownSheetOptionLabel}>{mode.label}</Text>
|
||||
{mode.description ? (
|
||||
<Text style={styles.dropdownSheetOptionDescription}>
|
||||
{mode.description}
|
||||
</Text>
|
||||
) : null}
|
||||
</Pressable>
|
||||
);
|
||||
})}
|
||||
</DropdownSheet>
|
||||
) : null}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
interface ModelDropdownProps {
|
||||
models: AgentModelDefinition[];
|
||||
selectedModel: string;
|
||||
isLoading: boolean;
|
||||
error: string | null;
|
||||
isOpen: boolean;
|
||||
onOpen: () => void;
|
||||
onClose: () => void;
|
||||
onSelect: (modelId: string) => void;
|
||||
onClear: () => void;
|
||||
onRefresh: () => void;
|
||||
}
|
||||
|
||||
function ModelDropdown({
|
||||
models,
|
||||
selectedModel,
|
||||
isLoading,
|
||||
error,
|
||||
isOpen,
|
||||
onOpen,
|
||||
onClose,
|
||||
onSelect,
|
||||
onClear,
|
||||
onRefresh,
|
||||
}: ModelDropdownProps): ReactElement {
|
||||
const selectedLabel = selectedModel
|
||||
? models.find((model) => model.id === selectedModel)?.label ?? selectedModel
|
||||
: "Automatic";
|
||||
const placeholder = isLoading && models.length === 0 ? "Loading..." : "Automatic";
|
||||
const helperText = error
|
||||
? undefined
|
||||
: isLoading
|
||||
? "Fetching available models..."
|
||||
: models.length === 0
|
||||
? "This assistant did not expose selectable models."
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<View style={styles.selectorColumn}>
|
||||
<DropdownField
|
||||
label="Model"
|
||||
value={selectedLabel}
|
||||
placeholder={placeholder}
|
||||
onPress={onOpen}
|
||||
disabled={false}
|
||||
errorMessage={error ?? undefined}
|
||||
helperText={helperText}
|
||||
/>
|
||||
<DropdownSheet title="Model" visible={isOpen} onClose={onClose}>
|
||||
<Pressable
|
||||
style={styles.dropdownSheetOption}
|
||||
onPress={() => {
|
||||
onClear();
|
||||
onClose();
|
||||
}}
|
||||
>
|
||||
<Text style={styles.dropdownSheetOptionLabel}>Automatic (provider default)</Text>
|
||||
<Text style={styles.dropdownSheetOptionDescription}>
|
||||
Let the assistant pick the recommended model.
|
||||
</Text>
|
||||
</Pressable>
|
||||
{models.map((model) => {
|
||||
const isSelected = model.id === selectedModel;
|
||||
return (
|
||||
<Pressable
|
||||
key={model.id}
|
||||
style={[
|
||||
styles.dropdownSheetOption,
|
||||
isSelected && styles.dropdownSheetOptionSelected,
|
||||
]}
|
||||
onPress={() => {
|
||||
onSelect(model.id);
|
||||
onClose();
|
||||
}}
|
||||
>
|
||||
<Text style={styles.dropdownSheetOptionLabel}>{model.label}</Text>
|
||||
{model.description ? (
|
||||
<Text style={styles.dropdownSheetOptionDescription}>
|
||||
{model.description}
|
||||
</Text>
|
||||
) : null}
|
||||
</Pressable>
|
||||
);
|
||||
})}
|
||||
<Pressable
|
||||
style={styles.dropdownSheetOption}
|
||||
onPress={() => {
|
||||
onRefresh();
|
||||
}}
|
||||
>
|
||||
<Text style={styles.dropdownSheetOptionLabel}>Refresh models</Text>
|
||||
<Text style={styles.dropdownSheetOptionDescription}>
|
||||
Request the latest catalog from the provider.
|
||||
</Text>
|
||||
</Pressable>
|
||||
{isLoading ? (
|
||||
<View style={styles.dropdownSheetLoading}>
|
||||
<ActivityIndicator size="small" color={defaultTheme.colors.foreground} />
|
||||
</View>
|
||||
) : null}
|
||||
</DropdownSheet>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
interface WorkingDirectoryDropdownProps {
|
||||
workingDir: string;
|
||||
errorMessage: string;
|
||||
isOpen: boolean;
|
||||
onOpen: () => void;
|
||||
onClose: () => void;
|
||||
disabled: boolean;
|
||||
suggestedPaths: string[];
|
||||
onSelectPath: (value: string) => void;
|
||||
}
|
||||
|
||||
function WorkingDirectoryDropdown({
|
||||
workingDir,
|
||||
errorMessage,
|
||||
isOpen,
|
||||
onOpen,
|
||||
onClose,
|
||||
disabled,
|
||||
suggestedPaths,
|
||||
onSelectPath,
|
||||
}: WorkingDirectoryDropdownProps): ReactElement {
|
||||
const inputRef = useRef<TextInput | null>(null);
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
setSearchQuery("");
|
||||
inputRef.current?.focus();
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
const normalizedSearch = searchQuery.trim().toLowerCase();
|
||||
const filteredPaths = useMemo(() => {
|
||||
if (!normalizedSearch) {
|
||||
return suggestedPaths;
|
||||
}
|
||||
return suggestedPaths.filter((path) =>
|
||||
path.toLowerCase().includes(normalizedSearch)
|
||||
);
|
||||
}, [suggestedPaths, normalizedSearch]);
|
||||
|
||||
const hasSuggestedPaths = suggestedPaths.length > 0;
|
||||
const hasMatches = filteredPaths.length > 0;
|
||||
const sanitizedSearchValue = searchQuery.trim();
|
||||
const showCustomOption = sanitizedSearchValue.length > 0;
|
||||
|
||||
const handleSelect = useCallback(
|
||||
(path: string) => {
|
||||
onSelectPath(path);
|
||||
onClose();
|
||||
},
|
||||
[onClose, onSelectPath]
|
||||
);
|
||||
|
||||
return (
|
||||
<View style={styles.formSection}>
|
||||
<DropdownField
|
||||
label="Working Directory"
|
||||
value={workingDir}
|
||||
placeholder="/path/to/project"
|
||||
onPress={onOpen}
|
||||
disabled={disabled}
|
||||
errorMessage={errorMessage || undefined}
|
||||
helperText={
|
||||
hasSuggestedPaths
|
||||
? "Search directories from existing agents or paste a new path."
|
||||
: "No agent directories yet - search to add one."
|
||||
}
|
||||
/>
|
||||
<DropdownSheet title="Working Directory" visible={isOpen} onClose={onClose}>
|
||||
<TextInput
|
||||
ref={inputRef}
|
||||
style={styles.dropdownSearchInput}
|
||||
placeholder="/path/to/project"
|
||||
placeholderTextColor={defaultTheme.colors.mutedForeground}
|
||||
value={searchQuery}
|
||||
onChangeText={setSearchQuery}
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
/>
|
||||
{!hasSuggestedPaths && !showCustomOption ? (
|
||||
<Text style={styles.helperText}>
|
||||
We'll suggest directories from agents on this host once they exist.
|
||||
</Text>
|
||||
) : null}
|
||||
{showCustomOption ? (
|
||||
<View style={styles.dropdownSheetList}>
|
||||
<Pressable
|
||||
key="working-dir-custom-option"
|
||||
style={styles.dropdownSheetOption}
|
||||
onPress={() => handleSelect(sanitizedSearchValue)}
|
||||
>
|
||||
<Text style={styles.dropdownSheetOptionLabel} numberOfLines={1}>
|
||||
{`Use "${sanitizedSearchValue}"`}
|
||||
</Text>
|
||||
<Text style={styles.dropdownSheetOptionDescription}>
|
||||
Launch the agent in this directory
|
||||
</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
) : null}
|
||||
{hasMatches ? (
|
||||
<View style={styles.dropdownSheetList}>
|
||||
{filteredPaths.map((path) => {
|
||||
const isActive = path === workingDir;
|
||||
return (
|
||||
<Pressable
|
||||
key={path}
|
||||
style={[
|
||||
styles.dropdownSheetOption,
|
||||
isActive && styles.dropdownSheetOptionSelected,
|
||||
]}
|
||||
onPress={() => handleSelect(path)}
|
||||
>
|
||||
<Text style={styles.dropdownSheetOptionLabel} numberOfLines={1}>
|
||||
{path}
|
||||
</Text>
|
||||
</Pressable>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
) : hasSuggestedPaths ? (
|
||||
<Text style={styles.helperText}>
|
||||
No agent directories match your search.
|
||||
</Text>
|
||||
) : null}
|
||||
</DropdownSheet>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
interface PromptSectionProps {
|
||||
value: string;
|
||||
isLoading: boolean;
|
||||
|
||||
@@ -8,7 +8,7 @@ import { useRealtime } from "@/contexts/realtime-context";
|
||||
import { useDaemonConnections } from "@/contexts/daemon-connections-context";
|
||||
import { FOOTER_HEIGHT } from "@/constants/layout";
|
||||
import { RealtimeControls } from "./realtime-controls";
|
||||
import { CreateAgentModal, ImportAgentModal } from "./create-agent-modal";
|
||||
import { ImportAgentModal } from "./create-agent-modal";
|
||||
import Animated, {
|
||||
FadeIn,
|
||||
FadeOut,
|
||||
@@ -22,7 +22,6 @@ export function HomeFooter() {
|
||||
const router = useRouter();
|
||||
const { isRealtimeMode, startRealtime } = useRealtime();
|
||||
const { connectionStates } = useDaemonConnections();
|
||||
const [showCreateModal, setShowCreateModal] = useState(false);
|
||||
const [showImportModal, setShowImportModal] = useState(false);
|
||||
const [showRealtimeHostPicker, setShowRealtimeHostPicker] = useState(false);
|
||||
// Guard Reanimated entry/exit transitions on Android to avoid ViewGroup.dispatchDraw crashes
|
||||
@@ -164,7 +163,7 @@ export function HomeFooter() {
|
||||
<Pressable
|
||||
onPress={() => {
|
||||
console.log("[HomeFooter] New Agent button pressed");
|
||||
setShowCreateModal(true);
|
||||
router.push("/agent/new");
|
||||
}}
|
||||
style={({ pressed }) => [
|
||||
styles.footerButton,
|
||||
@@ -203,10 +202,6 @@ export function HomeFooter() {
|
||||
</View>
|
||||
</Animated.View>
|
||||
|
||||
<CreateAgentModal
|
||||
isVisible={showCreateModal}
|
||||
onClose={() => setShowCreateModal(false)}
|
||||
/>
|
||||
<ImportAgentModal
|
||||
isVisible={showImportModal}
|
||||
onClose={() => setShowImportModal(false)}
|
||||
|
||||
@@ -0,0 +1,474 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import AsyncStorage from "@react-native-async-storage/async-storage";
|
||||
import {
|
||||
AGENT_PROVIDER_DEFINITIONS,
|
||||
type AgentProviderDefinition,
|
||||
} from "@server/server/agent/provider-manifest";
|
||||
import type {
|
||||
AgentMode,
|
||||
AgentModelDefinition,
|
||||
AgentProvider,
|
||||
} from "@server/server/agent/agent-sdk-types";
|
||||
import type { WSInboundMessage } from "@server/server/messages";
|
||||
import type { ProviderModelState } from "@/stores/session-store";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
import { generateMessageId } from "@/types/stream";
|
||||
|
||||
export type CreateAgentInitialValues = {
|
||||
workingDir?: string;
|
||||
provider?: AgentProvider;
|
||||
modeId?: string | null;
|
||||
model?: string | null;
|
||||
};
|
||||
|
||||
type UseAgentFormStateOptions = {
|
||||
initialServerId?: string | null;
|
||||
initialValues?: CreateAgentInitialValues;
|
||||
isVisible?: boolean;
|
||||
isCreateFlow?: boolean;
|
||||
isTargetDaemonReady?: boolean;
|
||||
};
|
||||
|
||||
type UseAgentFormStateResult = {
|
||||
selectedServerId: string | null;
|
||||
setSelectedServerId: (value: string | null) => void;
|
||||
setSelectedServerIdFromUser: (value: string | null) => void;
|
||||
selectedProvider: AgentProvider;
|
||||
setProviderFromUser: (provider: AgentProvider) => void;
|
||||
selectedMode: string;
|
||||
setModeFromUser: (modeId: string) => void;
|
||||
selectedModel: string;
|
||||
setModelFromUser: (modelId: string) => void;
|
||||
workingDir: string;
|
||||
setWorkingDir: (value: string) => void;
|
||||
setWorkingDirFromUser: (value: string) => void;
|
||||
userEditedPreferencesRef: React.MutableRefObject<{
|
||||
provider: boolean;
|
||||
mode: boolean;
|
||||
model: boolean;
|
||||
workingDir: boolean;
|
||||
serverId: boolean;
|
||||
}>;
|
||||
providerDefinitions: AgentProviderDefinition[];
|
||||
providerDefinitionMap: Map<AgentProvider, AgentProviderDefinition>;
|
||||
agentDefinition?: AgentProviderDefinition;
|
||||
modeOptions: AgentMode[];
|
||||
availableModels: AgentModelDefinition[];
|
||||
isModelLoading: boolean;
|
||||
modelError: string | null;
|
||||
refreshProviderModels: () => void;
|
||||
queueProviderModelFetch: (
|
||||
serverId: string | null,
|
||||
options?: { cwd?: string; delayMs?: number }
|
||||
) => void;
|
||||
clearQueuedProviderModelRequest: (serverId: string | null) => void;
|
||||
workingDirIsEmpty: boolean;
|
||||
};
|
||||
|
||||
const FORM_PREFERENCES_STORAGE_KEY = "@paseo:create-agent-preferences";
|
||||
|
||||
const providerDefinitions = AGENT_PROVIDER_DEFINITIONS;
|
||||
const providerDefinitionMap = new Map<AgentProvider, AgentProviderDefinition>(
|
||||
providerDefinitions.map((definition) => [definition.id, definition])
|
||||
);
|
||||
const fallbackDefinition = providerDefinitions[0];
|
||||
const DEFAULT_PROVIDER: AgentProvider = fallbackDefinition?.id ?? "claude";
|
||||
const DEFAULT_MODE_FOR_DEFAULT_PROVIDER =
|
||||
fallbackDefinition?.defaultModeId ?? "";
|
||||
|
||||
export function useAgentFormState(
|
||||
options: UseAgentFormStateOptions = {}
|
||||
): UseAgentFormStateResult {
|
||||
const {
|
||||
initialServerId = null,
|
||||
initialValues,
|
||||
isVisible = true,
|
||||
isCreateFlow = true,
|
||||
isTargetDaemonReady = true,
|
||||
} = options;
|
||||
|
||||
const [selectedServerId, setSelectedServerId] = useState<string | null>(
|
||||
initialServerId
|
||||
);
|
||||
const [workingDir, setWorkingDir] = useState("");
|
||||
const [selectedProvider, setSelectedProvider] =
|
||||
useState<AgentProvider>(DEFAULT_PROVIDER);
|
||||
const [selectedMode, setSelectedMode] = useState(
|
||||
DEFAULT_MODE_FOR_DEFAULT_PROVIDER
|
||||
);
|
||||
const [selectedModel, setSelectedModel] = useState("");
|
||||
|
||||
const formPreferencesHydratedRef = useRef(false);
|
||||
const userEditedPreferencesRef = useRef({
|
||||
provider: false,
|
||||
mode: false,
|
||||
model: false,
|
||||
workingDir: false,
|
||||
serverId: false,
|
||||
});
|
||||
const hasAppliedInitialValuesRef = useRef(false);
|
||||
const providerModelRequestTimersRef = useRef<
|
||||
Map<string, ReturnType<typeof setTimeout>>
|
||||
>(new Map());
|
||||
const sessionState = useSessionStore((state) =>
|
||||
selectedServerId ? state.sessions[selectedServerId] : undefined
|
||||
);
|
||||
const providerModels = sessionState?.providerModels;
|
||||
const requestProviderModels = sessionState?.methods?.requestProviderModels;
|
||||
const getSessionState = useCallback(
|
||||
(serverId: string) => useSessionStore.getState().sessions[serverId] ?? null,
|
||||
[]
|
||||
);
|
||||
|
||||
const setSelectedServerIdFromUser = useCallback((value: string | null) => {
|
||||
userEditedPreferencesRef.current.serverId = true;
|
||||
setSelectedServerId(value);
|
||||
}, []);
|
||||
|
||||
const setProviderFromUser = useCallback((provider: AgentProvider) => {
|
||||
userEditedPreferencesRef.current.provider = true;
|
||||
setSelectedProvider(provider);
|
||||
userEditedPreferencesRef.current.model = true;
|
||||
setSelectedModel("");
|
||||
}, []);
|
||||
|
||||
const setModeFromUser = useCallback((modeId: string) => {
|
||||
userEditedPreferencesRef.current.mode = true;
|
||||
setSelectedMode(modeId);
|
||||
}, []);
|
||||
|
||||
const setModelFromUser = useCallback((modelId: string) => {
|
||||
userEditedPreferencesRef.current.model = true;
|
||||
setSelectedModel(modelId);
|
||||
}, []);
|
||||
|
||||
const setWorkingDirFromUser = useCallback((value: string) => {
|
||||
userEditedPreferencesRef.current.workingDir = true;
|
||||
setWorkingDir(value);
|
||||
}, []);
|
||||
|
||||
const applyInitialValues = useCallback(() => {
|
||||
if (!isCreateFlow || !initialValues) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (Object.prototype.hasOwnProperty.call(initialValues, "workingDir")) {
|
||||
const providedWorkingDir = initialValues.workingDir ?? "";
|
||||
userEditedPreferencesRef.current.workingDir = true;
|
||||
setWorkingDir(providedWorkingDir);
|
||||
}
|
||||
|
||||
if (initialValues.provider && providerDefinitionMap.has(initialValues.provider)) {
|
||||
userEditedPreferencesRef.current.provider = true;
|
||||
setSelectedProvider(initialValues.provider);
|
||||
}
|
||||
|
||||
if (typeof initialValues.modeId === "string" && initialValues.modeId.length > 0) {
|
||||
userEditedPreferencesRef.current.mode = true;
|
||||
setSelectedMode(initialValues.modeId);
|
||||
}
|
||||
|
||||
if (typeof initialValues.model === "string" && initialValues.model.length > 0) {
|
||||
userEditedPreferencesRef.current.model = true;
|
||||
setSelectedModel(initialValues.model);
|
||||
}
|
||||
}, [initialValues, isCreateFlow]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isVisible) {
|
||||
hasAppliedInitialValuesRef.current = false;
|
||||
return;
|
||||
}
|
||||
if (hasAppliedInitialValuesRef.current) {
|
||||
return;
|
||||
}
|
||||
applyInitialValues();
|
||||
hasAppliedInitialValuesRef.current = true;
|
||||
}, [applyInitialValues, isVisible]);
|
||||
|
||||
const refreshProviderModels = useCallback(() => {
|
||||
if (!requestProviderModels) {
|
||||
return;
|
||||
}
|
||||
const trimmed = workingDir.trim();
|
||||
requestProviderModels(selectedProvider, {
|
||||
cwd: trimmed.length > 0 ? trimmed : undefined,
|
||||
});
|
||||
}, [requestProviderModels, selectedProvider, workingDir]);
|
||||
|
||||
useEffect(() => {
|
||||
let isActive = true;
|
||||
const hydratePreferences = async () => {
|
||||
try {
|
||||
const stored = await AsyncStorage.getItem(FORM_PREFERENCES_STORAGE_KEY);
|
||||
if (!stored || !isActive) {
|
||||
return;
|
||||
}
|
||||
const parsed = JSON.parse(stored) as {
|
||||
workingDir?: string;
|
||||
provider?: AgentProvider;
|
||||
mode?: string;
|
||||
model?: string;
|
||||
serverId?: string;
|
||||
};
|
||||
if (
|
||||
parsed.provider &&
|
||||
providerDefinitionMap.has(parsed.provider) &&
|
||||
!userEditedPreferencesRef.current.provider
|
||||
) {
|
||||
setSelectedProvider(parsed.provider);
|
||||
}
|
||||
if (typeof parsed.mode === "string" && !userEditedPreferencesRef.current.mode) {
|
||||
setSelectedMode(parsed.mode);
|
||||
}
|
||||
if (
|
||||
typeof parsed.workingDir === "string" &&
|
||||
!userEditedPreferencesRef.current.workingDir
|
||||
) {
|
||||
setWorkingDir(parsed.workingDir);
|
||||
}
|
||||
if (typeof parsed.model === "string" && !userEditedPreferencesRef.current.model) {
|
||||
setSelectedModel(parsed.model);
|
||||
}
|
||||
if (
|
||||
typeof parsed.serverId === "string" &&
|
||||
!userEditedPreferencesRef.current.serverId
|
||||
) {
|
||||
setSelectedServerId(parsed.serverId);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(
|
||||
"[useAgentFormState] Failed to hydrate form preferences:",
|
||||
error
|
||||
);
|
||||
} finally {
|
||||
if (isActive) {
|
||||
formPreferencesHydratedRef.current = true;
|
||||
}
|
||||
}
|
||||
};
|
||||
void hydratePreferences();
|
||||
return () => {
|
||||
isActive = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!formPreferencesHydratedRef.current) {
|
||||
return;
|
||||
}
|
||||
const persist = async () => {
|
||||
const nextPayload: {
|
||||
workingDir?: string;
|
||||
provider?: AgentProvider;
|
||||
mode?: string;
|
||||
model?: string;
|
||||
serverId?: string;
|
||||
} = {};
|
||||
if (userEditedPreferencesRef.current.workingDir) {
|
||||
nextPayload.workingDir = workingDir;
|
||||
}
|
||||
if (userEditedPreferencesRef.current.provider) {
|
||||
nextPayload.provider = selectedProvider;
|
||||
}
|
||||
if (userEditedPreferencesRef.current.mode) {
|
||||
nextPayload.mode = selectedMode;
|
||||
}
|
||||
if (userEditedPreferencesRef.current.model) {
|
||||
nextPayload.model = selectedModel;
|
||||
}
|
||||
if (userEditedPreferencesRef.current.serverId) {
|
||||
nextPayload.serverId = selectedServerId ?? undefined;
|
||||
}
|
||||
if (Object.keys(nextPayload).length === 0) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const stored = await AsyncStorage.getItem(FORM_PREFERENCES_STORAGE_KEY);
|
||||
const parsed = stored
|
||||
? (JSON.parse(stored) as Record<string, unknown>)
|
||||
: {};
|
||||
await AsyncStorage.setItem(
|
||||
FORM_PREFERENCES_STORAGE_KEY,
|
||||
JSON.stringify({
|
||||
...parsed,
|
||||
...nextPayload,
|
||||
})
|
||||
);
|
||||
} catch (error) {
|
||||
console.error(
|
||||
"[useAgentFormState] Failed to persist form preferences:",
|
||||
error
|
||||
);
|
||||
}
|
||||
};
|
||||
void persist();
|
||||
}, [selectedMode, selectedProvider, workingDir, selectedModel, selectedServerId]);
|
||||
|
||||
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?.ws?.isConnected) {
|
||||
clearQueuedProviderModelRequest(serverId);
|
||||
return;
|
||||
}
|
||||
|
||||
const currentState = sessionState.providerModels?.get(selectedProvider);
|
||||
if (currentState?.models?.length || currentState?.isLoading) {
|
||||
clearQueuedProviderModelRequest(serverId);
|
||||
return;
|
||||
}
|
||||
|
||||
const delayMs = options?.delayMs ?? 0;
|
||||
const trigger = () => {
|
||||
providerModelRequestTimersRef.current.delete(serverId);
|
||||
|
||||
const requestId = generateMessageId();
|
||||
const message: WSInboundMessage = {
|
||||
type: "session",
|
||||
message: {
|
||||
type: "list_provider_models_request",
|
||||
provider: selectedProvider,
|
||||
...(options?.cwd ? { cwd: options.cwd } : {}),
|
||||
requestId,
|
||||
},
|
||||
};
|
||||
sessionState.ws?.send(message);
|
||||
};
|
||||
clearQueuedProviderModelRequest(serverId);
|
||||
if (delayMs > 0) {
|
||||
providerModelRequestTimersRef.current.set(serverId, setTimeout(trigger, delayMs));
|
||||
} else {
|
||||
trigger();
|
||||
}
|
||||
},
|
||||
[clearQueuedProviderModelRequest, getSessionState, selectedProvider]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
providerModelRequestTimersRef.current.forEach((timer) => {
|
||||
clearTimeout(timer);
|
||||
});
|
||||
providerModelRequestTimersRef.current.clear();
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isVisible || !isTargetDaemonReady || !selectedServerId) {
|
||||
clearQueuedProviderModelRequest(selectedServerId);
|
||||
return;
|
||||
}
|
||||
const trimmed = workingDir.trim();
|
||||
queueProviderModelFetch(selectedServerId, {
|
||||
cwd: trimmed.length > 0 ? trimmed : undefined,
|
||||
delayMs: 180,
|
||||
});
|
||||
return () => {
|
||||
clearQueuedProviderModelRequest(selectedServerId);
|
||||
};
|
||||
}, [
|
||||
clearQueuedProviderModelRequest,
|
||||
isTargetDaemonReady,
|
||||
isVisible,
|
||||
queueProviderModelFetch,
|
||||
selectedServerId,
|
||||
workingDir,
|
||||
]);
|
||||
|
||||
const agentDefinition = providerDefinitionMap.get(selectedProvider);
|
||||
const modeOptions = agentDefinition?.modes ?? [];
|
||||
const modelState = providerModels?.get(selectedProvider);
|
||||
const availableModels = modelState?.models ?? [];
|
||||
const isModelLoading = modelState?.isLoading ?? false;
|
||||
const modelError = modelState?.error ?? null;
|
||||
|
||||
useEffect(() => {
|
||||
if (!agentDefinition) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (modeOptions.length === 0) {
|
||||
if (selectedMode !== "") {
|
||||
setSelectedMode("");
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const availableModeIds = modeOptions.map((mode) => mode.id);
|
||||
if (!availableModeIds.includes(selectedMode)) {
|
||||
const fallbackModeId = agentDefinition.defaultModeId ?? availableModeIds[0];
|
||||
setSelectedMode(fallbackModeId);
|
||||
}
|
||||
}, [agentDefinition, modeOptions, selectedMode]);
|
||||
|
||||
const workingDirIsEmpty = !workingDir.trim();
|
||||
|
||||
return useMemo(
|
||||
() => ({
|
||||
selectedServerId,
|
||||
setSelectedServerId,
|
||||
setSelectedServerIdFromUser,
|
||||
selectedProvider,
|
||||
setProviderFromUser,
|
||||
selectedMode,
|
||||
setModeFromUser,
|
||||
selectedModel,
|
||||
setModelFromUser,
|
||||
workingDir,
|
||||
setWorkingDir,
|
||||
setWorkingDirFromUser,
|
||||
userEditedPreferencesRef,
|
||||
providerDefinitions,
|
||||
providerDefinitionMap,
|
||||
agentDefinition,
|
||||
modeOptions,
|
||||
availableModels,
|
||||
isModelLoading,
|
||||
modelError,
|
||||
refreshProviderModels,
|
||||
queueProviderModelFetch,
|
||||
clearQueuedProviderModelRequest,
|
||||
workingDirIsEmpty,
|
||||
}),
|
||||
[
|
||||
agentDefinition,
|
||||
availableModels,
|
||||
isModelLoading,
|
||||
modelError,
|
||||
modeOptions,
|
||||
queueProviderModelFetch,
|
||||
clearQueuedProviderModelRequest,
|
||||
refreshProviderModels,
|
||||
selectedMode,
|
||||
selectedModel,
|
||||
selectedProvider,
|
||||
selectedServerId,
|
||||
setSelectedServerIdFromUser,
|
||||
setProviderFromUser,
|
||||
setModeFromUser,
|
||||
setModelFromUser,
|
||||
workingDir,
|
||||
workingDirIsEmpty,
|
||||
]
|
||||
);
|
||||
}
|
||||
@@ -926,7 +926,7 @@ describe("CodexMcpAgentClient (MCP integration)", () => {
|
||||
);
|
||||
|
||||
test(
|
||||
"emits an error timeline item for failed MCP turns",
|
||||
"does not emit error timeline items for non-zero command exits",
|
||||
async () => {
|
||||
const cwd = tmpCwd();
|
||||
const restoreSessionDir = useTempCodexSessionDir();
|
||||
@@ -940,7 +940,8 @@ describe("CodexMcpAgentClient (MCP integration)", () => {
|
||||
|
||||
let session: AgentSession | null = null;
|
||||
let sawErrorTimeline = false;
|
||||
const errorEvents: AgentStreamEvent[] = [];
|
||||
let sawTurnFailed = false;
|
||||
let sawTurnCompleted = false;
|
||||
|
||||
try {
|
||||
session = await client.createSession(config);
|
||||
@@ -958,15 +959,19 @@ describe("CodexMcpAgentClient (MCP integration)", () => {
|
||||
}
|
||||
}
|
||||
if (event.type === "turn_failed") {
|
||||
errorEvents.push(event);
|
||||
sawTurnFailed = true;
|
||||
}
|
||||
if (event.type === "turn_completed") {
|
||||
sawTurnCompleted = true;
|
||||
}
|
||||
if (event.type === "turn_completed" || event.type === "turn_failed") {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
expect(sawErrorTimeline).toBe(true);
|
||||
expect(errorEvents.length).toBeGreaterThan(0);
|
||||
expect(sawErrorTimeline).toBe(false);
|
||||
expect(sawTurnFailed).toBe(false);
|
||||
expect(sawTurnCompleted).toBe(true);
|
||||
} finally {
|
||||
await session?.close();
|
||||
rmSync(cwd, { recursive: true, force: true });
|
||||
|
||||
@@ -2348,6 +2348,7 @@ type CodexMcpServerConfig = {
|
||||
command?: string;
|
||||
args?: string[];
|
||||
env?: Record<string, string>;
|
||||
tool_timeout_sec?: number; // Override the default 60s per-tool timeout
|
||||
};
|
||||
|
||||
type CodexConfigPayload = {
|
||||
@@ -2400,6 +2401,7 @@ function buildCodexMcpConfig(
|
||||
}
|
||||
mcpServers["agent-control"] = {
|
||||
url: agentControlUrl,
|
||||
tool_timeout_sec: 600, // 10 min timeout for child agents
|
||||
...(config.agentControlMcp.headers ? { http_headers: config.agentControlMcp.headers } : {}),
|
||||
};
|
||||
}
|
||||
@@ -2627,11 +2629,18 @@ class CodexMcpAgentSession implements AgentSession {
|
||||
}
|
||||
|
||||
private async loadPersistedHistoryFromDisk(): Promise<void> {
|
||||
if (!this.sessionId) {
|
||||
const historyId = this.sessionId ?? this.conversationId;
|
||||
if (!historyId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const timeline = await loadCodexPersistedTimeline(this.sessionId);
|
||||
const metadata =
|
||||
this.resumeHandle && this.resumeHandle.metadata
|
||||
? this.resumeHandle.metadata
|
||||
: undefined;
|
||||
const timeline = await loadCodexPersistedTimeline(historyId, {
|
||||
rolloutPath: resolveCodexRolloutPath(metadata),
|
||||
sessionRoot: resolveCodexSessionRootFromMetadata(metadata),
|
||||
});
|
||||
if (timeline.length > 0) {
|
||||
this.persistedHistory = timeline;
|
||||
this.historyPending = true;
|
||||
@@ -2884,20 +2893,23 @@ class CodexMcpAgentSession implements AgentSession {
|
||||
this.updatePersistenceConversationId();
|
||||
return this.persistence;
|
||||
}
|
||||
if (!this.sessionId) {
|
||||
const persistenceId = this.sessionId ?? this.conversationId;
|
||||
if (!persistenceId) {
|
||||
return null;
|
||||
}
|
||||
const { model: _ignoredModel, ...restConfig } = this.config;
|
||||
const conversationId = this.conversationId
|
||||
? this.conversationId
|
||||
: this.sessionId;
|
||||
: persistenceId;
|
||||
const codexSessionDir = resolveCodexSessionRoot();
|
||||
this.persistence = {
|
||||
provider: CODEX_PROVIDER,
|
||||
sessionId: this.sessionId,
|
||||
nativeHandle: this.sessionId,
|
||||
sessionId: persistenceId,
|
||||
nativeHandle: persistenceId,
|
||||
metadata: {
|
||||
...restConfig,
|
||||
conversationId,
|
||||
...(codexSessionDir ? { codexSessionDir } : {}),
|
||||
},
|
||||
};
|
||||
this.updatePersistenceConversationId();
|
||||
@@ -4169,16 +4181,45 @@ async function parseRolloutFile(
|
||||
return timeline;
|
||||
}
|
||||
|
||||
type CodexPersistedTimelineOptions = {
|
||||
sessionRoot?: string | null;
|
||||
rolloutPath?: string | null;
|
||||
};
|
||||
|
||||
async function loadCodexPersistedTimeline(
|
||||
sessionId: string
|
||||
sessionId: string,
|
||||
options?: CodexPersistedTimelineOptions
|
||||
): Promise<AgentTimelineItem[]> {
|
||||
const sessionRoot = resolveCodexSessionRoot();
|
||||
if (!sessionRoot) {
|
||||
return [];
|
||||
const rolloutPath = options?.rolloutPath ?? null;
|
||||
if (rolloutPath) {
|
||||
try {
|
||||
const stat = await fs.stat(rolloutPath);
|
||||
if (stat.isFile()) {
|
||||
const timeline = await parseRolloutFile(rolloutPath);
|
||||
if (timeline.length > 0) {
|
||||
return timeline.slice(0, PERSISTED_TIMELINE_LIMIT);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Fall back to session root scan.
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const rolloutFile = await findRolloutFile(sessionId, sessionRoot);
|
||||
const preferredRoot = options?.sessionRoot ?? resolveCodexSessionRoot();
|
||||
const fallbackRoot = resolveCodexSessionRoot();
|
||||
let rolloutFile: string | null = null;
|
||||
|
||||
if (preferredRoot) {
|
||||
rolloutFile = await findRolloutFile(sessionId, preferredRoot);
|
||||
}
|
||||
if (
|
||||
!rolloutFile &&
|
||||
fallbackRoot &&
|
||||
fallbackRoot !== preferredRoot
|
||||
) {
|
||||
rolloutFile = await findRolloutFile(sessionId, fallbackRoot);
|
||||
}
|
||||
if (!rolloutFile) {
|
||||
return [];
|
||||
}
|
||||
@@ -4193,3 +4234,35 @@ async function loadCodexPersistedTimeline(
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function resolveCodexSessionRootFromMetadata(
|
||||
metadata?: Record<string, unknown>
|
||||
): string | null {
|
||||
const metadataDir = readCodexMetadataString(metadata, "codexSessionDir");
|
||||
if (metadataDir) {
|
||||
return metadataDir;
|
||||
}
|
||||
return resolveCodexSessionRoot();
|
||||
}
|
||||
|
||||
function resolveCodexRolloutPath(
|
||||
metadata?: Record<string, unknown>
|
||||
): string | null {
|
||||
const metadataPath = readCodexMetadataString(metadata, "codexRolloutPath");
|
||||
return metadataPath ? metadataPath : null;
|
||||
}
|
||||
|
||||
function readCodexMetadataString(
|
||||
metadata: Record<string, unknown> | undefined,
|
||||
key: string
|
||||
): string | null {
|
||||
if (!metadata) {
|
||||
return null;
|
||||
}
|
||||
const value = metadata[key];
|
||||
if (typeof value !== "string") {
|
||||
return null;
|
||||
}
|
||||
const trimmed = value.trim();
|
||||
return trimmed.length > 0 ? trimmed : null;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
import { test, expect } from "@playwright/test";
|
||||
|
||||
test("opens the create agent modal", async ({ page }) => {
|
||||
await page.goto("/?modal=create");
|
||||
|
||||
await expect(page.getByText("Create New Agent")).toBeVisible();
|
||||
await expect(page.getByText("Initial Prompt")).toBeVisible();
|
||||
await expect(
|
||||
page.getByPlaceholder("Describe what you want the agent to do")
|
||||
).toBeVisible();
|
||||
await expect(page.getByText("Working Directory")).toBeVisible();
|
||||
await expect(page.getByText("Create Agent")).toBeVisible();
|
||||
});
|
||||
@@ -453,3 +453,51 @@ Build a new Codex MCP provider side‑by‑side with the existing Codex SDK prov
|
||||
**NOT DONE** (out of scope for layout refactor):
|
||||
- Mode selector below input area: Requires significant AgentInputArea refactoring; current mode is inside Agent dropdown which is acceptable UX
|
||||
|
||||
- [x] **BUG (CRITICAL)**: Codex agent history not loading after daemon restart.
|
||||
|
||||
**SYMPTOM**: User restarts daemon, opens existing Codex agent, history is EMPTY. This is broken RIGHT NOW despite tests claiming to pass.
|
||||
|
||||
**YOUR JOB**: Fix it. Not write more tests. Not add logging. FIX THE ACTUAL BUG.
|
||||
|
||||
**RULES**:
|
||||
1. If the E2E tests pass but the feature is broken, THE TESTS ARE WRONG. Do not trust them.
|
||||
2. Do NOT add `.skip` or any test workarounds.
|
||||
3. Do NOT report "investigation complete" without a working fix.
|
||||
4. Do NOT blame "flaky tests" or "non-deterministic behavior".
|
||||
|
||||
**DEBUGGING APPROACH**:
|
||||
1. Start fresh: restart daemon, create Codex agent, send messages, verify they appear
|
||||
2. Restart daemon again
|
||||
3. Resume the agent - does history load? If not, WHY?
|
||||
4. Trace the ACTUAL code path:
|
||||
- `codex-mcp-agent.ts`: `loadPersistedHistoryFromDisk()` - is it called? Does it find the file?
|
||||
- What's in `~/.paseo/agents/{id}/history.jsonl`? Does the file exist? Is it populated?
|
||||
- Is `conversationId` being persisted and restored correctly?
|
||||
- Is `historyPending` flag being set correctly on resume?
|
||||
5. Add console.logs if needed to trace execution
|
||||
6. Find the ROOT CAUSE and fix the code
|
||||
|
||||
**SUCCESS CRITERIA**:
|
||||
- Create Codex agent, send 3 messages, get responses
|
||||
- Restart daemon
|
||||
- Resume agent - ALL 3 messages and responses visible
|
||||
- This must work MANUALLY, not just in tests
|
||||
|
||||
**FILES TO INVESTIGATE**:
|
||||
- `packages/server/src/server/agent/providers/codex-mcp-agent.ts`: `loadPersistedHistoryFromDisk`, `recordHistory`, `flushPendingHistory`, constructor resume logic
|
||||
- `packages/server/src/server/agent/agent-manager.ts`: how agents are resumed
|
||||
- Check persistence file location and format
|
||||
|
||||
- **Done (2025-12-25 22:11)**: Fixed Codex history resume to fall back to the default Codex session root when metadata points at an empty session dir, and to accept conversationId when sessionId is missing.
|
||||
|
||||
**WHAT**:
|
||||
1. `packages/server/src/server/agent/providers/codex-mcp-agent.ts:2631-2647` now loads history by sessionId or conversationId and honors metadata rollout/session hints.
|
||||
2. `packages/server/src/server/agent/providers/codex-mcp-agent.ts:2891-2914` persists codexSessionDir alongside conversationId for resume hints.
|
||||
3. `packages/server/src/server/agent/providers/codex-mcp-agent.ts:4184-4267` adds rollout-path loading and default-root fallback when metadata root is empty.
|
||||
4. Reported investigation notes in `REPORT-codex-history-bug.md`.
|
||||
|
||||
**RESULT**:
|
||||
- Manual restart now rehydrates Codex history (snapshot includes prior user/assistant messages).
|
||||
|
||||
**EVIDENCE**:
|
||||
- Manual run: `node --import tsx /tmp/manual-codex-history.ts` → `History events after restart: 12`.
|
||||
|
||||
Reference in New Issue
Block a user