From f8f4c559766e1b930245010beb0fe08a1dbf0512 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Mon, 16 Feb 2026 12:19:38 +0700 Subject: [PATCH] Show image previews in optimistic user messages --- .../app/src/components/agent-input-area.tsx | 1 + .../app/src/components/agent-stream-view.tsx | 1 + packages/app/src/components/message.tsx | 79 +++++++++++++---- .../src/screens/agent/agent-ready-screen.tsx | 34 +++++++ .../src/screens/agent/draft-agent-screen.tsx | 17 +++- packages/app/src/stores/create-flow-store.ts | 3 +- packages/app/src/types/stream.test.ts | 38 ++++++++ packages/app/src/types/stream.ts | 14 +++ .../src/utils/pending-create-images.test.ts | 88 +++++++++++++++++++ .../app/src/utils/pending-create-images.ts | 39 ++++++++ 10 files changed, 294 insertions(+), 20 deletions(-) create mode 100644 packages/app/src/utils/pending-create-images.test.ts create mode 100644 packages/app/src/utils/pending-create-images.ts diff --git a/packages/app/src/components/agent-input-area.tsx b/packages/app/src/components/agent-input-area.tsx index f405b31cb..6b89104a7 100644 --- a/packages/app/src/components/agent-input-area.tsx +++ b/packages/app/src/components/agent-input-area.tsx @@ -198,6 +198,7 @@ export function AgentInputArea({ id: messageId, text, timestamp: new Date(), + ...(images && images.length > 0 ? { images } : {}), }; // Append to head if streaming (keeps the user message with the current diff --git a/packages/app/src/components/agent-stream-view.tsx b/packages/app/src/components/agent-stream-view.tsx index 1317805cf..a2d093029 100644 --- a/packages/app/src/components/agent-stream-view.tsx +++ b/packages/app/src/components/agent-stream-view.tsx @@ -314,6 +314,7 @@ export function AgentStreamView({ return ( ({ lineHeight: 22, overflowWrap: "anywhere", }, + imagePreviewContainer: { + flexDirection: "row", + gap: theme.spacing[2], + flexWrap: "wrap", + }, + imagePreviewSpacing: { + marginBottom: theme.spacing[2], + }, + imagePill: { + borderRadius: theme.borderRadius.md, + borderWidth: 1, + borderColor: theme.colors.borderAccent, + overflow: "hidden", + }, + imageThumbnail: { + width: 48, + height: 48, + }, copyButton: { alignSelf: "flex-end", padding: theme.spacing[1], @@ -192,6 +212,7 @@ const userMessageStylesheet = StyleSheet.create((theme) => ({ export const UserMessage = memo(function UserMessage({ message, + images = [], timestamp, isFirstInGroup = true, isLastInGroup = true, @@ -201,8 +222,10 @@ export const UserMessage = memo(function UserMessage({ const [copyButtonHovered, setCopyButtonHovered] = useState(false); const resolvedDisableOuterSpacing = useDisableOuterSpacing(disableOuterSpacing); + const hasText = message.trim().length > 0; + const hasImages = images.length > 0; const showCopyButton = - Platform.OS !== "web" || messageHovered || copyButtonHovered; + hasText && (Platform.OS !== "web" || messageHovered || copyButtonHovered); return ( - - {message} - + {hasImages ? ( + + {images.map((image, index) => ( + + + + ))} + + ) : null} + {hasText ? ( + + {message} + + ) : null} - message} - containerStyle={[ - userMessageStylesheet.copyButton, - showCopyButton - ? userMessageStylesheet.copyButtonVisible - : userMessageStylesheet.copyButtonHidden, - ]} - accessibilityLabel="Copy message" - onHoverChange={setCopyButtonHovered} - /> + {hasText ? ( + message} + containerStyle={[ + userMessageStylesheet.copyButton, + showCopyButton + ? userMessageStylesheet.copyButtonVisible + : userMessageStylesheet.copyButtonHidden, + ]} + accessibilityLabel="Copy message" + onHoverChange={setCopyButtonHovered} + /> + ) : null} ); diff --git a/packages/app/src/screens/agent/agent-ready-screen.tsx b/packages/app/src/screens/agent/agent-ready-screen.tsx index 7ecf9237d..b2236af47 100644 --- a/packages/app/src/screens/agent/agent-ready-screen.tsx +++ b/packages/app/src/screens/agent/agent-ready-screen.tsx @@ -72,6 +72,7 @@ import { derivePendingPermissionKey, normalizeAgentSnapshot, } from "@/utils/agent-snapshots"; +import { mergePendingCreateImages } from "@/utils/pending-create-images"; import type { FetchAgentsEntry } from "@server/client/daemon-client"; import { DropdownMenu, @@ -492,6 +493,7 @@ function AgentScreenContent({ (state) => state.sessions[serverId]?.pendingPermissions ); const setAgents = useSessionStore((state) => state.setAgents); + const setAgentStreamTail = useSessionStore((state) => state.setAgentStreamTail); const setPendingPermissions = useSessionStore( (state) => state.setPendingPermissions ); @@ -637,6 +639,9 @@ function AgentScreenContent({ id: pendingCreate.messageId, text: pendingCreate.text, timestamp: new Date(pendingCreate.timestamp), + ...(pendingCreate.images && pendingCreate.images.length > 0 + ? { images: pendingCreate.images } + : {}), }, ]; }, [isPendingCreateForRoute, pendingCreate]); @@ -718,6 +723,32 @@ function AgentScreenContent({ (item.id === pendingCreate.messageId || item.text === pendingCreate.text) ); if (agent && hasUserMessage) { + if ( + resolvedAgentId && + pendingCreate.images && + pendingCreate.images.length > 0 + ) { + setAgentStreamTail(serverId, (prev) => { + const current = prev.get(resolvedAgentId); + if (!current) { + return prev; + } + + const merged = mergePendingCreateImages({ + streamItems: current, + messageId: pendingCreate.messageId, + text: pendingCreate.text, + images: pendingCreate.images, + }); + if (merged === current) { + return prev; + } + + const next = new Map(prev); + next.set(resolvedAgentId, merged); + return next; + }); + } clearPendingCreate(); } }, [ @@ -725,6 +756,9 @@ function AgentScreenContent({ clearPendingCreate, isPendingCreateForRoute, pendingCreate, + resolvedAgentId, + serverId, + setAgentStreamTail, streamItems, ]); diff --git a/packages/app/src/screens/agent/draft-agent-screen.tsx b/packages/app/src/screens/agent/draft-agent-screen.tsx index b9ba1dbd5..0f47da548 100644 --- a/packages/app/src/screens/agent/draft-agent-screen.tsx +++ b/packages/app/src/screens/agent/draft-agent-screen.tsx @@ -37,8 +37,11 @@ import { useCreateFlowStore } from "@/stores/create-flow-store"; import { MAX_CONTENT_WIDTH } from "@/constants/layout"; import { WelcomeScreen } from "@/components/welcome-screen"; import type { Agent } from "@/contexts/session-context"; -import type { StreamItem } from "@/types/stream"; -import { generateMessageId } from "@/types/stream"; +import { + generateMessageId, + type StreamItem, + type UserMessageImageAttachment, +} from "@/types/stream"; import { encodeImages } from "@/utils/encode-images"; import type { AgentProvider, @@ -259,6 +262,7 @@ export function DraftAgentScreen({ messageId: string; text: string; timestamp: Date; + images?: UserMessageImageAttachment[]; }; type DraftAgentMachineState = @@ -727,6 +731,9 @@ export function DraftAgentScreen({ id: machine.attempt.messageId, text: machine.attempt.text, timestamp: machine.attempt.timestamp, + ...(machine.attempt.images && machine.attempt.images.length > 0 + ? { images: machine.attempt.images } + : {}), }, ]; }, [machine]); @@ -789,7 +796,7 @@ export function DraftAgentScreen({ images, }: { text: string; - images?: Array<{ uri: string; mimeType: string }>; + images?: UserMessageImageAttachment[]; }) => { if (isSubmitting) { throw new Error("Already loading"); @@ -848,6 +855,7 @@ export function DraftAgentScreen({ messageId: generateMessageId(), text: trimmedPrompt, timestamp: new Date(), + ...(images && images.length > 0 ? { images } : {}), }; setPendingCreateAttempt({ serverId: selectedServerId, @@ -855,6 +863,9 @@ export function DraftAgentScreen({ messageId: attempt.messageId, text: attempt.text, timestamp: attempt.timestamp.getTime(), + ...(attempt.images && attempt.images.length > 0 + ? { images: attempt.images } + : {}), }); const modeId = modeOptions.length > 0 && selectedMode !== "" ? selectedMode : undefined; diff --git a/packages/app/src/stores/create-flow-store.ts b/packages/app/src/stores/create-flow-store.ts index 832eeedac..c9c3c6f23 100644 --- a/packages/app/src/stores/create-flow-store.ts +++ b/packages/app/src/stores/create-flow-store.ts @@ -1,4 +1,5 @@ import { create } from "zustand"; +import type { UserMessageImageAttachment } from "@/types/stream"; type PendingCreateAttempt = { serverId: string; @@ -6,6 +7,7 @@ type PendingCreateAttempt = { messageId: string; text: string; timestamp: number; + images?: UserMessageImageAttachment[]; }; type CreateFlowState = { @@ -24,4 +26,3 @@ export const useCreateFlowStore = create((set) => ({ ), clear: () => set({ pending: null }), })); - diff --git a/packages/app/src/types/stream.test.ts b/packages/app/src/types/stream.test.ts index 0468733e9..9ff8b1b42 100644 --- a/packages/app/src/types/stream.test.ts +++ b/packages/app/src/types/stream.test.ts @@ -3,6 +3,7 @@ import { describe, it } from "vitest"; import { hydrateStreamState, + reduceStreamUpdate, type AgentToolCallItem, type StreamItem, isAgentToolCallItem, @@ -399,4 +400,41 @@ describe("stream reducer canonical tool calls", () => { assert.ok(todos); assert.strictEqual(todos.items[0]?.text, "Task 1"); }); + + it("preserves optimistic user message images when authoritative user message arrives", () => { + const messageId = "msg-user-images"; + const optimisticImages = [ + { uri: "file:///tmp/optimistic.jpg", mimeType: "image/jpeg" }, + ]; + const initialState: StreamItem[] = [ + { + kind: "user_message", + id: messageId, + text: "Analyze this image", + timestamp: new Date("2025-01-01T11:10:00Z"), + images: optimisticImages, + }, + ]; + const event: AgentStreamEventPayload = { + type: "timeline", + provider: "claude", + item: { + type: "user_message", + text: "Analyze this image", + messageId, + }, + }; + const authoritativeTimestamp = new Date("2025-01-01T11:10:01Z"); + + const state = reduceStreamUpdate(initialState, event, authoritativeTimestamp); + const message = state.find((item) => item.kind === "user_message"); + + assert.ok(message); + assert.strictEqual(message.id, messageId); + assert.deepStrictEqual(message.images, optimisticImages); + assert.strictEqual( + message.timestamp.getTime(), + authoritativeTimestamp.getTime() + ); + }); }); diff --git a/packages/app/src/types/stream.ts b/packages/app/src/types/stream.ts index cdc86ee8f..7cf763c7e 100644 --- a/packages/app/src/types/stream.ts +++ b/packages/app/src/types/stream.ts @@ -59,11 +59,17 @@ export type StreamItem = | ActivityLogItem | CompactionItem; +export interface UserMessageImageAttachment { + uri: string; + mimeType: string; +} + export interface UserMessageItem { kind: "user_message"; id: string; text: string; timestamp: Date; + images?: UserMessageImageAttachment[]; } export interface AssistantMessageItem { @@ -198,12 +204,20 @@ function appendUserMessage( const existingIndex = state.findIndex( (entry) => entry.kind === "user_message" && entry.id === entryId ); + const existing = + existingIndex >= 0 && state[existingIndex]?.kind === "user_message" + ? state[existingIndex] + : null; + const preservedImages = existing?.images; const nextItem: UserMessageItem = { kind: "user_message", id: entryId, text: chunk, timestamp, + ...(preservedImages && preservedImages.length > 0 + ? { images: preservedImages } + : {}), }; if (existingIndex >= 0) { diff --git a/packages/app/src/utils/pending-create-images.test.ts b/packages/app/src/utils/pending-create-images.test.ts new file mode 100644 index 000000000..1571037ed --- /dev/null +++ b/packages/app/src/utils/pending-create-images.test.ts @@ -0,0 +1,88 @@ +import { describe, expect, it } from "vitest"; +import type { StreamItem } from "@/types/stream"; +import { mergePendingCreateImages } from "./pending-create-images"; + +function userMessage(params: { + id: string; + text: string; + images?: Array<{ uri: string; mimeType: string }>; +}): StreamItem { + return { + kind: "user_message", + id: params.id, + text: params.text, + timestamp: new Date("2026-01-01T00:00:00Z"), + ...(params.images ? { images: params.images } : {}), + }; +} + +describe("mergePendingCreateImages", () => { + it("returns same reference when pending images are absent", () => { + const streamItems = [userMessage({ id: "msg-1", text: "hello" })]; + const result = mergePendingCreateImages({ + streamItems, + messageId: "msg-1", + text: "hello", + images: [], + }); + expect(result).toBe(streamItems); + }); + + it("merges images by messageId when the matched message has none", () => { + const streamItems = [userMessage({ id: "msg-1", text: "hello" })]; + const images = [{ uri: "file:///tmp/image-1.jpg", mimeType: "image/jpeg" }]; + const result = mergePendingCreateImages({ + streamItems, + messageId: "msg-1", + text: "hello", + images, + }); + + expect(result).not.toBe(streamItems); + const updated = result[0]; + expect(updated?.kind).toBe("user_message"); + if (updated?.kind !== "user_message") { + throw new Error("Expected user_message item"); + } + expect(updated.images).toEqual(images); + }); + + it("falls back to text matching when messageId does not match", () => { + const streamItems = [userMessage({ id: "msg-1", text: "same text" })]; + const images = [{ uri: "file:///tmp/image-2.jpg", mimeType: "image/jpeg" }]; + const result = mergePendingCreateImages({ + streamItems, + messageId: "missing-id", + text: "same text", + images, + }); + + const updated = result[0]; + expect(updated?.kind).toBe("user_message"); + if (updated?.kind !== "user_message") { + throw new Error("Expected user_message item"); + } + expect(updated.images).toEqual(images); + }); + + it("does not overwrite existing user message images", () => { + const existingImages = [{ uri: "file:///tmp/existing.jpg", mimeType: "image/jpeg" }]; + const streamItems = [ + userMessage({ id: "msg-1", text: "hello", images: existingImages }), + ]; + const result = mergePendingCreateImages({ + streamItems, + messageId: "msg-1", + text: "hello", + images: [{ uri: "file:///tmp/new.jpg", mimeType: "image/jpeg" }], + }); + + expect(result).toBe(streamItems); + const unchanged = result[0]; + expect(unchanged?.kind).toBe("user_message"); + if (unchanged?.kind !== "user_message") { + throw new Error("Expected user_message item"); + } + expect(unchanged.images).toEqual(existingImages); + }); +}); diff --git a/packages/app/src/utils/pending-create-images.ts b/packages/app/src/utils/pending-create-images.ts new file mode 100644 index 000000000..2fbd25b16 --- /dev/null +++ b/packages/app/src/utils/pending-create-images.ts @@ -0,0 +1,39 @@ +import type { StreamItem, UserMessageImageAttachment } from "@/types/stream"; + +type MergePendingCreateImagesParams = { + streamItems: StreamItem[]; + messageId: string; + text: string; + images?: UserMessageImageAttachment[]; +}; + +export function mergePendingCreateImages({ + streamItems, + messageId, + text, + images, +}: MergePendingCreateImagesParams): StreamItem[] { + if (!images || images.length === 0) { + return streamItems; + } + + const targetIndex = streamItems.findIndex( + (item) => + item.kind === "user_message" && (item.id === messageId || item.text === text) + ); + if (targetIndex < 0) { + return streamItems; + } + + const target = streamItems[targetIndex]; + if (target.kind !== "user_message") { + return streamItems; + } + if (target.images && target.images.length > 0) { + return streamItems; + } + + const next = [...streamItems]; + next[targetIndex] = { ...target, images }; + return next; +}