Merge branch 'real-time-voice-panel-layout'

This commit is contained in:
Mohamed Boudra
2026-02-04 14:51:41 +07:00
4 changed files with 78 additions and 90 deletions
@@ -53,6 +53,7 @@ import { createMarkdownStyles } from "@/styles/markdown-styles";
import { MAX_CONTENT_WIDTH } from "@/constants/layout"; import { MAX_CONTENT_WIDTH } from "@/constants/layout";
import { isPerfLoggingEnabled, measurePayload, perfLog } from "@/utils/perf"; import { isPerfLoggingEnabled, measurePayload, perfLog } from "@/utils/perf";
import { VoiceCompactIndicator } from "./voice-compact-indicator"; import { VoiceCompactIndicator } from "./voice-compact-indicator";
import { useVoice } from "@/contexts/voice-context";
const isUserMessageItem = (item?: StreamItem) => item?.kind === "user_message"; const isUserMessageItem = (item?: StreamItem) => item?.kind === "user_message";
const isToolSequenceItem = (item?: StreamItem) => const isToolSequenceItem = (item?: StreamItem) =>
@@ -60,6 +61,23 @@ const isToolSequenceItem = (item?: StreamItem) =>
const AGENT_STREAM_LOG_TAG = "[AgentStreamView]"; const AGENT_STREAM_LOG_TAG = "[AgentStreamView]";
const STREAM_ITEM_LOG_MIN_COUNT = 200; const STREAM_ITEM_LOG_MIN_COUNT = 200;
const STREAM_ITEM_LOG_DELTA_THRESHOLD = 50; 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 { export interface AgentStreamViewProps {
agentId: string; agentId: string;
serverId?: string; serverId?: string;
@@ -78,6 +96,7 @@ export function AgentStreamView({
const flatListRef = useRef<FlatList<StreamItem>>(null); const flatListRef = useRef<FlatList<StreamItem>>(null);
const { theme } = useUnistyles(); const { theme } = useUnistyles();
const insets = useSafeAreaInsets(); const insets = useSafeAreaInsets();
const { isVoiceMode } = useVoice();
const [isNearBottom, setIsNearBottom] = useState(true); const [isNearBottom, setIsNearBottom] = useState(true);
const hasScrolledInitially = useRef(false); const hasScrolledInitially = useRef(false);
const hasAutoScrolledOnce = useRef(false); const hasAutoScrolledOnce = useRef(false);
@@ -363,26 +382,6 @@ export function AgentStreamView({
[handleInlinePathPress, agent.cwd, flatListData] [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( const renderStreamItem = useCallback(
({ item, index }: ListRenderItemInfo<StreamItem>) => { ({ item, index }: ListRenderItemInfo<StreamItem>) => {
const content = renderStreamItemContent(item, index); const content = renderStreamItemContent(item, index);
@@ -392,30 +391,13 @@ export function AgentStreamView({
const gapBelow = getGapBelow(item, index); 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 ( return (
<View style={[stylesheet.streamItemWrapper, { marginBottom: gapBelow }]}> <View style={[stylesheet.streamItemWrapper, { marginBottom: gapBelow }]}>
{content} {content}
{isEndOfTurn ? <TurnCopyButton getContent={getContent} /> : null}
</View> </View>
); );
}, },
[ [getGapBelow, renderStreamItemContent]
getGapBelow,
renderStreamItemContent,
flatListData,
agent.status,
collectTurnContent,
]
); );
const pendingPermissionItems = useMemo( const pendingPermissionItems = useMemo(
@@ -495,15 +477,33 @@ export function AgentStreamView({
}, [agentId, pendingPermissionItems.length, streamHead, streamItems]); }, [agentId, pendingPermissionItems.length, streamHead, streamItems]);
const showWorkingIndicator = agent.status === "running"; 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 listHeaderComponent = useMemo(() => {
const hasPermissions = pendingPermissionItems.length > 0; const hasPermissions = pendingPermissionItems.length > 0;
const hasHeadItems = streamHead && streamHead.length > 0; const hasHeadItems = streamHead && streamHead.length > 0;
if (!hasPermissions && !showWorkingIndicator && !hasHeadItems) { if (!hasPermissions && !showBottomBar && !hasHeadItems) {
return null; return null;
} }
const leftContent = showWorkingIndicator ? (
<WorkingIndicator />
) : showCopyButton ? (
<TurnCopyButton getContent={() => latestTurnContent} />
) : null;
return ( return (
<View style={stylesheet.contentWrapper}> <View style={stylesheet.contentWrapper}>
<View <View
@@ -539,10 +539,12 @@ export function AgentStreamView({
}) })
: null} : null}
{showWorkingIndicator ? ( {showBottomBar ? (
<View style={stylesheet.workingIndicatorWrapper}> <View style={stylesheet.bottomBarWrapper}>
<WorkingIndicator /> <View style={stylesheet.bottomBarLeft}>{leftContent}</View>
<VoiceCompactIndicator /> <View style={stylesheet.bottomBarRight}>
{isVoiceMode ? <VoiceCompactIndicator /> : null}
</View>
</View> </View>
) : null} ) : null}
</View> </View>
@@ -555,14 +557,27 @@ export function AgentStreamView({
streamHead, streamHead,
renderStreamItemContent, renderStreamItemContent,
tightGap, tightGap,
showBottomBar,
showCopyButton,
latestTurnContent,
isVoiceMode,
]); ]);
const flatListExtraData = useMemo( const flatListExtraData = useMemo(
() => ({ () => ({
pendingPermissionCount: pendingPermissionItems.length, pendingPermissionCount: pendingPermissionItems.length,
showWorkingIndicator, showWorkingIndicator,
showBottomBar,
showCopyButton,
isVoiceMode,
}), }),
[pendingPermissionItems.length, showWorkingIndicator] [
pendingPermissionItems.length,
showWorkingIndicator,
showBottomBar,
showCopyButton,
isVoiceMode,
]
); );
// FlatList's ListHeaderComponent renders at the *bottom* when inverted. // FlatList's ListHeaderComponent renders at the *bottom* when inverted.
@@ -1180,7 +1195,7 @@ const stylesheet = StyleSheet.create((theme) => ({
listHeaderContent: { listHeaderContent: {
gap: theme.spacing[3], gap: theme.spacing[3],
}, },
workingIndicatorWrapper: { bottomBarWrapper: {
flexDirection: "row", flexDirection: "row",
alignItems: "center", alignItems: "center",
justifyContent: "space-between", justifyContent: "space-between",
@@ -1188,6 +1203,15 @@ const stylesheet = StyleSheet.create((theme) => ({
paddingRight: 3, paddingRight: 3,
paddingTop: theme.spacing[3], paddingTop: theme.spacing[3],
paddingBottom: theme.spacing[2], paddingBottom: theme.spacing[2],
gap: theme.spacing[2],
},
bottomBarLeft: {
flex: 1,
alignItems: "flex-start",
},
bottomBarRight: {
flexShrink: 0,
alignItems: "flex-end",
}, },
workingIndicatorBubble: { workingIndicatorBubble: {
flexDirection: "row", flexDirection: "row",
-1
View File
@@ -261,7 +261,6 @@ const turnCopyButtonStylesheet = StyleSheet.create((theme) => ({
container: { container: {
alignSelf: "flex-start", alignSelf: "flex-start",
padding: theme.spacing[2], padding: theme.spacing[2],
marginLeft: theme.spacing[4],
}, },
iconColor: { iconColor: {
color: theme.colors.foregroundMuted, color: theme.colors.foregroundMuted,
@@ -1,6 +1,6 @@
import { Pressable, View } from "react-native"; import { Pressable, View } from "react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { AudioLines, MicOff } from "lucide-react-native"; import { Mic, MicOff } from "lucide-react-native";
import { VolumeMeter } from "@/components/volume-meter"; import { VolumeMeter } from "@/components/volume-meter";
import { useVoice } from "@/contexts/voice-context"; import { useVoice } from "@/contexts/voice-context";
@@ -35,7 +35,7 @@ export function VoiceCompactIndicator() {
{isMuted ? ( {isMuted ? (
<MicOff size={14} color={theme.colors.surface0} /> <MicOff size={14} color={theme.colors.surface0} />
) : ( ) : (
<AudioLines size={14} color={theme.colors.foreground} /> <Mic size={14} color={theme.colors.foreground} />
)} )}
</Pressable> </Pressable>
</View> </View>
@@ -73,4 +73,3 @@ const styles = StyleSheet.create((theme) => ({
borderColor: theme.colors.palette.red[800], borderColor: theme.colors.palette.red[800],
}, },
})); }));
+7 -41
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 { 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 { VolumeMeter } from "./volume-meter";
import { useVoice } from "@/contexts/voice-context"; import { useVoice } from "@/contexts/voice-context";
import { useDaemonConnections } from "@/contexts/daemon-connections-context"; import { useDaemonConnections } from "@/contexts/daemon-connections-context";
@@ -20,21 +20,10 @@ export function VoicePanel() {
const activeHost = activeServerId ? connectionStates.get(activeServerId) ?? null : null; const activeHost = activeServerId ? connectionStates.get(activeServerId) ?? null : null;
const hostLabel = activeHost?.daemon.label ?? null; const hostLabel = activeHost?.daemon.label ?? null;
const hostStatus = activeHost?.status ?? null; const hostSuffix = hostLabel ? ` (${hostLabel})` : "";
return ( return (
<View style={styles.container}> <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.contentRow}> <View style={styles.contentRow}>
<View style={styles.meterContainer}> <View style={styles.meterContainer}>
<VolumeMeter <VolumeMeter
@@ -43,6 +32,7 @@ export function VoicePanel() {
isDetecting={isDetecting} isDetecting={isDetecting}
isSpeaking={isSpeaking} isSpeaking={isSpeaking}
orientation="horizontal" orientation="horizontal"
variant="compact"
/> />
</View> </View>
@@ -50,7 +40,7 @@ export function VoicePanel() {
<Pressable <Pressable
onPress={toggleMute} onPress={toggleMute}
accessibilityRole="button" accessibilityRole="button"
accessibilityLabel={isMuted ? "Unmute voice" : "Mute voice"} accessibilityLabel={`${isMuted ? "Unmute voice" : "Mute voice"}${hostSuffix}`}
style={[ style={[
styles.iconButton, styles.iconButton,
isMuted && styles.iconButtonMuted, isMuted && styles.iconButtonMuted,
@@ -65,7 +55,7 @@ export function VoicePanel() {
<Pressable <Pressable
onPress={() => void stopVoice()} onPress={() => void stopVoice()}
accessibilityRole="button" accessibilityRole="button"
accessibilityLabel="Stop voice mode" accessibilityLabel={`Stop voice mode${hostSuffix}`}
style={[styles.iconButton, styles.iconButtonStop]} style={[styles.iconButton, styles.iconButtonStop]}
> >
<Square size={16} color="white" fill="white" /> <Square size={16} color="white" fill="white" />
@@ -84,32 +74,8 @@ const styles = StyleSheet.create((theme) => ({
borderWidth: theme.borderWidth[1], borderWidth: theme.borderWidth[1],
borderColor: theme.colors.border, borderColor: theme.colors.border,
backgroundColor: theme.colors.surface2, backgroundColor: theme.colors.surface2,
paddingVertical: theme.spacing[3], paddingVertical: theme.spacing[2],
paddingHorizontal: theme.spacing[3], paddingHorizontal: theme.spacing[3],
gap: theme.spacing[3],
},
headerRow: {
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: {
flex: 1,
textAlign: "right",
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.xs,
}, },
contentRow: { contentRow: {
flexDirection: "row", flexDirection: "row",