wip: uncommitted changes
🤖 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
4185db9c75
commit
e8890a2fc1
@@ -4,9 +4,8 @@ import {
|
|||||||
useEffect,
|
useEffect,
|
||||||
useMemo,
|
useMemo,
|
||||||
useCallback,
|
useCallback,
|
||||||
useLayoutEffect,
|
|
||||||
} from "react";
|
} from "react";
|
||||||
import type { ReactElement, RefObject, ReactNode } from "react";
|
import type { ReactElement, ReactNode } from "react";
|
||||||
import {
|
import {
|
||||||
View,
|
View,
|
||||||
Text,
|
Text,
|
||||||
@@ -21,9 +20,6 @@ import {
|
|||||||
type LayoutChangeEvent,
|
type LayoutChangeEvent,
|
||||||
type ListRenderItem,
|
type ListRenderItem,
|
||||||
Platform,
|
Platform,
|
||||||
NativeSyntheticEvent,
|
|
||||||
TextInputContentSizeChangeEventData,
|
|
||||||
TextInputKeyPressEventData,
|
|
||||||
} from "react-native";
|
} from "react-native";
|
||||||
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";
|
||||||
@@ -35,17 +31,12 @@ 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, RefreshCcw, Monitor } from "lucide-react-native";
|
import { Monitor, X } 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";
|
||||||
import { generateMessageId } from "@/types/stream";
|
import { generateMessageId } from "@/types/stream";
|
||||||
import { useDictation } from "@/hooks/use-dictation";
|
import { MessageInput } from "./message-input";
|
||||||
import type { DictationStatus } from "@/hooks/use-dictation";
|
|
||||||
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 { useDaemonConnections, type ConnectionStatus } from "@/contexts/daemon-connections-context";
|
import { useDaemonConnections, type ConnectionStatus } from "@/contexts/daemon-connections-context";
|
||||||
import type {
|
import type {
|
||||||
AgentProvider,
|
AgentProvider,
|
||||||
@@ -59,7 +50,7 @@ import { formatConnectionStatus } from "@/utils/daemons";
|
|||||||
import { trackAnalyticsEvent } from "@/utils/analytics";
|
import { trackAnalyticsEvent } from "@/utils/analytics";
|
||||||
import type { SessionContextValue } from "@/contexts/session-context";
|
import type { SessionContextValue } from "@/contexts/session-context";
|
||||||
import type { UseWebSocketReturn } from "@/hooks/use-websocket";
|
import type { UseWebSocketReturn } from "@/hooks/use-websocket";
|
||||||
import { useSessionStore } from "@/stores/session-store";
|
import { useSessionStore, type Agent } from "@/stores/session-store";
|
||||||
import {
|
import {
|
||||||
AssistantDropdown,
|
AssistantDropdown,
|
||||||
DropdownField,
|
DropdownField,
|
||||||
@@ -82,16 +73,6 @@ interface AgentFlowModalProps {
|
|||||||
onAfterClose?: () => void;
|
onAfterClose?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
type DictationToastConfig = {
|
|
||||||
variant: DictationToastVariant;
|
|
||||||
title: string;
|
|
||||||
subtitle?: string;
|
|
||||||
meta?: string;
|
|
||||||
actionLabel?: string;
|
|
||||||
onAction?: () => void;
|
|
||||||
onDismiss?: () => void;
|
|
||||||
};
|
|
||||||
|
|
||||||
interface ModalWrapperProps {
|
interface ModalWrapperProps {
|
||||||
isVisible: boolean;
|
isVisible: boolean;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
@@ -111,52 +92,17 @@ type CreateAgentSessionSlice = {
|
|||||||
}) => void;
|
}) => void;
|
||||||
resumeAgent: (options: { handle: any; overrides?: any; requestId?: string }) => void;
|
resumeAgent: (options: { handle: any; overrides?: any; requestId?: string }) => void;
|
||||||
sendAgentAudio: (
|
sendAgentAudio: (
|
||||||
agentId: string,
|
agentId: string | undefined,
|
||||||
audioBlob: Blob,
|
audioBlob: Blob,
|
||||||
requestId?: string,
|
requestId?: string,
|
||||||
options?: { mode?: "transcribe_only" | "auto_run" }
|
options?: { mode?: "transcribe_only" | "auto_run" }
|
||||||
) => Promise<void>;
|
) => Promise<void>;
|
||||||
agents: Map<string, any>;
|
agents: Map<string, Agent>;
|
||||||
};
|
};
|
||||||
|
|
||||||
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_MAX_HEIGHT = 200;
|
|
||||||
const IS_WEB = Platform.OS === "web";
|
const IS_WEB = Platform.OS === "web";
|
||||||
const DICTATION_AGENT_ID = "__dictation__";
|
|
||||||
|
|
||||||
type WebTextInputKeyPressEvent = NativeSyntheticEvent<
|
|
||||||
TextInputKeyPressEventData & {
|
|
||||||
metaKey?: boolean;
|
|
||||||
ctrlKey?: boolean;
|
|
||||||
shiftKey?: boolean;
|
|
||||||
}
|
|
||||||
>;
|
|
||||||
|
|
||||||
type TextAreaHandle = {
|
|
||||||
scrollHeight?: number;
|
|
||||||
style?: {
|
|
||||||
height?: string;
|
|
||||||
minHeight?: string;
|
|
||||||
maxHeight?: string;
|
|
||||||
overflowY?: string;
|
|
||||||
} & Record<string, unknown>;
|
|
||||||
};
|
|
||||||
|
|
||||||
const isTextAreaLike = (node: unknown): node is TextAreaHandle => {
|
|
||||||
if (!node || typeof node !== "object") {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
if (!("scrollHeight" in node) || !("style" in node)) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
const style = (node as { style?: unknown }).style;
|
|
||||||
if (!style || typeof style !== "object") {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
};
|
|
||||||
|
|
||||||
type ImportCandidate = {
|
type ImportCandidate = {
|
||||||
provider: AgentProvider;
|
provider: AgentProvider;
|
||||||
@@ -237,7 +183,6 @@ function AgentFlowModal({
|
|||||||
const backdropOpacity = useSharedValue(0);
|
const backdropOpacity = useSharedValue(0);
|
||||||
const { height: keyboardHeight } = useReanimatedKeyboardAnimation();
|
const { height: keyboardHeight } = useReanimatedKeyboardAnimation();
|
||||||
const isCompactLayout = screenWidth < 720;
|
const isCompactLayout = screenWidth < 720;
|
||||||
const shouldHandlePromptDesktopSubmit = IS_WEB;
|
|
||||||
const shouldAutoFocusPrompt = IS_WEB;
|
const shouldAutoFocusPrompt = IS_WEB;
|
||||||
const isImportFlow = flow === "import";
|
const isImportFlow = flow === "import";
|
||||||
const isCreateFlow = !isImportFlow;
|
const isCreateFlow = !isImportFlow;
|
||||||
@@ -451,7 +396,6 @@ function AgentFlowModal({
|
|||||||
|
|
||||||
const [isMounted, setIsMounted] = useState(isVisible);
|
const [isMounted, setIsMounted] = useState(isVisible);
|
||||||
const [initialPrompt, setInitialPrompt] = useState("");
|
const [initialPrompt, setInitialPrompt] = useState("");
|
||||||
const [promptInputHeight, setPromptInputHeight] = useState(PROMPT_MIN_HEIGHT);
|
|
||||||
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("");
|
||||||
@@ -469,103 +413,12 @@ function AgentFlowModal({
|
|||||||
);
|
);
|
||||||
const [isImportLoading, setIsImportLoading] = useState(false);
|
const [isImportLoading, setIsImportLoading] = useState(false);
|
||||||
const [importError, setImportError] = useState<string | null>(null);
|
const [importError, setImportError] = useState<string | null>(null);
|
||||||
const [dictationDebugInfo, setDictationDebugInfo] = useState<AudioDebugInfo | null>(null);
|
|
||||||
const [openDropdown, setOpenDropdown] = useState<DropdownKey | null>(null);
|
const [openDropdown, setOpenDropdown] = useState<DropdownKey | null>(null);
|
||||||
const pendingRequestIdRef = useRef<string | null>(null);
|
const pendingRequestIdRef = useRef<string | null>(null);
|
||||||
const shouldSyncBaseBranchRef = useRef(true);
|
const shouldSyncBaseBranchRef = useRef(true);
|
||||||
const promptInputRef = useRef<
|
|
||||||
TextInput | (TextInput & { getNativeRef?: () => unknown }) | null
|
|
||||||
>(null);
|
|
||||||
const focusPromptInputRef = useRef<() => void>(() => {});
|
|
||||||
const promptInputHeightRef = useRef(PROMPT_MIN_HEIGHT);
|
|
||||||
const promptBaselineHeightRef = useRef<number | null>(null);
|
|
||||||
const dictationRequestIdRef = useRef<string | null>(null);
|
|
||||||
|
|
||||||
const handleDictationTranscript = useCallback(
|
|
||||||
(text: string, _meta: { requestId: string }) => {
|
|
||||||
if (!isCreateFlow || !text) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setInitialPrompt((prev) => {
|
|
||||||
if (!prev) {
|
|
||||||
return text;
|
|
||||||
}
|
|
||||||
const needsSpace = /\s$/.test(prev);
|
|
||||||
return `${prev}${needsSpace ? "" : " "}${text}`;
|
|
||||||
});
|
|
||||||
focusPromptInputRef.current?.();
|
|
||||||
},
|
|
||||||
[isCreateFlow, setInitialPrompt]
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleDictationError = useCallback(
|
|
||||||
(dictationError: Error) => {
|
|
||||||
setErrorMessage(dictationError.message);
|
|
||||||
},
|
|
||||||
[]
|
|
||||||
);
|
|
||||||
|
|
||||||
const canStartDictation = useCallback(() => {
|
|
||||||
const allowed = isCreateFlow && !isLoading && isTargetDaemonReady && isWsConnected;
|
|
||||||
console.log("[CreateAgentModal] canStartDictation", {
|
|
||||||
allowed,
|
|
||||||
isCreateFlow,
|
|
||||||
isLoading,
|
|
||||||
isTargetDaemonReady,
|
|
||||||
isWsConnected,
|
|
||||||
});
|
|
||||||
return allowed;
|
|
||||||
}, [isCreateFlow, isLoading, isTargetDaemonReady, isWsConnected]);
|
|
||||||
|
|
||||||
const canConfirmDictation = useCallback(() => {
|
|
||||||
const allowed = isTargetDaemonReady && hasSendAgentAudio;
|
|
||||||
console.log("[CreateAgentModal] canConfirmDictation", {
|
|
||||||
allowed,
|
|
||||||
isTargetDaemonReady,
|
|
||||||
hasSendAgentAudio,
|
|
||||||
});
|
|
||||||
return allowed;
|
|
||||||
}, [hasSendAgentAudio, isTargetDaemonReady]);
|
|
||||||
|
|
||||||
const {
|
|
||||||
isRecording: isDictating,
|
|
||||||
isProcessing: isDictationProcessing,
|
|
||||||
volume: dictationVolume,
|
|
||||||
pendingRequestId: dictationPendingRequestId,
|
|
||||||
error: dictationError,
|
|
||||||
status: dictationStatus,
|
|
||||||
retryAttempt: dictationRetryAttempt,
|
|
||||||
maxRetryAttempts: dictationMaxRetryAttempts,
|
|
||||||
retryInfo: dictationRetryInfo,
|
|
||||||
failedRecording: dictationFailedRecording,
|
|
||||||
startDictation,
|
|
||||||
cancelDictation,
|
|
||||||
confirmDictation,
|
|
||||||
reset: resetDictation,
|
|
||||||
retryFailedDictation,
|
|
||||||
discardFailedDictation,
|
|
||||||
} = useDictation({
|
|
||||||
agentId: DICTATION_AGENT_ID,
|
|
||||||
sendAgentAudio,
|
|
||||||
ws: effectiveWs,
|
|
||||||
mode: "transcribe_only",
|
|
||||||
onTranscript: handleDictationTranscript,
|
|
||||||
onError: handleDictationError,
|
|
||||||
canStart: canStartDictation,
|
|
||||||
canConfirm: canConfirmDictation,
|
|
||||||
autoStopWhenHidden: { isVisible },
|
|
||||||
});
|
|
||||||
const shouldShowAudioDebug = AUDIO_DEBUG_ENABLED;
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
dictationRequestIdRef.current = dictationPendingRequestId;
|
|
||||||
}, [dictationPendingRequestId]);
|
|
||||||
|
|
||||||
const hasPendingCreateOrResume = pendingRequestIdRef.current !== null;
|
const hasPendingCreateOrResume = pendingRequestIdRef.current !== null;
|
||||||
const hasPendingDictation = dictationPendingRequestId !== null;
|
|
||||||
const shouldListenForStatus = isVisible || hasPendingCreateOrResume;
|
const shouldListenForStatus = isVisible || hasPendingCreateOrResume;
|
||||||
const shouldListenForDictation =
|
|
||||||
isCreateFlow && (isVisible || hasPendingDictation);
|
|
||||||
|
|
||||||
const idleProviderPrefetchHandleRef = useRef<ReturnType<
|
const idleProviderPrefetchHandleRef = useRef<ReturnType<
|
||||||
typeof InteractionManager.runAfterInteractions
|
typeof InteractionManager.runAfterInteractions
|
||||||
@@ -612,131 +465,15 @@ function AgentFlowModal({
|
|||||||
providerDefinitionMap.get(provider)?.label ?? provider,
|
providerDefinitionMap.get(provider)?.label ?? provider,
|
||||||
[]
|
[]
|
||||||
);
|
);
|
||||||
const setPromptHeight = useCallback((nextHeight: number) => {
|
|
||||||
const bounded = Math.min(
|
|
||||||
PROMPT_MAX_HEIGHT,
|
|
||||||
Math.max(PROMPT_MIN_HEIGHT, nextHeight)
|
|
||||||
);
|
|
||||||
if (Math.abs(promptInputHeightRef.current - bounded) < 1) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
promptInputHeightRef.current = bounded;
|
|
||||||
setPromptInputHeight(bounded);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const applyPromptMeasuredHeight = useCallback(
|
|
||||||
(measuredHeight: number) => {
|
|
||||||
if (promptBaselineHeightRef.current === null) {
|
|
||||||
promptBaselineHeightRef.current = measuredHeight;
|
|
||||||
}
|
|
||||||
const baseline = promptBaselineHeightRef.current ?? measuredHeight;
|
|
||||||
const normalized = measuredHeight - baseline + PROMPT_MIN_HEIGHT;
|
|
||||||
const bounded = Math.min(
|
|
||||||
PROMPT_MAX_HEIGHT,
|
|
||||||
Math.max(PROMPT_MIN_HEIGHT, normalized)
|
|
||||||
);
|
|
||||||
setPromptHeight(bounded);
|
|
||||||
return bounded;
|
|
||||||
},
|
|
||||||
[setPromptHeight]
|
|
||||||
);
|
|
||||||
|
|
||||||
const getPromptWebTextArea = useCallback((): TextAreaHandle | null => {
|
|
||||||
if (!IS_WEB) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
const node = promptInputRef.current;
|
|
||||||
if (!node) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
if (isTextAreaLike(node)) {
|
|
||||||
return node;
|
|
||||||
}
|
|
||||||
if (
|
|
||||||
typeof (node as { getNativeRef?: () => unknown }).getNativeRef ===
|
|
||||||
"function"
|
|
||||||
) {
|
|
||||||
const native = (
|
|
||||||
node as { getNativeRef?: () => unknown }
|
|
||||||
).getNativeRef?.();
|
|
||||||
if (isTextAreaLike(native)) {
|
|
||||||
return native;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const measurePromptWebInputHeight = useCallback(() => {
|
|
||||||
if (!IS_WEB) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
const element = getPromptWebTextArea();
|
|
||||||
if (!element?.style || typeof element.scrollHeight !== "number") {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
const previousHeight = element.style.height;
|
|
||||||
element.style.height = "auto";
|
|
||||||
const measuredHeight = element.scrollHeight;
|
|
||||||
element.style.height = previousHeight ?? "";
|
|
||||||
const bounded = applyPromptMeasuredHeight(measuredHeight);
|
|
||||||
element.style.height = `${bounded}px`;
|
|
||||||
element.style.minHeight = `${PROMPT_MIN_HEIGHT}px`;
|
|
||||||
element.style.maxHeight = `${PROMPT_MAX_HEIGHT}px`;
|
|
||||||
element.style.overflowY = bounded >= PROMPT_MAX_HEIGHT ? "auto" : "hidden";
|
|
||||||
return true;
|
|
||||||
}, [applyPromptMeasuredHeight, getPromptWebTextArea]);
|
|
||||||
|
|
||||||
const handlePromptContentSizeChange = useCallback(
|
|
||||||
(event: NativeSyntheticEvent<TextInputContentSizeChangeEventData>) => {
|
|
||||||
if (IS_WEB && measurePromptWebInputHeight()) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
applyPromptMeasuredHeight(event.nativeEvent.contentSize.height);
|
|
||||||
},
|
|
||||||
[applyPromptMeasuredHeight, measurePromptWebInputHeight]
|
|
||||||
);
|
|
||||||
|
|
||||||
const focusPromptInput = useCallback(() => {
|
|
||||||
if (!shouldAutoFocusPrompt) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const node = promptInputRef.current;
|
|
||||||
if (!node) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const target:
|
|
||||||
| (TextInput & { focus?: () => void })
|
|
||||||
| { focus?: () => void }
|
|
||||||
| null =
|
|
||||||
typeof (node as { focus?: () => void }).focus === "function"
|
|
||||||
? node
|
|
||||||
: typeof (node as { getNativeRef?: () => unknown }).getNativeRef ===
|
|
||||||
"function"
|
|
||||||
? ((node as { getNativeRef?: () => unknown }).getNativeRef?.() as {
|
|
||||||
focus?: () => void;
|
|
||||||
} | null)
|
|
||||||
: null;
|
|
||||||
if (target && typeof target.focus === "function") {
|
|
||||||
const exec = () => target.focus?.();
|
|
||||||
if (typeof requestAnimationFrame === "function") {
|
|
||||||
requestAnimationFrame(exec);
|
|
||||||
} else {
|
|
||||||
setTimeout(exec, 0);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, [shouldAutoFocusPrompt]);
|
|
||||||
useEffect(() => {
|
|
||||||
focusPromptInputRef.current = focusPromptInput;
|
|
||||||
}, [focusPromptInput]);
|
|
||||||
const activeSessionIds = useMemo(() => {
|
const activeSessionIds = useMemo(() => {
|
||||||
const ids = new Set<string>();
|
const ids = new Set<string>();
|
||||||
if (!agents) {
|
if (!agents) {
|
||||||
return ids;
|
return ids;
|
||||||
}
|
}
|
||||||
agents.forEach((agent: any) => {
|
// Use persistence.sessionId for filtering - this is the canonical reference
|
||||||
if (agent.sessionId) {
|
// to the provider's session file (Claude resume token, Codex thread ID, etc.)
|
||||||
ids.add(agent.sessionId);
|
agents.forEach((agent) => {
|
||||||
}
|
|
||||||
const persistedSessionId = agent.persistence?.sessionId;
|
const persistedSessionId = agent.persistence?.sessionId;
|
||||||
if (persistedSessionId) {
|
if (persistedSessionId) {
|
||||||
ids.add(persistedSessionId);
|
ids.add(persistedSessionId);
|
||||||
@@ -769,23 +506,6 @@ function AgentFlowModal({
|
|||||||
importSearchQuery,
|
importSearchQuery,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
useLayoutEffect(() => {
|
|
||||||
if (!IS_WEB) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
measurePromptWebInputHeight();
|
|
||||||
}, [initialPrompt, measurePromptWebInputHeight]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!shouldAutoFocusPrompt) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!isVisible || !isCreateFlow) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
focusPromptInput();
|
|
||||||
}, [focusPromptInput, isCreateFlow, isVisible, shouldAutoFocusPrompt]);
|
|
||||||
|
|
||||||
const logOfflineDaemonAction = useCallback(
|
const logOfflineDaemonAction = useCallback(
|
||||||
(action: "create" | "resume" | "dictation" | "import_list", reason?: string | null) => {
|
(action: "create" | "resume" | "dictation" | "import_list", reason?: string | null) => {
|
||||||
trackAnalyticsEvent({
|
trackAnalyticsEvent({
|
||||||
@@ -805,9 +525,6 @@ function AgentFlowModal({
|
|||||||
|
|
||||||
const resetFormState = useCallback(() => {
|
const resetFormState = useCallback(() => {
|
||||||
setInitialPrompt("");
|
setInitialPrompt("");
|
||||||
promptBaselineHeightRef.current = null;
|
|
||||||
promptInputHeightRef.current = PROMPT_MIN_HEIGHT;
|
|
||||||
setPromptHeight(PROMPT_MIN_HEIGHT);
|
|
||||||
setBaseBranch("");
|
setBaseBranch("");
|
||||||
setCreateNewBranch(false);
|
setCreateNewBranch(false);
|
||||||
setBranchName("");
|
setBranchName("");
|
||||||
@@ -822,160 +539,8 @@ function AgentFlowModal({
|
|||||||
pendingRequestIdRef.current = null;
|
pendingRequestIdRef.current = null;
|
||||||
pendingNavigationServerIdRef.current = null;
|
pendingNavigationServerIdRef.current = null;
|
||||||
shouldSyncBaseBranchRef.current = true;
|
shouldSyncBaseBranchRef.current = true;
|
||||||
dictationRequestIdRef.current = null;
|
|
||||||
setDictationDebugInfo(null);
|
|
||||||
void cancelDictation();
|
|
||||||
resetDictation();
|
|
||||||
cancelRepoInfo();
|
cancelRepoInfo();
|
||||||
}, [cancelRepoInfo, cancelDictation, resetDictation, resetRepoInfo, setPromptHeight]);
|
}, [cancelRepoInfo, resetRepoInfo]);
|
||||||
|
|
||||||
const handleDictationStart = useCallback(async () => {
|
|
||||||
console.log("[CreateAgentModal] handleDictationStart", {
|
|
||||||
isCreateFlow,
|
|
||||||
isLoading,
|
|
||||||
isDictating,
|
|
||||||
isDictationProcessing,
|
|
||||||
isTargetDaemonReady,
|
|
||||||
isWsConnected,
|
|
||||||
});
|
|
||||||
if (!isCreateFlow || isLoading || isDictating || isDictationProcessing) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!isTargetDaemonReady || !isWsConnected) {
|
|
||||||
logOfflineDaemonAction(
|
|
||||||
"dictation",
|
|
||||||
daemonAvailabilityError ?? "WebSocket disconnected"
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
if (shouldShowAudioDebug) {
|
|
||||||
setDictationDebugInfo(null);
|
|
||||||
}
|
|
||||||
setErrorMessage("");
|
|
||||||
await startDictation();
|
|
||||||
console.log("[CreateAgentModal] startDictation invoked");
|
|
||||||
} catch (error) {
|
|
||||||
const isCancelled = error instanceof Error && error.message.includes("Recording cancelled");
|
|
||||||
if (!isCancelled) {
|
|
||||||
console.error("[CreateAgentModal] Failed to start dictation:", error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, [
|
|
||||||
daemonAvailabilityError,
|
|
||||||
isCreateFlow,
|
|
||||||
isDictating,
|
|
||||||
isDictationProcessing,
|
|
||||||
isLoading,
|
|
||||||
isTargetDaemonReady,
|
|
||||||
isWsConnected,
|
|
||||||
logOfflineDaemonAction,
|
|
||||||
shouldShowAudioDebug,
|
|
||||||
startDictation,
|
|
||||||
]);
|
|
||||||
|
|
||||||
const handleDictationCancel = useCallback(async () => {
|
|
||||||
console.log("[CreateAgentModal] handleDictationCancel", {
|
|
||||||
isDictating,
|
|
||||||
});
|
|
||||||
if (dictationStatus === "failed") {
|
|
||||||
discardFailedDictation();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!isDictating) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
await cancelDictation();
|
|
||||||
} catch (error) {
|
|
||||||
console.error("[CreateAgentModal] Failed to cancel dictation:", error);
|
|
||||||
}
|
|
||||||
}, [cancelDictation, dictationStatus, discardFailedDictation, isDictating]);
|
|
||||||
|
|
||||||
const handleDictationConfirm = useCallback(async () => {
|
|
||||||
console.log("[CreateAgentModal] handleDictationConfirm", {
|
|
||||||
isDictating,
|
|
||||||
isDictationProcessing,
|
|
||||||
isTargetDaemonReady,
|
|
||||||
hasSendAgentAudio,
|
|
||||||
});
|
|
||||||
if (dictationStatus === "failed") {
|
|
||||||
void retryFailedDictation();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!isDictating || isDictationProcessing) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!isTargetDaemonReady || !hasSendAgentAudio) {
|
|
||||||
logOfflineDaemonAction("dictation");
|
|
||||||
setErrorMessage(
|
|
||||||
daemonAvailabilityError ??
|
|
||||||
"Dictation is unavailable until the selected host is online. Paseo reconnects automatically—try again once it comes back."
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
await confirmDictation();
|
|
||||||
} catch (error) {
|
|
||||||
console.error("[CreateAgentModal] Failed to complete dictation:", error);
|
|
||||||
}
|
|
||||||
}, [
|
|
||||||
daemonAvailabilityError,
|
|
||||||
confirmDictation,
|
|
||||||
dictationStatus,
|
|
||||||
hasSendAgentAudio,
|
|
||||||
isDictating,
|
|
||||||
isDictationProcessing,
|
|
||||||
isTargetDaemonReady,
|
|
||||||
logOfflineDaemonAction,
|
|
||||||
retryFailedDictation,
|
|
||||||
]);
|
|
||||||
|
|
||||||
const handlePromptDictationRetry = useCallback(() => {
|
|
||||||
void retryFailedDictation();
|
|
||||||
}, [retryFailedDictation]);
|
|
||||||
|
|
||||||
const handlePromptDictationDiscard = useCallback(() => {
|
|
||||||
discardFailedDictation();
|
|
||||||
}, [discardFailedDictation]);
|
|
||||||
|
|
||||||
const promptDictationToast = useMemo<DictationToastConfig | null>(() => {
|
|
||||||
if (dictationStatus === "retrying") {
|
|
||||||
const attempt = dictationRetryInfo?.attempt ?? Math.max(1, dictationRetryAttempt || 1);
|
|
||||||
const maxAttempts = dictationRetryInfo?.maxAttempts ?? dictationMaxRetryAttempts;
|
|
||||||
const retryMeta =
|
|
||||||
dictationRetryInfo?.nextRetryMs && dictationRetryInfo.nextRetryMs > 0
|
|
||||||
? `Attempt ${attempt}/${maxAttempts} · Next in ${Math.ceil(dictationRetryInfo.nextRetryMs / 1000)}s`
|
|
||||||
: `Attempt ${attempt}/${maxAttempts}`;
|
|
||||||
return {
|
|
||||||
variant: "warning",
|
|
||||||
title: "Retrying dictation…",
|
|
||||||
subtitle: dictationRetryInfo?.errorMessage ?? dictationError ?? "Network error",
|
|
||||||
meta: retryMeta,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
if (dictationStatus === "failed") {
|
|
||||||
return {
|
|
||||||
variant: "error",
|
|
||||||
title: "Dictation failed",
|
|
||||||
subtitle: dictationRetryInfo?.errorMessage ?? dictationError ?? "Unknown error",
|
|
||||||
actionLabel: "Retry",
|
|
||||||
onAction: handlePromptDictationRetry,
|
|
||||||
onDismiss: handlePromptDictationDiscard,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}, [
|
|
||||||
dictationError,
|
|
||||||
dictationMaxRetryAttempts,
|
|
||||||
dictationRetryAttempt,
|
|
||||||
dictationRetryInfo,
|
|
||||||
dictationStatus,
|
|
||||||
handlePromptDictationDiscard,
|
|
||||||
handlePromptDictationRetry,
|
|
||||||
]);
|
|
||||||
|
|
||||||
const navigateToAgentIfNeeded = useCallback(() => {
|
const navigateToAgentIfNeeded = useCallback(() => {
|
||||||
const agentId = pendingNavigationAgentIdRef.current;
|
const agentId = pendingNavigationAgentIdRef.current;
|
||||||
@@ -1591,36 +1156,6 @@ function AgentFlowModal({
|
|||||||
};
|
};
|
||||||
}, [handleClose, shouldListenForStatus, ws]);
|
}, [handleClose, shouldListenForStatus, ws]);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!shouldListenForDictation || !ws || !shouldShowAudioDebug) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const unsubscribe = ws.on("transcription_result", (message) => {
|
|
||||||
if (message.type !== "transcription_result") {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const pendingId = dictationRequestIdRef.current;
|
|
||||||
if (!pendingId || message.payload.requestId !== pendingId) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
dictationRequestIdRef.current = null;
|
|
||||||
setDictationDebugInfo({
|
|
||||||
requestId: pendingId,
|
|
||||||
transcript: message.payload.text?.trim(),
|
|
||||||
debugRecordingPath: message.payload.debugRecordingPath ?? undefined,
|
|
||||||
format: message.payload.format,
|
|
||||||
byteLength: message.payload.byteLength,
|
|
||||||
duration: message.payload.duration,
|
|
||||||
avgLogprob: message.payload.avgLogprob,
|
|
||||||
isLowConfidence: message.payload.isLowConfidence,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
unsubscribe();
|
|
||||||
};
|
|
||||||
}, [shouldListenForDictation, shouldShowAudioDebug, ws]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isVisible || !isImportFlow) {
|
if (!isVisible || !isImportFlow) {
|
||||||
return;
|
return;
|
||||||
@@ -1638,24 +1173,6 @@ function AgentFlowModal({
|
|||||||
|
|
||||||
const shouldRender = isVisible || isMounted;
|
const shouldRender = isVisible || isMounted;
|
||||||
const modalTitle = isImportFlow ? "Import Agent" : "Create New Agent";
|
const modalTitle = isImportFlow ? "Import Agent" : "Create New Agent";
|
||||||
const dictationAccessory = isCreateFlow ? (
|
|
||||||
<PromptDictationControls
|
|
||||||
isRecording={isDictating}
|
|
||||||
isProcessing={isDictationProcessing}
|
|
||||||
disabled={isLoading || !isWsConnected || !isTargetDaemonReady}
|
|
||||||
volume={dictationVolume}
|
|
||||||
onStart={handleDictationStart}
|
|
||||||
onCancel={handleDictationCancel}
|
|
||||||
onConfirm={handleDictationConfirm}
|
|
||||||
status={dictationStatus}
|
|
||||||
retryAttempt={dictationRetryAttempt}
|
|
||||||
maxRetryAttempts={dictationMaxRetryAttempts}
|
|
||||||
retryCountdownMs={dictationRetryInfo?.nextRetryMs}
|
|
||||||
errorMessage={dictationRetryInfo?.errorMessage ?? dictationError ?? undefined}
|
|
||||||
onRetry={handlePromptDictationRetry}
|
|
||||||
onDiscard={handlePromptDictationDiscard}
|
|
||||||
/>
|
|
||||||
) : null;
|
|
||||||
|
|
||||||
const gitBlockingError = useMemo(() => {
|
const gitBlockingError = useMemo(() => {
|
||||||
if (isNonGitDirectory) {
|
if (isNonGitDirectory) {
|
||||||
@@ -1720,26 +1237,6 @@ function AgentFlowModal({
|
|||||||
Boolean(gitBlockingError) ||
|
Boolean(gitBlockingError) ||
|
||||||
isLoading ||
|
isLoading ||
|
||||||
!isTargetDaemonReady;
|
!isTargetDaemonReady;
|
||||||
const handlePromptDesktopSubmitKeyPress = useCallback(
|
|
||||||
(event: WebTextInputKeyPressEvent) => {
|
|
||||||
if (!shouldHandlePromptDesktopSubmit) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (event.nativeEvent.key !== "Enter") {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const { shiftKey, metaKey, ctrlKey } = event.nativeEvent;
|
|
||||||
if (shiftKey || metaKey || ctrlKey) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (createDisabled) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
event.preventDefault();
|
|
||||||
void handleCreate();
|
|
||||||
},
|
|
||||||
[createDisabled, handleCreate, shouldHandlePromptDesktopSubmit]
|
|
||||||
);
|
|
||||||
const headerPaddingTop = useMemo(
|
const headerPaddingTop = useMemo(
|
||||||
() => insets.top + defaultTheme.spacing[4],
|
() => insets.top + defaultTheme.spacing[4],
|
||||||
[insets.top]
|
[insets.top]
|
||||||
@@ -1875,29 +1372,25 @@ function AgentFlowModal({
|
|||||||
) : null}
|
) : null}
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
<PromptSection
|
<View style={styles.formSection}>
|
||||||
value={initialPrompt}
|
<Text style={styles.label}>Initial Prompt</Text>
|
||||||
isLoading={isLoading}
|
<MessageInput
|
||||||
onChange={(text) => {
|
value={initialPrompt}
|
||||||
setInitialPrompt(text);
|
onChangeText={(text) => {
|
||||||
setErrorMessage("");
|
setInitialPrompt(text);
|
||||||
}}
|
setErrorMessage("");
|
||||||
inputRef={promptInputRef}
|
}}
|
||||||
inputHeight={promptInputHeight}
|
onSubmit={() => {
|
||||||
onContentSizeChange={handlePromptContentSizeChange}
|
void handleCreate();
|
||||||
onDesktopSubmit={handlePromptDesktopSubmitKeyPress}
|
}}
|
||||||
autoFocus={shouldAutoFocusPrompt}
|
ws={effectiveWs}
|
||||||
scrollEnabled={promptInputHeight >= PROMPT_MAX_HEIGHT}
|
sendAgentAudio={sendAgentAudio}
|
||||||
accessory={dictationAccessory}
|
placeholder="Describe what you want the agent to do"
|
||||||
/>
|
autoFocus={shouldAutoFocusPrompt}
|
||||||
|
disabled={isLoading || !isTargetDaemonReady}
|
||||||
{shouldShowAudioDebug && dictationDebugInfo ? (
|
isSubmitDisabled={createDisabled}
|
||||||
<AudioDebugNotice
|
|
||||||
info={dictationDebugInfo}
|
|
||||||
onDismiss={() => setDictationDebugInfo(null)}
|
|
||||||
title="Prompt Dictation Debug"
|
|
||||||
/>
|
/>
|
||||||
) : null}
|
</View>
|
||||||
|
|
||||||
<View
|
<View
|
||||||
style={[
|
style={[
|
||||||
@@ -2048,13 +1541,6 @@ function AgentFlowModal({
|
|||||||
)}
|
)}
|
||||||
</Pressable>
|
</Pressable>
|
||||||
</Animated.View>
|
</Animated.View>
|
||||||
{promptDictationToast ? (
|
|
||||||
<View style={styles.dictationToastPortal} pointerEvents="box-none">
|
|
||||||
<View pointerEvents="auto">
|
|
||||||
<DictationStatusNotice {...promptDictationToast} />
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
) : null}
|
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<View
|
<View
|
||||||
@@ -2257,184 +1743,6 @@ function ModalHeader({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
interface PromptSectionProps {
|
|
||||||
value: string;
|
|
||||||
isLoading: boolean;
|
|
||||||
onChange: (value: string) => void;
|
|
||||||
inputRef: RefObject<
|
|
||||||
TextInput | (TextInput & { getNativeRef?: () => unknown }) | null
|
|
||||||
>;
|
|
||||||
inputHeight: number;
|
|
||||||
onContentSizeChange: (
|
|
||||||
event: NativeSyntheticEvent<TextInputContentSizeChangeEventData>
|
|
||||||
) => void;
|
|
||||||
onDesktopSubmit?: (event: WebTextInputKeyPressEvent) => void;
|
|
||||||
autoFocus?: boolean;
|
|
||||||
scrollEnabled: boolean;
|
|
||||||
accessory?: ReactNode;
|
|
||||||
}
|
|
||||||
|
|
||||||
function PromptSection({
|
|
||||||
value,
|
|
||||||
isLoading,
|
|
||||||
onChange,
|
|
||||||
inputRef,
|
|
||||||
inputHeight,
|
|
||||||
onContentSizeChange,
|
|
||||||
onDesktopSubmit,
|
|
||||||
autoFocus,
|
|
||||||
scrollEnabled,
|
|
||||||
accessory,
|
|
||||||
}: PromptSectionProps): ReactElement {
|
|
||||||
return (
|
|
||||||
<View style={styles.formSection}>
|
|
||||||
<View style={styles.labelRow}>
|
|
||||||
<Text style={styles.label}>Initial Prompt</Text>
|
|
||||||
{accessory}
|
|
||||||
</View>
|
|
||||||
<TextInput
|
|
||||||
ref={inputRef}
|
|
||||||
style={[
|
|
||||||
styles.input,
|
|
||||||
styles.promptInput,
|
|
||||||
isLoading && styles.inputDisabled,
|
|
||||||
{
|
|
||||||
height: inputHeight,
|
|
||||||
minHeight: PROMPT_MIN_HEIGHT,
|
|
||||||
maxHeight: PROMPT_MAX_HEIGHT,
|
|
||||||
},
|
|
||||||
]}
|
|
||||||
placeholder="Describe what you want the agent to do"
|
|
||||||
placeholderTextColor={defaultTheme.colors.mutedForeground}
|
|
||||||
value={value}
|
|
||||||
onChangeText={onChange}
|
|
||||||
autoCapitalize="sentences"
|
|
||||||
autoCorrect
|
|
||||||
multiline
|
|
||||||
scrollEnabled={scrollEnabled}
|
|
||||||
onContentSizeChange={onContentSizeChange}
|
|
||||||
editable={!isLoading}
|
|
||||||
autoFocus={autoFocus}
|
|
||||||
blurOnSubmit={false}
|
|
||||||
onKeyPress={onDesktopSubmit}
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
interface PromptDictationControlsProps {
|
|
||||||
isRecording: boolean;
|
|
||||||
isProcessing: boolean;
|
|
||||||
disabled: boolean;
|
|
||||||
volume: number;
|
|
||||||
onStart: () => void;
|
|
||||||
onCancel: () => void;
|
|
||||||
onConfirm: () => void;
|
|
||||||
status: DictationStatus;
|
|
||||||
retryAttempt: number;
|
|
||||||
maxRetryAttempts: number;
|
|
||||||
retryCountdownMs?: number | null;
|
|
||||||
errorMessage?: string | null;
|
|
||||||
onRetry?: () => void;
|
|
||||||
onDiscard?: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
function PromptDictationControls({
|
|
||||||
isRecording,
|
|
||||||
isProcessing,
|
|
||||||
disabled,
|
|
||||||
volume,
|
|
||||||
onStart,
|
|
||||||
onCancel,
|
|
||||||
onConfirm,
|
|
||||||
status,
|
|
||||||
retryAttempt,
|
|
||||||
maxRetryAttempts,
|
|
||||||
retryCountdownMs,
|
|
||||||
errorMessage,
|
|
||||||
onRetry,
|
|
||||||
onDiscard,
|
|
||||||
}: PromptDictationControlsProps): ReactElement {
|
|
||||||
const { theme } = useUnistyles();
|
|
||||||
|
|
||||||
const isRetrying = status === "retrying";
|
|
||||||
const isFailed = status === "failed";
|
|
||||||
const showActiveState = isRecording || isProcessing || isRetrying || isFailed;
|
|
||||||
const cancelHandler = isFailed ? onDiscard ?? onCancel : onCancel;
|
|
||||||
const confirmHandler = isFailed ? onRetry ?? onConfirm : onConfirm;
|
|
||||||
|
|
||||||
if (!showActiveState) {
|
|
||||||
return (
|
|
||||||
<Pressable
|
|
||||||
onPress={onStart}
|
|
||||||
disabled={disabled}
|
|
||||||
accessibilityRole="button"
|
|
||||||
accessibilityLabel="Start voice dictation"
|
|
||||||
style={[styles.dictationButton, disabled && styles.dictationButtonDisabled]}
|
|
||||||
>
|
|
||||||
<Mic size={16} color={theme.colors.foreground} />
|
|
||||||
</Pressable>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<View style={styles.dictationActiveContainer}>
|
|
||||||
<View style={styles.dictationMeterWrapper}>
|
|
||||||
<VolumeMeter
|
|
||||||
volume={volume}
|
|
||||||
isMuted={false}
|
|
||||||
isDetecting
|
|
||||||
isSpeaking={false}
|
|
||||||
orientation="horizontal"
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
<View style={styles.dictationActionGroup}>
|
|
||||||
<Pressable
|
|
||||||
onPress={cancelHandler}
|
|
||||||
disabled={isProcessing && !isFailed}
|
|
||||||
accessibilityLabel="Cancel dictation"
|
|
||||||
style={[
|
|
||||||
styles.dictationActionButton,
|
|
||||||
styles.dictationActionButtonCancel,
|
|
||||||
isProcessing && !isFailed ? styles.dictationActionButtonDisabled : undefined,
|
|
||||||
]}
|
|
||||||
>
|
|
||||||
<X size={14} color={theme.colors.foreground} />
|
|
||||||
</Pressable>
|
|
||||||
<Pressable
|
|
||||||
onPress={confirmHandler}
|
|
||||||
disabled={isProcessing}
|
|
||||||
accessibilityLabel={isFailed ? "Retry dictation" : "Insert transcription"}
|
|
||||||
style={[
|
|
||||||
styles.dictationActionButton,
|
|
||||||
styles.dictationActionButtonConfirm,
|
|
||||||
isProcessing ? styles.dictationActionButtonDisabled : undefined,
|
|
||||||
]}
|
|
||||||
>
|
|
||||||
{isProcessing || isRetrying ? (
|
|
||||||
<ActivityIndicator size="small" color={theme.colors.background} />
|
|
||||||
) : isFailed ? (
|
|
||||||
<RefreshCcw size={14} color={theme.colors.background} />
|
|
||||||
) : (
|
|
||||||
<Check size={14} color={theme.colors.background} />
|
|
||||||
)}
|
|
||||||
</Pressable>
|
|
||||||
</View>
|
|
||||||
{(isRetrying || isFailed) && (
|
|
||||||
<Text style={[styles.dictationStatusLabel, { color: theme.colors.mutedForeground }]}>
|
|
||||||
{isRetrying
|
|
||||||
? `Retrying ${Math.max(1, retryAttempt)} / ${Math.max(1, maxRetryAttempts)}${
|
|
||||||
retryCountdownMs && retryCountdownMs > 0
|
|
||||||
? ` in ${Math.ceil(retryCountdownMs / 1000)}s`
|
|
||||||
: ""
|
|
||||||
}`
|
|
||||||
: errorMessage ?? "Dictation failed"}
|
|
||||||
</Text>
|
|
||||||
)}
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
interface GitOptionsSectionProps {
|
interface GitOptionsSectionProps {
|
||||||
baseBranch: string;
|
baseBranch: string;
|
||||||
onBaseBranchChange: (value: string) => void;
|
onBaseBranchChange: (value: string) => void;
|
||||||
@@ -2757,24 +2065,9 @@ const styles = StyleSheet.create(((theme: any) => ({
|
|||||||
paddingBottom: theme.spacing[8],
|
paddingBottom: theme.spacing[8],
|
||||||
gap: theme.spacing[6],
|
gap: theme.spacing[6],
|
||||||
},
|
},
|
||||||
dictationToastPortal: {
|
|
||||||
position: "absolute",
|
|
||||||
left: theme.spacing[4],
|
|
||||||
right: theme.spacing[4],
|
|
||||||
bottom: theme.spacing[6],
|
|
||||||
},
|
|
||||||
dictationNoticeWrapper: {
|
|
||||||
marginTop: theme.spacing[3],
|
|
||||||
},
|
|
||||||
formSection: {
|
formSection: {
|
||||||
gap: theme.spacing[3],
|
gap: theme.spacing[3],
|
||||||
},
|
},
|
||||||
labelRow: {
|
|
||||||
flexDirection: "row",
|
|
||||||
alignItems: "center",
|
|
||||||
justifyContent: "space-between",
|
|
||||||
gap: theme.spacing[3],
|
|
||||||
},
|
|
||||||
label: {
|
label: {
|
||||||
color: theme.colors.foreground,
|
color: theme.colors.foreground,
|
||||||
fontSize: theme.fontSize.sm,
|
fontSize: theme.fontSize.sm,
|
||||||
@@ -2899,13 +2192,6 @@ const styles = StyleSheet.create(((theme: any) => ({
|
|||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
paddingVertical: theme.spacing[4],
|
paddingVertical: theme.spacing[4],
|
||||||
},
|
},
|
||||||
promptInput: {
|
|
||||||
minHeight: theme.spacing[24],
|
|
||||||
textAlignVertical: "top",
|
|
||||||
outlineWidth: 0,
|
|
||||||
outlineColor: "transparent",
|
|
||||||
outlineStyle: "none",
|
|
||||||
},
|
|
||||||
inputDisabled: {
|
inputDisabled: {
|
||||||
opacity: theme.opacity[50],
|
opacity: theme.opacity[50],
|
||||||
},
|
},
|
||||||
@@ -2921,55 +2207,6 @@ const styles = StyleSheet.create(((theme: any) => ({
|
|||||||
color: theme.colors.mutedForeground,
|
color: theme.colors.mutedForeground,
|
||||||
fontSize: theme.fontSize.sm,
|
fontSize: theme.fontSize.sm,
|
||||||
},
|
},
|
||||||
dictationButton: {
|
|
||||||
borderWidth: theme.borderWidth[1],
|
|
||||||
borderColor: theme.colors.border,
|
|
||||||
borderRadius: theme.borderRadius.full,
|
|
||||||
padding: theme.spacing[2],
|
|
||||||
alignItems: "center",
|
|
||||||
justifyContent: "center",
|
|
||||||
},
|
|
||||||
dictationButtonDisabled: {
|
|
||||||
opacity: theme.opacity[50],
|
|
||||||
},
|
|
||||||
dictationActiveContainer: {
|
|
||||||
flexDirection: "row",
|
|
||||||
alignItems: "center",
|
|
||||||
gap: theme.spacing[2],
|
|
||||||
},
|
|
||||||
dictationMeterWrapper: {
|
|
||||||
width: 48,
|
|
||||||
alignItems: "center",
|
|
||||||
justifyContent: "center",
|
|
||||||
},
|
|
||||||
dictationActionGroup: {
|
|
||||||
flexDirection: "row",
|
|
||||||
gap: theme.spacing[2],
|
|
||||||
},
|
|
||||||
dictationActionButton: {
|
|
||||||
width: 32,
|
|
||||||
height: 32,
|
|
||||||
borderRadius: theme.borderRadius.full,
|
|
||||||
alignItems: "center",
|
|
||||||
justifyContent: "center",
|
|
||||||
borderWidth: theme.borderWidth[1],
|
|
||||||
},
|
|
||||||
dictationActionButtonCancel: {
|
|
||||||
borderColor: theme.colors.border,
|
|
||||||
backgroundColor: theme.colors.background,
|
|
||||||
},
|
|
||||||
dictationActionButtonConfirm: {
|
|
||||||
borderColor: theme.colors.foreground,
|
|
||||||
backgroundColor: theme.colors.foreground,
|
|
||||||
},
|
|
||||||
dictationActionButtonDisabled: {
|
|
||||||
opacity: theme.opacity[40],
|
|
||||||
},
|
|
||||||
dictationStatusLabel: {
|
|
||||||
marginTop: theme.spacing[1],
|
|
||||||
fontSize: theme.fontSize.xs,
|
|
||||||
fontWeight: theme.fontWeight.medium,
|
|
||||||
},
|
|
||||||
selectorRow: {
|
selectorRow: {
|
||||||
flexDirection: "row",
|
flexDirection: "row",
|
||||||
gap: theme.spacing[4],
|
gap: theme.spacing[4],
|
||||||
|
|||||||
@@ -0,0 +1,339 @@
|
|||||||
|
import { View, Text, Pressable, ActivityIndicator } from "react-native";
|
||||||
|
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||||
|
import { X, ArrowUp, RefreshCcw, Check, Mic } from "lucide-react-native";
|
||||||
|
import { VolumeMeter } from "./volume-meter";
|
||||||
|
import { FOOTER_HEIGHT } from "@/constants/layout";
|
||||||
|
import type { DictationStatus } from "@/hooks/use-dictation";
|
||||||
|
|
||||||
|
interface DictationControlsProps {
|
||||||
|
volume: number;
|
||||||
|
duration: number;
|
||||||
|
isRecording: boolean;
|
||||||
|
isProcessing: boolean;
|
||||||
|
status: DictationStatus;
|
||||||
|
onStart: () => void;
|
||||||
|
onCancel: () => void;
|
||||||
|
onAccept: () => void;
|
||||||
|
onAcceptAndSend: () => void;
|
||||||
|
onRetry?: () => void;
|
||||||
|
onDiscard?: () => void;
|
||||||
|
disabled?: boolean;
|
||||||
|
retryStatusText?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDuration(seconds: number): string {
|
||||||
|
const mins = Math.floor(seconds / 60);
|
||||||
|
const secs = seconds % 60;
|
||||||
|
return `${mins.toString().padStart(2, "0")}:${secs.toString().padStart(2, "0")}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DictationControls({
|
||||||
|
volume,
|
||||||
|
duration,
|
||||||
|
isRecording,
|
||||||
|
isProcessing,
|
||||||
|
status,
|
||||||
|
onStart,
|
||||||
|
onCancel,
|
||||||
|
onAccept,
|
||||||
|
onAcceptAndSend,
|
||||||
|
onRetry,
|
||||||
|
onDiscard,
|
||||||
|
disabled = false,
|
||||||
|
retryStatusText,
|
||||||
|
}: DictationControlsProps) {
|
||||||
|
const { theme } = useUnistyles();
|
||||||
|
const isRetrying = status === "retrying";
|
||||||
|
const isFailed = status === "failed";
|
||||||
|
const showActiveState = isRecording || isProcessing || isRetrying || isFailed;
|
||||||
|
const actionsDisabled = isProcessing || isRetrying;
|
||||||
|
const handleCancel = isFailed && onDiscard ? onDiscard : onCancel;
|
||||||
|
|
||||||
|
if (!showActiveState) {
|
||||||
|
return (
|
||||||
|
<Pressable
|
||||||
|
onPress={onStart}
|
||||||
|
disabled={disabled}
|
||||||
|
accessibilityRole="button"
|
||||||
|
accessibilityLabel="Start voice dictation"
|
||||||
|
style={[styles.micButton, disabled && styles.buttonDisabled]}
|
||||||
|
>
|
||||||
|
<Mic size={16} color={theme.colors.foreground} />
|
||||||
|
</Pressable>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={styles.activeContainer}>
|
||||||
|
<View style={styles.meterWrapper}>
|
||||||
|
<VolumeMeter
|
||||||
|
volume={volume}
|
||||||
|
isMuted={false}
|
||||||
|
isDetecting
|
||||||
|
isSpeaking={false}
|
||||||
|
orientation="horizontal"
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
<Text style={[styles.timerText, { color: theme.colors.foreground }]}>
|
||||||
|
{formatDuration(duration)}
|
||||||
|
</Text>
|
||||||
|
<View style={styles.actionGroup}>
|
||||||
|
<Pressable
|
||||||
|
onPress={handleCancel}
|
||||||
|
disabled={actionsDisabled && !isFailed}
|
||||||
|
accessibilityLabel="Cancel dictation"
|
||||||
|
style={[
|
||||||
|
styles.actionButton,
|
||||||
|
styles.actionButtonCancel,
|
||||||
|
actionsDisabled && !isFailed ? styles.buttonDisabled : undefined,
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<X size={14} color={theme.colors.foreground} />
|
||||||
|
</Pressable>
|
||||||
|
{actionsDisabled ? (
|
||||||
|
<View style={styles.loadingContainer}>
|
||||||
|
<ActivityIndicator size="small" color={theme.colors.foreground} />
|
||||||
|
</View>
|
||||||
|
) : isFailed ? (
|
||||||
|
<Pressable
|
||||||
|
onPress={onRetry}
|
||||||
|
accessibilityLabel="Retry dictation"
|
||||||
|
style={[styles.actionButton, styles.actionButtonConfirm]}
|
||||||
|
>
|
||||||
|
<RefreshCcw size={14} color={theme.colors.background} />
|
||||||
|
</Pressable>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Pressable
|
||||||
|
onPress={onAccept}
|
||||||
|
accessibilityLabel="Insert transcription"
|
||||||
|
style={[styles.actionButton, styles.actionButtonSecondary]}
|
||||||
|
>
|
||||||
|
<Check size={14} color={theme.colors.foreground} />
|
||||||
|
</Pressable>
|
||||||
|
<Pressable
|
||||||
|
onPress={onAcceptAndSend}
|
||||||
|
accessibilityLabel="Insert transcription and send"
|
||||||
|
style={[styles.actionButton, styles.actionButtonConfirm]}
|
||||||
|
>
|
||||||
|
<ArrowUp size={14} color={theme.colors.background} />
|
||||||
|
</Pressable>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
{retryStatusText && (isRetrying || isFailed) && (
|
||||||
|
<Text style={[styles.statusLabel, { color: theme.colors.mutedForeground }]}>
|
||||||
|
{retryStatusText}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Full-width overlay variant for the agent input footer.
|
||||||
|
* Uses blue background with white icons.
|
||||||
|
*/
|
||||||
|
export function DictationOverlay({
|
||||||
|
volume,
|
||||||
|
duration,
|
||||||
|
isRecording,
|
||||||
|
isProcessing,
|
||||||
|
status,
|
||||||
|
onCancel,
|
||||||
|
onAccept,
|
||||||
|
onAcceptAndSend,
|
||||||
|
onRetry,
|
||||||
|
onDiscard,
|
||||||
|
}: Omit<DictationControlsProps, "onStart" | "disabled" | "retryStatusText">) {
|
||||||
|
const { theme } = useUnistyles();
|
||||||
|
const isRetrying = status === "retrying";
|
||||||
|
const isFailed = status === "failed";
|
||||||
|
const showActiveState = isRecording || isProcessing || isRetrying || isFailed;
|
||||||
|
const actionsDisabled = isProcessing || isRetrying;
|
||||||
|
const handleCancel = isFailed && onDiscard ? onDiscard : onCancel;
|
||||||
|
|
||||||
|
if (!showActiveState) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={[overlayStyles.container, { backgroundColor: theme.colors.palette.blue[600] }]}>
|
||||||
|
<Pressable
|
||||||
|
onPress={handleCancel}
|
||||||
|
disabled={actionsDisabled && !isFailed}
|
||||||
|
style={[
|
||||||
|
overlayStyles.cancelButton,
|
||||||
|
actionsDisabled && !isFailed && overlayStyles.buttonDisabled,
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<X size={20} color={theme.colors.palette.white} strokeWidth={2.5} />
|
||||||
|
</Pressable>
|
||||||
|
|
||||||
|
<View style={overlayStyles.centerContainer}>
|
||||||
|
<VolumeMeter
|
||||||
|
volume={volume}
|
||||||
|
isMuted={false}
|
||||||
|
isDetecting
|
||||||
|
isSpeaking={false}
|
||||||
|
orientation="horizontal"
|
||||||
|
/>
|
||||||
|
<Text style={[overlayStyles.timerText, { color: theme.colors.palette.white }]}>
|
||||||
|
{formatDuration(duration)}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={overlayStyles.actionButtonsContainer}>
|
||||||
|
{actionsDisabled ? (
|
||||||
|
<View style={overlayStyles.loadingContainer}>
|
||||||
|
<ActivityIndicator size="small" color={theme.colors.palette.white} />
|
||||||
|
</View>
|
||||||
|
) : isFailed ? (
|
||||||
|
<Pressable
|
||||||
|
onPress={onRetry}
|
||||||
|
style={[overlayStyles.actionButton, { backgroundColor: theme.colors.palette.white }]}
|
||||||
|
>
|
||||||
|
<RefreshCcw size={20} color={theme.colors.palette.blue[600]} strokeWidth={2.5} />
|
||||||
|
</Pressable>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Pressable
|
||||||
|
onPress={onAccept}
|
||||||
|
style={[overlayStyles.actionButton, { backgroundColor: "rgba(255, 255, 255, 0.25)" }]}
|
||||||
|
>
|
||||||
|
<Check size={20} color={theme.colors.palette.white} strokeWidth={2.5} />
|
||||||
|
</Pressable>
|
||||||
|
<Pressable
|
||||||
|
onPress={onAcceptAndSend}
|
||||||
|
style={[overlayStyles.actionButton, { backgroundColor: theme.colors.palette.white }]}
|
||||||
|
>
|
||||||
|
<ArrowUp size={20} color={theme.colors.palette.blue[600]} strokeWidth={2.5} />
|
||||||
|
</Pressable>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const BUTTON_SIZE = 32;
|
||||||
|
|
||||||
|
const styles = StyleSheet.create((theme) => ({
|
||||||
|
micButton: {
|
||||||
|
width: BUTTON_SIZE,
|
||||||
|
height: BUTTON_SIZE,
|
||||||
|
borderRadius: theme.borderRadius.full,
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
borderWidth: theme.borderWidth[1],
|
||||||
|
borderColor: theme.colors.border,
|
||||||
|
backgroundColor: theme.colors.background,
|
||||||
|
},
|
||||||
|
activeContainer: {
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: theme.spacing[3],
|
||||||
|
},
|
||||||
|
meterWrapper: {
|
||||||
|
width: 80,
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
},
|
||||||
|
timerText: {
|
||||||
|
fontSize: theme.fontSize.sm,
|
||||||
|
fontWeight: theme.fontWeight.semibold,
|
||||||
|
fontVariant: ["tabular-nums"],
|
||||||
|
},
|
||||||
|
actionGroup: {
|
||||||
|
flexDirection: "row",
|
||||||
|
gap: theme.spacing[2],
|
||||||
|
},
|
||||||
|
actionButton: {
|
||||||
|
width: BUTTON_SIZE,
|
||||||
|
height: BUTTON_SIZE,
|
||||||
|
borderRadius: theme.borderRadius.full,
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
borderWidth: theme.borderWidth[1],
|
||||||
|
},
|
||||||
|
actionButtonCancel: {
|
||||||
|
borderColor: theme.colors.border,
|
||||||
|
backgroundColor: theme.colors.background,
|
||||||
|
},
|
||||||
|
actionButtonSecondary: {
|
||||||
|
borderColor: theme.colors.border,
|
||||||
|
backgroundColor: theme.colors.background,
|
||||||
|
},
|
||||||
|
actionButtonConfirm: {
|
||||||
|
borderColor: theme.colors.foreground,
|
||||||
|
backgroundColor: theme.colors.foreground,
|
||||||
|
},
|
||||||
|
buttonDisabled: {
|
||||||
|
opacity: 0.4,
|
||||||
|
},
|
||||||
|
loadingContainer: {
|
||||||
|
width: BUTTON_SIZE,
|
||||||
|
height: BUTTON_SIZE,
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
},
|
||||||
|
statusLabel: {
|
||||||
|
fontSize: theme.fontSize.xs,
|
||||||
|
fontWeight: theme.fontWeight.semibold,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
const OVERLAY_BUTTON_SIZE = 44;
|
||||||
|
const OVERLAY_VERTICAL_PADDING = (FOOTER_HEIGHT - OVERLAY_BUTTON_SIZE) / 2;
|
||||||
|
|
||||||
|
const overlayStyles = StyleSheet.create((theme) => ({
|
||||||
|
container: {
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
paddingHorizontal: theme.spacing[4],
|
||||||
|
paddingVertical: OVERLAY_VERTICAL_PADDING,
|
||||||
|
height: FOOTER_HEIGHT,
|
||||||
|
},
|
||||||
|
cancelButton: {
|
||||||
|
width: OVERLAY_BUTTON_SIZE,
|
||||||
|
height: OVERLAY_BUTTON_SIZE,
|
||||||
|
borderRadius: theme.borderRadius.full,
|
||||||
|
backgroundColor: "rgba(0, 0, 0, 0.15)",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
},
|
||||||
|
centerContainer: {
|
||||||
|
flex: 1,
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
gap: theme.spacing[4],
|
||||||
|
},
|
||||||
|
timerText: {
|
||||||
|
fontSize: theme.fontSize.xl,
|
||||||
|
fontWeight: theme.fontWeight.semibold,
|
||||||
|
fontVariant: ["tabular-nums"],
|
||||||
|
},
|
||||||
|
actionButtonsContainer: {
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: theme.spacing[2],
|
||||||
|
},
|
||||||
|
actionButton: {
|
||||||
|
width: OVERLAY_BUTTON_SIZE,
|
||||||
|
height: OVERLAY_BUTTON_SIZE,
|
||||||
|
borderRadius: theme.borderRadius.full,
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
},
|
||||||
|
buttonDisabled: {
|
||||||
|
opacity: 0.5,
|
||||||
|
},
|
||||||
|
loadingContainer: {
|
||||||
|
width: OVERLAY_BUTTON_SIZE,
|
||||||
|
height: OVERLAY_BUTTON_SIZE,
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
},
|
||||||
|
}));
|
||||||
@@ -1,129 +0,0 @@
|
|||||||
import { View, Text, Pressable, ActivityIndicator } from "react-native";
|
|
||||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
|
||||||
import { X, ArrowUp, RefreshCcw } from "lucide-react-native";
|
|
||||||
import { VolumeMeter } from "./volume-meter";
|
|
||||||
import { FOOTER_HEIGHT } from "@/constants/layout";
|
|
||||||
import type { DictationStatus } from "@/hooks/use-dictation";
|
|
||||||
|
|
||||||
interface VoiceNoteRecordingOverlayProps {
|
|
||||||
volume: number;
|
|
||||||
duration: number;
|
|
||||||
onCancel: () => void;
|
|
||||||
onSend: () => void;
|
|
||||||
isTranscribing?: boolean;
|
|
||||||
status?: DictationStatus;
|
|
||||||
onRetry?: () => void;
|
|
||||||
onDiscardFailed?: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatDuration(seconds: number): string {
|
|
||||||
const mins = Math.floor(seconds / 60);
|
|
||||||
const secs = seconds % 60;
|
|
||||||
return `${mins.toString().padStart(2, "0")}:${secs.toString().padStart(2, "0")}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function VoiceNoteRecordingOverlay({
|
|
||||||
volume,
|
|
||||||
duration,
|
|
||||||
onCancel,
|
|
||||||
onSend,
|
|
||||||
isTranscribing = false,
|
|
||||||
status = "idle",
|
|
||||||
onRetry,
|
|
||||||
onDiscardFailed,
|
|
||||||
}: VoiceNoteRecordingOverlayProps) {
|
|
||||||
const { theme } = useUnistyles();
|
|
||||||
const isRetrying = status === "retrying";
|
|
||||||
const isFailed = status === "failed";
|
|
||||||
const primaryDisabled = isTranscribing || isRetrying;
|
|
||||||
const handlePrimary = isFailed ? onRetry ?? onSend : onSend;
|
|
||||||
const handleCancel = isFailed && onDiscardFailed ? onDiscardFailed : onCancel;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<View style={[styles.container, { backgroundColor: theme.colors.palette.blue[600] }]}>
|
|
||||||
{/* Cancel button */}
|
|
||||||
<Pressable
|
|
||||||
onPress={handleCancel}
|
|
||||||
disabled={isTranscribing}
|
|
||||||
style={[styles.cancelButton, isTranscribing && styles.buttonDisabled]}
|
|
||||||
>
|
|
||||||
<X size={24} color={theme.colors.palette.white} strokeWidth={2.5} />
|
|
||||||
</Pressable>
|
|
||||||
|
|
||||||
{/* Center: Volume meter and timer */}
|
|
||||||
<View style={styles.centerContainer}>
|
|
||||||
<VolumeMeter
|
|
||||||
volume={volume}
|
|
||||||
isMuted={false}
|
|
||||||
isDetecting={true}
|
|
||||||
isSpeaking={false}
|
|
||||||
orientation="horizontal"
|
|
||||||
/>
|
|
||||||
<Text style={[styles.timerText, { color: theme.colors.palette.white }]}>
|
|
||||||
{formatDuration(duration)}
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
{/* Send button */}
|
|
||||||
<Pressable
|
|
||||||
onPress={handlePrimary}
|
|
||||||
disabled={primaryDisabled}
|
|
||||||
style={[styles.sendButton, { backgroundColor: theme.colors.palette.white }, primaryDisabled && styles.buttonDisabled]}
|
|
||||||
>
|
|
||||||
{isRetrying ? (
|
|
||||||
<ActivityIndicator size="small" color={theme.colors.palette.blue[600]} />
|
|
||||||
) : isTranscribing ? (
|
|
||||||
<ActivityIndicator size="small" color={theme.colors.palette.blue[600]} />
|
|
||||||
) : isFailed ? (
|
|
||||||
<RefreshCcw size={24} color={theme.colors.palette.blue[600]} strokeWidth={2.5} />
|
|
||||||
) : (
|
|
||||||
<ArrowUp size={24} color={theme.colors.palette.blue[600]} strokeWidth={2.5} />
|
|
||||||
)}
|
|
||||||
</Pressable>
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const BUTTON_SIZE = 56;
|
|
||||||
const VERTICAL_PADDING = (FOOTER_HEIGHT - BUTTON_SIZE) / 2;
|
|
||||||
|
|
||||||
const styles = StyleSheet.create((theme) => ({
|
|
||||||
container: {
|
|
||||||
flexDirection: "row",
|
|
||||||
alignItems: "center",
|
|
||||||
justifyContent: "space-between",
|
|
||||||
paddingHorizontal: theme.spacing[4],
|
|
||||||
paddingVertical: VERTICAL_PADDING,
|
|
||||||
height: FOOTER_HEIGHT,
|
|
||||||
},
|
|
||||||
cancelButton: {
|
|
||||||
width: BUTTON_SIZE,
|
|
||||||
height: BUTTON_SIZE,
|
|
||||||
borderRadius: theme.borderRadius.full,
|
|
||||||
backgroundColor: "rgba(0, 0, 0, 0.15)",
|
|
||||||
alignItems: "center",
|
|
||||||
justifyContent: "center",
|
|
||||||
},
|
|
||||||
centerContainer: {
|
|
||||||
flex: 1,
|
|
||||||
flexDirection: "row",
|
|
||||||
alignItems: "center",
|
|
||||||
justifyContent: "center",
|
|
||||||
gap: theme.spacing[4],
|
|
||||||
},
|
|
||||||
timerText: {
|
|
||||||
fontSize: theme.fontSize.xl,
|
|
||||||
fontWeight: theme.fontWeight.semibold,
|
|
||||||
fontVariant: ["tabular-nums"],
|
|
||||||
},
|
|
||||||
sendButton: {
|
|
||||||
width: BUTTON_SIZE,
|
|
||||||
height: BUTTON_SIZE,
|
|
||||||
borderRadius: theme.borderRadius.full,
|
|
||||||
alignItems: "center",
|
|
||||||
justifyContent: "center",
|
|
||||||
},
|
|
||||||
buttonDisabled: {
|
|
||||||
opacity: 0.5,
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
@@ -134,7 +134,6 @@ function normalizeAgentSnapshot(snapshot: AgentSnapshotPayload, serverId: string
|
|||||||
updatedAt,
|
updatedAt,
|
||||||
lastUserMessageAt,
|
lastUserMessageAt,
|
||||||
lastActivityAt: updatedAt,
|
lastActivityAt: updatedAt,
|
||||||
sessionId: snapshot.sessionId,
|
|
||||||
capabilities: snapshot.capabilities,
|
capabilities: snapshot.capabilities,
|
||||||
currentModeId: snapshot.currentModeId,
|
currentModeId: snapshot.currentModeId,
|
||||||
availableModes: snapshot.availableModes ?? [],
|
availableModes: snapshot.availableModes ?? [],
|
||||||
@@ -196,7 +195,7 @@ export interface SessionContextValue {
|
|||||||
images?: Array<{ uri: string; mimeType?: string }>
|
images?: Array<{ uri: string; mimeType?: string }>
|
||||||
) => Promise<void>;
|
) => Promise<void>;
|
||||||
sendAgentAudio: (
|
sendAgentAudio: (
|
||||||
agentId: string,
|
agentId: string | undefined,
|
||||||
audioBlob: Blob,
|
audioBlob: Blob,
|
||||||
requestId?: string,
|
requestId?: string,
|
||||||
options?: { mode?: "transcribe_only" | "auto_run" }
|
options?: { mode?: "transcribe_only" | "auto_run" }
|
||||||
@@ -265,6 +264,7 @@ export function SessionProvider({ children, serverUrl, serverId }: SessionProvid
|
|||||||
const activeAudioGroupsRef = useRef<Set<string>>(new Set());
|
const activeAudioGroupsRef = useRef<Set<string>>(new Set());
|
||||||
const previousAgentStatusRef = useRef<Map<string, AgentLifecycleStatus>>(new Map());
|
const previousAgentStatusRef = useRef<Map<string, AgentLifecycleStatus>>(new Map());
|
||||||
const providerModelRequestIdsRef = useRef<Map<any, string>>(new Map());
|
const providerModelRequestIdsRef = useRef<Map<any, string>>(new Map());
|
||||||
|
const sendAgentMessageRef = useRef<((agentId: string, message: string, images?: Array<{ uri: string; mimeType?: string }>) => Promise<void>) | null>(null);
|
||||||
const hasHydratedSnapshotRef = useRef(false);
|
const hasHydratedSnapshotRef = useRef(false);
|
||||||
const hasRequestedInitialSnapshotRef = useRef(false);
|
const hasRequestedInitialSnapshotRef = useRef(false);
|
||||||
const sessionStateTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
const sessionStateTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
@@ -751,6 +751,26 @@ export function SessionProvider({ children, serverUrl, serverId }: SessionProvid
|
|||||||
}
|
}
|
||||||
return next;
|
return next;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Flush queued messages when agent transitions from running to not running
|
||||||
|
const prevStatus = previousAgentStatusRef.current.get(agent.id);
|
||||||
|
if (prevStatus === "running" && agent.status !== "running") {
|
||||||
|
const session = useSessionStore.getState().sessions[serverId];
|
||||||
|
const queue = session?.queuedMessages.get(agent.id);
|
||||||
|
if (queue && queue.length > 0) {
|
||||||
|
const [next, ...rest] = queue;
|
||||||
|
console.log("[Session] Flushing queued message for agent:", agent.id, next.text);
|
||||||
|
if (sendAgentMessageRef.current) {
|
||||||
|
void sendAgentMessageRef.current(agent.id, next.text, next.images);
|
||||||
|
}
|
||||||
|
setQueuedMessages(serverId, (prev) => {
|
||||||
|
const updated = new Map(prev);
|
||||||
|
updated.set(agent.id, rest);
|
||||||
|
return updated;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
previousAgentStatusRef.current.set(agent.id, agent.status);
|
||||||
});
|
});
|
||||||
|
|
||||||
const unsubAgentStream = ws.on("agent_stream", (message) => {
|
const unsubAgentStream = ws.on("agent_stream", (message) => {
|
||||||
@@ -1254,29 +1274,6 @@ export function SessionProvider({ children, serverUrl, serverId }: SessionProvid
|
|||||||
};
|
};
|
||||||
}, [ws, audioPlayer, serverId, setIsPlayingAudio, setMessages, setCurrentAssistantMessage, setAgentStreamState, setAgentStreamingBuffer, clearAgentStreamingBuffer, setInitializingAgents, setAgents, setAgentLastActivity, setCommands, setPendingPermissions, setGitDiffs, setFileExplorer, setProviderModels, setHasHydratedAgents, updateConnectionStatus, getSession, saveDraftInput]);
|
}, [ws, audioPlayer, serverId, setIsPlayingAudio, setMessages, setCurrentAssistantMessage, setAgentStreamState, setAgentStreamingBuffer, clearAgentStreamingBuffer, setInitializingAgents, setAgents, setAgentLastActivity, setCommands, setPendingPermissions, setGitDiffs, setFileExplorer, setProviderModels, setHasHydratedAgents, updateConnectionStatus, getSession, saveDraftInput]);
|
||||||
|
|
||||||
// Auto-flush queued messages when agent transitions from running -> not running
|
|
||||||
useEffect(() => {
|
|
||||||
const session = getSession(serverId);
|
|
||||||
if (!session) return;
|
|
||||||
|
|
||||||
for (const [agentId, agent] of session.agents.entries()) {
|
|
||||||
const prevStatus = previousAgentStatusRef.current.get(agentId);
|
|
||||||
if (prevStatus === "running" && agent.status !== "running") {
|
|
||||||
const queue = session.queuedMessages.get(agentId);
|
|
||||||
if (queue && queue.length > 0) {
|
|
||||||
const [next, ...rest] = queue;
|
|
||||||
void sendAgentMessage(agentId, next.text, next.images);
|
|
||||||
setQueuedMessages(serverId, (prev) => {
|
|
||||||
const updated = new Map(prev);
|
|
||||||
updated.set(agentId, rest);
|
|
||||||
return updated;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
previousAgentStatusRef.current.set(agentId, agent.status);
|
|
||||||
}
|
|
||||||
}, [serverId, getSession, setQueuedMessages]);
|
|
||||||
|
|
||||||
const initializeAgent = useCallback(({ agentId, requestId }: { agentId: string; requestId?: string }) => {
|
const initializeAgent = useCallback(({ agentId, requestId }: { agentId: string; requestId?: string }) => {
|
||||||
console.log("[Session] initializeAgent called", { agentId, requestId });
|
console.log("[Session] initializeAgent called", { agentId, requestId });
|
||||||
setInitializingAgents(serverId, (prev) => {
|
setInitializingAgents(serverId, (prev) => {
|
||||||
@@ -1459,6 +1456,9 @@ export function SessionProvider({ children, serverUrl, serverId }: SessionProvid
|
|||||||
ws.send(msg);
|
ws.send(msg);
|
||||||
}, [encodeImages, serverId, ws, setAgentStreamState]);
|
}, [encodeImages, serverId, ws, setAgentStreamState]);
|
||||||
|
|
||||||
|
// Keep the ref updated so the agent_state handler can call it
|
||||||
|
sendAgentMessageRef.current = sendAgentMessage;
|
||||||
|
|
||||||
const cancelAgentRun = useCallback((agentId: string) => {
|
const cancelAgentRun = useCallback((agentId: string) => {
|
||||||
const msg: WSInboundMessage = {
|
const msg: WSInboundMessage = {
|
||||||
type: "session",
|
type: "session",
|
||||||
@@ -1493,7 +1493,7 @@ export function SessionProvider({ children, serverUrl, serverId }: SessionProvid
|
|||||||
}, [ws]);
|
}, [ws]);
|
||||||
|
|
||||||
const sendAgentAudio = useCallback(async (
|
const sendAgentAudio = useCallback(async (
|
||||||
agentId: string,
|
agentId: string | undefined,
|
||||||
audioBlob: Blob,
|
audioBlob: Blob,
|
||||||
requestId?: string,
|
requestId?: string,
|
||||||
options?: { mode?: "transcribe_only" | "auto_run" }
|
options?: { mode?: "transcribe_only" | "auto_run" }
|
||||||
@@ -1530,7 +1530,7 @@ export function SessionProvider({ children, serverUrl, serverId }: SessionProvid
|
|||||||
type: "session",
|
type: "session",
|
||||||
message: {
|
message: {
|
||||||
type: "send_agent_audio",
|
type: "send_agent_audio",
|
||||||
agentId,
|
...(agentId ? { agentId } : {}),
|
||||||
audio: base64Audio,
|
audio: base64Audio,
|
||||||
format,
|
format,
|
||||||
isLast: true,
|
isLast: true,
|
||||||
@@ -1540,7 +1540,7 @@ export function SessionProvider({ children, serverUrl, serverId }: SessionProvid
|
|||||||
};
|
};
|
||||||
ws.send(msg);
|
ws.send(msg);
|
||||||
|
|
||||||
console.log("[Session] Sent audio to agent:", agentId, format, audioBlob.size, "bytes", requestId ? `(requestId: ${requestId})` : "");
|
console.log("[Session] Sent audio:", agentId ?? "(no agent)", format, audioBlob.size, "bytes", requestId ? `(requestId: ${requestId})` : "");
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("[Session] Failed to send audio:", error);
|
console.error("[Session] Failed to send audio:", error);
|
||||||
throw error;
|
throw error;
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ export type DictationOutcome =
|
|||||||
| { type: "failure"; requestId: string; errorMessage: string; timestamp: number };
|
| { type: "failure"; requestId: string; errorMessage: string; timestamp: number };
|
||||||
|
|
||||||
export type UseDictationOptions = {
|
export type UseDictationOptions = {
|
||||||
agentId: string;
|
agentId?: string;
|
||||||
sendAgentAudio: SessionContextValue["sendAgentAudio"];
|
sendAgentAudio: SessionContextValue["sendAgentAudio"];
|
||||||
ws: UseWebSocketReturn;
|
ws: UseWebSocketReturn;
|
||||||
mode?: "transcribe_only" | "auto_run";
|
mode?: "transcribe_only" | "auto_run";
|
||||||
|
|||||||
@@ -91,7 +91,6 @@ export interface Agent {
|
|||||||
updatedAt: Date;
|
updatedAt: Date;
|
||||||
lastUserMessageAt: Date | null;
|
lastUserMessageAt: Date | null;
|
||||||
lastActivityAt: Date;
|
lastActivityAt: Date;
|
||||||
sessionId: string | null;
|
|
||||||
capabilities: AgentCapabilityFlags;
|
capabilities: AgentCapabilityFlags;
|
||||||
currentModeId: string | null;
|
currentModeId: string | null;
|
||||||
availableModes: AgentMode[];
|
availableModes: AgentMode[];
|
||||||
@@ -191,7 +190,7 @@ export interface SessionState {
|
|||||||
images?: Array<{ uri: string; mimeType?: string }>
|
images?: Array<{ uri: string; mimeType?: string }>
|
||||||
) => Promise<void>;
|
) => Promise<void>;
|
||||||
sendAgentAudio: (
|
sendAgentAudio: (
|
||||||
agentId: string,
|
agentId: string | undefined,
|
||||||
audioBlob: Blob,
|
audioBlob: Blob,
|
||||||
requestId?: string,
|
requestId?: string,
|
||||||
options?: { mode?: "transcribe_only" | "auto_run" }
|
options?: { mode?: "transcribe_only" | "auto_run" }
|
||||||
|
|||||||
@@ -89,7 +89,10 @@ class TestAgentSession implements AgentSession {
|
|||||||
async respondToPermission(): Promise<void> {}
|
async respondToPermission(): Promise<void> {}
|
||||||
|
|
||||||
describePersistence() {
|
describePersistence() {
|
||||||
return null;
|
return {
|
||||||
|
provider: this.provider,
|
||||||
|
sessionId: this.id,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async interrupt(): Promise<void> {}
|
async interrupt(): Promise<void> {}
|
||||||
@@ -165,7 +168,7 @@ describe("AgentManager", () => {
|
|||||||
|
|
||||||
expect(snapshot.runtimeInfo).toBeDefined();
|
expect(snapshot.runtimeInfo).toBeDefined();
|
||||||
expect(snapshot.runtimeInfo?.model).toBe("gpt-5.2-codex");
|
expect(snapshot.runtimeInfo?.model).toBe("gpt-5.2-codex");
|
||||||
expect(snapshot.runtimeInfo?.sessionId).toBe(snapshot.sessionId);
|
expect(snapshot.runtimeInfo?.sessionId).toBe(snapshot.persistence?.sessionId);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("runAgent refreshes runtimeInfo after completion", async () => {
|
test("runAgent refreshes runtimeInfo after completion", async () => {
|
||||||
|
|||||||
@@ -93,7 +93,6 @@ type ManagedAgentBase = {
|
|||||||
persistence: AgentPersistenceHandle | null;
|
persistence: AgentPersistenceHandle | null;
|
||||||
historyPrimed: boolean;
|
historyPrimed: boolean;
|
||||||
lastUserMessageAt: Date | null;
|
lastUserMessageAt: Date | null;
|
||||||
sessionId: string | null;
|
|
||||||
lastUsage?: AgentUsage;
|
lastUsage?: AgentUsage;
|
||||||
lastError?: string;
|
lastError?: string;
|
||||||
attention: AttentionState;
|
attention: AttentionState;
|
||||||
@@ -368,7 +367,6 @@ export class AgentManager {
|
|||||||
lifecycle: "closed",
|
lifecycle: "closed",
|
||||||
session: null,
|
session: null,
|
||||||
pendingRun: null,
|
pendingRun: null,
|
||||||
sessionId: agent.sessionId,
|
|
||||||
};
|
};
|
||||||
await session.close();
|
await session.close();
|
||||||
this.emitState(closedAgent);
|
this.emitState(closedAgent);
|
||||||
@@ -421,8 +419,14 @@ export class AgentManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const agent = this.requireAgent(agentId);
|
const agent = this.requireAgent(agentId);
|
||||||
|
const sessionId = agent.persistence?.sessionId;
|
||||||
|
if (!sessionId) {
|
||||||
|
throw new Error(
|
||||||
|
`Agent ${agentId} has no persistence.sessionId after run completed`
|
||||||
|
);
|
||||||
|
}
|
||||||
return {
|
return {
|
||||||
sessionId: agent.sessionId ?? agent.id,
|
sessionId,
|
||||||
finalText,
|
finalText,
|
||||||
usage,
|
usage,
|
||||||
timeline,
|
timeline,
|
||||||
@@ -767,7 +771,6 @@ export class AgentManager {
|
|||||||
provider: config.provider,
|
provider: config.provider,
|
||||||
cwd: config.cwd,
|
cwd: config.cwd,
|
||||||
session,
|
session,
|
||||||
sessionId: session.id,
|
|
||||||
capabilities: session.capabilities,
|
capabilities: session.capabilities,
|
||||||
config,
|
config,
|
||||||
runtimeInfo: undefined,
|
runtimeInfo: undefined,
|
||||||
@@ -878,7 +881,9 @@ export class AgentManager {
|
|||||||
|
|
||||||
switch (event.type) {
|
switch (event.type) {
|
||||||
case "thread_started":
|
case "thread_started":
|
||||||
agent.sessionId = event.sessionId;
|
// Update persistence with the new session ID from the provider.
|
||||||
|
// persistence.sessionId is the single source of truth for session identity.
|
||||||
|
agent.persistence = agent.session.describePersistence();
|
||||||
break;
|
break;
|
||||||
case "timeline":
|
case "timeline":
|
||||||
this.recordTimeline(agent, event.item);
|
this.recordTimeline(agent, event.item);
|
||||||
|
|||||||
@@ -407,7 +407,7 @@ describe("agent MCP end-to-end", () => {
|
|||||||
cwd: agentCwd,
|
cwd: agentCwd,
|
||||||
title: "MCP interrupt test",
|
title: "MCP interrupt test",
|
||||||
agentType: "codex",
|
agentType: "codex",
|
||||||
initialMode: "full-auto",
|
initialMode: "full-access",
|
||||||
background: true, // Start in background so create returns immediately
|
background: true, // Start in background so create returns immediately
|
||||||
},
|
},
|
||||||
})) as McpToolResult;
|
})) as McpToolResult;
|
||||||
|
|||||||
@@ -234,7 +234,6 @@ describe("toAgentPayload", () => {
|
|||||||
expect(payload.lastUserMessageAt).toBe(agent.lastUserMessageAt?.toISOString());
|
expect(payload.lastUserMessageAt).toBe(agent.lastUserMessageAt?.toISOString());
|
||||||
expect(payload.title).toBe("UI Payload");
|
expect(payload.title).toBe("UI Payload");
|
||||||
expect(payload.model).toBe(agent.config.model);
|
expect(payload.model).toBe(agent.config.model);
|
||||||
expect(payload.sessionId).toBe(agent.sessionId);
|
|
||||||
expect(payload.pendingPermissions.map((item) => item.id)).toEqual([
|
expect(payload.pendingPermissions.map((item) => item.id)).toEqual([
|
||||||
"perm-a",
|
"perm-a",
|
||||||
"perm-b",
|
"perm-b",
|
||||||
|
|||||||
@@ -77,7 +77,6 @@ export function toAgentPayload(
|
|||||||
? agent.lastUserMessageAt.toISOString()
|
? agent.lastUserMessageAt.toISOString()
|
||||||
: null,
|
: null,
|
||||||
status: agent.lifecycle,
|
status: agent.lifecycle,
|
||||||
sessionId: agent.sessionId,
|
|
||||||
capabilities: cloneCapabilities(agent.capabilities),
|
capabilities: cloneCapabilities(agent.capabilities),
|
||||||
currentModeId: agent.currentModeId,
|
currentModeId: agent.currentModeId,
|
||||||
availableModes: cloneAvailableModes(agent.availableModes),
|
availableModes: cloneAvailableModes(agent.availableModes),
|
||||||
|
|||||||
@@ -51,7 +51,6 @@ function createManagedAgent(
|
|||||||
provider,
|
provider,
|
||||||
cwd,
|
cwd,
|
||||||
session,
|
session,
|
||||||
sessionId: overrides.sessionId ?? "session-123",
|
|
||||||
capabilities:
|
capabilities:
|
||||||
overrides.capabilities ??
|
overrides.capabilities ??
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -66,6 +66,7 @@ export class AgentRegistry {
|
|||||||
private cache: Map<string, StoredAgentRecord> = new Map();
|
private cache: Map<string, StoredAgentRecord> = new Map();
|
||||||
private loaded = false;
|
private loaded = false;
|
||||||
private filePath: string;
|
private filePath: string;
|
||||||
|
private loadPromise: Promise<StoredAgentRecord[]> | null = null;
|
||||||
|
|
||||||
constructor(filePath: string) {
|
constructor(filePath: string) {
|
||||||
this.filePath = filePath;
|
this.filePath = filePath;
|
||||||
@@ -75,6 +76,15 @@ export class AgentRegistry {
|
|||||||
if (this.loaded) {
|
if (this.loaded) {
|
||||||
return Array.from(this.cache.values());
|
return Array.from(this.cache.values());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!this.loadPromise) {
|
||||||
|
this.loadPromise = this.doLoad();
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.loadPromise;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async doLoad(): Promise<StoredAgentRecord[]> {
|
||||||
try {
|
try {
|
||||||
const content = await fs.readFile(this.filePath, "utf8");
|
const content = await fs.readFile(this.filePath, "utf8");
|
||||||
const parsed = await this.parseContent(content);
|
const parsed = await this.parseContent(content);
|
||||||
|
|||||||
@@ -36,6 +36,47 @@ export interface AgentMcpServerOptions {
|
|||||||
callerAgentId?: string;
|
callerAgentId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const CLAUDE_TO_CODEX_MODE: Record<string, string> = {
|
||||||
|
plan: "read-only",
|
||||||
|
default: "auto",
|
||||||
|
acceptEdits: "auto",
|
||||||
|
bypassPermissions: "full-access",
|
||||||
|
};
|
||||||
|
|
||||||
|
const CODEX_TO_CLAUDE_MODE: Record<string, string> = {
|
||||||
|
"read-only": "plan",
|
||||||
|
auto: "default",
|
||||||
|
"full-access": "bypassPermissions",
|
||||||
|
};
|
||||||
|
|
||||||
|
function mapModeAcrossProviders(
|
||||||
|
sourceMode: string,
|
||||||
|
sourceProvider: AgentProvider,
|
||||||
|
targetProvider: AgentProvider
|
||||||
|
): string {
|
||||||
|
if (sourceProvider === targetProvider) {
|
||||||
|
return sourceMode;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sourceProvider === "claude" && targetProvider === "codex") {
|
||||||
|
const mapped = CLAUDE_TO_CODEX_MODE[sourceMode];
|
||||||
|
if (mapped) {
|
||||||
|
return mapped;
|
||||||
|
}
|
||||||
|
return "auto";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sourceProvider === "codex" && targetProvider === "claude") {
|
||||||
|
const mapped = CODEX_TO_CLAUDE_MODE[sourceMode];
|
||||||
|
if (mapped) {
|
||||||
|
return mapped;
|
||||||
|
}
|
||||||
|
return "default";
|
||||||
|
}
|
||||||
|
|
||||||
|
return sourceMode;
|
||||||
|
}
|
||||||
|
|
||||||
const AgentProviderEnum = z.enum(
|
const AgentProviderEnum = z.enum(
|
||||||
AGENT_PROVIDER_DEFINITIONS.map((definition) => definition.id) as [
|
AGENT_PROVIDER_DEFINITIONS.map((definition) => definition.id) as [
|
||||||
AgentProvider,
|
AgentProvider,
|
||||||
@@ -138,59 +179,91 @@ export async function createAgentMcpServer(
|
|||||||
version: "2.0.0",
|
version: "2.0.0",
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const agentToAgentInputSchema = {
|
||||||
|
title: z
|
||||||
|
.string()
|
||||||
|
.trim()
|
||||||
|
.min(1, "Title is required")
|
||||||
|
.max(40, "Title must be 40 characters or fewer")
|
||||||
|
.describe(
|
||||||
|
"Short descriptive title (<= 40 chars) summarizing the agent's focus. Use a single concise sentence that fits on mobile."
|
||||||
|
),
|
||||||
|
agentType: AgentProviderEnum.optional().describe(
|
||||||
|
"Optional agent implementation to spawn. Defaults to 'claude'."
|
||||||
|
),
|
||||||
|
initialPrompt: z
|
||||||
|
.string()
|
||||||
|
.optional()
|
||||||
|
.describe(
|
||||||
|
"Optional task to start immediately after creation (non-blocking)."
|
||||||
|
),
|
||||||
|
background: z
|
||||||
|
.boolean()
|
||||||
|
.optional()
|
||||||
|
.default(false)
|
||||||
|
.describe(
|
||||||
|
"Run agent in background. If false (default), waits for completion or permission request. If true, returns immediately."
|
||||||
|
),
|
||||||
|
};
|
||||||
|
|
||||||
|
const topLevelInputSchema = {
|
||||||
|
cwd: z
|
||||||
|
.string()
|
||||||
|
.describe(
|
||||||
|
"Required working directory for the agent (absolute, relative, or ~)."
|
||||||
|
),
|
||||||
|
title: z
|
||||||
|
.string()
|
||||||
|
.trim()
|
||||||
|
.min(1, "Title is required")
|
||||||
|
.max(40, "Title must be 40 characters or fewer")
|
||||||
|
.describe(
|
||||||
|
"Short descriptive title (<= 40 chars) summarizing the agent's focus. Use a single concise sentence that fits on mobile."
|
||||||
|
),
|
||||||
|
agentType: AgentProviderEnum.optional().describe(
|
||||||
|
"Optional agent implementation to spawn. Defaults to 'claude'."
|
||||||
|
),
|
||||||
|
initialPrompt: z
|
||||||
|
.string()
|
||||||
|
.optional()
|
||||||
|
.describe(
|
||||||
|
"Optional task to start immediately after creation (non-blocking)."
|
||||||
|
),
|
||||||
|
initialMode: z
|
||||||
|
.string()
|
||||||
|
.describe("Required session mode to configure before the first run."),
|
||||||
|
worktreeName: z
|
||||||
|
.string()
|
||||||
|
.optional()
|
||||||
|
.describe(
|
||||||
|
"Optional git worktree branch name (lowercase alphanumerics + hyphen)."
|
||||||
|
),
|
||||||
|
background: z
|
||||||
|
.boolean()
|
||||||
|
.optional()
|
||||||
|
.default(false)
|
||||||
|
.describe(
|
||||||
|
"Run agent in background. If false (default), waits for completion or permission request. If true, returns immediately."
|
||||||
|
),
|
||||||
|
parentAgentId: z
|
||||||
|
.string()
|
||||||
|
.optional()
|
||||||
|
.describe(
|
||||||
|
"Optional parent agent ID. When set, this agent is a child of the specified parent agent."
|
||||||
|
),
|
||||||
|
};
|
||||||
|
|
||||||
|
const createAgentInputSchema = callerAgentId
|
||||||
|
? agentToAgentInputSchema
|
||||||
|
: topLevelInputSchema;
|
||||||
|
|
||||||
server.registerTool(
|
server.registerTool(
|
||||||
"create_agent",
|
"create_agent",
|
||||||
{
|
{
|
||||||
title: "Create Agent",
|
title: "Create Agent",
|
||||||
description:
|
description:
|
||||||
"Create a new Claude or Codex agent tied to a working directory. Optionally run an initial prompt immediately or create a git worktree for the agent.",
|
"Create a new Claude or Codex agent tied to a working directory. Optionally run an initial prompt immediately or create a git worktree for the agent.",
|
||||||
inputSchema: {
|
inputSchema: createAgentInputSchema,
|
||||||
cwd: z
|
|
||||||
.string()
|
|
||||||
.describe(
|
|
||||||
"Required working directory for the agent (absolute, relative, or ~)."
|
|
||||||
),
|
|
||||||
title: z
|
|
||||||
.string()
|
|
||||||
.trim()
|
|
||||||
.min(1, "Title is required")
|
|
||||||
.max(40, "Title must be 40 characters or fewer")
|
|
||||||
.describe(
|
|
||||||
"Short descriptive title (<= 40 chars) summarizing the agent's focus. Use a single concise sentence that fits on mobile."
|
|
||||||
),
|
|
||||||
agentType: AgentProviderEnum.optional().describe(
|
|
||||||
"Optional agent implementation to spawn. Defaults to 'claude'."
|
|
||||||
),
|
|
||||||
initialPrompt: z
|
|
||||||
.string()
|
|
||||||
.optional()
|
|
||||||
.describe(
|
|
||||||
"Optional task to start immediately after creation (non-blocking)."
|
|
||||||
),
|
|
||||||
initialMode: z
|
|
||||||
.string()
|
|
||||||
.optional()
|
|
||||||
.describe("Optional session mode to configure before the first run."),
|
|
||||||
worktreeName: z
|
|
||||||
.string()
|
|
||||||
.optional()
|
|
||||||
.describe(
|
|
||||||
"Optional git worktree branch name (lowercase alphanumerics + hyphen)."
|
|
||||||
),
|
|
||||||
background: z
|
|
||||||
.boolean()
|
|
||||||
.optional()
|
|
||||||
.default(false)
|
|
||||||
.describe(
|
|
||||||
"Run agent in background. If false (default), waits for completion or permission request. If true, returns immediately."
|
|
||||||
),
|
|
||||||
parentAgentId: z
|
|
||||||
.string()
|
|
||||||
.optional()
|
|
||||||
.describe(
|
|
||||||
"Optional parent agent ID. When set, this agent is a child of the specified parent agent."
|
|
||||||
),
|
|
||||||
},
|
|
||||||
outputSchema: {
|
outputSchema: {
|
||||||
agentId: z.string(),
|
agentId: z.string(),
|
||||||
type: AgentProviderEnum,
|
type: AgentProviderEnum,
|
||||||
@@ -208,35 +281,79 @@ export async function createAgentMcpServer(
|
|||||||
permission: AgentPermissionRequestPayloadSchema.nullable().optional(),
|
permission: AgentPermissionRequestPayloadSchema.nullable().optional(),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
async ({
|
async (args) => {
|
||||||
cwd,
|
const {
|
||||||
agentType,
|
agentType,
|
||||||
initialPrompt,
|
initialPrompt,
|
||||||
initialMode,
|
background = false,
|
||||||
worktreeName,
|
title,
|
||||||
background = false,
|
} = args as {
|
||||||
title,
|
cwd?: string;
|
||||||
parentAgentId,
|
agentType?: AgentProvider;
|
||||||
}) => {
|
initialPrompt?: string;
|
||||||
let resolvedCwd = expandPath(cwd);
|
initialMode?: string;
|
||||||
|
worktreeName?: string;
|
||||||
|
background?: boolean;
|
||||||
|
title: string;
|
||||||
|
parentAgentId?: string;
|
||||||
|
};
|
||||||
|
|
||||||
if (worktreeName) {
|
let resolvedCwd: string;
|
||||||
const worktree = await createWorktree({
|
let resolvedMode: string | undefined;
|
||||||
branchName: worktreeName,
|
let resolvedParentAgentId: string | undefined;
|
||||||
cwd: resolvedCwd,
|
|
||||||
worktreeSlug: worktreeName,
|
if (callerAgentId) {
|
||||||
});
|
const parentAgent = agentManager.getAgent(callerAgentId);
|
||||||
resolvedCwd = worktree.worktreePath;
|
if (!parentAgent) {
|
||||||
|
throw new Error(`Parent agent ${callerAgentId} not found`);
|
||||||
|
}
|
||||||
|
resolvedCwd = parentAgent.cwd;
|
||||||
|
resolvedParentAgentId = callerAgentId;
|
||||||
|
|
||||||
|
const provider: AgentProvider = agentType ?? "claude";
|
||||||
|
const parentMode = parentAgent.currentModeId;
|
||||||
|
if (parentMode) {
|
||||||
|
resolvedMode = mapModeAcrossProviders(
|
||||||
|
parentMode,
|
||||||
|
parentAgent.provider,
|
||||||
|
provider
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const topLevelArgs = args as unknown as {
|
||||||
|
cwd: string;
|
||||||
|
initialMode: string;
|
||||||
|
worktreeName?: string;
|
||||||
|
parentAgentId?: string;
|
||||||
|
};
|
||||||
|
const {
|
||||||
|
cwd,
|
||||||
|
initialMode,
|
||||||
|
worktreeName,
|
||||||
|
parentAgentId,
|
||||||
|
} = topLevelArgs;
|
||||||
|
|
||||||
|
resolvedCwd = expandPath(cwd);
|
||||||
|
|
||||||
|
if (worktreeName) {
|
||||||
|
const worktree = await createWorktree({
|
||||||
|
branchName: worktreeName,
|
||||||
|
cwd: resolvedCwd,
|
||||||
|
worktreeSlug: worktreeName,
|
||||||
|
});
|
||||||
|
resolvedCwd = worktree.worktreePath;
|
||||||
|
}
|
||||||
|
|
||||||
|
resolvedMode = initialMode;
|
||||||
|
resolvedParentAgentId = parentAgentId;
|
||||||
}
|
}
|
||||||
|
|
||||||
const provider: AgentProvider = agentType ?? "claude";
|
const provider: AgentProvider = agentType ?? "claude";
|
||||||
const normalizedTitle = title?.trim() ?? null;
|
const normalizedTitle = title?.trim() ?? null;
|
||||||
// Use explicit parentAgentId if provided, otherwise default to caller agent ID
|
|
||||||
const resolvedParentAgentId = parentAgentId ?? callerAgentId;
|
|
||||||
const snapshot = await agentManager.createAgent({
|
const snapshot = await agentManager.createAgent({
|
||||||
provider,
|
provider,
|
||||||
cwd: resolvedCwd,
|
cwd: resolvedCwd,
|
||||||
modeId: initialMode,
|
modeId: resolvedMode,
|
||||||
title: normalizedTitle ?? undefined,
|
title: normalizedTitle ?? undefined,
|
||||||
parentAgentId: resolvedParentAgentId,
|
parentAgentId: resolvedParentAgentId,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -728,6 +728,12 @@ class ClaudeAgentSession implements AgentSession {
|
|||||||
stderr: (data: string) => {
|
stderr: (data: string) => {
|
||||||
console.error("[ClaudeAgentSDK]", data.trim());
|
console.error("[ClaudeAgentSDK]", data.trim());
|
||||||
},
|
},
|
||||||
|
env: {
|
||||||
|
...process.env,
|
||||||
|
// Increase MCP timeouts for long-running tool calls (10 minutes)
|
||||||
|
MCP_TIMEOUT: "600000",
|
||||||
|
MCP_TOOL_TIMEOUT: "600000",
|
||||||
|
},
|
||||||
...this.config.extra?.claude,
|
...this.config.extra?.claude,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -117,6 +117,15 @@ const MODE_PRESETS: Record<
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
function validateCodexMode(modeId: string): void {
|
||||||
|
if (!(modeId in MODE_PRESETS)) {
|
||||||
|
const validModes = Object.keys(MODE_PRESETS).join(", ");
|
||||||
|
throw new Error(
|
||||||
|
`Invalid Codex mode "${modeId}". Valid modes are: ${validModes}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function createToolCallTimelineItem(
|
function createToolCallTimelineItem(
|
||||||
data: Omit<ToolCallTimelineItem, "type">
|
data: Omit<ToolCallTimelineItem, "type">
|
||||||
): AgentTimelineItem {
|
): AgentTimelineItem {
|
||||||
@@ -2757,9 +2766,13 @@ class CodexMcpAgentSession implements AgentSession {
|
|||||||
private pendingResumeFile: string | null = null;
|
private pendingResumeFile: string | null = null;
|
||||||
|
|
||||||
constructor(config: CodexMcpAgentConfig, resumeHandle?: AgentPersistenceHandle) {
|
constructor(config: CodexMcpAgentConfig, resumeHandle?: AgentPersistenceHandle) {
|
||||||
|
if (config.modeId === undefined) {
|
||||||
|
throw new Error("Codex agent requires modeId to be specified");
|
||||||
|
}
|
||||||
|
validateCodexMode(config.modeId);
|
||||||
|
|
||||||
this.config = config;
|
this.config = config;
|
||||||
this.currentMode =
|
this.currentMode = config.modeId;
|
||||||
config.modeId !== undefined ? config.modeId : DEFAULT_CODEX_MODE_ID;
|
|
||||||
this.pendingLocalId = `codex-${randomUUID()}`;
|
this.pendingLocalId = `codex-${randomUUID()}`;
|
||||||
|
|
||||||
if (resumeHandle) {
|
if (resumeHandle) {
|
||||||
@@ -3053,6 +3066,8 @@ class CodexMcpAgentSession implements AgentSession {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async setMode(modeId: string): Promise<void> {
|
async setMode(modeId: string): Promise<void> {
|
||||||
|
validateCodexMode(modeId);
|
||||||
|
|
||||||
this.currentMode = modeId;
|
this.currentMode = modeId;
|
||||||
this.config.modeId = modeId;
|
this.config.modeId = modeId;
|
||||||
|
|
||||||
|
|||||||
@@ -243,7 +243,6 @@ export const AgentSnapshotPayloadSchema = z.object({
|
|||||||
updatedAt: z.string(),
|
updatedAt: z.string(),
|
||||||
lastUserMessageAt: z.string().nullable(),
|
lastUserMessageAt: z.string().nullable(),
|
||||||
status: AgentStatusSchema,
|
status: AgentStatusSchema,
|
||||||
sessionId: z.string().nullable(),
|
|
||||||
capabilities: AgentCapabilityFlagsSchema,
|
capabilities: AgentCapabilityFlagsSchema,
|
||||||
currentModeId: z.string().nullable(),
|
currentModeId: z.string().nullable(),
|
||||||
availableModes: z.array(AgentModeSchema),
|
availableModes: z.array(AgentModeSchema),
|
||||||
@@ -340,7 +339,7 @@ export const SendAgentMessageSchema = z.object({
|
|||||||
|
|
||||||
export const SendAgentAudioSchema = z.object({
|
export const SendAgentAudioSchema = z.object({
|
||||||
type: z.literal("send_agent_audio"),
|
type: z.literal("send_agent_audio"),
|
||||||
agentId: z.string(),
|
agentId: z.string().optional(), // Required for auto_run mode, optional for transcribe_only
|
||||||
audio: z.string(), // base64 encoded
|
audio: z.string(), // base64 encoded
|
||||||
format: z.string(),
|
format: z.string(),
|
||||||
isLast: z.boolean(),
|
isLast: z.boolean(),
|
||||||
|
|||||||
@@ -49,7 +49,6 @@ function createManagedAgent(
|
|||||||
provider,
|
provider,
|
||||||
cwd,
|
cwd,
|
||||||
session,
|
session,
|
||||||
sessionId: overrides.sessionId ?? "session-123",
|
|
||||||
capabilities:
|
capabilities:
|
||||||
overrides.capabilities ??
|
overrides.capabilities ??
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -609,7 +609,6 @@ export class Session {
|
|||||||
updatedAt: updatedAt.toISOString(),
|
updatedAt: updatedAt.toISOString(),
|
||||||
lastUserMessageAt: lastUserMessageAt ? lastUserMessageAt.toISOString() : null,
|
lastUserMessageAt: lastUserMessageAt ? lastUserMessageAt.toISOString() : null,
|
||||||
status: record.lastStatus,
|
status: record.lastStatus,
|
||||||
sessionId: null,
|
|
||||||
capabilities: defaultCapabilities,
|
capabilities: defaultCapabilities,
|
||||||
currentModeId: record.lastModeId ?? null,
|
currentModeId: record.lastModeId ?? null,
|
||||||
availableModes: [],
|
availableModes: [],
|
||||||
@@ -1107,6 +1106,17 @@ export class Session {
|
|||||||
const { agentId, audio, format, isLast, requestId, mode } = msg;
|
const { agentId, audio, format, isLast, requestId, mode } = msg;
|
||||||
const shouldAutoRun = mode === "auto_run";
|
const shouldAutoRun = mode === "auto_run";
|
||||||
|
|
||||||
|
// agentId is required for auto_run mode
|
||||||
|
if (shouldAutoRun && !agentId) {
|
||||||
|
console.error(
|
||||||
|
`[Session ${this.clientId}] agentId is required for auto_run mode`
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use placeholder for logging when agentId is not provided
|
||||||
|
const logAgentId = agentId ?? "transcription";
|
||||||
|
|
||||||
// Decode base64
|
// Decode base64
|
||||||
const audioBuffer = Buffer.from(audio, "base64");
|
const audioBuffer = Buffer.from(audio, "base64");
|
||||||
|
|
||||||
@@ -1114,20 +1124,20 @@ export class Session {
|
|||||||
// In the future, we might want to buffer chunks similar to realtime audio
|
// In the future, we might want to buffer chunks similar to realtime audio
|
||||||
if (!isLast) {
|
if (!isLast) {
|
||||||
console.log(
|
console.log(
|
||||||
`[Session ${this.clientId}] Buffering agent audio chunk for agent ${agentId}`
|
`[Session ${this.clientId}] Buffering agent audio chunk for agent ${logAgentId}`
|
||||||
);
|
);
|
||||||
// TODO: Implement buffering if needed
|
// TODO: Implement buffering if needed
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(
|
console.log(
|
||||||
`[Session ${this.clientId}] Transcribing audio for agent ${agentId}`
|
`[Session ${this.clientId}] Transcribing audio for agent ${logAgentId}`
|
||||||
);
|
);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Transcribe the audio
|
// Transcribe the audio
|
||||||
const result = await this.sttManager.transcribe(audioBuffer, format, {
|
const result = await this.sttManager.transcribe(audioBuffer, format, {
|
||||||
agentId,
|
agentId: logAgentId,
|
||||||
requestId,
|
requestId,
|
||||||
label: shouldAutoRun ? "dictation:auto_run" : "dictation",
|
label: shouldAutoRun ? "dictation:auto_run" : "dictation",
|
||||||
});
|
});
|
||||||
@@ -1135,13 +1145,13 @@ export class Session {
|
|||||||
const transcriptText = result.text.trim();
|
const transcriptText = result.text.trim();
|
||||||
if (!transcriptText) {
|
if (!transcriptText) {
|
||||||
console.log(
|
console.log(
|
||||||
`[Session ${this.clientId}] Empty transcription for agent ${agentId}, ignoring`
|
`[Session ${this.clientId}] Empty transcription for agent ${logAgentId}, ignoring`
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(
|
console.log(
|
||||||
`[Session ${this.clientId}] Transcribed audio for agent ${agentId}: ${transcriptText}`
|
`[Session ${this.clientId}] Transcribed audio for agent ${logAgentId}: ${transcriptText}`
|
||||||
);
|
);
|
||||||
|
|
||||||
// Emit transcription result to client with requestId
|
// Emit transcription result to client with requestId
|
||||||
@@ -1162,16 +1172,19 @@ export class Session {
|
|||||||
|
|
||||||
if (!shouldAutoRun) {
|
if (!shouldAutoRun) {
|
||||||
console.log(
|
console.log(
|
||||||
`[Session ${this.clientId}] Completed transcription for agent ${agentId} (requestId: ${requestId ?? "n/a"})`
|
`[Session ${this.clientId}] Completed transcription for agent ${logAgentId} (requestId: ${requestId ?? "n/a"})`
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// At this point, agentId is guaranteed to be defined (validated at function start)
|
||||||
|
const validAgentId = agentId!;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await this.ensureAgentLoaded(agentId);
|
await this.ensureAgentLoaded(validAgentId);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.handleAgentRunError(
|
this.handleAgentRunError(
|
||||||
agentId,
|
validAgentId,
|
||||||
error,
|
error,
|
||||||
"Failed to initialize agent before sending audio prompt"
|
"Failed to initialize agent before sending audio prompt"
|
||||||
);
|
);
|
||||||
@@ -1179,10 +1192,10 @@ export class Session {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await this.interruptAgentIfRunning(agentId);
|
await this.interruptAgentIfRunning(validAgentId);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.handleAgentRunError(
|
this.handleAgentRunError(
|
||||||
agentId,
|
validAgentId,
|
||||||
error,
|
error,
|
||||||
"Failed to interrupt running agent before sending audio prompt"
|
"Failed to interrupt running agent before sending audio prompt"
|
||||||
);
|
);
|
||||||
@@ -1190,22 +1203,22 @@ export class Session {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
this.agentManager.recordUserMessage(agentId, transcriptText);
|
this.agentManager.recordUserMessage(validAgentId, transcriptText);
|
||||||
} catch (recordError) {
|
} catch (recordError) {
|
||||||
console.error(
|
console.error(
|
||||||
`[Session ${this.clientId}] Failed to record transcribed user message for agent ${agentId}:`,
|
`[Session ${this.clientId}] Failed to record transcribed user message for agent ${validAgentId}:`,
|
||||||
recordError
|
recordError
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Send transcribed text to agent
|
// Send transcribed text to agent
|
||||||
this.startAgentStream(agentId, transcriptText);
|
this.startAgentStream(validAgentId, transcriptText);
|
||||||
console.log(
|
console.log(
|
||||||
`[Session ${this.clientId}] Sent transcribed text to agent ${agentId}`
|
`[Session ${this.clientId}] Sent transcribed text to agent ${validAgentId}`
|
||||||
);
|
);
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
console.error(
|
console.error(
|
||||||
`[Session ${this.clientId}] Failed to process audio for agent ${agentId}:`,
|
`[Session ${this.clientId}] Failed to process audio for agent ${logAgentId}:`,
|
||||||
error
|
error
|
||||||
);
|
);
|
||||||
this.emit({
|
this.emit({
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import type {
|
|||||||
AgentPersistenceHandle,
|
AgentPersistenceHandle,
|
||||||
AgentProvider,
|
AgentProvider,
|
||||||
} from "../agent/agent-sdk-types.js";
|
} from "../agent/agent-sdk-types.js";
|
||||||
|
import { getAgentProviderDefinition } from "../agent/provider-manifest.js";
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// Configuration
|
// Configuration
|
||||||
@@ -147,6 +148,12 @@ export class DaemonClient {
|
|||||||
// Record the current queue position so we only check NEW messages
|
// Record the current queue position so we only check NEW messages
|
||||||
const startPosition = this.messageQueue.length;
|
const startPosition = this.messageQueue.length;
|
||||||
|
|
||||||
|
// Apply default modeId if not provided (mimics frontend behavior)
|
||||||
|
const modeId =
|
||||||
|
options.modeId ??
|
||||||
|
getAgentProviderDefinition(options.provider).defaultModeId ??
|
||||||
|
undefined;
|
||||||
|
|
||||||
this.send({
|
this.send({
|
||||||
type: "create_agent_request",
|
type: "create_agent_request",
|
||||||
requestId,
|
requestId,
|
||||||
@@ -155,7 +162,7 @@ export class DaemonClient {
|
|||||||
cwd: options.cwd,
|
cwd: options.cwd,
|
||||||
title: options.title,
|
title: options.title,
|
||||||
model: options.model,
|
model: options.model,
|
||||||
modeId: options.modeId,
|
modeId,
|
||||||
mcpServers: options.mcpServers,
|
mcpServers: options.mcpServers,
|
||||||
extra: options.extra,
|
extra: options.extra,
|
||||||
},
|
},
|
||||||
@@ -209,6 +216,12 @@ export class DaemonClient {
|
|||||||
// Record the current queue position so we only check NEW messages
|
// Record the current queue position so we only check NEW messages
|
||||||
const startPosition = this.messageQueue.length;
|
const startPosition = this.messageQueue.length;
|
||||||
|
|
||||||
|
// Apply default modeId if not provided (mimics frontend behavior)
|
||||||
|
const modeId =
|
||||||
|
options.modeId ??
|
||||||
|
getAgentProviderDefinition(options.provider).defaultModeId ??
|
||||||
|
undefined;
|
||||||
|
|
||||||
this.send({
|
this.send({
|
||||||
type: "create_agent_request",
|
type: "create_agent_request",
|
||||||
requestId,
|
requestId,
|
||||||
@@ -217,7 +230,7 @@ export class DaemonClient {
|
|||||||
cwd: options.cwd,
|
cwd: options.cwd,
|
||||||
title: options.title,
|
title: options.title,
|
||||||
model: options.model,
|
model: options.model,
|
||||||
modeId: options.modeId,
|
modeId,
|
||||||
mcpServers: options.mcpServers,
|
mcpServers: options.mcpServers,
|
||||||
extra: options.extra,
|
extra: options.extra,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import { ClaudeAgentClient } from "./packages/server/src/server/agent/providers/claude-agent.js";
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const client = new ClaudeAgentClient();
|
||||||
|
const results = await client.listPersistedAgents({ limit: 30 });
|
||||||
|
console.log("Total results:", results.length);
|
||||||
|
console.log("Results:");
|
||||||
|
for (const r of results) {
|
||||||
|
console.log(` - ${r.sessionId.slice(0,8)} | ${r.cwd} | ${r.title?.slice(0,30)}`);
|
||||||
|
}
|
||||||
|
const target = results.find(r => r.sessionId.includes("0fea55e9"));
|
||||||
|
if (target) {
|
||||||
|
console.log("\nFound target session:", target.sessionId);
|
||||||
|
} else {
|
||||||
|
console.log("\nTarget session 0fea55e9 NOT FOUND in results");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
main().catch(console.error);
|
||||||
Reference in New Issue
Block a user