diff --git a/apps/mobile/app/project/[projectId]/[sessionId]/index.tsx b/apps/mobile/app/project/[projectId]/[sessionId]/index.tsx index 3b70269..6f12c0e 100644 --- a/apps/mobile/app/project/[projectId]/[sessionId]/index.tsx +++ b/apps/mobile/app/project/[projectId]/[sessionId]/index.tsx @@ -18,7 +18,7 @@ import { Text } from "@/components/ui/text" import { getMessages } from "@/lib/messages" import { Pressable } from "react-native" import { Button } from "@/components/ui/button" -import { MessageItem } from "@/components/message-item" +import { MessageItem, AssistantResponseGroup } from "@/components/message-item" import { SessionHeader } from "@/components/session-header" import { ChatInput, ImageAttachment } from "@/components/chat-input" import { useEventStream } from "@/components/hooks/event-stream" @@ -116,7 +116,7 @@ function SessionScreenInner({ projectId, sessionId }: { projectId: string; sessi const voice = useVoiceInput() - const scrollRef = useRef>(null) + const scrollRef = useRef>(null) useEffect(() => { const showListener = Keyboard.addListener(Platform.OS === "ios" ? "keyboardWillShow" : "keyboardDidShow", (e) => { @@ -816,35 +816,98 @@ function SessionScreenInner({ projectId, sessionId }: { projectId: string; sessi [rawMessages] ) + // Consecutive assistant messages belong to a single agent response. + // Group them so the response renders as one flow with a single + // aggregated token/cost footer once the whole group completes. + type DisplayItem = + | { kind: "user"; key: string; message: Message } + | { kind: "assistant-group"; key: string; messages: Message[] } + + const displayItems = useMemo(() => { + const sorted = [...rawMessages].sort( + (a, b) => (a.time?.created ?? 0) - (b.time?.created ?? 0) + ) + const asc: DisplayItem[] = [] + let currentAssistant: Message[] = [] + const flushAssistant = () => { + if (currentAssistant.length > 0) { + asc.push({ + kind: "assistant-group", + // Anchor the key to the first message so the row stays stable + // while the turn grows (new assistant messages join the group + // instead of mounting a new row per message part). + key: currentAssistant[0].id, + messages: [...currentAssistant], + }) + currentAssistant = [] + } + } + for (const m of sorted) { + if (m.role === "assistant") { + currentAssistant.push(m) + } else { + flushAssistant() + asc.push({ kind: "user", key: m.id, message: m }) + } + } + flushAssistant() + // Inverted list: newest first. + return asc.reverse() + }, [rawMessages]) + const renderItem = useCallback( - ({ item }: { item: Message }) => { - const hasQuestionTool = item.parts?.some( - (p) => - (p.type === "tool-invocation" && p.toolInvocation.toolName === "question") || - (p.type === "tool" && p.tool === "question") + ({ item }: { item: DisplayItem }) => { + if (item.kind === "user") { + const userMsg = item.message + return ( + + ) + } + const groupMessages = item.messages + const hasQuestionTool = groupMessages.some((msg) => + msg.parts?.some( + (p) => + (p.type === "tool-invocation" && p.toolInvocation.toolName === "question") || + (p.type === "tool" && p.tool === "question") + ) ) - const messagePermissions = permissionsByMessageId.get(item.id) ?? EMPTY_PERMISSIONS - const isStreamingMsg = isStreaming && item.role === "assistant" && !item.time?.completed + const groupPermissions: PermissionRequest[] = [] + for (const msg of groupMessages) { + const perms = permissionsByMessageId.get(msg.id) + if (perms) groupPermissions.push(...perms) + } + const isStreamingGroup = isStreaming && groupMessages.some((m) => m.role === "assistant" && !(m as { time?: { completed?: number } }).time?.completed) return ( - 0 ? groupPermissions : EMPTY_PERMISSIONS} onPermissionReply={handlePermissionReply} - streaming={isStreamingMsg} - queued={item.role === "user" && item.id.startsWith("local-") && !!item.queued} + streaming={isStreamingGroup} /> ) }, [theme, projectId, sessionId, pendingQuestions, permissionsByMessageId, handleQuestionReply, handleQuestionReject, handlePermissionReply, isStreaming] ) - const keyExtractor = useCallback((item: Message) => item.id, []) + const keyExtractor = useCallback((item: DisplayItem) => item.key, []) // Compute the current response "turn": all messages after the last user message. // The group start is anchored to the first assistant message of the turn so the @@ -976,10 +1039,10 @@ function SessionScreenInner({ projectId, sessionId }: { projectId: string; sessi - {messages.length > 0 ? ( + {displayItems.length > 0 ? ( 0 && ( + {!isAtBottom && displayItems.length > 0 && (