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 { AgentList } from "@/components/agent-list";
|
||||||
import { CreateAgentModal, ImportAgentModal } from "@/components/create-agent-modal";
|
import { CreateAgentModal, ImportAgentModal } from "@/components/create-agent-modal";
|
||||||
import { useAggregatedAgents } from "@/hooks/use-aggregated-agents";
|
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";
|
import { endNavigationTiming, HOME_NAVIGATION_KEY } from "@/utils/navigation-timing";
|
||||||
|
|
||||||
export default function HomeScreen() {
|
export default function HomeScreen() {
|
||||||
@@ -23,6 +23,7 @@ export default function HomeScreen() {
|
|||||||
isRevalidating,
|
isRevalidating,
|
||||||
refreshAll,
|
refreshAll,
|
||||||
} = useAggregatedAgents();
|
} = useAggregatedAgents();
|
||||||
|
const router = useRouter();
|
||||||
const [showCreateModal, setShowCreateModal] = useState(false);
|
const [showCreateModal, setShowCreateModal] = useState(false);
|
||||||
const [showImportModal, setShowImportModal] = useState(false);
|
const [showImportModal, setShowImportModal] = useState(false);
|
||||||
const [pendingImportServerId, setPendingImportServerId] = useState<string | null>(null);
|
const [pendingImportServerId, setPendingImportServerId] = useState<string | null>(null);
|
||||||
@@ -33,6 +34,7 @@ export default function HomeScreen() {
|
|||||||
serverId?: string;
|
serverId?: string;
|
||||||
}>();
|
}>();
|
||||||
const deepLinkHandledRef = useRef<string | null>(null);
|
const deepLinkHandledRef = useRef<string | null>(null);
|
||||||
|
const createDeepLinkHandledRef = useRef<string | null>(null);
|
||||||
|
|
||||||
// Keyboard animation
|
// Keyboard animation
|
||||||
const { height: keyboardHeight } = useReanimatedKeyboardAnimation();
|
const { height: keyboardHeight } = useReanimatedKeyboardAnimation();
|
||||||
@@ -48,8 +50,8 @@ export default function HomeScreen() {
|
|||||||
const hasAgents = aggregatedAgents.length > 0;
|
const hasAgents = aggregatedAgents.length > 0;
|
||||||
|
|
||||||
const handleCreateAgent = useCallback(() => {
|
const handleCreateAgent = useCallback(() => {
|
||||||
setShowCreateModal(true);
|
router.push("/agent/new");
|
||||||
}, []);
|
}, [router]);
|
||||||
|
|
||||||
const openImportModal = useCallback((serverIdOverride?: string | null) => {
|
const openImportModal = useCallback((serverIdOverride?: string | null) => {
|
||||||
setPendingImportServerId(serverIdOverride ?? null);
|
setPendingImportServerId(serverIdOverride ?? null);
|
||||||
@@ -75,6 +77,12 @@ export default function HomeScreen() {
|
|||||||
(value) => typeof value === "string" && value.trim().toLowerCase() === "import"
|
(value) => typeof value === "string" && value.trim().toLowerCase() === "import"
|
||||||
);
|
);
|
||||||
}, [action, flow, modal]);
|
}, [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 deepLinkServerId = typeof serverIdParam === "string" ? serverIdParam : null;
|
||||||
const deepLinkKey = useMemo(() => {
|
const deepLinkKey = useMemo(() => {
|
||||||
if (!wantsImportDeepLink) {
|
if (!wantsImportDeepLink) {
|
||||||
@@ -87,6 +95,16 @@ export default function HomeScreen() {
|
|||||||
serverId: deepLinkServerId,
|
serverId: deepLinkServerId,
|
||||||
});
|
});
|
||||||
}, [action, flow, modal, deepLinkServerId, wantsImportDeepLink]);
|
}, [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(() => {
|
useEffect(() => {
|
||||||
if (!wantsImportDeepLink || !deepLinkKey) {
|
if (!wantsImportDeepLink || !deepLinkKey) {
|
||||||
@@ -99,6 +117,17 @@ export default function HomeScreen() {
|
|||||||
deepLinkHandledRef.current = deepLinkKey;
|
deepLinkHandledRef.current = deepLinkKey;
|
||||||
openImportModal(deepLinkServerId);
|
openImportModal(deepLinkServerId);
|
||||||
}, [deepLinkKey, deepLinkServerId, openImportModal, wantsImportDeepLink]);
|
}, [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(
|
useFocusEffect(
|
||||||
useCallback(() => {
|
useCallback(() => {
|
||||||
|
|||||||
@@ -25,7 +25,6 @@ import {
|
|||||||
TextInputContentSizeChangeEventData,
|
TextInputContentSizeChangeEventData,
|
||||||
TextInputKeyPressEventData,
|
TextInputKeyPressEventData,
|
||||||
} from "react-native";
|
} from "react-native";
|
||||||
import AsyncStorage from "@react-native-async-storage/async-storage";
|
|
||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
import { useReanimatedKeyboardAnimation } from "react-native-keyboard-controller";
|
import { useReanimatedKeyboardAnimation } from "react-native-keyboard-controller";
|
||||||
import Animated, {
|
import Animated, {
|
||||||
@@ -36,7 +35,7 @@ import Animated, {
|
|||||||
runOnJS,
|
runOnJS,
|
||||||
} from "react-native-reanimated";
|
} from "react-native-reanimated";
|
||||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
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 { theme as defaultTheme } from "@/styles/theme";
|
||||||
import { useRecentPaths } from "@/hooks/use-recent-paths";
|
import { useRecentPaths } from "@/hooks/use-recent-paths";
|
||||||
import { useRouter } from "expo-router";
|
import { useRouter } from "expo-router";
|
||||||
@@ -47,18 +46,12 @@ import { VolumeMeter } from "@/components/volume-meter";
|
|||||||
import { AUDIO_DEBUG_ENABLED } from "@/config/audio-debug";
|
import { AUDIO_DEBUG_ENABLED } from "@/config/audio-debug";
|
||||||
import { AudioDebugNotice, type AudioDebugInfo } from "./audio-debug-notice";
|
import { AudioDebugNotice, type AudioDebugInfo } from "./audio-debug-notice";
|
||||||
import { DictationStatusNotice, type DictationToastVariant } from "./dictation-status-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 { useDaemonConnections, type ConnectionStatus } from "@/contexts/daemon-connections-context";
|
||||||
import type {
|
import type {
|
||||||
AgentProvider,
|
AgentProvider,
|
||||||
AgentMode,
|
|
||||||
AgentSessionConfig,
|
AgentSessionConfig,
|
||||||
AgentPersistenceHandle,
|
AgentPersistenceHandle,
|
||||||
AgentTimelineItem,
|
AgentTimelineItem,
|
||||||
AgentModelDefinition,
|
|
||||||
} from "@server/server/agent/agent-sdk-types";
|
} from "@server/server/agent/agent-sdk-types";
|
||||||
import { useDaemonRequest } from "@/hooks/use-daemon-request";
|
import { useDaemonRequest } from "@/hooks/use-daemon-request";
|
||||||
import type { WSInboundMessage, SessionOutboundMessage } from "@server/server/messages";
|
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 { SessionContextValue } from "@/contexts/session-context";
|
||||||
import type { UseWebSocketReturn } from "@/hooks/use-websocket";
|
import type { UseWebSocketReturn } from "@/hooks/use-websocket";
|
||||||
import { useSessionStore } from "@/stores/session-store";
|
import { useSessionStore } from "@/stores/session-store";
|
||||||
|
import {
|
||||||
export type CreateAgentInitialValues = {
|
AssistantDropdown,
|
||||||
workingDir?: string;
|
DropdownField,
|
||||||
provider?: AgentProvider;
|
DropdownSheet,
|
||||||
modeId?: string | null;
|
ModelDropdown,
|
||||||
model?: string | null;
|
PermissionsDropdown,
|
||||||
};
|
WorkingDirectoryDropdown,
|
||||||
|
} from "@/components/agent-form/agent-form-dropdowns";
|
||||||
|
import {
|
||||||
|
useAgentFormState,
|
||||||
|
type CreateAgentInitialValues,
|
||||||
|
} from "@/hooks/use-agent-form-state";
|
||||||
|
|
||||||
interface AgentFlowModalProps {
|
interface AgentFlowModalProps {
|
||||||
isVisible: boolean;
|
isVisible: boolean;
|
||||||
@@ -119,24 +117,12 @@ type CreateAgentSessionSlice = {
|
|||||||
options?: { mode?: "transcribe_only" | "auto_run" }
|
options?: { mode?: "transcribe_only" | "auto_run" }
|
||||||
) => Promise<void>;
|
) => Promise<void>;
|
||||||
agents: Map<string, any>;
|
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 BACKDROP_OPACITY = 0.55;
|
||||||
const IMPORT_PAGE_SIZE = 20;
|
const IMPORT_PAGE_SIZE = 20;
|
||||||
const PROMPT_MIN_HEIGHT = 64;
|
const PROMPT_MIN_HEIGHT = 64;
|
||||||
const PROMPT_MAX_HEIGHT = 200;
|
const PROMPT_MAX_HEIGHT = 200;
|
||||||
const FORM_PREFERENCES_STORAGE_KEY = "@paseo:create-agent-preferences";
|
|
||||||
const IS_WEB = Platform.OS === "web";
|
const IS_WEB = Platform.OS === "web";
|
||||||
const DICTATION_AGENT_ID = "__dictation__";
|
const DICTATION_AGENT_ID = "__dictation__";
|
||||||
|
|
||||||
@@ -260,7 +246,34 @@ function AgentFlowModal({
|
|||||||
const exists = daemonEntries.some((entry) => entry.daemon.id === serverId);
|
const exists = daemonEntries.some((entry) => entry.daemon.id === serverId);
|
||||||
return exists ? serverId : null;
|
return exists ? serverId : null;
|
||||||
}, [serverId, daemonEntries]);
|
}, [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) =>
|
const sessionState = useSessionStore((state) =>
|
||||||
selectedServerId ? state.sessions[selectedServerId] : undefined
|
selectedServerId ? state.sessions[selectedServerId] : undefined
|
||||||
@@ -278,18 +291,10 @@ function AgentFlowModal({
|
|||||||
resumeAgent: sessionState.methods.resumeAgent,
|
resumeAgent: sessionState.methods.resumeAgent,
|
||||||
sendAgentAudio: sessionState.methods.sendAgentAudio,
|
sendAgentAudio: sessionState.methods.sendAgentAudio,
|
||||||
agents: sessionState.agents,
|
agents: sessionState.agents,
|
||||||
providerModels: sessionState.providerModels,
|
|
||||||
requestProviderModels: sessionState.methods.requestProviderModels,
|
|
||||||
};
|
};
|
||||||
return slice;
|
return slice;
|
||||||
}, [selectedServerId, sessionState]);
|
}, [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(() => {
|
useEffect(() => {
|
||||||
if (selectedServerId) {
|
if (selectedServerId) {
|
||||||
const exists = daemonEntries.some((entry) => entry.daemon.id === selectedServerId);
|
const exists = daemonEntries.some((entry) => entry.daemon.id === selectedServerId);
|
||||||
@@ -349,8 +354,6 @@ function AgentFlowModal({
|
|||||||
const sendAgentAudio = sessionSendAgentAudio ?? noopSendAgentAudio;
|
const sendAgentAudio = sessionSendAgentAudio ?? noopSendAgentAudio;
|
||||||
const hasSendAgentAudio = Boolean(sessionSendAgentAudio);
|
const hasSendAgentAudio = Boolean(sessionSendAgentAudio);
|
||||||
const agents = session?.agents;
|
const agents = session?.agents;
|
||||||
const providerModels = session?.providerModels;
|
|
||||||
const requestProviderModels = session?.requestProviderModels;
|
|
||||||
const agentWorkingDirSuggestions = useMemo(() => {
|
const agentWorkingDirSuggestions = useMemo(() => {
|
||||||
if (!selectedServerId || !agents) {
|
if (!selectedServerId || !agents) {
|
||||||
return [];
|
return [];
|
||||||
@@ -440,15 +443,8 @@ function AgentFlowModal({
|
|||||||
);
|
);
|
||||||
|
|
||||||
const [isMounted, setIsMounted] = useState(isVisible);
|
const [isMounted, setIsMounted] = useState(isVisible);
|
||||||
const [workingDir, setWorkingDir] = useState("");
|
|
||||||
const [initialPrompt, setInitialPrompt] = useState("");
|
const [initialPrompt, setInitialPrompt] = useState("");
|
||||||
const [promptInputHeight, setPromptInputHeight] = useState(PROMPT_MIN_HEIGHT);
|
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 [baseBranch, setBaseBranch] = useState("");
|
||||||
const [createNewBranch, setCreateNewBranch] = useState(false);
|
const [createNewBranch, setCreateNewBranch] = useState(false);
|
||||||
const [branchName, setBranchName] = useState("");
|
const [branchName, setBranchName] = useState("");
|
||||||
@@ -476,15 +472,6 @@ function AgentFlowModal({
|
|||||||
const focusPromptInputRef = useRef<() => void>(() => {});
|
const focusPromptInputRef = useRef<() => void>(() => {});
|
||||||
const promptInputHeightRef = useRef(PROMPT_MIN_HEIGHT);
|
const promptInputHeightRef = useRef(PROMPT_MIN_HEIGHT);
|
||||||
const promptBaselineHeightRef = useRef<number | null>(null);
|
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 dictationRequestIdRef = useRef<string | null>(null);
|
||||||
|
|
||||||
const handleDictationTranscript = useCallback(
|
const handleDictationTranscript = useCallback(
|
||||||
@@ -573,9 +560,6 @@ function AgentFlowModal({
|
|||||||
const shouldListenForDictation =
|
const shouldListenForDictation =
|
||||||
isCreateFlow && (isVisible || hasPendingDictation);
|
isCreateFlow && (isVisible || hasPendingDictation);
|
||||||
|
|
||||||
const providerModelRequestTimersRef = useRef<
|
|
||||||
Map<string, ReturnType<typeof setTimeout>>
|
|
||||||
>(new Map());
|
|
||||||
const idleProviderPrefetchHandleRef = useRef<ReturnType<
|
const idleProviderPrefetchHandleRef = useRef<ReturnType<
|
||||||
typeof InteractionManager.runAfterInteractions
|
typeof InteractionManager.runAfterInteractions
|
||||||
> | null>(null);
|
> | null>(null);
|
||||||
@@ -587,86 +571,6 @@ function AgentFlowModal({
|
|||||||
const closeDropdown = useCallback(() => {
|
const closeDropdown = useCallback(() => {
|
||||||
setOpenDropdown(null);
|
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(
|
const handleUserWorkingDirChange = useCallback(
|
||||||
(value: string) => {
|
(value: string) => {
|
||||||
@@ -676,51 +580,6 @@ function AgentFlowModal({
|
|||||||
[setWorkingDirFromUser]
|
[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(
|
const handleBaseBranchChange = useCallback(
|
||||||
(value: string) => {
|
(value: string) => {
|
||||||
shouldSyncBaseBranchRef.current = false;
|
shouldSyncBaseBranchRef.current = false;
|
||||||
@@ -730,94 +589,6 @@ function AgentFlowModal({
|
|||||||
[setErrorMessage]
|
[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(
|
const providerFilterOptions = useMemo(
|
||||||
() => [
|
() => [
|
||||||
@@ -834,36 +605,6 @@ function AgentFlowModal({
|
|||||||
providerDefinitionMap.get(provider)?.label ?? provider,
|
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 setPromptHeight = useCallback((nextHeight: number) => {
|
||||||
const bounded = Math.min(
|
const bounded = Math.min(
|
||||||
PROMPT_MAX_HEIGHT,
|
PROMPT_MAX_HEIGHT,
|
||||||
@@ -1021,27 +762,6 @@ function AgentFlowModal({
|
|||||||
importSearchQuery,
|
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(() => {
|
useLayoutEffect(() => {
|
||||||
if (!IS_WEB) {
|
if (!IS_WEB) {
|
||||||
return;
|
return;
|
||||||
@@ -1270,10 +990,12 @@ function AgentFlowModal({
|
|||||||
});
|
});
|
||||||
}, [router, selectedDaemonId]);
|
}, [router, selectedDaemonId]);
|
||||||
|
|
||||||
const handleSelectServer = useCallback((serverId: string) => {
|
const handleSelectServer = useCallback(
|
||||||
userEditedPreferencesRef.current.serverId = true;
|
(serverId: string) => {
|
||||||
setSelectedServerId(serverId);
|
setSelectedServerIdFromUser(serverId);
|
||||||
}, []);
|
},
|
||||||
|
[setSelectedServerIdFromUser]
|
||||||
|
);
|
||||||
|
|
||||||
const handleCloseAnimationComplete = useCallback(() => {
|
const handleCloseAnimationComplete = useCallback(() => {
|
||||||
console.log("[CreateAgentModal] close animation complete – resetting form");
|
console.log("[CreateAgentModal] close animation complete – resetting form");
|
||||||
@@ -1388,10 +1110,6 @@ function AgentFlowModal({
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
return () => {
|
return () => {
|
||||||
providerModelRequestTimersRef.current.forEach((timer) => {
|
|
||||||
clearTimeout(timer);
|
|
||||||
});
|
|
||||||
providerModelRequestTimersRef.current.clear();
|
|
||||||
idleProviderPrefetchHandleRef.current?.cancel?.();
|
idleProviderPrefetchHandleRef.current?.cancel?.();
|
||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
@@ -1486,14 +1204,6 @@ function AgentFlowModal({
|
|||||||
}, [isCreateFlow, isVisible, workingDir]);
|
}, [isCreateFlow, isVisible, workingDir]);
|
||||||
|
|
||||||
useEffect(() => {
|
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?.cancel?.();
|
||||||
idleProviderPrefetchHandleRef.current = InteractionManager.runAfterInteractions(() => {
|
idleProviderPrefetchHandleRef.current = InteractionManager.runAfterInteractions(() => {
|
||||||
daemonEntries.forEach(({ daemon, status }) => {
|
daemonEntries.forEach(({ daemon, status }) => {
|
||||||
@@ -1996,7 +1706,6 @@ function AgentFlowModal({
|
|||||||
worktreeSlug,
|
worktreeSlug,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const workingDirIsEmpty = !workingDir.trim();
|
|
||||||
const promptIsEmpty = !initialPrompt.trim();
|
const promptIsEmpty = !initialPrompt.trim();
|
||||||
const createDisabled =
|
const createDisabled =
|
||||||
workingDirIsEmpty ||
|
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 {
|
interface PromptSectionProps {
|
||||||
value: string;
|
value: string;
|
||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import { useRealtime } from "@/contexts/realtime-context";
|
|||||||
import { useDaemonConnections } from "@/contexts/daemon-connections-context";
|
import { useDaemonConnections } from "@/contexts/daemon-connections-context";
|
||||||
import { FOOTER_HEIGHT } from "@/constants/layout";
|
import { FOOTER_HEIGHT } from "@/constants/layout";
|
||||||
import { RealtimeControls } from "./realtime-controls";
|
import { RealtimeControls } from "./realtime-controls";
|
||||||
import { CreateAgentModal, ImportAgentModal } from "./create-agent-modal";
|
import { ImportAgentModal } from "./create-agent-modal";
|
||||||
import Animated, {
|
import Animated, {
|
||||||
FadeIn,
|
FadeIn,
|
||||||
FadeOut,
|
FadeOut,
|
||||||
@@ -22,7 +22,6 @@ export function HomeFooter() {
|
|||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { isRealtimeMode, startRealtime } = useRealtime();
|
const { isRealtimeMode, startRealtime } = useRealtime();
|
||||||
const { connectionStates } = useDaemonConnections();
|
const { connectionStates } = useDaemonConnections();
|
||||||
const [showCreateModal, setShowCreateModal] = useState(false);
|
|
||||||
const [showImportModal, setShowImportModal] = useState(false);
|
const [showImportModal, setShowImportModal] = useState(false);
|
||||||
const [showRealtimeHostPicker, setShowRealtimeHostPicker] = useState(false);
|
const [showRealtimeHostPicker, setShowRealtimeHostPicker] = useState(false);
|
||||||
// Guard Reanimated entry/exit transitions on Android to avoid ViewGroup.dispatchDraw crashes
|
// Guard Reanimated entry/exit transitions on Android to avoid ViewGroup.dispatchDraw crashes
|
||||||
@@ -164,7 +163,7 @@ export function HomeFooter() {
|
|||||||
<Pressable
|
<Pressable
|
||||||
onPress={() => {
|
onPress={() => {
|
||||||
console.log("[HomeFooter] New Agent button pressed");
|
console.log("[HomeFooter] New Agent button pressed");
|
||||||
setShowCreateModal(true);
|
router.push("/agent/new");
|
||||||
}}
|
}}
|
||||||
style={({ pressed }) => [
|
style={({ pressed }) => [
|
||||||
styles.footerButton,
|
styles.footerButton,
|
||||||
@@ -203,10 +202,6 @@ export function HomeFooter() {
|
|||||||
</View>
|
</View>
|
||||||
</Animated.View>
|
</Animated.View>
|
||||||
|
|
||||||
<CreateAgentModal
|
|
||||||
isVisible={showCreateModal}
|
|
||||||
onClose={() => setShowCreateModal(false)}
|
|
||||||
/>
|
|
||||||
<ImportAgentModal
|
<ImportAgentModal
|
||||||
isVisible={showImportModal}
|
isVisible={showImportModal}
|
||||||
onClose={() => setShowImportModal(false)}
|
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(
|
test(
|
||||||
"emits an error timeline item for failed MCP turns",
|
"does not emit error timeline items for non-zero command exits",
|
||||||
async () => {
|
async () => {
|
||||||
const cwd = tmpCwd();
|
const cwd = tmpCwd();
|
||||||
const restoreSessionDir = useTempCodexSessionDir();
|
const restoreSessionDir = useTempCodexSessionDir();
|
||||||
@@ -940,7 +940,8 @@ describe("CodexMcpAgentClient (MCP integration)", () => {
|
|||||||
|
|
||||||
let session: AgentSession | null = null;
|
let session: AgentSession | null = null;
|
||||||
let sawErrorTimeline = false;
|
let sawErrorTimeline = false;
|
||||||
const errorEvents: AgentStreamEvent[] = [];
|
let sawTurnFailed = false;
|
||||||
|
let sawTurnCompleted = false;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
session = await client.createSession(config);
|
session = await client.createSession(config);
|
||||||
@@ -958,15 +959,19 @@ describe("CodexMcpAgentClient (MCP integration)", () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (event.type === "turn_failed") {
|
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") {
|
if (event.type === "turn_completed" || event.type === "turn_failed") {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
expect(sawErrorTimeline).toBe(true);
|
expect(sawErrorTimeline).toBe(false);
|
||||||
expect(errorEvents.length).toBeGreaterThan(0);
|
expect(sawTurnFailed).toBe(false);
|
||||||
|
expect(sawTurnCompleted).toBe(true);
|
||||||
} finally {
|
} finally {
|
||||||
await session?.close();
|
await session?.close();
|
||||||
rmSync(cwd, { recursive: true, force: true });
|
rmSync(cwd, { recursive: true, force: true });
|
||||||
|
|||||||
@@ -2348,6 +2348,7 @@ type CodexMcpServerConfig = {
|
|||||||
command?: string;
|
command?: string;
|
||||||
args?: string[];
|
args?: string[];
|
||||||
env?: Record<string, string>;
|
env?: Record<string, string>;
|
||||||
|
tool_timeout_sec?: number; // Override the default 60s per-tool timeout
|
||||||
};
|
};
|
||||||
|
|
||||||
type CodexConfigPayload = {
|
type CodexConfigPayload = {
|
||||||
@@ -2400,6 +2401,7 @@ function buildCodexMcpConfig(
|
|||||||
}
|
}
|
||||||
mcpServers["agent-control"] = {
|
mcpServers["agent-control"] = {
|
||||||
url: agentControlUrl,
|
url: agentControlUrl,
|
||||||
|
tool_timeout_sec: 600, // 10 min timeout for child agents
|
||||||
...(config.agentControlMcp.headers ? { http_headers: config.agentControlMcp.headers } : {}),
|
...(config.agentControlMcp.headers ? { http_headers: config.agentControlMcp.headers } : {}),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -2627,11 +2629,18 @@ class CodexMcpAgentSession implements AgentSession {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private async loadPersistedHistoryFromDisk(): Promise<void> {
|
private async loadPersistedHistoryFromDisk(): Promise<void> {
|
||||||
if (!this.sessionId) {
|
const historyId = this.sessionId ?? this.conversationId;
|
||||||
|
if (!historyId) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
const metadata =
|
||||||
const timeline = await loadCodexPersistedTimeline(this.sessionId);
|
this.resumeHandle && this.resumeHandle.metadata
|
||||||
|
? this.resumeHandle.metadata
|
||||||
|
: undefined;
|
||||||
|
const timeline = await loadCodexPersistedTimeline(historyId, {
|
||||||
|
rolloutPath: resolveCodexRolloutPath(metadata),
|
||||||
|
sessionRoot: resolveCodexSessionRootFromMetadata(metadata),
|
||||||
|
});
|
||||||
if (timeline.length > 0) {
|
if (timeline.length > 0) {
|
||||||
this.persistedHistory = timeline;
|
this.persistedHistory = timeline;
|
||||||
this.historyPending = true;
|
this.historyPending = true;
|
||||||
@@ -2884,20 +2893,23 @@ class CodexMcpAgentSession implements AgentSession {
|
|||||||
this.updatePersistenceConversationId();
|
this.updatePersistenceConversationId();
|
||||||
return this.persistence;
|
return this.persistence;
|
||||||
}
|
}
|
||||||
if (!this.sessionId) {
|
const persistenceId = this.sessionId ?? this.conversationId;
|
||||||
|
if (!persistenceId) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
const { model: _ignoredModel, ...restConfig } = this.config;
|
const { model: _ignoredModel, ...restConfig } = this.config;
|
||||||
const conversationId = this.conversationId
|
const conversationId = this.conversationId
|
||||||
? this.conversationId
|
? this.conversationId
|
||||||
: this.sessionId;
|
: persistenceId;
|
||||||
|
const codexSessionDir = resolveCodexSessionRoot();
|
||||||
this.persistence = {
|
this.persistence = {
|
||||||
provider: CODEX_PROVIDER,
|
provider: CODEX_PROVIDER,
|
||||||
sessionId: this.sessionId,
|
sessionId: persistenceId,
|
||||||
nativeHandle: this.sessionId,
|
nativeHandle: persistenceId,
|
||||||
metadata: {
|
metadata: {
|
||||||
...restConfig,
|
...restConfig,
|
||||||
conversationId,
|
conversationId,
|
||||||
|
...(codexSessionDir ? { codexSessionDir } : {}),
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
this.updatePersistenceConversationId();
|
this.updatePersistenceConversationId();
|
||||||
@@ -4169,16 +4181,45 @@ async function parseRolloutFile(
|
|||||||
return timeline;
|
return timeline;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type CodexPersistedTimelineOptions = {
|
||||||
|
sessionRoot?: string | null;
|
||||||
|
rolloutPath?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
async function loadCodexPersistedTimeline(
|
async function loadCodexPersistedTimeline(
|
||||||
sessionId: string
|
sessionId: string,
|
||||||
|
options?: CodexPersistedTimelineOptions
|
||||||
): Promise<AgentTimelineItem[]> {
|
): Promise<AgentTimelineItem[]> {
|
||||||
const sessionRoot = resolveCodexSessionRoot();
|
const rolloutPath = options?.rolloutPath ?? null;
|
||||||
if (!sessionRoot) {
|
if (rolloutPath) {
|
||||||
return [];
|
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 {
|
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) {
|
if (!rolloutFile) {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
@@ -4193,3 +4234,35 @@ async function loadCodexPersistedTimeline(
|
|||||||
return [];
|
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):
|
**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
|
- 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