mirror of
https://github.com/getpaseo/paseo.git
synced 2026-07-29 12:01:31 +00:00
Merge pull request #33 from getpaseo/show-image-previews-in-optimistic-messages
Show image previews in optimistic user messages
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -314,6 +314,7 @@ export function AgentStreamView({
|
||||
return (
|
||||
<UserMessage
|
||||
message={item.text}
|
||||
images={item.images}
|
||||
timestamp={item.timestamp.getTime()}
|
||||
isFirstInGroup={isFirstInGroup}
|
||||
isLastInGroup={isLastInGroup}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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,
|
||||
]);
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 }),
|
||||
}));
|
||||
|
||||
|
||||
@@ -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()
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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) {
|
||||
|
||||
88
packages/app/src/utils/pending-create-images.test.ts
Normal file
88
packages/app/src/utils/pending-create-images.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
39
packages/app/src/utils/pending-create-images.ts
Normal file
39
packages/app/src/utils/pending-create-images.ts
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user