Merge branch 'real-time-voice-panel-layout'
This commit is contained in:
@@ -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",
|
||||||
|
|||||||
@@ -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],
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
Reference in New Issue
Block a user