Merge pull request #33 from getpaseo/show-image-previews-in-optimistic-messages

Show image previews in optimistic user messages
This commit is contained in:
Mohamed Boudra
2026-02-18 10:03:07 +07:00
committed by GitHub
10 changed files with 294 additions and 20 deletions

View File

@@ -230,6 +230,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

View File

@@ -314,6 +314,7 @@ export function AgentStreamView({
return (
<UserMessage
message={item.text}
images={item.images}
timestamp={item.timestamp.getTime()}
isFirstInGroup={isFirstInGroup}
isLastInGroup={isLastInGroup}

View File

@@ -1,6 +1,7 @@
import {
View,
Text,
Image,
Pressable,
ActivityIndicator,
type LayoutChangeEvent,
@@ -61,7 +62,7 @@ import {
} from "@/styles/markdown-styles";
import { Colors, Fonts } from "@/constants/theme";
import * as Clipboard from "expo-clipboard";
import type { TodoEntry } from "@/types/stream";
import type { TodoEntry, UserMessageImageAttachment } from "@/types/stream";
import type { ToolCallDetail } from "@server/server/agent/agent-sdk-types";
import {
buildToolCallDisplayModel,
@@ -77,6 +78,7 @@ import { ToolCallDetailsContent } from "./tool-call-details";
interface UserMessageProps {
message: string;
images?: UserMessageImageAttachment[];
timestamp: number;
isFirstInGroup?: boolean;
isLastInGroup?: boolean;
@@ -286,6 +288,24 @@ const userMessageStylesheet = StyleSheet.create((theme) => ({
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],
@@ -301,6 +321,7 @@ const userMessageStylesheet = StyleSheet.create((theme) => ({
export const UserMessage = memo(function UserMessage({
message,
images = [],
timestamp,
isFirstInGroup = true,
isLastInGroup = true,
@@ -310,8 +331,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 (
<View
@@ -336,21 +359,45 @@ export const UserMessage = memo(function UserMessage({
}
>
<View style={userMessageStylesheet.bubble}>
<Text selectable style={userMessageStylesheet.text}>
{message}
</Text>
{hasImages ? (
<View
style={[
userMessageStylesheet.imagePreviewContainer,
hasText ? userMessageStylesheet.imagePreviewSpacing : undefined,
]}
>
{images.map((image, index) => (
<View
key={`${image.uri}-${index}`}
style={userMessageStylesheet.imagePill}
>
<Image
source={{ uri: image.uri }}
style={userMessageStylesheet.imageThumbnail}
/>
</View>
))}
</View>
) : null}
{hasText ? (
<Text selectable style={userMessageStylesheet.text}>
{message}
</Text>
) : null}
</View>
<TurnCopyButton
getContent={() => message}
containerStyle={[
userMessageStylesheet.copyButton,
showCopyButton
? userMessageStylesheet.copyButtonVisible
: userMessageStylesheet.copyButtonHidden,
]}
accessibilityLabel="Copy message"
onHoverChange={setCopyButtonHovered}
/>
{hasText ? (
<TurnCopyButton
getContent={() => message}
containerStyle={[
userMessageStylesheet.copyButton,
showCopyButton
? userMessageStylesheet.copyButtonVisible
: userMessageStylesheet.copyButtonHidden,
]}
accessibilityLabel="Copy message"
onHoverChange={setCopyButtonHovered}
/>
) : null}
</Pressable>
</View>
);

View File

@@ -72,6 +72,7 @@ import {
derivePendingPermissionKey,
normalizeAgentSnapshot,
} from "@/utils/agent-snapshots";
import { mergePendingCreateImages } from "@/utils/pending-create-images";
import { shouldClearAgentAttentionOnView } from "@/utils/agent-attention";
import type { FetchAgentsEntry } from "@server/client/daemon-client";
import {
@@ -493,6 +494,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
);
@@ -641,6 +643,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]);
@@ -722,6 +727,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();
}
}, [
@@ -729,6 +760,9 @@ function AgentScreenContent({
clearPendingCreate,
isPendingCreateForRoute,
pendingCreate,
resolvedAgentId,
serverId,
setAgentStreamTail,
streamItems,
]);

View File

@@ -40,8 +40,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,
@@ -262,6 +265,7 @@ export function DraftAgentScreen({
messageId: string;
text: string;
timestamp: Date;
images?: UserMessageImageAttachment[];
};
type DraftAgentMachineState =
@@ -831,6 +835,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]);
@@ -893,7 +900,7 @@ export function DraftAgentScreen({
images,
}: {
text: string;
images?: Array<{ uri: string; mimeType: string }>;
images?: UserMessageImageAttachment[];
}) => {
if (isSubmitting) {
throw new Error("Already loading");
@@ -952,6 +959,7 @@ export function DraftAgentScreen({
messageId: generateMessageId(),
text: trimmedPrompt,
timestamp: new Date(),
...(images && images.length > 0 ? { images } : {}),
};
setPendingCreateAttempt({
serverId: selectedServerId,
@@ -959,6 +967,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;

View File

@@ -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<CreateFlowState>((set) => ({
),
clear: () => set({ pending: null }),
}));

View File

@@ -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()
);
});
});

View File

@@ -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) {

View File

@@ -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);
});
});

View File

@@ -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;
}