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
co-authored by Claude Opus 4.5
parent 4185db9c75
commit e8890a2fc1
21 changed files with 694 additions and 1053 deletions
@@ -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],
@@ -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,
},
}));