Merge origin/main into daemon-server-id-host-selection

This commit is contained in:
Mohamed Boudra
2026-02-04 15:19:06 +07:00
44 changed files with 1664 additions and 569 deletions
@@ -7,11 +7,10 @@ import {
} from "react-native";
import { useState, useEffect, useRef, useCallback, useMemo } from "react";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { ArrowUp, AudioLines, MicOff, Square, Pencil } from "lucide-react-native";
import { ArrowUp, Square, Pencil } from "lucide-react-native";
import Animated, { useAnimatedStyle } from "react-native-reanimated";
import { useReanimatedKeyboardAnimation } from "react-native-keyboard-controller";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { useVoice } from "@/contexts/voice-context";
import { useIsFocused } from "@react-navigation/native";
import { FOOTER_HEIGHT, MAX_CONTENT_WIDTH } from "@/constants/layout";
import { generateMessageId } from "@/types/stream";
@@ -94,8 +93,6 @@ export function AgentInputArea({
const setQueuedMessages = useSessionStore((state) => state.setQueuedMessages);
const setAgentStreamTail = useSessionStore((state) => state.setAgentStreamTail);
const { isVoiceMode, isMuted: isVoiceMuted, toggleMute: toggleVoiceMute } = useVoice();
const [internalInput, setInternalInput] = useState("");
const userInput = value ?? internalInput;
const setUserInput = onChangeText ?? setInternalInput;
@@ -573,35 +570,6 @@ export function AgentInputArea({
/>
)}
{/* Voice quick mute indicator */}
{isVoiceMode && (
<View style={styles.voiceIndicatorRow}>
<Pressable
onPress={toggleVoiceMute}
accessibilityRole="button"
accessibilityLabel={isVoiceMuted ? "Unmute voice" : "Mute voice"}
style={[
styles.voiceIndicatorPill,
isVoiceMuted && styles.voiceIndicatorPillMuted,
]}
>
{isVoiceMuted ? (
<MicOff size={14} color={theme.colors.surface0} />
) : (
<AudioLines size={14} color={theme.colors.foreground} />
)}
<Text
style={[
styles.voiceIndicatorText,
isVoiceMuted && styles.voiceIndicatorTextMuted,
]}
>
{isVoiceMuted ? "Voice muted" : "Voice on"}
</Text>
</Pressable>
</View>
)}
{/* MessageInput handles everything: text, dictation, attachments, all buttons */}
<MessageInput
ref={messageInputRef}
@@ -666,33 +634,6 @@ const styles = StyleSheet.create(((theme: Theme) => ({
buttonDisabled: {
opacity: 0.5,
},
voiceIndicatorRow: {
flexDirection: "row",
justifyContent: "flex-end",
},
voiceIndicatorPill: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
paddingHorizontal: theme.spacing[3],
height: 32,
borderRadius: theme.borderRadius.full,
backgroundColor: theme.colors.surface2,
borderWidth: theme.borderWidth[1],
borderColor: theme.colors.border,
},
voiceIndicatorPillMuted: {
backgroundColor: theme.colors.palette.red[600],
borderColor: theme.colors.palette.red[800],
},
voiceIndicatorText: {
color: theme.colors.foreground,
fontSize: theme.fontSize.xs,
fontWeight: theme.fontWeight.medium,
},
voiceIndicatorTextMuted: {
color: theme.colors.surface0,
},
queueContainer: {
flexDirection: "column",
gap: theme.spacing[2],
@@ -52,6 +52,8 @@ import { ToolCallSheetProvider } from "./tool-call-sheet";
import { createMarkdownStyles } from "@/styles/markdown-styles";
import { MAX_CONTENT_WIDTH } from "@/constants/layout";
import { isPerfLoggingEnabled, measurePayload, perfLog } from "@/utils/perf";
import { VoiceCompactIndicator } from "./voice-compact-indicator";
import { useVoice } from "@/contexts/voice-context";
const isUserMessageItem = (item?: StreamItem) => item?.kind === "user_message";
const isToolSequenceItem = (item?: StreamItem) =>
@@ -59,6 +61,23 @@ const isToolSequenceItem = (item?: StreamItem) =>
const AGENT_STREAM_LOG_TAG = "[AgentStreamView]";
const STREAM_ITEM_LOG_MIN_COUNT = 200;
const STREAM_ITEM_LOG_DELTA_THRESHOLD = 50;
function collectAssistantTurnContent(
flatListData: StreamItem[],
startIndex: number
): string {
const messages: string[] = [];
for (let i = startIndex; i < flatListData.length; i++) {
const currentItem = flatListData[i];
if (currentItem.kind === "user_message") {
break;
}
if (currentItem.kind === "assistant_message") {
messages.push(currentItem.text);
}
}
return messages.reverse().join("\n\n");
}
export interface AgentStreamViewProps {
agentId: string;
serverId?: string;
@@ -77,6 +96,7 @@ export function AgentStreamView({
const flatListRef = useRef<FlatList<StreamItem>>(null);
const { theme } = useUnistyles();
const insets = useSafeAreaInsets();
const { isVoiceMode } = useVoice();
const [isNearBottom, setIsNearBottom] = useState(true);
const hasScrolledInitially = useRef(false);
const hasAutoScrolledOnce = useRef(false);
@@ -362,26 +382,6 @@ export function AgentStreamView({
[handleInlinePathPress, agent.cwd, flatListData]
);
const collectTurnContent = useCallback(
(index: number) => {
const messages: string[] = [];
// Walk backwards (older items) from current index
// In inverted list: index+1 is the item above (older in time)
for (let i = index; i < flatListData.length; i++) {
const currentItem = flatListData[i];
if (currentItem.kind === "user_message") {
break;
}
if (currentItem.kind === "assistant_message") {
messages.push(currentItem.text);
}
}
// Messages are collected newest-first, reverse for chronological order
return messages.reverse().join("\n\n");
},
[flatListData]
);
const renderStreamItem = useCallback(
({ item, index }: ListRenderItemInfo<StreamItem>) => {
const content = renderStreamItemContent(item, index);
@@ -391,30 +391,13 @@ export function AgentStreamView({
const gapBelow = getGapBelow(item, index);
// Check if this is the end of a turn (before a user message or end of stream when not running)
// In inverted list: index-1 is the next item (newer in time)
const nextItem = flatListData[index - 1];
const isEndOfTurn =
item.kind !== "user_message" &&
(nextItem?.kind === "user_message" ||
(nextItem === undefined && agent.status !== "running"));
const getContent = () => collectTurnContent(index);
return (
<View style={[stylesheet.streamItemWrapper, { marginBottom: gapBelow }]}>
{content}
{isEndOfTurn ? <TurnCopyButton getContent={getContent} /> : null}
</View>
);
},
[
getGapBelow,
renderStreamItemContent,
flatListData,
agent.status,
collectTurnContent,
]
[getGapBelow, renderStreamItemContent]
);
const pendingPermissionItems = useMemo(
@@ -494,15 +477,33 @@ export function AgentStreamView({
}, [agentId, pendingPermissionItems.length, streamHead, streamItems]);
const showWorkingIndicator = agent.status === "running";
const newestItem = flatListData[0] ?? null;
const latestTurnContent = useMemo(() => {
if (flatListData.length === 0) {
return "";
}
return collectAssistantTurnContent(flatListData, 0);
}, [flatListData]);
const showCopyButton =
agent.status !== "running" &&
newestItem?.kind === "assistant_message" &&
latestTurnContent.trim().length > 0;
const showBottomBar = showWorkingIndicator || showCopyButton || isVoiceMode;
const listHeaderComponent = useMemo(() => {
const hasPermissions = pendingPermissionItems.length > 0;
const hasHeadItems = streamHead && streamHead.length > 0;
if (!hasPermissions && !showWorkingIndicator && !hasHeadItems) {
if (!hasPermissions && !showBottomBar && !hasHeadItems) {
return null;
}
const leftContent = showWorkingIndicator ? (
<WorkingIndicator />
) : showCopyButton ? (
<TurnCopyButton getContent={() => latestTurnContent} />
) : null;
return (
<View style={stylesheet.contentWrapper}>
<View
@@ -538,9 +539,12 @@ export function AgentStreamView({
})
: null}
{showWorkingIndicator ? (
<View style={stylesheet.workingIndicatorWrapper}>
<WorkingIndicator />
{showBottomBar ? (
<View style={stylesheet.bottomBarWrapper}>
<View style={stylesheet.bottomBarLeft}>{leftContent}</View>
<View style={stylesheet.bottomBarRight}>
{isVoiceMode ? <VoiceCompactIndicator /> : null}
</View>
</View>
) : null}
</View>
@@ -553,14 +557,27 @@ export function AgentStreamView({
streamHead,
renderStreamItemContent,
tightGap,
showBottomBar,
showCopyButton,
latestTurnContent,
isVoiceMode,
]);
const flatListExtraData = useMemo(
() => ({
pendingPermissionCount: pendingPermissionItems.length,
showWorkingIndicator,
showBottomBar,
showCopyButton,
isVoiceMode,
}),
[pendingPermissionItems.length, showWorkingIndicator]
[
pendingPermissionItems.length,
showWorkingIndicator,
showBottomBar,
showCopyButton,
isVoiceMode,
]
);
// FlatList's ListHeaderComponent renders at the *bottom* when inverted.
@@ -1178,11 +1195,23 @@ const stylesheet = StyleSheet.create((theme) => ({
listHeaderContent: {
gap: theme.spacing[3],
},
workingIndicatorWrapper: {
alignItems: "flex-start",
bottomBarWrapper: {
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
paddingLeft: 3,
paddingRight: 3,
paddingTop: theme.spacing[3],
paddingBottom: theme.spacing[2],
gap: theme.spacing[2],
},
bottomBarLeft: {
flex: 1,
alignItems: "flex-start",
},
bottomBarRight: {
flexShrink: 0,
alignItems: "flex-end",
},
workingIndicatorBubble: {
flexDirection: "row",
@@ -179,6 +179,7 @@ export function DictationOverlay({
isDetecting
isSpeaking={false}
orientation="horizontal"
color={theme.colors.palette.white}
/>
<Text
style={[
-1
View File
@@ -261,7 +261,6 @@ const turnCopyButtonStylesheet = StyleSheet.create((theme) => ({
container: {
alignSelf: "flex-start",
padding: theme.spacing[2],
marginLeft: theme.spacing[4],
},
iconColor: {
color: theme.colors.foregroundMuted,
@@ -4,12 +4,13 @@ import {
type HostProfile,
} from "@/contexts/daemon-registry-context";
import { useDaemonConnections } from "@/contexts/daemon-connections-context";
import { buildDaemonWebSocketUrl, buildRelayWebSocketUrl } from "@/utils/daemon-endpoints";
import { buildDaemonWebSocketUrl, buildRelayWebSocketUrl, extractHostPortFromWebSocketUrl } from "@/utils/daemon-endpoints";
import { useEffect, useMemo, useRef, useState } from "react";
function buildCandidateUrls(daemon: HostProfile): string[] {
const endpoints = daemon.endpoints ?? [];
const sessionId = daemon.relay?.sessionId ?? null;
const relayEndpoint = daemon.relay?.endpoint ?? null;
const lastKnownGood =
typeof daemon.metadata?.lastKnownGoodEndpoint === "string"
? (daemon.metadata.lastKnownGoodEndpoint as string)
@@ -20,38 +21,26 @@ function buildCandidateUrls(daemon: HostProfile): string[] {
if (!out.includes(url)) out.push(url);
};
if (lastKnownGood) {
const isLastKnownRelay = !!relayEndpoint && lastKnownGood === relayEndpoint;
const directEndpoints = endpoints;
if (sessionId && relayEndpoint && isLastKnownRelay) {
push(buildRelayWebSocketUrl({ endpoint: relayEndpoint, sessionId }));
} else if (lastKnownGood) {
push(buildDaemonWebSocketUrl(lastKnownGood));
}
for (const endpoint of endpoints) {
for (const endpoint of directEndpoints) {
push(buildDaemonWebSocketUrl(endpoint));
}
if (sessionId) {
if (lastKnownGood) {
push(buildRelayWebSocketUrl({ endpoint: lastKnownGood, sessionId }));
}
for (const endpoint of endpoints) {
push(buildRelayWebSocketUrl({ endpoint, sessionId }));
}
if (sessionId && relayEndpoint) {
push(buildRelayWebSocketUrl({ endpoint: relayEndpoint, sessionId }));
}
return out;
}
function extractEndpointFromUrl(url: string): string | null {
try {
const parsed = new URL(url);
const host = parsed.hostname;
const port = parsed.port ? Number(parsed.port) : parsed.protocol === "wss:" ? 443 : 80;
const isIpv6 = host.includes(":") && !host.startsWith("[") && !host.endsWith("]");
return isIpv6 ? `[${host}]:${port}` : `${host}:${port}`;
} catch {
return null;
}
}
function ManagedDaemonSession({ daemon }: { daemon: HostProfile }) {
const { connectionStates } = useDaemonConnections();
const { updateDaemon } = useDaemonRegistry();
@@ -86,9 +75,15 @@ function ManagedDaemonSession({ daemon }: { daemon: HostProfile }) {
useEffect(() => {
if (!connection) return;
if (status === "online") {
if (pendingMetadataWriteRef.current) return;
const endpoint = extractEndpointFromUrl(activeUrl);
if (status === "online") {
if (pendingMetadataWriteRef.current) return;
const endpoint = (() => {
try {
return extractHostPortFromWebSocketUrl(activeUrl);
} catch {
return null;
}
})();
if (!endpoint) return;
if (daemon.metadata?.lastKnownGoodEndpoint === endpoint) return;
@@ -123,7 +118,12 @@ function ManagedDaemonSession({ daemon }: { daemon: HostProfile }) {
]);
return (
<SessionProvider key={`${daemon.id}:${activeUrl}`} serverUrl={activeUrl} serverId={daemon.id}>
<SessionProvider
key={`${daemon.id}:${activeUrl}`}
serverUrl={activeUrl}
serverId={daemon.id}
daemonPublicKeyB64={daemon.daemonPublicKeyB64}
>
{null}
</SessionProvider>
);
+52 -49
View File
@@ -1,5 +1,5 @@
import { useCallback, useMemo, useState, useEffect } from "react";
import { View, Pressable, Text, Platform, Modal, Alert } from "react-native";
import { View, Pressable, Text, Platform, Alert } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import Animated, {
useAnimatedStyle,
@@ -23,6 +23,7 @@ import { useSidebarAgentSections } from "@/hooks/use-sidebar-agent-sections";
import { useSidebarCollapsedSectionsStore } from "@/stores/sidebar-collapsed-sections-store";
import { useKeyboardNavStore } from "@/stores/keyboard-nav-store";
import { deriveSidebarShortcutAgentKeys } from "@/utils/sidebar-shortcuts";
import { AdaptiveModalSheet } from "@/components/adaptive-modal-sheet";
const DESKTOP_SIDEBAR_WIDTH = 320;
const SIDEBAR_AGENT_LIMIT = 15;
@@ -351,31 +352,31 @@ export function SlidingSidebar({ selectedAgentId }: SlidingSidebarProps) {
</Animated.View>
</GestureDetector>
<Modal
<AdaptiveModalSheet
title="Choose a host"
visible={showVoiceHostPicker}
transparent
animationType="fade"
onRequestClose={handleDismissVoiceHostPicker}
onClose={handleDismissVoiceHostPicker}
snapPoints={["45%", "70%"]}
testID="voice-host-picker"
>
<View style={styles.hostPickerOverlay}>
<Pressable style={styles.hostPickerBackdrop} onPress={handleDismissVoiceHostPicker} />
<View style={styles.hostPickerContainer}>
<Text style={styles.hostPickerTitle}>Choose a host</Text>
{voiceEligibleHosts.map((entry) => (
<Pressable
key={entry.daemon.id}
style={styles.hostPickerButton}
onPress={() => handleSelectVoiceHost(entry.daemon.id)}
>
<Text style={styles.hostPickerButtonText}>{entry.daemon.label}</Text>
</Pressable>
))}
<Pressable style={styles.hostPickerCancel} onPress={handleDismissVoiceHostPicker}>
<Text style={styles.hostPickerCancelText}>Cancel</Text>
<View style={styles.hostPickerList}>
{voiceEligibleHosts.map((entry) => (
<Pressable
key={entry.daemon.id}
style={styles.hostPickerOption}
onPress={() => handleSelectVoiceHost(entry.daemon.id)}
>
<Text style={styles.hostPickerOptionText}>{entry.daemon.label}</Text>
</Pressable>
</View>
))}
<Pressable
style={styles.hostPickerCancel}
onPress={handleDismissVoiceHostPicker}
>
<Text style={styles.hostPickerCancelText}>Cancel</Text>
</Pressable>
</View>
</Modal>
</AdaptiveModalSheet>
</View>
);
}
@@ -460,6 +461,32 @@ export function SlidingSidebar({ selectedAgentId }: SlidingSidebarProps) {
</Pressable>
</View>
</View>
<AdaptiveModalSheet
title="Choose a host"
visible={showVoiceHostPicker}
onClose={handleDismissVoiceHostPicker}
snapPoints={["45%", "70%"]}
testID="voice-host-picker"
>
<View style={styles.hostPickerList}>
{voiceEligibleHosts.map((entry) => (
<Pressable
key={entry.daemon.id}
style={styles.hostPickerOption}
onPress={() => handleSelectVoiceHost(entry.daemon.id)}
>
<Text style={styles.hostPickerOptionText}>{entry.daemon.label}</Text>
</Pressable>
))}
<Pressable
style={styles.hostPickerCancel}
onPress={handleDismissVoiceHostPicker}
>
<Text style={styles.hostPickerCancelText}>Cancel</Text>
</Pressable>
</View>
</AdaptiveModalSheet>
</View>
);
}
@@ -547,33 +574,10 @@ const styles = StyleSheet.create((theme) => ({
footerButtonTextHovered: {
color: theme.colors.foreground,
},
hostPickerOverlay: {
flex: 1,
justifyContent: "center",
alignItems: "center",
backgroundColor: "rgba(0, 0, 0, 0.5)",
padding: theme.spacing[4],
},
hostPickerBackdrop: {
...StyleSheet.absoluteFillObject,
},
hostPickerContainer: {
width: "100%",
maxWidth: 360,
backgroundColor: theme.colors.surface0,
borderRadius: theme.borderRadius["2xl"],
padding: theme.spacing[4],
borderWidth: theme.borderWidth[1],
borderColor: theme.colors.border,
hostPickerList: {
gap: theme.spacing[2],
},
hostPickerTitle: {
fontSize: theme.fontSize.base,
fontWeight: theme.fontWeight.semibold,
color: theme.colors.foreground,
marginBottom: theme.spacing[2],
},
hostPickerButton: {
hostPickerOption: {
paddingVertical: theme.spacing[3],
paddingHorizontal: theme.spacing[3],
borderRadius: theme.borderRadius.lg,
@@ -581,12 +585,11 @@ const styles = StyleSheet.create((theme) => ({
borderWidth: theme.borderWidth[1],
borderColor: theme.colors.border,
},
hostPickerButtonText: {
hostPickerOptionText: {
color: theme.colors.foreground,
fontSize: theme.fontSize.sm,
},
hostPickerCancel: {
marginTop: theme.spacing[2],
paddingVertical: theme.spacing[3],
paddingHorizontal: theme.spacing[3],
borderRadius: theme.borderRadius.lg,
@@ -208,7 +208,7 @@ export function DropdownMenuTrigger({
collapsable={false}
disabled={disabled}
onPress={handlePress}
style={({ pressed, hovered }) => {
style={({ pressed, hovered = false }) => {
if (typeof style === "function") {
return style({ pressed, hovered: Boolean(hovered), open: ctx.open });
}
@@ -0,0 +1,75 @@
import { Pressable, View } from "react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { Mic, MicOff } from "lucide-react-native";
import { VolumeMeter } from "@/components/volume-meter";
import { useVoice } from "@/contexts/voice-context";
export function VoiceCompactIndicator() {
const { theme } = useUnistyles();
const { isVoiceMode, volume, isMuted, isDetecting, isSpeaking, toggleMute } =
useVoice();
if (!isVoiceMode) {
return null;
}
return (
<View style={[styles.container, isMuted && styles.containerMuted]}>
<View style={styles.meterContainer}>
<VolumeMeter
volume={volume}
isMuted={isMuted}
isDetecting={isDetecting}
isSpeaking={isSpeaking}
orientation="horizontal"
variant="compact"
/>
</View>
<Pressable
onPress={toggleMute}
accessibilityRole="button"
accessibilityLabel={isMuted ? "Unmute voice" : "Mute voice"}
style={styles.muteButton}
hitSlop={8}
>
{isMuted ? (
<MicOff size={14} color={theme.colors.palette.white} />
) : (
<Mic size={14} color={theme.colors.foreground} />
)}
</Pressable>
</View>
);
}
const styles = StyleSheet.create((theme) => ({
container: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
paddingLeft: theme.spacing[3],
paddingRight: theme.spacing[1],
height: 32,
borderRadius: theme.borderRadius.full,
backgroundColor: theme.colors.surface2,
borderWidth: theme.borderWidth[1],
borderColor: theme.colors.border,
},
containerMuted: {
backgroundColor: theme.colors.palette.red[600],
borderWidth: 0,
},
meterContainer: {
justifyContent: "center",
},
muteButton: {
width: 28,
height: 28,
borderRadius: theme.borderRadius.full,
alignItems: "center",
justifyContent: "center",
backgroundColor: "transparent",
borderWidth: 0,
},
}));
+44 -67
View File
@@ -1,6 +1,6 @@
import { View, Text, Pressable } from "react-native";
import { View, Pressable } from "react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { MicOff, Square, AudioLines } from "lucide-react-native";
import { MicOff, Square } from "lucide-react-native";
import { VolumeMeter } from "./volume-meter";
import { useVoice } from "@/contexts/voice-context";
import { useDaemonConnections } from "@/contexts/daemon-connections-context";
@@ -20,55 +20,47 @@ export function VoicePanel() {
const activeHost = activeServerId ? connectionStates.get(activeServerId) ?? null : null;
const hostLabel = activeHost?.daemon.label ?? null;
const hostStatus = activeHost?.status ?? null;
const hostSuffix = hostLabel ? ` (${hostLabel})` : "";
return (
<View style={styles.container}>
<View style={styles.headerRow}>
<View style={styles.titleRow}>
<AudioLines size={16} color={theme.colors.foregroundMuted} />
<Text style={styles.titleText}>Voice</Text>
</View>
<Text style={styles.hostText} numberOfLines={1}>
{hostLabel ? `Host: ${hostLabel}` : "Host: unknown"}
{hostStatus ? ` (${hostStatus})` : ""}
</Text>
</View>
<View style={styles.meterRow}>
<VolumeMeter
volume={volume}
isMuted={isMuted}
isDetecting={isDetecting}
isSpeaking={isSpeaking}
orientation="horizontal"
/>
</View>
<View style={styles.actionsRow}>
<Pressable
onPress={toggleMute}
accessibilityRole="button"
accessibilityLabel={isMuted ? "Unmute voice" : "Mute voice"}
style={[
styles.iconButton,
isMuted && styles.iconButtonMuted,
]}
>
<MicOff
size={18}
color={isMuted ? theme.colors.surface0 : theme.colors.foreground}
<View style={styles.contentRow}>
<View style={styles.meterContainer}>
<VolumeMeter
volume={volume}
isMuted={isMuted}
isDetecting={isDetecting}
isSpeaking={isSpeaking}
orientation="horizontal"
variant="compact"
/>
</Pressable>
</View>
<Pressable
onPress={() => void stopVoice()}
accessibilityRole="button"
accessibilityLabel="Stop voice mode"
style={[styles.iconButton, styles.iconButtonStop]}
>
<Square size={16} color="white" fill="white" />
</Pressable>
<View style={styles.actionsRow}>
<Pressable
onPress={toggleMute}
accessibilityRole="button"
accessibilityLabel={`${isMuted ? "Unmute voice" : "Mute voice"}${hostSuffix}`}
style={[
styles.iconButton,
isMuted && styles.iconButtonMuted,
]}
>
<MicOff
size={18}
color={isMuted ? theme.colors.palette.white : theme.colors.foreground}
/>
</Pressable>
<Pressable
onPress={() => void stopVoice()}
accessibilityRole="button"
accessibilityLabel={`Stop voice mode${hostSuffix}`}
style={[styles.iconButton, styles.iconButtonStop]}
>
<Square size={16} color="white" fill="white" />
</Pressable>
</View>
</View>
</View>
);
@@ -82,35 +74,20 @@ const styles = StyleSheet.create((theme) => ({
borderWidth: theme.borderWidth[1],
borderColor: theme.colors.border,
backgroundColor: theme.colors.surface2,
paddingVertical: theme.spacing[3],
paddingVertical: theme.spacing[2],
paddingHorizontal: theme.spacing[3],
gap: theme.spacing[3],
},
headerRow: {
contentRow: {
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
gap: theme.spacing[3],
},
titleRow: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
flexShrink: 0,
},
titleText: {
color: theme.colors.foreground,
fontSize: theme.fontSize.sm,
fontWeight: theme.fontWeight.semibold,
},
hostText: {
meterContainer: {
flex: 1,
textAlign: "right",
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.xs,
},
meterRow: {
justifyContent: "center",
alignItems: "flex-start",
paddingLeft: theme.spacing[1],
},
actionsRow: {
flexDirection: "row",
@@ -130,7 +107,7 @@ const styles = StyleSheet.create((theme) => ({
},
iconButtonMuted: {
backgroundColor: theme.colors.palette.red[500],
borderColor: theme.colors.palette.red[600],
borderWidth: 0,
},
iconButtonStop: {
backgroundColor: theme.colors.palette.red[600],
+22 -8
View File
@@ -1,4 +1,4 @@
import { useEffect, useState, useRef } from "react";
import { useEffect } from "react";
import { View } from "react-native";
import ReanimatedAnimated, {
useSharedValue,
@@ -17,16 +17,29 @@ interface VolumeMeterProps {
isDetecting?: boolean;
isSpeaking?: boolean;
orientation?: "vertical" | "horizontal";
variant?: "default" | "compact";
color?: string;
}
export function VolumeMeter({ volume, isMuted = false, isDetecting = false, isSpeaking = false, orientation = "vertical" }: VolumeMeterProps) {
export function VolumeMeter({
volume,
isMuted = false,
isDetecting = false,
isSpeaking = false,
orientation = "vertical",
variant = "default",
color,
}: VolumeMeterProps) {
const { theme } = useUnistyles();
const isCompact = variant === "compact";
// Base dimensions
const LINE_SPACING = 8;
const LINE_WIDTH = 8;
const MAX_HEIGHT = orientation === "horizontal" ? 30 : 50;
const MIN_HEIGHT = orientation === "horizontal" ? 12 : 20;
const LINE_SPACING = isCompact ? 6 : 8;
const LINE_WIDTH = isCompact ? 6 : 8;
const MAX_HEIGHT =
orientation === "horizontal" ? (isCompact ? 18 : 30) : isCompact ? 32 : 50;
const MIN_HEIGHT =
orientation === "horizontal" ? (isCompact ? 8 : 12) : isCompact ? 14 : 20;
// Create shared values for 3 dots unconditionally
const line1Height = useSharedValue(MIN_HEIGHT);
@@ -127,8 +140,9 @@ export function VolumeMeter({ volume, isMuted = false, isDetecting = false, isSp
}
}, [volume, isMuted]);
const lineColor = "#FFFFFF";
const containerHeight = orientation === "horizontal" ? 60 : 100;
const lineColor = color ?? theme.colors.foreground;
const containerHeight =
orientation === "horizontal" ? (isCompact ? 32 : 60) : isCompact ? 64 : 100;
// Create animated styles unconditionally at top level
const line1Style = useAnimatedStyle(() => {