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:
Mohamed Boudra
2026-01-03 16:06:40 +07:00
parent 4185db9c75
commit e8890a2fc1
21 changed files with 694 additions and 1053 deletions

View File

@@ -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],

View 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",
},
}));

View File

@@ -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,
},
}));

View File

@@ -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;

View File

@@ -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";

View File

@@ -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" }

View File

@@ -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 () => {

View File

@@ -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);

View File

@@ -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;

View File

@@ -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",

View File

@@ -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),

View File

@@ -51,7 +51,6 @@ function createManagedAgent(
provider,
cwd,
session,
sessionId: overrides.sessionId ?? "session-123",
capabilities:
overrides.capabilities ??
{

View File

@@ -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);

View File

@@ -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,
});

View File

@@ -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,
};

View File

@@ -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;

View File

@@ -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(),

View File

@@ -49,7 +49,6 @@ function createManagedAgent(
provider,
cwd,
session,
sessionId: overrides.sessionId ?? "session-123",
capabilities:
overrides.capabilities ??
{

View File

@@ -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({

View File

@@ -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
View 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);