Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
101 changes: 82 additions & 19 deletions apps/mobile/app/project/[projectId]/[sessionId]/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -116,7 +116,7 @@ function SessionScreenInner({ projectId, sessionId }: { projectId: string; sessi

const voice = useVoiceInput()

const scrollRef = useRef<FlatList<Message>>(null)
const scrollRef = useRef<FlatList<any>>(null)

useEffect(() => {
const showListener = Keyboard.addListener(Platform.OS === "ios" ? "keyboardWillShow" : "keyboardDidShow", (e) => {
Expand Down Expand Up @@ -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<DisplayItem[]>(() => {
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 (
<MessageItem
message={userMsg}
theme={theme}
projectId={projectId}
sessionId={sessionId}
pendingQuestions={EMPTY_QUESTIONS}
onQuestionReply={handleQuestionReply}
onQuestionReject={handleQuestionReject}
pendingPermissions={permissionsByMessageId.get(userMsg.id) ?? EMPTY_PERMISSIONS}
onPermissionReply={handlePermissionReply}
streaming={false}
queued={userMsg.role === "user" && userMsg.id.startsWith("local-") && !!userMsg.queued}
/>
)
}
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 (
<MessageItem
message={item}
<AssistantResponseGroup
messages={groupMessages}
theme={theme}
projectId={projectId}
sessionId={sessionId}
pendingQuestions={hasQuestionTool ? pendingQuestions : EMPTY_QUESTIONS}
onQuestionReply={handleQuestionReply}
onQuestionReject={handleQuestionReject}
pendingPermissions={messagePermissions}
pendingPermissions={groupPermissions.length > 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
Expand Down Expand Up @@ -976,10 +1039,10 @@ function SessionScreenInner({ projectId, sessionId }: { projectId: string; sessi

<ConnectionBanner theme={theme} />

{messages.length > 0 ? (
{displayItems.length > 0 ? (
<FlatList
ref={scrollRef}
data={messages}
data={displayItems}
renderItem={renderItem}
keyExtractor={keyExtractor}
onContentSizeChange={scrollToBottomOnLoad}
Expand All @@ -1006,7 +1069,7 @@ function SessionScreenInner({ projectId, sessionId }: { projectId: string; sessi
ListEmptyComponent
)}

{!isAtBottom && messages.length > 0 && (
{!isAtBottom && displayItems.length > 0 && (
<View className="absolute left-0 right-0" style={{ bottom: insets.bottom + 150 + keyboardHeight }}>
<View className="items-center">
<Button variant="secondary" size="xs" className="rounded-full shadow-md" onPress={scrollToBottom}>
Expand All @@ -1017,7 +1080,7 @@ function SessionScreenInner({ projectId, sessionId }: { projectId: string; sessi
</View>
)}

{turn && turn.startAt != null && messages.length === 0 && (
{turn && turn.startAt != null && displayItems.length === 0 && (
<View className="px-4 py-2">
<WorkingIndicator startedAt={turn.startAt} endedAt={turn.endAt} agentName={turn.agent} />
</View>
Expand Down
94 changes: 57 additions & 37 deletions apps/mobile/components/hooks/event-stream.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,13 @@ type MessagePart = Part & {
messageID?: string
}

// A file url we can actually render on-device. Anything else (server-local
// paths, bare filenames) shows as a blank box without this guard.
const RENDERABLE_FILE_URL = /^(data:|https?:\/\/|file:\/\/|content:\/\/|ph:\/\/|asset:\/\/)/
function isRenderableFileUrl(url: unknown): boolean {
return typeof url === "string" && RENDERABLE_FILE_URL.test(url)
}

export function useGlobalSessionStatus(url?: string, token?: string) {
const abortRef = useRef<AbortController | null>(null)

Expand Down Expand Up @@ -204,57 +211,60 @@ export function useEventStream(url?: string, sessionId?: string, token?: string,
if (!info || info.sessionID !== currentSessionId) return

const existing = getOrCreatePending()
const existingIdx = existing.findIndex(m => m.id === info.id)
const infoParts = ((info as unknown as { parts?: Part[] }).parts ?? []) as Part[]

// Salvage device-local file uris BEFORE merging: the server
// echo of our optimistic message carries data: urls (or
// server-local paths), while the optimistic message holds
// the renderable file:// uri. The old code merged first
// (backup still empty, so server urls won) and only
// patched things up afterwards — leaving blank image boxes
// whenever the patch-up missed.
let salvagedLocalFileUrls: string[] = []
if (info.role === "user") {
const localIdx = existing.findIndex(m => m.id.startsWith("local-") && m.role === "user")
if (localIdx >= 0) {
const removed = existing[localIdx]
for (const p of removed.parts ?? []) {
if (p.type === "file" && p.url) salvagedLocalFileUrls.push(p.url)
}
existing.splice(localIdx, 1)
// Keep the legacy queue topped up for file parts
// that arrive via message.part.updated later.
localFileUriBackup.push(...salvagedLocalFileUrls)
}
}

// Only substitute a local uri where the server url isn't
// renderable on-device. Renderable server urls (data:,
// https:) are authoritative and permanent; device cache
// files can be evicted.
const swapInLocalUris = (parts: Part[]): Part[] => {
if (localFileUriBackup.length === 0) return parts
if (salvagedLocalFileUrls.length === 0) return parts
let i = 0
return parts.map((p) => {
if (p.type === "file" && localFileUriBackup.length > 0) {
const localUri = localFileUriBackup.shift()!
return { ...p, url: localUri } as Part
if (p.type === "file" && !isRenderableFileUrl(p.url) && i < salvagedLocalFileUrls.length) {
return { ...p, url: salvagedLocalFileUrls[i++] } as Part
}
return p
})
}

const existingIdx = existing.findIndex(m => m.id === info.id)
if (existingIdx >= 0) {
const existingMsg = existing[existingIdx]
const baseParts = (existingMsg.parts?.length ? existingMsg.parts : infoParts) as Part[]
existing[existingIdx] = { ...info, parts: swapInLocalUris([...baseParts]) } as Message
if (info.role === "user") {
// Server parts are authoritative for user messages
// (no text deltas to preserve); just fix up urls.
existing[existingIdx] = { ...info, parts: swapInLocalUris([...infoParts]) } as Message
} else {
const existingMsg = existing[existingIdx]
const baseParts = (existingMsg.parts?.length ? existingMsg.parts : infoParts) as Part[]
existing[existingIdx] = { ...info, parts: swapInLocalUris([...baseParts]) } as Message
}
} else {
existing.push({ ...info, parts: swapInLocalUris([...infoParts]) } as Message)
}

if (info.role === "user") {
for (let i = 0; i < existing.length; i++) {
if (existing[i].id.startsWith("local-") && existing[i].role === "user") {
const removed = existing[i]
for (const p of removed.parts ?? []) {
if (p.type === "file" && p.url) localFileUriBackup.push(p.url)
}
existing.splice(i, 1)
// New server message may already be in the list with
// unrenderable urls — swap salvaged local uris into it.
for (let j = existing.length - 1; j >= 0; j--) {
if (existing[j].id === info.id && localFileUriBackup.length > 0) {
const msg = existing[j]
const parts = (msg.parts ?? []).map((p) => {
if (p.type === "file" && localFileUriBackup.length > 0) {
const localUri = localFileUriBackup.shift()!
return { ...p, url: localUri } as Part
}
return p
})
existing[j] = { ...msg, parts } as Message
break
}
}
break
}
}
}

scheduleFlush()

if (info.role === "assistant") {
Expand Down Expand Up @@ -380,6 +390,16 @@ export function useEventStream(url?: string, sessionId?: string, token?: string,
const parts = msg.parts ?? []
const partIdx = parts.findIndex(p => p.id === effectivePart.id)

// Never clobber a renderable file url with an unrenderable
// server snapshot (server-local path, bare filename).
if (effectivePart.type === "file" && partIdx >= 0 && parts[partIdx].type === "file") {
const currentUrl = (parts[partIdx] as { url?: unknown }).url
const incomingUrl = (effectivePart as { url?: unknown }).url
if (isRenderableFileUrl(currentUrl) && !isRenderableFileUrl(incomingUrl)) {
effectivePart = { ...effectivePart, url: currentUrl } as MessagePart
}
}

// Official opencode event carries incremental `delta` alongside the
// `part` snapshot. The snapshot alone can be stale/empty for
// reasoning parts, so fold the delta into the text.
Expand Down
Loading
Loading