diff --git a/packages/app/src/components/agent-stream-view.tsx b/packages/app/src/components/agent-stream-view.tsx index 8b4077db0..303ed00c7 100644 --- a/packages/app/src/components/agent-stream-view.tsx +++ b/packages/app/src/components/agent-stream-view.tsx @@ -53,6 +53,7 @@ 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) => @@ -60,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; @@ -78,6 +96,7 @@ export function AgentStreamView({ const flatListRef = useRef>(null); const { theme } = useUnistyles(); const insets = useSafeAreaInsets(); + const { isVoiceMode } = useVoice(); const [isNearBottom, setIsNearBottom] = useState(true); const hasScrolledInitially = useRef(false); const hasAutoScrolledOnce = useRef(false); @@ -363,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) => { const content = renderStreamItemContent(item, index); @@ -392,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 ( {content} - {isEndOfTurn ? : null} ); }, - [ - getGapBelow, - renderStreamItemContent, - flatListData, - agent.status, - collectTurnContent, - ] + [getGapBelow, renderStreamItemContent] ); const pendingPermissionItems = useMemo( @@ -495,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 ? ( + + ) : showCopyButton ? ( + latestTurnContent} /> + ) : null; + return ( - - + {showBottomBar ? ( + + {leftContent} + + {isVoiceMode ? : null} + ) : null} @@ -555,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. @@ -1180,7 +1195,7 @@ const stylesheet = StyleSheet.create((theme) => ({ listHeaderContent: { gap: theme.spacing[3], }, - workingIndicatorWrapper: { + bottomBarWrapper: { flexDirection: "row", alignItems: "center", justifyContent: "space-between", @@ -1188,6 +1203,15 @@ const stylesheet = StyleSheet.create((theme) => ({ 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", diff --git a/packages/app/src/components/message.tsx b/packages/app/src/components/message.tsx index 063728c89..7c9d57ade 100644 --- a/packages/app/src/components/message.tsx +++ b/packages/app/src/components/message.tsx @@ -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, diff --git a/packages/app/src/components/voice-compact-indicator.tsx b/packages/app/src/components/voice-compact-indicator.tsx index e1215d85c..416d28888 100644 --- a/packages/app/src/components/voice-compact-indicator.tsx +++ b/packages/app/src/components/voice-compact-indicator.tsx @@ -1,6 +1,6 @@ import { Pressable, View } from "react-native"; 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 { useVoice } from "@/contexts/voice-context"; @@ -35,7 +35,7 @@ export function VoiceCompactIndicator() { {isMuted ? ( ) : ( - + )} @@ -73,4 +73,3 @@ const styles = StyleSheet.create((theme) => ({ borderColor: theme.colors.palette.red[800], }, })); - diff --git a/packages/app/src/components/voice-panel.tsx b/packages/app/src/components/voice-panel.tsx index c5875f972..a73012873 100644 --- a/packages/app/src/components/voice-panel.tsx +++ b/packages/app/src/components/voice-panel.tsx @@ -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,21 +20,10 @@ 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 ( - - - - Voice - - - {hostLabel ? `Host: ${hostLabel}` : "Host: unknown"} - {hostStatus ? ` (${hostStatus})` : ""} - - - @@ -50,7 +40,7 @@ export function VoicePanel() { void stopVoice()} accessibilityRole="button" - accessibilityLabel="Stop voice mode" + accessibilityLabel={`Stop voice mode${hostSuffix}`} style={[styles.iconButton, styles.iconButtonStop]} > @@ -84,32 +74,8 @@ 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: { - 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: { flexDirection: "row",