diff --git a/packages/app/assets/audio/thinking-tone.wav b/packages/app/assets/audio/thinking-tone.wav
new file mode 100644
index 000000000..aed18cce6
Binary files /dev/null and b/packages/app/assets/audio/thinking-tone.wav differ
diff --git a/packages/app/src/app/_layout.tsx b/packages/app/src/app/_layout.tsx
index aecf39d3e..a300f6c59 100644
--- a/packages/app/src/app/_layout.tsx
+++ b/packages/app/src/app/_layout.tsx
@@ -442,6 +442,7 @@ function ProvidersWrapper({ children }: { children: ReactNode }) {
return (
+
{children}
);
@@ -582,7 +583,6 @@ export default function RootLayout() {
-
diff --git a/packages/app/src/components/agent-input-area.tsx b/packages/app/src/components/agent-input-area.tsx
index abec6dd01..55153a952 100644
--- a/packages/app/src/components/agent-input-area.tsx
+++ b/packages/app/src/components/agent-input-area.tsx
@@ -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,
diff --git a/packages/app/src/components/left-sidebar.tsx b/packages/app/src/components/left-sidebar.tsx
index 8c304ff8c..9add6a26f 100644
--- a/packages/app/src/components/left-sidebar.tsx
+++ b/packages/app/src/components/left-sidebar.tsx
@@ -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
) : (
) : (
+ _event: string,
+ _details: Record
): 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(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(null)
const inputWrapperRef = useRef(null)
@@ -293,6 +301,19 @@ export const MessageInput = forwardRef(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(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(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(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(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(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(funct
/>
) : showRealtimeOverlay && voice ? (
{
diff --git a/packages/app/src/components/message.tsx b/packages/app/src/components/message.tsx
index 821593b22..6dbf5b1c6 100644
--- a/packages/app/src/components/message.tsx
+++ b/packages/app/src/components/message.tsx
@@ -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(() => {
diff --git a/packages/app/src/components/realtime-voice-overlay.tsx b/packages/app/src/components/realtime-voice-overlay.tsx
index 2f0b34b3d..2901caf31 100644
--- a/packages/app/src/components/realtime-voice-overlay.tsx
+++ b/packages/app/src/components/realtime-voice-overlay.tsx
@@ -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 (
diff --git a/packages/app/src/components/sidebar-workspace-list.tsx b/packages/app/src/components/sidebar-workspace-list.tsx
index ae2f18a23..8aeb78624 100644
--- a/packages/app/src/components/sidebar-workspace-list.tsx
+++ b/packages/app/src/components/sidebar-workspace-list.tsx
@@ -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
@@ -1156,6 +1160,13 @@ function ProjectBlock({
[renderWorkspaceRow]
)
+ const handleWorkspaceDragEnd = useCallback(
+ (workspaces: SidebarWorkspaceEntry[]) => {
+ onWorkspaceReorder(project.projectKey, workspaces)
+ },
+ [onWorkspaceReorder, project.projectKey]
+ )
+
return (
{flattenedWorkspace ? (
@@ -1201,9 +1212,9 @@ function ProjectBlock({
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()
@@ -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({
project.projectKey}
+ keyExtractor={projectKeyExtractor}
renderItem={renderProject}
onDragEnd={handleProjectDragEnd}
scrollEnabled={false}
diff --git a/packages/app/src/components/stream-strategy-web.tsx b/packages/app/src/components/stream-strategy-web.tsx
index 21d360bfb..1b3d4c8c3 100644
--- a/packages/app/src/components/stream-strategy-web.tsx
+++ b/packages/app/src/components/stream-strategy-web.tsx
@@ -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): void {
- if (!IS_DEV) {
- return
- }
- console.log('[WebStickyBottom]', event, details)
+function logWebStickyBottom(_event: string, _details: Record): void {
+ // Intentionally disabled: this path is too noisy during voice debugging.
}
function getDebugNow(): number | null {
diff --git a/packages/app/src/components/ui/combobox.tsx b/packages/app/src/components/ui/combobox.tsx
index cdc963e41..648ec3859 100644
--- a/packages/app/src/components/ui/combobox.tsx
+++ b/packages/app/src/components/ui/combobox.tsx
@@ -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(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"
diff --git a/packages/app/src/components/use-bottom-anchor-controller.test.ts b/packages/app/src/components/use-bottom-anchor-controller.test.ts
index 84120424f..7844e5c4d 100644
--- a/packages/app/src/components/use-bottom-anchor-controller.test.ts
+++ b/packages/app/src/components/use-bottom-anchor-controller.test.ts
@@ -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 }> = [];
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();
});
diff --git a/packages/app/src/components/use-bottom-anchor-controller.ts b/packages/app/src/components/use-bottom-anchor-controller.ts
index 99867341d..db98ac849 100644
--- a/packages/app/src/components/use-bottom-anchor-controller.ts
+++ b/packages/app/src/components/use-bottom-anchor-controller.ts
@@ -110,8 +110,6 @@ type CreateBottomAnchorControllerDriverInput = {
isNearBottom: () => boolean;
scrollToBottom: (animated: boolean) => void;
onModeChange: (mode: BottomAnchorMode) => void;
- log: (event: BottomAnchorEvent, details: Record) => 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
-): 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),
diff --git a/packages/app/src/components/voice-compact-indicator.tsx b/packages/app/src/components/voice-compact-indicator.tsx
index b882313c9..c4bd2f0fb 100644
--- a/packages/app/src/components/voice-compact-indicator.tsx
+++ b/packages/app/src/components/voice-compact-indicator.tsx
@@ -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;
diff --git a/packages/app/src/components/voice-panel.tsx b/packages/app/src/components/voice-panel.tsx
index c1ed3fda2..d5b8e2645 100644
--- a/packages/app/src/components/voice-panel.tsx
+++ b/packages/app/src/components/voice-panel.tsx
@@ -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
diff --git a/packages/app/src/components/volume-meter.tsx b/packages/app/src/components/volume-meter.tsx
index 6c87be3af..f3cb74e2e 100644
--- a/packages/app/src/components/volume-meter.tsx
+++ b/packages/app/src/components/volume-meter.tsx
@@ -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,
};
});
diff --git a/packages/app/src/contexts/session-context.tsx b/packages/app/src/contexts/session-context.tsx
index 77ff03e23..b2d3aac6a 100644
--- a/packages/app/src/contexts/session-context.tsx
+++ b/packages/app/src/contexts/session-context.tsx
@@ -1,7 +1,7 @@
import { useRef, ReactNode, useCallback, useEffect, useMemo } from "react";
+import { Buffer } from "buffer";
import { AppState, Platform } from "react-native";
import { useQueryClient } from "@tanstack/react-query";
-import { useAudioPlayer } from "@/hooks/use-audio-player";
import { useClientActivity } from "@/hooks/use-client-activity";
import { usePushTokenRegistration } from "@/hooks/use-push-token-registration";
import { clearArchiveAgentPending } from "@/hooks/use-archive-agent";
@@ -29,8 +29,13 @@ import type { DaemonClient } from "@server/client/daemon-client";
import { File } from "expo-file-system";
import {
getHostRuntimeStore,
- useHostRuntimeSession,
+ useHostRuntimeIsConnected,
} from "@/runtime/host-runtime";
+import {
+ useVoiceAudioEngineOptional,
+ useVoiceRuntimeOptional,
+} from "@/contexts/voice-context";
+import type { AudioPlaybackSource } from "@/voice/audio-engine-types";
import {
useSessionStore,
type Agent,
@@ -73,6 +78,66 @@ export type {
const HISTORY_STALE_AFTER_MS = 60_000;
const AUTHORITATIVE_REVALIDATION_DEBOUNCE_MS = 300;
+type AudioOutputPayload = Extract<
+ SessionOutboundMessage,
+ { type: "audio_output" }
+>["payload"];
+
+interface BufferedAudioChunk {
+ chunkIndex: number;
+ audio: string;
+ format: string;
+ id: string;
+}
+
+function decodeBase64Chunk(base64: string): Uint8Array {
+ return Buffer.from(base64, "base64");
+}
+
+function buildAudioPlaybackSource(
+ chunks: BufferedAudioChunk[]
+): AudioPlaybackSource {
+ const decodedChunks = chunks.map((chunk) => decodeBase64Chunk(chunk.audio));
+ const totalSize = decodedChunks.reduce((sum, chunk) => sum + chunk.length, 0);
+ const output = new Uint8Array(totalSize);
+ let offset = 0;
+ for (const chunk of decodedChunks) {
+ output.set(chunk, offset);
+ offset += chunk.length;
+ }
+
+ const format = chunks[0]?.format ?? "pcm";
+ const mimeType =
+ format === "pcm"
+ ? "audio/pcm;rate=24000;bits=16"
+ : format === "mp3"
+ ? "audio/mpeg"
+ : `audio/${format}`;
+
+ const bytes = output.slice();
+ return {
+ size: bytes.byteLength,
+ type: mimeType,
+ async arrayBuffer() {
+ return bytes.buffer.slice(
+ bytes.byteOffset,
+ bytes.byteOffset + bytes.byteLength
+ );
+ },
+ };
+}
+
+function logVoiceSession(
+ event: string,
+ details?: Record
+): void {
+ if (details) {
+ console.log(`[VoiceSession] ${event}`, details);
+ return;
+ }
+ console.log(`[VoiceSession] ${event}`);
+}
+
const findLatestAssistantMessageText = (items: StreamItem[]): string | null => {
for (let i = items.length - 1; i >= 0; i -= 1) {
const item = items[i];
@@ -206,8 +271,15 @@ function SessionProviderInternal({
serverId,
client,
}: SessionProviderClientProps) {
+ const voiceRuntime = useVoiceRuntimeOptional();
+ const voiceAudioEngine = useVoiceAudioEngineOptional();
+ console.log("[SessionProvider] render", {
+ serverId,
+ hasVoiceRuntime: Boolean(voiceRuntime),
+ hasVoiceAudioEngine: Boolean(voiceAudioEngine),
+ });
const queryClient = useQueryClient();
- const { isConnected } = useHostRuntimeSession(serverId);
+ const isConnected = useHostRuntimeIsConnected(serverId);
// Zustand store actions
const initializeSession = useSessionStore((state) => state.initializeSession);
@@ -276,16 +348,6 @@ function SessionProviderInternal({
(state) => state.sessions[serverId]?.agents
);
- // State for voice detection flags (will be set by RealtimeContext)
- const isDetectingRef = useRef(false);
- const isSpeakingRef = useRef(false);
-
- const audioPlayer = useAudioPlayer({
- isDetecting: () => isDetectingRef.current,
- isSpeaking: () => isSpeakingRef.current,
- });
-
- const activeAudioGroupsRef = useRef>(new Set());
const previousAgentStatusRef = useRef