diff --git a/docs/architecture.md b/docs/architecture.md
index 0ed8c46ade..e0e32921cd 100644
--- a/docs/architecture.md
+++ b/docs/architecture.md
@@ -75,6 +75,8 @@ not retain non-Git directories.
| `server/agent/agent-storage.ts` | File-backed JSON persistence at `$PASEO_HOME/agents/` |
| `server/agent/tools/` | Transport-neutral catalog for workspaces, agents, permissions, and automation |
| `server/agent/mcp-server.ts` | Thin MCP adapter that registers the Paseo tool catalog with the MCP SDK |
+| `server/voice-chat/` | App-level voice calls, provider orchestration, and semantic call events |
+| `server/session/dictation/` | Composer dictation stream lifetime and speech-readiness gating |
| `server/agent/providers/` | Provider adapters (see "Agent providers" below) |
| `server/orchestration-skills/` | Bundled catalog, host selection, convergence, and skill-directory transactions |
| `server/relay-transport.ts` | Outbound relay connection with E2E encryption |
@@ -111,7 +113,25 @@ Cross-platform React Native app that connects to one or more daemons.
- Composer UI and submit/draft behavior live in `packages/app/src/composer/`; screens and panels should integrate it from there instead of dropping composer internals into `components/`, `hooks/`, or `screens/workspace/`
- Timeline reducers in `timeline/session-stream-reducers.ts` handle compaction, gap detection, sequence-based deduplication
- Timeline sync correctness is documented in [docs/timeline-sync.md](timeline-sync.md): live streams are for immediacy, `fetch_agent_timeline_request` is authoritative, and catch-up is paged but complete.
-- Voice features: dictation (STT) and voice agent (realtime)
+- App `VoiceRuntime` owns the app-level call UX, current route context, and semantic transcript. It
+ prepares installed client transports and accepts the server's selected answer without inspecting
+ transport kind. Each transport owns capture, playback, its data plane, mute, volume, and media
+ state; `transports/daemon-audio.ts` is the production transport in this slice. A WebRTC transport
+ adds another factory at app composition and does not change `VoiceRuntime`.
+- Server `VoiceChat` serializes start/stop, cancels startup, routes provider-neutral state/events,
+ forwards opaque transport data, and reacts to the provider's terminal signal. It knows only the
+ `VoiceCallProvider` contract; concrete data-plane validation and serialization stay in the
+ provider and matching client transport. Providers own prompts, credentials, hidden resources,
+ tools, media integration, and cleanup. The manual provider creates a hidden orchestrator through
+ the atomic required-Paseo-tools capability with an authoritative provider prompt, and owns a
+ separate speech runtime configured from the top-level `manualVoice` block. Dictation keeps its own
+ speech runtime. A complete manual orchestrator provider and mode selection is required; partial
+ configuration does not fall back to another provider or mode. Cleanup detaches work delegated by
+ the hidden orchestrator before archiving it.
+- Voice calls end as soon as the final client socket disconnects. The containing client session may
+ remain reconnectable, but calls are not replayed or rehydrated in this protocol slice. The sidebar
+ footer starts the call and hosts its controls; routes and visible agents do not own its lifetime.
+ Dictation remains a separate composer feature.
Consumers request directory or timeline data without choosing memory, cache, or network. The owner
publishes an accepted cache hit and then reconciles it over the existing network path. A miss or an
@@ -264,7 +284,8 @@ optionally carry the same `turnId`. New clients use these fields when present an
status once at the directory boundary rather than maintaining a second activity model.
- Terminal subscribe/input/capture commands
-- Voice/dictation streaming events (`dictation_stream_*`, `assistant_chunk`, `audio_output`, `transcription_result`)
+- App-level voice call commands and events (`voice.call.*`) and independent dictation streams
+ (`dictation_stream_*`)
- Request/response pairs for fetch, list, create, etc., correlated by `requestId`; failures use `rpc_error`
`directory_suggestions_request` is one daemon-owned filesystem search capability. The daemon
diff --git a/packages/app/src/app/_layout.tsx b/packages/app/src/app/_layout.tsx
index c57dba4429..3ea961b166 100644
--- a/packages/app/src/app/_layout.tsx
+++ b/packages/app/src/app/_layout.tsx
@@ -4,7 +4,13 @@ import { PortalProvider } from "@gorhom/portal";
import { QueryClientProvider } from "@tanstack/react-query";
import * as Linking from "expo-linking";
import * as Notifications from "expo-notifications";
-import { Stack, useNavigationContainerRef, usePathname, useRouter } from "expo-router";
+import {
+ Stack,
+ useGlobalSearchParams,
+ useNavigationContainerRef,
+ usePathname,
+ useRouter,
+} from "expo-router";
import {
createContext,
type ReactNode,
@@ -63,7 +69,7 @@ import { HorizontalScrollProvider } from "@/contexts/horizontal-scroll-context";
import { SessionProvider } from "@/contexts/session-context";
import { SidebarCalloutProvider } from "@/contexts/sidebar-callout-context";
import { ToastProvider } from "@/contexts/toast-context";
-import { VoiceProvider } from "@/contexts/voice-context";
+import { useVoiceOptional, VoiceProvider } from "@/contexts/voice-context";
import {
resolveStartupBlocker,
resolveStartupNavigationReady,
@@ -667,11 +673,35 @@ function ProvidersWrapper({ children }: { children: ReactNode }) {
{children}
+
);
}
+function VoiceCallContextSync() {
+ const voice = useVoiceOptional();
+ const params = useGlobalSearchParams<{
+ serverId?: string | string[];
+ workspaceId?: string | string[];
+ agentId?: string | string[];
+ }>();
+ const serverId = Array.isArray(params.serverId) ? params.serverId[0] : params.serverId;
+ const workspaceId = Array.isArray(params.workspaceId)
+ ? params.workspaceId[0]
+ : params.workspaceId;
+ const agentId = Array.isArray(params.agentId) ? params.agentId[0] : params.agentId;
+
+ useEffect(() => {
+ if (!voice?.isVoiceMode || voice.activeServerId !== serverId) return;
+ void voice
+ .updateCallContext({ workspaceId: workspaceId ?? null, agentId: agentId ?? null })
+ .catch((error) => console.warn("[VoiceCall] Failed to update app context", error));
+ }, [agentId, serverId, voice, workspaceId]);
+
+ return null;
+}
+
function DesktopWindowControlsSync() {
const { isLoading } = useAppSettings();
const { theme } = useUnistyles();
diff --git a/packages/app/src/components/left-sidebar.tsx b/packages/app/src/components/left-sidebar.tsx
index 1cdfe83e6d..b664148ff3 100644
--- a/packages/app/src/components/left-sidebar.tsx
+++ b/packages/app/src/components/left-sidebar.tsx
@@ -1,5 +1,13 @@
-import { router } from "expo-router";
-import { FolderPlus, GitBranch, Import, Server, Settings, X } from "lucide-react-native";
+import { router, useGlobalSearchParams } from "expo-router";
+import {
+ AudioLines,
+ FolderPlus,
+ GitBranch,
+ Import,
+ Server,
+ Settings,
+ X,
+} from "lucide-react-native";
import { useTranslation } from "react-i18next";
import { memo, useCallback, useEffect, useMemo, useRef, useState, type RefObject } from "react";
import {
@@ -42,7 +50,9 @@ import { RetainedPanelActivity } from "@/components/retained-panel";
import type { SidebarWorkspaceGroup } from "@/components/sidebar/sidebar-labels";
import type { SidebarProjectIconTarget } from "@/utils/sidebar-project-row-model";
import { type SidebarGroupMode, useSidebarViewStore } from "@/stores/sidebar-view-store";
-import { useHosts } from "@/runtime/host-runtime";
+import { useHostRuntimeIsConnected, useHosts } from "@/runtime/host-runtime";
+import { useVoiceOptional } from "@/contexts/voice-context";
+import { useToast } from "@/contexts/toast-context";
import { usePanelStore } from "@/stores/panel-store";
import { useOwnsWindowChromeCorner, WindowChromeSafeArea } from "@/utils/desktop-window";
import { useCloseAgentListGesture } from "@/mobile-panels/gestures";
@@ -52,6 +62,11 @@ import { openHostOverview } from "@/navigation/settings-navigation";
import { SidebarAgentListSkeleton } from "./sidebar-agent-list-skeleton";
import { SidebarCalloutSlot } from "./sidebar-callout-slot";
import { SidebarWorkspaceList } from "./sidebar-workspace-list";
+import { VoiceCallSurface } from "./voice-call-surface";
+import {
+ useActiveWorkspaceSelection,
+ useLastWorkspaceSelection,
+} from "@/stores/navigation-active-workspace-store";
type SidebarTheme = ReturnType["theme"];
@@ -268,6 +283,7 @@ function sidebarHostOptionTestID(serverId: string): string {
function FooterIconButton({
buttonRef,
onPress,
+ disabled = false,
testID,
label,
icon: Icon,
@@ -276,6 +292,7 @@ function FooterIconButton({
theme,
}: {
onPress: () => void;
+ disabled?: boolean;
testID: string;
label: string;
icon: typeof FolderPlus;
@@ -289,19 +306,20 @@ function FooterIconButton({
{({ hovered }) => (
)}
@@ -313,6 +331,68 @@ function FooterIconButton({
);
}
+function firstSearchParam(value: string | string[] | undefined): string | null {
+ return Array.isArray(value) ? (value[0] ?? null) : (value ?? null);
+}
+
+function SidebarVoiceChatButton({ theme }: { theme: SidebarTheme }) {
+ const { t } = useTranslation();
+ const toast = useToast();
+ const voice = useVoiceOptional();
+ const activeWorkspace = useActiveWorkspaceSelection();
+ const lastWorkspace = useLastWorkspaceSelection();
+ const workspace = activeWorkspace ?? lastWorkspace;
+ const params = useGlobalSearchParams<{
+ serverId?: string | string[];
+ workspaceId?: string | string[];
+ agentId?: string | string[];
+ }>();
+ const isConnected = useHostRuntimeIsConnected(workspace?.serverId ?? "");
+ const isActiveRoute =
+ workspace?.serverId === firstSearchParam(params.serverId) &&
+ workspace?.workspaceId === firstSearchParam(params.workspaceId);
+ const agentId = isActiveRoute ? firstSearchParam(params.agentId) : null;
+ const disabled = !voice || voice.isVoiceSwitching;
+
+ const handlePress = useCallback(() => {
+ if (!voice || disabled) return;
+ if (!workspace) {
+ toast.error("Open a workspace before starting voice chat.");
+ return;
+ }
+ if (!isConnected) {
+ toast.error("Connect to the host before starting voice chat.");
+ return;
+ }
+ void voice
+ .startCall(workspace.serverId, { workspaceId: workspace.workspaceId, agentId })
+ .catch((error) => {
+ console.error("[Sidebar] Failed to start voice chat", error);
+ toast.error(error instanceof Error ? error.message : "Unable to start voice chat");
+ });
+ }, [agentId, disabled, isConnected, toast, voice, workspace]);
+
+ if (voice?.isVoiceMode) return null;
+
+ return (
+
+ );
+}
+
+function VoiceWorkspaceListClearance() {
+ const voice = useVoiceOptional();
+ return voice?.isVoiceMode ? : null;
+}
+
+const voiceWorkspaceListClearance = ;
+
function footerAddProjectButtonStyle({
hovered,
}: PressableStateCallbackType & { hovered?: boolean }) {
@@ -489,6 +569,7 @@ function SidebarFooter({
icon={Import}
theme={theme}
/>
+
)}
+
)}
+
({
paddingVertical: theme.spacing[1],
paddingHorizontal: theme.spacing[1],
},
+ footerIconButtonDisabled: {
+ opacity: theme.opacity[50],
+ },
+ voiceWorkspaceListClearance: {
+ height: theme.spacing[12],
+ },
tooltipRow: {
flexDirection: "row",
alignItems: "center",
diff --git a/packages/app/src/components/realtime-voice-overlay.tsx b/packages/app/src/components/realtime-voice-overlay.tsx
index 88b2e378b4..f4930c1481 100644
--- a/packages/app/src/components/realtime-voice-overlay.tsx
+++ b/packages/app/src/components/realtime-voice-overlay.tsx
@@ -3,24 +3,26 @@ import { useMemo } from "react";
import { useTranslation } from "react-i18next";
import { Pressable, View } from "react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
-import { Mic, MicOff, Square } from "lucide-react-native";
-import { FOOTER_HEIGHT } from "@/constants/layout";
+import { MessageCircle, Mic, MicOff, Square } from "lucide-react-native";
import { useVoiceTelemetry } from "@/contexts/voice-context";
import { VolumeMeter } from "./volume-meter";
interface RealtimeVoiceOverlayProps {
isMuted: boolean;
isSwitching: boolean;
+ isTranscriptOpen: boolean;
+ onToggleTranscript: () => void;
onToggleMute: () => void;
onStop: () => void;
}
-const OVERLAY_BUTTON_SIZE = 44;
-const OVERLAY_VERTICAL_PADDING = (FOOTER_HEIGHT - OVERLAY_BUTTON_SIZE) / 2;
+const OVERLAY_BUTTON_SIZE = 28;
export function RealtimeVoiceOverlay({
isMuted,
isSwitching,
+ isTranscriptOpen,
+ onToggleTranscript,
onToggleMute,
onStop,
}: RealtimeVoiceOverlayProps) {
@@ -42,12 +44,26 @@ export function RealtimeVoiceOverlay({
);
return (
+
+
+
+
@@ -62,9 +78,9 @@ export function RealtimeVoiceOverlay({
style={muteButtonStyle}
>
{isMuted ? (
-
+
) : (
-
+
)}
@@ -79,7 +95,7 @@ export function RealtimeVoiceOverlay({
) : (
({
flexDirection: "row",
alignItems: "center",
width: "100%",
- height: FOOTER_HEIGHT,
- borderRadius: theme.borderRadius["2xl"],
+ minHeight: 40,
+ borderRadius: theme.borderRadius.xl,
justifyContent: "space-between",
- paddingHorizontal: theme.spacing[4],
- paddingVertical: OVERLAY_VERTICAL_PADDING,
+ paddingHorizontal: theme.spacing[2],
+ paddingVertical: theme.spacing[1.5],
backgroundColor: theme.colors.surface1,
borderWidth: theme.borderWidth[1],
borderColor: theme.colors.border,
},
meterContainer: {
flex: 1,
+ minWidth: 0,
alignItems: "center",
justifyContent: "center",
},
actionsContainer: {
flexDirection: "row",
alignItems: "center",
- gap: theme.spacing[2],
+ gap: theme.spacing[1],
},
actionButton: {
width: OVERLAY_BUTTON_SIZE,
@@ -127,6 +144,14 @@ const styles = StyleSheet.create((theme) => ({
borderWidth: theme.borderWidth[1],
borderColor: theme.colors.border,
},
+ transcriptButton: {
+ backgroundColor: theme.colors.surface0,
+ borderWidth: theme.borderWidth[1],
+ borderColor: theme.colors.border,
+ },
+ transcriptButtonOpen: {
+ backgroundColor: theme.colors.interactionHighlight,
+ },
muteButtonMuted: {
backgroundColor: theme.colors.palette.red[600],
borderColor: theme.colors.palette.red[800],
diff --git a/packages/app/src/components/voice-call-surface.tsx b/packages/app/src/components/voice-call-surface.tsx
new file mode 100644
index 0000000000..7167685ca2
--- /dev/null
+++ b/packages/app/src/components/voice-call-surface.tsx
@@ -0,0 +1,149 @@
+import { useCallback, useState } from "react";
+import { Pressable, ScrollView, Text, View } from "react-native";
+import { StyleSheet } from "react-native-unistyles";
+import { RealtimeVoiceOverlay } from "@/components/realtime-voice-overlay";
+import { useVoiceOptional } from "@/contexts/voice-context";
+import type { VoiceCallEvent } from "@getpaseo/protocol/messages";
+
+function VoiceCallEventRow({ event }: { event: VoiceCallEvent }) {
+ switch (event.type) {
+ case "transcript":
+ return (
+
+ {event.speaker === "user" ? "You:" : "Paseo:"}
+ {event.text}
+
+ );
+ case "activity":
+ return (
+
+ {event.label}
+ {event.state}
+
+ );
+ case "notice":
+ return {event.text};
+ case "error":
+ return {event.message};
+ }
+}
+
+export function VoiceCallSurface() {
+ const voice = useVoiceOptional();
+ const [transcriptOpen, setTranscriptOpen] = useState(false);
+ const toggleTranscript = useCallback(() => setTranscriptOpen((open) => !open), []);
+
+ if (!voice || (!voice.isVoiceMode && voice.events.length === 0)) return null;
+
+ const showTranscript = transcriptOpen || !voice.isVoiceMode;
+
+ return (
+
+
+ {showTranscript ? (
+
+ {voice.events.length === 0 ? (
+ Conversation will appear here.
+ ) : (
+ voice.events.map((event) => )
+ )}
+
+ ) : null}
+ {voice.isVoiceMode ? (
+
+ ) : (
+
+ Dismiss
+
+ )}
+
+
+ );
+}
+
+const styles = StyleSheet.create((theme) => ({
+ host: {
+ width: "100%",
+ paddingHorizontal: theme.spacing[2],
+ paddingBottom: theme.spacing[1.5],
+ },
+ surface: {
+ width: "100%",
+ gap: theme.spacing[2],
+ },
+ transcript: {
+ maxHeight: 200,
+ paddingHorizontal: theme.spacing[2],
+ paddingVertical: theme.spacing[1.5],
+ borderRadius: theme.borderRadius.lg,
+ borderWidth: theme.borderWidth[1],
+ borderColor: theme.colors.border,
+ backgroundColor: theme.colors.surface0,
+ },
+ transcriptContent: {
+ gap: theme.spacing[1.5],
+ },
+ empty: {
+ color: theme.colors.foregroundMuted,
+ fontSize: theme.fontSize.sm,
+ },
+ eventText: {
+ color: theme.colors.foreground,
+ fontSize: theme.fontSize.sm,
+ },
+ eventLabel: {
+ color: theme.colors.foregroundMuted,
+ fontWeight: theme.fontWeight.medium,
+ },
+ activityRow: {
+ minWidth: 0,
+ flexDirection: "row",
+ alignItems: "center",
+ justifyContent: "space-between",
+ gap: theme.spacing[2],
+ },
+ activityLabel: {
+ minWidth: 0,
+ flexShrink: 1,
+ color: theme.colors.foregroundMuted,
+ fontSize: theme.fontSize.sm,
+ },
+ activityState: {
+ color: theme.colors.foregroundExtraMuted,
+ fontSize: theme.fontSize.sm,
+ },
+ notice: {
+ color: theme.colors.foregroundMuted,
+ fontSize: theme.fontSize.sm,
+ },
+ error: {
+ color: theme.colors.palette.red[500],
+ fontSize: theme.fontSize.sm,
+ },
+ transcriptButton: {
+ alignSelf: "flex-end",
+ paddingHorizontal: theme.spacing[3],
+ paddingVertical: theme.spacing[2],
+ borderRadius: theme.borderRadius.full,
+ backgroundColor: theme.colors.surface1,
+ borderWidth: theme.borderWidth[1],
+ borderColor: theme.colors.border,
+ },
+ transcriptButtonText: {
+ color: theme.colors.foreground,
+ fontSize: theme.fontSize.sm,
+ fontWeight: "600",
+ },
+}));
diff --git a/packages/app/src/composer/index.tsx b/packages/app/src/composer/index.tsx
index 5a1d51f1fc..7575b03a37 100644
--- a/packages/app/src/composer/index.tsx
+++ b/packages/app/src/composer/index.tsx
@@ -1,11 +1,5 @@
import { LoadingSpinner } from "@/components/ui/loading-spinner";
-import {
- View,
- Pressable,
- Text,
- StyleSheet as RNStyleSheet,
- type PressableStateCallbackType,
-} from "react-native";
+import { View, Pressable, Text, StyleSheet as RNStyleSheet } from "react-native";
import type { TFunction } from "i18next";
import {
useState,
@@ -25,7 +19,6 @@ import {
ArrowUp,
Square,
Pencil,
- AudioLines,
CircleDot,
FileText,
GitPullRequest,
@@ -75,7 +68,6 @@ import {
type QueueWriter,
type QueuedComposerMessage,
} from "@/composer/actions";
-import { useVoiceOptional } from "@/contexts/voice-context";
import { useToast } from "@/contexts/toast-context";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { Shortcut } from "@/components/ui/shortcut";
@@ -182,14 +174,6 @@ function resolveIsComposerLocked(
return submitBehavior === "preserve-and-lock" && isSubmitLoading;
}
-function resolveIsVoiceModeForAgent(
- voice: ReturnType,
- serverId: string,
- agentId: string,
-): boolean {
- return voice?.isVoiceModeForAgent(serverId, agentId) ?? false;
-}
-
function resolveKeyboardPriority(isMessageInputFocused: boolean): number {
return isMessageInputFocused ? 200 : 100;
}
@@ -241,19 +225,6 @@ function buildCancelButtonStyle(isConnected: boolean, isCancellingAgent: boolean
return [styles.cancelButton, disabled].filter((value): value is object => Boolean(value));
}
-function buildRealtimeVoiceButtonStyle(
- hovered: boolean | undefined,
- voiceButtonDisabled: boolean,
- reserveLeadingSpace: boolean,
-): object[] {
- const hoveredStyle = hovered ? styles.iconButtonHovered : undefined;
- const disabledStyle = voiceButtonDisabled ? styles.buttonDisabled : undefined;
- const reserveStyle = reserveLeadingSpace ? styles.realtimeVoiceButtonCompactReserve : undefined;
- return [styles.realtimeVoiceButton, reserveStyle, hoveredStyle, disabledStyle].filter(
- (value): value is object => Boolean(value),
- );
-}
-
function buildAgentStateSelector(serverId: string, agentId: string) {
return (state: ReturnType) => {
const agent = state.sessions[serverId]?.agents?.get(agentId) ?? null;
@@ -487,29 +458,6 @@ function resolveErrorMessage(error: unknown): string | null {
return null;
}
-interface AttemptStartRealtimeVoiceArgs {
- voice: ReturnType;
- isConnected: boolean;
- hasAgent: boolean;
- serverId: string;
- agentId: string;
- toastErrorRef: { current: (message: string) => void };
-}
-
-function attemptStartRealtimeVoice(args: AttemptStartRealtimeVoiceArgs): void {
- const { voice, isConnected, hasAgent, serverId, agentId, toastErrorRef } = args;
- if (!voice || !isConnected || !hasAgent) return;
- if (voice.isVoiceSwitching) return;
- if (voice.isVoiceModeForAgent(serverId, agentId)) return;
- void voice.startVoice(serverId, agentId).catch((error) => {
- console.error("[Composer] Failed to start voice mode", error);
- const message = resolveErrorMessage(error);
- if (message && message.trim().length > 0) {
- toastErrorRef.current(message);
- }
- });
-}
-
function focusMessageInputWithPlatformStrategy(messageInputRef: {
current: MessageInputRef | null;
}): void {
@@ -1043,88 +991,6 @@ function ComposerCancelButton({
);
}
-interface ComposerVoiceModeButtonProps {
- buttonIconSize: number;
- handleToggleRealtimeVoice: () => void;
- isConnected: boolean;
- isVoiceSwitching: boolean;
- realtimeVoiceButtonStyle: (
- state: PressableStateCallbackType & { hovered?: boolean },
- ) => (object | undefined)[];
- voiceToggleKeys: ReturnType;
- t: TFunction;
-}
-
-interface ComposerRightControlsSlotProps extends ComposerVoiceModeButtonProps {
- isVoiceModeForAgent: boolean;
- hasAgent: boolean;
- isAgentRunning: boolean;
- hasSendableContent: boolean;
- isCompact: boolean;
- showVoice: boolean;
-}
-
-function ComposerRightControlsSlot({
- isVoiceModeForAgent,
- hasAgent,
- isAgentRunning,
- hasSendableContent,
- isCompact,
- showVoice,
- ...voiceProps
-}: ComposerRightControlsSlotProps) {
- const hideVoiceForCompactInput = isCompact && hasSendableContent;
- const showVoiceModeButton =
- showVoice && !isVoiceModeForAgent && hasAgent && !isAgentRunning && !hideVoiceForCompactInput;
- if (!showVoiceModeButton) return null;
- return (
-
-
-
- );
-}
-
-function ComposerVoiceModeButton({
- buttonIconSize,
- handleToggleRealtimeVoice,
- isConnected,
- isVoiceSwitching,
- realtimeVoiceButtonStyle,
- voiceToggleKeys,
- t,
-}: ComposerVoiceModeButtonProps) {
- const shortcutNode = voiceToggleKeys ? : null;
- const renderTriggerContent = useCallback(
- ({ hovered }: PressableStateCallbackType & { hovered?: boolean }) => {
- if (isVoiceSwitching) {
- return ;
- }
- const colorMapping = hovered ? iconForegroundMapping : iconForegroundMutedMapping;
- return ;
- },
- [buttonIconSize, isVoiceSwitching],
- );
- return (
-
-
- {renderTriggerContent}
-
-
-
- {t("composer.voice.voiceMode")}
- {shortcutNode}
-
-
-
- );
-}
-
export function Composer({ isPaneFocused, ...props }: ComposerProps) {
return (
@@ -1192,8 +1058,6 @@ function ComposerContentImpl({
const toast = useToast();
const toastErrorRef = useRef(toast.error);
toastErrorRef.current = toast.error;
- const voice = useVoiceOptional();
- const voiceToggleKeys = useShortcutKeys("voice-toggle");
const agentInterruptKeys = useShortcutKeys("agent-interrupt");
const isDictationReady = useIsDictationReady({
serverId,
@@ -1838,19 +1702,6 @@ function ComposerContentImpl({
focusMessageInputWithPlatformStrategy(messageInputRef);
}, []);
- const isVoiceModeForAgent = resolveIsVoiceModeForAgent(voice, serverId, agentId);
-
- const handleToggleRealtimeVoice = useCallback(() => {
- attemptStartRealtimeVoice({
- voice,
- isConnected,
- hasAgent,
- serverId,
- agentId,
- toastErrorRef,
- });
- }, [agentId, hasAgent, isConnected, serverId, voice]);
-
const handleEditQueuedMessage = useCallback(
(id: string) => {
const result = editQueuedComposerMessage({
@@ -1912,8 +1763,6 @@ function ComposerContentImpl({
],
);
- const hasSendableContent = userInput.trim().length > 0 || selectedAttachments.length > 0;
-
// Handle keyboard navigation for command autocomplete.
const handleCommandKeyPress = useCallback(
(event: ComposerKeyPressEvent) => autocompleteOnKeyPressRef.current(event),
@@ -1925,14 +1774,6 @@ function ComposerContentImpl({
[isConnected, isCancellingAgent],
);
- const isVoiceSwitching = voice?.isVoiceSwitching ?? false;
- const voiceButtonDisabled = !isConnected || isVoiceSwitching;
- const realtimeVoiceButtonStyle = useCallback(
- (state: PressableStateCallbackType & { hovered?: boolean }) =>
- buildRealtimeVoiceButtonStyle(state.hovered, voiceButtonDisabled, isCompactLayout),
- [isCompactLayout, voiceButtonDisabled],
- );
-
const activeActionContent = useMemo(
() => (
(
-
- ),
- [
- buttonIconSize,
- handleToggleRealtimeVoice,
- hasAgent,
- hasSendableContent,
- isAgentRunning,
- isConnected,
- isCompactLayout,
- isVoiceModeForAgent,
- isVoiceSwitching,
- mode.showVoice,
- realtimeVoiceButtonStyle,
- t,
- voiceToggleKeys,
- ],
- );
-
const { contextWindowMaxTokens, contextWindowUsedTokens } = resolveContextWindowValues(
agentState.contextWindowMaxTokens,
agentState.contextWindowUsedTokens,
@@ -2362,9 +2168,9 @@ function ComposerContentImpl({
disabled={isSubmitLoading}
leftContent={leftContent}
beforeVoiceContent={beforeVoiceContent}
- rightContent={rightContent}
activeActionContent={activeActionContent}
voiceServerId={serverId}
+ voiceWorkspaceId={workspaceId ?? undefined}
voiceAgentId={agentId}
isAgentRunning={isAgentRunning}
defaultSendBehavior={activeSendBehavior}
@@ -2461,11 +2267,6 @@ const styles = StyleSheet.create((theme: Theme) => ({
justifyContent: "center",
marginLeft: theme.spacing[1],
},
- rightControls: {
- flexDirection: "row",
- alignItems: "center",
- gap: theme.spacing[1],
- },
contextWindowMeterSlot: {
width: 28,
height: 28,
@@ -2473,20 +2274,6 @@ const styles = StyleSheet.create((theme: Theme) => ({
alignItems: "center",
justifyContent: "center",
},
- realtimeVoiceButton: {
- width: 28,
- height: 28,
- borderRadius: theme.borderRadius.full,
- alignItems: "center",
- justifyContent: "center",
- },
- realtimeVoiceButtonCompactReserve: {
- marginLeft: theme.spacing[1],
- },
- realtimeVoiceButtonActive: {
- backgroundColor: theme.colors.palette.green[600],
- borderColor: theme.colors.palette.green[800],
- },
iconButtonHovered: {
backgroundColor: theme.colors.surface2,
},
@@ -2554,7 +2341,6 @@ const ThemedPencil = withUnistyles(Pencil);
const ThemedArrowUp = withUnistyles(ArrowUp);
const ThemedGitPullRequest = withUnistyles(GitPullRequest);
const ThemedCircleDot = withUnistyles(CircleDot);
-const ThemedAudioLines = withUnistyles(AudioLines);
const ThemedPaperclip = withUnistyles(Paperclip);
const ThemedImageIcon = withUnistyles(ImageIcon);
const ThemedClipboardPaste = withUnistyles(ClipboardPaste);
diff --git a/packages/app/src/composer/input/input.tsx b/packages/app/src/composer/input/input.tsx
index fc8a260cfb..411fd2b366 100644
--- a/packages/app/src/composer/input/input.tsx
+++ b/packages/app/src/composer/input/input.tsx
@@ -24,7 +24,6 @@ import { ICON_SIZE, type Theme } from "@/styles/theme";
import { ArrowUp, Mic, MicOff, CornerDownLeft, Plus, Square } from "lucide-react-native";
import { useDictation } from "@/hooks/use-dictation";
import { DictationOverlay } from "@/components/dictation-controls";
-import { RealtimeVoiceOverlay } from "@/components/realtime-voice-overlay";
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
import { useSessionStore } from "@/stores/session-store";
import { useVoiceOptional } from "@/contexts/voice-context";
@@ -144,6 +143,7 @@ export interface MessageInputProps {
/** Primary action to render when the agent is active and the composer has no sendable content. */
activeActionContent?: React.ReactNode;
voiceServerId?: string;
+ voiceWorkspaceId?: string;
voiceAgentId?: string;
/** When true and there's sendable content, calls onQueue instead of onSubmit */
isAgentRunning?: boolean;
@@ -594,16 +594,9 @@ function MessageInputOverlay({
/>
);
}
- if (showRealtimeOverlay && voice) {
- return (
-
- );
- }
+ void showRealtimeOverlay;
+ void voice;
+ void onRealtimeVoiceStop;
return null;
}
@@ -830,41 +823,50 @@ interface ToggleRealtimeVoiceContext {
voice:
| {
isVoiceSwitching: boolean;
- isVoiceModeForAgent: (serverId: string, agentId: string) => boolean;
- startVoice: (serverId: string, agentId: string) => Promise;
+ isCallActiveOnServer: (serverId: string) => boolean;
+ startCall: (
+ serverId: string,
+ context: { workspaceId: string | null; agentId: string | null },
+ ) => Promise;
}
| null
| undefined;
voiceServerId: string | undefined;
+ voiceWorkspaceId: string | undefined;
voiceAgentId: string | undefined;
isConnected: boolean;
disabled: boolean;
- isAgentRunning: boolean;
handleStopRealtimeVoice: () => Promise | void;
toast: { error: (msg: string) => void };
- interruptBeforeVoiceMessage: string;
}
function toggleRealtimeVoiceImpl(ctx: ToggleRealtimeVoiceContext): void {
- if (!ctx.voice || !ctx.voiceServerId || !ctx.voiceAgentId || !ctx.isConnected || ctx.disabled) {
+ if (
+ !ctx.voice ||
+ !ctx.voiceServerId ||
+ !ctx.voiceWorkspaceId ||
+ !ctx.isConnected ||
+ ctx.disabled
+ ) {
return;
}
if (ctx.voice.isVoiceSwitching) return;
- if (ctx.voice.isVoiceModeForAgent(ctx.voiceServerId, ctx.voiceAgentId)) {
+ if (ctx.voice.isCallActiveOnServer(ctx.voiceServerId)) {
void ctx.handleStopRealtimeVoice();
return;
}
- if (ctx.isAgentRunning) {
- ctx.toast.error(ctx.interruptBeforeVoiceMessage);
- return;
- }
- void ctx.voice.startVoice(ctx.voiceServerId, ctx.voiceAgentId).catch((error) => {
- console.error("[MessageInput] Failed to start realtime voice", error);
- const message = extractErrorMessage(error);
- if (message && message.trim().length > 0) {
- ctx.toast.error(message);
- }
- });
+ void ctx.voice
+ .startCall(ctx.voiceServerId, {
+ workspaceId: ctx.voiceWorkspaceId,
+ agentId: ctx.voiceAgentId ?? null,
+ })
+ .catch((error) => {
+ console.error("[MessageInput] Failed to start realtime voice", error);
+ const message = extractErrorMessage(error);
+ if (message && message.trim().length > 0) {
+ ctx.toast.error(message);
+ }
+ });
}
interface StartDictationContext {
@@ -959,12 +961,11 @@ function queueMessageImpl(ctx: QueueMessageContext): void {
}
function computeIsRealtimeVoiceForAgent(
- voice: { isVoiceModeForAgent: (serverId: string, agentId: string) => boolean } | null | undefined,
+ voice: { isCallActiveOnServer: (serverId: string) => boolean } | null | undefined,
voiceServerId: string | undefined,
- voiceAgentId: string | undefined,
): boolean {
- if (!voice || !voiceServerId || !voiceAgentId) return false;
- return voice.isVoiceModeForAgent(voiceServerId, voiceAgentId);
+ if (!voice || !voiceServerId) return false;
+ return voice.isCallActiveOnServer(voiceServerId);
}
function computeShouldShowDictationOverlay(
@@ -1068,6 +1069,7 @@ interface ResolvedMessageInputProps {
rightContent: React.ReactNode;
activeActionContent: React.ReactNode;
voiceServerId: string | undefined;
+ voiceWorkspaceId: string | undefined;
voiceAgentId: string | undefined;
isAgentRunning: boolean;
defaultSendBehavior: "interrupt" | "steer" | "queue";
@@ -1115,6 +1117,7 @@ function resolveMessageInputProps(props: MessageInputProps): ResolvedMessageInpu
rightContent: props.rightContent,
activeActionContent: props.activeActionContent,
voiceServerId: props.voiceServerId,
+ voiceWorkspaceId: props.voiceWorkspaceId,
voiceAgentId: props.voiceAgentId,
isAgentRunning: props.isAgentRunning ?? false,
defaultSendBehavior: props.defaultSendBehavior,
@@ -1170,6 +1173,7 @@ export const MessageInput = forwardRef(
rightContent,
activeActionContent,
voiceServerId,
+ voiceWorkspaceId,
voiceAgentId,
isAgentRunning,
defaultSendBehavior,
@@ -1375,11 +1379,7 @@ export const MessageInput = forwardRef(
enableDuration: true,
});
- const isRealtimeVoiceForCurrentAgent = computeIsRealtimeVoiceForAgent(
- voice,
- voiceServerId,
- voiceAgentId,
- );
+ const isRealtimeVoiceForCurrentAgent = computeIsRealtimeVoiceForAgent(voice, voiceServerId);
const showDictationOverlay = computeShouldShowDictationOverlay(
isDictating,
isDictationProcessing,
@@ -1452,9 +1452,6 @@ export const MessageInput = forwardRef(
await stopRealtimeVoice({
voice,
isRealtimeVoiceForCurrentAgent,
- isAgentRunning,
- client,
- voiceAgentId,
});
} catch (error) {
console.error("[MessageInput] Failed to stop realtime voice", error);
@@ -1463,30 +1460,28 @@ export const MessageInput = forwardRef(
toast.error(message);
}
}
- }, [client, isAgentRunning, isRealtimeVoiceForCurrentAgent, toast, voice, voiceAgentId]);
+ }, [isRealtimeVoiceForCurrentAgent, toast, voice]);
const handleToggleRealtimeVoiceShortcut = useCallback(() => {
toggleRealtimeVoiceImpl({
voice,
voiceServerId,
+ voiceWorkspaceId,
voiceAgentId,
isConnected,
disabled,
- isAgentRunning,
handleStopRealtimeVoice,
toast,
- interruptBeforeVoiceMessage: t("composer.voice.interruptBeforeVoice"),
});
}, [
disabled,
handleStopRealtimeVoice,
- isAgentRunning,
isConnected,
- t,
toast,
voice,
voiceAgentId,
voiceServerId,
+ voiceWorkspaceId,
]);
const minimizeInputHeight = useCallback(() => {
diff --git a/packages/app/src/composer/input/state.test.ts b/packages/app/src/composer/input/state.test.ts
index 5503ea1512..5a8a000db4 100644
--- a/packages/app/src/composer/input/state.test.ts
+++ b/packages/app/src/composer/input/state.test.ts
@@ -276,43 +276,14 @@ describe("composer send behavior", () => {
});
describe("stopRealtimeVoice", () => {
- it("keeps voice mode active when the running agent refuses cancellation", async () => {
- const cancellationError = new Error("active run cancellation was not acknowledged");
- const cancelAgent = vi.fn().mockRejectedValue(cancellationError);
+ it("stops the app-level call without touching the visible agent", async () => {
const stopVoice = vi.fn().mockResolvedValue(undefined);
- await expect(
- stopRealtimeVoice({
- voice: { stopVoice },
- isRealtimeVoiceForCurrentAgent: true,
- isAgentRunning: true,
- client: { cancelAgent },
- voiceAgentId: "agent-1",
- }),
- ).rejects.toBe(cancellationError);
-
- expect(stopVoice).not.toHaveBeenCalled();
- });
-
- it("stops voice mode after the running agent acknowledges cancellation", async () => {
- const calls: string[] = [];
-
await stopRealtimeVoice({
- voice: {
- stopVoice: async () => {
- calls.push("stop voice");
- },
- },
+ voice: { stopVoice },
isRealtimeVoiceForCurrentAgent: true,
- isAgentRunning: true,
- client: {
- cancelAgent: async () => {
- calls.push("cancel agent");
- },
- },
- voiceAgentId: "agent-1",
});
- expect(calls).toEqual(["cancel agent", "stop voice"]);
+ expect(stopVoice).toHaveBeenCalledOnce();
});
});
diff --git a/packages/app/src/composer/input/state.ts b/packages/app/src/composer/input/state.ts
index a63aeb02dc..d5cb18d1ba 100644
--- a/packages/app/src/composer/input/state.ts
+++ b/packages/app/src/composer/input/state.ts
@@ -41,9 +41,6 @@ export function resolveComposerSurfacePresentation(
interface StopRealtimeVoiceContext {
voice: { stopVoice: () => Promise } | null | undefined;
isRealtimeVoiceForCurrentAgent: boolean;
- isAgentRunning: boolean;
- client: { cancelAgent: (agentId: string) => Promise } | null;
- voiceAgentId: string | undefined;
}
interface SendActionContext {
@@ -183,12 +180,5 @@ export function runMessageInputKeyboardAction(
export async function stopRealtimeVoice(ctx: StopRealtimeVoiceContext): Promise {
if (!ctx.voice || !ctx.isRealtimeVoiceForCurrentAgent) return;
- if (ctx.isAgentRunning) {
- if (!ctx.client || !ctx.voiceAgentId) {
- throw new Error("Cannot stop the running voice agent while the host is unavailable");
- }
- await ctx.client.cancelAgent(ctx.voiceAgentId);
- }
-
await ctx.voice.stopVoice();
}
diff --git a/packages/app/src/contexts/session-context.tsx b/packages/app/src/contexts/session-context.tsx
index 475eb26f27..275742f3aa 100644
--- a/packages/app/src/contexts/session-context.tsx
+++ b/packages/app/src/contexts/session-context.tsx
@@ -1,5 +1,4 @@
import { useRef, ReactNode, useCallback, useEffect } from "react";
-import { Buffer } from "buffer";
import { AppState } from "react-native";
import { useQueryClient } from "@tanstack/react-query";
import { useTranslation } from "react-i18next";
@@ -34,8 +33,7 @@ import type { AgentSessionConfig } from "@getpaseo/protocol/agent-types";
import type { GitSetupOptions } from "@getpaseo/protocol/messages";
import type { AgentPermissionResponse } from "@getpaseo/protocol/agent-types";
import { getHostRuntimeStore, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
-import { useVoiceAudioEngineOptional, useVoiceRuntimeOptional } from "@/contexts/voice-context";
-import type { AudioPlaybackSource } from "@/voice/audio-engine-types";
+import { useVoiceRuntimeOptional } from "@/contexts/voice-context";
import {
selectAgentTimelineState,
useSessionStore,
@@ -72,50 +70,11 @@ export type {
AgentFileExplorerState,
} from "@/stores/session-store";
-type AudioOutputPayload = Extract["payload"];
-
-interface BufferedAudioChunk {
- chunkIndex: number;
- audio: string;
- format: string;
- id: string;
-}
-
// COMPAT(selectiveAgentTimeline): added in v0.1.106, remove after 2027-01-12.
function getTimelineDeliveryMode(selectiveAgentTimeline?: boolean): TimelineDeliveryMode {
return selectiveAgentTimeline ? "selective" : "legacy";
}
-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";
- let mimeType: string;
- if (format === "pcm") mimeType = "audio/pcm;rate=24000;bits=16";
- else if (format === "mp3") mimeType = "audio/mpeg";
- else mimeType = `audio/${format}`;
-
- const bytes = output.slice();
- return {
- size: bytes.byteLength,
- type: mimeType,
- async arrayBuffer() {
- return bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength);
- },
- };
-}
-
const findLatestAssistantMessageText = (items: StreamItem[]): string | null => {
for (let i = items.length - 1; i >= 0; i -= 1) {
const item = items[i];
@@ -186,15 +145,6 @@ type WorkspaceSetupProgressPayload = Extract<
{ type: "workspace_setup_progress" }
>["payload"];
-function notifyVoiceAbortFailure(
- data: Extract["payload"],
- notifyError: (message: string) => void,
-): void {
- if (data.type === "error" && data.metadata?.voiceAbortFailed === true) {
- notifyError(data.content);
- }
-}
-
interface SessionProviderSharedProps {
children: ReactNode;
serverId: string;
@@ -222,7 +172,6 @@ export function SessionProvider(props: SessionProviderProps) {
function SessionProviderInternal({ children, serverId, client }: SessionProviderClientProps) {
const { t } = useTranslation();
const voiceRuntime = useVoiceRuntimeOptional();
- const voiceAudioEngine = useVoiceAudioEngineOptional();
const queryClient = useQueryClient();
const isConnected = useHostRuntimeIsConnected(serverId);
const toast = useToast();
@@ -255,8 +204,6 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
const appStateRef = useRef(AppState.currentState);
const forcedTimelineTailReplacements = useRef(new Set());
const viewedTimelineSyncRef = useRef(null);
- const audioOutputBuffersRef = useRef