From a6bdff9c75c22dc170f70cf290c37070843fc67f Mon Sep 17 00:00:00 2001 From: Ammar Date: Sun, 14 Dec 2025 12:28:20 -0600 Subject: [PATCH] =?UTF-8?q?=F0=9F=A4=96=20fix:=20preserve=20review=20forma?= =?UTF-8?q?tting=20in=20queued=20messages?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Previously, reviews in queued messages were displayed as raw text with XML tags. Now they render with the same nice formatting as sent user messages (file path, line range, code snippet, comment). Changes: - Add reviews field to QueuedMessage type and queued-message-changed event - Extract shared UserMessageContent component for rendering user messages - MessageQueue.getReviews() exposes reviews from stored metadata - Add QueuedMessageWithReviews story to cover the new functionality The UserMessageContent extraction also deduplicates ~60 lines of code between UserMessage and QueuedMessage components. --- .../components/Messages/QueuedMessage.tsx | 42 ++++------- .../components/Messages/UserMessage.tsx | 59 +++------------ .../Messages/UserMessageContent.tsx | 72 +++++++++++++++++++ src/browser/stores/WorkspaceStore.ts | 8 ++- src/browser/stories/App.reviews.stories.tsx | 61 ++++++++++++++++ src/browser/stories/storyHelpers.ts | 14 +++- src/common/orpc/schemas/stream.ts | 12 ++++ src/common/types/message.ts | 2 + src/node/services/agentSession.ts | 1 + src/node/services/messageQueue.ts | 22 ++++++ 10 files changed, 212 insertions(+), 81 deletions(-) create mode 100644 src/browser/components/Messages/UserMessageContent.tsx diff --git a/src/browser/components/Messages/QueuedMessage.tsx b/src/browser/components/Messages/QueuedMessage.tsx index 308521cef3..bdc18a26f3 100644 --- a/src/browser/components/Messages/QueuedMessage.tsx +++ b/src/browser/components/Messages/QueuedMessage.tsx @@ -1,6 +1,7 @@ import React, { useCallback, useState } from "react"; import type { ButtonConfig } from "./MessageWindow"; import { MessageWindow } from "./MessageWindow"; +import { UserMessageContent } from "./UserMessageContent"; import type { QueuedMessage as QueuedMessageType } from "@/common/types/message"; import { Pencil } from "lucide-react"; import { Tooltip, TooltipTrigger, TooltipContent } from "../ui/tooltip"; @@ -61,32 +62,19 @@ export const QueuedMessage: React.FC = ({ ); return ( - <> - - {content && ( -
-            {content}
-          
- )} - {message.imageParts && message.imageParts.length > 0 && ( -
- {message.imageParts.map((img, idx) => ( - {`Attachment - ))} -
- )} -
- + + + ); }; diff --git a/src/browser/components/Messages/UserMessage.tsx b/src/browser/components/Messages/UserMessage.tsx index 657a9e74b2..a7fbbf2c9c 100644 --- a/src/browser/components/Messages/UserMessage.tsx +++ b/src/browser/components/Messages/UserMessage.tsx @@ -1,7 +1,8 @@ import React from "react"; -import type { DisplayedMessage, ReviewNoteDataForDisplay } from "@/common/types/message"; +import type { DisplayedMessage } from "@/common/types/message"; import type { ButtonConfig } from "./MessageWindow"; import { MessageWindow } from "./MessageWindow"; +import { UserMessageContent } from "./UserMessageContent"; import { TerminalOutput } from "./TerminalOutput"; import { formatKeybind, KEYBINDS } from "@/browser/utils/ui/keybinds"; import { useCopyToClipboard } from "@/browser/hooks/useCopyToClipboard"; @@ -9,24 +10,6 @@ import { copyToClipboard } from "@/browser/utils/clipboard"; import { usePersistedState } from "@/browser/hooks/usePersistedState"; import { VIM_ENABLED_KEY } from "@/common/constants/storage"; import { Clipboard, ClipboardCheck, Pencil } from "lucide-react"; -import { ReviewBlockFromData } from "../shared/ReviewBlock"; - -/** Helper component to render reviews from structured data with optional text */ -const ReviewsWithText: React.FC<{ - reviews: ReviewNoteDataForDisplay[]; - textContent: string; -}> = ({ reviews, textContent }) => ( -
- {reviews.map((review, idx) => ( - - ))} - {textContent && ( -
-        {textContent}
-      
- )} -
-); interface UserMessageProps { message: DisplayedMessage & { type: "user" }; @@ -107,15 +90,6 @@ export const UserMessage: React.FC = ({ ); } - // Check if we have structured review data in metadata - const hasReviews = message.reviews && message.reviews.length > 0; - - // Extract plain text content (without review tags) for display alongside review blocks - const plainTextContent = hasReviews - ? content.replace(/[\s\S]*?<\/review>\s*/g, "").trim() - : content; - - // Otherwise, render as normal user message return ( = ({ className={className} variant="user" > - {hasReviews ? ( - // Use structured review data from metadata - - ) : ( - // No reviews - just plain text - content && ( -
-            {content}
-          
- ) - )} - {message.imageParts && message.imageParts.length > 0 && ( -
- {message.imageParts.map((img, idx) => ( - {`Attachment - ))} -
- )} +
); }; diff --git a/src/browser/components/Messages/UserMessageContent.tsx b/src/browser/components/Messages/UserMessageContent.tsx new file mode 100644 index 0000000000..62bd84ddd3 --- /dev/null +++ b/src/browser/components/Messages/UserMessageContent.tsx @@ -0,0 +1,72 @@ +import React from "react"; +import type { ReviewNoteDataForDisplay } from "@/common/types/message"; +import type { ImagePart } from "@/common/orpc/schemas"; +import { ReviewBlockFromData } from "../shared/ReviewBlock"; + +interface UserMessageContentProps { + content: string; + reviews?: ReviewNoteDataForDisplay[]; + imageParts?: ImagePart[]; + /** Controls styling: "sent" for full styling, "queued" for muted preview */ + variant: "sent" | "queued"; +} + +const textStyles = { + sent: "font-primary m-0 leading-6 break-words whitespace-pre-wrap text-[var(--color-user-text)]", + queued: "text-subtle m-0 font-mono text-xs leading-4 break-words whitespace-pre-wrap opacity-90", +} as const; + +const imageContainerStyles = { + sent: "mt-3 flex flex-wrap gap-3", + queued: "mt-2 flex flex-wrap gap-2", +} as const; + +const imageStyles = { + sent: "max-h-[300px] max-w-72 rounded-xl border border-[var(--color-attachment-border)] object-cover", + queued: "border-border-light max-h-[300px] max-w-80 rounded border", +} as const; + +/** + * Shared content renderer for user messages (sent and queued). + * Handles reviews, text content, and image attachments. + */ +export const UserMessageContent: React.FC = ({ + content, + reviews, + imageParts, + variant, +}) => { + const hasReviews = reviews && reviews.length > 0; + + // Strip review tags from text when displaying alongside review blocks + const textContent = hasReviews + ? content.replace(/[\s\S]*?<\/review>\s*/g, "").trim() + : content; + + return ( + <> + {hasReviews ? ( +
+ {reviews.map((review, idx) => ( + + ))} + {textContent &&
{textContent}
} +
+ ) : ( + content &&
{content}
+ )} + {imageParts && imageParts.length > 0 && ( +
+ {imageParts.map((img, idx) => ( + {`Attachment + ))} +
+ )} + + ); +}; diff --git a/src/browser/stores/WorkspaceStore.ts b/src/browser/stores/WorkspaceStore.ts index fe0d15ebf2..8b217422d6 100644 --- a/src/browser/stores/WorkspaceStore.ts +++ b/src/browser/stores/WorkspaceStore.ts @@ -281,13 +281,17 @@ export class WorkspaceStore { // Create QueuedMessage once here instead of on every render // Use displayText which handles slash commands (shows /compact instead of expanded prompt) - // Show queued message if there's text OR images (support image-only queued messages) - const hasContent = data.queuedMessages.length > 0 || (data.imageParts?.length ?? 0) > 0; + // Show queued message if there's text OR images OR reviews (support review-only queued messages) + const hasContent = + data.queuedMessages.length > 0 || + (data.imageParts?.length ?? 0) > 0 || + (data.reviews?.length ?? 0) > 0; const queuedMessage: QueuedMessage | null = hasContent ? { id: `queued-${workspaceId}`, content: data.displayText, imageParts: data.imageParts, + reviews: data.reviews, } : null; diff --git a/src/browser/stories/App.reviews.stories.tsx b/src/browser/stories/App.reviews.stories.tsx index e4eb54f5d6..01cceff930 100644 --- a/src/browser/stories/App.reviews.stories.tsx +++ b/src/browser/stories/App.reviews.stories.tsx @@ -7,6 +7,7 @@ import { setupSimpleChatStory, setReviews, createReview } from "./storyHelpers"; import { blurActiveElement, waitForChatInputAutofocusDone } from "./storyPlayHelpers.js"; import { createUserMessage, createAssistantMessage } from "./mockFactory"; import { within, userEvent, waitFor } from "@storybook/test"; +import type { WorkspaceChatMessage } from "@/common/orpc/types"; export default { ...appMeta, @@ -279,3 +280,63 @@ export const BulkReviewActions: AppStory = { blurActiveElement(); }, }; + +/** + * Shows reviews in a queued message with nice formatting. + * The queued message appears when the user sends a message while the assistant is busy. + * Reviews are displayed with proper formatting (file path, line range, code snippet, comment). + */ +export const QueuedMessageWithReviews: AppStory = { + render: () => ( + { + const workspaceId = "ws-queued-reviews"; + + return setupSimpleChatStory({ + workspaceId, + workspaceName: "feature/auth", + projectName: "my-app", + messages: [ + createUserMessage("msg-1", "Help me fix authentication", { historySequence: 1 }), + createAssistantMessage("msg-2", "I'll analyze the code and help you fix it...", { + historySequence: 2, + }), + ], + onChat: (wsId, emit) => { + // Emit the queued message with reviews (simulating user queued a message with reviews) + emit({ + type: "queued-message-changed", + workspaceId: wsId, + queuedMessages: ["Please also check this issue"], + displayText: "Please also check this issue", + reviews: [ + { + filePath: "src/api/auth.ts", + lineRange: "42-48", + selectedCode: + "const token = generateToken();\nconst expiry = Date.now() + 3600000;", + userNote: "Consider using a constant for the token expiry duration", + }, + { + filePath: "src/utils/helpers.ts", + lineRange: "15", + selectedCode: "function validate(input) { return input.length > 0; }", + userNote: "This validation could be more robust", + }, + ], + } as WorkspaceChatMessage); + }, + }); + }} + /> + ), + play: async ({ canvasElement }: { canvasElement: HTMLElement }) => { + // Wait for the queued message to appear + const canvas = within(canvasElement); + await waitFor(() => { + canvas.getByText("Queued"); + }); + await waitForChatInputAutofocusDone(canvasElement); + blurActiveElement(); + }, +}; diff --git a/src/browser/stories/storyHelpers.ts b/src/browser/stories/storyHelpers.ts index d965143f71..24904e7c1d 100644 --- a/src/browser/stories/storyHelpers.ts +++ b/src/browser/stories/storyHelpers.ts @@ -157,6 +157,8 @@ export interface SimpleChatSetupOptions { backgroundProcesses?: BackgroundProcessFixture[]; /** Session usage data for Costs tab */ sessionUsage?: MockSessionUsage; + /** Optional custom chat handler for emitting additional events (e.g., queued-message-changed) */ + onChat?: (workspaceId: string, emit: (msg: WorkspaceChatMessage) => void) => void; } /** @@ -191,11 +193,21 @@ export function setupSimpleChatStory(opts: SimpleChatSetupOptions): APIClient { ? new Map([[workspaceId, opts.sessionUsage]]) : undefined; + // Create onChat handler that combines static messages with custom handler + const baseOnChat = createOnChatAdapter(chatHandlers); + const onChat = opts.onChat + ? (wsId: string, emit: (msg: WorkspaceChatMessage) => void) => { + const cleanup = baseOnChat(wsId, emit); + opts.onChat!(wsId, emit); + return cleanup; + } + : baseOnChat; + // Return ORPC client return createMockORPCClient({ projects: groupWorkspacesByProject(workspaces), workspaces, - onChat: createOnChatAdapter(chatHandlers), + onChat, executeBash: createGitStatusExecutor(gitStatus), providersConfig: opts.providersConfig, backgroundProcesses: bgProcesses, diff --git a/src/common/orpc/schemas/stream.ts b/src/common/orpc/schemas/stream.ts index 996e5a302e..8a39a5a4a3 100644 --- a/src/common/orpc/schemas/stream.ts +++ b/src/common/orpc/schemas/stream.ts @@ -238,12 +238,24 @@ export const ChatMuxMessageSchema = MuxMessageSchema.extend({ type: z.literal("message"), }); +// Review data schema for queued message display +export const ReviewNoteDataSchema = z.object({ + filePath: z.string(), + lineRange: z.string(), + selectedCode: z.string(), + selectedDiff: z.string().optional(), + oldStart: z.number().optional(), + newStart: z.number().optional(), + userNote: z.string(), +}); + export const QueuedMessageChangedEventSchema = z.object({ type: z.literal("queued-message-changed"), workspaceId: z.string(), queuedMessages: z.array(z.string()), displayText: z.string(), imageParts: z.array(ImagePartSchema).optional(), + reviews: z.array(ReviewNoteDataSchema).optional(), }); export const RestoreToInputEventSchema = z.object({ diff --git a/src/common/types/message.ts b/src/common/types/message.ts index f8af4dd71c..661676dbce 100644 --- a/src/common/types/message.ts +++ b/src/common/types/message.ts @@ -253,6 +253,8 @@ export interface QueuedMessage { id: string; content: string; imageParts?: ImagePart[]; + /** Structured review data for rich UI display (from muxMetadata) */ + reviews?: ReviewNoteDataForDisplay[]; } // Helper to create a simple text message diff --git a/src/node/services/agentSession.ts b/src/node/services/agentSession.ts index 41a476b9e2..360148de1c 100644 --- a/src/node/services/agentSession.ts +++ b/src/node/services/agentSession.ts @@ -766,6 +766,7 @@ export class AgentSession { queuedMessages: this.messageQueue.getMessages(), displayText: this.messageQueue.getDisplayText(), imageParts: this.messageQueue.getImageParts(), + reviews: this.messageQueue.getReviews(), }); } diff --git a/src/node/services/messageQueue.ts b/src/node/services/messageQueue.ts index 916de4cee7..e2f11a47c0 100644 --- a/src/node/services/messageQueue.ts +++ b/src/node/services/messageQueue.ts @@ -1,4 +1,5 @@ import type { ImagePart, SendMessageOptions } from "@/common/orpc/types"; +import type { ReviewNoteData } from "@/common/types/review"; // Type guard for compaction request metadata (for display text) interface CompactionMetadata { @@ -12,6 +13,17 @@ function isCompactionMetadata(meta: unknown): meta is CompactionMetadata { return obj.type === "compaction-request" && typeof obj.rawCommand === "string"; } +// Type guard for metadata with reviews +interface MetadataWithReviews { + reviews?: ReviewNoteData[]; +} + +function hasReviews(meta: unknown): meta is MetadataWithReviews { + if (typeof meta !== "object" || meta === null) return false; + const obj = meta as Record; + return Array.isArray(obj.reviews); +} + /** * Queue for messages sent during active streaming. * @@ -118,6 +130,16 @@ export class MessageQueue { return [...this.accumulatedImages]; } + /** + * Get reviews from metadata for display. + */ + getReviews(): ReviewNoteData[] | undefined { + if (hasReviews(this.firstMuxMetadata) && this.firstMuxMetadata.reviews?.length) { + return this.firstMuxMetadata.reviews; + } + return undefined; + } + /** * Get combined message and options for sending. */