refactor(app): replace voice hooks with voice runtime and audio engine architecture

This commit is contained in:
Mohamed Boudra
2026-03-13 20:53:55 +07:00
parent 730f34753a
commit 80a2f8052c
51 changed files with 4308 additions and 2380 deletions
@@ -103,6 +103,23 @@ export function AgentInputArea({
const { client, isConnected, snapshot } = useHostRuntimeSession(serverId)
const toast = useToast()
const voice = useVoiceOptional()
console.log("[AgentInputArea] render", {
serverId,
agentId,
isConnected,
isVoiceMode: voice?.isVoiceMode ?? false,
isVoiceSwitching: voice?.isVoiceSwitching ?? false,
isMuted: voice?.isMuted ?? false,
})
console.log("[AgentInputArea] voice_snapshot", {
serverId,
agentId,
isVoiceMode: voice?.isVoiceMode ?? false,
isVoiceSwitching: voice?.isVoiceSwitching ?? false,
isMuted: voice?.isMuted ?? false,
activeServerId: voice?.activeServerId ?? null,
activeAgentId: voice?.activeAgentId ?? null,
})
const isDictationReady =
isConnected &&
(snapshot?.agentDirectoryStatus === 'ready' ||
@@ -151,6 +168,19 @@ export function AgentInputArea({
`message-input:${serverId}:${agentId}:${Math.random().toString(36).slice(2)}`
)
useEffect(() => {
console.log("[AgentInputArea] mount", {
serverId,
agentId,
})
return () => {
console.log("[AgentInputArea] unmount", {
serverId,
agentId,
})
}
}, [agentId, serverId])
const autocomplete = useAgentAutocomplete({
userInput,
cursorIndex,
+64 -2
View File
@@ -144,6 +144,70 @@ export function LeftSidebar({ selectedAgentId: _selectedAgentId }: LeftSidebarPr
// Track user-initiated refresh to avoid showing spinner on background revalidation
const [isManualRefresh, setIsManualRefresh] = useState(false)
useEffect(() => {
console.log('[LeftSidebar] isOpen_changed', {
isOpen,
isMobile,
mobileView,
desktopAgentListOpen,
})
}, [desktopAgentListOpen, isMobile, isOpen, mobileView])
useEffect(() => {
console.log('[LeftSidebar] pathname_changed', {
pathname,
activeServerIdFromPath,
activeServerId,
})
}, [activeServerId, activeServerIdFromPath, pathname])
useEffect(() => {
console.log('[LeftSidebar] hosts_changed', {
hostCount: daemons.length,
serverIds: daemons.map((daemon) => daemon.serverId),
runtimeConnectionStatusSignature,
})
}, [daemons, runtimeConnectionStatusSignature])
useEffect(() => {
console.log('[LeftSidebar] active_host_changed', {
activeServerId,
activeHostLabel,
activeHostStatus,
})
}, [activeHostLabel, activeHostStatus, activeServerId])
useEffect(() => {
console.log('[LeftSidebar] projects_changed', {
activeServerId,
projectCount: projects.length,
projectKeys: projects.map((project) => project.projectKey),
workspaceCounts: projects.map((project) => ({
projectKey: project.projectKey,
workspaceCount: project.workspaces.length,
})),
isInitialLoad,
isRevalidating,
})
}, [activeServerId, isInitialLoad, isRevalidating, projects])
useEffect(() => {
console.log('[LeftSidebar] collapsed_or_shortcuts_changed', {
collapsedProjectKeys: Array.from(collapsedProjectKeys),
shortcutCount: shortcutIndexByWorkspaceKey.size,
})
}, [collapsedProjectKeys, shortcutIndexByWorkspaceKey])
useEffect(() => {
console.log('[LeftSidebar] animation_context_changed', {
windowWidth,
translateXValue: translateX.value,
backdropOpacityValue: backdropOpacity.value,
isGesturing: isGesturing.value,
hasCloseGestureRef: Boolean(closeGestureRef.current),
})
}, [backdropOpacity, closeGestureRef, isGesturing, translateX, windowWidth])
const handleRefresh = useCallback(() => {
setIsManualRefresh(true)
refreshAll()
@@ -354,7 +418,6 @@ export function LeftSidebar({ selectedAgentId: _selectedAgentId }: LeftSidebarPr
<SidebarAgentListSkeleton />
) : (
<SidebarWorkspaceList
isOpen={isOpen}
serverId={activeServerId}
collapsedProjectKeys={collapsedProjectKeys}
onToggleProjectCollapsed={toggleProjectCollapsed}
@@ -481,7 +544,6 @@ export function LeftSidebar({ selectedAgentId: _selectedAgentId }: LeftSidebarPr
<SidebarAgentListSkeleton />
) : (
<SidebarWorkspaceList
isOpen={isOpen}
serverId={activeServerId}
collapsedProjectKeys={collapsedProjectKeys}
onToggleProjectCollapsed={toggleProjectCollapsed}
+81 -10
View File
@@ -101,7 +101,6 @@ export interface MessageInputRef {
const MIN_INPUT_HEIGHT = 30
const MAX_INPUT_HEIGHT = 160
const IS_WEB = Platform.OS === 'web'
const IS_DEV = Boolean((globalThis as { __DEV__?: boolean }).__DEV__)
type WebTextInputKeyPressEvent = NativeSyntheticEvent<
TextInputKeyPressEventData & {
@@ -125,13 +124,10 @@ type TextAreaHandle = {
}
function logWebStickyBottom(
event: string,
details: Record<string, unknown>
_event: string,
_details: Record<string, unknown>
): void {
if (!IS_DEV || !IS_WEB) {
return
}
console.log('[WebStickyBottom]', event, details)
// Intentionally disabled: this path is too noisy during voice debugging.
}
function getDebugNow(): number | null {
@@ -220,6 +216,18 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
markScrollInvestigationRender(investigationComponentId)
const toast = useToast()
const voice = useVoiceOptional()
console.log("[MessageInput] render", {
voiceServerId: voiceServerId ?? null,
voiceAgentId: voiceAgentId ?? null,
hasVoice: Boolean(voice),
isVoiceMode: voice?.isVoiceMode ?? false,
isVoiceSwitching: voice?.isVoiceSwitching ?? false,
isMuted: voice?.isMuted ?? false,
disabled,
isSubmitLoading,
valueLength: value.length,
imageCount: images.length,
})
const [inputHeight, setInputHeight] = useState(MIN_INPUT_HEIGHT)
const rootRef = useRef<View | null>(null)
const inputWrapperRef = useRef<View | null>(null)
@@ -293,6 +301,19 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
)
)
useEffect(() => {
console.log("[MessageInput] mount", {
voiceServerId: voiceServerId ?? null,
voiceAgentId: voiceAgentId ?? null,
})
return () => {
console.log("[MessageInput] unmount", {
voiceServerId: voiceServerId ?? null,
voiceAgentId: voiceAgentId ?? null,
})
}
}, [voiceAgentId, voiceServerId])
useEffect(() => {
valueRef.current = value
}, [value])
@@ -407,6 +428,26 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
const showRealtimeOverlay = isRealtimeVoiceForCurrentAgent
const showOverlay = showDictationOverlay || showRealtimeOverlay
useEffect(() => {
console.log("[MessageInput] overlay_state", {
showDictationOverlay,
showRealtimeOverlay,
showOverlay,
isDictating,
isDictationProcessing,
dictationStatus,
isRealtimeVoiceForCurrentAgent,
})
}, [
dictationStatus,
isDictating,
isDictationProcessing,
isRealtimeVoiceForCurrentAgent,
showDictationOverlay,
showOverlay,
showRealtimeOverlay,
])
useEffect(() => {
if (isDictating || isDictationProcessing) {
return
@@ -443,6 +484,11 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
}))
const handleVoicePress = useCallback(async () => {
console.log("[MessageInput] handleVoicePress", {
isRealtimeVoiceForCurrentAgent,
isDictating,
hasVoice: Boolean(voice),
})
if (isRealtimeVoiceForCurrentAgent && voice) {
voice.toggleMute()
return
@@ -462,28 +508,39 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
])
const handleCancelRecording = useCallback(async () => {
console.log("[MessageInput] handleCancelRecording")
await cancelDictation()
}, [cancelDictation])
const handleAcceptRecording = useCallback(async () => {
console.log("[MessageInput] handleAcceptRecording")
sendAfterTranscriptRef.current = false
await confirmDictation()
}, [confirmDictation])
const handleAcceptAndSendRecording = useCallback(async () => {
console.log("[MessageInput] handleAcceptAndSendRecording")
sendAfterTranscriptRef.current = true
await confirmDictation()
}, [confirmDictation])
const handleRetryFailedRecording = useCallback(() => {
console.log("[MessageInput] handleRetryFailedRecording")
void retryFailedDictation()
}, [retryFailedDictation])
const handleDiscardFailedRecording = useCallback(() => {
console.log("[MessageInput] handleDiscardFailedRecording")
discardFailedDictation()
}, [discardFailedDictation])
const handleStopRealtimeVoice = useCallback(async () => {
console.log("[MessageInput] handleStopRealtimeVoice", {
hasVoice: Boolean(voice),
isRealtimeVoiceForCurrentAgent,
isAgentRunning,
voiceAgentId: voiceAgentId ?? null,
})
if (!voice || !isRealtimeVoiceForCurrentAgent) {
return
}
@@ -503,6 +560,18 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
}, [client, isAgentRunning, isRealtimeVoiceForCurrentAgent, voice, voiceAgentId])
const handleToggleRealtimeVoiceShortcut = useCallback(() => {
console.log("[MessageInput] handleToggleRealtimeVoiceShortcut", {
hasVoice: Boolean(voice),
voiceServerId: voiceServerId ?? null,
voiceAgentId: voiceAgentId ?? null,
isConnected,
disabled,
isVoiceSwitching: voice?.isVoiceSwitching ?? false,
isVoiceModeForAgent:
voice && voiceServerId && voiceAgentId
? voice.isVoiceModeForAgent(voiceServerId, voiceAgentId)
: false,
})
if (!voice || !voiceServerId || !voiceAgentId || !isConnected || disabled) {
return
}
@@ -524,6 +593,11 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
}, [disabled, handleStopRealtimeVoice, isConnected, toast, voice, voiceAgentId, voiceServerId])
const handleSendMessage = useCallback(() => {
console.log("[MessageInput] handleSendMessage", {
valueLength: value.length,
imageCount: images.length,
isAgentRunning,
})
const trimmed = value.trim()
if (!trimmed && images.length === 0) return
const payload = {
@@ -1070,10 +1144,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
/>
) : showRealtimeOverlay && voice ? (
<RealtimeVoiceOverlay
volume={voice.volume}
isMuted={voice.isMuted}
isDetecting={voice.isDetecting}
isSpeaking={voice.isSpeaking}
isSwitching={voice.isVoiceSwitching}
onToggleMute={voice.toggleMute}
onStop={() => {
+5 -3
View File
@@ -1926,9 +1926,11 @@ export const ToolCall = memo(function ToolCall({
const hasDetails =
Boolean(error) ||
(effectiveDetail
? effectiveDetail.type !== "unknown" ||
effectiveDetail.input !== null ||
effectiveDetail.output !== null
? effectiveDetail.type === "plain_text"
? Boolean(effectiveDetail.text)
: effectiveDetail.type !== "unknown" ||
effectiveDetail.input !== null ||
effectiveDetail.output !== null
: false);
const handleToggle = useCallback(() => {
@@ -1,14 +1,13 @@
import { ActivityIndicator, Pressable, View } from "react-native";
import { useEffect } from "react";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { Mic, MicOff, Square } from "lucide-react-native";
import { FOOTER_HEIGHT } from "@/constants/layout";
import { useVoiceTelemetry } from "@/contexts/voice-context";
import { VolumeMeter } from "./volume-meter";
interface RealtimeVoiceOverlayProps {
volume: number;
isMuted: boolean;
isDetecting: boolean;
isSpeaking: boolean;
isSwitching: boolean;
onToggleMute: () => void;
onStop: () => void;
@@ -18,15 +17,27 @@ const OVERLAY_BUTTON_SIZE = 44;
const OVERLAY_VERTICAL_PADDING = (FOOTER_HEIGHT - OVERLAY_BUTTON_SIZE) / 2;
export function RealtimeVoiceOverlay({
volume,
isMuted,
isDetecting,
isSpeaking,
isSwitching,
onToggleMute,
onStop,
}: RealtimeVoiceOverlayProps) {
const { theme } = useUnistyles();
const { volume, isDetecting, isSpeaking } = useVoiceTelemetry();
console.log("[RealtimeVoiceOverlay] render", {
isMuted,
isSwitching,
volume,
isDetecting,
isSpeaking,
});
useEffect(() => {
console.log("[RealtimeVoiceOverlay] mount");
return () => {
console.log("[RealtimeVoiceOverlay] unmount");
};
}, []);
return (
<View style={styles.container}>
@@ -78,8 +78,12 @@ function toProjectIconDataUri(icon: { mimeType: string; data: string } | null):
return `data:${icon.mimeType};base64,${icon.data}`
}
const workspaceKeyExtractor = (workspace: SidebarWorkspaceEntry) =>
workspace.workspaceKey
const projectKeyExtractor = (project: SidebarProjectEntry) => project.projectKey
interface SidebarWorkspaceListProps {
isOpen?: boolean
projects: SidebarProjectEntry[]
serverId: string | null
collapsedProjectKeys: ReadonlySet<string>
@@ -1156,6 +1160,13 @@ function ProjectBlock({
[renderWorkspaceRow]
)
const handleWorkspaceDragEnd = useCallback(
(workspaces: SidebarWorkspaceEntry[]) => {
onWorkspaceReorder(project.projectKey, workspaces)
},
[onWorkspaceReorder, project.projectKey]
)
return (
<View style={styles.projectBlock}>
{flattenedWorkspace ? (
@@ -1201,9 +1212,9 @@ function ProjectBlock({
<DraggableList
testID={`sidebar-workspace-list-${project.projectKey}`}
data={project.workspaces}
keyExtractor={(workspace) => workspace.workspaceKey}
keyExtractor={workspaceKeyExtractor}
renderItem={renderWorkspace}
onDragEnd={(workspaces) => onWorkspaceReorder(project.projectKey, workspaces)}
onDragEnd={handleWorkspaceDragEnd}
scrollEnabled={false}
useDragHandle
nestable={useNestable}
@@ -1218,7 +1229,6 @@ function ProjectBlock({
}
export function SidebarWorkspaceList({
isOpen = true,
projects,
serverId,
collapsedProjectKeys,
@@ -1258,7 +1268,7 @@ export function SidebarWorkspaceList({
}, [pathname])
const projectIconRequests = useMemo(() => {
if (!isOpen || !serverId) {
if (!serverId) {
return []
}
const unique = new Map<string, { serverId: string; cwd: string }>()
@@ -1270,7 +1280,7 @@ export function SidebarWorkspaceList({
unique.set(`${serverId}:${cwd}`, { serverId, cwd })
}
return Array.from(unique.values())
}, [isOpen, projects, serverId])
}, [projects, serverId])
const projectIconQueries = useQueries({
queries: projectIconRequests.map((request) => ({
@@ -1285,7 +1295,6 @@ export function SidebarWorkspaceList({
},
select: toProjectIconDataUri,
enabled: Boolean(
isOpen &&
getHostRuntimeStore().getClient(request.serverId) &&
isHostRuntimeConnected(getHostRuntimeStore().getSnapshot(request.serverId)) &&
request.cwd
@@ -1444,7 +1453,7 @@ export function SidebarWorkspaceList({
<DraggableList
testID="sidebar-project-list"
data={projects}
keyExtractor={(project) => project.projectKey}
keyExtractor={projectKeyExtractor}
renderItem={renderProject}
onDragEnd={handleProjectDragEnd}
scrollEnabled={false}
@@ -24,7 +24,6 @@ const USER_SCROLL_DELTA_EPSILON = 1
const AUTO_SCROLL_BOTTOM_THRESHOLD_PX = 64
const AUTO_SCROLL_RESUME_THRESHOLD_PX = 1
const WEB_STREAM_SCROLLBAR_STYLE_ID = 'web-stream-viewport-scrollbar-style'
const IS_DEV = Boolean((globalThis as { __DEV__?: boolean }).__DEV__)
const WEB_STREAM_SCROLLBAR_STYLE = `
#agent-chat-scroll-web-dom-scroll,
#agent-chat-scroll-web-dom-virtualized {
@@ -40,11 +39,8 @@ const WEB_STREAM_SCROLLBAR_STYLE = `
}
`
function logWebStickyBottom(event: string, details: Record<string, unknown>): void {
if (!IS_DEV) {
return
}
console.log('[WebStickyBottom]', event, details)
function logWebStickyBottom(_event: string, _details: Record<string, unknown>): void {
// Intentionally disabled: this path is too noisy during voice debugging.
}
function getDebugNow(): number | null {
+16 -3
View File
@@ -64,6 +64,7 @@ export interface ComboboxProps {
title?: string
open?: boolean
onOpenChange?: (open: boolean) => void
enableDismissOnClose?: boolean
desktopPlacement?: 'top-start' | 'bottom-start'
/**
* Prevents an initial frame at 0,0 by hiding desktop content until floating
@@ -224,6 +225,7 @@ export function Combobox({
title = 'Select',
open,
onOpenChange,
enableDismissOnClose,
desktopPlacement = 'top-start',
desktopPreventInitialFlash = true,
anchorRef,
@@ -235,6 +237,7 @@ export function Combobox({
!isMobile && Platform.OS === 'web' && effectiveOptionsPosition === 'above-search'
const { height: windowHeight } = useWindowDimensions()
const bottomSheetRef = useRef<BottomSheetModal>(null)
const hasPresentedBottomSheetRef = useRef(false)
const snapPoints = useMemo(() => ['60%', '90%'], [])
const [availableSize, setAvailableSize] = useState<{ width?: number; height?: number } | null>(
null
@@ -379,11 +382,20 @@ export function Combobox({
useEffect(() => {
if (!isMobile) return
if (isOpen) {
bottomSheetRef.current?.present()
if (enableDismissOnClose === false && hasPresentedBottomSheetRef.current) {
bottomSheetRef.current?.snapToIndex(0)
} else {
hasPresentedBottomSheetRef.current = true
bottomSheetRef.current?.present()
}
} else {
bottomSheetRef.current?.dismiss()
if (enableDismissOnClose === false) {
bottomSheetRef.current?.close()
} else {
bottomSheetRef.current?.dismiss()
}
}
}, [isOpen, isMobile])
}, [enableDismissOnClose, isOpen, isMobile])
const handleSheetChange = useCallback(
(index: number) => {
@@ -622,6 +634,7 @@ export function Combobox({
onChange={handleSheetChange}
backdropComponent={renderBackdrop}
enablePanDownToClose
enableDismissOnClose={enableDismissOnClose}
backgroundComponent={ComboboxSheetBackground}
handleIndicatorStyle={styles.bottomSheetHandle}
keyboardBehavior="extend"
@@ -132,8 +132,6 @@ function createDriverHarness(input?: {
context.measurementState.offsetY = 720;
});
const modeChanges: BottomAnchorMode[] = [];
const warnings: Array<{ agentId: string; reason: string }> = [];
const logs: Array<{ event: string; details: Record<string, unknown> }> = [];
const driver = __private__.createBottomAnchorControllerDriver({
getAgentId: () => context.agentId,
getIsAuthoritativeHistoryReady: () => context.authoritativeReady,
@@ -145,10 +143,6 @@ function createDriverHarness(input?: {
onModeChange: (mode) => {
modeChanges.push(mode);
},
log: (event, details) => {
logs.push({ event, details });
},
warn: (details) => warnings.push(details),
scheduleFrame: (params) => scheduler.schedule(params),
cancelFrame: (handle) => scheduler.cancel(handle),
});
@@ -159,8 +153,6 @@ function createDriverHarness(input?: {
scheduler,
scrollToBottom,
modeChanges,
logs,
warnings,
};
}
@@ -367,7 +359,6 @@ describe("bottom anchor controller driver", () => {
harness.scheduler.flushAll();
expect(harness.scrollToBottom).toHaveBeenCalledTimes(1);
expect(harness.warnings).toEqual([]);
expect(harness.driver.getSnapshot().pendingRequest).toBeNull();
});
@@ -110,8 +110,6 @@ type CreateBottomAnchorControllerDriverInput = {
isNearBottom: () => boolean;
scrollToBottom: (animated: boolean) => void;
onModeChange: (mode: BottomAnchorMode) => void;
log: (event: BottomAnchorEvent, details: Record<string, unknown>) => void;
warn: (details: { agentId: string; reason: BottomAnchorRequestReason }) => void;
scheduleFrame: (params: {
kind: "attempt" | "verification";
callback: () => void;
@@ -123,17 +121,6 @@ type CreateBottomAnchorControllerDriverInput = {
const MAX_VERIFICATION_RETRIES = 3;
const WEB_PARTIAL_VIRTUALIZED_CONFIRMATION_DELAY_FRAMES = 1;
const USER_SCROLL_AWAY_DELTA_PX = 24;
const IS_DEV = Boolean((globalThis as { __DEV__?: boolean }).__DEV__);
function logBottomAnchorEvent(
event: BottomAnchorEvent,
details: Record<string, unknown>
): void {
if (!IS_DEV) {
return;
}
console.debug("[BottomAnchor]", event, details);
}
function scheduleAnimationFrameWithDelay(input: {
callback: () => void;
@@ -323,10 +310,6 @@ function createBottomAnchorControllerDriver(
return;
}
blockedReason = nextBlockedReason;
input.log(
"blocked_reason_changed",
getLogContext({ nextBlockedReason })
);
};
const setModeInternal = (nextMode: BottomAnchorMode) => {
@@ -367,13 +350,6 @@ function createBottomAnchorControllerDriver(
setBlockedReason(null);
return;
}
input.log(
"request_cancelled",
getLogContext({
cancelledRequestReason: currentRequest.reason,
cancelReason: reason,
})
);
pendingRequest = null;
cancelPendingAttempt();
setBlockedReason(null);
@@ -398,19 +374,6 @@ function createBottomAnchorControllerDriver(
if (verificationHandle) {
input.cancelFrame(verificationHandle);
}
input.log(
"verification_scheduled",
getLogContext({
retries: attemptContext.retries,
startedContentHeight: attemptContext.startedContentHeight ?? null,
startedOffsetY: attemptContext.startedOffsetY ?? null,
startedViewportHeight: attemptContext.startedViewportHeight ?? null,
scheduledMeasurementState:
getDetailedMeasurementState(scheduledMeasurementState),
verificationDelayFrames:
delayFramesOverride ?? input.getTransportBehavior().verificationDelayFrames,
})
);
verificationHandle = input.scheduleFrame({
kind: "verification",
delayFrames:
@@ -427,15 +390,6 @@ function createBottomAnchorControllerDriver(
});
if (verificationBlockedReason) {
input.log(
"attempt_verified",
getLogContext({
verificationPhase: "blocked",
verificationBlockedReason,
retries: attemptContext.retries,
measurementState: getDetailedMeasurementState(measurementState),
})
);
pendingVerification = attemptContext;
setBlockedReason(verificationBlockedReason);
return;
@@ -451,26 +405,6 @@ function createBottomAnchorControllerDriver(
input.getTransportBehavior().verificationRetryMode,
});
input.log(
"attempt_verified",
getLogContext({
verifiedNearBottom,
retries: attemptContext.retries,
retryDisposition,
contentHeightDeltaSinceAttempt:
measurementState.contentHeight -
(attemptContext.startedContentHeight ?? measurementState.contentHeight),
offsetDeltaSinceAttempt:
measurementState.offsetY -
(attemptContext.startedOffsetY ?? measurementState.offsetY),
viewportHeightDeltaSinceAttempt:
measurementState.viewportHeight -
(attemptContext.startedViewportHeight ??
measurementState.viewportHeight),
measurementState: getDetailedMeasurementState(measurementState),
})
);
if (verifiedNearBottom) {
if (
isRequestAttempt &&
@@ -494,7 +428,6 @@ function createBottomAnchorControllerDriver(
pendingVerification = null;
markStickyMeasurementVerified();
if (isRequestAttempt) {
input.log("request_fulfilled", getLogContext());
pendingRequest = null;
}
setBlockedReason(null);
@@ -520,21 +453,7 @@ function createBottomAnchorControllerDriver(
return;
}
input.log(
"attempt_failed",
getLogContext({
retries: attemptContext.retries,
retryDisposition,
measurementState: getDetailedMeasurementState(measurementState),
})
);
pendingVerification = null;
if (isRequestAttempt && currentRequest) {
input.warn({
agentId: input.getAgentId(),
reason: currentRequest.reason,
});
}
setBlockedReason(
isRequestAttempt ? "waiting_for_post_layout_verification" : null
);
@@ -552,14 +471,6 @@ function createBottomAnchorControllerDriver(
startedViewportHeight: measurementState.viewportHeight,
};
pendingVerification = attemptContext;
input.log(
"attempt_started",
getLogContext({
animated,
retries: attemptContext.retries,
measurementState: getDetailedMeasurementState(measurementState),
})
);
input.scrollToBottom(animated);
scheduleVerification(attemptContext);
setBlockedReason(deriveDriverBlockedReason(input.getMeasurementState()));
@@ -576,18 +487,6 @@ function createBottomAnchorControllerDriver(
| "manual_reevaluate"
| "retry_scroll"
) => {
input.log(
"evaluate_called",
getLogContext({
evaluateReason: reason,
animated,
hasAttemptHandle: attemptHandle !== null,
hasVerificationHandle: verificationHandle !== null,
pendingVerificationRequestId: pendingVerification?.requestId ?? null,
pendingVerificationRetries: pendingVerification?.retries ?? null,
measurementState: getDetailedMeasurementState(input.getMeasurementState()),
})
);
if (attemptHandle) {
return;
}
@@ -610,17 +509,6 @@ function createBottomAnchorControllerDriver(
!shouldAttemptForPendingRequest &&
!shouldAttemptForStickyVerification
) {
input.log(
"attempt_started",
getLogContext({
attemptPhase: "skipped",
evaluateReason: reason,
nextBlockedReason,
shouldAttemptForPendingRequest,
shouldAttemptForStickyVerification,
measurementState: getDetailedMeasurementState(measurementState),
})
);
return;
}
@@ -630,17 +518,6 @@ function createBottomAnchorControllerDriver(
};
const createRequest = (request: BottomAnchorRouteRequest | BottomAnchorLocalRequest) => {
const existing = pendingRequest;
if (existing) {
input.log(
"request_cancelled",
getLogContext({
cancelledRequestReason: existing.reason,
cancelReason: "replaced_by_new_request",
})
);
}
cancelPendingAttempt();
const nextRequest: BottomAnchorRequest = {
id: requestSequence + 1,
@@ -659,10 +536,6 @@ function createBottomAnchorControllerDriver(
? "sticky-bottom"
: __private__.deriveModeForLocalRequest({ reason: request.reason })
);
input.log(
"request_created",
getLogContext({ requestReason: request.reason })
);
evaluate(request.reason === "jump-to-bottom", "request_created");
};
@@ -707,7 +580,6 @@ function createBottomAnchorControllerDriver(
}
cancelPendingRequest("user_scrolled_away");
setModeInternal("detached");
input.log("detached_by_user", getLogContext());
},
handleViewportMetricsChange(params) {
if (
@@ -905,10 +777,6 @@ export function useBottomAnchorController(input: {
isNearBottom: () => isNearBottomRef.current(),
scrollToBottom: (animated) => scrollToBottomRef.current(animated),
onModeChange: (nextMode) => setMode(nextMode),
log: (event, details) => logBottomAnchorEvent(event, details),
warn: (details) => {
console.warn("[BottomAnchor] request could not be fulfilled", details);
},
scheduleFrame: ({ callback, delayFrames }) =>
scheduleAnimationFrameWithDelay({ callback, delayFrames }),
cancelFrame: (handle) => cancelScheduledAnimationFrame(handle),
@@ -1,21 +1,35 @@
import { ActivityIndicator, Alert, Pressable, View } from "react-native";
import { useEffect } from "react";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { Mic, MicOff, Square } from "lucide-react-native";
import { VolumeMeter } from "@/components/volume-meter";
import { useVoice } from "@/contexts/voice-context";
import { useVoice, useVoiceTelemetry } from "@/contexts/voice-context";
export function VoiceCompactIndicator() {
const { theme } = useUnistyles();
const { volume, isDetecting, isSpeaking } = useVoiceTelemetry();
const {
isVoiceMode,
isVoiceSwitching,
volume,
isMuted,
isDetecting,
isSpeaking,
toggleMute,
stopVoice,
} = useVoice();
console.log("[VoiceCompactIndicator] render", {
isVoiceMode,
isVoiceSwitching,
isMuted,
volume,
isDetecting,
isSpeaking,
});
useEffect(() => {
console.log("[VoiceCompactIndicator] mount");
return () => {
console.log("[VoiceCompactIndicator] unmount");
};
}, []);
if (!isVoiceMode) {
return null;
+17 -4
View File
@@ -1,22 +1,35 @@
import { View, Pressable } from "react-native";
import { useEffect } from "react";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { MicOff, Square } from "lucide-react-native";
import { VolumeMeter } from "./volume-meter";
import { useVoice } from "@/contexts/voice-context";
import { useVoice, useVoiceTelemetry } from "@/contexts/voice-context";
import { useHosts } from "@/runtime/host-runtime";
export function VoicePanel() {
const { theme } = useUnistyles();
const daemons = useHosts();
const { volume, isDetecting, isSpeaking } = useVoiceTelemetry();
const {
volume,
isMuted,
isDetecting,
isSpeaking,
stopVoice,
toggleMute,
activeServerId,
} = useVoice();
console.log("[VoicePanel] render", {
activeServerId,
isMuted,
volume,
isDetecting,
isSpeaking,
});
useEffect(() => {
console.log("[VoicePanel] mount");
return () => {
console.log("[VoicePanel] unmount");
};
}, []);
const hostLabel = activeServerId
? daemons.find((daemon) => daemon.serverId === activeServerId)?.label ?? null
+25 -54
View File
@@ -3,10 +3,9 @@ import { View } from "react-native";
import ReanimatedAnimated, {
useSharedValue,
useAnimatedStyle,
withSpring,
withTiming,
withRepeat,
withSequence,
withTiming,
Easing,
} from "react-native-reanimated";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
@@ -41,9 +40,7 @@ export function VolumeMeter({
orientation === "horizontal" ? (isCompact ? 8 : 12) : isCompact ? 14 : 20;
// Create shared values for 3 dots unconditionally
const line1Height = useSharedValue(MIN_HEIGHT);
const line2Height = useSharedValue(MIN_HEIGHT);
const line3Height = useSharedValue(MIN_HEIGHT);
const animatedVolume = useSharedValue(0);
const line1Pulse = useSharedValue(1);
const line2Pulse = useSharedValue(1);
const line3Pulse = useSharedValue(1);
@@ -90,54 +87,19 @@ export function VolumeMeter({
);
}, [isMuted]);
// Update heights based on volume with different responsiveness for all dots
// Drive a single animated volume value and derive the individual bar heights
// on the UI thread instead of scheduling three independent springs per sample.
useEffect(() => {
if (isMuted) {
// When muted, keep all lines at minimum height without animation
line1Height.value = MIN_HEIGHT;
line2Height.value = MIN_HEIGHT;
line3Height.value = MIN_HEIGHT;
animatedVolume.value = 0;
return;
}
if (volume > 0.001) {
// Active volume - animate heights based on volume
const target1 = MIN_HEIGHT + (MAX_HEIGHT * volume * 1.2);
const target2 = MIN_HEIGHT + (MAX_HEIGHT * volume * 1.05);
const target3 = MIN_HEIGHT + (MAX_HEIGHT * volume * 0.9);
line1Height.value = withSpring(target1, {
damping: 10,
stiffness: 200,
});
line2Height.value = withSpring(target2, {
damping: 12.5,
stiffness: 175,
});
line3Height.value = withSpring(target3, {
damping: 15,
stiffness: 150,
});
} else {
// No volume - return to minimum
line1Height.value = withSpring(MIN_HEIGHT, {
damping: 20,
stiffness: 150,
});
line2Height.value = withSpring(MIN_HEIGHT, {
damping: 20,
stiffness: 150,
});
line3Height.value = withSpring(MIN_HEIGHT, {
damping: 20,
stiffness: 150,
});
}
}, [volume, isMuted]);
animatedVolume.value = withTiming(volume, {
duration: volume > animatedVolume.value ? 70 : 140,
easing: Easing.out(Easing.cubic),
});
}, [animatedVolume, isMuted, volume]);
const lineColor = color ?? theme.colors.foreground;
const containerHeight =
@@ -147,9 +109,12 @@ export function VolumeMeter({
const line1Style = useAnimatedStyle(() => {
const isActive = isSpeaking;
const baseOpacity = isMuted ? 0.3 : isActive ? 0.9 : 0.5;
const volumeBoost = isMuted || !isActive ? 0 : volume * 0.3;
const currentVolume = isMuted ? 0 : animatedVolume.value;
const currentHeight = MIN_HEIGHT + (MAX_HEIGHT * currentVolume * 1.2);
const volumeBoost = isMuted || !isActive ? 0 : currentVolume * 0.3;
return {
height: line1Height.value * (isMuted || volume > 0.001 ? 1 : line1Pulse.value),
height:
currentHeight * (isMuted || currentVolume > 0.001 ? 1 : line1Pulse.value),
opacity: baseOpacity + volumeBoost,
};
});
@@ -157,9 +122,12 @@ export function VolumeMeter({
const line2Style = useAnimatedStyle(() => {
const isActive = isSpeaking;
const baseOpacity = isMuted ? 0.3 : isActive ? 0.9 : 0.5;
const volumeBoost = isMuted || !isActive ? 0 : volume * 0.3;
const currentVolume = isMuted ? 0 : animatedVolume.value;
const currentHeight = MIN_HEIGHT + (MAX_HEIGHT * currentVolume * 1.05);
const volumeBoost = isMuted || !isActive ? 0 : currentVolume * 0.3;
return {
height: line2Height.value * (isMuted || volume > 0.001 ? 1 : line2Pulse.value),
height:
currentHeight * (isMuted || currentVolume > 0.001 ? 1 : line2Pulse.value),
opacity: baseOpacity + volumeBoost,
};
});
@@ -167,9 +135,12 @@ export function VolumeMeter({
const line3Style = useAnimatedStyle(() => {
const isActive = isSpeaking;
const baseOpacity = isMuted ? 0.3 : isActive ? 0.9 : 0.5;
const volumeBoost = isMuted || !isActive ? 0 : volume * 0.3;
const currentVolume = isMuted ? 0 : animatedVolume.value;
const currentHeight = MIN_HEIGHT + (MAX_HEIGHT * currentVolume * 0.9);
const volumeBoost = isMuted || !isActive ? 0 : currentVolume * 0.3;
return {
height: line3Height.value * (isMuted || volume > 0.001 ? 1 : line3Pulse.value),
height:
currentHeight * (isMuted || currentVolume > 0.001 ? 1 : line3Pulse.value),
opacity: baseOpacity + volumeBoost,
};
});