refactor(app): replace voice hooks with voice runtime and audio engine architecture
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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={() => {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user