mirror of
https://github.com/getpaseo/paseo.git
synced 2026-07-29 12:01:31 +00:00
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:
@@ -4,9 +4,8 @@ import {
|
||||
useEffect,
|
||||
useMemo,
|
||||
useCallback,
|
||||
useLayoutEffect,
|
||||
} from "react";
|
||||
import type { ReactElement, RefObject, ReactNode } from "react";
|
||||
import type { ReactElement, ReactNode } from "react";
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
@@ -21,9 +20,6 @@ import {
|
||||
type LayoutChangeEvent,
|
||||
type ListRenderItem,
|
||||
Platform,
|
||||
NativeSyntheticEvent,
|
||||
TextInputContentSizeChangeEventData,
|
||||
TextInputKeyPressEventData,
|
||||
} from "react-native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { useReanimatedKeyboardAnimation } from "react-native-keyboard-controller";
|
||||
@@ -35,17 +31,12 @@ import Animated, {
|
||||
runOnJS,
|
||||
} from "react-native-reanimated";
|
||||
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 { useRecentPaths } from "@/hooks/use-recent-paths";
|
||||
import { useRouter } from "expo-router";
|
||||
import { generateMessageId } from "@/types/stream";
|
||||
import { useDictation } from "@/hooks/use-dictation";
|
||||
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 { MessageInput } from "./message-input";
|
||||
import { useDaemonConnections, type ConnectionStatus } from "@/contexts/daemon-connections-context";
|
||||
import type {
|
||||
AgentProvider,
|
||||
@@ -59,7 +50,7 @@ import { formatConnectionStatus } from "@/utils/daemons";
|
||||
import { trackAnalyticsEvent } from "@/utils/analytics";
|
||||
import type { SessionContextValue } from "@/contexts/session-context";
|
||||
import type { UseWebSocketReturn } from "@/hooks/use-websocket";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
import { useSessionStore, type Agent } from "@/stores/session-store";
|
||||
import {
|
||||
AssistantDropdown,
|
||||
DropdownField,
|
||||
@@ -82,16 +73,6 @@ interface AgentFlowModalProps {
|
||||
onAfterClose?: () => void;
|
||||
}
|
||||
|
||||
type DictationToastConfig = {
|
||||
variant: DictationToastVariant;
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
meta?: string;
|
||||
actionLabel?: string;
|
||||
onAction?: () => void;
|
||||
onDismiss?: () => void;
|
||||
};
|
||||
|
||||
interface ModalWrapperProps {
|
||||
isVisible: boolean;
|
||||
onClose: () => void;
|
||||
@@ -111,52 +92,17 @@ type CreateAgentSessionSlice = {
|
||||
}) => void;
|
||||
resumeAgent: (options: { handle: any; overrides?: any; requestId?: string }) => void;
|
||||
sendAgentAudio: (
|
||||
agentId: string,
|
||||
agentId: string | undefined,
|
||||
audioBlob: Blob,
|
||||
requestId?: string,
|
||||
options?: { mode?: "transcribe_only" | "auto_run" }
|
||||
) => Promise<void>;
|
||||
agents: Map<string, any>;
|
||||
agents: Map<string, Agent>;
|
||||
};
|
||||
|
||||
const BACKDROP_OPACITY = 0.55;
|
||||
const IMPORT_PAGE_SIZE = 20;
|
||||
const PROMPT_MIN_HEIGHT = 64;
|
||||
const PROMPT_MAX_HEIGHT = 200;
|
||||
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 = {
|
||||
provider: AgentProvider;
|
||||
@@ -237,7 +183,6 @@ function AgentFlowModal({
|
||||
const backdropOpacity = useSharedValue(0);
|
||||
const { height: keyboardHeight } = useReanimatedKeyboardAnimation();
|
||||
const isCompactLayout = screenWidth < 720;
|
||||
const shouldHandlePromptDesktopSubmit = IS_WEB;
|
||||
const shouldAutoFocusPrompt = IS_WEB;
|
||||
const isImportFlow = flow === "import";
|
||||
const isCreateFlow = !isImportFlow;
|
||||
@@ -451,7 +396,6 @@ function AgentFlowModal({
|
||||
|
||||
const [isMounted, setIsMounted] = useState(isVisible);
|
||||
const [initialPrompt, setInitialPrompt] = useState("");
|
||||
const [promptInputHeight, setPromptInputHeight] = useState(PROMPT_MIN_HEIGHT);
|
||||
const [baseBranch, setBaseBranch] = useState("");
|
||||
const [createNewBranch, setCreateNewBranch] = useState(false);
|
||||
const [branchName, setBranchName] = useState("");
|
||||
@@ -469,103 +413,12 @@ function AgentFlowModal({
|
||||
);
|
||||
const [isImportLoading, setIsImportLoading] = useState(false);
|
||||
const [importError, setImportError] = useState<string | null>(null);
|
||||
const [dictationDebugInfo, setDictationDebugInfo] = useState<AudioDebugInfo | null>(null);
|
||||
const [openDropdown, setOpenDropdown] = useState<DropdownKey | null>(null);
|
||||
const pendingRequestIdRef = useRef<string | null>(null);
|
||||
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 hasPendingDictation = dictationPendingRequestId !== null;
|
||||
const shouldListenForStatus = isVisible || hasPendingCreateOrResume;
|
||||
const shouldListenForDictation =
|
||||
isCreateFlow && (isVisible || hasPendingDictation);
|
||||
|
||||
const idleProviderPrefetchHandleRef = useRef<ReturnType<
|
||||
typeof InteractionManager.runAfterInteractions
|
||||
@@ -612,131 +465,15 @@ function AgentFlowModal({
|
||||
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 ids = new Set<string>();
|
||||
if (!agents) {
|
||||
return ids;
|
||||
}
|
||||
agents.forEach((agent: any) => {
|
||||
if (agent.sessionId) {
|
||||
ids.add(agent.sessionId);
|
||||
}
|
||||
// Use persistence.sessionId for filtering - this is the canonical reference
|
||||
// to the provider's session file (Claude resume token, Codex thread ID, etc.)
|
||||
agents.forEach((agent) => {
|
||||
const persistedSessionId = agent.persistence?.sessionId;
|
||||
if (persistedSessionId) {
|
||||
ids.add(persistedSessionId);
|
||||
@@ -769,23 +506,6 @@ function AgentFlowModal({
|
||||
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(
|
||||
(action: "create" | "resume" | "dictation" | "import_list", reason?: string | null) => {
|
||||
trackAnalyticsEvent({
|
||||
@@ -805,9 +525,6 @@ function AgentFlowModal({
|
||||
|
||||
const resetFormState = useCallback(() => {
|
||||
setInitialPrompt("");
|
||||
promptBaselineHeightRef.current = null;
|
||||
promptInputHeightRef.current = PROMPT_MIN_HEIGHT;
|
||||
setPromptHeight(PROMPT_MIN_HEIGHT);
|
||||
setBaseBranch("");
|
||||
setCreateNewBranch(false);
|
||||
setBranchName("");
|
||||
@@ -822,160 +539,8 @@ function AgentFlowModal({
|
||||
pendingRequestIdRef.current = null;
|
||||
pendingNavigationServerIdRef.current = null;
|
||||
shouldSyncBaseBranchRef.current = true;
|
||||
dictationRequestIdRef.current = null;
|
||||
setDictationDebugInfo(null);
|
||||
void cancelDictation();
|
||||
resetDictation();
|
||||
cancelRepoInfo();
|
||||
}, [cancelRepoInfo, cancelDictation, resetDictation, resetRepoInfo, setPromptHeight]);
|
||||
|
||||
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,
|
||||
]);
|
||||
}, [cancelRepoInfo, resetRepoInfo]);
|
||||
|
||||
const navigateToAgentIfNeeded = useCallback(() => {
|
||||
const agentId = pendingNavigationAgentIdRef.current;
|
||||
@@ -1591,36 +1156,6 @@ function AgentFlowModal({
|
||||
};
|
||||
}, [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(() => {
|
||||
if (!isVisible || !isImportFlow) {
|
||||
return;
|
||||
@@ -1638,24 +1173,6 @@ function AgentFlowModal({
|
||||
|
||||
const shouldRender = isVisible || isMounted;
|
||||
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(() => {
|
||||
if (isNonGitDirectory) {
|
||||
@@ -1720,26 +1237,6 @@ function AgentFlowModal({
|
||||
Boolean(gitBlockingError) ||
|
||||
isLoading ||
|
||||
!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(
|
||||
() => insets.top + defaultTheme.spacing[4],
|
||||
[insets.top]
|
||||
@@ -1875,29 +1372,25 @@ function AgentFlowModal({
|
||||
) : null}
|
||||
</View>
|
||||
|
||||
<PromptSection
|
||||
value={initialPrompt}
|
||||
isLoading={isLoading}
|
||||
onChange={(text) => {
|
||||
setInitialPrompt(text);
|
||||
setErrorMessage("");
|
||||
}}
|
||||
inputRef={promptInputRef}
|
||||
inputHeight={promptInputHeight}
|
||||
onContentSizeChange={handlePromptContentSizeChange}
|
||||
onDesktopSubmit={handlePromptDesktopSubmitKeyPress}
|
||||
autoFocus={shouldAutoFocusPrompt}
|
||||
scrollEnabled={promptInputHeight >= PROMPT_MAX_HEIGHT}
|
||||
accessory={dictationAccessory}
|
||||
/>
|
||||
|
||||
{shouldShowAudioDebug && dictationDebugInfo ? (
|
||||
<AudioDebugNotice
|
||||
info={dictationDebugInfo}
|
||||
onDismiss={() => setDictationDebugInfo(null)}
|
||||
title="Prompt Dictation Debug"
|
||||
<View style={styles.formSection}>
|
||||
<Text style={styles.label}>Initial Prompt</Text>
|
||||
<MessageInput
|
||||
value={initialPrompt}
|
||||
onChangeText={(text) => {
|
||||
setInitialPrompt(text);
|
||||
setErrorMessage("");
|
||||
}}
|
||||
onSubmit={() => {
|
||||
void handleCreate();
|
||||
}}
|
||||
ws={effectiveWs}
|
||||
sendAgentAudio={sendAgentAudio}
|
||||
placeholder="Describe what you want the agent to do"
|
||||
autoFocus={shouldAutoFocusPrompt}
|
||||
disabled={isLoading || !isTargetDaemonReady}
|
||||
isSubmitDisabled={createDisabled}
|
||||
/>
|
||||
) : null}
|
||||
</View>
|
||||
|
||||
<View
|
||||
style={[
|
||||
@@ -2048,13 +1541,6 @@ function AgentFlowModal({
|
||||
)}
|
||||
</Pressable>
|
||||
</Animated.View>
|
||||
{promptDictationToast ? (
|
||||
<View style={styles.dictationToastPortal} pointerEvents="box-none">
|
||||
<View pointerEvents="auto">
|
||||
<DictationStatusNotice {...promptDictationToast} />
|
||||
</View>
|
||||
</View>
|
||||
) : null}
|
||||
</>
|
||||
) : (
|
||||
<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 {
|
||||
baseBranch: string;
|
||||
onBaseBranchChange: (value: string) => void;
|
||||
@@ -2757,24 +2065,9 @@ const styles = StyleSheet.create(((theme: any) => ({
|
||||
paddingBottom: theme.spacing[8],
|
||||
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: {
|
||||
gap: theme.spacing[3],
|
||||
},
|
||||
labelRow: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
gap: theme.spacing[3],
|
||||
},
|
||||
label: {
|
||||
color: theme.colors.foreground,
|
||||
fontSize: theme.fontSize.sm,
|
||||
@@ -2899,13 +2192,6 @@ const styles = StyleSheet.create(((theme: any) => ({
|
||||
alignItems: "center",
|
||||
paddingVertical: theme.spacing[4],
|
||||
},
|
||||
promptInput: {
|
||||
minHeight: theme.spacing[24],
|
||||
textAlignVertical: "top",
|
||||
outlineWidth: 0,
|
||||
outlineColor: "transparent",
|
||||
outlineStyle: "none",
|
||||
},
|
||||
inputDisabled: {
|
||||
opacity: theme.opacity[50],
|
||||
},
|
||||
@@ -2921,55 +2207,6 @@ const styles = StyleSheet.create(((theme: any) => ({
|
||||
color: theme.colors.mutedForeground,
|
||||
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: {
|
||||
flexDirection: "row",
|
||||
gap: theme.spacing[4],
|
||||
|
||||
339
packages/app/src/components/dictation-controls.tsx
Normal file
339
packages/app/src/components/dictation-controls.tsx
Normal file
@@ -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,
|
||||
lastUserMessageAt,
|
||||
lastActivityAt: updatedAt,
|
||||
sessionId: snapshot.sessionId,
|
||||
capabilities: snapshot.capabilities,
|
||||
currentModeId: snapshot.currentModeId,
|
||||
availableModes: snapshot.availableModes ?? [],
|
||||
@@ -196,7 +195,7 @@ export interface SessionContextValue {
|
||||
images?: Array<{ uri: string; mimeType?: string }>
|
||||
) => Promise<void>;
|
||||
sendAgentAudio: (
|
||||
agentId: string,
|
||||
agentId: string | undefined,
|
||||
audioBlob: Blob,
|
||||
requestId?: string,
|
||||
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 previousAgentStatusRef = useRef<Map<string, AgentLifecycleStatus>>(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 hasRequestedInitialSnapshotRef = useRef(false);
|
||||
const sessionStateTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
@@ -751,6 +751,26 @@ export function SessionProvider({ children, serverUrl, serverId }: SessionProvid
|
||||
}
|
||||
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) => {
|
||||
@@ -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]);
|
||||
|
||||
// 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 }) => {
|
||||
console.log("[Session] initializeAgent called", { agentId, requestId });
|
||||
setInitializingAgents(serverId, (prev) => {
|
||||
@@ -1459,6 +1456,9 @@ export function SessionProvider({ children, serverUrl, serverId }: SessionProvid
|
||||
ws.send(msg);
|
||||
}, [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 msg: WSInboundMessage = {
|
||||
type: "session",
|
||||
@@ -1493,7 +1493,7 @@ export function SessionProvider({ children, serverUrl, serverId }: SessionProvid
|
||||
}, [ws]);
|
||||
|
||||
const sendAgentAudio = useCallback(async (
|
||||
agentId: string,
|
||||
agentId: string | undefined,
|
||||
audioBlob: Blob,
|
||||
requestId?: string,
|
||||
options?: { mode?: "transcribe_only" | "auto_run" }
|
||||
@@ -1530,7 +1530,7 @@ export function SessionProvider({ children, serverUrl, serverId }: SessionProvid
|
||||
type: "session",
|
||||
message: {
|
||||
type: "send_agent_audio",
|
||||
agentId,
|
||||
...(agentId ? { agentId } : {}),
|
||||
audio: base64Audio,
|
||||
format,
|
||||
isLast: true,
|
||||
@@ -1540,7 +1540,7 @@ export function SessionProvider({ children, serverUrl, serverId }: SessionProvid
|
||||
};
|
||||
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) {
|
||||
console.error("[Session] Failed to send audio:", error);
|
||||
throw error;
|
||||
|
||||
@@ -31,7 +31,7 @@ export type DictationOutcome =
|
||||
| { type: "failure"; requestId: string; errorMessage: string; timestamp: number };
|
||||
|
||||
export type UseDictationOptions = {
|
||||
agentId: string;
|
||||
agentId?: string;
|
||||
sendAgentAudio: SessionContextValue["sendAgentAudio"];
|
||||
ws: UseWebSocketReturn;
|
||||
mode?: "transcribe_only" | "auto_run";
|
||||
|
||||
@@ -91,7 +91,6 @@ export interface Agent {
|
||||
updatedAt: Date;
|
||||
lastUserMessageAt: Date | null;
|
||||
lastActivityAt: Date;
|
||||
sessionId: string | null;
|
||||
capabilities: AgentCapabilityFlags;
|
||||
currentModeId: string | null;
|
||||
availableModes: AgentMode[];
|
||||
@@ -191,7 +190,7 @@ export interface SessionState {
|
||||
images?: Array<{ uri: string; mimeType?: string }>
|
||||
) => Promise<void>;
|
||||
sendAgentAudio: (
|
||||
agentId: string,
|
||||
agentId: string | undefined,
|
||||
audioBlob: Blob,
|
||||
requestId?: string,
|
||||
options?: { mode?: "transcribe_only" | "auto_run" }
|
||||
|
||||
@@ -89,7 +89,10 @@ class TestAgentSession implements AgentSession {
|
||||
async respondToPermission(): Promise<void> {}
|
||||
|
||||
describePersistence() {
|
||||
return null;
|
||||
return {
|
||||
provider: this.provider,
|
||||
sessionId: this.id,
|
||||
};
|
||||
}
|
||||
|
||||
async interrupt(): Promise<void> {}
|
||||
@@ -165,7 +168,7 @@ describe("AgentManager", () => {
|
||||
|
||||
expect(snapshot.runtimeInfo).toBeDefined();
|
||||
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 () => {
|
||||
|
||||
@@ -93,7 +93,6 @@ type ManagedAgentBase = {
|
||||
persistence: AgentPersistenceHandle | null;
|
||||
historyPrimed: boolean;
|
||||
lastUserMessageAt: Date | null;
|
||||
sessionId: string | null;
|
||||
lastUsage?: AgentUsage;
|
||||
lastError?: string;
|
||||
attention: AttentionState;
|
||||
@@ -368,7 +367,6 @@ export class AgentManager {
|
||||
lifecycle: "closed",
|
||||
session: null,
|
||||
pendingRun: null,
|
||||
sessionId: agent.sessionId,
|
||||
};
|
||||
await session.close();
|
||||
this.emitState(closedAgent);
|
||||
@@ -421,8 +419,14 @@ export class AgentManager {
|
||||
}
|
||||
|
||||
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 {
|
||||
sessionId: agent.sessionId ?? agent.id,
|
||||
sessionId,
|
||||
finalText,
|
||||
usage,
|
||||
timeline,
|
||||
@@ -767,7 +771,6 @@ export class AgentManager {
|
||||
provider: config.provider,
|
||||
cwd: config.cwd,
|
||||
session,
|
||||
sessionId: session.id,
|
||||
capabilities: session.capabilities,
|
||||
config,
|
||||
runtimeInfo: undefined,
|
||||
@@ -878,7 +881,9 @@ export class AgentManager {
|
||||
|
||||
switch (event.type) {
|
||||
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;
|
||||
case "timeline":
|
||||
this.recordTimeline(agent, event.item);
|
||||
|
||||
@@ -407,7 +407,7 @@ describe("agent MCP end-to-end", () => {
|
||||
cwd: agentCwd,
|
||||
title: "MCP interrupt test",
|
||||
agentType: "codex",
|
||||
initialMode: "full-auto",
|
||||
initialMode: "full-access",
|
||||
background: true, // Start in background so create returns immediately
|
||||
},
|
||||
})) as McpToolResult;
|
||||
|
||||
@@ -234,7 +234,6 @@ describe("toAgentPayload", () => {
|
||||
expect(payload.lastUserMessageAt).toBe(agent.lastUserMessageAt?.toISOString());
|
||||
expect(payload.title).toBe("UI Payload");
|
||||
expect(payload.model).toBe(agent.config.model);
|
||||
expect(payload.sessionId).toBe(agent.sessionId);
|
||||
expect(payload.pendingPermissions.map((item) => item.id)).toEqual([
|
||||
"perm-a",
|
||||
"perm-b",
|
||||
|
||||
@@ -77,7 +77,6 @@ export function toAgentPayload(
|
||||
? agent.lastUserMessageAt.toISOString()
|
||||
: null,
|
||||
status: agent.lifecycle,
|
||||
sessionId: agent.sessionId,
|
||||
capabilities: cloneCapabilities(agent.capabilities),
|
||||
currentModeId: agent.currentModeId,
|
||||
availableModes: cloneAvailableModes(agent.availableModes),
|
||||
|
||||
@@ -51,7 +51,6 @@ function createManagedAgent(
|
||||
provider,
|
||||
cwd,
|
||||
session,
|
||||
sessionId: overrides.sessionId ?? "session-123",
|
||||
capabilities:
|
||||
overrides.capabilities ??
|
||||
{
|
||||
|
||||
@@ -66,6 +66,7 @@ export class AgentRegistry {
|
||||
private cache: Map<string, StoredAgentRecord> = new Map();
|
||||
private loaded = false;
|
||||
private filePath: string;
|
||||
private loadPromise: Promise<StoredAgentRecord[]> | null = null;
|
||||
|
||||
constructor(filePath: string) {
|
||||
this.filePath = filePath;
|
||||
@@ -75,6 +76,15 @@ export class AgentRegistry {
|
||||
if (this.loaded) {
|
||||
return Array.from(this.cache.values());
|
||||
}
|
||||
|
||||
if (!this.loadPromise) {
|
||||
this.loadPromise = this.doLoad();
|
||||
}
|
||||
|
||||
return this.loadPromise;
|
||||
}
|
||||
|
||||
private async doLoad(): Promise<StoredAgentRecord[]> {
|
||||
try {
|
||||
const content = await fs.readFile(this.filePath, "utf8");
|
||||
const parsed = await this.parseContent(content);
|
||||
|
||||
@@ -36,6 +36,47 @@ export interface AgentMcpServerOptions {
|
||||
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(
|
||||
AGENT_PROVIDER_DEFINITIONS.map((definition) => definition.id) as [
|
||||
AgentProvider,
|
||||
@@ -138,59 +179,91 @@ export async function createAgentMcpServer(
|
||||
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(
|
||||
"create_agent",
|
||||
{
|
||||
title: "Create Agent",
|
||||
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.",
|
||||
inputSchema: {
|
||||
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."
|
||||
),
|
||||
},
|
||||
inputSchema: createAgentInputSchema,
|
||||
outputSchema: {
|
||||
agentId: z.string(),
|
||||
type: AgentProviderEnum,
|
||||
@@ -208,35 +281,79 @@ export async function createAgentMcpServer(
|
||||
permission: AgentPermissionRequestPayloadSchema.nullable().optional(),
|
||||
},
|
||||
},
|
||||
async ({
|
||||
cwd,
|
||||
agentType,
|
||||
initialPrompt,
|
||||
initialMode,
|
||||
worktreeName,
|
||||
background = false,
|
||||
title,
|
||||
parentAgentId,
|
||||
}) => {
|
||||
let resolvedCwd = expandPath(cwd);
|
||||
async (args) => {
|
||||
const {
|
||||
agentType,
|
||||
initialPrompt,
|
||||
background = false,
|
||||
title,
|
||||
} = args as {
|
||||
cwd?: string;
|
||||
agentType?: AgentProvider;
|
||||
initialPrompt?: string;
|
||||
initialMode?: string;
|
||||
worktreeName?: string;
|
||||
background?: boolean;
|
||||
title: string;
|
||||
parentAgentId?: string;
|
||||
};
|
||||
|
||||
if (worktreeName) {
|
||||
const worktree = await createWorktree({
|
||||
branchName: worktreeName,
|
||||
cwd: resolvedCwd,
|
||||
worktreeSlug: worktreeName,
|
||||
});
|
||||
resolvedCwd = worktree.worktreePath;
|
||||
let resolvedCwd: string;
|
||||
let resolvedMode: string | undefined;
|
||||
let resolvedParentAgentId: string | undefined;
|
||||
|
||||
if (callerAgentId) {
|
||||
const parentAgent = agentManager.getAgent(callerAgentId);
|
||||
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 normalizedTitle = title?.trim() ?? null;
|
||||
// Use explicit parentAgentId if provided, otherwise default to caller agent ID
|
||||
const resolvedParentAgentId = parentAgentId ?? callerAgentId;
|
||||
const snapshot = await agentManager.createAgent({
|
||||
provider,
|
||||
cwd: resolvedCwd,
|
||||
modeId: initialMode,
|
||||
modeId: resolvedMode,
|
||||
title: normalizedTitle ?? undefined,
|
||||
parentAgentId: resolvedParentAgentId,
|
||||
});
|
||||
|
||||
@@ -728,6 +728,12 @@ class ClaudeAgentSession implements AgentSession {
|
||||
stderr: (data: string) => {
|
||||
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,
|
||||
};
|
||||
|
||||
|
||||
@@ -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(
|
||||
data: Omit<ToolCallTimelineItem, "type">
|
||||
): AgentTimelineItem {
|
||||
@@ -2757,9 +2766,13 @@ class CodexMcpAgentSession implements AgentSession {
|
||||
private pendingResumeFile: string | null = null;
|
||||
|
||||
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.currentMode =
|
||||
config.modeId !== undefined ? config.modeId : DEFAULT_CODEX_MODE_ID;
|
||||
this.currentMode = config.modeId;
|
||||
this.pendingLocalId = `codex-${randomUUID()}`;
|
||||
|
||||
if (resumeHandle) {
|
||||
@@ -3053,6 +3066,8 @@ class CodexMcpAgentSession implements AgentSession {
|
||||
}
|
||||
|
||||
async setMode(modeId: string): Promise<void> {
|
||||
validateCodexMode(modeId);
|
||||
|
||||
this.currentMode = modeId;
|
||||
this.config.modeId = modeId;
|
||||
|
||||
|
||||
@@ -243,7 +243,6 @@ export const AgentSnapshotPayloadSchema = z.object({
|
||||
updatedAt: z.string(),
|
||||
lastUserMessageAt: z.string().nullable(),
|
||||
status: AgentStatusSchema,
|
||||
sessionId: z.string().nullable(),
|
||||
capabilities: AgentCapabilityFlagsSchema,
|
||||
currentModeId: z.string().nullable(),
|
||||
availableModes: z.array(AgentModeSchema),
|
||||
@@ -340,7 +339,7 @@ export const SendAgentMessageSchema = z.object({
|
||||
|
||||
export const SendAgentAudioSchema = z.object({
|
||||
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
|
||||
format: z.string(),
|
||||
isLast: z.boolean(),
|
||||
|
||||
@@ -49,7 +49,6 @@ function createManagedAgent(
|
||||
provider,
|
||||
cwd,
|
||||
session,
|
||||
sessionId: overrides.sessionId ?? "session-123",
|
||||
capabilities:
|
||||
overrides.capabilities ??
|
||||
{
|
||||
|
||||
@@ -609,7 +609,6 @@ export class Session {
|
||||
updatedAt: updatedAt.toISOString(),
|
||||
lastUserMessageAt: lastUserMessageAt ? lastUserMessageAt.toISOString() : null,
|
||||
status: record.lastStatus,
|
||||
sessionId: null,
|
||||
capabilities: defaultCapabilities,
|
||||
currentModeId: record.lastModeId ?? null,
|
||||
availableModes: [],
|
||||
@@ -1107,6 +1106,17 @@ export class Session {
|
||||
const { agentId, audio, format, isLast, requestId, mode } = msg;
|
||||
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
|
||||
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
|
||||
if (!isLast) {
|
||||
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
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(
|
||||
`[Session ${this.clientId}] Transcribing audio for agent ${agentId}`
|
||||
`[Session ${this.clientId}] Transcribing audio for agent ${logAgentId}`
|
||||
);
|
||||
|
||||
try {
|
||||
// Transcribe the audio
|
||||
const result = await this.sttManager.transcribe(audioBuffer, format, {
|
||||
agentId,
|
||||
agentId: logAgentId,
|
||||
requestId,
|
||||
label: shouldAutoRun ? "dictation:auto_run" : "dictation",
|
||||
});
|
||||
@@ -1135,13 +1145,13 @@ export class Session {
|
||||
const transcriptText = result.text.trim();
|
||||
if (!transcriptText) {
|
||||
console.log(
|
||||
`[Session ${this.clientId}] Empty transcription for agent ${agentId}, ignoring`
|
||||
`[Session ${this.clientId}] Empty transcription for agent ${logAgentId}, ignoring`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
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
|
||||
@@ -1162,16 +1172,19 @@ export class Session {
|
||||
|
||||
if (!shouldAutoRun) {
|
||||
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;
|
||||
}
|
||||
|
||||
// At this point, agentId is guaranteed to be defined (validated at function start)
|
||||
const validAgentId = agentId!;
|
||||
|
||||
try {
|
||||
await this.ensureAgentLoaded(agentId);
|
||||
await this.ensureAgentLoaded(validAgentId);
|
||||
} catch (error) {
|
||||
this.handleAgentRunError(
|
||||
agentId,
|
||||
validAgentId,
|
||||
error,
|
||||
"Failed to initialize agent before sending audio prompt"
|
||||
);
|
||||
@@ -1179,10 +1192,10 @@ export class Session {
|
||||
}
|
||||
|
||||
try {
|
||||
await this.interruptAgentIfRunning(agentId);
|
||||
await this.interruptAgentIfRunning(validAgentId);
|
||||
} catch (error) {
|
||||
this.handleAgentRunError(
|
||||
agentId,
|
||||
validAgentId,
|
||||
error,
|
||||
"Failed to interrupt running agent before sending audio prompt"
|
||||
);
|
||||
@@ -1190,22 +1203,22 @@ export class Session {
|
||||
}
|
||||
|
||||
try {
|
||||
this.agentManager.recordUserMessage(agentId, transcriptText);
|
||||
this.agentManager.recordUserMessage(validAgentId, transcriptText);
|
||||
} catch (recordError) {
|
||||
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
|
||||
);
|
||||
}
|
||||
|
||||
// Send transcribed text to agent
|
||||
this.startAgentStream(agentId, transcriptText);
|
||||
this.startAgentStream(validAgentId, transcriptText);
|
||||
console.log(
|
||||
`[Session ${this.clientId}] Sent transcribed text to agent ${agentId}`
|
||||
`[Session ${this.clientId}] Sent transcribed text to agent ${validAgentId}`
|
||||
);
|
||||
} catch (error: any) {
|
||||
console.error(
|
||||
`[Session ${this.clientId}] Failed to process audio for agent ${agentId}:`,
|
||||
`[Session ${this.clientId}] Failed to process audio for agent ${logAgentId}:`,
|
||||
error
|
||||
);
|
||||
this.emit({
|
||||
|
||||
@@ -14,6 +14,7 @@ import type {
|
||||
AgentPersistenceHandle,
|
||||
AgentProvider,
|
||||
} from "../agent/agent-sdk-types.js";
|
||||
import { getAgentProviderDefinition } from "../agent/provider-manifest.js";
|
||||
|
||||
// ============================================================================
|
||||
// Configuration
|
||||
@@ -147,6 +148,12 @@ export class DaemonClient {
|
||||
// Record the current queue position so we only check NEW messages
|
||||
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({
|
||||
type: "create_agent_request",
|
||||
requestId,
|
||||
@@ -155,7 +162,7 @@ export class DaemonClient {
|
||||
cwd: options.cwd,
|
||||
title: options.title,
|
||||
model: options.model,
|
||||
modeId: options.modeId,
|
||||
modeId,
|
||||
mcpServers: options.mcpServers,
|
||||
extra: options.extra,
|
||||
},
|
||||
@@ -209,6 +216,12 @@ export class DaemonClient {
|
||||
// Record the current queue position so we only check NEW messages
|
||||
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({
|
||||
type: "create_agent_request",
|
||||
requestId,
|
||||
@@ -217,7 +230,7 @@ export class DaemonClient {
|
||||
cwd: options.cwd,
|
||||
title: options.title,
|
||||
model: options.model,
|
||||
modeId: options.modeId,
|
||||
modeId,
|
||||
mcpServers: options.mcpServers,
|
||||
extra: options.extra,
|
||||
},
|
||||
|
||||
18
test-list-persisted.ts
Normal file
18
test-list-persisted.ts
Normal file
@@ -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